From 31730bd3d43ccc352d08b3a51ea22e1eb9ae5d0c Mon Sep 17 00:00:00 2001 From: Kesavan Date: Sun, 12 Jul 2026 05:05:55 -0400 Subject: [PATCH] =?UTF-8?q?Agent=20chat=20UI=20improvements=20(#60)=20?= =?UTF-8?q?=E2=80=94=20rebased=20onto=20current=20development?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed, updated rebase of PR #60 (agent-ui-improvements, by @schneidermc / Magdalena) onto current development, which had rewritten agent-chat.js (990->1387 lines) since the PR was opened. - agent-chat.js: 3-way reconciliation — kept new development's askPending / ASK-clear feature + clearPendingAsks(); applied #60's redesign (docked panel, collapsed spark rail replacing the header toggle, send<->stop composer morph, "You" user labels via author_id/you_id); made the composer ask-aware (offers Send, not Stop, during a pending ask). Stripped 4 stray null bytes that made git treat the file as binary. - server.py: #60's SO_REUSEADDR fix is already in development — dropped as redundant. - bridge.py, agent_ws.py, agent-chat.css, _header.html, index.html, projection-viewer.js: merged clean. Verified live: docked panel renders, rail toggle opens it, header toggle gone, composer send button present, connects, zero JS console errors. Co-authored-by: schneidermc --- gently/harness/bridge.py | 7 + gently/ui/web/routes/agent_ws.py | 37 +++++- gently/ui/web/static/css/agent-chat.css | 132 ++++++++++--------- gently/ui/web/static/js/agent-chat.js | Bin 66583 -> 67994 bytes gently/ui/web/static/js/projection-viewer.js | 2 +- gently/ui/web/templates/_header.html | 13 +- gently/ui/web/templates/index.html | 38 ++++-- 7 files changed, 144 insertions(+), 85 deletions(-) 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 6e77219453fbde51392f6358deebf02032907ac1..bfa50a8e174759c789ebff3ebc033e960d67c401 100644 GIT binary patch delta 3898 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 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 @@
- - + {# Agent lives in the docked right sidebar (collapses to a spark rail) — + no header toggle. Ctrl/Cmd+J still opens it. #} {% endif %} diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 14767be8..af177670 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -108,8 +108,8 @@

Take a quick look

{% endif %} {% include '_header.html' %} {% include '_navbar.html' %} - +
{% if ux_v2 %}
- +