/** * The console page. Type a task, watch the harness work, open what it built, * and read what any run did step by step. * * There is one reading of a run rather than two. A turn produces a run, and a * run's artifacts are the record of it — so the timeline is what the page * shows whether the turn finished an hour ago or is still going, and the live * event stream drives the status line and the re-reads rather than a feed of * its own. */ import { html, LitElement, nothing, type TemplateResult } from "lit"; import { cancelTurn, type ConsoleChatEventEnvelope, type ConsoleFlow, type ConsoleListedRun, type ConsoleRunSource, listRuns, readRunFlow, startTask, } from "./api.ts"; import { consolePath, pageMount } from "./mount.ts"; import "./index-view.ts"; import "./flow-view.ts"; import "./run-view.ts"; import type { ConsoleTurnResultPiece } from "../turn-result.ts"; /** Which surface the shell is showing: the runs, or the pattern index. */ type View = "console" | "index"; /** A run and the `delegate_task` children it started. */ interface RunNode { run: ConsoleListedRun; children: RunNode[]; } /** * What a run row says about where a run came from, for a run this console did * not make. The console's own runs carry no label: they are the ordinary case. */ const SOURCE_LABELS: Readonly> = { console: undefined, ask: "/ask", agent: "agent()", }; /** * The run list as a tree. A subagent run names its parent, so a child sits * under the run that delegated to it rather than beside it at the top level, * where it would read as work someone asked for directly. */ export const runTree = ( runs: readonly ConsoleListedRun[], ): readonly RunNode[] => { const nodes = new Map( runs.map((run) => [run.runId, { run, children: [] }]), ); const roots: RunNode[] = []; for (const run of runs) { const node = nodes.get(run.runId)!; const parent = run.parentRunId === undefined ? undefined : nodes.get(run.parentRunId); if (parent === undefined) { roots.push(node); } else { parent.children.push(node); } } const byRunId = (left: RunNode, right: RunNode) => left.run.runId.localeCompare(right.run.runId); for (const node of nodes.values()) { node.children.sort(byRunId); } return roots; }; /** * The piece the page raises once a turn completes: the last one the turn * named, or, when it named none, the one already shown. */ export const consolePieceAfterTurn = ( pieces: readonly ConsoleTurnResultPiece[], shown: ConsoleTurnResultPiece | undefined, ): ConsoleTurnResultPiece | undefined => pieces.at(-1) ?? shown; /** Selects a new console parent run to open while a turn is executing. */ export const freshConsoleRun = ( runs: readonly ConsoleListedRun[], beforeTurn: ReadonlySet, ): ConsoleListedRun | undefined => runs.find((run) => run.source === "console" && run.parentRunId === undefined && !beforeTurn.has(run.runId) ); export class ConsoleApp extends LitElement { static override properties = { sessionId: { attribute: false }, turnId: { attribute: false }, runs: { attribute: false }, openRunId: { attribute: false }, flow: { attribute: false }, flowError: { attribute: false }, flowLoading: { attribute: false }, focusStep: { attribute: false }, state: { attribute: false }, running: { attribute: false }, activity: { attribute: false }, piece: { attribute: false }, error: { attribute: false }, view: { attribute: false }, }; declare view: View; declare sessionId: string | undefined; declare turnId: string | undefined; declare runs: readonly ConsoleListedRun[]; declare openRunId: string | undefined; /** The open run's conversation map, which the third column draws. */ declare flow: ConsoleFlow | undefined; /** * Why the map could not be read. Held apart from `flow` because an absent * map and a refused one are different things to a reader: one is still * arriving, the other needs asking for again. */ declare flowError: string | undefined; /** Whether a map read is in flight, so a retry cannot start a second. */ declare flowLoading: boolean; /** The step the middle column is reading, marked in the map. */ declare focusStep: number | undefined; declare state: string; declare running: boolean; /** The last thing the live stream reported, shown while a turn runs. */ declare activity: string | undefined; /** The last piece a turn named, raised above the timeline. */ declare piece: ConsoleTurnResultPiece | undefined; declare error: string | undefined; /** The last sequence rendered; every reconnect resumes from it. */ #lastSequence = 0; #stream: EventSource | undefined; /** Run ids known before the running turn started, so its own is spottable. */ #runIdsBeforeTurn = new Set(); /** Guards the map read against a second run being opened mid-flight. */ #flowReads = 0; constructor() { super(); this.runs = []; this.state = "idle"; this.running = false; this.view = "console"; } protected override createRenderRoot(): HTMLElement { return this; } override connectedCallback(): void { super.connectedCallback(); void this.#loadRuns(); const params = new URLSearchParams(location.search); if (params.get("view") === "index") { this.view = "index"; } const named = params.get("sessionId"); if (named !== null && named !== "") { this.sessionId = named; this.#subscribe(); } } override disconnectedCallback(): void { super.disconnectedCallback(); this.#stream?.close(); } protected override willUpdate(changed: Map): void { if ( changed.has("openRunId") && this.openRunId !== changed.get("openRunId") ) { this.flow = undefined; this.flowError = undefined; this.focusStep = undefined; void this.#loadFlow(); } } /** * Reads the open run's map. Guarded like every other read here: opening a * second run while the first map is in flight must not draw the first. */ async #loadFlow(): Promise { const read = ++this.#flowReads; const runId = this.openRunId; if (runId === undefined) { return; } this.flowLoading = true; try { const flow = await readRunFlow(runId); if (read === this.#flowReads && this.openRunId === runId) { this.flow = flow; this.flowError = undefined; } } catch (error) { // A map that cannot be read leaves the rest of the run readable, and // says so rather than reading as one still arriving. if (read === this.#flowReads && this.openRunId === runId) { this.flow = undefined; this.flowError = error instanceof Error ? error.message : String(error); } } finally { if (read === this.#flowReads) { this.flowLoading = false; } } } /** * Follows a click in the map. A node of a child run opens that run, whose own * timeline is where its steps are numbered; anything else moves the middle * column to the step. */ #followMap(target: { runId?: string; step: number }): void { if (target.runId !== undefined && target.runId !== this.openRunId) { this.openRunId = target.runId; return; } this.focusStep = target.step; } async #loadRuns(): Promise { try { this.runs = await listRuns(); this.error = undefined; } catch (error) { this.error = error instanceof Error ? error.message : String(error); } } /** * Re-reads the run list and the open run. A run writes its artifacts as it * goes, so this is what makes the timeline fill in while a turn is still * running; it is driven by a tool completing rather than by a clock. */ async #refresh(): Promise { await this.#loadRuns(); if (this.openRunId === undefined) { // The turn's own run is whichever of this console's appeared after it // started; an `/ask` or `agent()` run can appear in the meantime too. const fresh = freshConsoleRun(this.runs, this.#runIdsBeforeTurn); if (fresh !== undefined) { this.openRunId = fresh.runId; } } const view = this.querySelector("console-run-view") as | { refresh: () => Promise } | null; await view?.refresh(); await this.#loadFlow(); } #subscribe(): void { this.#stream?.close(); const stream = new EventSource( consolePath( pageMount(), `/api/events?sessionId=${ encodeURIComponent(this.sessionId ?? "") }&afterSequence=${this.#lastSequence}`, ), ); this.#stream = stream; stream.addEventListener("chat", (message) => { this.#onEvent(JSON.parse((message as MessageEvent).data)); }); stream.addEventListener("error", () => { if (stream.readyState === EventSource.CLOSED) { this.#subscribe(); } }); } #onEvent(envelope: ConsoleChatEventEnvelope): void { this.#lastSequence = envelope.sequence; const event = envelope.event; switch (event.kind) { case "turn_started": this.turnId = event.turn.turnId; this.state = "working"; this.running = true; break; case "tool_started": this.activity = `calling ${event.tool.toolId}`; break; case "subagent_started": this.activity = `delegating to ${event.subagent.profile}`; break; case "tool_completed": this.activity = `${event.tool.toolId} ${event.status}`; void this.#refresh(); break; case "turn_completed": this.piece = consolePieceAfterTurn(event.result.pieces, this.piece); this.state = "done"; this.running = false; this.activity = undefined; void this.#refresh(); break; case "turn_canceled": this.state = "canceling"; break; case "status_changed": // A canceled turn is still winding down when it says so, and the // session reporting no active turn is where it has actually stopped. // That is the only end a cancellation has, so the page reads it as one. if (event.session.activeTurnId === undefined && this.running) { this.state = this.state === "canceling" ? "canceled" : "idle"; this.running = false; this.activity = undefined; void this.#refresh(); } break; case "turn_failed": this.state = "failed"; this.running = false; this.activity = undefined; void this.#refresh(); break; default: break; } } async #start(): Promise { // The button is disabled while a turn runs, but the attribute is written on // the next render rather than on the click, so a second click that lands // first is refused here rather than starting a second turn. if (this.running) { return; } const input = this.querySelector("#task") as HTMLTextAreaElement; const text = input.value.trim(); if (text === "") { return; } this.running = true; this.state = "starting"; this.error = undefined; this.#runIdsBeforeTurn = new Set(this.runs.map((run) => run.runId)); this.openRunId = undefined; if (this.sessionId === undefined) { this.piece = undefined; this.#lastSequence = 0; } try { const started = await startTask(text, this.sessionId); input.value = ""; this.turnId = started.turnId; if (this.sessionId === undefined) { this.sessionId = started.sessionId; history.replaceState( null, "", `?sessionId=${encodeURIComponent(started.sessionId)}`, ); this.#subscribe(); } } catch (error) { this.running = false; this.state = "failed"; this.error = error instanceof Error ? error.message : String(error); } } /** * Asks for the running turn to stop. A refused cancel leaves the turn * running, so the page goes back to reporting what the turn is doing and says * why it is still doing it. */ async #cancel(): Promise { if (this.sessionId === undefined) { return; } const before = this.state; this.state = "canceling"; try { await cancelTurn(this.sessionId, this.turnId); this.error = undefined; } catch (error) { this.state = before; this.error = error instanceof Error ? error.message : String(error); } } /** * Switches surface, and says so in the address bar: the Index view is one an * operator reloads into, and the session the runs are being watched under is * kept so switching back returns to it rather than to a fresh page. */ #showView(view: View): void { this.view = view; const params = new URLSearchParams(); if (this.sessionId !== undefined) { params.set("sessionId", this.sessionId); } if (view === "index") { params.set("view", "index"); } const query = params.toString(); history.replaceState( null, "", query === "" ? location.pathname : `?${query}`, ); } #runRow(node: RunNode, depth: number): TemplateResult { const run = node.run; const sourceLabel = SOURCE_LABELS[run.source]; return html` ${node.children.map((child) => this.#runRow(child, depth + 1))} `; } protected override render(): TemplateResult { const viewTab = (view: View, label: string) => html` `; return html`

cf-harness console

${viewTab("console", "Runs")} ${viewTab("index", "Index")}
${this.running && this.activity !== undefined ? this.activity : this.state}
${this.view === "index" ? html`` : this.#consoleView()}
`; } /** Type a task, watch it run, read what any run did. */ #consoleView(): TemplateResult { return html`
${this.sessionId === undefined ? nothing : html` `}
${this.error === undefined ? nothing : html`

${this.error}

`} ${this.piece === undefined ? nothing : html`
Open your piece
${this.piece.slug} — ${this.piece.url}
`}
Runs
${this.runs.length === 0 ? html`

No run has been made yet.

` : runTree(this.runs).map((node) => this.#runRow(node, 0))}
) => this.openRunId = event.detail} @step-selected=${(event: CustomEvent) => this.focusStep = event.detail} >
Map
${this.openRunId === undefined ? html`

Choose a run to see how it went.

` : this.flowError !== undefined ? html`

${this.flowError}

` : html` , ) => this.#followMap(event.detail)} > `}
`; } } customElements.define("console-app", ConsoleApp);