mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 21:46:40 +02:00
feat(extension): Terminal-only sidebar — auth fix, UX polish, chat rip
The chat queue path is gone. The Chrome side panel is now just an
interactive claude PTY in xterm.js. Activity / Refs / Inspector still
exist behind the `debug` toggle in the footer.
Three threads of change, all from dogfood iteration on top of
cc-pty-import:
1. fix(server): cross-port WS auth via Sec-WebSocket-Protocol
- Browsers can't set Authorization on a WebSocket upgrade. We had
been minting an HttpOnly gstack_pty cookie via /pty-session, but
SameSite=Strict cookies don't survive the cross-port jump from
server.ts:34567 to the agent's random port from a chrome-extension
origin. The WS opened then immediately closed → "Session ended."
- /pty-session now also returns ptySessionToken in the JSON body.
- Extension calls `new WebSocket(url, [`gstack-pty.<token>`])`.
Browser sends Sec-WebSocket-Protocol on the upgrade.
- Agent reads the protocol header, validates against validTokens,
and MUST echo the protocol back (Chromium closes the connection
immediately if a server doesn't pick one of the offered protocols).
- Cookie path is kept as a fallback for non-browser callers (curl,
integration tests).
- New integration test exercises the full protocol-auth round-trip
via raw fetch+Upgrade so a future regression of this exact class
fails in CI.
2. fix(extension): UX polish on the Terminal pane
- Eager auto-connect when the sidebar opens — no "Press any key to
start" friction every reload.
- Always-visible ↻ Restart button in the terminal toolbar (not
gated on the ENDED state) so the user can force a fresh claude
mid-session.
- MutationObserver on #tab-terminal's class attribute drives a
fitAddon.fit() + term.refresh() when the pane becomes visible
again — xterm doesn't auto-redraw after display:none → display:flex.
3. feat(extension): rip the chat tab + sidebar-agent.ts
- Sidebar is Terminal-only. No more Terminal | Chat primary nav.
- sidebar-agent.ts deleted. /sidebar-command, /sidebar-chat,
/sidebar-agent/event, /sidebar-tabs* and friends all deleted.
- The pickSidebarModel router (sonnet vs opus) is gone — the live
PTY uses whatever model the user's `claude` CLI is configured with.
- Quick-actions (🧹 Cleanup / 📸 Screenshot / 🍪 Cookies) survive
in the Terminal toolbar. Cleanup now injects its prompt into the
live PTY via window.gstackInjectToTerminal — no more
/sidebar-command POST. The Inspector "Send to Code" action uses
the same injection path.
- clear-chat button removed from the footer.
- sidepanel.js shed ~900 lines of chat polling, optimistic UI,
stop-agent, etc.
Net diff: -3.4k lines across 16 files. CLAUDE.md, TODOS.md, and
docs/designs/SIDEBAR_MESSAGE_FLOW.md rewritten to match. The sidebar
regression test (browse/test/sidebar-tabs.test.ts) is rewritten as 27
structural assertions locking the new layout — Terminal sole pane,
no chat input, quick-actions in toolbar, eager-connect, MutationObserver
repaint, restart helper.
This commit is contained in:
+119
-56
@@ -38,6 +38,7 @@
|
||||
mount: document.getElementById('terminal-mount'),
|
||||
ended: document.getElementById('terminal-ended'),
|
||||
restart: document.getElementById('terminal-restart'),
|
||||
restartNow: document.getElementById('terminal-restart-now'),
|
||||
};
|
||||
|
||||
/** State machine. */
|
||||
@@ -109,10 +110,12 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* POST /pty-session to mint a fresh terminal session. Returns
|
||||
* { terminalPort, ptySessionToken, expiresAt } on success, or
|
||||
* { error } on failure. The token rides on the WebSocket
|
||||
* Sec-WebSocket-Protocol header, which is the only auth header
|
||||
* the browser WebSocket API lets us set. The token is NOT persisted —
|
||||
* each sidebar load mints a fresh one and discards it on close.
|
||||
*/
|
||||
async function mintSession() {
|
||||
const serverPort = getServerPort();
|
||||
@@ -183,6 +186,22 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a string into the live PTY (the same way a real keystroke would).
|
||||
* Used by the toolbar's Cleanup button and the Inspector's "Send to Code"
|
||||
* action so the user can drive claude from outside-the-keyboard surfaces.
|
||||
* Returns true if the bytes went out, false if no live session.
|
||||
*/
|
||||
window.gstackInjectToTerminal = function (text) {
|
||||
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return false;
|
||||
try {
|
||||
ws.send(new TextEncoder().encode(text));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
async function connect() {
|
||||
if (state !== STATE.IDLE) return; // already connecting/live
|
||||
setState(STATE.CONNECTING);
|
||||
@@ -192,7 +211,11 @@
|
||||
setState(STATE.IDLE, { message: `Cannot start: ${minted.error}` });
|
||||
return;
|
||||
}
|
||||
const { terminalPort } = minted;
|
||||
const { terminalPort, ptySessionToken } = minted;
|
||||
if (!ptySessionToken) {
|
||||
setState(STATE.IDLE, { message: 'Cannot start: no session token returned' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-flight: does claude even exist on PATH?
|
||||
const claudeStatus = await checkClaudeAvailable(terminalPort);
|
||||
@@ -205,7 +228,12 @@
|
||||
setState(STATE.LIVE);
|
||||
fitAddon && fitAddon.fit();
|
||||
|
||||
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`);
|
||||
// Token rides on Sec-WebSocket-Protocol — the only auth header the
|
||||
// browser WebSocket API lets us set. Cross-port HttpOnly cookies with
|
||||
// SameSite=Strict don't survive the jump from server.ts:34567 to the
|
||||
// agent's random port from a chrome-extension origin, so cookies
|
||||
// alone weren't reliable.
|
||||
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${ptySessionToken}`]);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
@@ -256,66 +284,101 @@
|
||||
|
||||
// ─── Wiring ───────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
// First-keystroke trigger on the bootstrap card.
|
||||
document.addEventListener('keydown', onAnyKey, { once: false, capture: true });
|
||||
/**
|
||||
* Force a fresh session: close any open WS, dispose xterm, return to
|
||||
* IDLE, kick off auto-connect. Safe to call from any state.
|
||||
*/
|
||||
function forceRestart() {
|
||||
try { ws && ws.close(); } catch {}
|
||||
ws = null;
|
||||
if (term) {
|
||||
try { term.dispose(); } catch {}
|
||||
term = null;
|
||||
fitAddon = null;
|
||||
}
|
||||
setState(STATE.IDLE, { message: 'Starting Claude Code...' });
|
||||
tryAutoConnect();
|
||||
}
|
||||
|
||||
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) => {
|
||||
/**
|
||||
* Repaint xterm when the Terminal pane becomes visible. xterm.js has a
|
||||
* known issue where its renderer doesn't redraw after a display:none →
|
||||
* display:flex flip — the canvas/DOM stays blank until something forces
|
||||
* a layout pass. fit() recomputes dimensions, refresh() redraws.
|
||||
*/
|
||||
function repaintIfLive() {
|
||||
if (state !== STATE.LIVE || !term) return;
|
||||
try { fitAddon && fitAddon.fit(); } catch {}
|
||||
try { term.refresh(0, term.rows - 1); } catch {}
|
||||
try {
|
||||
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 {}
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function init() {
|
||||
setState(STATE.IDLE, { message: 'Starting Claude Code...' });
|
||||
|
||||
els.installRetry?.addEventListener('click', () => {
|
||||
// Re-probe claude on PATH, then try a connect.
|
||||
setState(STATE.IDLE, { message: 'Starting Claude Code...' });
|
||||
tryAutoConnect();
|
||||
});
|
||||
|
||||
// Initial state
|
||||
setState(STATE.IDLE);
|
||||
// Two restart buttons:
|
||||
// - els.restart lives inside the ENDED state card (visible only after
|
||||
// a session has ended).
|
||||
// - els.restartNow lives in the always-visible toolbar (lets the user
|
||||
// force a fresh claude mid-session without waiting for it to exit).
|
||||
els.restart?.addEventListener('click', forceRestart);
|
||||
els.restartNow?.addEventListener('click', forceRestart);
|
||||
|
||||
|
||||
// Repaint after a debug-tab → primary-pane transition. The debug
|
||||
// tabs (Activity / Refs / Inspector) hide the Terminal pane via
|
||||
// .tab-content { display: none }; xterm doesn't auto-redraw when its
|
||||
// container flips back to visible, so we listen for the close-debug
|
||||
// event and force a fit + refresh.
|
||||
const observer = new MutationObserver(() => {
|
||||
const term = document.getElementById('tab-terminal');
|
||||
if (term?.classList.contains('active')) {
|
||||
requestAnimationFrame(repaintIfLive);
|
||||
}
|
||||
});
|
||||
const target = document.getElementById('tab-terminal');
|
||||
if (target) observer.observe(target, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
tryAutoConnect();
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* Eager-connect when the sidebar opens. Polls for sidepanel.js to populate
|
||||
* window.gstackServerPort + window.gstackAuthToken (which it does as soon
|
||||
* as /health succeeds), then fires connect() automatically. The user
|
||||
* doesn't have to press a key — Terminal is the default tab and "tap to
|
||||
* start" was a needless paper cut on every reload.
|
||||
*/
|
||||
function tryAutoConnect() {
|
||||
if (state !== STATE.IDLE) return;
|
||||
// Ignore pure modifier keys.
|
||||
if (['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(ev.key)) return;
|
||||
connect();
|
||||
let waited = 0;
|
||||
const tick = () => {
|
||||
// If the user navigated away (Chat tab) or already connected, drop out.
|
||||
if (state !== STATE.IDLE) return;
|
||||
if (getServerPort() && getAuthToken()) {
|
||||
connect();
|
||||
return;
|
||||
}
|
||||
waited += 200;
|
||||
if (waited > 15000) {
|
||||
setState(STATE.IDLE, { message: 'Browse server not ready. Reload sidebar to retry.' });
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 200);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
+29
-25
@@ -675,36 +675,40 @@ 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-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #0a0a0a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.terminal-toolbar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.terminal-toolbar-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #27272a;
|
||||
color: #a1a1aa;
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.terminal-toolbar-btn:hover {
|
||||
color: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
.terminal-bootstrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
+15
-80
@@ -25,57 +25,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security event banner — fires on prompt injection detection.
|
||||
Variant A from /plan-design-review 2026-04-19: centered alert-heavy,
|
||||
big red error icon, mono layer scores in expandable details. -->
|
||||
<div class="security-banner" id="security-banner" role="alert" aria-live="assertive" style="display:none">
|
||||
<button class="security-banner-close" id="security-banner-close" aria-label="Dismiss">×</button>
|
||||
<div class="security-banner-icon" aria-hidden="true">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="security-banner-title" id="security-banner-title">Session terminated</div>
|
||||
<div class="security-banner-subtitle" id="security-banner-subtitle">prompt injection detected</div>
|
||||
<button class="security-banner-expand" id="security-banner-expand" aria-expanded="false" aria-controls="security-banner-details">
|
||||
<span>What happened</span>
|
||||
<svg class="security-banner-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="security-banner-details" id="security-banner-details" hidden>
|
||||
<div class="security-banner-section-label">SECURITY LAYERS</div>
|
||||
<div class="security-banner-layers" id="security-banner-layers"></div>
|
||||
<div class="security-banner-section-label" id="security-banner-suspect-label" hidden>SUSPECTED TEXT</div>
|
||||
<pre class="security-banner-suspect" id="security-banner-suspect" hidden></pre>
|
||||
</div>
|
||||
<div class="security-banner-actions" id="security-banner-actions" hidden>
|
||||
<button type="button" class="security-banner-btn security-banner-btn-block" id="security-banner-btn-block">Block session</button>
|
||||
<button type="button" class="security-banner-btn security-banner-btn-allow" id="security-banner-btn-allow">Allow and continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser tab bar -->
|
||||
<div class="browser-tabs" id="browser-tabs" style="display:none"></div>
|
||||
|
||||
<!-- 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) -->
|
||||
<!-- Terminal pane is now the sole primary surface. Activity / Refs /
|
||||
Inspector still exist behind the `debug` toggle in the footer. -->
|
||||
<main id="tab-terminal" class="tab-content active" role="tabpanel" aria-label="Terminal">
|
||||
<!-- Toolbar with browser quick-actions on the left, Restart on the right.
|
||||
Restart is always visible so the user can force a fresh claude any
|
||||
time, not just from the ENDED state. -->
|
||||
<div class="terminal-toolbar" id="terminal-toolbar">
|
||||
<div class="terminal-toolbar-actions">
|
||||
<button id="chat-cleanup-btn" class="terminal-toolbar-btn" title="Remove ads, banners, popups">🧹 Cleanup</button>
|
||||
<button id="chat-screenshot-btn" class="terminal-toolbar-btn" title="Take a screenshot">📸 Screenshot</button>
|
||||
<button id="chat-cookies-btn" class="terminal-toolbar-btn" title="Import cookies from your browser">🍪 Cookies</button>
|
||||
</div>
|
||||
<button class="terminal-toolbar-btn" id="terminal-restart-now" title="Restart Claude Code session">↻ Restart</button>
|
||||
</div>
|
||||
<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 id="terminal-bootstrap-status">Starting Claude Code...</p>
|
||||
<p class="muted" id="terminal-bootstrap-hint">Real PTY. Real terminal. Real claude.</p>
|
||||
<pre id="loading-debug" class="muted" style="font-size:11px; font-family:'JetBrains Mono',monospace; white-space:pre-wrap; margin-top:8px; color:#71717A;"></pre>
|
||||
</div>
|
||||
<div class="terminal-install-card" id="terminal-install-card" style="display:none">
|
||||
<p><strong>Claude Code not found</strong></p>
|
||||
@@ -89,22 +60,6 @@
|
||||
</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>
|
||||
<p id="loading-status">Looking for browse server...</p>
|
||||
<pre id="loading-debug" class="muted" style="font-size:11px; font-family:'JetBrains Mono',monospace; white-space:pre-wrap; margin-top:8px; color:#71717A;"></pre>
|
||||
</div>
|
||||
<div class="chat-welcome" id="chat-welcome" style="display:none">
|
||||
<div class="chat-welcome-icon">G</div>
|
||||
<p>Send a message to Claude Code.</p>
|
||||
<p class="muted">Your agent will see it and act on it.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Debug: Activity Tab (hidden by default) -->
|
||||
<main id="tab-activity" class="tab-content" role="log" aria-live="polite">
|
||||
<div class="empty-state" id="empty-state">
|
||||
@@ -204,30 +159,10 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Experimental chat banner (shown when chatEnabled) -->
|
||||
<div id="experimental-banner" class="experimental-banner" style="display: none;">
|
||||
Browser co-pilot — controls this browser, reports back to your workspace
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Toolbar -->
|
||||
<div class="quick-actions" id="quick-actions">
|
||||
<button id="chat-cleanup-btn" class="quick-action-btn" title="Remove ads, banners, popups">🧹 Cleanup</button>
|
||||
<button id="chat-screenshot-btn" class="quick-action-btn" title="Take a screenshot">📸 Screenshot</button>
|
||||
<button id="chat-cookies-btn" class="quick-action-btn" title="Import cookies from your browser">🍪 Cookies</button>
|
||||
</div>
|
||||
|
||||
<!-- Command Bar -->
|
||||
<div class="command-bar">
|
||||
<button class="stop-btn" id="stop-agent-btn" title="Stop agent" style="display: none;">■</button>
|
||||
<input type="text" class="command-input" id="command-input" placeholder="Ask about this page..." autocomplete="off" spellcheck="false">
|
||||
<button class="send-btn" id="send-btn" title="Send">↑</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer with connection + debug toggle -->
|
||||
<footer>
|
||||
<div class="footer-left">
|
||||
<button class="debug-toggle" id="debug-toggle" title="Toggle debug panels">debug</button>
|
||||
<button class="footer-btn" id="clear-chat" title="Clear chat">clear</button>
|
||||
<button class="footer-btn" id="reload-sidebar" title="Reload sidebar">reload</button>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
|
||||
+62
-981
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user