/** * The stopped-turn marker, live and on reopen. * * A cancelled turn ends with one durable assistant row whose metadata carries * `cancelled: {request_id, execution_id, reason}`. The server streams the same * row as a text chunk carrying that object and repeats it on the terminal * `frame_update`, and `GET /frames/{fid}/messages` projects it. Rendering all * three as this element -- instead of as prose -- keeps live and reopen * identical and in the UI's language. * * Without it a turn stopped mid-Cell kept its "Running analysis" card and * reopened with only the in-progress narration, i.e. a claim that the stopped * cell was still running. */ import { LANG } from "../../i18n/runtime"; import { markCardStopped } from "./cardState"; import { el } from "./dom"; import type { LiveStream } from "./stream"; /** The `stop` glyph from sessions/icon, inlined to keep this lane's import graph. */ const STOP_ICON = ''; /** Feature-local copy: en.ts / zh.ts are generated extracts. */ const COPY: Record<"en" | "zh", Record> = { en: { "turn.stopped.user": "Stopped by user", "turn.stopped.autoBudget": "Stopped: the Auto Mode budget was reached", }, zh: { "turn.stopped.user": "已由用户停止", "turn.stopped.autoBudget": "已停止:已达到自动模式预算", }, }; export function stoppedT(key: string): string { return (COPY[LANG] || COPY.en)[key] ?? COPY.en[key] ?? key; } export type StoppedIdentity = { reason: "user" | "auto_budget"; request_id?: string; execution_id?: string; }; /** Accept only the allowlisted shape; anything else is not a marker. */ export function cancelledIdentity(value: unknown): StoppedIdentity | null { if (!value || typeof value !== "object") return null; const raw = value as Record; const reason = raw.reason; if (reason !== "user" && reason !== "auto_budget") return null; const out: StoppedIdentity = { reason }; if (typeof raw.request_id === "string" && raw.request_id) { out.request_id = raw.request_id.slice(0, 96); } if (typeof raw.execution_id === "string" && raw.execution_id) { out.execution_id = raw.execution_id.slice(0, 96); } return out; } export function stoppedMarker(identity: StoppedIdentity): HTMLElement { const box = el("div", "msg-stopped"); box.dataset.stopReason = identity.reason; if (identity.request_id) box.dataset.requestId = identity.request_id; if (identity.execution_id) box.dataset.executionId = identity.execution_id; const ic = el("span", "ic"); ic.innerHTML = STOP_ICON; box.appendChild(ic); box.appendChild( el( "span", "lbl", stoppedT(identity.reason === "auto_budget" ? "turn.stopped.autoBudget" : "turn.stopped.user"), ), ); return box; } type StoppableStream = LiveStream & { stopped?: boolean }; /** * Close a live stream as stopped: the activity card that was still the last * thing on screen is marked stopped, then the marker is appended. A card with * narration after it finished before the Stop (the outcome narration is only * published for a cell that was not cancelled), so it keeps its state, and so * does a card whose own outcome already arrived (cardState.ts). */ export function appendLiveStoppedMarker(st: LiveStream, identity: StoppedIdentity): void { const live = st as StoppableStream; if (live.stopped) return; live.stopped = true; if (st.toolCard && !String(st.text || "").trim()) markCardStopped(st.toolCard); st.wrap.classList.add("turn-stopped"); st.wrap.dataset.turnStatus = "cancelled"; st.wrap.appendChild(stoppedMarker(identity)); } /** * Terminal fallback: a `cancelled` terminal whose marker chunk this client * never saw (an older daemon, a socket that reconnected past it) still ends * the live block with a marker rather than with a "running" card. */ export function finishStoppedStream(st: LiveStream | null, detail: unknown): void { if (!st) return; const rec = detail && typeof detail === "object" ? (detail as Record) : null; appendLiveStoppedMarker(st, cancelledIdentity(rec && rec.cancelled) || { reason: "user" }); }