From b6a0b457d7bcd43238308013750b632b676d1377 Mon Sep 17 00:00:00 2001 From: hchen Date: Sun, 6 Sep 2026 17:08:42 -0500 Subject: [PATCH] Add live view feature for agent's browser during execution --- src/lib/styles/pages/_chat.scss | 58 ++++++++++++++++ .../[conversationId]/chat-box.svelte | 67 ++++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/lib/styles/pages/_chat.scss b/src/lib/styles/pages/_chat.scss index 9459bee2..991f6e6e 100644 --- a/src/lib/styles/pages/_chat.scss +++ b/src/lib/styles/pages/_chat.scss @@ -2336,6 +2336,64 @@ } +/* The live browser, offered above the composer while the agent is driving one. + + It sits in the footer rather than in the thread because the link is only good for the step + running now — an agent walking a flow starts a run per node and each node's link dies with + it. One current link beats a column of expired ones. + + Deliberately quiet: this is an offer, not an event. It must not read as a message, and it + must not push the composer around, hence the small fixed height and the flex-shrink. */ + + +.cb-live-view-strip { + flex-shrink: 0; + display: flex; + align-items: center; + /* Centred over the composer rather than left-aligned with it. The strip is one short offer, + not a row of a list, so hanging it off the left edge left it reading as a stray caption of + whatever sits above. Centred, it belongs to the composer it is pinned to. The note still + ellipsises when narrow — the shrinking happens inside the centred group. */ + justify-content: center; + gap: 0.5rem; + min-height: 1.6rem; + padding: 0 0.25rem 0.2rem; + font-size: 0.8rem; + overflow: hidden; + white-space: nowrap; +} + + +.cb-live-view-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-weight: 600; + text-decoration: none; + + i { + font-size: 1rem; + } + + &:hover, + &:focus-visible { + text-decoration: underline; + } +} + + +/* The clause after the link. Derived from the surrounding text colour rather than a fixed grey + so it stays readable in either theme, and held well above the small-text contrast minimum. */ + + +.cb-live-view-note { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: color-mix(in srgb, currentColor 62%, transparent); +} + + .cb-input-hide { display: none; height: 2%; diff --git a/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte b/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte index bca039a4..6edf71cb 100644 --- a/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte +++ b/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte @@ -117,6 +117,21 @@ let editingBotMsgUid = $state(''); let highlightedMsgId = $state(''); let indication = $state(''); + + /** + * Where the agent's browser can be watched RIGHT NOW, when it is driving one. + * + * Pinned above the composer rather than put in the thread, and that placement is the whole + * point. The link is per-run and short-lived — an agent walking a flow node by node starts a + * new run for every node, and each node's link dies with it — so a link left in the transcript + * is a dead link within a minute or two, and a fifteen-node flow left fifteen of them. Pinned, + * there is exactly one on screen and it is always the step running now. + * + * Arrives on the indication, after a `|` (see `onIndicationReceived`). Cleared only by + * `resetProgress`, i.e. at the end of a turn — which for a flow is when the whole flow is + * done, and is exactly when there is no longer a browser to watch. + */ + let liveViewUrl = $state(''); /** * Wall clock (ms) the progress line currently on screen started at, and its age in whole * seconds. `progressSince === 0` means nothing is being timed — no wait has begun since @@ -1172,6 +1187,9 @@ progressStep = 0; progressSince = 0; progressElapsed = 0; + // The turn is over, so there is no browser left to watch. What survives the flow is the + // RECORDING, and that link is written into the thread by whoever ran the flow. + liveViewUrl = ''; } /** `m:ss`. Minutes run past 60 rather than growing an hours field no run needs. */ @@ -1194,7 +1212,34 @@ function onIndicationReceived(message) { isThinking = true; startProgressClock(); - trackProgress((message.indication || '').split('|')[0]); + + // `text|url`. The first field has always been the progress line; anything after it was + // discarded. The second is now the live view of whatever browser this step is driving. + const parts = (message.indication || '').split('|'); + + // Read BEFORE trackProgress, and independently of it. trackProgress drops a line that is + // already showing — it is not a new step and must not restart the clock — and a backend + // that repeats its progress line while moving to a new run would otherwise have no way to + // hand over the new link. + trackLiveView(parts[1]); + trackProgress(parts[0]); + } + + /** + * Adopts a live-view URL, or leaves the current one alone. + * + * An indication with no URL does NOT clear the pin. Most steps of a flow send none — the + * backend only knows one when a browser task has just been handed out — and clearing on those + * would make the link flicker away between every pair of steps. It ends with the turn instead, + * in `resetProgress`. + * + * @param {string | undefined} url + */ + function trackLiveView(url) { + const next = (url || '').trim(); + if (next) { + liveViewUrl = next; + } } /** @param {import('$conversationTypes').ChatResponseModel} message */ @@ -2946,6 +2991,26 @@
+ + {#if liveViewUrl} +
+ + + Watch the execution + + take the controls if it needs a hand +
+ {/if}
{#if PUBLIC_LIVECHAT_VOICE_ENABLED === 'true' && !disableSpeech}