mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 21:25:27 +02:00
feat(extension): Terminal as default sidebar tab
Adds a primary tab bar (Terminal | Chat) above the existing tab-content
panes. Terminal is the default-active tab; clicking Chat returns to the
existing claude -p one-shot flow which is preserved verbatim.
manifest.json: adds ws://127.0.0.1:*/ to host_permissions so MV3 doesn't
block the WebSocket upgrade.
sidepanel.html: new primary-tabs nav, new #tab-terminal pane with a
"Press any key to start Claude Code" bootstrap card, claude-not-found
install card, xterm mount point, and "session ended" restart UI. Loads
xterm.js + xterm-addon-fit + sidepanel-terminal.js. tab-chat is no
longer the .active default.
sidepanel.js: new activePrimaryPaneId() helper that reads which primary
tab is selected. Debug-close paths now route back to whichever primary
pane is active (was hardcoded to tab-chat). Primary-tab click handler
toggles .active classes and aria-selected. window.gstackServerPort and
window.gstackAuthToken exposed so sidepanel-terminal.js can build the
/pty-session POST and the WS URL.
sidepanel-terminal.js (new): xterm.js lifecycle. Lazy-spawn — first
keystroke fires POST /pty-session, then opens
ws://127.0.0.1:<terminalPort>/ws. Origin + cookie are set automatically
by the browser. Resize observer sends {type:"resize"} text frames.
ResizeObserver, tab-switch hooks, restart button, install-card retry.
On WS close shows "Session ended, click to restart" — no auto-reconnect
(codex outside-voice flagged that as session-burning).
sidepanel.css: primary-tabs bar + Terminal pane styling (full-height
xterm container, install card, ended state).
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Live activity feed and @ref overlays for gstack browse",
|
||||
"permissions": ["sidePanel", "storage", "activeTab", "scripting"],
|
||||
"host_permissions": ["http://127.0.0.1:*/"],
|
||||
"host_permissions": ["http://127.0.0.1:*/", "ws://127.0.0.1:*/"],
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Terminal sidebar tab — interactive Claude Code PTY in xterm.js.
|
||||
*
|
||||
* Lifecycle (per plan + codex review):
|
||||
* 1. Sidebar opens. Terminal is the default-active tab.
|
||||
* 2. Bootstrap card shows "Press any key to start Claude Code."
|
||||
* 3. On first keystroke (lazy spawn — codex finding #8): the extension
|
||||
* a) POSTs /pty-session on the browse server with the AUTH_TOKEN to
|
||||
* mint a short-lived HttpOnly cookie scoped to the terminal-agent.
|
||||
* b) Opens ws://127.0.0.1:<terminalPort>/ws — the cookie travels
|
||||
* automatically. Terminal-agent validates the cookie + the
|
||||
* chrome-extension:// Origin (codex finding #9), then spawns
|
||||
* claude in a PTY.
|
||||
* 4. Bytes pump both ways. Resize observer sends {type:"resize"} text
|
||||
* frames; tab-switch hooks send {type:"tabSwitch"} frames.
|
||||
* 5. PTY exits or WS closes -> we show "Session ended" with a restart
|
||||
* button. We do NOT auto-reconnect (codex finding #8: auto-reconnect
|
||||
* = burn fresh claude session every time).
|
||||
*
|
||||
* Keep this file dependency-free. xterm.js + xterm-addon-fit are loaded
|
||||
* via <script src> tags in sidepanel.html (window.Terminal, window.FitAddon).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const Terminal = window.Terminal;
|
||||
const FitAddonModule = window.FitAddon;
|
||||
if (!Terminal) {
|
||||
console.error('[gstack terminal] xterm not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const els = {
|
||||
bootstrap: document.getElementById('terminal-bootstrap'),
|
||||
bootstrapStatus: document.getElementById('terminal-bootstrap-status'),
|
||||
installCard: document.getElementById('terminal-install-card'),
|
||||
installRetry: document.getElementById('terminal-install-retry'),
|
||||
mount: document.getElementById('terminal-mount'),
|
||||
ended: document.getElementById('terminal-ended'),
|
||||
restart: document.getElementById('terminal-restart'),
|
||||
};
|
||||
|
||||
/** State machine. */
|
||||
const STATE = { IDLE: 'idle', CONNECTING: 'connecting', LIVE: 'live', ENDED: 'ended', NO_CLAUDE: 'no-claude' };
|
||||
let state = STATE.IDLE;
|
||||
|
||||
let term = null;
|
||||
let fitAddon = null;
|
||||
let ws = null;
|
||||
|
||||
function show(el) { el.style.display = ''; }
|
||||
function hide(el) { el.style.display = 'none'; }
|
||||
|
||||
function setState(next, opts = {}) {
|
||||
state = next;
|
||||
switch (next) {
|
||||
case STATE.IDLE:
|
||||
show(els.bootstrap);
|
||||
hide(els.installCard);
|
||||
hide(els.mount);
|
||||
hide(els.ended);
|
||||
els.bootstrapStatus.textContent = opts.message || 'Press any key to start Claude Code.';
|
||||
break;
|
||||
case STATE.CONNECTING:
|
||||
show(els.bootstrap);
|
||||
hide(els.installCard);
|
||||
hide(els.mount);
|
||||
hide(els.ended);
|
||||
els.bootstrapStatus.textContent = 'Connecting...';
|
||||
break;
|
||||
case STATE.LIVE:
|
||||
hide(els.bootstrap);
|
||||
hide(els.installCard);
|
||||
show(els.mount);
|
||||
hide(els.ended);
|
||||
break;
|
||||
case STATE.ENDED:
|
||||
hide(els.bootstrap);
|
||||
hide(els.installCard);
|
||||
hide(els.mount);
|
||||
show(els.ended);
|
||||
break;
|
||||
case STATE.NO_CLAUDE:
|
||||
show(els.bootstrap);
|
||||
show(els.installCard);
|
||||
hide(els.mount);
|
||||
hide(els.ended);
|
||||
els.bootstrapStatus.textContent = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read auth + terminalPort from the server's /health. We don't fetch this
|
||||
* here — sidepanel.js already polls /health for connection state and
|
||||
* exposes the relevant fields on window.gstackHealth (set below in init()).
|
||||
* If terminalPort is missing, the agent isn't ready yet.
|
||||
*/
|
||||
function getHealth() {
|
||||
return window.gstackHealth || {};
|
||||
}
|
||||
|
||||
function getServerPort() {
|
||||
return window.gstackServerPort || null;
|
||||
}
|
||||
|
||||
function getAuthToken() {
|
||||
return window.gstackAuthToken || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /pty-session to mint the HttpOnly cookie. Returns { terminalPort,
|
||||
* expiresAt } on success, or null with reason on failure. Note: we do
|
||||
* NOT receive the cookie value; it lives in the browser's HttpOnly jar
|
||||
* and travels with the next same-origin request automatically.
|
||||
*/
|
||||
async function mintSession() {
|
||||
const serverPort = getServerPort();
|
||||
const token = getAuthToken();
|
||||
if (!serverPort || !token) {
|
||||
return { error: 'browse server not ready' };
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${serverPort}/pty-session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '');
|
||||
return { error: `${resp.status} ${body || resp.statusText}` };
|
||||
}
|
||||
return await resp.json();
|
||||
} catch (err) {
|
||||
return { error: err && err.message ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkClaudeAvailable(terminalPort) {
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${terminalPort}/claude-available`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!resp.ok) return { available: false };
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return { available: false };
|
||||
}
|
||||
}
|
||||
|
||||
function ensureXterm() {
|
||||
if (term) return;
|
||||
term = new Terminal({
|
||||
fontFamily: '"JetBrains Mono", "SF Mono", Menlo, monospace',
|
||||
fontSize: 13,
|
||||
theme: { background: '#0a0a0a', foreground: '#e5e5e5' },
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
allowTransparency: false,
|
||||
convertEol: false,
|
||||
});
|
||||
if (FitAddonModule && FitAddonModule.FitAddon) {
|
||||
fitAddon = new FitAddonModule.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
}
|
||||
term.open(els.mount);
|
||||
fitAddon && fitAddon.fit();
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
try {
|
||||
fitAddon && fitAddon.fit();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
ro.observe(els.mount);
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (state !== STATE.IDLE) return; // already connecting/live
|
||||
setState(STATE.CONNECTING);
|
||||
|
||||
const minted = await mintSession();
|
||||
if (minted.error) {
|
||||
setState(STATE.IDLE, { message: `Cannot start: ${minted.error}` });
|
||||
return;
|
||||
}
|
||||
const { terminalPort } = minted;
|
||||
|
||||
// Pre-flight: does claude even exist on PATH?
|
||||
const claudeStatus = await checkClaudeAvailable(terminalPort);
|
||||
if (!claudeStatus.available) {
|
||||
setState(STATE.NO_CLAUDE);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureXterm();
|
||||
setState(STATE.LIVE);
|
||||
fitAddon && fitAddon.fit();
|
||||
|
||||
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
} catch {}
|
||||
// Send a single byte to nudge the agent to spawn claude (lazy-spawn trigger).
|
||||
try { ws.send(new TextEncoder().encode('\n')); } catch {}
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
if (typeof ev.data === 'string') {
|
||||
// Agent control message (rare). Treat as JSON; error frames carry code.
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'error' && msg.code === 'CLAUDE_NOT_FOUND') {
|
||||
setState(STATE.NO_CLAUDE);
|
||||
try { ws.close(); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
// Binary: feed to xterm.
|
||||
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
|
||||
term.write(buf);
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
ws = null;
|
||||
if (state !== STATE.NO_CLAUDE) setState(STATE.ENDED);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', (err) => {
|
||||
console.error('[gstack terminal] ws error', err);
|
||||
});
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
try { ws && ws.close(); } catch {}
|
||||
ws = null;
|
||||
if (term) {
|
||||
try { term.dispose(); } catch {}
|
||||
term = null;
|
||||
fitAddon = null;
|
||||
}
|
||||
setState(STATE.IDLE);
|
||||
}
|
||||
|
||||
// ─── Wiring ───────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
// First-keystroke trigger on the bootstrap card.
|
||||
document.addEventListener('keydown', onAnyKey, { once: false, capture: true });
|
||||
|
||||
els.installRetry?.addEventListener('click', async () => {
|
||||
// Re-probe and try connecting again.
|
||||
const minted = await mintSession();
|
||||
if (!minted.error) {
|
||||
const claudeStatus = await checkClaudeAvailable(minted.terminalPort);
|
||||
if (claudeStatus.available) {
|
||||
setState(STATE.IDLE);
|
||||
// Auto-trigger reconnect on next key
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
els.restart?.addEventListener('click', () => {
|
||||
// Clean restart. Drop xterm state too — codex 1C: each session is fresh.
|
||||
if (term) {
|
||||
try { term.dispose(); } catch {}
|
||||
term = null;
|
||||
fitAddon = null;
|
||||
}
|
||||
setState(STATE.IDLE);
|
||||
});
|
||||
|
||||
// Tab switching: tell the agent which browser tab is active so claude's
|
||||
// active-tab.json stays in sync. sidepanel.js owns the active-tab state;
|
||||
// we listen for its "tab activated" event.
|
||||
document.addEventListener('gstack:active-tab-changed', (ev) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tabSwitch',
|
||||
tabId: ev.detail?.tabId,
|
||||
url: ev.detail?.url,
|
||||
title: ev.detail?.title,
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// Initial state
|
||||
setState(STATE.IDLE);
|
||||
}
|
||||
|
||||
function onAnyKey(ev) {
|
||||
// Only trigger if Terminal pane is the active one and we're idle.
|
||||
const terminalActive = document.getElementById('tab-terminal')?.classList.contains('active');
|
||||
if (!terminalActive) return;
|
||||
if (state !== STATE.IDLE) return;
|
||||
// Ignore pure modifier keys.
|
||||
if (['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(ev.key)) return;
|
||||
connect();
|
||||
}
|
||||
|
||||
// Wait for sidepanel.js to populate window.gstackServerPort + window.gstackAuthToken.
|
||||
// sidepanel.js already polls /health and resolves the connection; we just need
|
||||
// to wait for it. If those globals aren't available within 10s, surface a
|
||||
// "browse server not ready" message — user can reload sidebar.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -675,6 +675,94 @@ body::after {
|
||||
}
|
||||
.tab-content.active { display: flex; flex-direction: column; }
|
||||
|
||||
/* ─── Primary surface tabs (Terminal | Chat) ──────────────────── */
|
||||
.primary-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #0f0f0f;
|
||||
padding: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.primary-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #71717a;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.primary-tab:hover { color: #e5e5e5; }
|
||||
.primary-tab.active {
|
||||
color: #e5e5e5;
|
||||
border-bottom-color: #f59e0b;
|
||||
}
|
||||
|
||||
/* ─── Terminal Tab ────────────────────────────────────────────── */
|
||||
#tab-terminal {
|
||||
background: #0a0a0a;
|
||||
padding: 0;
|
||||
}
|
||||
.terminal-bootstrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: #71717a;
|
||||
padding: 24px;
|
||||
}
|
||||
.terminal-bootstrap-icon {
|
||||
font-size: 32px;
|
||||
color: #f59e0b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.terminal-bootstrap p { margin: 4px 0; }
|
||||
.terminal-install-card {
|
||||
margin: 24px;
|
||||
padding: 16px;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
.terminal-install-card a { color: #f59e0b; }
|
||||
.install-retry-btn {
|
||||
margin-top: 12px;
|
||||
padding: 6px 14px;
|
||||
background: #f59e0b;
|
||||
color: #0a0a0a;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.install-retry-btn:hover { opacity: 0.9; }
|
||||
.terminal-mount {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background: #0a0a0a;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.terminal-mount .xterm,
|
||||
.terminal-mount .xterm .xterm-viewport,
|
||||
.terminal-mount .xterm .xterm-screen {
|
||||
height: 100% !important;
|
||||
}
|
||||
.terminal-ended {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #71717a;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ─── Activity Feed ───────────────────────────────────── */
|
||||
#activity-feed { flex: 1; }
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="sidepanel.css">
|
||||
<link rel="stylesheet" href="lib/xterm.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Security shield — reflects ~/.gstack/security/session-state.json status.
|
||||
@@ -59,8 +60,37 @@
|
||||
<!-- Browser tab bar -->
|
||||
<div class="browser-tabs" id="browser-tabs" style="display:none"></div>
|
||||
|
||||
<!-- Chat Tab (default, full height) -->
|
||||
<main id="tab-chat" class="tab-content active">
|
||||
<!-- Primary surface tabs: Terminal (default) | Chat. Activity / Refs /
|
||||
Inspector still exist as a separate debug-tabs strip below. The
|
||||
Terminal tab is default-active per /plan-eng-review Issue 1B
|
||||
(subsequently informed by codex's spawn-waste finding: PTY only
|
||||
spawns when the user types, so default-active is cheap). -->
|
||||
<nav class="primary-tabs" id="primary-tabs" role="tablist">
|
||||
<button class="primary-tab active" role="tab" data-pane="terminal" aria-selected="true">Terminal</button>
|
||||
<button class="primary-tab" role="tab" data-pane="chat" aria-selected="false">Chat</button>
|
||||
</nav>
|
||||
|
||||
<!-- Terminal Tab (default-active) -->
|
||||
<main id="tab-terminal" class="tab-content active" role="tabpanel" aria-label="Terminal">
|
||||
<div class="terminal-bootstrap" id="terminal-bootstrap">
|
||||
<div class="terminal-bootstrap-icon">▸</div>
|
||||
<p id="terminal-bootstrap-status">Press any key to start Claude Code.</p>
|
||||
<p class="muted" id="terminal-bootstrap-hint">Real PTY. Real terminal. Real claude.</p>
|
||||
</div>
|
||||
<div class="terminal-install-card" id="terminal-install-card" style="display:none">
|
||||
<p><strong>Claude Code not found</strong></p>
|
||||
<p class="muted">Install: <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank">docs.anthropic.com/en/docs/claude-code</a></p>
|
||||
<button class="install-retry-btn" id="terminal-install-retry">I installed it — try again</button>
|
||||
</div>
|
||||
<div class="terminal-mount" id="terminal-mount" style="display:none"></div>
|
||||
<div class="terminal-ended" id="terminal-ended" style="display:none">
|
||||
<p>Session ended.</p>
|
||||
<button class="install-retry-btn" id="terminal-restart">Start a new session</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Chat Tab (the existing claude -p one-shot chat path; preserved verbatim) -->
|
||||
<main id="tab-chat" class="tab-content" role="tabpanel" aria-label="Chat">
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="chat-loading" id="chat-loading">
|
||||
<div class="chat-loading-spinner"></div>
|
||||
@@ -215,6 +245,9 @@
|
||||
<button class="tab close-debug" id="close-debug" title="Close debug">×</button>
|
||||
</nav>
|
||||
|
||||
<script src="lib/xterm.js"></script>
|
||||
<script src="lib/xterm-addon-fit.js"></script>
|
||||
<script src="sidepanel.js"></script>
|
||||
<script src="sidepanel-terminal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+45
-3
@@ -914,14 +914,22 @@ const debugTabs = document.getElementById('debug-tabs');
|
||||
const closeDebug = document.getElementById('close-debug');
|
||||
let debugOpen = false;
|
||||
|
||||
// Resolve the primary surface tab the user expects to see when debug is
|
||||
// closed. Default-active is Terminal per /plan-eng-review Issue 1B.
|
||||
function activePrimaryPaneId() {
|
||||
const sel = document.querySelector('.primary-tab.active');
|
||||
const pane = sel?.dataset.pane;
|
||||
return pane ? `tab-${pane}` : 'tab-terminal';
|
||||
}
|
||||
|
||||
debugToggle.addEventListener('click', () => {
|
||||
debugOpen = !debugOpen;
|
||||
debugToggle.classList.toggle('active', debugOpen);
|
||||
debugTabs.style.display = debugOpen ? 'flex' : 'none';
|
||||
if (!debugOpen) {
|
||||
// Close debug panels, show chat
|
||||
// Close debug panels, restore the active primary surface (Terminal or Chat).
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
document.getElementById('tab-chat').classList.add('active');
|
||||
document.getElementById(activePrimaryPaneId()).classList.add('active');
|
||||
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
|
||||
}
|
||||
});
|
||||
@@ -931,7 +939,7 @@ closeDebug.addEventListener('click', () => {
|
||||
debugToggle.classList.remove('active');
|
||||
debugTabs.style.display = 'none';
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
document.getElementById('tab-chat').classList.add('active');
|
||||
document.getElementById(activePrimaryPaneId()).classList.add('active');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => {
|
||||
@@ -945,6 +953,30 @@ document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => {
|
||||
});
|
||||
});
|
||||
|
||||
// Primary-tab switching: Terminal | Chat. Pure show/hide. Both panes keep
|
||||
// their state when hidden — switching to Chat doesn't kill the PTY, and
|
||||
// switching back to Terminal doesn't reset the chat scroll position.
|
||||
document.querySelectorAll('.primary-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.primary-tab').forEach(b => {
|
||||
b.classList.remove('active');
|
||||
b.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-selected', 'true');
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
const pane = btn.dataset.pane;
|
||||
document.getElementById(`tab-${pane}`).classList.add('active');
|
||||
// Close debug when switching primary tabs (debug is opt-in).
|
||||
if (debugOpen) {
|
||||
debugOpen = false;
|
||||
debugToggle.classList.remove('active');
|
||||
debugTabs.style.display = 'none';
|
||||
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Activity Feed ──────────────────────────────────────────────
|
||||
|
||||
function getEntryClass(entry) {
|
||||
@@ -1660,6 +1692,16 @@ function updateConnection(url, token) {
|
||||
const wasConnected = !!serverUrl;
|
||||
serverUrl = url;
|
||||
serverToken = token || null;
|
||||
// Expose for sidepanel-terminal.js (PTY surface). The terminal pane needs
|
||||
// the bootstrap token to POST /pty-session and the port to derive the WS
|
||||
// URL. We never expose the PTY token — it lives in an HttpOnly cookie.
|
||||
if (url) {
|
||||
try { window.gstackServerPort = parseInt(new URL(url).port, 10); } catch {}
|
||||
window.gstackAuthToken = token || null;
|
||||
} else {
|
||||
window.gstackServerPort = null;
|
||||
window.gstackAuthToken = null;
|
||||
}
|
||||
if (url) {
|
||||
document.getElementById('footer-dot').className = 'dot connected';
|
||||
const port = new URL(url).port;
|
||||
|
||||
Reference in New Issue
Block a user