diff --git a/src/lib/helpers/utils/common.js b/src/lib/helpers/utils/common.js index 8c6ba68e..ee3f9a2c 100644 --- a/src/lib/helpers/utils/common.js +++ b/src/lib/helpers/utils/common.js @@ -93,6 +93,22 @@ const urlPattern = /https?:\/\/[^\s)"'<>]+/g; * @returns {string | null} */ export function liveRunIdInText(text) { + return liveViewInText(text)?.runId ?? null; +} + +/** + * The live view a message points at — its run id, the URL to open, and the moment that URL + * stops working — or null when the message has none. + * + * One URL covers a run's whole life. The executor's run page serves the live screen while the + * run is going and the recorded history afterwards, deliberately ungated on the run being + * current, so a reader who keeps the link can still see what happened. The only thing that + * ends is the credential. + * + * @param {string | null | undefined} text + * @returns {{ runId: string, url: string, expiresAt: number | null } | null} + */ +export function liveViewInText(text) { if (!text) return null; for (const match of text.matchAll(urlPattern)) { @@ -105,12 +121,40 @@ export function liveRunIdInText(text) { } const runId = liveRunId(url); - if (runId) return runId; + if (runId) { + return { runId, url: match[0], expiresAt: liveTokenExpiry(url.searchParams.get('t')) }; + } } return null; } +/** + * When a live-view token stops being accepted, in ms since the epoch, or null when it cannot + * be read. + * + * The token is `..` and its payload is documented as readable on + * purpose — only the signature is secret, and the run id in it is one the holder of the link + * already has. That lets a client tell a usable link from a dead one without asking anybody, + * which is the difference between offering a link and offering a disappointment. + * + * Unreadable means UNKNOWN, not expired: the executor is the authority on its own credential, + * and guessing "dead" would hide a link that works. Indexed from the end because the signature + * is base64url and the expiry is the segment before it, whatever the run id turns out to hold. + * + * @param {string | null} token + * @returns {number | null} + */ +function liveTokenExpiry(token) { + if (!token) return null; + + const parts = token.split('.'); + if (parts.length < 3) return null; + + const seconds = Number(parts[parts.length - 2]); + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null; +} + /** @param {any} object */ export function formatObject(object) { let res = {}; diff --git a/src/lib/styles/pages/_chat.scss b/src/lib/styles/pages/_chat.scss index e19516e0..b4273de7 100644 --- a/src/lib/styles/pages/_chat.scss +++ b/src/lib/styles/pages/_chat.scss @@ -1421,22 +1421,46 @@ border-radius: 0.5rem; background-color: rgb(249 250 251); color: rgb(107 114 128); + + /* + * An ABSOLUTE line-height, set here and inherited by both children, is what keeps the + * icon on the sentence's line. A unitless 1.5 would resolve against each child's own + * font-size, giving the larger icon a taller line box than the text and standing it + * proud of the first line — which no margin nudge fixes, because the gap changes with + * the icon size. One shared line box, so both are centred in the same strip. + */ + font-size: 0.8125rem; + line-height: 1.21875rem; /* 0.8125rem × 1.5 */ } .cb-sys-note-icon { flex-shrink: 0; - margin-top: 0.14rem; font-size: 0.95rem; color: var(--color-primary); } +/* A note with nothing left to offer: the link's credential has expired, so it is a record of + something that happened rather than a way in. The accent goes — it is the app's promise that + a thing is actionable — and the border stops being dashed, because there is no longer an + activity for the dashes to suggest. */ + + +.cb-sys-note-spent { + border-style: solid; +} + + +.cb-sys-note-spent .cb-sys-note-icon { + color: inherit; +} + + +/* Size and line box come from the note itself, so the icon shares them exactly. */ .cb-sys-note-text { min-width: 0; color: inherit; - font-size: 0.8125rem; - line-height: 1.5; & p { @@ -1448,6 +1472,13 @@ * The one thing in here that is meant to be clicked, so it carries the accent while * the sentence around it stays muted. Underlined only on hover — a permanent underline * on a line this quiet reads as emphasis rather than as a link. + * + * The COLOUR here cannot win on its own. Markdown.svelte always applies `markdown-lite`, + * whose scoped `.markdown-lite :global(a) { color: white }` carries the component's hash + * class and so outranks this selector — white on this near-white note, i.e. invisible. + * Which is why the note also passes `markdown-dark`, the variant meant for light + * surfaces; it ships last in that component and wins on equal specificity. Dropping that + * class is what makes the link vanish, not anything here. */ & a { color: var(--color-primary); @@ -1583,6 +1614,10 @@ .cb-bubble-thinking { float: left; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; background-color: var(--color-light, #eff2f7); border: 1px solid rgb(229 231 235); border-radius: 14px 14px 14px 4px; @@ -1591,6 +1626,37 @@ .cb-chat-indication { font-size: 1em; + min-width: 0; +} + + +/* Step number and elapsed time, to the right of the progress line. Muted and smaller + because they qualify the sentence rather than being part of it: the reader is meant to + take in what is happening first and how long it has taken second. */ + + +.cb-progress-meta { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78em; + white-space: nowrap; + + /* Derived from the bubble's own text colour rather than a fixed grey, so it stays + readable whichever palette the bubble ends up with. 62% keeps it clearly secondary + while holding well above the 4.5:1 small-text minimum on the bubble fill. */ + color: color-mix(in srgb, currentColor 62%, transparent); + + /* Fixed-width digits. The clock repaints every second, and proportional digits made + the bubble's right edge twitch on each tick — motion that means nothing. */ + font-variant-numeric: tabular-nums; +} + + +.cb-progress-meta-item:not(:first-child)::before { + content: '·'; + margin-right: 0.4rem; } @@ -4762,14 +4828,12 @@ } + /* Both size and line box move together, or the icon leaves the line again. */ .cb-sys-note { max-width: 100%; padding: 0.4rem 0.6rem; - } - - - .cb-sys-note-text { font-size: 0.72rem; + line-height: 1.08rem; /* 0.72rem × 1.5 */ } diff --git a/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte b/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte index b14d1601..e64ee380 100644 --- a/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte +++ b/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte @@ -56,7 +56,7 @@ import { webSpeech } from '$lib/services/web-speech'; import LocalStorageManager from '$lib/helpers/utils/storage-manager'; import { clickoutsideDirective } from '$lib/helpers/directives'; - import { delay, directToAgentPage, formatNumber, liveRunIdInText } from '$lib/helpers/utils/common'; + import { delay, directToAgentPage, formatNumber, liveRunIdInText, liveViewInText } from '$lib/helpers/utils/common'; import { AgentExtensions } from '$lib/helpers/utils/agent'; import { utcToLocal } from '$lib/helpers/datetime'; import { replaceNewLine } from '$lib/helpers/http'; @@ -116,6 +116,15 @@ let editingBotMsgUid = $state(''); let highlightedMsgId = $state(''); let indication = $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 + * the last user turn. + */ + let progressSince = $state(0); + let progressElapsed = $state(0); + /** How many DISTINCT progress lines this turn has produced, i.e. which step we are on. */ + let progressStep = $state(0); let mode = $state(''); let notificationText = $state(''); let successText = $state("Done"); @@ -223,12 +232,100 @@ && currentUser?.id !== conversationUser?.id || !AgentExtensions.chatable(agent)); + /* + * A wait shorter than this keeps the bare dots. They are the familiar shape of an ordinary + * turn, and a label plus a clock flashing up for one second is noise. Past it the wait is + * long enough that "is this still running?" becomes a real question, so the bubble starts + * answering it in words — even when nothing has told us WHAT is running. + */ + const SILENT_WAIT_SECONDS = 2; + + /** True once the bubble owes the reader words instead of dots. */ + let showProgressText = $derived(!!indication || progressElapsed >= SILENT_WAIT_SECONDS); + $effect(() => { if (!isWaiting && !disableAction) { focusChatTextArea(); } }); + /* + * The run still going behind the live-view link on screen. + * + * A planner's turn does not end until its whole plan does — the reason the link is pushed + * from a hook instead of written into the reply — so "this turn is still in flight" IS "the + * run is still going". At most one link is ever rendered (hideSupersededLiveLinks drops the + * rest), so one flag covers the conversation. + * + * A soft signal, and it can be wrong for a few seconds after a reload mid-run, before the + * first progress push arrives. Tolerable because both mislabellings lead to the SAME page: + * one URL serves the live screen and the recording, and the executor renders whichever the + * run actually is. Only the sentence around the link is ever wrong, never the destination. + */ + let liveRunInFlight = $derived.by(() => { + if (!isWaiting) return false; + + const lastUser = dialogs.findLastIndex(msg => !BOT_SENDERS.includes(msg?.sender?.role || '')); + const lastLink = dialogs.findLastIndex(msg => !!liveRunIdInText(msg?.rich_content?.message?.text || msg?.text)); + return lastLink > lastUser; + }); + + /** When the live-view link on screen stops working, or null when nothing on screen expires. */ + let liveViewExpiresAt = $derived.by(() => { + for (let i = dialogs.length - 1; i >= 0; i--) { + const msg = dialogs[i]; + if (!BOT_SENDERS.includes(msg?.sender?.role || '')) continue; + + const view = liveViewInText(msg?.rich_content?.message?.text || msg?.text); + if (view) return view.expiresAt; + } + return null; + }); + + /** Read by the render to decide whether the link is still worth offering. */ + let linkClock = $state(Date.now()); + + /* + * Ages the live-view link every half minute. + * + * Its credential expires thirty minutes after it was minted and the executor refuses it + * from then on. Nothing pushes a message when that moment passes, so without a clock a link + * that died while the page sat open would go on presenting itself as openable — the same + * failure hideSupersededLiveLinks exists to prevent, reached from the other direction. + * Half a minute is finer than anyone can care about, and the timer stops itself once the + * link is spent, so an idle chat is not left ticking. + */ + $effect(() => { + if (!liveViewExpiresAt) return; + + const timer = setInterval(() => { + linkClock = Date.now(); + if (linkClock >= liveViewExpiresAt) clearInterval(timer); + }, 30_000); + return () => clearInterval(timer); + }); + + /* + * Ages the progress line once a second. + * + * The clock is what carries "still running" once the dots are gone: a browser task can sit + * on one step for a minute, and a static sentence in a bot-coloured bubble reads as a reply + * that has already arrived. Re-runs only when the clock starts, stops or restarts — the tick + * writes `progressElapsed`, which nothing in here reads, so it cannot re-trigger itself. + * + * Typing off and on again mid-turn pauses and resumes the same clock rather than restarting + * it, because `progressSince` is untouched: the number stays the age of the STEP, not of the + * latest gap in the signalling. + */ + $effect(() => { + if (!isThinking || !progressSince) return; + + const tick = () => { progressElapsed = Math.floor((Date.now() - progressSince) / 1000); }; + tick(); + const timer = setInterval(tick, 1000); + return () => clearInterval(timer); + }); + setContext('chat-window-context', { autoScrollToBottom: autoScrollToBottom }); @@ -599,6 +696,18 @@ /** @param {import('$conversationTypes').ChatResponseModel} message */ function onMessageReceivedFromClient(message) { + /* + * A turn opened by someone else — a CSR, or this user in another tab — never went + * through sendChatMessage, so this is the only place its progress gets cleared. + * + * Restricted to messages that are not ours on purpose. Our own send already reset + * synchronously; resetting again on the echo would risk landing after the turn's first + * indication and dropping the step it was announcing. + */ + if (message?.sender?.id && message.sender.id !== currentUser?.id) { + resetProgress(); + } + autoScrollLog = true; dialogs.push({ ...message, @@ -775,17 +884,76 @@ isSendingMsg = false; messageQueue = []; isHandlingQueue = false; + resetProgress(); refresh(); } isStopStreamClicked = false; }); } - /** @param {import('$conversationTypes').ChatResponseModel} message */ + /** + * Adopts `text` as what the agent is currently doing, if it is news. + * + * Each distinct line is one step: the backend pushes an indication per function call, and a + * browser task pushes one per browser step, so counting the changes here yields the step + * number without either side having to carry a counter. A resend of the line already showing + * is dropped rather than counted — it is not a new step, and it must not restart the clock + * that is the only sign a long step is still alive. + * + * @param {string} text + */ + function trackProgress(text) { + if (!text || text === indication) return; + + indication = text; + progressStep += 1; + progressSince = Date.now(); + progressElapsed = 0; + } + + /** Begins timing a wait, unless something is already being timed. */ + function startProgressClock() { + if (progressSince) return; + + progressSince = Date.now(); + progressElapsed = 0; + } + + /** + * Forgets the turn's progress. + * + * Only the end of a turn may call this — a new user message, or the user stopping the run. + * Anything finer-grained (a typing-off, a function returning) is a gap WITHIN a turn, and + * clearing on those is what left the bubble as three anonymous dots. + */ + function resetProgress() { + indication = ''; + progressStep = 0; + progressSince = 0; + progressElapsed = 0; + } + + /** `m:ss`. Minutes run past 60 rather than growing an hours field no run needs. */ + function formatElapsed(/** @type {number} */ seconds) { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; + } + + /** + * Sticky by design: an indication is never cleared here, and typing-off no longer clears it + * either, so the last thing we were told survives the silence after it. + * + * That silence is the whole problem this handles. A web task is one indication followed by + * minutes of the executor working, and the old pairing of "clear on typing-off, only ever + * set on a fresh indication" left the entire run rendered as three dots. Keeping the line + * up means the reader can always see which step is outstanding; the clock beside it says + * how long it has been outstanding for. + * + * @param {import('$conversationTypes').ChatResponseModel} message + */ function onIndicationReceived(message) { isThinking = true; - const retIndication = message.indication || ''; - indication = retIndication.split('|')[0]; + startProgressClock(); + trackProgress((message.indication || '').split('|')[0]); } /** @param {import('$conversationTypes').ChatResponseModel} message */ @@ -829,9 +997,11 @@ function onSenderActionGenerated(data) { if (data?.sender_action == SenderAction.TypingOn) { isThinking = true; + startProgressClock(); } else if (data?.sender_action == SenderAction.TypingOff) { + // Label and clock deliberately survive. A single turn toggles typing off and on + // between function calls, so this is not the end of anything — see resetProgress. isThinking = false; - indication = ''; } } @@ -874,6 +1044,7 @@ */ async function sendChatMessage(msgText, data = null, conversationId = null) { isSendingMsg = true; + resetProgress(); clearInstantLogs(); renewUserSentMessages(msgText); const agentId = page.params.agentId; @@ -2026,10 +2197,11 @@ {#each dialogGroup as message} - {@const runId = BOT_SENDERS.includes(message.sender?.role) - ? liveRunIdInText(message?.rich_content?.message?.text || message?.text) + {@const liveView = BOT_SENDERS.includes(message.sender?.role) + ? liveViewInText(message?.rich_content?.message?.text || message?.text) : null} - {#if runId} + {#if liveView} + {@const spent = !!liveView.expiresAt && liveView.expiresAt <= linkClock}
  • -
    - - +
    + + + {#if spent} + + This run's recording has expired. + {:else if liveRunInFlight} + + {:else} + + + {/if}
  • {:else} @@ -2340,14 +2553,33 @@
    - {#if !!indication} - - {indication} + {#if showProgressText} + + + {indication || 'Working on it'} + + {:else} +
    + +
    {/if} -
    - -