diff --git a/gently/__init__.py b/gently/__init__.py index 29b0b3b4..25c8c925 100644 --- a/gently/__init__.py +++ b/gently/__init__.py @@ -81,7 +81,7 @@ except ImportError: _VISUALIZATION_AVAILABLE = False -__version__ = "0.22.0.dev0" +__version__ = "0.22.0" __all__ = [ # Main entry point "Gently", diff --git a/gently/harness/bridge.py b/gently/harness/bridge.py index ddbfa9ca..ae244987 100644 --- a/gently/harness/bridge.py +++ b/gently/harness/bridge.py @@ -663,6 +663,13 @@ async def stream_response( pending_choice_result = None try: + # Open the stream envelope immediately — before the (potentially + # slow) context build + Claude API call with extended thinking — so + # every client shows the "Working…" trust signal the instant the + # turn starts, not only once the first token arrives. Pairs with the + # stream_end emitted on StopAsyncIteration below. + await send_fn({"type": "stream_start"}) + while True: try: if pending_choice_result is not None: diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index fdf2fc5f..3f69006c 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -161,6 +161,7 @@ def _record_display(msg): "role": "user", "text": msg.get("text", ""), "author": msg.get("author"), + "author_id": msg.get("author_id"), } ) elif t == "autonomous_start": @@ -338,16 +339,20 @@ async def agent_websocket(websocket: WebSocket): can_control = role in CONTROL_ROLES # Assign a stable id for control arbitration. The label shown to other - # clients is the username when authenticated, else a generic window id. + # clients is the username when authenticated, else "Anonymous". The UI + # renders "You" for the viewer's own messages by matching client_id, so + # anonymous participants don't need disambiguating numbers. _client_counter["n"] += 1 client_id = f"agent_client_{_client_counter['n']}" - client_label = username or f"window {_client_counter['n']}" + client_label = username or "Anonymous" - # Send connection metadata (version, tokens, embryo count, commands) + # Send connection metadata (version, tokens, embryo count, commands). + # you_id lets the client label its own messages "You". meta = bridge.get_connect_metadata() _connected_msg = { "type": "connected", **meta, + "you_id": client_id, "timestamp": datetime.now().isoformat(), } await websocket.send_json(_connected_msg) @@ -840,8 +845,17 @@ async def _run_resolution_bootstrap(): active_task.cancel() # Echo the user's message to ALL clients (so observers see - # what was asked), then stream the reply to everyone. - await _broadcast({"type": "user_message", "text": text, "author": client_label}) + # what was asked), then stream the reply to everyone. author + # is the display name (username or "Anonymous"); author_id + # lets each client render its own messages as "You". + await _broadcast( + { + "type": "user_message", + "text": text, + "author": client_label, + "author_id": client_id, + } + ) active_task = asyncio.create_task( bridge.stream_response(text, _broadcast, choice_future_factory) ) @@ -865,6 +879,10 @@ async def _run_resolution_bootstrap(): if active_task and not active_task.done(): active_task.cancel() active_task = None + # A cancelled stream emits no stream_end of its own, so + # tell every client the turn is over — otherwise their + # "Working…" indicator spins forever after Stop. + await _broadcast({"type": "stream_end"}) elif msg_type == "command": command = data.get("command", "").strip() @@ -975,6 +993,15 @@ async def _run_resolution_bootstrap(): wizard_task.cancel() if active_task and not active_task.done(): active_task.cancel() + # This connection was mid-stream — persist whatever the agent + # generated so far, otherwise a reload loses the in-progress + # reply (it's only committed on stream_end). Guarded to the + # owning connection so an observer's disconnect can't split a + # still-streaming reply into two history entries. + try: + _flush_agent_buf() + except Exception: + logger.debug("Could not flush agent buffer on disconnect", exc_info=True) if bootstrap_task is not None and not bootstrap_task.done(): bootstrap_task.cancel() # Release control arbitration for this client; hand the wheel diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py index c16c548d..6107689c 100644 --- a/gently/ui/web/server.py +++ b/gently/ui/web/server.py @@ -784,6 +784,12 @@ async def on_start(self): import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Match uvicorn's own bind (it sets SO_REUSEADDR): without this, the + # pre-flight check is stricter than the real server and falsely reports + # "port in use" for ~60s after a clean shutdown, while sockets from + # closed browser WebSockets linger in TIME_WAIT. uvicorn would rebind + # fine — so this check must too, and only trip on a live LISTEN holder. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.bind((self.host, self.port)) except OSError: diff --git a/gently/ui/web/static/css/agent-chat.css b/gently/ui/web/static/css/agent-chat.css index fdaaa8e3..44964d96 100644 --- a/gently/ui/web/static/css/agent-chat.css +++ b/gently/ui/web/static/css/agent-chat.css @@ -1,40 +1,55 @@ /* Floating agent-chat window — the web-side control surface. Restrained, professional styling for a lab instrument. */ -/* ── Header toggle (replaces the floating FAB) ─────────────── */ -.header-agent-toggle { - display: inline-flex; align-items: center; gap: 7px; - padding: 5px 10px; border-radius: 8px; - border: 1px solid var(--border); - background: var(--bg-hover); color: var(--text); - font: 500 12.5px/1 'Inter Tight', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - cursor: pointer; position: relative; - transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; -} -.header-agent-toggle:hover { border-color: var(--accent); } -.header-agent-toggle[aria-pressed="true"] { - border-color: var(--accent); color: var(--accent); - background: rgba(96, 165, 250, 0.12); -} -.header-agent-toggle svg { display: block; } -.header-agent-label { letter-spacing: 0.01em; } -.header-agent-dot { - width: 7px; height: 7px; border-radius: 50%; - background: var(--text-muted); flex: 0 0 auto; +/* ── Collapsed rail (always-present agent affordance) ────────── + When the docked panel is collapsed it shrinks to this thin vertical rail: + a spark glyph (click to open), a vertical "Agent" label, plus the connection + dot + unseen-activity badge so a tucked-away agent can still signal it woke + or has something pending. Shown only via the body.chat-docked rules below. */ +.agent-rail { + display: none; /* shown only when collapsed (see :not(.open) rule) */ + flex-direction: column; align-items: center; + width: 100%; height: 100%; padding: 8px 0 0; + border: none; background: transparent; + cursor: pointer; } -.header-agent-dot.ok { background: var(--accent-green); } -.header-agent-badge { - position: absolute; top: -6px; right: -6px; +/* Icon-only activity-bar button: contained rounded hover target (VS Code/Google + style), with the connection state + activity count attached to the icon. */ +.agent-rail-icon { + position: relative; + display: flex; align-items: center; justify-content: center; + width: 40px; height: 40px; border-radius: 10px; + color: var(--text-muted); + transition: background 0.15s ease, color 0.15s ease; +} +.agent-rail:hover .agent-rail-icon { background: var(--bg-hover); color: var(--text); } +.agent-rail-spark { display: flex; } +/* Presence dot in the icon's corner (avatar-with-status pattern). */ +.agent-rail-dot { + position: absolute; right: 5px; bottom: 5px; + width: 8px; height: 8px; border-radius: 50%; + background: var(--text-muted); + border: 2px solid var(--bg-card); box-sizing: content-box; +} +.agent-rail-dot.ok { background: var(--accent-green); } +/* Unseen-activity count badge in the icon's top-right corner. */ +.agent-rail-badge { + position: absolute; top: -4px; right: -4px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--accent-purple); color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; + border: 2px solid var(--bg-card); box-sizing: content-box; } -.header-agent-badge.hidden { display: none; } +.agent-rail-badge.hidden { display: none; } /* ── Docked agent panel ──────────────────────────────────── - Default = overlay slide-over, absolutely positioned inside .app-shell (which - sits below the global header/navbar). Pin (body.chat-docked) turns it into a - real column that pushes .app-main. */ + The panel is always docked (body.chat-docked, set on load): a real column + that pushes .app-main, never a float over content. Collapsing it (header + Agent toggle / Ctrl+J / ×) drops it to width 0 to reclaim space. + + The base .agent-chat rules below (absolute, translateX slide) are the + pre-JS / no-chat-docked fallback so the panel stays parked off-screen until + restorePrefs() docks it; body.chat-docked overrides them into the column. */ .agent-chat { position: absolute; top: 0; right: 0; bottom: 0; @@ -54,7 +69,7 @@ } .agent-chat.open { transform: translateX(0); } -/* Pinned: a real pushing column — no float shadow, just a seam. */ +/* Docked: a real pushing column — no float shadow, just a seam. */ body.chat-docked .agent-chat { position: relative; transform: none; @@ -64,9 +79,12 @@ body.chat-docked .agent-chat { transition: none; z-index: auto; } +/* Collapsed: shrink to the rail. Hide every child, then re-show only the rail. */ body.chat-docked .agent-chat:not(.open) { - width: 0; border-left: none; overflow: hidden; + width: 48px; flex: 0 0 48px; overflow: hidden; } +body.chat-docked .agent-chat:not(.open) > * { display: none; } +body.chat-docked .agent-chat:not(.open) > .agent-rail { display: flex; } @media (prefers-reduced-motion: reduce) { .agent-chat { transition: opacity 0.12s ease; } @@ -85,15 +103,6 @@ body.chat-docked .agent-chat:not(.open) { .agent-control-banner.hidden { display: none; } -/* Pin button in the panel header. */ -.agent-chat-pin { - background: none; border: none; color: var(--text-muted); - cursor: pointer; padding: 2px; display: flex; align-items: center; - border-radius: 5px; -} -.agent-chat-pin:hover { color: var(--text); background: var(--bg-hover); } -.agent-chat-pin[aria-pressed="true"] { color: var(--accent); } - /* ── Header ─────────────────────────────────────────────── */ .agent-chat-header { display: flex; @@ -132,10 +141,12 @@ body.chat-docked .agent-chat:not(.open) { .agent-chat-conn.ac-conn-bad { color: var(--accent-orange, #fb923c); border-color: rgba(251, 146, 60, 0.35); } .agent-chat-close { background: none; border: none; - color: var(--text-muted); font-size: 20px; line-height: 1; - cursor: pointer; padding: 0 2px; + color: var(--text-muted); line-height: 1; + cursor: pointer; padding: 2px; border-radius: 5px; + display: inline-flex; align-items: center; } -.agent-chat-close:hover { color: var(--text); } +.agent-chat-close svg { display: block; } +.agent-chat-close:hover { color: var(--text); background: var(--bg-hover); } /* ── Control banner ─────────────────────────────────────── */ .agent-control-banner { @@ -172,6 +183,8 @@ body.chat-docked .agent-chat:not(.open) { color: var(--accent-purple); margin-bottom: 4px; } +/* Sender name on user bubbles: muted so it doesn't compete with the message. */ +.ac-role-user { color: var(--text-muted); } .ac-turn-agent .ac-content { color: var(--text); } .ac-turn-agent .ac-content code { font-family: 'JetBrains Mono', ui-monospace, monospace; @@ -180,7 +193,9 @@ body.chat-docked .agent-chat:not(.open) { padding: 1px 5px; border-radius: 4px; } -/* User: right-aligned, subtle accent block (not a loud bubble) */ +/* User: right-aligned, subtle accent block (not a loud bubble). Your own + messages use the blue accent; other participants get a neutral bubble so a + shared chat is easy to read at a glance. */ .ac-turn-user { align-items: flex-end; } .ac-turn-user .ac-content { background: rgba(96, 165, 250, 0.12); @@ -191,6 +206,10 @@ body.chat-docked .agent-chat:not(.open) { max-width: 88%; white-space: pre-wrap; word-wrap: break-word; } +.ac-turn-user.ac-from-other .ac-content { + background: rgba(127, 127, 127, 0.12); + border-color: rgba(127, 127, 127, 0.24); +} /* ── Autonomous (wake) turns ────────────────────────────── */ .ac-autonomous-banner { @@ -391,28 +410,23 @@ body.chat-docked .agent-chat:not(.open) { .agent-chat-input textarea::placeholder { color: var(--text-muted); } .agent-chat-input textarea:focus { outline: none; border-color: var(--accent); } .agent-chat-input textarea:disabled { opacity: 0.55; } +/* Circular icon button (ChatGPT/Claude style): an up-arrow to send, which + morphs into a stop square (.is-stop) while a cancellable turn is running. */ .agent-chat-send { flex: 0 0 auto; align-self: flex-end; - padding: 9px 16px; border-radius: 9px; + width: 36px; height: 36px; padding: 0; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; border: none; background: var(--accent); color: #fff; - font-weight: 600; font-size: 13px; cursor: pointer; - transition: background 0.12s ease; + cursor: pointer; transition: background 0.12s ease, opacity 0.12s ease; } .agent-chat-send:hover:not(:disabled) { background: var(--accent-hover); } -.agent-chat-send:disabled { opacity: 0.5; cursor: default; } -/* Send now queues while busy (it no longer doubles as Stop), so just dim it. */ -.agent-chat-send.ac-busy { opacity: 0.6; } - -/* Explicit Stop (separate from Send), shown only during a cancellable turn. */ -.ac-stop { - flex: 0 0 auto; align-self: flex-end; - padding: 9px 12px; border-radius: 9px; - border: 1px solid var(--color-danger, #f87171); - background: transparent; color: var(--color-danger, #f87171); - font-weight: 600; font-size: 13px; cursor: pointer; -} -.ac-stop:hover { background: rgba(248, 113, 113, 0.12); } -.ac-stop.hidden { display: none; } +.agent-chat-send:disabled { opacity: 0.4; cursor: default; } +.agent-chat-send svg { display: block; } +/* Toggle which glyph shows; same filled circle in both states (the icon is the + signal), matching how Claude/ChatGPT morph the composer button. */ +.agent-chat-send .ac-icon-stop { display: none; } +.agent-chat-send.is-stop .ac-icon-send { display: none; } +.agent-chat-send.is-stop .ac-icon-stop { display: block; } /* ── Queued-message panel (type-while-busy) ─────────────── */ .ac-queue { diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 63339b92..d9646a88 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -21,6 +21,7 @@ const AgentChat = (() => { let currentAgentEl = null; // the agent content element being streamed into let activityEl = null; // the persistent "working…" indicator (reused) let me = null; // { authenticated, username, role, can_control } + let myConnId = null; // this connection's id, for labelling own msgs "You" // Autocomplete: slash-command + @tool registries (pushed by the server on // connect) and the live dropdown state. @@ -33,11 +34,10 @@ const AgentChat = (() => { let busySource = null; // 'user' | 'wake' while busy let msgQueue = []; // messages typed while busy, sent on idle let queuePanel = null; // the "⏳ Queued (N)" panel element - let stopBtn = null; // explicit Stop button (separate from Send) // DOM refs (resolved in init) let panel, log, input, sendBtn, conn, banner, closeBtn, userEl, signoutBtn; - let toggleBtn, pinBtn, resizeEl, toggleDot, toggleBadge; // docked-panel chrome + let railBtn, resizeEl, toggleDot, toggleBadge; // docked-panel chrome let pendingSlot = null; // sticky slot for ASK approval proposals let acComplete = null; // the autocomplete dropdown element @@ -109,13 +109,10 @@ const AgentChat = (() => { function addTurn(role) { const wrap = document.createElement('div'); wrap.className = `ac-turn ac-turn-${role}`; + // No per-turn role label: the agent's replies are plain text and the + // user's sit in a bubble (modern chat convention). Autonomous (wake) + // turns are still marked by the banner + accent rail, not a label. if (role === 'agent' && autonomousTurn) wrap.classList.add('ac-turn-autonomous'); - if (role === 'agent') { - const label = document.createElement('div'); - label.className = 'ac-role'; - label.textContent = autonomousTurn ? 'Gently · autonomous' : 'Gently'; - wrap.appendChild(label); - } const content = document.createElement('div'); content.className = 'ac-content'; wrap.appendChild(content); @@ -124,15 +121,30 @@ const AgentChat = (() => { return content; } - function addUserMessage(text, author) { + /** Normalize an author for display: clean up legacy/anonymous labels. */ + function displayAuthor(author) { + if (!author) return 'Anonymous'; + // Legacy/per-connection labels ("window 3", "User 5") read as anonymous. + if (/^(window|user)\s+\d+$/i.test(author)) return 'Anonymous'; + return author; + } + + function addUserMessage(text, author, authorId) { const wrap = document.createElement('div'); wrap.className = 'ac-turn ac-turn-user'; - if (author) { - const label = document.createElement('div'); - label.className = 'ac-role ac-role-user'; - label.textContent = author; - wrap.appendChild(label); - } + // Single shared chat, so every user message is labelled. It's "You" when + // it's from this connection (authorId match) or — once logged in — from + // your username (stable across reloads); otherwise the sender's name, or + // "Anonymous" for an unsigned-in participant. A local echo (no author + // info) is always you. + const mine = (!author && !authorId) + || (authorId && authorId === myConnId) + || (author && me && me.username && author === me.username); + const label = document.createElement('div'); + label.className = 'ac-role ac-role-user'; + label.textContent = mine ? 'You' : displayAuthor(author); + wrap.appendChild(label); + if (!mine) wrap.classList.add('ac-from-other'); const content = document.createElement('div'); content.className = 'ac-content'; content.textContent = text; @@ -149,7 +161,7 @@ const AgentChat = (() => { stickBottom = true; newCount = 0; // a full rebuild jumps to latest (items || []).forEach(it => { if (it.role === 'user') { - addUserMessage(it.text, it.author); + addUserMessage(it.text, it.author, it.author_id); } else if (it.role === 'agent') { const c = addTurn('agent'); c._raw = it.text || ''; @@ -199,6 +211,7 @@ const AgentChat = (() => { switch (msg.type) { case 'connected': reconnectDelay = 1000; + myConnId = msg.you_id || myConnId; // for labelling own messages "You" setConn(true, msg.version ? `Connected · v${msg.version}` : 'Connected'); // The bridge ships the command + tool registries on connect. // Capture them so the composer can offer autocomplete — the @@ -219,7 +232,7 @@ const AgentChat = (() => { case 'user_message': hideActivity(); - addUserMessage(msg.text, msg.author); + addUserMessage(msg.text, msg.author, msg.author_id); break; case 'stream_start': @@ -536,13 +549,11 @@ const AgentChat = (() => { banner.classList.add('hidden'); banner.innerHTML = ''; input.disabled = false; - sendBtn.disabled = false; input.placeholder = 'Message Gently… ( / commands · @ tools )'; } else { banner.classList.remove('hidden'); const who = holderLabel || 'another session'; input.disabled = true; - sendBtn.disabled = true; if (me && me.accounts && !me.authenticated) { // Anonymous — viewing is open; sign in to control. banner.innerHTML = `Viewing — sign in to control.`; @@ -566,25 +577,50 @@ const AgentChat = (() => { input.placeholder = 'Viewing only — take control to drive…'; } } + renderComposerButton(); // enable/disable + send/stop mode follow control } function setBusy(busy, source) { agentBusy = !!busy; busySource = agentBusy ? (source || 'user') : null; - // Send no longer doubles as Stop — it queues while busy. A separate Stop - // (shown only for a cancellable user turn) aborts the current turn. - if (stopBtn) stopBtn.classList.toggle('hidden', !(agentBusy && busySource === 'user')); - sendBtn.classList.toggle('ac-busy', agentBusy); + renderComposerButton(); // morph send <-> stop if (agentBusy) { input.placeholder = (busySource === 'wake') ? 'Gently is acting autonomously — your message will queue' : 'Gently is working — your message will queue'; } else { + // Turn ended (completed, errored, or cancelled) — clear the working + // indicator. Cancel emits no stream_end of its own, so without this + // the "Working…" dots would spin forever after Stop. + hideActivity(); if (hasControl) input.placeholder = 'Message Gently… ( / commands · @ tools )'; drainQueue(); // a turn just ended — send the next queued message } } + /** Set the composer button to send (up-arrow) or stop (square) mode. */ + function renderComposerButton() { + if (!sendBtn) return; + const stopMode = agentBusy && busySource === 'user'; + sendBtn.classList.toggle('is-stop', stopMode); + if (stopMode) { + sendBtn.disabled = false; + sendBtn.setAttribute('aria-label', 'Stop'); + sendBtn.title = 'Stop the current turn'; + } else { + // Send is enabled only with control and some text to send. + sendBtn.disabled = !hasControl || input.value.trim() === ''; + sendBtn.setAttribute('aria-label', 'Send message'); + sendBtn.title = 'Send'; + } + } + + /** Abort the current cancellable turn and clear local busy/indicator state. */ + function cancelTurn() { + send({ type: 'cancel' }); + setBusy(false); + } + // ── Message queue (type-while-busy) ─────────────────────── function enqueue(text) { msgQueue.push(text); renderQueue(); } function removeQueued(i) { @@ -701,10 +737,9 @@ const AgentChat = (() => { function togglePanel(open) { panelOpen = (open === undefined) ? !panelOpen : open; panel.classList.toggle('open', panelOpen); - if (toggleBtn) { - toggleBtn.setAttribute('aria-pressed', panelOpen ? 'true' : 'false'); - toggleBtn.setAttribute('aria-expanded', panelOpen ? 'true' : 'false'); - } + // Remember collapse state so a reload restores it (defaults to open). + try { localStorage.setItem('gently-chat-open', panelOpen ? '1' : '0'); } catch (_) {} + if (railBtn) railBtn.setAttribute('aria-expanded', panelOpen ? 'true' : 'false'); if (panelOpen) { clearBadge(); if (!ws) connect(); @@ -740,24 +775,6 @@ const AgentChat = (() => { return w; } - function applyDock(docked, persist) { - document.body.classList.toggle('chat-docked', docked); - if (pinBtn) { - pinBtn.setAttribute('aria-pressed', docked ? 'true' : 'false'); - pinBtn.title = docked ? 'Unpin (float over content)' : 'Pin to dock'; - } - if (persist) { try { localStorage.setItem('gently-chat-docked', docked ? '1' : '0'); } catch (_) {} } - // Suppress the slide animation across the mode flip, then notify viewers. - panel.style.transition = 'none'; - requestAnimationFrame(() => { panel.style.transition = ''; emitLayoutChanged(); }); - } - - function togglePin() { - const docked = !document.body.classList.contains('chat-docked'); - if (docked && !panelOpen) togglePanel(true); // pinning implies showing - applyDock(docked, true); - } - function setupResize() { if (!resizeEl) return; let startX = 0, startW = 0, dragging = false, rafId = 0, pid = null; @@ -806,8 +823,14 @@ const AgentChat = (() => { try { const w = parseInt(localStorage.getItem('gently-chat-w')); if (w) setChatWidth(w, false); - if (localStorage.getItem('gently-chat-docked') === '1') applyDock(true, false); } catch (_) {} + // The agent panel is always docked — a real column that pushes content, + // not a float over it. It's open by default; the header Agent toggle / + // Ctrl+J / × collapse it to width 0 to reclaim space for the viewer. + document.body.classList.add('chat-docked'); + let open = true; + try { open = localStorage.getItem('gently-chat-open') !== '0'; } catch (_) {} + togglePanel(open); } // Unseen-activity badge on the header toggle — so a closed panel still tells @@ -863,17 +886,16 @@ const AgentChat = (() => { closeBtn = document.getElementById('agent-chat-close'); userEl = document.getElementById('agent-chat-user'); signoutBtn = document.getElementById('agent-chat-signout'); - toggleBtn = document.getElementById('agent-chat-toggle'); - pinBtn = document.getElementById('agent-chat-pin'); + railBtn = document.getElementById('agent-rail-toggle'); // collapsed-rail spark resizeEl = document.getElementById('agent-chat-resize'); - toggleDot = document.getElementById('agent-chat-toggle-dot'); - toggleBadge = document.getElementById('agent-chat-toggle-badge'); + // Connection dot + unseen-activity badge now live on the collapsed rail. + toggleDot = document.getElementById('agent-rail-dot'); + toggleBadge = document.getElementById('agent-rail-badge'); if (!panel) return; // markup not present restorePrefs(); - if (toggleBtn) toggleBtn.addEventListener('click', () => togglePanel()); + if (railBtn) railBtn.addEventListener('click', () => togglePanel(true)); closeBtn.addEventListener('click', () => togglePanel(false)); - if (pinBtn) pinBtn.addEventListener('click', togglePin); setupResize(); // Ctrl/Cmd+J toggles the panel from anywhere. document.addEventListener('keydown', (e) => { @@ -907,13 +929,8 @@ const AgentChat = (() => { queuePanel.className = 'ac-queue hidden'; if (inputWrap.parentNode) inputWrap.parentNode.insertBefore(queuePanel, inputWrap); - // Explicit Stop button — shown only during a cancellable user turn. - stopBtn = document.createElement('button'); - stopBtn.className = 'ac-stop hidden'; - stopBtn.textContent = 'Stop'; - stopBtn.title = 'Stop the current turn'; - stopBtn.addEventListener('click', () => { send({ type: 'cancel' }); setBusy(false); }); - inputWrap.appendChild(stopBtn); + // (Stop is no longer a separate button — the composer send button + // morphs into a stop square while a cancellable turn runs.) // Sticky ASK-approval slot — above the queue + composer, never scrolls away. pendingSlot = document.createElement('div'); @@ -932,8 +949,12 @@ const AgentChat = (() => { renderJumpPill(); }); - sendBtn.addEventListener('click', submit); - input.addEventListener('input', () => { autosize(); updateCompletions(); }); + // One button, two roles: stop a running cancellable turn, else send. + sendBtn.addEventListener('click', () => { + if (agentBusy && busySource === 'user') cancelTurn(); + else submit(); + }); + input.addEventListener('input', () => { autosize(); updateCompletions(); renderComposerButton(); }); // Close the menu shortly after blur (delay lets a mousedown selection land). input.addEventListener('blur', () => setTimeout(hideCompletions, 120)); input.addEventListener('keydown', (e) => { @@ -949,9 +970,10 @@ const AgentChat = (() => { // Escape mirrors Stop: cancel a cancellable (user) turn and clear busy // (a cancelled turn emits no stream_end, so clear optimistically). if (e.key === 'Escape' && agentBusy && busySource === 'user') { - e.preventDefault(); send({ type: 'cancel' }); setBusy(false); + e.preventDefault(); cancelTurn(); } }); + renderComposerButton(); // initial state: disabled until there's text } document.addEventListener('DOMContentLoaded', init); diff --git a/gently/ui/web/static/js/projection-viewer.js b/gently/ui/web/static/js/projection-viewer.js index 0e20b9fa..5cf7719d 100644 --- a/gently/ui/web/static/js/projection-viewer.js +++ b/gently/ui/web/static/js/projection-viewer.js @@ -344,7 +344,7 @@ const ProjectionViewer = { // panel can dock/resize and the window can resize. The animation loop // re-renders every frame, so on a size change we only need to resize the // renderer + camera (coalesced to one rAF). Also listen for the explicit - // layout-change event the chat dock fires on pin/unpin. + // layout-change event the chat dock fires on collapse/expand + resize. if (this._resizeObserver) this._resizeObserver.disconnect(); this._resizeObserver = new ResizeObserver(() => { if (this._resizeRaf) cancelAnimationFrame(this._resizeRaf); diff --git a/gently/ui/web/templates/_header.html b/gently/ui/web/templates/_header.html index 3f113590..cf4a4d03 100644 --- a/gently/ui/web/templates/_header.html +++ b/gently/ui/web/templates/_header.html @@ -25,17 +25,8 @@