Merge structured-view/140-web-structured
This commit is contained in:
+43
-1
@@ -11,6 +11,8 @@ import type {
|
||||
Projects,
|
||||
RecentDirs,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
StructuredPromptRequest,
|
||||
Uuid,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -152,4 +154,44 @@ export async function fetchBranches(path: string): Promise<Branches> {
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export { eventsWsUrl } from "./routes.js";
|
||||
/** Send a prompt to a structured session's ACP turn (#138). `409
|
||||
* turn_in_flight` when one is already active — the caller reflects that from
|
||||
* `ReducedState.turn` instead of racing this call. */
|
||||
export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.structuredPrompt(id), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ prompt } satisfies StructuredPromptRequest),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancel a structured session's active ACP turn (#138). */
|
||||
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.structuredCancel(id), { method: "POST", credentials: "same-origin" }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Answer a pending ACP permission request (#139). Always resolves once the
|
||||
* session itself is a live structured session — a nonce that is unknown or
|
||||
* already consumed still answers `204` (docs/STRUCTURED-VIEW.md), so two
|
||||
* clients racing the same approval never see this reject. */
|
||||
export async function resolveStructuredApproval(
|
||||
id: Uuid,
|
||||
nonce: string,
|
||||
decision: StructuredApprovalDecision,
|
||||
): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.structuredApproval(id, nonce), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(decision),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
|
||||
|
||||
+8
-44
@@ -4,14 +4,16 @@ import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { attachWsUrl } from "./routes.js";
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
||||
import type { PaneConnection } from "./pane.js";
|
||||
import type { ClientControl, Uuid } from "./types.js";
|
||||
import { WebglPolicy } from "./webglRenderer.js";
|
||||
|
||||
export type { AttachInfo } from "./paneTitle.js";
|
||||
|
||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||
* rationed rather than handed to every connection. */
|
||||
const webglPolicy = new WebglPolicy();
|
||||
@@ -27,25 +29,7 @@ const webglPolicy = new WebglPolicy();
|
||||
* session ending) tears the socket down — so switching away and back is
|
||||
* instant, with scrollback already in place.
|
||||
*/
|
||||
export interface AttachConnection {
|
||||
readonly sessionId: Uuid;
|
||||
/** The `.attach` element (header + terminal body) to mount into a pane. */
|
||||
readonly el: HTMLElement;
|
||||
/** Becomes the visible view: fit to the pane, focus, sync size.
|
||||
* `focus: false` mounts without taking the keyboard — what a sidebar
|
||||
* preview needs, so j/k keeps working while the pane is on screen. */
|
||||
onMount(opts?: { focus?: boolean }): void;
|
||||
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
||||
onUnmount(): void;
|
||||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||||
focusTerminal(): void;
|
||||
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
|
||||
* handling — the leader's literal Ctrl-K escape hatch
|
||||
* (docs/COMMAND-BAR.md). */
|
||||
sendInput(data: string): void;
|
||||
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
||||
dispose(): void;
|
||||
}
|
||||
export interface AttachConnection extends PaneConnection {}
|
||||
|
||||
export interface AttachOptions {
|
||||
/** Invoked when the user clicks the view's explicit "disconnect" control. */
|
||||
@@ -59,23 +43,6 @@ export interface AttachOptions {
|
||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||
}
|
||||
|
||||
/** What the header shows about the session (name / command). Optional — falls
|
||||
* back to a short session id when the session isn't known yet. */
|
||||
export interface AttachInfo {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** Header echoes the sidebar: the program becomes its tool icon when we have a
|
||||
* mark for it and stays as its bare name when we do not. Arguments are
|
||||
* dropped, as on the card. */
|
||||
function commandFragment(command: string): string {
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? "" : program;
|
||||
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||
}
|
||||
|
||||
export function createAttachConnection(
|
||||
sessionId: Uuid,
|
||||
opts: AttachOptions = {},
|
||||
@@ -85,15 +52,12 @@ export function createAttachConnection(
|
||||
el.className = "attach";
|
||||
// Useful identity over a bare UUID: name · command. The full id stays in the
|
||||
// tooltip for when you really need it.
|
||||
const titleHtml = info
|
||||
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
||||
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
||||
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
||||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
||||
const titleHtml = paneTitleHtml(sessionId, info);
|
||||
const titleAttr = paneTitleAttr(sessionId, info);
|
||||
el.innerHTML = `
|
||||
<header class="attach-header">
|
||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
|
||||
<span class="attach-title" title="${titleAttr}">${titleHtml}</span>
|
||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||
</header>
|
||||
|
||||
+261
-6
@@ -2,28 +2,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ConnectionPool } from "./connections.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
|
||||
// the pool reaches the focused connection with them.
|
||||
const sent: { id: string; data: string }[] = [];
|
||||
vi.mock("./attach.js", () => ({
|
||||
createAttachConnection: (id: string) => ({
|
||||
const disposed: string[] = [];
|
||||
const mounts: { id: string; focus: boolean | undefined }[] = [];
|
||||
function fakeConn(kind: string, id: string) {
|
||||
const el = document.createElement("div");
|
||||
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
|
||||
el.dataset.kind = kind;
|
||||
el.dataset.session = id;
|
||||
return {
|
||||
sessionId: id,
|
||||
el: document.createElement("div"),
|
||||
onMount() {},
|
||||
kind,
|
||||
el,
|
||||
onMount(opts?: { focus?: boolean }) {
|
||||
mounts.push({ id, focus: opts?.focus });
|
||||
},
|
||||
onUnmount() {},
|
||||
focusTerminal() {},
|
||||
sendInput(data: string) {
|
||||
sent.push({ id, data });
|
||||
},
|
||||
dispose() {},
|
||||
}),
|
||||
dispose() {
|
||||
disposed.push(`${kind}:${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
vi.mock("./attach.js", () => ({
|
||||
createAttachConnection: (id: string) => fakeConn("attach", id),
|
||||
}));
|
||||
vi.mock("./structured.js", () => ({
|
||||
createStructuredConnection: (id: string) => fakeConn("structured", id),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
sent.length = 0;
|
||||
disposed.length = 0;
|
||||
mounts.length = 0;
|
||||
});
|
||||
|
||||
/** Minimal fixture for the `CatalogState` a `CatalogStore` broadcasts. */
|
||||
function session(id: string, view?: "Terminal" | "Structured"): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/repo",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
view,
|
||||
};
|
||||
}
|
||||
|
||||
/** A `CatalogStore` an id can be pushed into on demand, mirroring
|
||||
* events.test.ts's own injectable-socket pattern. */
|
||||
class FakeCatalogSocket {
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
close(): void {}
|
||||
push(sessions: Session[]): void {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
|
||||
}
|
||||
}
|
||||
function fakeStore(): { store: CatalogStore; socket: FakeCatalogSocket } {
|
||||
const socket = new FakeCatalogSocket();
|
||||
const store = new CatalogStore({ socket: () => socket as unknown as WebSocket });
|
||||
store.start();
|
||||
return { store, socket };
|
||||
}
|
||||
|
||||
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
|
||||
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
|
||||
|
||||
@@ -125,3 +180,203 @@ describe("ConnectionPool.sendToFocused", () => {
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// A deep link or restored layout can `mount()` a session before the catalog
|
||||
// snapshot names its view — the daemon defaults an absent `view` to
|
||||
// `"Terminal"`, but so does an *unknown* session, and only one of those is
|
||||
// actually resolved (#140 review).
|
||||
describe("ConnectionPool: view resolution races", () => {
|
||||
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
|
||||
pool.mount(A, pane); // no info: deep-link-before-snapshot race
|
||||
expect(disposed).toEqual([]);
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
// The fresh connection landed in the same pane nothing re-mounted it into.
|
||||
expect(pane.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
|
||||
pool.mount(A, document.createElement("div"));
|
||||
pool.mount(B, document.createElement("div"));
|
||||
pool.unmount(A);
|
||||
pool.unmount(B);
|
||||
disposed.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
// B's own element is still parked — A's repair didn't wipe its siblings
|
||||
// out of the shared hidden cache.
|
||||
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
|
||||
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
|
||||
});
|
||||
|
||||
it("does not replace a connection whose resolved view already matches", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||
disposed.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
|
||||
mockFetch();
|
||||
const pool = new ConnectionPool();
|
||||
const pane = document.createElement("div");
|
||||
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||
disposed.length = 0;
|
||||
|
||||
pool.mount(A, pane); // e.g. a re-render before the catalog answers again
|
||||
|
||||
expect(disposed).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||
disposed.length = 0;
|
||||
|
||||
// `A` is absent from this snapshot — main.ts's own subscription handles
|
||||
// closing the pane; the pool must not race it by "fixing" the view first.
|
||||
socket.push([session(B, "Terminal")]);
|
||||
|
||||
expect(disposed).toEqual([]);
|
||||
});
|
||||
|
||||
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
(pane.firstElementChild as HTMLElement).focus();
|
||||
expect(document.activeElement).toBe(pane.firstElementChild);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([{ id: A, focus: true }]);
|
||||
pane.remove();
|
||||
});
|
||||
|
||||
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
|
||||
// docs/PREVIEW.md: previewing a session already open in the current tab
|
||||
// moves the keyboard into the sidebar without ever calling setFocus, so
|
||||
// `focusedId` keeps naming the pane the operator was in before — but the
|
||||
// DOM disagrees, and the DOM is what must decide.
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
(pane.firstElementChild as HTMLElement).focus();
|
||||
|
||||
const sidebarInput = document.createElement("input");
|
||||
document.body.appendChild(sidebarInput);
|
||||
sidebarInput.focus();
|
||||
expect(document.activeElement).toBe(sidebarInput);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||
pane.remove();
|
||||
sidebarInput.remove();
|
||||
});
|
||||
|
||||
it("counts focus on a descendant of the pane's element, not just the element itself", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
const textarea = document.createElement("textarea");
|
||||
(pane.firstElementChild as HTMLElement).appendChild(textarea);
|
||||
textarea.focus();
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(mounts).toEqual([{ id: A, focus: true }]);
|
||||
pane.remove();
|
||||
});
|
||||
|
||||
it("treats document.body (nothing meaningfully focused) as no active descendant", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||
pane.remove();
|
||||
});
|
||||
|
||||
it("does not steal focus for a merely-visible pane that was never actually focused", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const paneA = document.createElement("div");
|
||||
const paneB = document.createElement("div");
|
||||
pool.mount(A, paneA);
|
||||
pool.mount(B, paneB);
|
||||
pool.setFocus(B); // B is the real keyboard focus; A is only visible
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||
});
|
||||
|
||||
it("does not focus a parked (unmounted) connection it repairs", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeStore();
|
||||
const pool = new ConnectionPool({ store });
|
||||
const pane = document.createElement("div");
|
||||
pool.mount(A, pane);
|
||||
pool.setFocus(A);
|
||||
pool.unmount(A); // parked — no longer visible, `focusedId` still names it
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
|
||||
});
|
||||
});
|
||||
|
||||
+110
-18
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
createAttachConnection,
|
||||
type AttachConnection,
|
||||
type AttachInfo,
|
||||
} from "./attach.js";
|
||||
import { createAttachConnection } from "./attach.js";
|
||||
import type { AttachInfo } from "./paneTitle.js";
|
||||
import type { PaneConnection } from "./pane.js";
|
||||
import { createStructuredConnection } from "./structured.js";
|
||||
import { setSessionFocus } from "./api.js";
|
||||
import type { Uuid } from "./types.js";
|
||||
import type { CatalogState, CatalogStore } from "./events.js";
|
||||
import { sessionName, type SessionView, type Uuid } from "./types.js";
|
||||
|
||||
/**
|
||||
* How many *parked* (off-screen but still-connected) sessions to keep warm
|
||||
@@ -16,6 +16,15 @@ import type { Uuid } from "./types.js";
|
||||
*/
|
||||
const MAX_PARKED = 6;
|
||||
|
||||
/** A session's view defaults to `"Terminal"` wherever it isn't carried —
|
||||
* matching the wire default (`SessionView`'s `#[default]`, types.ts). Only
|
||||
* meaningful once the session itself is known; an altogether-unknown
|
||||
* session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved
|
||||
* state callers must check for separately, not "Terminal". */
|
||||
function resolveView(view: SessionView | undefined): SessionView {
|
||||
return view ?? "Terminal";
|
||||
}
|
||||
|
||||
export interface PoolOptions {
|
||||
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
|
||||
onFocusChange?: (id: Uuid | null) => void;
|
||||
@@ -27,6 +36,9 @@ export interface PoolOptions {
|
||||
onClosed?: (id: Uuid) => void;
|
||||
/** Forwarded to each connection: should a dropped socket be retried? */
|
||||
shouldReconnect?: (id: Uuid, code: number) => boolean;
|
||||
/** Threaded through to a structured connection so its rail can read live
|
||||
* worktree/git-status data (#140). Terminal connections don't need it. */
|
||||
store?: CatalogStore;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,12 +52,19 @@ export interface PoolOptions {
|
||||
*/
|
||||
export class ConnectionPool {
|
||||
/** All live connections. Insertion order doubles as LRU: oldest first. */
|
||||
private readonly cache = new Map<Uuid, AttachConnection>();
|
||||
private readonly cache = new Map<Uuid, PaneConnection>();
|
||||
/** The view each cached connection was actually created for — a deep link
|
||||
* or a restored layout can `mount()` a session before the catalog
|
||||
* snapshot names its view, defaulting it to `"Terminal"`; this is what
|
||||
* lets a later reconciliation notice the resolved view disagrees (#140
|
||||
* review). */
|
||||
private readonly connViews = new Map<Uuid, SessionView>();
|
||||
/** Sessions currently mounted into a visible pane (never evicted). */
|
||||
private readonly mounted = new Set<Uuid>();
|
||||
private readonly hidden: HTMLElement;
|
||||
private focusedId: Uuid | null = null;
|
||||
private readonly opts: PoolOptions;
|
||||
private readonly unsubscribeStore?: () => void;
|
||||
|
||||
constructor(opts: PoolOptions = {}) {
|
||||
this.opts = opts;
|
||||
@@ -54,6 +73,11 @@ export class ConnectionPool {
|
||||
this.hidden.className = "conn-cache";
|
||||
this.hidden.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(this.hidden);
|
||||
// A session `mount()`ed before its catalog entry exists gets a
|
||||
// `"Terminal"` connection by default; once the snapshot actually names
|
||||
// the view, fix any connection that guessed wrong — nothing else
|
||||
// re-`mount()`s an already-open pane just because the catalog updated.
|
||||
this.unsubscribeStore = opts.store?.subscribe((state) => this.reconcileViews(state));
|
||||
}
|
||||
|
||||
/** The focused session, or null when nothing is focused (welcome pane). */
|
||||
@@ -77,18 +101,18 @@ export class ConnectionPool {
|
||||
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
|
||||
let conn = this.cache.get(id);
|
||||
if (conn) {
|
||||
this.cache.delete(id);
|
||||
this.cache.set(id, conn);
|
||||
// `info === undefined` means "session not known yet", not "Terminal" —
|
||||
// never downgrade a resolved connection just because a later caller
|
||||
// couldn't resolve the session. Only a *resolved* view that disagrees
|
||||
// with what this connection was built for forces a replace.
|
||||
if (info !== undefined && resolveView(info.view) !== this.connViews.get(id)) {
|
||||
conn = this.replace(id, info);
|
||||
} else {
|
||||
this.cache.delete(id);
|
||||
this.cache.set(id, conn); // LRU refresh
|
||||
}
|
||||
} else {
|
||||
conn = createAttachConnection(
|
||||
id,
|
||||
{
|
||||
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
|
||||
onClosed: (sid) => this.opts.onClosed?.(sid),
|
||||
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
|
||||
},
|
||||
info,
|
||||
);
|
||||
conn = this.create(id, info);
|
||||
this.cache.set(id, conn);
|
||||
}
|
||||
if (conn.el.parentElement !== pane) {
|
||||
@@ -101,6 +125,71 @@ export class ConnectionPool {
|
||||
this.evict();
|
||||
}
|
||||
|
||||
private create(id: Uuid, info?: AttachInfo): PaneConnection {
|
||||
const view = resolveView(info?.view);
|
||||
this.connViews.set(id, view);
|
||||
const connOpts = {
|
||||
onRequestClose: (sid: Uuid) => this.opts.onRequestClose?.(sid),
|
||||
onClosed: (sid: Uuid) => this.opts.onClosed?.(sid),
|
||||
shouldReconnect: (sid: Uuid, code: number) => this.opts.shouldReconnect?.(sid, code) ?? false,
|
||||
};
|
||||
return view === "Structured"
|
||||
? createStructuredConnection(id, connOpts, info, this.opts.store)
|
||||
: createAttachConnection(id, connOpts, info);
|
||||
}
|
||||
|
||||
/** Dispose whatever is cached for `id` and cache a freshly created
|
||||
* connection in its place. Does not touch the DOM or `mounted` — callers
|
||||
* place the new element themselves. */
|
||||
private replace(id: Uuid, info?: AttachInfo): PaneConnection {
|
||||
this.cache.get(id)?.dispose();
|
||||
const fresh = this.create(id, info);
|
||||
this.cache.set(id, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Proactively fix any cached connection whose view no longer matches the
|
||||
* catalog — the only path that repairs a pane nothing will `mount()`
|
||||
* again on its own (parked, or mounted but not currently being
|
||||
* re-rendered by anything else). A session absent from `state` (not yet
|
||||
* known, or removed) is left alone — that's `main.ts`'s
|
||||
* `closePane`/eviction concern, not a view mismatch. */
|
||||
private reconcileViews(state: CatalogState): void {
|
||||
for (const id of [...this.cache.keys()]) {
|
||||
const session = state.sessions.get(id);
|
||||
if (session === undefined || resolveView(session.view) === this.connViews.get(id)) continue;
|
||||
const existing = this.cache.get(id)!;
|
||||
const parent = existing.el.parentElement;
|
||||
const wasMounted = this.mounted.has(id);
|
||||
// Capture before anything below touches the DOM: dispose()/el.remove()
|
||||
// detach `existing.el`, and once a focused descendant is detached the
|
||||
// browser moves `document.activeElement` to `<body>` — too late to
|
||||
// tell whether the keyboard was actually in this pane.
|
||||
const hadDomFocus = existing.el.contains(document.activeElement);
|
||||
const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view };
|
||||
const fresh = this.replace(id, info);
|
||||
existing.el.remove(); // don't rely on dispose() to clear the DOM
|
||||
if (parent && parent !== this.hidden) {
|
||||
parent.replaceChildren(fresh.el);
|
||||
} else {
|
||||
// Parked: `this.hidden` holds every parked connection's element as
|
||||
// siblings — append, never replaceChildren, or they'd all vanish.
|
||||
this.hidden.appendChild(fresh.el);
|
||||
}
|
||||
if (wasMounted) {
|
||||
// `focusedId` names the session the pool was last told to focus —
|
||||
// but a same-tab sidebar preview moves the keyboard into the
|
||||
// sidebar without clearing it (multiview.ts skips `setFocus` while
|
||||
// previewing), so `focusedId` alone can point at a pane the
|
||||
// keyboard already left. Only re-focus when the DOM agrees it
|
||||
// never did.
|
||||
fresh.onMount({ focus: this.focusedId === id && hadDomFocus });
|
||||
} else {
|
||||
fresh.onUnmount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
|
||||
unmount(id: Uuid): void {
|
||||
if (!this.mounted.delete(id)) return;
|
||||
@@ -177,13 +266,16 @@ export class ConnectionPool {
|
||||
if (!conn) return;
|
||||
conn.dispose();
|
||||
this.cache.delete(id);
|
||||
this.connViews.delete(id);
|
||||
this.mounted.delete(id);
|
||||
if (this.focusedId === id) this.setFocus(null);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unsubscribeStore?.();
|
||||
for (const conn of this.cache.values()) conn.dispose();
|
||||
this.cache.clear();
|
||||
this.connViews.clear();
|
||||
this.mounted.clear();
|
||||
this.hidden.remove();
|
||||
}
|
||||
|
||||
+2
-1
@@ -172,6 +172,7 @@ const pool = new ConnectionPool({
|
||||
onRequestClose: (id) => closePane(id),
|
||||
onClosed: (id) => closePane(id),
|
||||
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
|
||||
store,
|
||||
});
|
||||
|
||||
// Session death used to reach the UI on two uncoordinated channels — the row
|
||||
@@ -209,7 +210,7 @@ function scheduleSave(): void {
|
||||
|
||||
function infoFor(sessionId: string) {
|
||||
const s = store.snapshot().sessions.get(sessionId);
|
||||
return s ? { name: sessionName(s), command: s.command } : undefined;
|
||||
return s ? { name: sessionName(s), command: s.command, view: s.view } : undefined;
|
||||
}
|
||||
|
||||
/** Drive the shell's responsive layout off whether any pane is open. */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Shared shape a mounted pane connection satisfies, whatever session view is
|
||||
// behind it — what ConnectionPool needs to mount/park/dispose a pane without
|
||||
// caring whether it holds an xterm attach (attach.ts) or a structured
|
||||
// transcript (structured.ts).
|
||||
|
||||
import type { Uuid } from "./types.js";
|
||||
|
||||
export interface PaneConnection {
|
||||
readonly sessionId: Uuid;
|
||||
/** The element to mount into a pane. */
|
||||
readonly el: HTMLElement;
|
||||
/** Becomes the visible view: fit/focus/sync as the connection needs.
|
||||
* `focus: false` mounts without taking the keyboard (a sidebar preview). */
|
||||
onMount(opts?: { focus?: boolean }): void;
|
||||
/** Parked into the hidden cache: keep streaming, stop driving focus/sizing. */
|
||||
onUnmount(): void;
|
||||
/** Put the keyboard into this pane's primary input — the terminal for an
|
||||
* attach connection, the composer for a structured one. */
|
||||
focusTerminal(): void;
|
||||
/** Raw bytes into a PTY, bypassing normal key handling (the leader's
|
||||
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
|
||||
* has no PTY, so this is a no-op there. */
|
||||
sendInput(data: string): void;
|
||||
/** Fully disconnect and remove the element. */
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { paneTitleAttr, paneTitleHtml } from "./paneTitle.js";
|
||||
|
||||
describe("paneTitleHtml", () => {
|
||||
it("falls back to a short session id when there is no info yet", () => {
|
||||
expect(paneTitleHtml("abcdef12-3456-7890-abcd-ef1234567890")).toContain("abcdef12");
|
||||
});
|
||||
|
||||
it("shows the name alone when command matches it", () => {
|
||||
const html = paneTitleHtml("s1", { name: "claude", command: "claude" });
|
||||
expect(html).toContain("claude");
|
||||
expect(html).not.toContain("at-cmd");
|
||||
});
|
||||
|
||||
it("escapes a name that looks like markup", () => {
|
||||
const html = paneTitleHtml("s1", { name: "<img onerror=x>", command: "claude" });
|
||||
expect(html).not.toContain("<img");
|
||||
});
|
||||
});
|
||||
|
||||
describe("paneTitleAttr", () => {
|
||||
it("escapes the attribute form too", () => {
|
||||
const attr = paneTitleAttr("s1", { name: '"quoted"', command: "claude" });
|
||||
expect(attr).not.toContain('"quoted"');
|
||||
expect(attr).toContain(""quoted"");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Pane header title: name/command echoing the sidebar card, shared by the
|
||||
// xterm attach view (attach.ts) and the structured transcript view
|
||||
// (structured.ts) so both panes read identically.
|
||||
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import type { SessionView, Uuid } from "./types.js";
|
||||
|
||||
/** What a pane header shows about its session. Falls back to a short session
|
||||
* id when the session isn't known yet. */
|
||||
export interface AttachInfo {
|
||||
name: string;
|
||||
command: string;
|
||||
/** Which connection kind to mount (#140). Absent/`"Terminal"` mounts the
|
||||
* xterm attach; `"Structured"` mounts the transcript view. */
|
||||
view?: SessionView;
|
||||
}
|
||||
|
||||
function commandFragment(command: string): string {
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? "" : program;
|
||||
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||
}
|
||||
|
||||
export function paneTitleHtml(sessionId: Uuid, info?: AttachInfo): string {
|
||||
return info
|
||||
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
||||
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
||||
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
||||
}
|
||||
|
||||
/** Attribute-safe (`title="..."`) form of the same identity. */
|
||||
export function paneTitleAttr(sessionId: Uuid, info?: AttachInfo): string {
|
||||
return escapeHtml(info ? `${info.name} · ${info.command} (${sessionId})` : sessionId);
|
||||
}
|
||||
@@ -37,6 +37,27 @@ export function sessionFocus(id: Uuid): string {
|
||||
return `${session(id)}/focus`;
|
||||
}
|
||||
|
||||
/** `WS /api/structured/:id` — transcript/state snapshots and live deltas. */
|
||||
export function structured(id: Uuid): string {
|
||||
return `${API_PREFIX}/structured/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
|
||||
export function structuredPrompt(id: Uuid): string {
|
||||
return `${session(id)}/structured/prompt`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
||||
export function structuredCancel(id: Uuid): string {
|
||||
return `${session(id)}/structured/cancel`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission
|
||||
* request. */
|
||||
export function structuredApproval(id: Uuid, nonce: string): string {
|
||||
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
|
||||
}
|
||||
|
||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||
export function withPathQuery(route: string, value: string): string {
|
||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||
@@ -55,3 +76,7 @@ export function eventsWsUrl(): string {
|
||||
export function attachWsUrl(id: Uuid): string {
|
||||
return `${wsBase()}${attach(id)}`;
|
||||
}
|
||||
|
||||
export function structuredWsUrl(id: Uuid): string {
|
||||
return `${wsBase()}${structured(id)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
|
||||
return {
|
||||
...actual,
|
||||
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
||||
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
||||
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js";
|
||||
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
|
||||
import type { ReducedState, Session, StructuredEvent, TranscriptRow } from "./types.js";
|
||||
|
||||
/** Just enough of a WebSocket for the connection to drive (mirrors
|
||||
* events.test.ts's FakeSocket, adapted for JSON structured events). */
|
||||
class FakeSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
open(): void {
|
||||
this.onopen?.();
|
||||
}
|
||||
emit(event: StructuredEvent): void {
|
||||
this.onmessage?.({ data: JSON.stringify(event) });
|
||||
}
|
||||
drop(code = 1006, reason = ""): void {
|
||||
this.onclose?.({ code, reason });
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function row(id: string, kind: TranscriptRow["kind"], data: unknown): TranscriptRow {
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||
|
||||
const allApprovalOptions = [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
|
||||
{ optionId: "r1", name: "Reject once", kind: "reject_once" as const },
|
||||
];
|
||||
|
||||
function mount(opts: {
|
||||
sockets: FakeSocket[];
|
||||
shouldReconnect?: (id: string, code: number) => boolean;
|
||||
store?: CatalogStore;
|
||||
}) {
|
||||
let i = 0;
|
||||
const factory: StructuredSocketFactory = () => opts.sockets[i++] as unknown as WebSocket;
|
||||
const conn = createStructuredConnection(
|
||||
"s1",
|
||||
{ shouldReconnect: opts.shouldReconnect },
|
||||
{ name: "s1", command: "claude", view: "Structured" },
|
||||
opts.store,
|
||||
factory,
|
||||
);
|
||||
document.body.appendChild(conn.el);
|
||||
return conn;
|
||||
}
|
||||
|
||||
describe("structured connection: data flow", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("renders every row from the initial snapshot", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 2,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { type: "text", text: "hello" } }),
|
||||
row("agent:1", "agent_text", { content: { type: "text", text: "hi there" } }),
|
||||
],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(conn.el.textContent).toContain("hello");
|
||||
expect(conn.el.textContent).toContain("hi there");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("appends and patches rows via projection_update without duplicating them", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) },
|
||||
state: idleState,
|
||||
});
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) },
|
||||
state: idleState,
|
||||
});
|
||||
const rows = conn.el.querySelectorAll(".st-row-agent");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(conn.el.textContent).toContain("partial done");
|
||||
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: turn/composer gating", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("disables the composer while a turn is active and re-enables when idle", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(cancel.hidden).toBe(false);
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(cancel.hidden).toBe(true);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("sends a prompt on Enter, clears the draft, and restores it on failure", async () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "do the thing";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing");
|
||||
expect(input.value).toBe("");
|
||||
|
||||
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
||||
input.value = "retry me";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(input.value).toBe("retry me");
|
||||
expect(conn.el.textContent).toContain("boom");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("cancels the active turn via the cancel button", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: true, active_tool_calls: [] } } });
|
||||
(conn.el.querySelector(".structured-cancel") as HTMLButtonElement).click();
|
||||
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: approvals", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("shows decision buttons only for the currently pending approval, and answers with its nonce", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "append",
|
||||
row: row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions }),
|
||||
},
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
expect(buttons).toHaveLength(3);
|
||||
buttons[1].click(); // "Allow for session"
|
||||
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_for_session");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions })] },
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
expect(conn.el.querySelectorAll(".st-approval-acts button")).toHaveLength(3);
|
||||
// Resolved on another client: state clears with no transcript delta at all.
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
|
||||
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not act on a stale click after the approval already moved on", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
// Approval is superseded before the click is handled (buttons already
|
||||
// disabled by the click handler is not the path under test here — this
|
||||
// simulates the row having already flipped to "answered" underneath it).
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(resolveStructuredApproval).not.toHaveBeenCalled();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("restores the buttons and surfaces an error when the POST rejects while still pending", async () => {
|
||||
vi.mocked(resolveStructuredApproval).mockRejectedValueOnce(new Error("network down"));
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[0].click();
|
||||
expect(buttons[0].disabled).toBe(true);
|
||||
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
|
||||
expect(buttons[0].disabled).toBe(false);
|
||||
expect(buttons[1].disabled).toBe(false);
|
||||
expect(conn.el.textContent).toContain("network down");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not resurrect buttons or show an error for a decision the approval already moved past", async () => {
|
||||
let reject!: (e: unknown) => void;
|
||||
vi.mocked(resolveStructuredApproval).mockImplementationOnce(
|
||||
() => new Promise((_, r) => { reject = r; }),
|
||||
);
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
||||
});
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[0].click();
|
||||
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_once");
|
||||
|
||||
// Superseded by a new approval before the in-flight POST settles.
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "append", row: row("approval:2", "approval", { toolCall: {}, options: allApprovalOptions }) },
|
||||
state: { ...idleState, pending_approval: { nonce: "n2", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
||||
});
|
||||
reject(new Error("too late"));
|
||||
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
|
||||
|
||||
expect(conn.el.textContent).not.toContain("too late");
|
||||
const newButtons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
expect(newButtons).toHaveLength(3);
|
||||
expect(newButtons[0].disabled).toBe(false);
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: reconnection/error/loading", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("shows connecting, then connected, then reconnecting with backoff, and rebuilds from the fresh snapshot", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||
const state = () => (conn.el.querySelector(".attach-state") as HTMLElement).dataset.state;
|
||||
expect(state()).toBe("connecting");
|
||||
|
||||
first.open();
|
||||
expect(state()).toBe("open");
|
||||
first.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: { cursor: 0, rows: [row("user:1", "user", { content: { type: "text", text: "before" } })] },
|
||||
});
|
||||
expect(conn.el.textContent).toContain("before");
|
||||
|
||||
first.drop(1006, "");
|
||||
expect(state()).toBe("connecting"); // "reconnecting…"
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
second.open();
|
||||
expect(state()).toBe("open");
|
||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
expect(conn.el.textContent).not.toContain("before"); // rebuilt wholesale, not merged
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("reports a daemon rejection reason and does not schedule a retry", () => {
|
||||
const only = new FakeSocket();
|
||||
const conn = mount({ sockets: [only], shouldReconnect: () => true });
|
||||
only.open();
|
||||
only.drop(4004, "session is not a live structured session");
|
||||
const stateEl = conn.el.querySelector(".attach-state") as HTMLElement;
|
||||
expect(stateEl.dataset.state).toBe("error");
|
||||
expect(stateEl.textContent).toBe("session is not a live structured session");
|
||||
vi.advanceTimersByTime(60_000);
|
||||
// No second socket was ever asked for — only one factory call happened.
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: pane lifecycle", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("sendInput is a no-op — there is no PTY behind a structured session", () => {
|
||||
const conn = mount({ sockets: [new FakeSocket()] });
|
||||
expect(() => conn.sendInput("\x0b")).not.toThrow();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("carries the same off-canvas sessions button as the terminal attach header", () => {
|
||||
const conn = mount({ sockets: [new FakeSocket()] });
|
||||
const btn = conn.el.querySelector(".sessions-btn") as HTMLButtonElement;
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn.getAttribute("aria-label")).toBe("Show sessions");
|
||||
expect(btn.type).toBe("button");
|
||||
|
||||
let toggled = 0;
|
||||
const onToggle = () => toggled++;
|
||||
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
||||
btn.click();
|
||||
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
||||
expect(toggled).toBe(1);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("focusTerminal focuses the composer only when it is enabled", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
conn.focusTerminal();
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
conn.focusTerminal();
|
||||
expect(document.activeElement).toBe(input);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("dispose closes the socket and unsubscribes from the store", () => {
|
||||
const store = new CatalogStore({ socket: () => new FakeSocket() as unknown as WebSocket });
|
||||
const unsubscribe = vi.spyOn(store, "subscribe");
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws], store });
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
conn.dispose();
|
||||
expect(ws.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects live worktree/blocked-work data from the store in the rail", () => {
|
||||
const catalogWs = new FakeSocket();
|
||||
const store = new CatalogStore({ socket: () => catalogWs as unknown as WebSocket });
|
||||
store.start();
|
||||
const session: Session = {
|
||||
id: "s1",
|
||||
cwd: "/repo",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
|
||||
blocked_work: { uncommitted_files: 1, untracked_files: 0, unpushed_commits: 2 },
|
||||
};
|
||||
catalogWs.emit({ type: "Snapshot", sessions: [session] } as never);
|
||||
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws], store });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(conn.el.textContent).toContain("feat/x");
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: deferred mount focus", () => {
|
||||
let frames: FrameRequestCallback[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
frames = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
frames.push(cb);
|
||||
return frames.length;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function runFrames(): void {
|
||||
const pending = frames;
|
||||
frames = [];
|
||||
for (const cb of pending) cb(0);
|
||||
}
|
||||
|
||||
it("does not focus the composer if the pane was parked before its deferred frame ran", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
conn.onMount({ focus: true });
|
||||
conn.onUnmount(); // parked again before the frame below runs
|
||||
runFrames();
|
||||
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not focus the composer if it was re-mounted into a different pane before the stale frame ran", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
conn.onMount({ focus: true }); // stale frame #1
|
||||
conn.onUnmount();
|
||||
conn.onMount({ focus: false }); // the pane that actually won focus, without it
|
||||
runFrames(); // both frames fire; only the latest should have any say
|
||||
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("still focuses normally when nothing raced the frame", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
conn.onMount({ focus: true });
|
||||
runFrames();
|
||||
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).toBe(input);
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
// A single live structured session: a transcript + rail bound to the
|
||||
// daemon's `WS /api/structured/:id` (#140). Mirrors attach.ts's contract —
|
||||
// decoupled from the UI, streaming whether or not mounted, torn down only by
|
||||
// `dispose()` — but the wire is JSON rows, not PTY bytes, and every connect
|
||||
// (fresh or reconnect) resends the *whole* transcript rather than resuming a
|
||||
// cursor (docs/STRUCTURED-VIEW.md), so a snapshot always replaces state
|
||||
// wholesale instead of being merged into what came before.
|
||||
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
structuredWsUrl,
|
||||
} from "./api.js";
|
||||
import { requestToggleDrawer } from "./drawer.js";
|
||||
import type { CatalogState, CatalogStore } from "./events.js";
|
||||
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
||||
import type { PaneConnection } from "./pane.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
import { buildTranscriptRow, renderRail } from "./structuredRender.js";
|
||||
import type {
|
||||
AcpPlan,
|
||||
AcpUsage,
|
||||
ReducedState,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
StructuredEvent,
|
||||
TranscriptDelta,
|
||||
TranscriptRow,
|
||||
TranscriptRowKind,
|
||||
Uuid,
|
||||
} from "./types.js";
|
||||
|
||||
export interface StructuredOptions {
|
||||
onRequestClose?: (sessionId: Uuid) => void;
|
||||
onClosed?: (sessionId: Uuid) => void;
|
||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||
}
|
||||
|
||||
/** Injectable so tests can drive the socket without a daemon — the same seam
|
||||
* `CatalogStore` uses (events.ts). */
|
||||
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
|
||||
|
||||
const idleTurn = (): ReducedState["turn"] => ({
|
||||
responding: false,
|
||||
thinking: false,
|
||||
active_tool_calls: [],
|
||||
});
|
||||
|
||||
export function createStructuredConnection(
|
||||
sessionId: Uuid,
|
||||
opts: StructuredOptions = {},
|
||||
info?: AttachInfo,
|
||||
store?: CatalogStore,
|
||||
socketFactory: StructuredSocketFactory = (id) => new WebSocket(structuredWsUrl(id)),
|
||||
): PaneConnection {
|
||||
const el = document.createElement("div");
|
||||
el.className = "attach structured";
|
||||
el.innerHTML = `
|
||||
<header class="attach-header">
|
||||
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||
<span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span>
|
||||
<span class="st-pills"></span>
|
||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||
</header>
|
||||
<div class="structured-body">
|
||||
<div class="structured-scroll">
|
||||
<div class="structured-rows"></div>
|
||||
<form class="structured-composer">
|
||||
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"></textarea>
|
||||
<button type="submit" class="btn btn-primary structured-send">Send</button>
|
||||
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
|
||||
</form>
|
||||
</div>
|
||||
<aside class="structured-rail"></aside>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
|
||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
||||
const railEl = el.querySelector(".structured-rail") as HTMLElement;
|
||||
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
|
||||
const composerForm = el.querySelector(".structured-composer") as HTMLFormElement;
|
||||
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||
|
||||
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
|
||||
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
|
||||
// view): the session list is an off-canvas drawer and this opens it —
|
||||
// same control attach.ts's terminal header carries.
|
||||
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
|
||||
requestToggleDrawer(),
|
||||
);
|
||||
|
||||
const setConnState = (text: string, kind: "connecting" | "open" | "closed" | "error") => {
|
||||
stateEl.textContent = text;
|
||||
stateEl.dataset.state = kind;
|
||||
};
|
||||
|
||||
// ---- transcript state: mirrors the daemon's own Projection shape (id ->
|
||||
// index, patched in place) so a Patch never needs to search the array. ----
|
||||
let rows: TranscriptRow[] = [];
|
||||
const rowIndex = new Map<string, number>();
|
||||
const rowEls = new Map<string, HTMLElement>();
|
||||
let state: ReducedState = { turn: idleTurn() };
|
||||
let pendingApprovalRowId: string | null = null;
|
||||
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
||||
|
||||
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function approvalStateFor(row: TranscriptRow) {
|
||||
if (row.kind !== "approval") return undefined;
|
||||
return {
|
||||
pending: row.id === pendingApprovalRowId,
|
||||
onDecide: (decision: StructuredApprovalDecision) => decide(row.id, decision),
|
||||
};
|
||||
}
|
||||
|
||||
async function decide(rowId: string, decision: StructuredApprovalDecision): Promise<void> {
|
||||
const nonce = state.pending_approval?.nonce;
|
||||
// Already resolved elsewhere between the click and here — the row's
|
||||
// buttons should already be gone, but a stale nonce is a no-op, not an
|
||||
// error (docs/STRUCTURED-VIEW.md: any nonce answers 204). Resolve
|
||||
// quietly: there is nothing for the row to restore.
|
||||
if (!nonce || rowId !== pendingApprovalRowId) return;
|
||||
try {
|
||||
await resolveStructuredApproval(sessionId, nonce, decision);
|
||||
} catch (e: unknown) {
|
||||
// Only propagate the failure — so the row re-enables its buttons — if
|
||||
// this is still the pending approval. If it moved on while the
|
||||
// request was in flight (superseded, cancelled, answered elsewhere),
|
||||
// the state-driven re-render already replaced the row; reviving
|
||||
// buttons on it would be reviving a decision that no longer applies.
|
||||
if (rowId === pendingApprovalRowId) {
|
||||
showError(String(e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mountRow(row: TranscriptRow): void {
|
||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
||||
rowEls.set(row.id, el);
|
||||
rowsEl.appendChild(el);
|
||||
}
|
||||
|
||||
function remountRow(row: TranscriptRow): void {
|
||||
const old = rowEls.get(row.id);
|
||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
||||
rowEls.set(row.id, el);
|
||||
if (old) old.replaceWith(el);
|
||||
else rowsEl.appendChild(el);
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||
rows = [...snapshot.rows];
|
||||
rowIndex.clear();
|
||||
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
||||
rowEls.clear();
|
||||
rowsEl.replaceChildren();
|
||||
for (const row of rows) mountRow(row);
|
||||
}
|
||||
|
||||
function applyDelta(delta: TranscriptDelta | null | undefined): void {
|
||||
if (!delta) return;
|
||||
if (delta.type === "append") {
|
||||
rowIndex.set(delta.row.id, rows.length);
|
||||
rows.push(delta.row);
|
||||
mountRow(delta.row);
|
||||
} else if (delta.type === "patch") {
|
||||
const i = rowIndex.get(delta.row.id);
|
||||
if (i === undefined) {
|
||||
rowIndex.set(delta.row.id, rows.length);
|
||||
rows.push(delta.row);
|
||||
mountRow(delta.row);
|
||||
} else {
|
||||
rows[i] = delta.row;
|
||||
remountRow(delta.row);
|
||||
}
|
||||
} else {
|
||||
const i = rowIndex.get(delta.id);
|
||||
if (i === undefined) return;
|
||||
rows.splice(i, 1);
|
||||
rowIndex.clear();
|
||||
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
||||
rowEls.get(delta.id)?.remove();
|
||||
rowEls.delete(delta.id);
|
||||
}
|
||||
}
|
||||
|
||||
function toolRowCount(): number {
|
||||
return rows.reduce((n, r) => n + (r.kind === "tool_call" || r.kind === "tool_diff" ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
function renderPills(): void {
|
||||
const parts: string[] = [];
|
||||
if (state.model) parts.push(state.model);
|
||||
if (state.mode) parts.push(state.mode);
|
||||
pillsEl.textContent = parts.join(" · ");
|
||||
}
|
||||
|
||||
function renderRailNow(): void {
|
||||
renderRail(railEl, {
|
||||
plan: (state.plan ?? null) as AcpPlan | null,
|
||||
turn: state.turn,
|
||||
toolRowCount: toolRowCount(),
|
||||
usage: (state.usage ?? null) as AcpUsage | null,
|
||||
session: latestSession,
|
||||
});
|
||||
}
|
||||
|
||||
function renderComposerState(): void {
|
||||
const active = state.turn.responding || state.turn.thinking;
|
||||
input.disabled = active;
|
||||
sendBtn.disabled = active;
|
||||
cancelBtn.hidden = !active;
|
||||
}
|
||||
|
||||
function applyState(next: ReducedState): void {
|
||||
state = next;
|
||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||
if (newPendingId !== pendingApprovalRowId) {
|
||||
const prev = pendingApprovalRowId;
|
||||
pendingApprovalRowId = newPendingId;
|
||||
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
|
||||
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
|
||||
}
|
||||
renderPills();
|
||||
renderRailNow();
|
||||
renderComposerState();
|
||||
}
|
||||
|
||||
// ---- error banner ----
|
||||
let errorEl: HTMLElement | null = null;
|
||||
function showError(message: string): void {
|
||||
if (!errorEl) {
|
||||
errorEl = document.createElement("div");
|
||||
errorEl.className = "structured-error";
|
||||
composerForm.insertAdjacentElement("beforebegin", errorEl);
|
||||
}
|
||||
errorEl.textContent = message;
|
||||
}
|
||||
function clearError(): void {
|
||||
errorEl?.remove();
|
||||
errorEl = null;
|
||||
}
|
||||
|
||||
// ---- composer ----
|
||||
function autoResize(): void {
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||
}
|
||||
input.addEventListener("input", autoResize);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
composerForm.requestSubmit();
|
||||
}
|
||||
});
|
||||
composerForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value;
|
||||
if (!text.trim() || input.disabled) return;
|
||||
input.value = "";
|
||||
autoResize();
|
||||
clearError();
|
||||
sendStructuredPrompt(sessionId, text).catch((e: unknown) => {
|
||||
input.value = text;
|
||||
autoResize();
|
||||
showError(String(e));
|
||||
});
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
clearError();
|
||||
cancelStructuredTurn(sessionId).catch((e: unknown) => showError(String(e)));
|
||||
});
|
||||
|
||||
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
|
||||
const NARROW_BREAKPOINT = 560;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
|
||||
bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT);
|
||||
});
|
||||
resizeObserver.observe(bodyEl);
|
||||
|
||||
// ---- live worktree/git-status rail data ----
|
||||
const unsubscribeStore = store?.subscribe((catalog: CatalogState) => {
|
||||
latestSession = catalog.sessions.get(sessionId);
|
||||
renderRailNow();
|
||||
});
|
||||
|
||||
// ---- socket lifecycle ----
|
||||
let ws: WebSocket;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
let disposed = false;
|
||||
|
||||
function connect(): void {
|
||||
ws = socketFactory(sessionId);
|
||||
ws.onopen = () => {
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setConnState("connected", "open");
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
if (typeof ev.data !== "string") return;
|
||||
let msg: StructuredEvent;
|
||||
try {
|
||||
msg = JSON.parse(ev.data) as StructuredEvent;
|
||||
} catch {
|
||||
return; // not a structured event we understand
|
||||
}
|
||||
switch (msg.type) {
|
||||
case "transcript_snapshot":
|
||||
applySnapshot(msg.snapshot);
|
||||
break;
|
||||
case "reduced_state":
|
||||
applyState(msg.state);
|
||||
break;
|
||||
case "projection_update":
|
||||
applyDelta(msg.transcript);
|
||||
applyState(msg.state);
|
||||
break;
|
||||
}
|
||||
};
|
||||
ws.onerror = () => setConnState("error", "error");
|
||||
ws.onclose = (ev) => {
|
||||
const reason = ev.reason?.trim();
|
||||
if (ev.code >= 4000 && reason && !disposed) {
|
||||
setConnState(reason, "error");
|
||||
showError(`cannot attach: ${reason}`);
|
||||
return;
|
||||
}
|
||||
if (disposed) return;
|
||||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setConnState("reconnecting…", "connecting");
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
setConnState(`closed (${ev.code})`, "closed");
|
||||
opts.onClosed?.(sessionId);
|
||||
};
|
||||
}
|
||||
connect();
|
||||
|
||||
// A pane can be parked again before its deferred focus frame fires (a
|
||||
// preview stepped past it, a split closed it) — the frame must not focus a
|
||||
// hidden textarea out from under whatever the operator moved to since.
|
||||
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
|
||||
// it is no longer the most recent one.
|
||||
let mountToken = 0;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
el,
|
||||
onMount(mountOpts?: { focus?: boolean }) {
|
||||
const focusOnMount = mountOpts?.focus !== false;
|
||||
const token = ++mountToken;
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== mountToken) return; // parked/remounted since this was scheduled
|
||||
if (focusOnMount && !input.disabled) input.focus();
|
||||
});
|
||||
},
|
||||
onUnmount() {
|
||||
mountToken++;
|
||||
},
|
||||
focusTerminal() {
|
||||
if (!input.disabled) input.focus();
|
||||
},
|
||||
sendInput() {
|
||||
// No PTY behind a structured session — the leader's literal-byte
|
||||
// escape hatch has nothing to do here.
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
||||
resizeObserver.disconnect();
|
||||
unsubscribeStore?.();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeDiffLines, diffStats, type DiffLine } from "./structuredDiff.js";
|
||||
|
||||
function texts(lines: DiffLine[]): Array<[string, string]> {
|
||||
return lines.map((l) => [l.kind, l.text]);
|
||||
}
|
||||
|
||||
describe("computeDiffLines", () => {
|
||||
it("renders a brand new file (no oldText) as all additions", () => {
|
||||
const lines = computeDiffLines(null, "a\nb\n");
|
||||
expect(texts(lines)).toEqual([
|
||||
["add", "a"],
|
||||
["add", "b"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a full deletion as all removals", () => {
|
||||
const lines = computeDiffLines("a\nb\n", "");
|
||||
expect(texts(lines)).toEqual([
|
||||
["del", "a"],
|
||||
["del", "b"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows a small replace as context around del/add", () => {
|
||||
const lines = computeDiffLines("one\ntwo\nthree\n", "one\ntwx\nthree\n");
|
||||
expect(texts(lines)).toEqual([
|
||||
["ctx", "one"],
|
||||
["del", "two"],
|
||||
["add", "twx"],
|
||||
["ctx", "three"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves identical text as pure context, uncollapsed when short", () => {
|
||||
const lines = computeDiffLines("a\nb\nc\n", "a\nb\nc\n");
|
||||
expect(texts(lines)).toEqual([
|
||||
["ctx", "a"],
|
||||
["ctx", "b"],
|
||||
["ctx", "c"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
|
||||
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
||||
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
||||
const newText = [...common.slice(0, 10), "NEW", ...common.slice(10)].join("\n");
|
||||
const lines = computeDiffLines(oldText, newText);
|
||||
// The leading run keeps only its last 3 lines (adjacent to the change),
|
||||
// behind a marker for the rest; the trailing run mirrors that.
|
||||
expect(lines[0].kind).toBe("hunk");
|
||||
expect(lines[1]).toEqual({ kind: "ctx", text: "line7" });
|
||||
expect(lines[2]).toEqual({ kind: "ctx", text: "line8" });
|
||||
expect(lines[3]).toEqual({ kind: "ctx", text: "line9" });
|
||||
expect(lines[4].kind).toBe("del");
|
||||
expect(lines[5].kind).toBe("add");
|
||||
expect(lines[6]).toEqual({ kind: "ctx", text: "line10" });
|
||||
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("produces one hunk for the long middle run, none for the short edges", () => {
|
||||
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
|
||||
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
|
||||
const middle = Array.from({ length: 10 }, (_, i) => `c${i}`);
|
||||
const oldText = ["lead0", "lead1", "OLD1", ...middle, "OLD2", "trail0", "trail1"].join("\n");
|
||||
const newText = ["lead0", "lead1", "NEW1", ...middle, "NEW2", "trail0", "trail1"].join("\n");
|
||||
const lines = computeDiffLines(oldText, newText);
|
||||
const dels = lines.filter((l) => l.kind === "del").map((l) => l.text);
|
||||
const adds = lines.filter((l) => l.kind === "add").map((l) => l.text);
|
||||
expect(dels).toEqual(["OLD1", "OLD2"]);
|
||||
expect(adds).toEqual(["NEW1", "NEW2"]);
|
||||
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(1);
|
||||
expect(texts(lines).slice(0, 2)).toEqual([
|
||||
["ctx", "lead0"],
|
||||
["ctx", "lead1"],
|
||||
]);
|
||||
expect(texts(lines).slice(-2)).toEqual([
|
||||
["ctx", "trail0"],
|
||||
["ctx", "trail1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an old file with no trailing newline", () => {
|
||||
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
||||
expect(texts(lines)).toEqual([
|
||||
["ctx", "a"],
|
||||
["ctx", "b"],
|
||||
["add", "c"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffStats", () => {
|
||||
it("counts add/del lines, ignoring context and hunk markers", () => {
|
||||
const lines = computeDiffLines("a\nb\nc\n", "a\nx\ny\nc\n");
|
||||
expect(diffStats(lines)).toEqual({ added: 2, removed: 1 });
|
||||
});
|
||||
|
||||
it("is zero for identical text", () => {
|
||||
expect(diffStats(computeDiffLines("same\n", "same\n"))).toEqual({ added: 0, removed: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// Line-level diff for a `ToolDiff` row's `Diff` content block (#140). ACP
|
||||
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
||||
// coloured hunks the design calls for have to be computed here.
|
||||
|
||||
export type DiffLineKind = "add" | "del" | "ctx" | "hunk";
|
||||
|
||||
export interface DiffLine {
|
||||
kind: DiffLineKind;
|
||||
/** The line's text (a `hunk` line is a human summary, not source text). */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Interior unchanged runs longer than this collapse to a summary line
|
||||
* flanked by this many lines of real context on each side. */
|
||||
const CONTEXT = 3;
|
||||
|
||||
/** Bounds the O(n·m) LCS table: above this cell count, the changed middle is
|
||||
* shown as one coarse del-then-add block instead of a fine-grained diff —
|
||||
* still correct, just not minimal. */
|
||||
const LCS_CELL_LIMIT = 250_000;
|
||||
|
||||
function splitLines(text: string): string[] {
|
||||
if (text === "") return [];
|
||||
const body = text.endsWith("\n") ? text.slice(0, -1) : text;
|
||||
return body.split("\n");
|
||||
}
|
||||
|
||||
type Op = { kind: "add" | "del" | "ctx"; text: string };
|
||||
|
||||
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
|
||||
* shared prefix/suffix by the caller. */
|
||||
function lcsDiff(a: string[], b: string[]): Op[] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (n === 0) return b.map((text) => ({ kind: "add", text }));
|
||||
if (m === 0) return a.map((text) => ({ kind: "del", text }));
|
||||
if (n * m > LCS_CELL_LIMIT) {
|
||||
return [...a.map((text) => ({ kind: "del" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))];
|
||||
}
|
||||
// dp[i][j] = length of the LCS of a[i:] and b[j:].
|
||||
const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] =
|
||||
a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const ops: Op[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
ops.push({ kind: "ctx", text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ kind: "del", text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
ops.push({ kind: "add", text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) ops.push({ kind: "del", text: a[i++] });
|
||||
while (j < m) ops.push({ kind: "add", text: b[j++] });
|
||||
return ops;
|
||||
}
|
||||
|
||||
function diffLines(a: string[], b: string[]): Op[] {
|
||||
let pre = 0;
|
||||
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
|
||||
let suf = 0;
|
||||
while (
|
||||
suf < a.length - pre &&
|
||||
suf < b.length - pre &&
|
||||
a[a.length - 1 - suf] === b[b.length - 1 - suf]
|
||||
) {
|
||||
suf++;
|
||||
}
|
||||
const prefixCtx: Op[] = a.slice(0, pre).map((text) => ({ kind: "ctx", text }));
|
||||
const suffixCtx: Op[] = a.slice(a.length - suf).map((text) => ({ kind: "ctx", text }));
|
||||
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
||||
return [...prefixCtx, ...middle, ...suffixCtx];
|
||||
}
|
||||
|
||||
/** Collapse a long run of unchanged lines to a summary, keeping up to
|
||||
* {@link CONTEXT} real lines adjacent to each neighbouring change — same
|
||||
* shape a unified diff gives a reader, without claiming line numbers this
|
||||
* module never tracked. A run touching the start/end of the whole diff has
|
||||
* no change to be adjacent to on that side, so it keeps no context there
|
||||
* (unless it is the *only* run, i.e. nothing changed at all). */
|
||||
function hunk(ops: Op[]): DiffLine[] {
|
||||
const out: DiffLine[] = [];
|
||||
let i = 0;
|
||||
while (i < ops.length) {
|
||||
if (ops[i].kind !== "ctx") {
|
||||
out.push(ops[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < ops.length && ops[j].kind === "ctx") j++;
|
||||
const run = ops.slice(i, j);
|
||||
const wholeDiff = i === 0 && j === ops.length;
|
||||
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
||||
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
||||
if (run.length <= keepHead + keepTail) {
|
||||
out.push(...run);
|
||||
} else {
|
||||
if (keepHead) out.push(...run.slice(0, keepHead));
|
||||
out.push({
|
||||
kind: "hunk",
|
||||
text: `⋯ ${run.length - keepHead - keepTail} unchanged lines ⋯`,
|
||||
});
|
||||
if (keepTail) out.push(...run.slice(run.length - keepTail));
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Coloured, hunked diff lines for a `Diff` content block. `oldText` absent
|
||||
* (a new file) renders every line of `newText` as added. */
|
||||
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
||||
const newLines = splitLines(newText);
|
||||
if (oldText == null) return newLines.map((text) => ({ kind: "add", text }));
|
||||
const ops = diffLines(splitLines(oldText), newLines);
|
||||
return hunk(ops);
|
||||
}
|
||||
|
||||
/** `+added -removed` counts straight off the diff — real derived data, not a
|
||||
* fabricated duration or line count. */
|
||||
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const line of lines) {
|
||||
if (line.kind === "add") added++;
|
||||
else if (line.kind === "del") removed++;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
branchFromWorktreePath,
|
||||
buildTranscriptRow,
|
||||
renderInlineMarkdown,
|
||||
renderRail,
|
||||
} from "./structuredRender.js";
|
||||
import type { Session, TranscriptRow } from "./types.js";
|
||||
|
||||
function row(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow {
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
describe("buildTranscriptRow: user/agent/thought", () => {
|
||||
it("renders a user row with the text content and no markdown", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("user", { content: { type: "text", text: "hi <b>there</b>" } }),
|
||||
);
|
||||
expect(el.className).toContain("st-row-user");
|
||||
expect(el.textContent).toContain("hi <b>there</b>");
|
||||
// never actual markup from user input
|
||||
expect(el.querySelector("b")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders agent prose as paragraphs with inline markdown", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("agent_text", { content: { type: "text", text: "one **two** `three`\n\nfour" } }),
|
||||
);
|
||||
expect(el.className).toContain("st-row-agent");
|
||||
const ps = el.querySelectorAll("p");
|
||||
expect(ps).toHaveLength(2);
|
||||
expect(ps[0].innerHTML).toContain("<strong>two</strong>");
|
||||
expect(ps[0].innerHTML).toContain("<code>three</code>");
|
||||
});
|
||||
|
||||
it("renders a thought row as plain text", () => {
|
||||
const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } }));
|
||||
expect(el.className).toContain("st-row-thought");
|
||||
expect(el.textContent).toBe("hmm");
|
||||
});
|
||||
|
||||
it("falls back to a labelled placeholder for non-text content, never crashing", () => {
|
||||
const el = buildTranscriptRow(row("user", { content: { type: "image" } }));
|
||||
expect(el.textContent).toContain("[image]");
|
||||
});
|
||||
|
||||
it("never clips text: a very long user message is not truncated", () => {
|
||||
const long = "x".repeat(5000);
|
||||
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
|
||||
expect(el.textContent).toContain(long);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderInlineMarkdown", () => {
|
||||
it("escapes raw HTML before applying markdown", () => {
|
||||
expect(renderInlineMarkdown("<script>alert(1)</script>")).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("does not treat a single asterisk pair meant as bold as nested italics", () => {
|
||||
expect(renderInlineMarkdown("**bold**")).toBe("<strong>bold</strong>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: tool calls", () => {
|
||||
it("shows glyph, name, argument and no meta for a completed non-diff call", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_call", {
|
||||
toolCallId: "t1",
|
||||
title: "Read(src/lib.rs)",
|
||||
kind: "read",
|
||||
status: "completed",
|
||||
}),
|
||||
);
|
||||
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||||
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Read");
|
||||
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("src/lib.rs");
|
||||
expect(head.querySelector(".st-tool-meta")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the kind label and full title when the title has no Name(arg) shape", () => {
|
||||
const el = buildTranscriptRow(row("tool_call", { title: "does something odd", kind: "execute" }));
|
||||
const head = el.querySelector(".st-tool-head")!;
|
||||
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Execute");
|
||||
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("does something odd");
|
||||
});
|
||||
|
||||
it("shows a running badge for an in-progress call", () => {
|
||||
const el = buildTranscriptRow(row("tool_call", { title: "Bash(sleep 1)", status: "in_progress" }));
|
||||
const meta = el.querySelector(".st-tool-meta")!;
|
||||
expect(meta.textContent).toBe("running");
|
||||
expect(meta.className).toContain("st-tool-meta-running");
|
||||
});
|
||||
|
||||
it("shows a failed badge for a failed call", () => {
|
||||
const el = buildTranscriptRow(row("tool_call", { title: "Bash(false)", status: "failed" }));
|
||||
expect(el.querySelector(".st-tool-meta")!.textContent).toBe("failed");
|
||||
});
|
||||
|
||||
it("starts collapsed and expands its text output on click", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_call", {
|
||||
title: "Grep(x)",
|
||||
status: "completed",
|
||||
content: [{ type: "content", content: { type: "text", text: "match found" } }],
|
||||
}),
|
||||
);
|
||||
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||||
const out = el.querySelector(".st-tool-out") as HTMLElement;
|
||||
expect(out.hidden).toBe(true);
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
head.click();
|
||||
expect(out.hidden).toBe(false);
|
||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(out.textContent).toContain("match found");
|
||||
head.click();
|
||||
expect(out.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expand when there is nothing to show", () => {
|
||||
const el = buildTranscriptRow(row("tool_call", { title: "Read(x)", status: "completed" }));
|
||||
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||||
head.click();
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: tool diffs", () => {
|
||||
it("shows +added -removed derived from the diff, and renders coloured hunks on expand", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_diff", {
|
||||
title: "Edit(x.rs)",
|
||||
status: "completed",
|
||||
content: [{ type: "diff", path: "/repo/x.rs", oldText: "a\nb\n", newText: "a\nc\n" }],
|
||||
}),
|
||||
);
|
||||
const meta = el.querySelector(".st-tool-meta")!;
|
||||
expect(meta.textContent).toContain("+1");
|
||||
expect(meta.textContent).toContain("−1");
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
const diff = el.querySelector(".st-diff") as HTMLElement;
|
||||
expect(diff).not.toBeNull();
|
||||
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
|
||||
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
|
||||
});
|
||||
|
||||
it("gives the diff its own horizontally-scrolling container", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_diff", {
|
||||
title: "Edit(x.rs)",
|
||||
content: [{ type: "diff", path: "/repo/x.rs", newText: "new file\n" }],
|
||||
}),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the
|
||||
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
||||
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: approval", () => {
|
||||
const approvalRow = (options: Array<{ optionId: string; name: string; kind: string }>) =>
|
||||
row("approval", {
|
||||
sessionId: "s1",
|
||||
toolCall: { toolCallId: "t1", title: "Write outside worktree", locations: [{ path: "/etc/x" }] },
|
||||
options,
|
||||
});
|
||||
|
||||
const allThreeOptions = [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
|
||||
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
|
||||
];
|
||||
|
||||
it("shows the three decision buttons only when the request offers all three option kinds", () => {
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||||
pending: true,
|
||||
onDecide: () => Promise.resolve(),
|
||||
});
|
||||
const buttons = el.querySelectorAll(".st-approval-acts button");
|
||||
expect(buttons).toHaveLength(3);
|
||||
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
||||
});
|
||||
|
||||
it("hides the buttons and shows an answered note once resolved", () => {
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||||
pending: false,
|
||||
onDecide: () => Promise.resolve(),
|
||||
});
|
||||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||||
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
|
||||
});
|
||||
|
||||
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
||||
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[2].click();
|
||||
expect(onDecide).toHaveBeenCalledWith("deny");
|
||||
expect(buttons[0].disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a row with no approval state as not pending, defensively", () => {
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions));
|
||||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||||
});
|
||||
|
||||
it("omits Allow for session when the request offers no allow_always option", () => {
|
||||
const el = buildTranscriptRow(
|
||||
approvalRow([
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
|
||||
]),
|
||||
{ pending: true, onDecide: () => Promise.resolve() },
|
||||
);
|
||||
const labels = [...el.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent);
|
||||
expect(labels).toEqual(["Allow once", "Deny"]);
|
||||
});
|
||||
|
||||
it("offers Deny for either reject_once or reject_always, never for neither", () => {
|
||||
const rejectAlwaysOnly = buildTranscriptRow(
|
||||
approvalRow([{ optionId: "r2", name: "Reject always", kind: "reject_always" }]),
|
||||
{ pending: true, onDecide: () => Promise.resolve() },
|
||||
);
|
||||
expect([...rejectAlwaysOnly.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent)).toEqual([
|
||||
"Deny",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows a 'no decision offered' note instead of an empty action row when nothing matches", () => {
|
||||
const el = buildTranscriptRow(approvalRow([{ optionId: "x", name: "Something else", kind: "allow_once" }]), {
|
||||
pending: true,
|
||||
onDecide: () => Promise.resolve(),
|
||||
});
|
||||
// allow_once is offered, so this specific case still shows a button —
|
||||
// use a kind the client doesn't map to any decision at all.
|
||||
expect(el.querySelectorAll(".st-approval-acts button")).toHaveLength(1);
|
||||
|
||||
const none = buildTranscriptRow(approvalRow([]), { pending: true, onDecide: () => Promise.resolve() });
|
||||
expect(none.querySelector(".st-approval-acts")).toBeNull();
|
||||
expect(none.querySelector(".st-approval-resolved")!.textContent).toBe("no decision offered");
|
||||
});
|
||||
|
||||
it("re-enables the buttons when onDecide rejects", async () => {
|
||||
const onDecide = vi.fn().mockRejectedValue(new Error("network down"));
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[0].click();
|
||||
expect(buttons[0].disabled).toBe(true);
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
expect(buttons[0].disabled).toBe(false);
|
||||
expect(buttons[1].disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the buttons disabled when onDecide resolves", async () => {
|
||||
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||||
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||||
buttons[0].click();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
expect(buttons[0].disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||||
it("renders a plan or notice row without crashing", () => {
|
||||
expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow();
|
||||
expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("branchFromWorktreePath", () => {
|
||||
it("takes everything after the last /worktrees/ segment", () => {
|
||||
expect(branchFromWorktreePath("/home/n/tea/maestro/worktrees/acp-bridge")).toBe("acp-bridge");
|
||||
});
|
||||
|
||||
it("keeps slashes in a branch name that contains them", () => {
|
||||
expect(branchFromWorktreePath("/repo/worktrees/feature/login")).toBe("feature/login");
|
||||
});
|
||||
|
||||
it("falls back to the basename when there is no /worktrees/ marker", () => {
|
||||
expect(branchFromWorktreePath("/some/other/path")).toBe("path");
|
||||
});
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
cwd: "/repo",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderRail", () => {
|
||||
it("omits every section that has nothing to show", () => {
|
||||
const el = document.createElement("div");
|
||||
renderRail(el, {
|
||||
plan: null,
|
||||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||
toolRowCount: 0,
|
||||
usage: null,
|
||||
session: session(),
|
||||
});
|
||||
// Turn always renders (it has a state and a count); the rest are absent.
|
||||
expect(el.querySelectorAll("h3")).toHaveLength(1);
|
||||
expect(el.querySelector("h3")!.textContent).toBe("Turn");
|
||||
});
|
||||
|
||||
it("renders the plan with status marks", () => {
|
||||
const el = document.createElement("div");
|
||||
renderRail(el, {
|
||||
plan: {
|
||||
entries: [
|
||||
{ content: "done thing", priority: "high", status: "completed" },
|
||||
{ content: "now thing", priority: "high", status: "in_progress" },
|
||||
{ content: "later thing", priority: "low", status: "pending" },
|
||||
],
|
||||
},
|
||||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||
toolRowCount: 0,
|
||||
usage: null,
|
||||
session: session(),
|
||||
});
|
||||
const items = el.querySelectorAll(".st-plan li");
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].className).toBe("st-plan-done");
|
||||
expect(items[1].className).toBe("st-plan-now");
|
||||
expect(items[2].className).toBe("st-plan-todo");
|
||||
});
|
||||
|
||||
it("renders context usage and a meter proportional to used/size", () => {
|
||||
const el = document.createElement("div");
|
||||
renderRail(el, {
|
||||
plan: null,
|
||||
turn: { responding: true, thinking: false, active_tool_calls: ["a"] },
|
||||
toolRowCount: 3,
|
||||
usage: { used: 50_000, size: 200_000 },
|
||||
session: session(),
|
||||
});
|
||||
expect(el.querySelector(".st-meter i")!.getAttribute("style")).toContain("25%");
|
||||
});
|
||||
|
||||
it("renders the worktree section only when the session has one", () => {
|
||||
const el = document.createElement("div");
|
||||
renderRail(el, {
|
||||
plan: null,
|
||||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||
toolRowCount: 0,
|
||||
usage: null,
|
||||
session: session({
|
||||
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
|
||||
blocked_work: { uncommitted_files: 2, untracked_files: 1, unpushed_commits: 3 },
|
||||
}),
|
||||
});
|
||||
const headers = [...el.querySelectorAll("h3")].map((h) => h.textContent);
|
||||
expect(headers).toContain("Worktree");
|
||||
const rows = [...el.querySelectorAll(".st-kv-row")].map((r) => r.textContent);
|
||||
expect(rows.some((t) => t?.includes("feat/x"))).toBe(true);
|
||||
expect(rows.some((t) => t?.includes("3"))).toBe(true); // ahead
|
||||
expect(rows.some((t) => t?.includes("dirty") && t.includes("3"))).toBe(true); // 2+1
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
// Pure per-row and rail DOM builders for the structured transcript (#140).
|
||||
// No socket, no daemon calls — structured.ts owns the connection and calls
|
||||
// into here with data already in hand, so every rendering decision here is
|
||||
// unit-testable without a WebSocket.
|
||||
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { computeDiffLines, diffStats } from "./structuredDiff.js";
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
AcpPlan,
|
||||
AcpToolCallContent,
|
||||
AcpToolCallLike,
|
||||
AcpUsage,
|
||||
InTheWay,
|
||||
Session,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// row taxonomy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Glyph shown in a tool row's head, by `ToolKind`. Unrecognised/absent kinds
|
||||
* fall back to the generic bullet — never a blank glyph column. */
|
||||
const KIND_GLYPH: Record<string, string> = {
|
||||
read: "≡",
|
||||
edit: "±",
|
||||
delete: "✕",
|
||||
move: "→",
|
||||
search: "⌕",
|
||||
execute: "$",
|
||||
think: "…",
|
||||
fetch: "⇣",
|
||||
switch_mode: "⇄",
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
read: "Read",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
move: "Move",
|
||||
search: "Search",
|
||||
execute: "Execute",
|
||||
think: "Think",
|
||||
fetch: "Fetch",
|
||||
switch_mode: "Switch mode",
|
||||
};
|
||||
|
||||
function toolGlyph(kind: string | undefined): string {
|
||||
return (kind && KIND_GLYPH[kind]) || "•";
|
||||
}
|
||||
|
||||
function toolKindLabel(kind: string | undefined): string {
|
||||
return (kind && KIND_LABEL[kind]) || "Tool";
|
||||
}
|
||||
|
||||
/** Split a title into `{label, arg}` when it follows the adapter's own
|
||||
* `Name(argument)` convention; otherwise the whole title is the arg and the
|
||||
* tool's `kind` supplies a generic label. Never guesses at a shape the data
|
||||
* doesn't actually show. */
|
||||
function splitToolTitle(
|
||||
title: string | undefined,
|
||||
name: string | null | undefined,
|
||||
kind: string | undefined,
|
||||
): { label: string; arg: string } {
|
||||
const label = name ?? toolKindLabel(kind);
|
||||
if (!title) return { label, arg: "" };
|
||||
const m = /^(\S+)\(([\s\S]*)\)$/.exec(title);
|
||||
if (m) return { label: name ?? m[1], arg: m[2] };
|
||||
return { label, arg: title };
|
||||
}
|
||||
|
||||
function el(tag: string, className?: string): HTMLElement {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Content-block text, or a labelled placeholder for anything that isn't
|
||||
* text — never dropped silently, never fabricated. */
|
||||
function contentBlockText(block: AcpContentBlock | undefined): string {
|
||||
if (!block) return "";
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return block.text;
|
||||
case "image":
|
||||
return "[image]";
|
||||
case "audio":
|
||||
return "[audio]";
|
||||
case "resource_link":
|
||||
return `[resource: ${block.name ?? block.uri ?? "linked"}]`;
|
||||
case "resource":
|
||||
return "[resource]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** A tiny, dependency-free inline renderer: `**bold**`, `*italic*`, and
|
||||
* `` `code` `` become real markup; everything else stays escaped plain
|
||||
* text. No block-level markdown (lists, headings) — agent prose is
|
||||
* paragraphs, not documents. */
|
||||
export function renderInlineMarkdown(text: string): string {
|
||||
const escaped = escapeHtml(text);
|
||||
return escaped
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
|
||||
}
|
||||
|
||||
function renderProse(container: HTMLElement, text: string): void {
|
||||
const paragraphs = text.split(/\n{2,}/);
|
||||
for (const para of paragraphs) {
|
||||
if (!para) continue;
|
||||
const p = document.createElement("p");
|
||||
p.innerHTML = renderInlineMarkdown(para);
|
||||
container.appendChild(p);
|
||||
}
|
||||
if (container.children.length === 0) container.textContent = "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// row builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement {
|
||||
const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined;
|
||||
const text = contentBlockText(data?.content);
|
||||
const wrap = el("div", `st-row st-row-${variant}`);
|
||||
const body = el("div", "st-body");
|
||||
if (variant === "user") {
|
||||
const who = el("div", "st-who");
|
||||
who.textContent = "You";
|
||||
body.appendChild(who);
|
||||
const line = el("div", "st-user-text");
|
||||
line.textContent = text;
|
||||
body.appendChild(line);
|
||||
} else if (variant === "agent") {
|
||||
renderProse(body, text);
|
||||
} else {
|
||||
body.textContent = text;
|
||||
}
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildDiffBlock(diff: { path: string; oldText?: string | null; newText: string }): HTMLElement {
|
||||
const wrap = el("div", "st-diff-block");
|
||||
const label = el("div", "st-diff-path");
|
||||
label.textContent = diff.path;
|
||||
label.title = diff.path;
|
||||
wrap.appendChild(label);
|
||||
const scroller = el("div", "st-diff");
|
||||
const pre = document.createElement("pre");
|
||||
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
|
||||
const span = document.createElement("span");
|
||||
span.className = `st-ln st-ln-${line.kind}`;
|
||||
span.textContent = line.text;
|
||||
pre.appendChild(span);
|
||||
pre.appendChild(document.createTextNode("\n"));
|
||||
}
|
||||
scroller.appendChild(pre);
|
||||
wrap.appendChild(scroller);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** `+added -removed` across every diff block in a tool call's content —
|
||||
* real derived data, never a fabricated duration. */
|
||||
function toolDiffMeta(content: AcpToolCallContent[]): string {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const block of content) {
|
||||
if (block.type !== "diff") continue;
|
||||
const stats = diffStats(computeDiffLines(block.oldText, block.newText));
|
||||
added += stats.added;
|
||||
removed += stats.removed;
|
||||
}
|
||||
return `+${added} −${removed}`;
|
||||
}
|
||||
|
||||
function statusMeta(status: string | undefined, hasDiff: boolean): { text: string; kind: string } | null {
|
||||
if (status === "in_progress") return { text: "running", kind: "running" };
|
||||
if (status === "failed") return { text: "failed", kind: "failed" };
|
||||
if (status === "pending" && !hasDiff) return { text: "pending", kind: "pending" };
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildToolRow(row: TranscriptRow): HTMLElement {
|
||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||
const content = tool.content ?? [];
|
||||
const hasDiff = content.some((c) => c.type === "diff");
|
||||
const { label, arg } = splitToolTitle(tool.title, tool.name, tool.kind);
|
||||
|
||||
const wrap = el("div", "st-row st-row-tool");
|
||||
const card = el("div", "st-tool");
|
||||
wrap.appendChild(card);
|
||||
|
||||
const head = document.createElement("button");
|
||||
head.type = "button";
|
||||
head.className = "st-tool-head";
|
||||
head.setAttribute("aria-expanded", "false");
|
||||
|
||||
const glyph = el("span", "st-tool-glyph");
|
||||
glyph.textContent = toolGlyph(tool.kind);
|
||||
glyph.setAttribute("aria-hidden", "true");
|
||||
const name = el("span", "st-tool-name");
|
||||
name.textContent = label;
|
||||
const argEl = el("span", "st-tool-arg");
|
||||
argEl.textContent = arg;
|
||||
argEl.title = arg;
|
||||
head.append(glyph, name, argEl);
|
||||
|
||||
const metaParts: string[] = [];
|
||||
if (hasDiff) metaParts.push(toolDiffMeta(content));
|
||||
const meta = statusMeta(tool.status, hasDiff);
|
||||
if (meta) metaParts.push(meta.text);
|
||||
if (metaParts.length > 0) {
|
||||
const metaEl = el("span", "st-tool-meta");
|
||||
if (meta) metaEl.classList.add(`st-tool-meta-${meta.kind}`);
|
||||
metaEl.textContent = metaParts.join(" · ");
|
||||
head.appendChild(metaEl);
|
||||
}
|
||||
card.appendChild(head);
|
||||
|
||||
const out = el("div", "st-tool-out");
|
||||
out.hidden = true;
|
||||
let outBuilt = false;
|
||||
const buildOut = () => {
|
||||
if (outBuilt) return;
|
||||
outBuilt = true;
|
||||
const texts = content
|
||||
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
|
||||
.map((c) => contentBlockText(c.content))
|
||||
.filter(Boolean);
|
||||
if (texts.length > 0) {
|
||||
const textPanel = el("div", "st-tool-text");
|
||||
textPanel.textContent = texts.join("\n");
|
||||
out.appendChild(textPanel);
|
||||
}
|
||||
for (const block of content) {
|
||||
if (block.type === "diff") out.appendChild(buildDiffBlock(block));
|
||||
if (block.type === "terminal") {
|
||||
const note = el("div", "st-tool-text");
|
||||
note.textContent = "[embedded terminal]";
|
||||
out.appendChild(note);
|
||||
}
|
||||
}
|
||||
if (out.children.length === 0) out.hidden = true;
|
||||
};
|
||||
card.appendChild(out);
|
||||
|
||||
head.addEventListener("click", () => {
|
||||
const expanded = head.getAttribute("aria-expanded") === "true";
|
||||
if (!expanded) buildOut();
|
||||
if (out.children.length === 0) return; // nothing to show — stay collapsed
|
||||
head.setAttribute("aria-expanded", String(!expanded));
|
||||
out.hidden = expanded;
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildPlainRow(row: TranscriptRow): HTMLElement {
|
||||
const wrap = el("div", "st-row st-row-notice");
|
||||
const body = el("div", "st-body");
|
||||
body.textContent = `[${row.kind}]`;
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export interface ApprovalRowState {
|
||||
pending: boolean;
|
||||
/** Resolves once the decision has been submitted; rejects when the POST
|
||||
* failed and this approval is still the one awaiting a decision, so the
|
||||
* caller knows to restore its controls. Never rejects for a stale click
|
||||
* (the approval already moved on) — there is nothing to restore then. */
|
||||
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Which decisions a permission request actually backs, by the
|
||||
* `PermissionOptionKind` values it offers (docs/STRUCTURED-VIEW.md's
|
||||
* "Decision mapping"). A decision with no matching option resolves
|
||||
* `Cancelled` server-side regardless of what the client sent — offering its
|
||||
* button would let the operator "allow" something that silently no-ops. */
|
||||
function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDecision[] {
|
||||
const kinds = new Set(options.map((o) => o.kind));
|
||||
const decisions: StructuredApprovalDecision[] = [];
|
||||
if (kinds.has("allow_once")) decisions.push("allow_once");
|
||||
if (kinds.has("allow_always")) decisions.push("allow_for_session");
|
||||
if (kinds.has("reject_once") || kinds.has("reject_always")) decisions.push("deny");
|
||||
return decisions;
|
||||
}
|
||||
|
||||
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
|
||||
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||
const toolCall = request.toolCall ?? {};
|
||||
const wrap = el("div", "st-row st-row-approval");
|
||||
const card = el("div", "st-approval");
|
||||
wrap.appendChild(card);
|
||||
|
||||
const title = el("div", "st-approval-title");
|
||||
title.innerHTML = `<span aria-hidden="true">⚠</span> permission requested`;
|
||||
card.appendChild(title);
|
||||
|
||||
const what = el("div", "st-approval-what");
|
||||
what.textContent = toolCall.title ?? toolKindLabel(toolCall.kind);
|
||||
card.appendChild(what);
|
||||
|
||||
const path = toolCall.locations?.[0]?.path;
|
||||
if (path) {
|
||||
const pathEl = el("div", "st-approval-path");
|
||||
pathEl.textContent = path;
|
||||
card.appendChild(pathEl);
|
||||
}
|
||||
|
||||
if (approval.pending) {
|
||||
const acts = el("div", "st-approval-acts");
|
||||
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = cls ? `act ${cls}` : "act";
|
||||
btn.textContent = label;
|
||||
btn.addEventListener("click", () => {
|
||||
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
|
||||
buttons.forEach((b) => (b.disabled = true));
|
||||
approval.onDecide(decision).catch(() => {
|
||||
// Rejects only when this approval is still pending (see
|
||||
// ApprovalRowState) — restore the controls so the operator can
|
||||
// retry; the error itself is surfaced by the connection.
|
||||
buttons.forEach((b) => (b.disabled = false));
|
||||
});
|
||||
});
|
||||
return btn;
|
||||
};
|
||||
const offered = offeredDecisions(request.options ?? []);
|
||||
const specs: [StructuredApprovalDecision, string, string][] = [
|
||||
["allow_once", "Allow once", "primary"],
|
||||
["allow_for_session", "Allow for session", ""],
|
||||
["deny", "Deny", "deny"],
|
||||
];
|
||||
for (const [decision, label, cls] of specs) {
|
||||
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
|
||||
}
|
||||
if (acts.children.length > 0) {
|
||||
card.appendChild(acts);
|
||||
} else {
|
||||
const none = el("div", "st-approval-resolved");
|
||||
none.textContent = "no decision offered";
|
||||
card.appendChild(none);
|
||||
}
|
||||
} else {
|
||||
const resolved = el("div", "st-approval-resolved");
|
||||
resolved.textContent = "answered";
|
||||
card.appendChild(resolved);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
||||
* `approval` row; pass it whenever the row might be that kind. */
|
||||
export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowState): HTMLElement {
|
||||
let built: HTMLElement;
|
||||
switch (row.kind) {
|
||||
case "user":
|
||||
built = buildContentRow(row, "user");
|
||||
break;
|
||||
case "agent_text":
|
||||
built = buildContentRow(row, "agent");
|
||||
break;
|
||||
case "agent_thought":
|
||||
built = buildContentRow(row, "thought");
|
||||
break;
|
||||
case "tool_call":
|
||||
case "tool_diff":
|
||||
built = buildToolRow(row);
|
||||
break;
|
||||
case "approval":
|
||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||
break;
|
||||
default:
|
||||
built = buildPlainRow(row);
|
||||
break;
|
||||
}
|
||||
built.dataset.rowId = row.id;
|
||||
return built;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PLAN_MARK: Record<string, string> = { completed: "✓", in_progress: "▸", pending: "·" };
|
||||
const PLAN_CLASS: Record<string, string> = { completed: "done", in_progress: "now", pending: "todo" };
|
||||
|
||||
function renderPlanSection(plan: AcpPlan | null | undefined): HTMLElement | null {
|
||||
if (!plan || plan.entries.length === 0) return null;
|
||||
const section = el("div", "st-rail-section");
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = "Plan";
|
||||
section.appendChild(h);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "st-plan";
|
||||
for (const entry of plan.entries) {
|
||||
const li = document.createElement("li");
|
||||
li.className = `st-plan-${PLAN_CLASS[entry.status] ?? "todo"}`;
|
||||
li.setAttribute("aria-label", `${entry.status.replace("_", " ")}: ${entry.content}`);
|
||||
const mark = el("span", "st-plan-mark");
|
||||
mark.textContent = PLAN_MARK[entry.status] ?? "·";
|
||||
mark.setAttribute("aria-hidden", "true");
|
||||
const text = document.createElement("span");
|
||||
text.textContent = entry.content;
|
||||
li.append(mark, text);
|
||||
list.appendChild(li);
|
||||
}
|
||||
section.appendChild(list);
|
||||
return section;
|
||||
}
|
||||
|
||||
function kv(label: string, value: string): HTMLElement {
|
||||
const row = el("div", "st-kv-row");
|
||||
const k = el("span", "st-kv-k");
|
||||
k.textContent = label;
|
||||
const v = el("span", "st-kv-v");
|
||||
v.textContent = value;
|
||||
row.append(k, v);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderTurnSection(
|
||||
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] },
|
||||
toolRowCount: number,
|
||||
usage: AcpUsage | null | undefined,
|
||||
): HTMLElement {
|
||||
const section = el("div", "st-rail-section");
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = "Turn";
|
||||
section.appendChild(h);
|
||||
const box = el("div", "st-kv");
|
||||
const activity = turn.thinking ? "thinking" : turn.responding ? "responding" : "idle";
|
||||
box.appendChild(kv("state", activity));
|
||||
box.appendChild(kv("active tools", String(turn.active_tool_calls.length)));
|
||||
box.appendChild(kv("tool calls", String(toolRowCount)));
|
||||
if (usage?.cost) box.appendChild(kv("cost", `$${usage.cost.amount.toFixed(2)}`));
|
||||
section.appendChild(box);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderContextSection(usage: AcpUsage | null | undefined): HTMLElement | null {
|
||||
if (!usage) return null;
|
||||
const section = el("div", "st-rail-section");
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = "Context";
|
||||
section.appendChild(h);
|
||||
const box = el("div", "st-kv");
|
||||
box.appendChild(kv("used", formatTokenCount(usage.used)));
|
||||
box.appendChild(kv("window", formatTokenCount(usage.size)));
|
||||
section.appendChild(box);
|
||||
const meter = el("div", "st-meter");
|
||||
const fill = el("i");
|
||||
const pct = usage.size > 0 ? Math.min(100, (usage.used / usage.size) * 100) : 0;
|
||||
fill.style.width = `${pct}%`;
|
||||
meter.appendChild(fill);
|
||||
section.appendChild(meter);
|
||||
return section;
|
||||
}
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
}
|
||||
|
||||
/** `SessionWorktree.path` is `<repo>/worktrees/<branch>` (docs/WORKTREES.md);
|
||||
* the branch is not carried anywhere else on the wire, so derive it here. */
|
||||
export function branchFromWorktreePath(path: string): string {
|
||||
const marker = "/worktrees/";
|
||||
const i = path.indexOf(marker);
|
||||
if (i >= 0) return path.slice(i + marker.length);
|
||||
return path.split("/").filter(Boolean).pop() ?? path;
|
||||
}
|
||||
|
||||
function renderWorktreeSection(session: Session | undefined): HTMLElement | null {
|
||||
const worktree = session?.worktree;
|
||||
if (!worktree) return null;
|
||||
const section = el("div", "st-rail-section");
|
||||
const h = document.createElement("h3");
|
||||
h.textContent = "Worktree";
|
||||
section.appendChild(h);
|
||||
const box = el("div", "st-kv");
|
||||
box.appendChild(kv("branch", branchFromWorktreePath(worktree.path)));
|
||||
const blocked: InTheWay | null | undefined = session?.blocked_work;
|
||||
box.appendChild(kv("ahead", String(blocked?.unpushed_commits ?? 0)));
|
||||
const dirty = (blocked?.uncommitted_files ?? 0) + (blocked?.untracked_files ?? 0);
|
||||
box.appendChild(kv("dirty", String(dirty)));
|
||||
section.appendChild(box);
|
||||
return section;
|
||||
}
|
||||
|
||||
export interface RailContext {
|
||||
plan: AcpPlan | null | undefined;
|
||||
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] };
|
||||
toolRowCount: number;
|
||||
usage: AcpUsage | null | undefined;
|
||||
session: Session | undefined;
|
||||
}
|
||||
|
||||
/** Rebuild the rail wholesale from the latest state — rails are small, so a
|
||||
* full rebuild per update is simpler and cheap enough that no incremental
|
||||
* patching is worth the complexity. */
|
||||
export function renderRail(container: HTMLElement, ctx: RailContext): void {
|
||||
const sections = [
|
||||
renderPlanSection(ctx.plan),
|
||||
renderTurnSection(ctx.turn, ctx.toolRowCount, ctx.usage),
|
||||
renderContextSection(ctx.usage),
|
||||
renderWorktreeSection(ctx.session),
|
||||
].filter((s): s is HTMLElement => s !== null);
|
||||
container.replaceChildren(...sections);
|
||||
}
|
||||
@@ -2165,6 +2165,505 @@ input.leaf-rename:focus {
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
/* ---------- structured view (#140) ----------
|
||||
`.attach.structured` reuses `.attach`'s flex column, `.attach-header` and
|
||||
`.attach-state` verbatim (attach.ts and structured.ts share paneTitle.ts's
|
||||
header markup) — only the body differs: a transcript column + rail grid
|
||||
instead of an xterm surface. Hierarchy is deliberate: the approval is the
|
||||
only lifted, card-like element on the page; everything else is either a
|
||||
tinted block (user), plain text (agent), or a thin bordered row (tool). */
|
||||
|
||||
.st-pills {
|
||||
font-size: var(--fs-11);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.structured-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 232px;
|
||||
}
|
||||
|
||||
.structured-body.st-narrow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.structured-body.st-narrow .structured-rail {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
max-height: 40%;
|
||||
}
|
||||
|
||||
.structured-scroll {
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.structured-rows {
|
||||
padding: var(--sp-3) var(--sp-3) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.structured-rail {
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
padding: var(--sp-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.structured-rail h3 {
|
||||
margin: 0 0 var(--sp-2);
|
||||
font-size: var(--fs-11);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.st-plan {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-plan li {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.st-plan-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.st-plan-done {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.st-plan-now {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.st-plan-now .st-plan-mark,
|
||||
.st-plan-done .st-plan-mark {
|
||||
color: var(--live);
|
||||
}
|
||||
|
||||
.st-plan-todo {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.st-plan-todo .st-plan-mark {
|
||||
color: var(--border-strong);
|
||||
}
|
||||
|
||||
.st-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.st-kv-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-kv-k {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.st-kv-v {
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.st-meter {
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: var(--r-1);
|
||||
overflow: hidden;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-meter i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--live);
|
||||
}
|
||||
|
||||
/* every transcript row carries its own layout; none of them are cards */
|
||||
.st-row {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.st-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* user turn: the only tinted block besides the approval — an accent rail,
|
||||
never a full border, so it still reads lighter than the approval card. */
|
||||
.st-row-user {
|
||||
border-left: 2px solid var(--accent);
|
||||
background: var(--surface-2);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.st-who {
|
||||
font-size: var(--fs-11);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.st-user-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* agent prose: no card, no background — plain text is the point */
|
||||
.st-row-agent .st-body p {
|
||||
margin: 0 0 var(--sp-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.st-row-agent .st-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.st-row-agent code {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
padding: 0 3px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.st-row-thought {
|
||||
color: var(--faint);
|
||||
font-style: italic;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.st-row-notice {
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
/* tool rows: bordered, compact, deliberately not lifted */
|
||||
.st-tool {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.st-tool-head {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.st-tool-head:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.st-tool-glyph {
|
||||
width: 16px;
|
||||
flex: 0 0 auto;
|
||||
text-align: center;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.st-tool-name {
|
||||
font-weight: 500;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.st-tool-arg {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.st-tool-meta {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--fs-11);
|
||||
color: var(--faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.st-tool-meta-running {
|
||||
color: var(--live);
|
||||
}
|
||||
|
||||
.st-tool-meta-failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.st-tool-out {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* `display: flex` above outranks the UA sheet's `[hidden]` (same pitfall as
|
||||
`.wt-step`/`.raw-wrap`) — restate it explicitly. */
|
||||
.st-tool-out[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.st-tool-text {
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.st-diff-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.st-diff-path {
|
||||
font-size: var(--fs-11);
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the diff gets its own horizontal scroller, independent of the page */
|
||||
.st-diff {
|
||||
font-size: var(--fs-12);
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
}
|
||||
|
||||
.st-diff pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.st-ln {
|
||||
display: block;
|
||||
white-space: pre;
|
||||
padding: 0 var(--sp-3);
|
||||
}
|
||||
|
||||
.st-ln-add {
|
||||
background: color-mix(in oklab, var(--live) 14%, transparent);
|
||||
}
|
||||
|
||||
.st-ln-del {
|
||||
background: var(--danger-tint);
|
||||
}
|
||||
|
||||
.st-ln-ctx {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.st-ln-hunk {
|
||||
color: var(--faint);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* the one lifted, card-like element on the surface: the only thing the
|
||||
operator owes a decision on */
|
||||
.st-approval {
|
||||
border: 1px solid var(--warn);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--r-2);
|
||||
background: color-mix(in oklab, var(--warn) 12%, var(--surface));
|
||||
box-shadow: var(--shadow-1);
|
||||
padding: var(--sp-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.st-approval-title {
|
||||
color: var(--warn);
|
||||
font-size: var(--fs-11);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.st-approval-what {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.st-approval-path {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
padding: var(--sp-2);
|
||||
font-size: var(--fs-12);
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.st-approval-acts {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.st-approval-resolved {
|
||||
font-size: var(--fs-11);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.act {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: var(--fs-12);
|
||||
height: 26px;
|
||||
padding: 0 var(--sp-3);
|
||||
border-radius: var(--r-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.act:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.act:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.act:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.act.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.act.deny {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
|
||||
}
|
||||
|
||||
.act.deny:hover {
|
||||
background: var(--danger-tint);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* composer: sticky at the bottom of the scrolling transcript column */
|
||||
.structured-composer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: var(--sp-3);
|
||||
padding: var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.structured-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-height: 160px;
|
||||
resize: none;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
.structured-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||
}
|
||||
|
||||
.structured-input:disabled {
|
||||
color: var(--faint);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* `.btn` sets `display: inline-flex`, which outranks the UA sheet's
|
||||
`[hidden]` — restate it explicitly (same pitfall as `.st-tool-out`). */
|
||||
.structured-cancel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.structured-error {
|
||||
margin: 0 var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--danger-tint);
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-12);
|
||||
border-radius: var(--r-2);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.act {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
|
||||
.modal-backdrop {
|
||||
|
||||
@@ -60,8 +60,15 @@ export interface Session {
|
||||
* `session/new` (#134). Only a `Structured` session whose handshake
|
||||
* succeeded has one; a respawn passes it to `session/load` (#135). */
|
||||
acp_session_id?: string | null;
|
||||
/** Terminal (PTY bytes) or structured (ACP rows) (#133). Fixed at spawn and
|
||||
* immutable for the session's life. Defaults to `"Terminal"` on the wire, so
|
||||
* absent means terminal. */
|
||||
view?: SessionView;
|
||||
}
|
||||
|
||||
/** A session's process shape (#133): mirrors `maestro_protocol::SessionView`. */
|
||||
export type SessionView = "Terminal" | "Structured";
|
||||
|
||||
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
||||
export interface SessionWorktree {
|
||||
path: string;
|
||||
@@ -367,6 +374,189 @@ export function abbreviateCwd(cwd: string, home?: string): string {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structured-session projections (#133/#140). Mirror of
|
||||
// `maestro_protocol`'s "Structured-session projections" section. `data`,
|
||||
// `ReducedState.plan/usage/context/pending_approval` and every `Acp*` type
|
||||
// below are the daemon's opaque ACP payloads (`serde_json::Value` on the
|
||||
// wire) — shaped to `claude-code-acp`'s ACP v1 schema, not covered by
|
||||
// maestro-protocol's own compatibility guarantee. Treat every field as
|
||||
// optional and degrade gracefully rather than trust this shape exactly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
|
||||
export type TranscriptRowKind =
|
||||
| "user"
|
||||
| "agent_text"
|
||||
| "agent_thought"
|
||||
| "tool_call"
|
||||
| "tool_diff"
|
||||
| "approval"
|
||||
| "plan"
|
||||
| "notice";
|
||||
|
||||
/** One row of a structured session's transcript. */
|
||||
export interface TranscriptRow {
|
||||
id: string;
|
||||
kind: TranscriptRowKind;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/** The complete transcript at a durable event-log position (`WS
|
||||
* /api/structured/:id`'s first message, and every replay page). */
|
||||
export interface TranscriptSnapshot {
|
||||
cursor: number;
|
||||
rows: TranscriptRow[];
|
||||
}
|
||||
|
||||
/** A change to one transcript row, addressed by `id`. */
|
||||
export type TranscriptDelta =
|
||||
| { type: "append"; row: TranscriptRow }
|
||||
| { type: "patch"; row: TranscriptRow }
|
||||
| { type: "remove"; id: string };
|
||||
|
||||
/** Per-turn indicators derived from the ACP stream — read verbatim, never
|
||||
* re-derived client-side (docs/STRUCTURED-VIEW.md). */
|
||||
export interface TurnState {
|
||||
responding: boolean;
|
||||
thinking: boolean;
|
||||
active_tool_calls: string[];
|
||||
}
|
||||
|
||||
/** A whole-session view derived from ACP notifications. `plan`/`usage`/
|
||||
* `context`/`pending_approval` are opaque ACP JSON — see the module note
|
||||
* above. `pending_approval` is `{nonce, request: AcpRequestPermissionRequest}`
|
||||
* while a permission request is live, absent otherwise. */
|
||||
export interface ReducedState {
|
||||
turn: TurnState;
|
||||
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
|
||||
plan?: AcpPlan | null;
|
||||
usage?: AcpUsage | null;
|
||||
context?: AcpUsage | null;
|
||||
model?: string | null;
|
||||
mode?: string | null;
|
||||
}
|
||||
|
||||
/** Server messages on `WS /api/structured/:id`. */
|
||||
export type StructuredEvent =
|
||||
| { type: "transcript_snapshot"; snapshot: TranscriptSnapshot }
|
||||
| { type: "reduced_state"; state: ReducedState }
|
||||
| { type: "projection_update"; transcript?: TranscriptDelta | null; state: ReducedState };
|
||||
|
||||
/** `POST /api/sessions/:id/structured/prompt` body. */
|
||||
export interface StructuredPromptRequest {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON
|
||||
* string (a bracketless Rust enum), not `{decision: ...}`. */
|
||||
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
|
||||
|
||||
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
|
||||
|
||||
/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a
|
||||
* generic placeholder rather than being dropped silently. */
|
||||
export type AcpContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; mimeType?: string }
|
||||
| { type: "audio"; mimeType?: string }
|
||||
| { type: "resource_link"; uri?: string; name?: string }
|
||||
| { type: "resource"; resource?: unknown };
|
||||
|
||||
/** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */
|
||||
export interface AcpContentChunk {
|
||||
content: AcpContentBlock;
|
||||
messageId?: string | null;
|
||||
}
|
||||
|
||||
/** `ToolKind`, snake_case on the wire. */
|
||||
export type AcpToolKind =
|
||||
| "read"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "move"
|
||||
| "search"
|
||||
| "execute"
|
||||
| "think"
|
||||
| "fetch"
|
||||
| "switch_mode"
|
||||
| "other";
|
||||
|
||||
/** `ToolCallStatus`, snake_case on the wire. */
|
||||
export type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
|
||||
|
||||
/** `Diff` — a file modification. `oldText` absent means a new file. */
|
||||
export interface AcpDiff {
|
||||
path: string;
|
||||
oldText?: string | null;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
/** `ToolCallContent`. */
|
||||
export type AcpToolCallContent =
|
||||
| { type: "content"; content: AcpContentBlock }
|
||||
| ({ type: "diff" } & AcpDiff)
|
||||
| { type: "terminal"; terminalId?: string };
|
||||
|
||||
export interface AcpToolCallLocation {
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}
|
||||
|
||||
/** `ToolCall` (a `ToolCall`/`ToolDiff` row's `data`) and, loosely, the
|
||||
* flattened `ToolCallUpdate` an approval's `toolCall` field carries — the
|
||||
* two share every field below, just with different subsets required. */
|
||||
export interface AcpToolCallLike {
|
||||
toolCallId?: string;
|
||||
title?: string;
|
||||
/** Unstable ACP capability; absent when the adapter doesn't send one. */
|
||||
name?: string | null;
|
||||
kind?: AcpToolKind;
|
||||
status?: AcpToolCallStatus;
|
||||
content?: AcpToolCallContent[];
|
||||
locations?: AcpToolCallLocation[];
|
||||
rawInput?: unknown;
|
||||
rawOutput?: unknown;
|
||||
}
|
||||
|
||||
/** `PlanEntryPriority` / `PlanEntryStatus`, snake_case on the wire. */
|
||||
export type AcpPlanPriority = "high" | "medium" | "low";
|
||||
export type AcpPlanEntryStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
export interface AcpPlanEntry {
|
||||
content: string;
|
||||
priority: AcpPlanPriority;
|
||||
status: AcpPlanEntryStatus;
|
||||
}
|
||||
|
||||
/** `Plan` — `ReducedState.plan`. */
|
||||
export interface AcpPlan {
|
||||
entries: AcpPlanEntry[];
|
||||
}
|
||||
|
||||
/** `UsageUpdate` — `ReducedState.usage`/`.context` (the daemon folds one
|
||||
* update into both fields). */
|
||||
export interface AcpUsage {
|
||||
used: number;
|
||||
size: number;
|
||||
cost?: { amount: number; currency: string } | null;
|
||||
}
|
||||
|
||||
export interface AcpPermissionOption {
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
|
||||
}
|
||||
|
||||
/** `RequestPermissionRequest`, serialized without its nonce (the nonce lives
|
||||
* only in `ReducedState.pending_approval.nonce`) — an `Approval` row's `data`,
|
||||
* and `ReducedState.pending_approval.request`. */
|
||||
export interface AcpRequestPermissionRequest {
|
||||
sessionId: string;
|
||||
toolCall: AcpToolCallLike;
|
||||
options: AcpPermissionOption[];
|
||||
}
|
||||
|
||||
/** `~/git.naps.pt/naps62/maestro` → `~/g/n/maestro`: keep the last segment
|
||||
* verbatim, collapse every earlier one to its first character. Two-or-fewer
|
||||
* segments are left alone (nothing to gain). Returns escaped HTML with the
|
||||
|
||||
Reference in New Issue
Block a user