Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/lib/styles/pages/_chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
67 changes: 66 additions & 1 deletion src/routes/chat/[agentId]/[conversationId]/chat-box.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand All @@ -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 */
Expand Down Expand Up @@ -2946,6 +2991,26 @@
</div>

<div class={`cb-input-section cb-css-animation ${!loadEditor ? 'cb-input-hide' : 'cb-fade-in'}`}>
<!--
The agent is driving a browser and you can watch it. Pinned here rather
than posted into the thread because the link belongs to the step running
NOW: it expires with that step, so in the transcript it would be a dead
link a minute later, one per step. Here there is one, and it is current.
-->
{#if liveViewUrl}
<div class="cb-live-view-strip">
<a
class="cb-live-view-link"
href={liveViewUrl}
target="_blank"
rel="noreferrer"
>
<i class="mdi mdi-monitor-eye" aria-hidden="true"></i>
<span>Watch the execution</span>
</a>
<span class="cb-live-view-note">take the controls if it needs a hand</span>
</div>
{/if}
<div class="cb-input-row">
<div class="cb-col-auto">
{#if PUBLIC_LIVECHAT_VOICE_ENABLED === 'true' && !disableSpeech}
Expand Down
Loading