diff --git a/gently/harness/bridge.py b/gently/harness/bridge.py index fdd21040..67a87f2a 100644 --- a/gently/harness/bridge.py +++ b/gently/harness/bridge.py @@ -671,6 +671,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 4c3a8ea7..072c9a46 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -163,6 +163,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": @@ -340,16 +341,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) @@ -851,8 +856,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) ) @@ -876,6 +890,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() @@ -986,6 +1004,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/static/css/agent-chat.css b/gently/ui/web/static/css/agent-chat.css index 106d79bd..3fd7fc82 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 { @@ -402,28 +421,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 6e772194..bfa50a8e 100644 Binary files a/gently/ui/web/static/js/agent-chat.js and b/gently/ui/web/static/js/agent-chat.js differ 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 @@