Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8c42bfb2 | ||
|
|
e9e98e0c49 | ||
|
|
02f1844650 | ||
|
|
1b7ec1f54c | ||
|
|
ee61cea64f | ||
|
|
7e21371e47 | ||
|
|
52ae636dbb | ||
|
|
9d93e6e60e | ||
|
|
9ba1078386 |
@@ -21,7 +21,7 @@ import {
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "./matcher.js";
|
||||
} from "./utils/matcher.js";
|
||||
import { splitPath } from "./paths.js";
|
||||
import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// A session's action page (docs/COMMAND-BAR.md, "Drill-in"): the same overlay
|
||||
// over kill, rename and spawn similar, still fuzzy searchable. The page names
|
||||
// the session it acts on, so the target is never ambiguous.
|
||||
|
||||
import { sessionName, type Session } from "../../types.js";
|
||||
import { ResultList } from "./ResultList.js";
|
||||
import type { BarGroup } from "./model.js";
|
||||
|
||||
export interface ActionPageProps {
|
||||
session: Session;
|
||||
/** The three actions, ranked by the query line — one kind, so no headers. */
|
||||
groups: BarGroup[];
|
||||
highlighted: number;
|
||||
onActivate(index: number): void;
|
||||
}
|
||||
|
||||
export function ActionPage({ session, groups, highlighted, onActivate }: ActionPageProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="presentation"
|
||||
className="cmdbar-page-header border-b border-border px-sp-3 py-sp-1 text-fs-11 text-muted"
|
||||
>
|
||||
{`Actions for "${sessionName(session)}"`}
|
||||
</div>
|
||||
<ResultList
|
||||
groups={groups}
|
||||
showHeaders={false}
|
||||
highlighted={highlighted}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "../../catalog.js";
|
||||
import { COMMAND_SPECS, type Command } from "../../commands.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
||||
|
||||
vi.mock("../../api.js", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
listDir: vi.fn(async () => ({ path: "", entries: [] })),
|
||||
projects: vi.fn(async () => []),
|
||||
recentDirs: vi.fn(async () => ({ dirs: [] })),
|
||||
}));
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function command(over: Partial<Command> = {}): Command {
|
||||
return {
|
||||
id: "cmd",
|
||||
label: "A command",
|
||||
group: "Test",
|
||||
keys: ["a"],
|
||||
scope: "global",
|
||||
run: () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const sessions = [
|
||||
session("api", { current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z" }),
|
||||
session("worker", {
|
||||
current_cwd: "/home/me/dev/worker",
|
||||
last_focused_at: "2026-01-01T10:00:00Z",
|
||||
}),
|
||||
session("notes", { current_cwd: "/home/me/notes" }),
|
||||
];
|
||||
|
||||
function open(over: Partial<CommandBarProps> = {}) {
|
||||
return render(
|
||||
<CommandBar
|
||||
sessions={sessions}
|
||||
homeDir="/home/me"
|
||||
commands={[]}
|
||||
onOpenSession={() => {}}
|
||||
onClose={() => {}}
|
||||
{...over}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
return screen.getByRole("combobox") as HTMLInputElement;
|
||||
}
|
||||
|
||||
function type(value: string): void {
|
||||
fireEvent.change(input(), { target: { value } });
|
||||
}
|
||||
|
||||
function press(key: string, init: Record<string, unknown> = {}): void {
|
||||
fireEvent.keyDown(input(), { key, ...init });
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
function activeName(): string | undefined {
|
||||
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("the overlay", () => {
|
||||
it("is a registry command, so the help overlay lists it", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
|
||||
expect(spec?.keys).toEqual(["ctrl-k"]);
|
||||
expect(spec?.scope).toBe("global");
|
||||
});
|
||||
|
||||
it("lists sessions by recency under a kind header on an empty query", () => {
|
||||
open();
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
]);
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
expect(activeName()).toBe("api");
|
||||
});
|
||||
|
||||
it("ranks a typed query through the matcher and highlights the matched range", () => {
|
||||
open();
|
||||
type("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect([...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent)).toEqual(
|
||||
["wor"],
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights inside the directory when that is the field that matched", () => {
|
||||
open();
|
||||
type("dev/wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
expect(
|
||||
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
|
||||
).toBe("dev/wor");
|
||||
});
|
||||
|
||||
it("renders a no-results state rather than an empty list", () => {
|
||||
open();
|
||||
type("zzz");
|
||||
expect(rowNames()).toEqual([]);
|
||||
expect(screen.getByText("No matches")).toBeDefined();
|
||||
});
|
||||
|
||||
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
|
||||
open();
|
||||
press("ArrowUp");
|
||||
expect(activeName()).toBe("api");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
press("n", { ctrlKey: true });
|
||||
expect(activeName()).toBe("notes");
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("notes");
|
||||
press("p", { ctrlKey: true });
|
||||
expect(activeName()).toBe("worker");
|
||||
});
|
||||
|
||||
it("puts the highlight back on the best row whenever the query changes", () => {
|
||||
open();
|
||||
press("ArrowDown");
|
||||
expect(activeName()).toBe("worker");
|
||||
type("o");
|
||||
expect(activeName()).toBe(rowNames()[0]);
|
||||
});
|
||||
|
||||
it("opens the highlighted session through the caller's open path on Enter", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
open({ onOpenSession, onClose });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises the shared select event when the caller supplies no handler", () => {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
open({ onOpenSession: undefined });
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("api");
|
||||
});
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
open({ onOpenSession, onClose });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes on Escape and on a press outside the panel", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
fireEvent.mouseDown(document.querySelector(".cmdbar-overlay") as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stays open for a press inside the panel", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the keyboard to whatever held it when it opened", () => {
|
||||
const before = document.createElement("input");
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
const view = open();
|
||||
expect(document.activeElement).toBe(input());
|
||||
view.unmount();
|
||||
expect(document.activeElement).toBe(before);
|
||||
before.remove();
|
||||
});
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ onOpenSession });
|
||||
fireEvent.click(document.querySelectorAll(".cmdbar-row")[1]);
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sigils", () => {
|
||||
const commands = [
|
||||
command({ id: "help.toggle", label: "Show help", keys: ["?"] }),
|
||||
command({ id: "spawn.open", label: "Open the spawn form", keys: ["n"], run: vi.fn() }),
|
||||
];
|
||||
|
||||
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||
open({ commands });
|
||||
type(">");
|
||||
expect(rowNames()).toEqual(["Show help", "Open the spawn form"]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
expect([...document.querySelectorAll(".cmdbar-row-keys kbd")].map((k) => k.textContent)).toEqual(
|
||||
["?", "n"],
|
||||
);
|
||||
});
|
||||
|
||||
it("matches a command on its label and never on its chord", () => {
|
||||
open({ commands });
|
||||
type(">help");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
type(">?");
|
||||
expect(rowNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the highlighted command on Enter", () => {
|
||||
const run = vi.fn();
|
||||
open({ commands: [command({ label: "Run me", run })] });
|
||||
type(">run");
|
||||
press("Enter");
|
||||
expect(run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||
open({ commands });
|
||||
type(">show");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
type("");
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
});
|
||||
|
||||
it("lists recent dirs then projects under `~`", async () => {
|
||||
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/x"]));
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||
const listDirEntries = vi.fn(async () => ["maestro", "api"]);
|
||||
open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries });
|
||||
// The second `~` is a literal query character and reaches the matcher.
|
||||
type("~~/dev/mae");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
|
||||
expect(listDirEntries).toHaveBeenCalledWith("~/dev/");
|
||||
});
|
||||
|
||||
it("spawns in the highlighted directory on Enter", async () => {
|
||||
const onSpawnDirectory = vi.fn();
|
||||
open({ loadDirectories: async () => ["/srv/x"], onSpawnDirectory });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Enter");
|
||||
expect(onSpawnDirectory).toHaveBeenCalledWith("/srv/x");
|
||||
});
|
||||
|
||||
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
open({ loadDirectories: async () => ["/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Enter");
|
||||
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("/srv/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session action page", () => {
|
||||
function drill(over: Partial<CommandBarProps> = {}) {
|
||||
const view = open(over);
|
||||
press("Tab");
|
||||
return view;
|
||||
}
|
||||
|
||||
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||
drill();
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
});
|
||||
|
||||
it("names the session being acted on", () => {
|
||||
drill();
|
||||
expect(screen.getByText('Actions for "api"')).toBeDefined();
|
||||
});
|
||||
|
||||
it("drops the kind headers, since the page is one kind", () => {
|
||||
drill();
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stays fuzzy-searchable over the three actions", () => {
|
||||
drill();
|
||||
type("ren");
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("does nothing on a command row", () => {
|
||||
open({ commands: [command({ label: "Show help" })] });
|
||||
type(">show");
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Show help"]);
|
||||
});
|
||||
|
||||
it("does nothing on a directory row", async () => {
|
||||
open({ loadDirectories: async () => ["/srv/x"] });
|
||||
type("~");
|
||||
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["/srv/x"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||
const onClose = vi.fn();
|
||||
open({ onClose });
|
||||
type("wor");
|
||||
press("Tab");
|
||||
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||
press("Escape");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(input().value).toBe("wor");
|
||||
expect(rowNames()).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
it("returns to the result list on backspace at an empty query", () => {
|
||||
drill();
|
||||
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||
});
|
||||
|
||||
it("keeps backspace ordinary while the page's query has text", () => {
|
||||
drill();
|
||||
type("ren");
|
||||
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||
expect(rowNames()).toEqual(["Rename session"]);
|
||||
});
|
||||
|
||||
it("closes the bar when the page's Escape has already been used", () => {
|
||||
const onClose = vi.fn();
|
||||
drill({ onClose });
|
||||
press("Escape");
|
||||
press("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||
for (const [query, key] of [
|
||||
["kill", "onKillSession"],
|
||||
["rename", "onRenameSession"],
|
||||
["spawn", "onSpawnSimilar"],
|
||||
] as const) {
|
||||
const handler = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
drill({ [key]: handler, onClose });
|
||||
type(query);
|
||||
press("Enter");
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "api" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("hands each action to the flow it already has, by default", () => {
|
||||
for (const [query, event] of [
|
||||
["kill", KILL_SESSION_EVENT],
|
||||
["rename", RENAME_SESSION_EVENT],
|
||||
["spawn", SPAWN_SIMILAR_EVENT],
|
||||
] as const) {
|
||||
const listener = vi.fn();
|
||||
document.addEventListener(event, listener);
|
||||
drill();
|
||||
type(query);
|
||||
press("Enter");
|
||||
document.removeEventListener(event, listener);
|
||||
expect((listener.mock.calls[0][0] as CustomEvent).detail.id).toBe("api");
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list. Sessions and commands rank together by default; `>` narrows to
|
||||
// commands, `~` to directories. `tab` on a session drills into its action page.
|
||||
//
|
||||
// The bar picks an action and hands off — it never reimplements one. Each
|
||||
// handler defaults to the event the flow that already implements it listens
|
||||
// on, so the React bar and the legacy shell raise the same flows.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import {
|
||||
KILL_SESSION_EVENT,
|
||||
RENAME_SESSION_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "../../catalog.js";
|
||||
import { publishedCommands, type Command } from "../../commands.js";
|
||||
import { sessionName, type Session } from "../../types.js";
|
||||
import { ActionPage } from "./ActionPage.js";
|
||||
import { ResultList } from "./ResultList.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
commandBarCandidates,
|
||||
dirEntryPaths,
|
||||
directoryBarCandidates,
|
||||
isActionCandidate,
|
||||
listDirEntries as defaultListDirEntries,
|
||||
loadBarDirectories,
|
||||
parseBarQuery,
|
||||
probeDir,
|
||||
sessionActionCandidates,
|
||||
sessionBarCandidates,
|
||||
PLACEHOLDERS,
|
||||
type BarCandidate,
|
||||
type BarResult,
|
||||
type DirectoryBarCandidate,
|
||||
type SessionAction,
|
||||
} from "./model.js";
|
||||
|
||||
/** Raised when Enter lands on a directory row and no handler was given. The
|
||||
* bar picks the directory; the shell owns the spawn, which is the same
|
||||
* fast-forward path `N` takes. */
|
||||
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
||||
|
||||
export interface CommandBarProps {
|
||||
/** Sessions to build candidates from, as the store has them right now. */
|
||||
sessions: readonly Session[];
|
||||
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
|
||||
homeDir?: string;
|
||||
/** The command registry to list under `>`. Defaults to every bound command,
|
||||
* in registry order. */
|
||||
commands?: readonly Command[];
|
||||
/** The directory list behind `~`, fetched once on first use. */
|
||||
loadDirectories?: () => Promise<string[]>;
|
||||
/** One directory's subdirectories, for filling the list in as a path is
|
||||
* typed. Defaults to `GET /api/dirs`. */
|
||||
listDirEntries?: (dir: string) => Promise<string[]>;
|
||||
onOpenSession?(sessionId: string): void;
|
||||
onSpawnDirectory?(path: string): void;
|
||||
onKillSession?(session: Session): void;
|
||||
onRenameSession?(session: Session): void;
|
||||
onSpawnSimilar?(session: Session): void;
|
||||
/** Closing returns the keyboard to whatever held it when the bar opened. */
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
function raise(name: string, detail: unknown): void {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail }));
|
||||
}
|
||||
|
||||
export function CommandBar(props: CommandBarProps) {
|
||||
const {
|
||||
sessions,
|
||||
homeDir,
|
||||
commands,
|
||||
loadDirectories = loadBarDirectories,
|
||||
listDirEntries = defaultListDirEntries,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [raw, setRaw] = useState("");
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
// The action page: `page` is the session being acted on, `stashed` the
|
||||
// result-list query it was drilled into from — backing out restores it, so
|
||||
// the round trip costs the operator nothing.
|
||||
const [page, setPage] = useState<Session | null>(null);
|
||||
const [stashed, setStashed] = useState("");
|
||||
|
||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||
const [directories, setDirectories] = useState<DirectoryBarCandidate[]>([]);
|
||||
const [probed, setProbed] = useState<DirectoryBarCandidate[]>([]);
|
||||
const requestedRef = useRef(false);
|
||||
const listingsRef = useRef(new Map<string, string[]>());
|
||||
|
||||
// On the action page the query line is over the three actions, so there is
|
||||
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||
const parsed = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]);
|
||||
|
||||
const sessionCandidates = useMemo(
|
||||
() => sessionBarCandidates(sessions, homeDir),
|
||||
[sessions, homeDir],
|
||||
);
|
||||
const commandCandidates = useMemo(
|
||||
() => commandBarCandidates(commands ?? publishedCommands()),
|
||||
[commands],
|
||||
);
|
||||
|
||||
// Closing returns the keyboard to whatever held it when the bar opened: a
|
||||
// round trip through the bar never silently moves focus.
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
inputRef.current?.focus();
|
||||
return () => {
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The recents-and-projects list, the first time `~` is used. No directories
|
||||
// is a shorter list, not an error state.
|
||||
useEffect(() => {
|
||||
if (parsed.sigil !== "~" || requestedRef.current) return;
|
||||
requestedRef.current = true;
|
||||
let live = true;
|
||||
loadDirectories()
|
||||
.then((paths) => {
|
||||
if (live) setDirectories(directoryBarCandidates(paths, homeDir));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [parsed.sigil, loadDirectories, homeDir]);
|
||||
|
||||
// Fill the list in from `GET /api/dirs` once the query looks like a path: the
|
||||
// directory it names is listed and its children join the candidates, so `~`
|
||||
// reaches a directory that was never spawned in.
|
||||
const dir = parsed.sigil === "~" ? probeDir(parsed.query) : "";
|
||||
useEffect(() => {
|
||||
if (dir === "") {
|
||||
setProbed([]);
|
||||
return;
|
||||
}
|
||||
const cached = listingsRef.current.get(dir);
|
||||
if (cached) {
|
||||
setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir));
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
setProbed([]);
|
||||
listDirEntries(dir)
|
||||
.then((entries) => {
|
||||
listingsRef.current.set(dir, entries);
|
||||
if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir));
|
||||
})
|
||||
.catch(() => {
|
||||
/* an unreadable directory contributes nothing */
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [dir, listDirEntries, homeDir]);
|
||||
|
||||
/** What the matcher sees for the query line as it stands. With no sigil the
|
||||
* list is mixed — sessions and commands together — except on an empty query,
|
||||
* where the bar is a session switcher. */
|
||||
const candidates: BarCandidate[] = useMemo(() => {
|
||||
if (page) return sessionActionCandidates();
|
||||
switch (parsed.sigil) {
|
||||
case ">":
|
||||
return [...commandCandidates];
|
||||
case "~": {
|
||||
const seen = new Set<string>();
|
||||
return [...directories, ...probed].filter((c) =>
|
||||
seen.has(c.path) ? false : (seen.add(c.path), true),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return parsed.query === ""
|
||||
? [...sessionCandidates]
|
||||
: [...sessionCandidates, ...commandCandidates];
|
||||
}
|
||||
}, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => barGroups(barRows(parsed.query, candidates)),
|
||||
[parsed.query, candidates],
|
||||
);
|
||||
// Grouping is a rendering of the ranking, so the flattened groups are the
|
||||
// rows in the order they appear — which is the order the highlight walks and
|
||||
// Enter acts on.
|
||||
const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
// The highlight starts on the globally best row every time the list changes:
|
||||
// an old index would point at a different result.
|
||||
useEffect(() => {
|
||||
setHighlighted(0);
|
||||
}, [rows]);
|
||||
|
||||
/** The highlight does not wrap: it stops at the first and last row. */
|
||||
const move = useCallback(
|
||||
(delta: number) => {
|
||||
setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta)));
|
||||
},
|
||||
[rows.length],
|
||||
);
|
||||
|
||||
const runAction = useCallback(
|
||||
(action: SessionAction, session: Session) => {
|
||||
onClose();
|
||||
switch (action.id) {
|
||||
case "kill":
|
||||
(props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session);
|
||||
return;
|
||||
case "rename":
|
||||
(props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session);
|
||||
return;
|
||||
case "spawn-similar":
|
||||
(props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar],
|
||||
);
|
||||
|
||||
const activate = useCallback(
|
||||
(index: number) => {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const candidate = result.candidate;
|
||||
if (isActionCandidate(candidate)) {
|
||||
// The page cannot be up without a session to act on.
|
||||
if (page) runAction(candidate.action, page);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
(props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))(
|
||||
candidate.session.id,
|
||||
);
|
||||
return;
|
||||
case "command":
|
||||
candidate.command.run();
|
||||
return;
|
||||
case "directory":
|
||||
(props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))(
|
||||
candidate.path,
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory],
|
||||
);
|
||||
|
||||
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||
* other kind, and none on the page itself, which has no session rows. */
|
||||
const drillIn = useCallback(() => {
|
||||
const candidate = rows[highlighted]?.candidate;
|
||||
if (page || candidate?.kind !== "session") return;
|
||||
setPage(candidate.session);
|
||||
setStashed(raw);
|
||||
setRaw("");
|
||||
}, [rows, highlighted, page, raw]);
|
||||
|
||||
/** Escape, or backspace at an empty query: back to the result list with the
|
||||
* query the drill-in was made from. */
|
||||
const leavePage = useCallback(() => {
|
||||
setPage(null);
|
||||
setRaw(stashed);
|
||||
setStashed("");
|
||||
}, [stashed]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
// On an action page Escape is one level up, not the way out.
|
||||
if (page) leavePage();
|
||||
else onClose();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
drillIn();
|
||||
return;
|
||||
case "Backspace":
|
||||
// Ordinary editing, except at an empty query on the action page.
|
||||
if (page && raw === "") {
|
||||
e.preventDefault();
|
||||
leavePage();
|
||||
}
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
activate(highlighted);
|
||||
return;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
return;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
return;
|
||||
case "n":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
}
|
||||
return;
|
||||
case "p":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="cmdbar-overlay fixed inset-0 z-modal flex items-start justify-center bg-bg/70 pt-[12vh] backdrop-blur-sm max-[600px]:items-end max-[600px]:pt-0"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command bar"
|
||||
onMouseDown={(e) => {
|
||||
// A press on the scrim closes; one inside the panel is left alone.
|
||||
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="cmdbar-panel flex w-full max-w-[560px] flex-col overflow-hidden rounded-r-3 border border-border bg-surface shadow-2 max-[600px]:max-w-none max-[600px]:rounded-b-none"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="cmdbar-results"
|
||||
aria-label="Search sessions, commands and directories"
|
||||
aria-activedescendant={activeRowId}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="cmdbar-input w-full border-b border-border bg-transparent px-sp-3 py-sp-2 text-fs-15 text-ink outline-none"
|
||||
placeholder={page ? `Act on ${sessionName(page)}…` : PLACEHOLDERS[parsed.sigil ?? "mixed"]}
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{page ? (
|
||||
<ActionPage
|
||||
session={page}
|
||||
groups={groups}
|
||||
highlighted={highlighted}
|
||||
onActivate={activate}
|
||||
/>
|
||||
) : (
|
||||
<ResultList
|
||||
groups={groups}
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped.
|
||||
showHeaders={parsed.sigil === null}
|
||||
highlighted={highlighted}
|
||||
onActivate={activate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// The bar's result list: kind headers over rank-ordered rows
|
||||
// (docs/COMMAND-BAR.md, "Structure"). Headers are labels, never rows — the
|
||||
// highlight walks the rows alone, in the order they are rendered.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { keyCaps } from "../../commands.js";
|
||||
import {
|
||||
highlightSegments,
|
||||
isActionCandidate,
|
||||
metaText,
|
||||
rowName,
|
||||
type BarGroup,
|
||||
type BarResult,
|
||||
} from "./model.js";
|
||||
|
||||
export interface ResultListProps {
|
||||
groups: BarGroup[];
|
||||
/** Dropped while a sigil is in force or on the action page: one kind needs
|
||||
* no header. */
|
||||
showHeaders: boolean;
|
||||
/** Index into the flattened rows, in the order they render. */
|
||||
highlighted: number;
|
||||
onActivate(index: number): void;
|
||||
}
|
||||
|
||||
function Highlighted({ text, ranges }: { text: string; ranges: BarResult["ranges"] }) {
|
||||
return (
|
||||
<>
|
||||
{highlightSegments(text, ranges).map((segment, i) =>
|
||||
segment.hit ? (
|
||||
<mark key={i} className="cmdbar-hit bg-transparent font-semibold text-accent">
|
||||
{segment.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{segment.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
result,
|
||||
index,
|
||||
active,
|
||||
onActivate,
|
||||
}: {
|
||||
result: BarResult;
|
||||
index: number;
|
||||
active: boolean;
|
||||
onActivate(index: number): void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const candidate = result.candidate;
|
||||
|
||||
useEffect(() => {
|
||||
if (active) ref.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const name = rowName(candidate);
|
||||
const nameMatched =
|
||||
result.field === "name" || result.field === "label" || result.field === "path";
|
||||
const meta = metaText(result);
|
||||
const dead = candidate.kind === "session" && candidate.session.status === "Dead";
|
||||
const chord =
|
||||
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||
? candidate.command.keys[0]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="option"
|
||||
id={`cmdbar-row-${index}`}
|
||||
aria-selected={active}
|
||||
data-index={index}
|
||||
data-kind={candidate.kind}
|
||||
data-session-id={candidate.kind === "session" ? candidate.session.id : undefined}
|
||||
data-action-id={isActionCandidate(candidate) ? candidate.action.id : undefined}
|
||||
data-command-id={
|
||||
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||
? candidate.command.id
|
||||
: undefined
|
||||
}
|
||||
data-path={candidate.kind === "directory" ? candidate.path : undefined}
|
||||
className={[
|
||||
"cmdbar-row flex cursor-pointer items-baseline gap-sp-2 rounded-r-1 px-sp-3 py-sp-1 text-fs-13",
|
||||
active ? "is-active bg-surface-2 text-ink" : "text-ink",
|
||||
dead ? "is-dead opacity-60" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => onActivate(index)}
|
||||
>
|
||||
<span className="cmdbar-row-name truncate">
|
||||
{nameMatched ? <Highlighted text={name} ranges={result.ranges} /> : name}
|
||||
</span>
|
||||
{chord !== undefined ? (
|
||||
<span className="cmdbar-row-keys ml-auto flex gap-1">
|
||||
{keyCaps(chord).map((cap, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="rounded-r-1 border border-border px-1 text-fs-11 text-muted"
|
||||
>
|
||||
{cap}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
) : meta ? (
|
||||
<span className="cmdbar-row-meta ml-auto truncate text-fs-11 text-muted">
|
||||
{result.field !== undefined && result.field !== "name" ? (
|
||||
<Highlighted text={meta} ranges={result.ranges} />
|
||||
) : (
|
||||
meta
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultList({ groups, showHeaders, highlighted, onActivate }: ResultListProps) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div
|
||||
id="cmdbar-results"
|
||||
role="listbox"
|
||||
aria-label="Results"
|
||||
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||
>
|
||||
<div className="cmdbar-empty px-sp-3 py-sp-2 text-fs-12 text-muted">No matches</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
return (
|
||||
<div
|
||||
id="cmdbar-results"
|
||||
role="listbox"
|
||||
aria-label="Results"
|
||||
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<div key={group.kind}>
|
||||
{showHeaders ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="cmdbar-group-title px-sp-3 pt-sp-2 pb-1 text-fs-11 uppercase tracking-wide text-muted"
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
) : null}
|
||||
{group.results.map((result) => {
|
||||
const at = index++;
|
||||
return (
|
||||
<Row
|
||||
key={at}
|
||||
result={result}
|
||||
index={at}
|
||||
active={at === highlighted}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
||||
export { ResultList, type ResultListProps } from "./ResultList.js";
|
||||
export { ActionPage, type ActionPageProps } from "./ActionPage.js";
|
||||
export { useCommandBar, type CommandBarControls, type UseCommandBarOptions } from "./useCommandBar.js";
|
||||
export * from "./model.js";
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Command } from "../../commands.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import {
|
||||
barGroups,
|
||||
barRows,
|
||||
commandBarCandidates,
|
||||
dedupeCommands,
|
||||
directoryBarCandidates,
|
||||
directoryCandidate,
|
||||
highlightSegments,
|
||||
orderSessionsForBar,
|
||||
parseBarQuery,
|
||||
sessionActionCandidates,
|
||||
sessionBarCandidates,
|
||||
sessionCandidate,
|
||||
} from "./model.js";
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/me/dev/api",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function command(over: Partial<Command> = {}): Command {
|
||||
return {
|
||||
id: "a",
|
||||
label: "A command",
|
||||
group: "Test",
|
||||
keys: ["a"],
|
||||
scope: "global",
|
||||
run: () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("empty-query ordering", () => {
|
||||
it("sorts most recently focused first", () => {
|
||||
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
|
||||
});
|
||||
|
||||
it("sorts a never-focused session after every focused one, newest created first", () => {
|
||||
const focused = session("focused", { last_focused_at: "2026-01-01T09:00:00Z" });
|
||||
const old = session("old", { created_at: "2026-01-01T00:00:00Z" });
|
||||
const fresh = session("fresh", { created_at: "2026-01-02T00:00:00Z" });
|
||||
expect(orderSessionsForBar([old, fresh, focused]).map((s) => s.id)).toEqual([
|
||||
"focused",
|
||||
"fresh",
|
||||
"old",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts dead sessions after live ones regardless of recency", () => {
|
||||
const dead = session("dead", { status: "Dead", last_focused_at: "2026-01-01T12:00:00Z" });
|
||||
const live = session("live", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
expect(orderSessionsForBar([dead, live]).map((s) => s.id)).toEqual(["live", "dead"]);
|
||||
});
|
||||
|
||||
it("treats an empty last_focused_at as never focused", () => {
|
||||
const blank = session("blank", { last_focused_at: "" });
|
||||
const focused = session("focused", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
expect(orderSessionsForBar([blank, focused]).map((s) => s.id)).toEqual(["focused", "blank"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session candidates", () => {
|
||||
it("carries the candidate table's fields, with the directory abbreviated", () => {
|
||||
const s = session("api", {
|
||||
metadata: { name: "api" },
|
||||
current_cwd: "/home/me/dev/api",
|
||||
command: "claude --resume",
|
||||
});
|
||||
expect(sessionCandidate(s, "/home/me").fields).toEqual({
|
||||
name: "api",
|
||||
directory: "~/dev/api",
|
||||
command: "claude --resume",
|
||||
tool: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
|
||||
const s = session("edit", {
|
||||
metadata: {},
|
||||
command: "hx src/",
|
||||
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
|
||||
});
|
||||
const candidate = sessionCandidate(s, "/home/me");
|
||||
expect(candidate.fields.tool).toBeUndefined();
|
||||
expect(candidate.fields.directory).toBe("~/dev/api");
|
||||
// No `metadata.name`: the sidebar's fallback is the command line.
|
||||
expect(candidate.fields.name).toBe("hx src/");
|
||||
});
|
||||
|
||||
it("builds candidates in the empty-query order", () => {
|
||||
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
|
||||
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
|
||||
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rows and grouping", () => {
|
||||
it("an empty query keeps every candidate in input order", () => {
|
||||
const candidates = sessionBarCandidates([session("a"), session("b")]);
|
||||
expect(barRows("", candidates).map((r) => r.score)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("groups by kind without reordering the ranking", () => {
|
||||
const rows = barRows("kill", [
|
||||
...sessionBarCandidates([session("killer-app", { metadata: { name: "killer-app" } })]),
|
||||
...commandBarCandidates([command({ label: "Kill selected session" })]),
|
||||
]);
|
||||
const groups = barGroups(rows);
|
||||
expect(groups.map((g) => g.title)).toEqual(["Sessions", "Commands"]);
|
||||
expect(groups.flatMap((g) => g.results)).toEqual(rows);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsing the query line", () => {
|
||||
it("reads a sigil only in first position", () => {
|
||||
expect(parseBarQuery(">help")).toEqual({ sigil: ">", query: "help" });
|
||||
expect(parseBarQuery("~dev")).toEqual({ sigil: "~", query: "dev" });
|
||||
});
|
||||
|
||||
it("leaves a sigil character elsewhere in the query literal", () => {
|
||||
expect(parseBarQuery("~dev/~scratch")).toEqual({ sigil: "~", query: "dev/~scratch" });
|
||||
expect(parseBarQuery("a>b")).toEqual({ sigil: null, query: "a>b" });
|
||||
});
|
||||
|
||||
it("returns to the mixed list when the sigil is backspaced away", () => {
|
||||
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("command candidates", () => {
|
||||
it("dedupes by action, not by chord", () => {
|
||||
const run = () => {};
|
||||
const cmds = [
|
||||
command({ id: "one", label: "Do it", keys: ["j"], run }),
|
||||
command({ id: "two", label: "Do it", keys: ["down"], run }),
|
||||
];
|
||||
expect(commandBarCandidates(cmds)).toHaveLength(1);
|
||||
expect(dedupeCommands(cmds)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("matches on the label only, never on the chord", () => {
|
||||
const candidates = commandBarCandidates([command({ label: "Show help", keys: ["ctrl-q"] })]);
|
||||
expect(barRows("ctrlq", candidates)).toEqual([]);
|
||||
expect(barRows("show", candidates)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory candidates", () => {
|
||||
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
||||
const candidate = directoryCandidate("/home/me/dev/api", "/home/me");
|
||||
expect(candidate.fields.path).toBe("~/dev/api");
|
||||
expect(candidate.path).toBe("/home/me/dev/api");
|
||||
});
|
||||
|
||||
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
||||
const candidates = directoryBarCandidates(
|
||||
["~/dev/api", "/home/me/dev/api", "", "/srv/x"],
|
||||
"/home/me",
|
||||
);
|
||||
expect(candidates.map((c) => c.path)).toEqual(["/home/me/dev/api", "/srv/x"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the action page's candidates", () => {
|
||||
it("offers no action beyond the three", () => {
|
||||
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
||||
"kill",
|
||||
"rename",
|
||||
"spawn-similar",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlight segments", () => {
|
||||
it("splits the text on the matcher's ranges", () => {
|
||||
expect(highlightSegments("web-search", [[0, 1], [4, 5]])).toEqual([
|
||||
{ text: "w", hit: true },
|
||||
{ text: "eb-", hit: false },
|
||||
{ text: "s", hit: true },
|
||||
{ text: "earch", hit: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
// The command bar's pure model (docs/COMMAND-BAR.md), lifted from
|
||||
// ../../commandBar.ts for the React port (#247): what a candidate is, the
|
||||
// order an empty query lists them in, how a query line splits into a sigil and
|
||||
// a query, and how a ranked list groups for rendering.
|
||||
//
|
||||
// Nothing here touches the DOM or the network apart from `loadBarDirectories`,
|
||||
// which is the one place the `~` list comes from.
|
||||
|
||||
import { listDir, projects as fetchProjects, recentDirs } from "../../api.js";
|
||||
import type { Command } from "../../commands.js";
|
||||
import { splitPath } from "../../paths.js";
|
||||
import { splitCommand } from "../../toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "../../types.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
type CandidateKind,
|
||||
type CommandCandidate,
|
||||
type DirectoryCandidate,
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "../../utils/matcher.js";
|
||||
|
||||
/** A matcher candidate that remembers what it came from, so a result can be
|
||||
* acted on without a second lookup. The extra property is invisible to the
|
||||
* matcher, which only ever reads `kind` and `fields`. */
|
||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||
export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||
/** `fields.path` is the abbreviated path the operator reads and the matcher
|
||||
* ranks; `path` is the one a spawn is made in. */
|
||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||
|
||||
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||
* single `label` field, ranked exactly like a registry command — and carries
|
||||
* the action it stands for instead of a registry entry. */
|
||||
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||
|
||||
export type BarCandidate =
|
||||
| SessionBarCandidate
|
||||
| CommandBarCandidate
|
||||
| ActionBarCandidate
|
||||
| DirectoryBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** One kind's slice of the ranked list, in rank order. */
|
||||
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||
kind: CandidateKind;
|
||||
title: string;
|
||||
results: MatchResult<C>[];
|
||||
}
|
||||
|
||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
session: "Sessions",
|
||||
command: "Commands",
|
||||
directory: "Directories",
|
||||
};
|
||||
|
||||
// ---- the sigils ----
|
||||
|
||||
export type BarSigil = ">" | "~";
|
||||
|
||||
export interface BarQuery {
|
||||
/** The sigil in force, or null for the mixed list. */
|
||||
sigil: BarSigil | null;
|
||||
/** What the matcher sees: everything after a sigil, or the whole query. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
export const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
||||
mixed: "Go to a session…",
|
||||
">": "Run a command…",
|
||||
"~": "Spawn in a directory…",
|
||||
};
|
||||
|
||||
/** Split a raw query line into its sigil and the query proper.
|
||||
*
|
||||
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
|
||||
* literal query character, which is what a directory query with a second `~`
|
||||
* in it depends on. Backspacing past a sigil needs no special case either —
|
||||
* the first character goes and the query is a mixed one again. */
|
||||
export function parseBarQuery(raw: string): BarQuery {
|
||||
const first = raw[0];
|
||||
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
|
||||
return { sigil: null, query: raw };
|
||||
}
|
||||
|
||||
// ---- the session action page ----
|
||||
|
||||
/** The actions a session's action page offers, and no others: the bar picks
|
||||
* one and hands off to the flow that already implements it
|
||||
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||
|
||||
export interface SessionAction {
|
||||
id: SessionActionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||
{ id: "kill", label: "Kill session" },
|
||||
{ id: "rename", label: "Rename session" },
|
||||
{ id: "spawn-similar", label: "Spawn similar" },
|
||||
];
|
||||
|
||||
/** The action page's candidates, in the order above — which is the order an
|
||||
* empty query lists them in, since the matcher keeps input order. */
|
||||
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||
return SESSION_ACTIONS.map((action) => ({
|
||||
kind: "command",
|
||||
action,
|
||||
fields: { label: action.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||
* `command` kind, so what separates them is what they carry. */
|
||||
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||
return candidate.kind === "command" && "action" in candidate;
|
||||
}
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
const stamp = s.last_focused_at;
|
||||
if (typeof stamp !== "string" || stamp === "") return null;
|
||||
const ms = Date.parse(stamp);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function createdAt(s: Session): number {
|
||||
const ms = Date.parse(s.created_at ?? "");
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
|
||||
* focused first; a session that has never been focused sorts after every
|
||||
* session that has, most recently created first among those; and a dead
|
||||
* session sorts after every live one regardless of recency, because a session
|
||||
* you cannot attach to is not where you just were.
|
||||
*
|
||||
* This is also the input order for a non-empty query, which is what the
|
||||
* matcher's last tie-break preserves. */
|
||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const deadA = a.status === "Dead" ? 1 : 0;
|
||||
const deadB = b.status === "Dead" ? 1 : 0;
|
||||
if (deadA !== deadB) return deadA - deadB;
|
||||
|
||||
const focusA = focusedAt(a);
|
||||
const focusB = focusedAt(b);
|
||||
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
|
||||
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
|
||||
|
||||
return createdAt(b) - createdAt(a);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
|
||||
* table names, with the directory abbreviated the way the sidebar shows it. */
|
||||
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
|
||||
const treePath = sessionTreePath(s);
|
||||
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
|
||||
const tool = splitCommand(s.command).tool ?? undefined;
|
||||
return {
|
||||
kind: "session",
|
||||
session: s,
|
||||
fields: {
|
||||
name: sessionName(s),
|
||||
...(directory ? { directory } : {}),
|
||||
...(s.command ? { command: s.command } : {}),
|
||||
...(tool ? { tool } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Every session as a candidate, in the empty-query order. */
|
||||
export function sessionBarCandidates(
|
||||
sessions: readonly Session[],
|
||||
homeDir?: string,
|
||||
): SessionBarCandidate[] {
|
||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||
}
|
||||
|
||||
/** Drop the commands that would list twice. Several chords can be bound to one
|
||||
* action — the registry already carries its aliases on one entry, but nothing
|
||||
* stops two entries running the same handler, and listed naively that is two
|
||||
* identical rows. The dedupe is by **action**, never by chord: the chord is
|
||||
* displayed, so a command with two of them is still one row. */
|
||||
export function dedupeCommands(commands: readonly Command[]): Command[] {
|
||||
const seenRun = new Set<Command["run"]>();
|
||||
const seenLabel = new Set<string>();
|
||||
const kept: Command[] = [];
|
||||
for (const command of commands) {
|
||||
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
|
||||
seenRun.add(command.run);
|
||||
seenLabel.add(command.label);
|
||||
kept.push(command);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Every bound command as a candidate, in registry order. Only the label is a
|
||||
* field: the chord is displayed beside the row, never matched, because
|
||||
* matching it would make `>` results depend on a user's rebinds. */
|
||||
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
|
||||
return dedupeCommands(commands).map((command) => ({
|
||||
kind: "command",
|
||||
command,
|
||||
fields: { label: command.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
|
||||
* a path the operator typed or `/api/dirs` completed. */
|
||||
export function expandHome(path: string, homeDir?: string): string {
|
||||
if (!homeDir) return path;
|
||||
if (path === "~") return homeDir;
|
||||
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
|
||||
}
|
||||
|
||||
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
|
||||
const absolute = expandHome(path, homeDir);
|
||||
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
|
||||
}
|
||||
|
||||
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
|
||||
* first mention of each — recent dirs (newest first) ahead of the project
|
||||
* list, which is the empty-query order the contract asks for. */
|
||||
export function directoryBarCandidates(
|
||||
paths: readonly string[],
|
||||
homeDir?: string,
|
||||
): DirectoryBarCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const out: DirectoryBarCandidate[] = [];
|
||||
for (const path of paths) {
|
||||
if (path === "") continue;
|
||||
const candidate = directoryCandidate(path, homeDir);
|
||||
if (seen.has(candidate.path)) continue;
|
||||
seen.add(candidate.path);
|
||||
out.push(candidate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The directory list the bar starts from: what the spawn form already knows —
|
||||
* `GET /api/recent-dirs` and the project list — either alone if the other
|
||||
* fails. */
|
||||
export async function loadBarDirectories(): Promise<string[]> {
|
||||
const [recents, discovered] = await Promise.all([
|
||||
recentDirs()
|
||||
.then((r) => r.dirs.map((d) => d.cwd))
|
||||
.catch(() => [] as string[]),
|
||||
fetchProjects()
|
||||
.then((list) => list.map((p) => p.path))
|
||||
.catch(() => [] as string[]),
|
||||
]);
|
||||
return [...recents, ...discovered];
|
||||
}
|
||||
|
||||
/** One directory's children as candidate paths, for filling the `~` list in as
|
||||
* a path is typed. The directory being listed comes from the query itself. */
|
||||
export function dirEntryPaths(dir: string, entries: readonly string[]): string[] {
|
||||
return entries.map((entry) => `${dir}${entry}`);
|
||||
}
|
||||
|
||||
/** The directory a `~` query names, or `""` when it names none yet. */
|
||||
export function probeDir(query: string): string {
|
||||
return splitPath(query).dir;
|
||||
}
|
||||
|
||||
/** `GET /api/dirs`, in the shape the bar wants it. */
|
||||
export function listDirEntries(dir: string): Promise<string[]> {
|
||||
return listDir(dir).then((listing) => listing.entries);
|
||||
}
|
||||
|
||||
// ---- grouping ----
|
||||
|
||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||
* ranking, never a reordering of it: a group holds its matches in rank order,
|
||||
* and groups appear in the order their best-scoring member does — so the first
|
||||
* row under the first header is the globally best result. */
|
||||
export function barGroups<C extends BarCandidate>(
|
||||
results: readonly MatchResult<C>[],
|
||||
): BarGroup<C>[] {
|
||||
const groups: BarGroup<C>[] = [];
|
||||
for (const result of results) {
|
||||
const kind = result.candidate.kind;
|
||||
let group = groups.find((g) => g.kind === kind);
|
||||
if (!group) {
|
||||
group = { kind, title: KIND_TITLES[kind], results: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.results.push(result);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||
* this list. */
|
||||
export function barRows<C extends BarCandidate>(
|
||||
query: string,
|
||||
candidates: readonly C[],
|
||||
): MatchResult<C>[] {
|
||||
return matchAndRank(query, candidates);
|
||||
}
|
||||
|
||||
// ---- row text ----
|
||||
|
||||
/** The name line of a row: the session's name, the command's label, or the
|
||||
* directory's path. */
|
||||
export function rowName(candidate: BarCandidate): string {
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
return candidate.fields.name;
|
||||
case "command":
|
||||
return candidate.fields.label;
|
||||
case "directory":
|
||||
return candidate.fields.path;
|
||||
}
|
||||
}
|
||||
|
||||
/** The secondary line under a session row: the field the match was found in
|
||||
* when that was not the name, so a session found through its directory says
|
||||
* so. Only sessions have one — a command shows keycaps instead, and a
|
||||
* directory's path is already the name. */
|
||||
export function metaText(result: BarResult): string | undefined {
|
||||
if (result.candidate.kind !== "session") return undefined;
|
||||
const fields = result.candidate.fields;
|
||||
switch (result.field) {
|
||||
case "directory":
|
||||
return fields.directory;
|
||||
case "command":
|
||||
return fields.command;
|
||||
case "tool":
|
||||
return fields.tool;
|
||||
default:
|
||||
return fields.directory;
|
||||
}
|
||||
}
|
||||
|
||||
/** One run of a row's text, flagged when the matcher matched inside it. The
|
||||
* React rendering of `renderHighlighted`: ranges come from the matcher as
|
||||
* `[start, end)` character pairs over the field that won. */
|
||||
export interface HighlightSegment {
|
||||
text: string;
|
||||
hit: boolean;
|
||||
}
|
||||
|
||||
export function highlightSegments(
|
||||
text: string,
|
||||
ranges: readonly MatchRange[],
|
||||
): HighlightSegment[] {
|
||||
const out: HighlightSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const [start, end] of ranges) {
|
||||
if (start > cursor) out.push({ text: text.slice(cursor, start), hit: false });
|
||||
out.push({ text: text.slice(start, end), hit: true });
|
||||
cursor = end;
|
||||
}
|
||||
if (cursor < text.length) out.push({ text: text.slice(cursor), hit: false });
|
||||
return out;
|
||||
}
|
||||
|
||||
export { sessionName };
|
||||
export type { CandidateKind, MatchRange, MatchResult };
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
|
||||
import { useCommandBar, type UseCommandBarOptions } from "./useCommandBar.js";
|
||||
|
||||
let controls: ReturnType<typeof useCommandBar>;
|
||||
|
||||
function Probe(opts: UseCommandBarOptions) {
|
||||
controls = useCommandBar(opts);
|
||||
return <div>{controls.open ? "open" : "closed"}</div>;
|
||||
}
|
||||
|
||||
function ctrlK(target: EventTarget = window): void {
|
||||
act(() => {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useCommandBar", () => {
|
||||
it("opens on ctrl-k, from the capture phase so an attached xterm never sees it", () => {
|
||||
const view = render(<Probe />);
|
||||
ctrlK();
|
||||
expect(view.container.textContent).toBe("open");
|
||||
});
|
||||
|
||||
it("stays deaf while the bar is already up — its own input owns the keys", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Probe onOpen={onOpen} />);
|
||||
ctrlK();
|
||||
ctrlK();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves a form field's own Ctrl-K alone", () => {
|
||||
const field = document.createElement("input");
|
||||
document.body.appendChild(field);
|
||||
const view = render(<Probe />);
|
||||
ctrlK(field);
|
||||
expect(view.container.textContent).toBe("closed");
|
||||
field.remove();
|
||||
});
|
||||
|
||||
it("opens from the sidebar's search field, the touch entry point", () => {
|
||||
const view = render(<Probe />);
|
||||
act(() => {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||
});
|
||||
expect(view.container.textContent).toBe("open");
|
||||
});
|
||||
|
||||
it("runs the caller's hook before opening, so a preview is discarded first", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Probe onOpen={onOpen} />);
|
||||
ctrlK();
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes on demand", () => {
|
||||
const view = render(<Probe />);
|
||||
ctrlK();
|
||||
act(() => controls.closeBar());
|
||||
expect(view.container.textContent).toBe("closed");
|
||||
});
|
||||
|
||||
it("binds nothing while disabled — another overlay owns the keyboard", () => {
|
||||
const view = render(<Probe enabled={false} />);
|
||||
ctrlK();
|
||||
expect(view.container.textContent).toBe("closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Opening and closing the bar (docs/COMMAND-BAR.md, "Opening"). `ctrl-k` is a
|
||||
// registry command like any other, so the `?` overlay lists it without a
|
||||
// second list; the touch entry point — the sidebar's search field — raises
|
||||
// `OPEN_COMMAND_BAR_EVENT` and lands on the same open.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
|
||||
import { CommandSet, defineCommands, isBarTypingTarget } from "../../commands.js";
|
||||
|
||||
export interface UseCommandBarOptions {
|
||||
/** Run before the bar opens — the shell discards a preview here, since the
|
||||
* bar is not previewed into (docs/PREVIEW.md). */
|
||||
onOpen?(): void;
|
||||
/** Off while another overlay owns the keyboard (the restart overlay, the
|
||||
* help dialog). Defaults to on. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandBarControls {
|
||||
open: boolean;
|
||||
openBar(): void;
|
||||
closeBar(): void;
|
||||
}
|
||||
|
||||
export function useCommandBar(opts: UseCommandBarOptions = {}): CommandBarControls {
|
||||
const { onOpen, enabled = true } = opts;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openBar = useCallback(() => {
|
||||
onOpen?.();
|
||||
setOpen(true);
|
||||
}, [onOpen]);
|
||||
|
||||
const closeBar = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const keys = new CommandSet(defineCommands({ "bar.open": openBar }));
|
||||
|
||||
// Capture phase: an attached xterm swallows every keystroke it is given, so
|
||||
// the bar's binding has to be seen ahead of the terminal.
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
// The bar owns the keyboard while it is up; its own input handles the keys.
|
||||
if (open) return;
|
||||
// The pane's hidden textarea is not a typing target for the bar: ctrl-k
|
||||
// is seen ahead of xterm and opens the bar. A real form field — the spawn
|
||||
// form's inputs — keeps its own Ctrl+K.
|
||||
if (isBarTypingTarget(e.target)) return;
|
||||
keys.handle(e);
|
||||
};
|
||||
const onTouchEntry = () => {
|
||||
if (!open) openBar();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
document.addEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeydown, true);
|
||||
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
|
||||
};
|
||||
}, [enabled, open, openBar]);
|
||||
|
||||
return { open, openBar, closeBar };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { leaves } from "../../layout.js";
|
||||
import { createLayoutStore } from "../../stores/layoutStore.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import { MultiView } from "./MultiView.js";
|
||||
|
||||
vi.mock("../terminal/TerminalPane.js", () => ({
|
||||
TerminalPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`terminal-${sessionId}`} />,
|
||||
}));
|
||||
|
||||
vi.mock("../structured/StructuredPane.js", () => ({
|
||||
StructuredPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`structured-${sessionId}`} />,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function store() {
|
||||
return createLayoutStore({
|
||||
load: () => null,
|
||||
save: vi.fn(),
|
||||
scheduler: { setTimer: () => 1, clearTimer: () => {} },
|
||||
});
|
||||
}
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/n",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("MultiView", () => {
|
||||
it("shows the empty state when the active tab has no panes", () => {
|
||||
const s = store();
|
||||
render(<MultiView store={s} />);
|
||||
expect(screen.getByText(/No session open/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a pane per leaf, dispatching to the terminal or structured body", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().openSessionBeside("s2");
|
||||
const sessions = new Map([
|
||||
["s1", session("s1")],
|
||||
["s2", session("s2", { view: "Structured" })],
|
||||
]);
|
||||
render(<MultiView store={s} sessions={sessions} />);
|
||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||
expect(screen.getByTestId("structured-s2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("split right adds a second pane to the tree", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
fireEvent.click(screen.getByTitle("Split right"));
|
||||
expect(screen.getAllByText("Empty pane")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("close pane removes it", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
const leafId = leaves(s.getState().layout.tabs[0].root)[0].id;
|
||||
s.getState().splitFocused(leafId, "row"); // adds an empty leaf beside s1
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
// splitFocused(after: true) puts the fresh empty leaf second — close it.
|
||||
fireEvent.click(screen.getAllByTitle("Close pane")[1]);
|
||||
expect(screen.queryByText("Empty pane")).toBeNull();
|
||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("prunes a leaf whose session no longer exists, once the live set is known", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().openSessionBeside("dead");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||
expect(screen.queryByTestId("terminal-dead")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not touch the layout while the live set is still unknown", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} />);
|
||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("preview mode (docs/PREVIEW.md)", () => {
|
||||
it("shows a session no pane holds in a throwaway tab, marked as a preview", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
// s1's own tab is still in the strip — the preview never touched it.
|
||||
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
|
||||
const pane = document.querySelector('[data-session-id="s9"]');
|
||||
expect(pane).toBeTruthy();
|
||||
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
|
||||
expect(screen.getByText("preview")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("puts the layout back byte for byte once the preview ends", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
const before = JSON.stringify(s.getState().layout);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
act(() => s.getState().previewSession(null));
|
||||
|
||||
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||
expect(screen.queryByText("preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not grab the keyboard while previewing", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||
expect(pane.querySelector('[data-testid="terminal-s9"]')).toBeTruthy();
|
||||
// TerminalPane's `focus` prop is verified by TerminalPane's own tests;
|
||||
// here the preview must never end up as the active-tab's accent pane.
|
||||
expect(pane.className).not.toContain("border-accent");
|
||||
});
|
||||
|
||||
it("clicking into a previewed pane commits it: no more preview, nothing moves a second time", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||
fireEvent.pointerDown(pane);
|
||||
|
||||
expect(s.getState().preview).toBeNull();
|
||||
expect(screen.queryByText("preview")).toBeNull();
|
||||
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
|
||||
// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
|
||||
// single-pane collapse stay with whichever issue wires this into the shell
|
||||
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { activeTab, type Dir, type Node } from "../../layout.js";
|
||||
import { usePreview } from "../../hooks/usePreview.js";
|
||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||
import type { Session, Uuid } from "../../types.js";
|
||||
import { PaneContainer } from "./PaneContainer.js";
|
||||
import { PaneTitle } from "./PaneTitle.js";
|
||||
import { TabBar } from "./TabBar.js";
|
||||
|
||||
export interface MultiViewProps {
|
||||
store: LayoutStoreApi;
|
||||
/** Live sessions, keyed by id — drives pane titles and (once known) boot
|
||||
* reconciliation. Passing `undefined` defers reconciliation. */
|
||||
sessions?: ReadonlyMap<Uuid, Session>;
|
||||
onDisconnect?: (sessionId: Uuid) => void;
|
||||
emptyState?: ReactNode;
|
||||
}
|
||||
|
||||
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
|
||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
||||
const tab = activeTab(layout);
|
||||
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
|
||||
const isPreviewing = previewedSessionId !== null;
|
||||
|
||||
const reconciledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (reconciledRef.current || !sessions) return;
|
||||
reconciledRef.current = true;
|
||||
store.getState().reconcile(new Set(sessions.keys()));
|
||||
}, [sessions, store]);
|
||||
|
||||
return (
|
||||
<div className="mv flex h-full min-h-0 flex-col">
|
||||
<TabBar store={store} />
|
||||
<div className="mv-canvas relative min-h-0 flex-1">
|
||||
{tab.root ? (
|
||||
<SplitNode
|
||||
node={tab.root}
|
||||
tab={tab}
|
||||
store={store}
|
||||
sessions={sessions}
|
||||
onDisconnect={onDisconnect}
|
||||
previewedSessionId={previewedSessionId}
|
||||
commitPreview={commitPreview}
|
||||
/>
|
||||
) : (
|
||||
(emptyState ?? (
|
||||
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
||||
No session open — pick one from the sidebar, or spawn a new one.
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isPreviewing && (
|
||||
<>
|
||||
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
|
||||
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
|
||||
preview
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TreeProps {
|
||||
tab: { id: string; focusedLeafId: string | null };
|
||||
store: LayoutStoreApi;
|
||||
sessions?: ReadonlyMap<Uuid, Session>;
|
||||
onDisconnect?: (sessionId: Uuid) => void;
|
||||
previewedSessionId: Uuid | null;
|
||||
commitPreview: (sessionId: Uuid) => void;
|
||||
}
|
||||
|
||||
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
|
||||
if (node.kind === "leaf") {
|
||||
return <Pane leaf={node} isFocused={rest.tab.focusedLeafId === node.id} {...rest} />;
|
||||
}
|
||||
return <SplitContainer node={node} {...rest} />;
|
||||
}
|
||||
|
||||
function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { kind: "split" }> }) {
|
||||
const slotA = useRef<HTMLDivElement | null>(null);
|
||||
const slotB = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const onDividerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const divider = e.currentTarget;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
divider.setPointerCapture(e.pointerId);
|
||||
const rect = container.getBoundingClientRect();
|
||||
const move = (ev: PointerEvent) => {
|
||||
const frac =
|
||||
node.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height;
|
||||
node.ratio = Math.max(0.1, Math.min(0.9, frac));
|
||||
if (slotA.current) slotA.current.style.flexGrow = String(node.ratio);
|
||||
if (slotB.current) slotB.current.style.flexGrow = String(1 - node.ratio);
|
||||
};
|
||||
const up = (ev: PointerEvent) => {
|
||||
try {
|
||||
divider.releasePointerCapture(ev.pointerId);
|
||||
} catch {
|
||||
// already released
|
||||
}
|
||||
divider.removeEventListener("pointermove", move);
|
||||
divider.removeEventListener("pointerup", up);
|
||||
rest.store.getState().commitLayout();
|
||||
};
|
||||
divider.addEventListener("pointermove", move);
|
||||
divider.addEventListener("pointerup", up);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`mv-split flex h-full min-h-0 w-full ${node.dir === "row" ? "flex-row" : "flex-col"}`}
|
||||
>
|
||||
<div ref={slotA} className="mv-slot min-h-0 min-w-0" style={{ flex: `${node.ratio} 1 0` }}>
|
||||
<SplitNode node={node.a} {...rest} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"mv-divider flex-none bg-border-strong hover:bg-accent " +
|
||||
(node.dir === "row" ? "w-[3px] cursor-col-resize" : "h-[3px] cursor-row-resize")
|
||||
}
|
||||
onPointerDown={onDividerPointerDown}
|
||||
/>
|
||||
<div ref={slotB} className="mv-slot min-h-0 min-w-0" style={{ flex: `${1 - node.ratio} 1 0` }}>
|
||||
<SplitNode node={node.b} {...rest} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pane({
|
||||
leaf,
|
||||
isFocused,
|
||||
store,
|
||||
sessions,
|
||||
onDisconnect,
|
||||
previewedSessionId,
|
||||
commitPreview,
|
||||
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
||||
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
||||
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
|
||||
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
||||
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
|
||||
}
|
||||
data-leaf-id={leaf.id}
|
||||
data-session-id={leaf.sessionId ?? undefined}
|
||||
onPointerDownCapture={() => {
|
||||
// Clicking into a previewed pane is the operator choosing it
|
||||
// (docs/PREVIEW.md).
|
||||
if (isPreview) commitPreview(leaf.sessionId!);
|
||||
store.getState().focusLeaf(leaf.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
|
||||
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
|
||||
}
|
||||
>
|
||||
{leaf.sessionId ? (
|
||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||
) : (
|
||||
<span className="text-faint">Empty pane</span>
|
||||
)}
|
||||
<div className="mv-pane-tools flex items-center gap-sp-1">
|
||||
{leaf.sessionId && onDisconnect && (
|
||||
<button
|
||||
type="button"
|
||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||
title="Disconnect"
|
||||
onClick={() => onDisconnect(leaf.sessionId!)}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||
title="Split right"
|
||||
onClick={() => splitFocused("row")}
|
||||
>
|
||||
▥
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||
title="Split down"
|
||||
onClick={() => splitFocused("col")}
|
||||
>
|
||||
▤
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
||||
title="Close pane"
|
||||
onClick={() => store.getState().closePane(leaf.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mv-pane-host min-h-0 flex-1">
|
||||
{leaf.sessionId ? (
|
||||
<PaneContainer
|
||||
sessionId={leaf.sessionId}
|
||||
session={session}
|
||||
focus={isFocused && previewedSessionId === null}
|
||||
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
|
||||
/>
|
||||
) : (
|
||||
<div className="mv-pane-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
||||
Empty pane — pick a session from the sidebar, or spawn one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Session } from "../../types.js";
|
||||
import { PaneContainer } from "./PaneContainer.js";
|
||||
|
||||
vi.mock("../terminal/TerminalPane.js", () => ({
|
||||
TerminalPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
|
||||
<div data-testid="terminal-pane" data-session-id={sessionId} data-focus={String(focus)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../structured/StructuredPane.js", () => ({
|
||||
StructuredPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
|
||||
<div data-testid="structured-pane" data-session-id={sessionId} data-focus={String(focus)} />
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function session(over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
cwd: "/home/n",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("PaneContainer", () => {
|
||||
it("renders TerminalPane when the session has no view (defaults to Terminal)", () => {
|
||||
render(<PaneContainer sessionId="s1" session={session()} />);
|
||||
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
|
||||
expect(screen.queryByTestId("structured-pane")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders TerminalPane when the session isn't known yet", () => {
|
||||
render(<PaneContainer sessionId="s1" />);
|
||||
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders StructuredPane for a Structured session", () => {
|
||||
render(<PaneContainer sessionId="s1" session={session({ view: "Structured" })} focus={false} />);
|
||||
const pane = screen.getByTestId("structured-pane");
|
||||
expect(pane.dataset.sessionId).toBe("s1");
|
||||
expect(pane.dataset.focus).toBe("false");
|
||||
expect(screen.queryByTestId("terminal-pane")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Dispatches a leaf's session to the right body — TerminalPane for a PTY
|
||||
// session, StructuredPane for an ACP one (session.view, #133/#140/#249).
|
||||
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import type { Session, Uuid } from "../../types.js";
|
||||
import { StructuredPane, type StructuredPaneHandle } from "../structured/StructuredPane.js";
|
||||
import { TerminalPane, type TerminalPaneHandle } from "../terminal/TerminalPane.js";
|
||||
|
||||
export interface PaneContainerHandle {
|
||||
focusTerminal(): void;
|
||||
/** No-op for a structured pane — it has no PTY (pane.ts's PaneConnection contract). */
|
||||
sendInput(data: string): void;
|
||||
}
|
||||
|
||||
export interface PaneContainerProps {
|
||||
sessionId: Uuid;
|
||||
session?: Session;
|
||||
/** Grabs the keyboard once connected. A sidebar preview passes `false`. */
|
||||
focus?: boolean;
|
||||
onClosed?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PaneContainer = forwardRef<PaneContainerHandle, PaneContainerProps>(function PaneContainer(
|
||||
{ sessionId, session, focus, onClosed, className },
|
||||
ref,
|
||||
) {
|
||||
const terminalRef = useRef<TerminalPaneHandle>(null);
|
||||
const structuredRef = useRef<StructuredPaneHandle>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusTerminal: () => {
|
||||
terminalRef.current?.focusTerminal();
|
||||
structuredRef.current?.focusComposer();
|
||||
},
|
||||
sendInput: (data: string) => terminalRef.current?.sendInput(data),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return session?.view === "Structured" ? (
|
||||
<StructuredPane ref={structuredRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
|
||||
) : (
|
||||
<TerminalPane ref={terminalRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Session } from "../../types.js";
|
||||
import { PaneTitle } from "./PaneTitle.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function session(over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
cwd: "/home/n",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("PaneTitle", () => {
|
||||
it("falls back to a short session id when the session isn't known yet", () => {
|
||||
render(<PaneTitle sessionId="abcdef12-3456-7890-abcd-ef1234567890" />);
|
||||
expect(screen.getByText(/abcdef12/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the name alone when the command matches it", () => {
|
||||
render(<PaneTitle sessionId="s1" session={session({ command: "claude", metadata: { name: "claude" } })} />);
|
||||
expect(screen.getByText("claude")).toBeTruthy();
|
||||
expect(document.querySelector(".at-cmd")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the command alongside a custom name", () => {
|
||||
render(
|
||||
<PaneTitle sessionId="s1" session={session({ command: "hx src/main.rs", metadata: { name: "editor" } })} />,
|
||||
);
|
||||
expect(screen.getByText("editor")).toBeTruthy();
|
||||
expect(document.querySelector(".at-cmd")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("never injects markup from a session name", () => {
|
||||
render(<PaneTitle sessionId="s1" session={session({ metadata: { name: "<img onerror=x>" } })} />);
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
expect(screen.getByText("<img onerror=x>")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// React port of paneTitle.ts: a pane header's session identity — the name,
|
||||
// then the command as its tool mark when recognised, same as the sidebar
|
||||
// card's own CommandFragment (SessionCard.tsx). JSX escapes text nodes, so
|
||||
// unlike the vanilla string-builder this needs no manual HTML escaping.
|
||||
|
||||
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
|
||||
import { sessionName, type Session, type Uuid } from "../../types.js";
|
||||
|
||||
export interface PaneTitleProps {
|
||||
sessionId: Uuid;
|
||||
session?: Session;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
|
||||
if (!session) {
|
||||
return (
|
||||
<span className={className ?? "at-title"} title={sessionId}>
|
||||
session <span className="sid">{sessionId.slice(0, 8)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const name = sessionName(session);
|
||||
const showCommand = Boolean(session.command) && session.command !== name;
|
||||
const { tool, program } = splitCommand(session.command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
|
||||
return (
|
||||
<span className={className ?? "at-title"} title={`${name} · ${session.command} (${sessionId})`}>
|
||||
<span className="at-name">{name}</span>
|
||||
{showCommand && (
|
||||
<span className="at-cmd">
|
||||
{icon ? <span className="contents" dangerouslySetInnerHTML={{ __html: icon }} /> : <span>{program}</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createLayoutStore } from "../../stores/layoutStore.js";
|
||||
import { TabBar } from "./TabBar.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function store() {
|
||||
return createLayoutStore({
|
||||
load: () => null,
|
||||
save: vi.fn(),
|
||||
scheduler: { setTimer: () => 1, clearTimer: () => {} },
|
||||
});
|
||||
}
|
||||
|
||||
function dataTransfer() {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
setData: (type: string, value: string) => data.set(type, value),
|
||||
getData: (type: string) => data.get(type) ?? "",
|
||||
effectAllowed: "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("TabBar", () => {
|
||||
it("renders one tab per layout tab, marking the active one", () => {
|
||||
const s = store();
|
||||
s.getState().addTab();
|
||||
render(<TabBar store={s} />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs).toHaveLength(2);
|
||||
expect(tabs[1].getAttribute("aria-selected")).toBe("true");
|
||||
expect(tabs[0].getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows the open-session count next to a tab's title", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<TabBar store={s} />);
|
||||
expect(screen.getByText(/· 1/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking a tab selects it", () => {
|
||||
const s = store();
|
||||
s.getState().addTab();
|
||||
const [first] = s.getState().layout.tabs;
|
||||
render(<TabBar store={s} />);
|
||||
fireEvent.click(screen.getAllByRole("tab")[0]);
|
||||
expect(s.getState().layout.activeTabId).toBe(first.id);
|
||||
});
|
||||
|
||||
it("the + button adds a tab", () => {
|
||||
const s = store();
|
||||
render(<TabBar store={s} />);
|
||||
fireEvent.click(screen.getByTitle("New tab"));
|
||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides the close control on the last remaining tab", () => {
|
||||
const s = store();
|
||||
render(<TabBar store={s} />);
|
||||
expect(screen.queryByTitle("Close tab")).toBeNull();
|
||||
});
|
||||
|
||||
it("closing a tab removes it", () => {
|
||||
const s = store();
|
||||
s.getState().addTab();
|
||||
render(<TabBar store={s} />);
|
||||
fireEvent.click(screen.getAllByTitle("Close tab")[0]);
|
||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("dragging a tab onto another reorders them", () => {
|
||||
const s = store();
|
||||
s.getState().addTab();
|
||||
s.getState().addTab();
|
||||
const [a, b, c] = s.getState().layout.tabs;
|
||||
render(<TabBar store={s} />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
const dt = dataTransfer();
|
||||
fireEvent.dragStart(tabs[0], { dataTransfer: dt });
|
||||
fireEvent.dragOver(tabs[2], { dataTransfer: dt });
|
||||
fireEvent.drop(tabs[2], { dataTransfer: dt });
|
||||
expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([b.id, c.id, a.id]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// The tab strip above the canvas (issue #249): click to switch, drag to
|
||||
// reorder, a close ✕ per tab (hidden on the last one), a trailing + to add.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { leaves } from "../../layout.js";
|
||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||
|
||||
export interface TabBarProps {
|
||||
store: LayoutStoreApi;
|
||||
}
|
||||
|
||||
export function TabBar({ store }: TabBarProps) {
|
||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
||||
const previewTempTabId = useStore(store, (s: LayoutStore) => s.preview?.tempTabId ?? null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="mv-tabs flex items-center gap-sp-1 overflow-x-auto border-b border-border-strong bg-surface px-sp-2 text-fs-12" role="tablist">
|
||||
{layout.tabs.map((tab) => {
|
||||
const count = leaves(tab.root).filter((l) => l.sessionId).length;
|
||||
const isActive = tab.id === layout.activeTabId;
|
||||
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
|
||||
// A preview's throwaway tab is not a place to go — it goes on its
|
||||
// own the moment the preview ends (docs/PREVIEW.md).
|
||||
const isPreviewTab = tab.id === previewTempTabId;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
draggable
|
||||
data-tab-id={tab.id}
|
||||
className={
|
||||
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
|
||||
(isPreviewTab
|
||||
? "border border-dashed border-preview-ring italic text-preview"
|
||||
: isActive
|
||||
? "bg-surface-2 text-ink"
|
||||
: "text-faint hover:text-ink") +
|
||||
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
||||
}
|
||||
onDragStart={(e) => {
|
||||
setDraggingId(tab.id);
|
||||
e.dataTransfer.setData("text/plain", tab.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!draggingId || draggingId === tab.id) return;
|
||||
e.preventDefault();
|
||||
setOverId(tab.id);
|
||||
}}
|
||||
onDragLeave={() => setOverId((current) => (current === tab.id ? null : current))}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const fromId = e.dataTransfer.getData("text/plain") || draggingId;
|
||||
setDraggingId(null);
|
||||
setOverId(null);
|
||||
if (fromId && fromId !== tab.id) store.getState().reorderTab(fromId, tab.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggingId(null);
|
||||
setOverId(null);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isPreviewTab) return;
|
||||
store.getState().selectTab(tab.id);
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{tab.title}
|
||||
{count ? ` · ${count}` : ""}
|
||||
</span>
|
||||
{layout.tabs.length > 1 && !isPreviewTab && (
|
||||
<span
|
||||
className="mv-tab-close text-faint hover:text-ink"
|
||||
title="Close tab"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
store.getState().closeTab(tab.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="mv-tab-add px-sp-2 py-sp-1 text-muted hover:text-ink"
|
||||
title="New tab"
|
||||
onClick={() => store.getState().addTab()}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApprovalView } from "./ApprovalView.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const ALL_OPTIONS = [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
|
||||
{ optionId: "r1", name: "Reject", kind: "reject_once" as const },
|
||||
];
|
||||
|
||||
function buttons(): string[] {
|
||||
return [...document.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("ApprovalView", () => {
|
||||
it("asks the operator with the three decisions the daemon exposes", () => {
|
||||
render(
|
||||
<ApprovalView
|
||||
request={{ toolCall: { title: "Write(a.ts)", locations: [{ path: "/repo/a.ts" }] }, options: ALL_OPTIONS }}
|
||||
pending
|
||||
onDecide={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(buttons()).toEqual(["Allow once", "Allow for session", "Deny"]);
|
||||
expect(screen.getByText("Write(a.ts)")).toBeTruthy();
|
||||
expect(screen.getByText("/repo/a.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("submits the decision the operator picked", async () => {
|
||||
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
|
||||
fireEvent.click(screen.getByText("Deny"));
|
||||
await waitFor(() => expect(onDecide).toHaveBeenCalledWith("deny"));
|
||||
});
|
||||
|
||||
it("disables every button once one is pressed, so a decision is sent once", () => {
|
||||
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={() => new Promise(() => {})} />);
|
||||
fireEvent.click(screen.getByText("Allow once"));
|
||||
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
|
||||
expect(all.every((b) => b.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it("restores the controls when the decision failed and this approval is still pending", async () => {
|
||||
const onDecide = vi.fn().mockRejectedValue(new Error("nope"));
|
||||
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
|
||||
fireEvent.click(screen.getByText("Allow once"));
|
||||
await waitFor(() => {
|
||||
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
|
||||
expect(all.every((b) => !b.disabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("offers no button for a decision the request cannot back", () => {
|
||||
render(
|
||||
<ApprovalView
|
||||
request={{ options: [{ optionId: "a1", name: "Allow once", kind: "allow_once" }] }}
|
||||
pending
|
||||
onDecide={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(buttons()).toEqual(["Allow once"]);
|
||||
});
|
||||
|
||||
it("says so when the request offers no decision at all", () => {
|
||||
render(<ApprovalView request={{ options: [] }} pending onDecide={() => Promise.resolve()} />);
|
||||
expect(screen.getByText("no decision offered")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a resolved approval as a one-liner, with no controls", () => {
|
||||
render(
|
||||
<ApprovalView
|
||||
request={{ toolCall: { title: "Write(a.ts)" }, options: ALL_OPTIONS }}
|
||||
pending={false}
|
||||
onDecide={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Write(a.ts) — approved")).toBeTruthy();
|
||||
expect(document.querySelector(".st-approval-acts")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// An `Approval` row. The daemon exposes exactly three decisions
|
||||
// (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
|
||||
// them. A decision the request offers no matching ACP option for is not shown
|
||||
// — its button would "allow" something that silently resolves `Cancelled`.
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AcpPermissionOption, AcpToolCallLike, StructuredApprovalDecision } from "../../types.js";
|
||||
import { offeredDecisions, toolKindLabel } from "./model.js";
|
||||
|
||||
const SPECS: [StructuredApprovalDecision, string, string][] = [
|
||||
["allow_once", "Allow once", "primary"],
|
||||
["allow_for_session", "Allow for session", ""],
|
||||
["deny", "Deny", "deny"],
|
||||
];
|
||||
|
||||
export interface ApprovalViewProps {
|
||||
/** The `RequestPermissionRequest` the row carries — serialized without its
|
||||
* nonce, which never leaves the daemon's live wire. */
|
||||
request: { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||
/** Whether this is the one live approval. A restored/answered row is not. */
|
||||
pending: boolean;
|
||||
/** Resolves once the decision reached the daemon; rejects only while this
|
||||
* approval is still the pending one, so the buttons can come back. */
|
||||
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ApprovalView({ request, pending, onDecide }: ApprovalViewProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toolCall = request.toolCall ?? {};
|
||||
const what = toolCall.title ?? toolKindLabel(toolCall.kind);
|
||||
|
||||
// Resolved approvals render as a compact one-liner inside a tool group (or
|
||||
// are dropped entirely when standalone — see model.ts's transcriptItems).
|
||||
if (!pending) {
|
||||
return <div className="st-row st-row-approval st-approval-compact">{`${what} — approved`}</div>;
|
||||
}
|
||||
|
||||
const offered = offeredDecisions(request.options ?? []);
|
||||
const path = toolCall.locations?.[0]?.path;
|
||||
|
||||
const decide = (decision: StructuredApprovalDecision) => {
|
||||
setBusy(true);
|
||||
onDecide(decision).catch(() => {
|
||||
// Rejects only while this approval is still pending — restore the
|
||||
// controls so the operator can retry. The error itself is surfaced by
|
||||
// the pane's banner.
|
||||
setBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="st-row st-row-approval">
|
||||
<div className="st-approval">
|
||||
<div className="st-approval-title">
|
||||
<span aria-hidden="true">⚠</span> permission requested
|
||||
</div>
|
||||
<div className="st-approval-what">{what}</div>
|
||||
{path && <div className="st-approval-path">{path}</div>}
|
||||
{offered.length > 0 ? (
|
||||
<div className="st-approval-acts">
|
||||
{SPECS.filter(([decision]) => offered.includes(decision)).map(([decision, label, cls]) => (
|
||||
<button
|
||||
key={decision}
|
||||
type="button"
|
||||
className={cls ? `act ${cls}` : "act"}
|
||||
disabled={busy}
|
||||
onClick={() => decide(decision)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="st-approval-resolved">no decision offered</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// The composer: draft, attachments, `/` command autocomplete off the live
|
||||
// `available_commands_update` notices, the session-settings menu, and the
|
||||
// send/cancel pair. Sending is the pane's job — this only hands it a draft and
|
||||
// the files that go with it.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type ClipboardEvent,
|
||||
type DragEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
|
||||
import type { AcpSessionConfigChoice, AcpSessionConfigOption } from "../../types.js";
|
||||
import {
|
||||
applyCommand,
|
||||
commandTokenAt,
|
||||
filterCommands,
|
||||
orderConfigOptions,
|
||||
type AvailableCommand,
|
||||
} from "./model.js";
|
||||
|
||||
/** Per-message ceilings the daemon enforces on uploads
|
||||
* (docs/STRUCTURED-VIEW.md, "Prompt and cancel"). */
|
||||
export const MAX_ATTACHMENTS = 10;
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
/** Pasted text at or above either bound becomes a `.txt` attachment instead of
|
||||
* going inline. */
|
||||
const PASTE_TO_FILE_CHARS = 4_000;
|
||||
const PASTE_TO_FILE_LINES = 40;
|
||||
|
||||
export interface ComposerProps {
|
||||
/** Files chosen but not yet sent. Owned by the pane so a failed send can put
|
||||
* them back. */
|
||||
attachments: File[];
|
||||
onAttachmentsChange(files: File[]): void;
|
||||
commands: AvailableCommand[];
|
||||
configOptions: AcpSessionConfigOption[];
|
||||
/** A turn is in flight — the cancel control replaces nothing, it appears
|
||||
* beside send (a queued message is still allowed). */
|
||||
turnActive: boolean;
|
||||
/** An upload/POST is in flight: the attachment controls are frozen. */
|
||||
uploading: boolean;
|
||||
configBusy: boolean;
|
||||
/** Restored into the box when a send fails. */
|
||||
draft: string;
|
||||
onDraftChange(draft: string): void;
|
||||
onSubmit(text: string, attachments: File[]): void;
|
||||
onCancel(): void;
|
||||
onConfigChange(option: AcpSessionConfigOption, value: string | boolean): void;
|
||||
onError(message: string): void;
|
||||
menuId: string;
|
||||
}
|
||||
|
||||
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
|
||||
return (option.options ?? []).flatMap((choice) => ("options" in choice ? choice.options : [choice]));
|
||||
}
|
||||
|
||||
function currentConfigLabel(option: AcpSessionConfigOption): string {
|
||||
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
|
||||
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
|
||||
?? String(option.currentValue);
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
attachments,
|
||||
onAttachmentsChange,
|
||||
commands,
|
||||
configOptions,
|
||||
turnActive,
|
||||
uploading,
|
||||
configBusy,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onConfigChange,
|
||||
onError,
|
||||
menuId,
|
||||
}: ComposerProps) {
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const configRef = useRef<HTMLDivElement | null>(null);
|
||||
const [caret, setCaret] = useState(0);
|
||||
const [activeCommand, setActiveCommand] = useState(0);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
/** Escape closes the menu until the draft or caret moves again. */
|
||||
const [menuDismissed, setMenuDismissed] = useState(false);
|
||||
const pasteCount = useRef(0);
|
||||
|
||||
// Grow with the draft, up to the CSS max-height; past that it scrolls.
|
||||
useLayoutEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.style.height = "auto";
|
||||
input.style.overflowY = "hidden";
|
||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||
if (input.scrollHeight > 160) input.style.overflowY = "auto";
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configOpen) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!configRef.current?.contains(event.target as Node)) setConfigOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||
}, [configOpen]);
|
||||
|
||||
const token = commandTokenAt(draft, caret, caret);
|
||||
const matches = token && commands.length > 0 ? filterCommands(commands, token.query) : [];
|
||||
const menuOpen = matches.length > 0 && !menuDismissed;
|
||||
const activeIndex = menuOpen ? Math.min(Math.max(activeCommand, 0), matches.length - 1) : -1;
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (uploading) return;
|
||||
const next = [...attachments];
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
onError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||||
} else if (next.length >= MAX_ATTACHMENTS) {
|
||||
onError(`Attach up to ${MAX_ATTACHMENTS} files per message.`);
|
||||
} else {
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
onAttachmentsChange(next);
|
||||
},
|
||||
[attachments, onAttachmentsChange, onError, uploading],
|
||||
);
|
||||
|
||||
const syncCaret = () => {
|
||||
setMenuDismissed(false);
|
||||
setCaret(inputRef.current?.selectionStart ?? 0);
|
||||
};
|
||||
|
||||
const chooseCommand = (command: AvailableCommand) => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
const current = commandTokenAt(draft, input.selectionStart, input.selectionEnd);
|
||||
if (!current) return;
|
||||
const applied = applyCommand(draft, current, command.name);
|
||||
onDraftChange(applied.value);
|
||||
setActiveCommand(0);
|
||||
// The caret has to move after React has written the new value.
|
||||
requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
input.setSelectionRange(applied.cursor, applied.cursor);
|
||||
setCaret(applied.cursor);
|
||||
});
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if ((!draft.trim() && attachments.length === 0) || uploading) return;
|
||||
onSubmit(draft, attachments);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (menuOpen) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
||||
setActiveCommand((index) => (index + direction + matches.length) % matches.length);
|
||||
return;
|
||||
}
|
||||
if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
chooseCommand(matches[activeIndex]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMenuDismissed(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard || uploading) return;
|
||||
if (clipboard.files.length) {
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(clipboard.files));
|
||||
return;
|
||||
}
|
||||
const text = clipboard.getData("text/plain");
|
||||
if (text.length < PASTE_TO_FILE_CHARS && text.split("\n").length < PASTE_TO_FILE_LINES) return;
|
||||
// No slot for it, or too big to upload: leave it inline rather than
|
||||
// dropping it on the floor.
|
||||
if (attachments.length >= MAX_ATTACHMENTS || new Blob([text]).size > MAX_ATTACHMENT_BYTES) return;
|
||||
event.preventDefault();
|
||||
addFiles([new File([text], `pasted-${++pasteCount.current}.txt`, { type: "text/plain" })]);
|
||||
};
|
||||
|
||||
const ordered = orderConfigOptions(configOptions);
|
||||
const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
|
||||
|
||||
return (
|
||||
<form
|
||||
className="structured-composer"
|
||||
onSubmit={(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
onDragOver={(event: DragEvent) => {
|
||||
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
|
||||
}}
|
||||
onDrop={(event: DragEvent) => {
|
||||
if (!event.dataTransfer?.files.length) return;
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(event.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="structured-attach"
|
||||
aria-label="Attach files"
|
||||
title="Attach files (up to 20 MiB each)"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
className="structured-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
disabled={uploading}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="structured-input-wrap">
|
||||
<div className="structured-attachments" aria-label="Attachments" aria-live="polite" hidden={attachments.length === 0}>
|
||||
{attachments.map((file, index) => (
|
||||
<span key={`${file.name}-${index}`} className="structured-attachment">
|
||||
<span title={`${file.name} (${file.size.toLocaleString()} bytes)`}>{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
disabled={uploading}
|
||||
onClick={() => onAttachmentsChange(attachments.filter((_, i) => i !== index))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div id={menuId} className="structured-command-menu" role="listbox" hidden={!menuOpen}>
|
||||
{matches.map((command, index) => (
|
||||
<div
|
||||
key={command.name}
|
||||
id={`${menuId}-option-${index}`}
|
||||
className="structured-command-option"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => chooseCommand(command)}
|
||||
>
|
||||
<div className="structured-command-title">
|
||||
<span className="structured-command-name">{`/${command.name}`}</span>
|
||||
<span className="structured-command-hint">{command.input?.hint ?? ""}</span>
|
||||
</div>
|
||||
<div className="structured-command-description">
|
||||
{command.description.replace(/\s+/g, " ").trim()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="structured-input"
|
||||
rows={1}
|
||||
placeholder="Reply, or / for commands…"
|
||||
autoComplete="off"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls={menuId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
onDraftChange(event.target.value);
|
||||
setActiveCommand(0);
|
||||
setMenuDismissed(false);
|
||||
setCaret(event.target.selectionStart ?? event.target.value.length);
|
||||
}}
|
||||
onClick={syncCaret}
|
||||
onSelect={syncCaret}
|
||||
onKeyUp={(event) => {
|
||||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) syncCaret();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
</div>
|
||||
<div className="structured-config" ref={configRef} hidden={ordered.length === 0}>
|
||||
<button
|
||||
type="button"
|
||||
className="structured-config-trigger"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={configOpen}
|
||||
aria-label={`Session settings. ${summary.replaceAll("\n", ". ")}`}
|
||||
title={summary}
|
||||
disabled={configBusy}
|
||||
onClick={() => setConfigOpen((open) => !open)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="structured-config-menu"
|
||||
role="group"
|
||||
aria-label="Session settings"
|
||||
hidden={!configOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
setConfigOpen(false);
|
||||
configRef.current?.querySelector<HTMLButtonElement>(".structured-config-trigger")?.focus();
|
||||
}}
|
||||
>
|
||||
{ordered.map((option) => (
|
||||
<label key={option.id} className="structured-config-row" title={option.description ?? undefined}>
|
||||
<span className="structured-config-name">{option.name}</span>
|
||||
{option.type === "select" ? (
|
||||
<select
|
||||
className="structured-config-select"
|
||||
disabled={configBusy}
|
||||
value={String(option.currentValue)}
|
||||
onChange={(event) => onConfigChange(option, event.target.value)}
|
||||
>
|
||||
{configChoices(option).map((choice) => (
|
||||
<option key={choice.value} value={choice.value} title={choice.description ?? ""}>
|
||||
{choice.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : option.type === "boolean" ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="structured-config-toggle"
|
||||
disabled={configBusy}
|
||||
checked={option.currentValue === true}
|
||||
onChange={(event) => onConfigChange(option, event.target.checked)}
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="structured-send">
|
||||
<span>Send</span>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="m7 5 5 5-5 5M12 10H3" />
|
||||
</svg>
|
||||
</button>
|
||||
{turnActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="structured-cancel"
|
||||
aria-label="Cancel turn"
|
||||
title="Cancel turn"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<svg className="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="50 14" strokeLinecap="round" />
|
||||
</svg>
|
||||
<svg className="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// docs/STRUCTURED-VIEW.md: the web client's user, agent, thought, tool-detail
|
||||
// and notice text blocks copy their complete source text when clicked or
|
||||
// keyboard-activated. The visible node is whatever it wraps; this only adds
|
||||
// the affordance and the "copied"/"error" state the CSS reads.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
|
||||
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand?.("copy") ?? false;
|
||||
textarea.remove();
|
||||
if (!copied) throw new Error("clipboard unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
export interface CopyableProps {
|
||||
/** The complete source text — not whatever the DOM happens to show. */
|
||||
text: string;
|
||||
/** Names the thing in the "Copy …" label ("message", "diff", "path", …). */
|
||||
label: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
title?: string;
|
||||
children?: ReactNode;
|
||||
/** Extra attributes the row builders set (`data-row-id`, `lang`, …). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Wrapping a node in a copy affordance. Empty `text` renders the node plain —
|
||||
* a control that copies nothing is worse than no control. */
|
||||
export function Copyable({
|
||||
text,
|
||||
label,
|
||||
as: Tag = "div",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: CopyableProps) {
|
||||
const [copyState, setCopyState] = useState<"copied" | "error" | null>(null);
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current !== null) window.clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
void writeClipboard(text).then(
|
||||
() => {
|
||||
setCopyState("copied");
|
||||
if (timer.current !== null) window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopyState(null), 1200);
|
||||
},
|
||||
() => setCopyState("error"),
|
||||
);
|
||||
}, [text]);
|
||||
|
||||
if (!text) {
|
||||
return (
|
||||
<Tag className={className} {...rest}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const ariaLabel = copyState === "copied" ? `${label} copied` : `Copy ${label}`;
|
||||
return (
|
||||
<Tag
|
||||
className={className ? `${className} st-copyable` : "st-copyable"}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={ariaLabel}
|
||||
title={`Copy ${label}`}
|
||||
data-copy-state={copyState ?? undefined}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
// A link inside the block is a link, not a copy target.
|
||||
if ((event.target as Element | null)?.closest("a")) return;
|
||||
copy();
|
||||
}}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
copy();
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import { toolDiffMeta } from "./model.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function lines(kind: string): string[] {
|
||||
return [...document.querySelectorAll(`.st-ln-${kind}`)].map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
describe("DiffView", () => {
|
||||
it("marks insertions and deletions with their column prefixes", () => {
|
||||
render(<DiffView diff={{ path: "src/a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
|
||||
expect(lines("del")).toEqual(["- two"]);
|
||||
expect(lines("add")).toEqual(["+ TWO", "+ three"]);
|
||||
expect(lines("ctx")).toEqual([" one"]);
|
||||
expect(screen.getByText("src/a.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("carries the +added −removed counts the tool head shows", () => {
|
||||
render(<DiffView diff={{ path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
|
||||
const body = document.querySelector<HTMLElement>(".st-diff")!;
|
||||
expect(body.dataset.added).toBe("2");
|
||||
expect(body.dataset.removed).toBe("1");
|
||||
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
|
||||
.toBe("+2 −1");
|
||||
});
|
||||
|
||||
it("renders every line of a new file as added", () => {
|
||||
render(<DiffView diff={{ path: "new.ts", newText: "alpha\nbeta\n" }} />);
|
||||
expect(lines("add")).toEqual(["+ alpha", "+ beta"]);
|
||||
expect(lines("del")).toEqual([]);
|
||||
});
|
||||
|
||||
it("collapses a long unchanged run to a hunk summary", () => {
|
||||
const filler = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n");
|
||||
render(<DiffView diff={{ path: "a.ts", oldText: `head\n${filler}\n`, newText: `HEAD\n${filler}\n` }} />);
|
||||
expect(lines("hunk")).toEqual(["⋯ 17 unchanged lines ⋯"]);
|
||||
});
|
||||
|
||||
it("copies the whole diff, prefixes included", () => {
|
||||
render(<DiffView diff={{ path: "a.ts", oldText: "one\n", newText: "two\n" }} />);
|
||||
const body = document.querySelector<HTMLElement>(".st-diff")!;
|
||||
expect(body.getAttribute("aria-label")).toBe("Copy diff");
|
||||
expect(body.textContent).toBe("- one+ two");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// A `Diff` content block, rendered as coloured hunks. ACP hands over whole
|
||||
// `oldText`/`newText`, so the lines and their `+added −removed` counts are
|
||||
// computed here (structuredDiff.ts) rather than parsed out of a patch.
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { computeDiffLines, diffStats, type DiffLine } from "../../structuredDiff.js";
|
||||
import type { AcpDiff } from "../../types.js";
|
||||
import { Copyable } from "./Copyable.js";
|
||||
|
||||
/** Column prefix by line kind. A `hunk` summary and an `eof` marker are their
|
||||
* own complete text, so neither takes one. */
|
||||
const PREFIX: Record<DiffLine["kind"], string> = {
|
||||
add: "+ ",
|
||||
del: "- ",
|
||||
ctx: " ",
|
||||
hunk: "",
|
||||
eof: "",
|
||||
};
|
||||
|
||||
export function diffLineText(line: DiffLine): string {
|
||||
return PREFIX[line.kind] + line.text;
|
||||
}
|
||||
|
||||
export interface DiffViewProps {
|
||||
diff: AcpDiff;
|
||||
}
|
||||
|
||||
export function DiffView({ diff }: DiffViewProps) {
|
||||
const lines = useMemo(() => computeDiffLines(diff.oldText, diff.newText), [diff.oldText, diff.newText]);
|
||||
const stats = useMemo(() => diffStats(lines), [lines]);
|
||||
const plain = useMemo(() => lines.map(diffLineText).join("\n"), [lines]);
|
||||
|
||||
return (
|
||||
<div className="st-diff-block">
|
||||
<Copyable className="st-diff-path" text={diff.path} label="path" title={diff.path}>
|
||||
{diff.path}
|
||||
</Copyable>
|
||||
<Copyable
|
||||
className="st-diff"
|
||||
text={plain}
|
||||
label="diff"
|
||||
data-added={stats.added}
|
||||
data-removed={stats.removed}
|
||||
>
|
||||
<pre>
|
||||
{lines.map((line, index) => (
|
||||
<span key={index} className={`st-ln st-ln-${line.kind}`}>
|
||||
{diffLineText(line)}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
</Copyable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
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),
|
||||
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
|
||||
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
||||
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
||||
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
uploadStructuredFile,
|
||||
} from "../../api.js";
|
||||
import { INITIAL_RECONNECT_MS } from "../../reconnect.js";
|
||||
import type { ReducedState, StructuredEvent, TranscriptRow } from "../../types.js";
|
||||
import { StructuredPane } from "./StructuredPane.js";
|
||||
|
||||
/** Just enough of a WebSocket for the pane to drive (mirrors structured.test.ts's
|
||||
* own fake, which the pre-React connection used). */
|
||||
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 {
|
||||
act(() => this.onopen?.());
|
||||
}
|
||||
emit(event: StructuredEvent): void {
|
||||
act(() => this.onmessage?.({ data: JSON.stringify(event) }));
|
||||
}
|
||||
drop(code = 1006, reason = ""): void {
|
||||
act(() => this.onclose?.({ code, reason }));
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||
const busy: ReducedState = { turn: { responding: true, thinking: false, active_tool_calls: [] } };
|
||||
|
||||
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
function chunk(id: string, kind: "user" | "agent_text" | "agent_thought", text: string): TranscriptRow {
|
||||
return row(id, kind, { content: { type: "text", text } });
|
||||
}
|
||||
|
||||
const APPROVAL_REQUEST = {
|
||||
toolCall: { title: "Write(secrets.txt)", kind: "edit", locations: [{ path: "/repo/secrets.txt" }] },
|
||||
options: [
|
||||
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
|
||||
{ optionId: "r1", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
};
|
||||
|
||||
function mount(): { socket: FakeSocket; sockets: FakeSocket[] } {
|
||||
const sockets: FakeSocket[] = [];
|
||||
render(
|
||||
<StructuredPane
|
||||
sessionId="s1"
|
||||
socketFactory={() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as WebSocket;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
sockets[0].open();
|
||||
return { socket: sockets[0], sockets };
|
||||
}
|
||||
|
||||
function snapshot(socket: FakeSocket, rows: TranscriptRow[], state: ReducedState = idle): void {
|
||||
socket.emit({ type: "transcript_snapshot", snapshot: { cursor: rows.length, rows } });
|
||||
socket.emit({ type: "reduced_state", state });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("the eight row kinds", () => {
|
||||
it("renders user, agent, thought, tool, diff, approval and notice rows; never a plan", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(
|
||||
socket,
|
||||
[
|
||||
chunk("user:1", "user", "please edit it"),
|
||||
chunk("agent:1", "agent_text", "**bold** answer"),
|
||||
chunk("thought:1", "agent_thought", "considering"),
|
||||
row("tool:1", "tool_call", { title: "Read(src/main.ts)", kind: "read", status: "completed" }),
|
||||
row("tool:2", "tool_diff", {
|
||||
title: "Edit(a.ts)",
|
||||
kind: "edit",
|
||||
status: "completed",
|
||||
content: [{ type: "diff", path: "a.ts", oldText: "one\n", newText: "one\ntwo\n" }],
|
||||
}),
|
||||
row("approval:1", "approval", APPROVAL_REQUEST),
|
||||
row("plan", "plan", { entries: [{ content: "step", priority: "high", status: "pending" }] }),
|
||||
row("notice:1", "notice", { sessionUpdate: "mystery_update" }),
|
||||
],
|
||||
{ ...idle, pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("please edit it")).toBeTruthy();
|
||||
expect(document.querySelector(".st-row-agent strong")?.textContent).toBe("bold");
|
||||
expect(document.querySelector(".st-row-thought")?.textContent).toContain("considering");
|
||||
expect(screen.getByText("src/main.ts")).toBeTruthy();
|
||||
// The diff row's head carries the line counts, not a fabricated duration.
|
||||
expect(document.querySelector('[data-row-id="tool:2"] .st-tool-meta')?.textContent).toBe("+1 −0");
|
||||
expect(screen.getByText("permission requested")).toBeTruthy();
|
||||
expect(screen.getByText("[notice]")).toBeTruthy();
|
||||
expect(document.querySelector('[data-row-id="plan"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the state-only notices it consumes for `/` autocomplete", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [
|
||||
row("notice:1", "notice", {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "compact", description: "Compact the conversation" }],
|
||||
}),
|
||||
row("notice:2", "notice", { sessionUpdate: "session_info_update" }),
|
||||
]);
|
||||
expect(screen.queryByText("[notice]")).toBeNull();
|
||||
|
||||
// …and offers them in the composer instead.
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
fireEvent.change(input, { target: { value: "/comp" } });
|
||||
expect(screen.getByText("/compact")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a completed turn's footer inline, and retires it once the next turn starts", () => {
|
||||
const { socket } = mount();
|
||||
const footer = row("turn_meta:1", "notice", {
|
||||
maestroTurnMeta: { elapsedMs: 65_000, outcome: "completed", model: "sonnet", contextDelta: 1200 },
|
||||
});
|
||||
snapshot(socket, [chunk("user:1", "user", "go"), footer]);
|
||||
expect(screen.getByText("1m 5s · +1.2k context tokens · sonnet")).toBeTruthy();
|
||||
|
||||
socket.emit({
|
||||
type: "projection_update",
|
||||
transcript: { type: "append", row: chunk("user:2", "user", "again") },
|
||||
state: idle,
|
||||
});
|
||||
expect(screen.queryByText(/1m 5s/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool rows and activity groups", () => {
|
||||
it("collapses two or more tools in a turn into one group, opened on click", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [
|
||||
chunk("user:1", "user", "go"),
|
||||
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||
chunk("agent:1", "agent_text", "still working"),
|
||||
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
|
||||
chunk("agent:2", "agent_text", "the answer"),
|
||||
]);
|
||||
|
||||
const head = screen.getByText("2 tool calls").closest("button")!;
|
||||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||||
// The final answer stays outside the group; the intermediate text is in it.
|
||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
|
||||
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
|
||||
|
||||
fireEvent.click(head);
|
||||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("discloses a tool's output only once its head is opened", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [
|
||||
row("tool:1", "tool_call", {
|
||||
title: "Execute(ls)",
|
||||
kind: "execute",
|
||||
status: "completed",
|
||||
content: [{ type: "content", content: { type: "text", text: "a.ts\nb.ts" } }],
|
||||
rawInput: { command: "ls" },
|
||||
rawOutput: { exit_code: 0 },
|
||||
}),
|
||||
]);
|
||||
expect(document.querySelector(".st-tool-text")).toBeNull();
|
||||
|
||||
fireEvent.click(document.querySelector<HTMLButtonElement>(".st-tool-head")!);
|
||||
expect(document.querySelector(".st-tool-text")?.textContent).toBe("a.ts\nb.ts");
|
||||
expect(screen.getByText("Exit code")).toBeTruthy();
|
||||
expect(document.querySelector(".st-tool-exit-ok")?.textContent).toBe("0");
|
||||
});
|
||||
|
||||
it("patches a tool row in place rather than appending a second one", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "in_progress" })]);
|
||||
expect(document.querySelector(".st-tool-meta")?.textContent).toBe("running");
|
||||
|
||||
socket.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
|
||||
},
|
||||
state: idle,
|
||||
});
|
||||
expect(document.querySelectorAll('[data-row-id="tool:1"]')).toHaveLength(1);
|
||||
expect(document.querySelector(".st-tool-meta")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("approvals", () => {
|
||||
it("answers with the live nonce, offering only the decisions the request backs", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
|
||||
...idle,
|
||||
pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never },
|
||||
});
|
||||
|
||||
expect(screen.getByText("/repo/secrets.txt")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Allow for session"));
|
||||
await waitFor(() =>
|
||||
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "nonce-1", "allow_for_session"),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides a decision the request offers no ACP option for", () => {
|
||||
const { socket } = mount();
|
||||
const request = { toolCall: { title: "Read(a.ts)" }, options: [{ optionId: "r", name: "No", kind: "reject_once" }] };
|
||||
snapshot(socket, [row("approval:1", "approval", request)], {
|
||||
...idle,
|
||||
pending_approval: { nonce: "nonce-2", request: request as never },
|
||||
});
|
||||
expect(screen.getByText("Deny")).toBeTruthy();
|
||||
expect(screen.queryByText("Allow once")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats only the newest approval as pending, and hides resolved standalone ones", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(
|
||||
socket,
|
||||
[row("approval:1", "approval", APPROVAL_REQUEST), row("approval:2", "approval", APPROVAL_REQUEST)],
|
||||
{ ...idle, pending_approval: { nonce: "nonce-3", request: APPROVAL_REQUEST as never } },
|
||||
);
|
||||
expect(screen.getAllByText("permission requested")).toHaveLength(1);
|
||||
expect(screen.queryByText(/approved/)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the controls once the approval is no longer pending", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
|
||||
...idle,
|
||||
pending_approval: { nonce: "nonce-4", request: APPROVAL_REQUEST as never },
|
||||
});
|
||||
expect(screen.getByText("Allow once")).toBeTruthy();
|
||||
|
||||
socket.emit({ type: "reduced_state", state: idle });
|
||||
expect(screen.queryByText("Allow once")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt and cancel", () => {
|
||||
it("sends a draft, clearing the composer", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
fireEvent.change(input, { target: { value: "do the thing" } });
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing"));
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("uploads attachments and names them in the prompt", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
|
||||
fireEvent.change(document.querySelector<HTMLInputElement>(".structured-file-input")!, {
|
||||
target: { files: [file] },
|
||||
});
|
||||
expect(screen.getByText("notes.txt")).toBeTruthy();
|
||||
|
||||
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
|
||||
target: { value: "read this" },
|
||||
});
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
await waitFor(() => expect(uploadStructuredFile).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith(
|
||||
"s1",
|
||||
'read this\n\nAttached file "notes.txt": "/uploads/pasted.txt"',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("queues a second message while a turn is in flight, and sends it when the turn ends", async () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [], busy);
|
||||
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
|
||||
target: { value: "next up" },
|
||||
});
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".st-row-queued")?.textContent).toBe("next up");
|
||||
|
||||
socket.emit({ type: "reduced_state", state: idle });
|
||||
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
|
||||
});
|
||||
|
||||
it("hands a failed send's draft back to the operator", async () => {
|
||||
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
fireEvent.change(input, { target: { value: "keep me" } });
|
||||
fireEvent.submit(document.querySelector("form.structured-composer")!);
|
||||
|
||||
await waitFor(() => expect(document.querySelector(".structured-error")?.textContent).toContain("boom"));
|
||||
expect(input.value).toBe("keep me");
|
||||
});
|
||||
|
||||
it("shows the cancel control only while a turn is live", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, []);
|
||||
expect(document.querySelector(".structured-cancel")).toBeNull();
|
||||
|
||||
socket.emit({ type: "reduced_state", state: busy });
|
||||
fireEvent.click(document.querySelector<HTMLButtonElement>(".structured-cancel")!);
|
||||
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
|
||||
});
|
||||
|
||||
it("reports live turn activity above the composer", () => {
|
||||
const { socket } = mount();
|
||||
snapshot(socket, [], {
|
||||
turn: {
|
||||
responding: true,
|
||||
thinking: false,
|
||||
active_tool_calls: ["t1", "t2"],
|
||||
started_at: new Date(Date.now() - 5_000).toISOString(),
|
||||
},
|
||||
usage: { used: 12_000, size: 200_000 },
|
||||
});
|
||||
const activity = document.querySelector<HTMLElement>(".structured-activity")!;
|
||||
expect(activity.hidden).toBe(false);
|
||||
expect(activity.textContent).toContain("Running 2 tools");
|
||||
expect(activity.textContent).toContain("12k context / 200k context");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the socket", () => {
|
||||
it("replaces the transcript wholesale on every snapshot, never merging", async () => {
|
||||
const { socket, sockets } = mount();
|
||||
snapshot(socket, [chunk("user:1", "user", "first")]);
|
||||
socket.drop(1006);
|
||||
|
||||
// The retry is on reconnect.ts's backoff schedule, not immediate.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, INITIAL_RECONNECT_MS + 20));
|
||||
});
|
||||
const replacement = sockets[1];
|
||||
expect(replacement).toBeDefined();
|
||||
replacement.open();
|
||||
snapshot(replacement, [chunk("user:2", "user", "second")]);
|
||||
expect(screen.queryByText("first")).toBeNull();
|
||||
expect(screen.getByText("second")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reports a daemon rejection instead of retrying it", () => {
|
||||
const status = vi.fn();
|
||||
const sockets: FakeSocket[] = [];
|
||||
render(
|
||||
<StructuredPane
|
||||
sessionId="s1"
|
||||
onStatusChange={status}
|
||||
socketFactory={() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as WebSocket;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
sockets[0].open();
|
||||
sockets[0].drop(4004, "not a structured session");
|
||||
expect(sockets).toHaveLength(1);
|
||||
expect(status).toHaveBeenCalledWith("error", "not a structured session");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
// React port of structured.ts (#246): a live structured session — the
|
||||
// transcript from `WS /api/structured/:id`, the composer that drives
|
||||
// `.../prompt` and `.../cancel`, and the approval controls that answer
|
||||
// `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
|
||||
//
|
||||
// Like TerminalPane, the pane draws the body only: pane chrome (title,
|
||||
// disconnect, sessions drawer) belongs to whatever composes it, which reads
|
||||
// the connection state through `onStatusChange`.
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
cancelStructuredTurn,
|
||||
resolveStructuredApproval,
|
||||
sendStructuredPrompt,
|
||||
setStructuredConfig,
|
||||
uploadStructuredFile,
|
||||
} from "../../api.js";
|
||||
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
|
||||
import type {
|
||||
AcpSessionConfigOption,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow as Row,
|
||||
Uuid,
|
||||
} from "../../types.js";
|
||||
import { Composer } from "./Composer.js";
|
||||
import {
|
||||
commandsFromTranscript,
|
||||
pendingApprovalRowId as pendingApprovalRowIdOf,
|
||||
transcriptItems,
|
||||
turnIsActive,
|
||||
} from "./model.js";
|
||||
import { ToolGroup } from "./ToolGroup.js";
|
||||
import { TranscriptRow } from "./TranscriptRow.js";
|
||||
import { useStructuredSession, type StructuredStatus, type StructuredSocketFactory } from "./useStructuredSession.js";
|
||||
import "./structured.css";
|
||||
|
||||
/** Within this much of the bottom, wheel and touch imprecision should still
|
||||
* count as following. */
|
||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||
|
||||
interface QueuedPrompt {
|
||||
text: string;
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
export interface StructuredPaneProps {
|
||||
sessionId: Uuid;
|
||||
/** Grabs the composer once mounted. A sidebar preview passes `false`. */
|
||||
focus?: boolean;
|
||||
shouldReconnect?: (code: number) => boolean;
|
||||
onClosed?: () => void;
|
||||
onStatusChange?: (status: StructuredStatus, detail?: string) => void;
|
||||
/** Test seam: drive the socket without a daemon. */
|
||||
socketFactory?: StructuredSocketFactory;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface StructuredPaneHandle {
|
||||
/** Put the keyboard in the composer — the structured pane's terminal. */
|
||||
focusComposer(): void;
|
||||
}
|
||||
|
||||
export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPaneProps>(function StructuredPane(
|
||||
{ sessionId, focus = true, shouldReconnect, onClosed, onStatusChange, socketFactory, className },
|
||||
ref,
|
||||
) {
|
||||
const session = useStructuredSession(sessionId, { shouldReconnect, onClosed, socketFactory });
|
||||
const { rows, state, status, detail, socketGeneration } = session;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<HTMLDivElement | null>(null);
|
||||
const followingBottom = useRef(true);
|
||||
|
||||
const [expandedRows, setExpandedRows] = useState<ReadonlySet<string>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set());
|
||||
const [draft, setDraft] = useState("");
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [queue, setQueue] = useState<QueuedPrompt[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [configBusy, setConfigBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
/** Server path → object URL, so a user row can preview an image it uploaded. */
|
||||
const imageUrls = useRef(new Map<string, string>());
|
||||
|
||||
// Submission bookkeeping, ported from structured.ts: a prompt is "sending"
|
||||
// from the POST until the projection shows the turn it claimed, and an
|
||||
// acceptance that straddles a reconnect forces one more reconnect so the
|
||||
// snapshot cannot predate the 204.
|
||||
const submission = useRef(0);
|
||||
const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null);
|
||||
const optimisticStart = useRef<number | null>(null);
|
||||
const promptPending = useRef(false);
|
||||
const [awaitingBegin, setAwaitingBegin] = useState<number | null>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusComposer: () => composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange?.(status, detail ?? undefined);
|
||||
}, [status, detail, onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focus) return;
|
||||
composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus();
|
||||
}, [focus]);
|
||||
|
||||
const urls = imageUrls.current;
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const url of urls.values()) URL.revokeObjectURL(url);
|
||||
urls.clear();
|
||||
},
|
||||
[urls],
|
||||
);
|
||||
|
||||
const turnActive = turnIsActive(state);
|
||||
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
|
||||
const items = useMemo(
|
||||
() => transcriptItems(rows, pendingRowId, turnActive),
|
||||
[rows, pendingRowId, turnActive],
|
||||
);
|
||||
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
|
||||
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
|
||||
|
||||
const active = turnActive || awaitingBegin !== null;
|
||||
|
||||
// Stop saying "Sending" once the turn the submission was waiting for has
|
||||
// begun — or, when it was accepted but a replacement socket has
|
||||
// re-snapshotted since, once that newer snapshot has landed. Without the
|
||||
// second case a prompt accepted across a reconnect would say "Sending"
|
||||
// forever.
|
||||
useEffect(() => {
|
||||
if (turnActive) {
|
||||
setAwaitingBegin(null);
|
||||
} else if (
|
||||
awaitingBegin !== null &&
|
||||
accepted.current?.generation === awaitingBegin &&
|
||||
socketGeneration > accepted.current.socketGeneration
|
||||
) {
|
||||
setAwaitingBegin(null);
|
||||
}
|
||||
}, [turnActive, awaitingBegin, socketGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) optimisticStart.current = null;
|
||||
}, [active]);
|
||||
|
||||
// Keep the pane pinned to the newest row unless the operator scrolled away.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !followingBottom.current) return;
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
}, [items, active, queue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
followingBottom.current = el.scrollHeight - el.clientHeight - el.scrollTop <= FOLLOW_BOTTOM_THRESHOLD_PX;
|
||||
};
|
||||
|
||||
const decide = useCallback(
|
||||
async (rowId: string, decision: StructuredApprovalDecision): Promise<void> => {
|
||||
const nonce = state.pending_approval?.nonce;
|
||||
// Already resolved elsewhere between the click and here. A stale nonce
|
||||
// is a no-op, not an error (docs/STRUCTURED-VIEW.md: any nonce answers
|
||||
// 204), and the row's buttons are already gone — resolve quietly.
|
||||
if (!nonce || rowId !== pendingRowId) return;
|
||||
try {
|
||||
await resolveStructuredApproval(sessionId, nonce, decision);
|
||||
} catch (e: unknown) {
|
||||
// Propagate — so the row re-enables its buttons — only while this is
|
||||
// still the pending approval. If it moved on in flight, reviving the
|
||||
// buttons would revive a decision that no longer applies.
|
||||
setError(String(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[sessionId, state.pending_approval?.nonce, pendingRowId],
|
||||
);
|
||||
|
||||
const dispatchPrompt = useCallback(
|
||||
(text: string, files: File[]) => {
|
||||
const generation = ++submission.current;
|
||||
const submissionSocket = socketGeneration;
|
||||
setAwaitingBegin(generation);
|
||||
optimisticStart.current = Date.now();
|
||||
accepted.current = null;
|
||||
promptPending.current = true;
|
||||
setUploading(files.length > 0);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
|
||||
const send = async () => {
|
||||
const references: string[] = [];
|
||||
for (const file of files) {
|
||||
const { path } = await uploadStructuredFile(sessionId, file);
|
||||
if (file.type.startsWith("image/")) imageUrls.current.set(path, URL.createObjectURL(file));
|
||||
references.push(`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
|
||||
}
|
||||
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
|
||||
};
|
||||
|
||||
void send().then(
|
||||
() => {
|
||||
promptPending.current = false;
|
||||
if (generation !== submission.current) return;
|
||||
setUploading(false);
|
||||
setAttachments((current) => (current === files ? [] : current));
|
||||
accepted.current = { generation, socketGeneration };
|
||||
// A replacement socket may have captured idle before the POST
|
||||
// claimed the prompt. Reconnect so its snapshot cannot predate the
|
||||
// 204.
|
||||
if (socketGeneration > submissionSocket) session.reconnect();
|
||||
},
|
||||
(e: unknown) => {
|
||||
promptPending.current = false;
|
||||
if (generation !== submission.current) return;
|
||||
setAwaitingBegin((current) => (current === generation ? null : current));
|
||||
optimisticStart.current = null;
|
||||
setUploading(false);
|
||||
// The draft and its attachments are the operator's work — a failed
|
||||
// send hands both back rather than eating them.
|
||||
setAttachments((current) => (current.length === 0 ? files : current));
|
||||
setDraft((current) => (current === "" ? text : current));
|
||||
setError(String(e));
|
||||
},
|
||||
);
|
||||
},
|
||||
[sessionId, socketGeneration, session],
|
||||
);
|
||||
|
||||
// A queued message goes out as soon as the projected turn is idle — the
|
||||
// daemon answers a second prompt `409 turn_in_flight`, so the queue is
|
||||
// client-local (docs/STRUCTURED-VIEW.md).
|
||||
useEffect(() => {
|
||||
if (active || promptPending.current || queue.length === 0) return;
|
||||
const [next, ...rest] = queue;
|
||||
setQueue(rest);
|
||||
dispatchPrompt(next.text, next.attachments);
|
||||
}, [active, queue, dispatchPrompt]);
|
||||
|
||||
const submit = (text: string, files: File[]) => {
|
||||
if (active || queue.length > 0) {
|
||||
setQueue((current) => [...current, { text, attachments: files }]);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
dispatchPrompt(text, files);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setError(null);
|
||||
cancelStructuredTurn(sessionId).catch((e: unknown) => setError(String(e)));
|
||||
};
|
||||
|
||||
const changeConfig = (option: AcpSessionConfigOption, value: string | boolean) => {
|
||||
setConfigBusy(true);
|
||||
setError(null);
|
||||
setStructuredConfig(sessionId, { config_id: option.id, value })
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof Error ? e.message : "Could not change the session setting"),
|
||||
)
|
||||
.finally(() => setConfigBusy(false));
|
||||
};
|
||||
|
||||
const elapsedMs = () => {
|
||||
const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN;
|
||||
const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticStart.current;
|
||||
return startedAt === null ? 0 : Math.max(0, now - startedAt);
|
||||
};
|
||||
|
||||
const activityLabel = () => {
|
||||
const tools = state.turn.active_tool_calls.length;
|
||||
const label = awaitingBegin !== null
|
||||
? "Sending"
|
||||
: state.turn.thinking
|
||||
? "Thinking"
|
||||
: tools > 0
|
||||
? `Running ${tools} ${tools === 1 ? "tool" : "tools"}`
|
||||
: "Responding";
|
||||
const parts = [label, formatElapsed(elapsedMs())];
|
||||
if (typeof state.usage?.used === "number") {
|
||||
const usage = formatContextTokens(state.usage.used);
|
||||
parts.push(
|
||||
typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage,
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
const setRowExpanded = (id: string, expanded: boolean) =>
|
||||
setExpandedRows((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const setGroupExpanded = (id: string, expanded: boolean) =>
|
||||
setExpandedGroups((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const renderRow = (row: Row) => (
|
||||
<TranscriptRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pendingApproval={row.id === pendingRowId}
|
||||
onDecide={(decision) => decide(row.id, decision)}
|
||||
expanded={expandedRows.has(row.id)}
|
||||
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
|
||||
imageUrls={imageUrls.current}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className ? `structured-pane ${className}` : "structured-pane"}>
|
||||
<div className="structured-body">
|
||||
<div className="structured-main">
|
||||
<div className="structured-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="structured-rows">
|
||||
{items.map((item) =>
|
||||
item.type === "row" ? (
|
||||
renderRow(item.row)
|
||||
) : (
|
||||
<ToolGroup
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
toolCount={item.group.toolCount}
|
||||
expanded={expandedGroups.has(item.id)}
|
||||
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
|
||||
>
|
||||
{item.group.rows.map(renderRow)}
|
||||
</ToolGroup>
|
||||
),
|
||||
)}
|
||||
{queue.map((queued, index) => (
|
||||
<div key={`queued-${index}`} className="st-row st-row-queued">
|
||||
<div className="st-body">
|
||||
<div className="st-user-text">{queued.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="structured-activity" role="status" aria-live="polite" hidden={!active}>
|
||||
<span className="structured-activity-pulse" aria-hidden="true" />
|
||||
<span className="structured-activity-text">{active ? activityLabel() : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
{error !== null && <div className="structured-error">{error}</div>}
|
||||
<div ref={composerRef}>
|
||||
<Composer
|
||||
attachments={attachments}
|
||||
onAttachmentsChange={setAttachments}
|
||||
commands={commands}
|
||||
configOptions={configOptions}
|
||||
turnActive={active}
|
||||
uploading={uploading}
|
||||
configBusy={configBusy}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSubmit={submit}
|
||||
onCancel={cancel}
|
||||
onConfigChange={changeConfig}
|
||||
onError={setError}
|
||||
menuId={`structured-command-menu-${sessionId}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// A turn's collapsed activity block: two or more tool rows after the same
|
||||
// `User` row, plus the narration absorbed between them. The header counts tool
|
||||
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
|
||||
// chronological order.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ToolGroupProps {
|
||||
id: string;
|
||||
toolCount: number;
|
||||
expanded: boolean;
|
||||
onExpandedChange(expanded: boolean): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
|
||||
return (
|
||||
<div className="st-tool-group" data-tool-group-id={id}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool-group-head"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<span className="st-tool-group-glyph" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
|
||||
</button>
|
||||
<div
|
||||
className={
|
||||
expanded ? "st-tool-group-body st-disclosure is-expanded" : "st-tool-group-body st-disclosure"
|
||||
}
|
||||
aria-hidden={!expanded}
|
||||
inert={!expanded}
|
||||
>
|
||||
<div className="st-disclosure-clip">
|
||||
<div className="st-disclosure-inner">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// The glyph column of a tool row's head. Recognition is narrow on purpose: an
|
||||
// unrecognised or absent `ToolKind` gets the generic bullet, never a blank
|
||||
// column and never a mark that stands for the wrong kind of work.
|
||||
|
||||
import type { AcpToolKind } from "../../types.js";
|
||||
|
||||
/** Glyph shown in a tool row's head, by `ToolKind`. */
|
||||
const KIND_GLYPH: Record<string, string> = {
|
||||
read: "≡",
|
||||
edit: "±",
|
||||
delete: "✕",
|
||||
move: "→",
|
||||
search: "⌕",
|
||||
execute: "$",
|
||||
think: "…",
|
||||
fetch: "⇣",
|
||||
switch_mode: "⇄",
|
||||
};
|
||||
|
||||
export function toolGlyph(kind: string | undefined): string {
|
||||
return (kind && KIND_GLYPH[kind]) || "•";
|
||||
}
|
||||
|
||||
export interface ToolIconProps {
|
||||
kind: AcpToolKind | string | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolIcon({ kind, className = "st-tool-glyph" }: ToolIconProps) {
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{toolGlyph(kind)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// One transcript row, by kind. docs/STRUCTURED-VIEW.md fixes the eight kinds
|
||||
// and leaves every rendering decision here: markdown for AgentText, an inline
|
||||
// italic stream for AgentThought, a tool card for ToolCall/ToolDiff, a diff
|
||||
// view for ToolDiff's content, an approval card for Approval, and an opaque
|
||||
// collapsed line for a Notice nobody has taught the client about. `Plan` is
|
||||
// deliberately not drawn — the web client has no plan rail.
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "../../structuredRender.js";
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
AcpToolCallContent,
|
||||
AcpToolCallLike,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow as Row,
|
||||
TurnMeta,
|
||||
} from "../../types.js";
|
||||
import { ApprovalView } from "./ApprovalView.js";
|
||||
import { Copyable } from "./Copyable.js";
|
||||
import { DiffView } from "./DiffView.js";
|
||||
import {
|
||||
contentBlockText,
|
||||
isStateOnlyNotice,
|
||||
splitToolTitle,
|
||||
statusMeta,
|
||||
toolDiffMeta,
|
||||
turnMeta,
|
||||
} from "./model.js";
|
||||
import { safeMediaSource, toolPayload } from "./toolPayload.js";
|
||||
import { ToolIcon } from "./ToolIcon.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// content rows: user / agent / thought
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Model-authored markdown, sanitized by structuredRender.ts before it enters
|
||||
* the DOM. Tables get their own scroller and links open out of the app. */
|
||||
function Prose({ html }: { html: string }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const container = ref.current;
|
||||
if (!container) return;
|
||||
for (const table of Array.from(container.querySelectorAll("table"))) {
|
||||
if (table.parentElement?.classList.contains("st-markdown-table-wrap")) continue;
|
||||
const scroller = document.createElement("div");
|
||||
scroller.className = "st-markdown-table-wrap";
|
||||
table.replaceWith(scroller);
|
||||
scroller.appendChild(table);
|
||||
}
|
||||
for (const link of container.querySelectorAll("a")) {
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
}
|
||||
}, [html]);
|
||||
return <div ref={ref} className="st-body" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
export interface ContentRowProps {
|
||||
row: Row;
|
||||
variant: "user" | "agent" | "thought";
|
||||
/** Object URLs for images this prompt uploaded, keyed by the server path the
|
||||
* row's text names. */
|
||||
imageUrls?: Map<string, string>;
|
||||
}
|
||||
|
||||
/** `Attached file "name": "path"` references the composer appends on send. */
|
||||
const ATTACHED_FILE_RE = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
|
||||
|
||||
function attachedImages(text: string, imageUrls: Map<string, string>): string[] {
|
||||
const urls: string[] = [];
|
||||
ATTACHED_FILE_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = ATTACHED_FILE_RE.exec(text)) !== null) {
|
||||
let serverPath: string;
|
||||
try {
|
||||
serverPath = JSON.parse(`"${match[1]}"`) as string;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const url = imageUrls.get(serverPath);
|
||||
if (url) urls.push(url);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
export function ContentRow({ row, variant, imageUrls }: ContentRowProps) {
|
||||
const data = row.data as { content?: AcpContentBlock } | undefined;
|
||||
const text = contentBlockText(data?.content);
|
||||
const displayText = variant === "thought" ? text.trim() : text;
|
||||
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
|
||||
|
||||
let body: ReactNode;
|
||||
if (variant === "user") {
|
||||
const images = imageUrls && imageUrls.size > 0 ? attachedImages(displayText, imageUrls) : [];
|
||||
// With previews in hand the path references are noise — the image is the
|
||||
// attachment. Without them the text is all the operator has, so it stays.
|
||||
const shown =
|
||||
images.length > 0
|
||||
? displayText.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim()
|
||||
: displayText;
|
||||
body = (
|
||||
<div className="st-body">
|
||||
{shown && <div className="st-user-text">{shown}</div>}
|
||||
{images.map((src) => (
|
||||
<img key={src} className="st-user-image" src={src} alt="Uploaded image" loading="lazy" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (variant === "agent") {
|
||||
body = <Prose html={renderMarkdown(displayText)} />;
|
||||
} else {
|
||||
body = <div className="st-body" dangerouslySetInnerHTML={{ __html: renderInlineMarkdown(displayText) }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Copyable className={`st-row st-row-${variant}`} text={displayText} label={label} data-row-id={row.id}>
|
||||
{body}
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tool rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToolDetailBlock({ label, text }: { label: string; text: string }) {
|
||||
return (
|
||||
<div className="st-tool-detail">
|
||||
<div className="st-tool-detail-label">{label}</div>
|
||||
<Copyable as="pre" text={text} label={label.toLowerCase()}>
|
||||
{text}
|
||||
</Copyable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentBlockView({ block }: { block: AcpContentBlock }) {
|
||||
if (block.type === "text") {
|
||||
if (!block.text) return null;
|
||||
return (
|
||||
<Copyable className="st-tool-text" text={block.text} label="output">
|
||||
{block.text}
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
if (block.type === "image") {
|
||||
const source = safeMediaSource(block.mimeType, block.data, "image");
|
||||
if (source) return <img className="st-tool-image" src={source} alt="Tool output" loading="lazy" />;
|
||||
return <ToolDetailBlock label="Image" text={block.uri ?? block.mimeType ?? "Unavailable"} />;
|
||||
}
|
||||
if (block.type === "audio") {
|
||||
const source = safeMediaSource(block.mimeType, block.data, "audio");
|
||||
if (source) return <audio className="st-tool-audio" controls src={source} />;
|
||||
return <ToolDetailBlock label="Audio" text={block.mimeType ?? "Unavailable"} />;
|
||||
}
|
||||
if (block.type === "resource_link") {
|
||||
return (
|
||||
<div className="st-tool-resource">
|
||||
<span className="st-tool-resource-name">{block.title ?? block.name ?? "Resource"}</span>
|
||||
{block.uri && (
|
||||
<Copyable as="code" className="st-tool-resource-uri" text={block.uri} label="resource URI">
|
||||
{block.uri}
|
||||
</Copyable>
|
||||
)}
|
||||
{block.description && <span className="st-tool-resource-description">{block.description}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const resource = block.resource;
|
||||
if (typeof resource?.text === "string") {
|
||||
return (
|
||||
<ToolDetailBlock label={resource.uri ? `Resource · ${resource.uri}` : "Resource"} text={resource.text} />
|
||||
);
|
||||
}
|
||||
const printed = resource === undefined ? "" : JSON.stringify(resource, null, 2);
|
||||
return printed ? <ToolDetailBlock label="Resource" text={printed} /> : null;
|
||||
}
|
||||
|
||||
function ToolBody({ tool, content }: { tool: AcpToolCallLike; content: AcpToolCallContent[] }) {
|
||||
const payload = toolPayload(tool, content);
|
||||
const hasRaw = payload.raw.input !== null || payload.raw.output !== null;
|
||||
return (
|
||||
<>
|
||||
{content.map((block, index) => {
|
||||
if (block.type === "content") return <ContentBlockView key={index} block={block.content} />;
|
||||
if (block.type === "diff") return <DiffView key={index} diff={block} />;
|
||||
return (
|
||||
<div key={index} className="st-tool-terminal">
|
||||
{block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal"}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{payload.details.map((detail, index) => (
|
||||
<ToolDetailBlock key={`${detail.label}-${index}`} label={detail.label} text={detail.text} />
|
||||
))}
|
||||
{payload.exitCode !== null && (
|
||||
<div className="st-tool-fact">
|
||||
<span className="st-tool-fact-label">Exit code</span>
|
||||
<code className={payload.exitCode === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed"}>
|
||||
{payload.exitCode}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{hasRaw && (
|
||||
<details className="st-tool-raw">
|
||||
<summary>Raw JSON</summary>
|
||||
<div className="st-tool-raw-body">
|
||||
{payload.raw.input !== null && <ToolDetailBlock label="Input" text={payload.raw.input} />}
|
||||
{payload.raw.output !== null && <ToolDetailBlock label="Output" text={payload.raw.output} />}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a tool call has anything at all to disclose — a head with nothing
|
||||
* behind it stays a plain line rather than an empty disclosure. */
|
||||
function hasToolBody(tool: AcpToolCallLike, content: AcpToolCallContent[]): boolean {
|
||||
if (content.length > 0) return true;
|
||||
const payload = toolPayload(tool, content);
|
||||
return payload.details.length > 0 || payload.exitCode !== null || payload.raw.input !== null || payload.raw.output !== null;
|
||||
}
|
||||
|
||||
export interface ToolRowProps {
|
||||
row: Row;
|
||||
expanded: boolean;
|
||||
onExpandedChange(expanded: boolean): void;
|
||||
}
|
||||
|
||||
export function ToolRow({ row, expanded, onExpandedChange }: ToolRowProps) {
|
||||
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 meta = statusMeta(tool.status, hasDiff);
|
||||
const metaParts: string[] = [];
|
||||
if (hasDiff) metaParts.push(toolDiffMeta(content));
|
||||
if (meta) metaParts.push(meta.text);
|
||||
const disclosable = hasToolBody(tool, content);
|
||||
const open = expanded && disclosable;
|
||||
|
||||
return (
|
||||
<div className="st-row st-row-tool" data-row-id={row.id}>
|
||||
<div className="st-tool">
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool-head"
|
||||
aria-expanded={open}
|
||||
onClick={() => disclosable && onExpandedChange(!open)}
|
||||
>
|
||||
<ToolIcon kind={tool.kind} />
|
||||
<span className="st-tool-name">{label}</span>
|
||||
<span className="st-tool-arg" title={arg}>
|
||||
{arg}
|
||||
</span>
|
||||
{metaParts.length > 0 && (
|
||||
<span className={meta ? `st-tool-meta st-tool-meta-${meta.kind}` : "st-tool-meta"}>
|
||||
{metaParts.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{disclosable && (
|
||||
<div
|
||||
className={open ? "st-tool-out st-disclosure is-expanded" : "st-tool-out st-disclosure"}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
>
|
||||
<div className="st-disclosure-clip">
|
||||
<div className="st-disclosure-inner">
|
||||
{/* Built only once opened: a long transcript would otherwise
|
||||
render every tool's whole output up front. */}
|
||||
{open && <ToolBody tool={tool} content={content} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// notices and turn footers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function compactNumber(value: number): string {
|
||||
if (Math.abs(value) < 1_000) return String(value);
|
||||
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
|
||||
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
|
||||
}
|
||||
|
||||
export function TurnMetaRow({ meta }: { meta: TurnMeta }) {
|
||||
const parts = [formatElapsed(meta.elapsedMs)];
|
||||
if (typeof meta.contextDelta === "number") {
|
||||
const sign = meta.contextDelta > 0 ? "+" : "";
|
||||
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
|
||||
} else if (typeof meta.contextTokens === "number") {
|
||||
parts.push(`${compactNumber(meta.contextTokens)} context`);
|
||||
}
|
||||
if (meta.model) parts.push(meta.model);
|
||||
if (meta.outcome !== "completed") parts.push(meta.outcome);
|
||||
const text = parts.join(" · ");
|
||||
return (
|
||||
<Copyable
|
||||
className={`st-turn-meta st-turn-meta-${meta.outcome}`}
|
||||
text={text}
|
||||
label="turn details"
|
||||
aria-label={`Turn ${meta.outcome}`}
|
||||
>
|
||||
<span className="st-turn-meta-dot" aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
/** The floor presentation docs/STRUCTURED-VIEW.md allows for a `Notice` the
|
||||
* client has nothing better to do with: an opaque, collapsed line. */
|
||||
export function NoticeRow({ row }: { row: Row }) {
|
||||
const text = `[${row.kind}]`;
|
||||
return (
|
||||
<Copyable className="st-row st-row-notice" text={text} label="notice" data-row-id={row.id}>
|
||||
<div className="st-body">{text}</div>
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TranscriptRowProps {
|
||||
row: Row;
|
||||
/** This row is the one live approval (docs/STRUCTURED-VIEW.md's nonce
|
||||
* rules). Every other approval row is history. */
|
||||
pendingApproval?: boolean;
|
||||
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
imageUrls?: Map<string, string>;
|
||||
}
|
||||
|
||||
/** One transcript row, or `null` for the rows the web client omits (`plan`,
|
||||
* and the state-only notices it consumes for `/` autocomplete). */
|
||||
export function TranscriptRow({
|
||||
row,
|
||||
pendingApproval = false,
|
||||
onDecide,
|
||||
expanded = false,
|
||||
onExpandedChange,
|
||||
imageUrls,
|
||||
}: TranscriptRowProps) {
|
||||
switch (row.kind) {
|
||||
case "user":
|
||||
return <ContentRow row={row} variant="user" imageUrls={imageUrls} />;
|
||||
case "agent_text":
|
||||
return <ContentRow row={row} variant="agent" />;
|
||||
case "agent_thought":
|
||||
return <ContentRow row={row} variant="thought" />;
|
||||
case "tool_call":
|
||||
case "tool_diff":
|
||||
return (
|
||||
<ToolRow row={row} expanded={expanded} onExpandedChange={(next) => onExpandedChange?.(next)} />
|
||||
);
|
||||
case "approval": {
|
||||
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
|
||||
return (
|
||||
<ApprovalView
|
||||
request={request}
|
||||
pending={pendingApproval}
|
||||
onDecide={onDecide ?? (() => Promise.resolve())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "plan":
|
||||
return null;
|
||||
case "notice": {
|
||||
if (isStateOnlyNotice(row)) return null;
|
||||
const meta = turnMeta(row);
|
||||
return meta ? <TurnMetaRow meta={meta} /> : <NoticeRow row={row} />;
|
||||
}
|
||||
default:
|
||||
return <NoticeRow row={row} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { StructuredPane, type StructuredPaneHandle, type StructuredPaneProps } from "./StructuredPane.js";
|
||||
export { TranscriptRow, type TranscriptRowProps } from "./TranscriptRow.js";
|
||||
export { DiffView, type DiffViewProps } from "./DiffView.js";
|
||||
export { ApprovalView, type ApprovalViewProps } from "./ApprovalView.js";
|
||||
export { ToolGroup, type ToolGroupProps } from "./ToolGroup.js";
|
||||
export { ToolIcon, toolGlyph, type ToolIconProps } from "./ToolIcon.js";
|
||||
export { Composer, type ComposerProps } from "./Composer.js";
|
||||
export {
|
||||
useStructuredSession,
|
||||
type StructuredSession,
|
||||
type StructuredSocketFactory,
|
||||
type StructuredStatus,
|
||||
} from "./useStructuredSession.js";
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ReducedState, TranscriptRow } from "../../types.js";
|
||||
import {
|
||||
activityGroupsByTurn,
|
||||
applyCommand,
|
||||
commandsFromTranscript,
|
||||
commandTokenAt,
|
||||
contentBlockText,
|
||||
filterCommands,
|
||||
isStateOnlyNotice,
|
||||
offeredDecisions,
|
||||
orderConfigOptions,
|
||||
pendingApprovalRowId,
|
||||
splitToolTitle,
|
||||
statusMeta,
|
||||
toolDiffMeta,
|
||||
toolKindLabel,
|
||||
transcriptItems,
|
||||
turnMeta,
|
||||
} from "./model.js";
|
||||
|
||||
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
|
||||
return { id, kind, data };
|
||||
}
|
||||
|
||||
function tool(id: string): TranscriptRow {
|
||||
return row(id, "tool_call", { title: `Read(${id})`, status: "completed" });
|
||||
}
|
||||
|
||||
function text(id: string, kind: "user" | "agent_text" | "agent_thought", body: string): TranscriptRow {
|
||||
return row(id, kind, { content: { type: "text", text: body } });
|
||||
}
|
||||
|
||||
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
||||
|
||||
describe("row taxonomy", () => {
|
||||
it("splits an adapter's Name(argument) title, and falls back to the kind", () => {
|
||||
expect(splitToolTitle("Read(src/main.ts)", null, "read")).toEqual({ label: "Read", arg: "src/main.ts" });
|
||||
expect(splitToolTitle("something opaque", null, "search")).toEqual({
|
||||
label: "Search",
|
||||
arg: "something opaque",
|
||||
});
|
||||
expect(splitToolTitle(undefined, null, undefined)).toEqual({ label: "Tool", arg: "" });
|
||||
});
|
||||
|
||||
it("labels an unknown tool kind rather than leaving it blank", () => {
|
||||
expect(toolKindLabel("execute")).toBe("Execute");
|
||||
expect(toolKindLabel("no_such_kind")).toBe("Tool");
|
||||
expect(toolKindLabel(undefined)).toBe("Tool");
|
||||
});
|
||||
|
||||
it("names non-text content blocks instead of dropping them", () => {
|
||||
expect(contentBlockText({ type: "text", text: "hi" })).toBe("hi");
|
||||
expect(contentBlockText({ type: "image" })).toBe("[image]");
|
||||
expect(contentBlockText({ type: "resource_link", name: "spec.md" })).toBe("[resource: spec.md]");
|
||||
expect(contentBlockText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("counts a tool call's diff lines", () => {
|
||||
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
|
||||
.toBe("+2 −1");
|
||||
expect(toolDiffMeta([])).toBe("+0 −0");
|
||||
});
|
||||
|
||||
it("shows a status only while it is worth showing", () => {
|
||||
expect(statusMeta("in_progress", false)).toEqual({ text: "running", kind: "running" });
|
||||
expect(statusMeta("failed", true)).toEqual({ text: "failed", kind: "failed" });
|
||||
expect(statusMeta("pending", true)).toBeNull(); // the diff counts already say it
|
||||
expect(statusMeta("completed", false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notices", () => {
|
||||
it("reads a maestroTurnMeta footer, and only a well-formed one", () => {
|
||||
const meta = { elapsedMs: 4200, outcome: "completed", model: "sonnet" };
|
||||
expect(turnMeta(row("n1", "notice", { maestroTurnMeta: meta }))).toEqual(meta);
|
||||
expect(turnMeta(row("n2", "notice", { maestroTurnMeta: { outcome: "completed" } }))).toBeNull();
|
||||
expect(turnMeta(row("n3", "notice", { sessionUpdate: "session_info_update" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the state-only notices the client consumes rather than shows", () => {
|
||||
expect(isStateOnlyNotice(row("n1", "notice", { sessionUpdate: "available_commands_update" }))).toBe(true);
|
||||
expect(isStateOnlyNotice(row("n2", "notice", { sessionUpdate: "session_info_update" }))).toBe(true);
|
||||
expect(isStateOnlyNotice(row("n3", "notice", { sessionUpdate: "something_else" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("approvals", () => {
|
||||
it("offers only decisions the request actually backs", () => {
|
||||
expect(
|
||||
offeredDecisions([
|
||||
{ optionId: "a", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "r", name: "Reject", kind: "reject_once" },
|
||||
]),
|
||||
).toEqual(["allow_once", "deny"]);
|
||||
expect(offeredDecisions([{ optionId: "a", name: "Always", kind: "allow_always" }])).toEqual([
|
||||
"allow_for_session",
|
||||
]);
|
||||
expect(offeredDecisions([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("points a live pending approval at the newest approval row", () => {
|
||||
const rows = [row("approval:1", "approval"), tool("tool:1"), row("approval:2", "approval")];
|
||||
const pending: ReducedState = {
|
||||
...idle,
|
||||
pending_approval: { nonce: "n", request: {} as never },
|
||||
};
|
||||
expect(pendingApprovalRowId(rows, pending)).toBe("approval:2");
|
||||
expect(pendingApprovalRowId(rows, idle)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity grouping", () => {
|
||||
it("groups a turn's tools once there are two of them", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
|
||||
const groups = activityGroupsByTurn(rows, null, false);
|
||||
expect([...groups.keys()]).toEqual(["turn:user:1"]);
|
||||
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 });
|
||||
});
|
||||
|
||||
it("leaves a single tool call standalone", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1")];
|
||||
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
||||
});
|
||||
|
||||
it("absorbs intermediate narration but keeps the final answer outside", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
text("agent:1", "agent_text", "working"),
|
||||
tool("tool:2"),
|
||||
text("agent:2", "agent_text", "done"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||
expect(group.toolCount).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the newest thought outside while the turn is still live", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
tool("tool:2"),
|
||||
text("thought:1", "agent_thought", "hmm"),
|
||||
text("thought:2", "agent_thought", "aha"),
|
||||
];
|
||||
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||||
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
|
||||
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
|
||||
});
|
||||
|
||||
it("absorbs a resolved approval, never the pending one", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
row("approval:1", "approval"),
|
||||
tool("tool:2"),
|
||||
row("approval:2", "approval"),
|
||||
tool("tool:3"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcript render list", () => {
|
||||
it("renders a group once, at its first member's position", () => {
|
||||
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2"), text("agent:1", "agent_text", "done")];
|
||||
const items = transcriptItems(rows, null, false);
|
||||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
|
||||
"user:1",
|
||||
"turn:user:1",
|
||||
"agent:1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits plan rows, state-only notices and standalone resolved approvals", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
row("plan", "plan", { entries: [] }),
|
||||
row("notice:1", "notice", { sessionUpdate: "available_commands_update", availableCommands: [] }),
|
||||
row("approval:1", "approval"),
|
||||
text("agent:1", "agent_text", "done"),
|
||||
];
|
||||
const items = transcriptItems(rows, null, false);
|
||||
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual(["user:1", "agent:1"]);
|
||||
});
|
||||
|
||||
it("keeps a turn footer only while it is the final item", () => {
|
||||
const footer = row("turn_meta:1", "notice", { maestroTurnMeta: { elapsedMs: 10, outcome: "completed" } });
|
||||
expect(transcriptItems([text("user:1", "user", "go"), footer], null, false)).toHaveLength(2);
|
||||
const later = [text("user:1", "user", "go"), footer, text("user:2", "user", "again")];
|
||||
expect(transcriptItems(later, null, false).map((i) => (i.type === "row" ? i.row.id : i.id))).toEqual([
|
||||
"user:1",
|
||||
"user:2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer commands", () => {
|
||||
const notice = row("notice:9", "notice", {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "compact", description: "Compact the\n conversation" },
|
||||
{ name: "cost", description: "Show cost", input: { hint: "[period]" } },
|
||||
{ name: 42 },
|
||||
],
|
||||
});
|
||||
|
||||
it("takes the newest available_commands_update, skipping malformed entries", () => {
|
||||
const stale = row("notice:1", "notice", {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "old", description: "gone" }],
|
||||
});
|
||||
expect(commandsFromTranscript([stale, notice]).map((c) => c.name)).toEqual(["compact", "cost"]);
|
||||
expect(commandsFromTranscript([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("only matches a /token under the caret", () => {
|
||||
expect(commandTokenAt("/comp", 5, 5)).toEqual({ start: 0, end: 5, query: "comp" });
|
||||
expect(commandTokenAt("hello /co there", 9, 9)).toEqual({ start: 6, end: 9, query: "co" });
|
||||
expect(commandTokenAt("no slash", 3, 3)).toBeNull();
|
||||
expect(commandTokenAt("/comp", 2, 5)).toBeNull(); // a selection is not a caret
|
||||
});
|
||||
|
||||
it("ranks prefix matches ahead of substring matches", () => {
|
||||
const commands = commandsFromTranscript([notice]);
|
||||
expect(filterCommands(commands, "co").map((c) => c.name)).toEqual(["compact", "cost"]);
|
||||
expect(filterCommands(commands, "act").map((c) => c.name)).toEqual(["compact"]);
|
||||
});
|
||||
|
||||
it("replaces only the token under the caret", () => {
|
||||
const token = commandTokenAt("say /co now", 7, 7)!;
|
||||
expect(applyCommand("say /co now", token, "compact")).toEqual({ value: "say /compact now", cursor: 13 });
|
||||
const trailing = commandTokenAt("/co", 3, 3)!;
|
||||
expect(applyCommand("/co", trailing, "compact")).toEqual({ value: "/compact ", cursor: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("session config", () => {
|
||||
it("orders the menu by category, unknown categories last", () => {
|
||||
const options = [
|
||||
{ id: "d", category: null },
|
||||
{ id: "b", category: "thought_level" },
|
||||
{ id: "a", category: "model" },
|
||||
{ id: "c", category: "mode" },
|
||||
];
|
||||
expect(orderConfigOptions(options).map((o) => o.id)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
// Pure transcript logic for the structured pane (#246) — the decisions
|
||||
// docs/STRUCTURED-VIEW.md leaves to a client, kept out of the components so
|
||||
// they can be tested without a DOM or a socket. Ported from structured.ts's
|
||||
// private helpers and the parts of structuredRender.ts that were data, not
|
||||
// markup.
|
||||
|
||||
import type {
|
||||
AcpContentBlock,
|
||||
AcpPermissionOption,
|
||||
AcpToolCallContent,
|
||||
ReducedState,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow,
|
||||
TranscriptRowKind,
|
||||
TurnMeta,
|
||||
} from "../../types.js";
|
||||
import { computeDiffLines, diffStats } from "../../structuredDiff.js";
|
||||
|
||||
/** Human label behind a `ToolKind`. An unrecognised/absent kind is "Tool" —
|
||||
* never a blank label. */
|
||||
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",
|
||||
};
|
||||
|
||||
export 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. */
|
||||
export 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 };
|
||||
}
|
||||
|
||||
/** Content-block text, or a labelled placeholder for anything that isn't
|
||||
* text — never dropped silently, never fabricated. */
|
||||
export 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 "";
|
||||
}
|
||||
}
|
||||
|
||||
/** `+added −removed` across every diff block in a tool call's content — real
|
||||
* derived data, never a fabricated duration. */
|
||||
export 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}`;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a completed,
|
||||
* cancelled or failed turn's durable footer — `null` for every other notice. */
|
||||
export function turnMeta(row: TranscriptRow): TurnMeta | null {
|
||||
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null;
|
||||
const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta;
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const meta = value as Partial<TurnMeta>;
|
||||
if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null;
|
||||
return meta as TurnMeta;
|
||||
}
|
||||
|
||||
/** Notices the web client consumes as state and omits from the visible
|
||||
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
|
||||
export function isStateOnlyNotice(row: TranscriptRow): boolean {
|
||||
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false;
|
||||
const update = (row.data as { sessionUpdate?: string }).sessionUpdate;
|
||||
return update === "available_commands_update" || update === "session_info_update";
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** The row id a live `pending_approval` names: the newest `Approval` row.
|
||||
* Only one approval is ever live (docs/STRUCTURED-VIEW.md, "One live
|
||||
* approval at a time"), so the last one is the pending one. */
|
||||
export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState): string | null {
|
||||
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
|
||||
}
|
||||
|
||||
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function turnIsActive(state: ReducedState): boolean {
|
||||
return state.turn.responding || state.turn.thinking;
|
||||
}
|
||||
|
||||
export interface ActivityGroup {
|
||||
/** Header label count — tool rows only, never absorbed text. */
|
||||
toolCount: number;
|
||||
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
||||
rows: TranscriptRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tool rows (and intermediate agent text / resolved approvals between
|
||||
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
|
||||
* row follows it in the same turn — it gets absorbed into the group so the
|
||||
* collapsed block preserves chronological order. Resolved approval rows (not
|
||||
* the currently pending one) are absorbed the same way. The *last* agent text
|
||||
* with no subsequent tool stays standalone (the prominent final answer).
|
||||
*
|
||||
* Returns groups keyed by `turn:<userId>`.
|
||||
*/
|
||||
export function activityGroupsByTurn(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
): Map<string, ActivityGroup> {
|
||||
// First: count tools per turn to know which turns qualify for grouping.
|
||||
const toolCounts = new Map<string, number>();
|
||||
let turnId = "before-first-message";
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
|
||||
const gid = `turn:${turnId}`;
|
||||
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||
}
|
||||
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||
const groups = new Map<string, ActivityGroup>();
|
||||
turnId = "before-first-message";
|
||||
// Pending rows that might be intermediate (not yet confirmed by a following
|
||||
// tool). If no tool follows, they stay standalone.
|
||||
let pending: TranscriptRow[] = [];
|
||||
let currentGid = `turn:${turnId}`;
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") {
|
||||
turnId = row.id;
|
||||
currentGid = `turn:${turnId}`;
|
||||
pending = [];
|
||||
continue;
|
||||
}
|
||||
const count = toolCounts.get(currentGid) ?? 0;
|
||||
if (count < 2) continue;
|
||||
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
|
||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
||||
if (isIntermediateText || isResolvedApproval) {
|
||||
pending.push(row);
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
|
||||
// Flush any pending rows — a tool follows, so they're intermediate.
|
||||
group.rows.push(...pending);
|
||||
pending = [];
|
||||
group.rows.push(row);
|
||||
groups.set(currentGid, group);
|
||||
}
|
||||
}
|
||||
// A final agent text is the answer, so it stays outside. Thought rows are
|
||||
// working commentary, though: when a turn already has a disclosure they
|
||||
// belong with its activity even if the next tool call has not arrived yet.
|
||||
// While the turn is still live the most recent one stays outside as visible
|
||||
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
|
||||
const lastGroup = groups.get(currentGid);
|
||||
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
|
||||
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
||||
lastGroup.rows.push(...absorb);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** One entry of the flattened render list: either a standalone row or a whole
|
||||
* activity group. */
|
||||
export type TranscriptItem =
|
||||
| { type: "row"; row: TranscriptRow }
|
||||
| { type: "group"; id: string; group: ActivityGroup };
|
||||
|
||||
/**
|
||||
* The transcript in render order: rows, with each turn's tool activity folded
|
||||
* into a single group item where one exists. Resolved approvals not absorbed
|
||||
* into a group are dropped — the tool row itself already identifies the
|
||||
* action. `plan` rows and state-only notices are dropped too (both clients
|
||||
* omit `Plan` from the transcript). A turn footer only survives as the final
|
||||
* item, so old footers stop reading like transcript content.
|
||||
*/
|
||||
export function transcriptItems(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
): TranscriptItem[] {
|
||||
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
|
||||
const grouped = new Set<string>();
|
||||
for (const group of groups.values()) for (const row of group.rows) grouped.add(row.id);
|
||||
|
||||
const items: TranscriptItem[] = [];
|
||||
let turnId = "before-first-message";
|
||||
const emitted = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.kind === "user") turnId = row.id;
|
||||
const groupId = `turn:${turnId}`;
|
||||
const group = groups.get(groupId);
|
||||
if (group && grouped.has(row.id)) {
|
||||
// The group renders once, at its first member's position.
|
||||
if (!emitted.has(groupId)) {
|
||||
emitted.add(groupId);
|
||||
items.push({ type: "group", id: groupId, group });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "plan") continue;
|
||||
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
|
||||
// A resolved approval outside a group adds nothing the tool row does not
|
||||
// already say. A pending one always renders.
|
||||
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
|
||||
items.push({ type: "row", row });
|
||||
}
|
||||
|
||||
return items.filter(
|
||||
(item, index) =>
|
||||
!(item.type === "row" && turnMeta(item.row) !== null) || index === items.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composer: `/` command autocomplete off `available_commands_update` notices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AvailableCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
input?: { hint?: string };
|
||||
}
|
||||
|
||||
/** The live command list is whatever the most recent
|
||||
* `available_commands_update` notice carries (docs/STRUCTURED-VIEW.md). */
|
||||
export function commandsFromTranscript(rows: TranscriptRow[]): AvailableCommand[] {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
if (rows[i].kind !== "notice") continue;
|
||||
const data = rows[i].data as { sessionUpdate?: string; availableCommands?: unknown[] } | undefined;
|
||||
if (data?.sessionUpdate !== "available_commands_update") continue;
|
||||
return (data.availableCommands ?? []).flatMap((value) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const command = value as Partial<AvailableCommand>;
|
||||
if (typeof command.name !== "string" || typeof command.description !== "string") return [];
|
||||
return [{ name: command.name, description: command.description, input: command.input }];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface CommandToken {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** The whitespace-delimited token under the caret, when it is a `/command`
|
||||
* being typed. Completing one replaces only that token. */
|
||||
export function commandTokenAt(
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): CommandToken | null {
|
||||
if (selectionStart !== selectionEnd) return null;
|
||||
let start = selectionStart;
|
||||
while (start > 0 && !/\s/.test(value[start - 1])) start--;
|
||||
let end = selectionStart;
|
||||
while (end < value.length && !/\s/.test(value[end])) end++;
|
||||
const match = /^\/([^\s/]*)$/.exec(value.slice(start, selectionStart));
|
||||
return match ? { start, end, query: match[1].toLocaleLowerCase() } : null;
|
||||
}
|
||||
|
||||
/** Substring match, prefix matches first, then alphabetical. */
|
||||
export function filterCommands(commands: AvailableCommand[], query: string): AvailableCommand[] {
|
||||
return commands
|
||||
.filter((command) => command.name.toLocaleLowerCase().includes(query))
|
||||
.sort((a, b) => {
|
||||
const aStarts = a.name.toLocaleLowerCase().startsWith(query);
|
||||
const bStarts = b.name.toLocaleLowerCase().startsWith(query);
|
||||
return aStarts === bStarts ? a.name.localeCompare(b.name) : aStarts ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace the token under the caret with `/name`, returning the new draft and
|
||||
* where the caret lands. A trailing space is added only when the completion
|
||||
* ends the draft. */
|
||||
export function applyCommand(
|
||||
value: string,
|
||||
token: CommandToken,
|
||||
name: string,
|
||||
): { value: string; cursor: number } {
|
||||
const before = value.slice(0, token.start);
|
||||
const after = value.slice(token.end);
|
||||
let replacement = `/${name}`;
|
||||
let cursor = before.length + replacement.length;
|
||||
if (after.length === 0) {
|
||||
replacement += " ";
|
||||
cursor++;
|
||||
} else if (/^[ \t]/.test(after)) {
|
||||
cursor++;
|
||||
}
|
||||
return { value: before + replacement + after, cursor };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// session config menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Category order the config menu sorts by; anything else lands after. */
|
||||
const CONFIG_CATEGORY_ORDER = ["model", "thought_level", "mode", "model_config"];
|
||||
|
||||
export function orderConfigOptions<T extends { category?: string | null }>(options: T[]): T[] {
|
||||
return [...options].sort((a, b) => {
|
||||
const rank = (category?: string | null) => CONFIG_CATEGORY_ORDER.indexOf(category ?? "");
|
||||
const aRank = rank(a.category);
|
||||
const bRank = rank(b.category);
|
||||
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
// What a tool row shows below its head, derived from `rawInput`/`rawOutput`.
|
||||
// The adapters emit a handful of recognisable payload shapes (a shell command,
|
||||
// an MCP call, a search, a plain function call); anything else falls through to
|
||||
// the raw JSON disclosure rather than being guessed at.
|
||||
|
||||
import type { AcpContentBlock, AcpToolCallContent, AcpToolCallLike } from "../../types.js";
|
||||
|
||||
/** One labelled `<pre>` block under a tool head. */
|
||||
export interface ToolDetail {
|
||||
label: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ToolPayload {
|
||||
details: ToolDetail[];
|
||||
/** A shell command's exit code, when the output carried one. */
|
||||
exitCode: number | null;
|
||||
raw: { input: string | null; output: string | null };
|
||||
}
|
||||
|
||||
function printable(value: unknown): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonPrintable(value: unknown): string | null {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? null;
|
||||
} catch {
|
||||
return printable(value);
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Push a detail unless it is empty, or repeats text the content blocks above
|
||||
* already show verbatim. */
|
||||
function push(details: ToolDetail[], label: string, value: unknown, duplicates?: Set<string>): void {
|
||||
const text = printable(value);
|
||||
if (text !== null && text !== "" && !duplicates?.has(text)) details.push({ label, text });
|
||||
}
|
||||
|
||||
/** The text of every `content` block, so a `rawOutput` echoing it is not shown
|
||||
* a second time. */
|
||||
function contentTexts(content: AcpToolCallContent[]): Set<string> {
|
||||
return new Set(
|
||||
content
|
||||
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
|
||||
.filter((block) => block.content.type === "text")
|
||||
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
|
||||
);
|
||||
}
|
||||
|
||||
export function toolPayload(tool: AcpToolCallLike, content: AcpToolCallContent[]): ToolPayload {
|
||||
const details: ToolDetail[] = [];
|
||||
let exitCode: number | null = null;
|
||||
const input = record(tool.rawInput);
|
||||
const texts = contentTexts(content);
|
||||
|
||||
if (input) {
|
||||
if (typeof input.command === "string") {
|
||||
details.push({ label: "Command", text: input.command });
|
||||
push(details, "Working directory", input.cwd);
|
||||
const result = record(tool.rawOutput);
|
||||
if (result) {
|
||||
push(details, "Output", result.formatted_output ?? result.stdout ?? result.output, texts);
|
||||
if (typeof result.exit_code === "number") exitCode = result.exit_code;
|
||||
}
|
||||
} else if (typeof input.server === "string" && typeof input.tool === "string" && "arguments" in input) {
|
||||
push(details, "Arguments", input.arguments);
|
||||
const result = record(tool.rawOutput);
|
||||
if (result) {
|
||||
push(details, "Result", result.result, texts);
|
||||
push(details, "Error", result.error);
|
||||
}
|
||||
} else if (typeof input.query === "string" && (tool.kind === "search" || input.type === "webSearch")) {
|
||||
details.push({ label: "Query", text: input.query });
|
||||
push(details, "Action", input.action);
|
||||
} else if ("arguments" in input) {
|
||||
push(details, "Arguments", input.arguments);
|
||||
const result = record(tool.rawOutput);
|
||||
if (result) push(details, "Result", result.output, texts);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
details,
|
||||
exitCode,
|
||||
raw: { input: jsonPrintable(tool.rawInput), output: jsonPrintable(tool.rawOutput) },
|
||||
};
|
||||
}
|
||||
|
||||
/** A `data:` URL for an inline image/audio block, or `null` when the mime type
|
||||
* is absent or does not name that family — never a URL built from an
|
||||
* unvalidated string. */
|
||||
export function safeMediaSource(
|
||||
mimeType: string | undefined,
|
||||
data: string | undefined,
|
||||
family: "image" | "audio",
|
||||
): string | null {
|
||||
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
|
||||
return `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// The `WS /api/structured/:id` half of the pane (#246). Every connect — fresh
|
||||
// or reconnect — resends the whole transcript rather than resuming a cursor
|
||||
// (docs/STRUCTURED-VIEW.md), so a snapshot replaces state wholesale instead of
|
||||
// merging into what came before.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { structuredWsUrl } from "../../routes.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
||||
import type {
|
||||
ReducedState,
|
||||
StructuredEvent,
|
||||
TranscriptDelta,
|
||||
TranscriptRow,
|
||||
Uuid,
|
||||
} from "../../types.js";
|
||||
|
||||
export type StructuredStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
|
||||
|
||||
/** Injectable so tests can drive the socket without a daemon — the same seam
|
||||
* structured.ts used. */
|
||||
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
|
||||
|
||||
const idleState = (): ReducedState => ({
|
||||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||||
});
|
||||
|
||||
function applyDelta(rows: TranscriptRow[], delta: TranscriptDelta): TranscriptRow[] {
|
||||
if (delta.type === "append") {
|
||||
// A patch can arrive for a row this connection has never seen (its
|
||||
// snapshot predates it); treating an unknown id as an append keeps the
|
||||
// transcript complete rather than dropping the row.
|
||||
const at = rows.findIndex((row) => row.id === delta.row.id);
|
||||
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
|
||||
}
|
||||
if (delta.type === "patch") {
|
||||
const at = rows.findIndex((row) => row.id === delta.row.id);
|
||||
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
|
||||
}
|
||||
return rows.filter((row) => row.id !== delta.id);
|
||||
}
|
||||
|
||||
export interface StructuredSession {
|
||||
rows: TranscriptRow[];
|
||||
state: ReducedState;
|
||||
status: StructuredStatus;
|
||||
/** A daemon-supplied close reason (code ≥ 4000), when there is one. */
|
||||
detail: string | null;
|
||||
/** The number of sockets this pane has opened, newest last. A submission
|
||||
* that straddles a reconnect uses it to notice its acceptance may predate
|
||||
* the current snapshot. */
|
||||
socketGeneration: number;
|
||||
/** Drop the socket and reconnect, so the next snapshot cannot predate a
|
||||
* POST that has already been accepted. */
|
||||
reconnect(): void;
|
||||
}
|
||||
|
||||
export interface UseStructuredSessionOptions {
|
||||
shouldReconnect?: (code: number) => boolean;
|
||||
onClosed?: () => void;
|
||||
socketFactory?: StructuredSocketFactory;
|
||||
}
|
||||
|
||||
/** Application-defined close codes (daemon rejection) and the clean-close code
|
||||
* — retrying either would only loop. */
|
||||
function defaultShouldReconnect(code: number): boolean {
|
||||
return code < 4000 && code !== 1000;
|
||||
}
|
||||
|
||||
export function useStructuredSession(
|
||||
sessionId: Uuid,
|
||||
{
|
||||
shouldReconnect = defaultShouldReconnect,
|
||||
onClosed,
|
||||
socketFactory = (id) => new WebSocket(structuredWsUrl(id)),
|
||||
}: UseStructuredSessionOptions = {},
|
||||
): StructuredSession {
|
||||
const [rows, setRows] = useState<TranscriptRow[]>([]);
|
||||
const [state, setState] = useState<ReducedState>(idleState);
|
||||
const [status, setStatus] = useState<StructuredStatus>("connecting");
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const [socketGeneration, setSocketGeneration] = useState(0);
|
||||
|
||||
// Read through refs so a changed callback never tears the socket down.
|
||||
const shouldReconnectRef = useRef(shouldReconnect);
|
||||
const onClosedRef = useRef(onClosed);
|
||||
const factoryRef = useRef(socketFactory);
|
||||
shouldReconnectRef.current = shouldReconnect;
|
||||
onClosedRef.current = onClosed;
|
||||
factoryRef.current = socketFactory;
|
||||
|
||||
const reconnectRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let timer: number | null = null;
|
||||
|
||||
const closeSocket = () => {
|
||||
const existing = socket;
|
||||
socket = null;
|
||||
if (!existing) return;
|
||||
existing.onopen = null;
|
||||
existing.onmessage = null;
|
||||
existing.onerror = null;
|
||||
existing.onclose = null;
|
||||
try {
|
||||
existing.close();
|
||||
} catch {
|
||||
// ignore — an already-closing socket has nothing to close
|
||||
}
|
||||
};
|
||||
|
||||
const cancelTimer = () => {
|
||||
if (timer === null) return;
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
function connect(): void {
|
||||
if (disposed) return;
|
||||
cancelTimer();
|
||||
closeSocket();
|
||||
const current = factoryRef.current(sessionId);
|
||||
socket = current;
|
||||
setSocketGeneration((n) => n + 1);
|
||||
|
||||
current.onopen = () => {
|
||||
if (disposed || socket !== current) return;
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setDetail(null);
|
||||
setStatus("open");
|
||||
};
|
||||
|
||||
current.onmessage = (ev) => {
|
||||
if (disposed || socket !== current) return;
|
||||
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":
|
||||
setRows([...msg.snapshot.rows]);
|
||||
break;
|
||||
case "reduced_state":
|
||||
setState(msg.state);
|
||||
break;
|
||||
case "projection_update":
|
||||
if (msg.transcript) {
|
||||
const delta = msg.transcript;
|
||||
setRows((previous) => applyDelta(previous, delta));
|
||||
}
|
||||
setState(msg.state);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
current.onerror = () => {
|
||||
if (disposed || socket !== current) return;
|
||||
setStatus("error");
|
||||
};
|
||||
|
||||
current.onclose = (ev) => {
|
||||
if (disposed || socket !== current) return;
|
||||
socket = null;
|
||||
const reason = ev.reason?.trim();
|
||||
if (ev.code >= 4000 && reason) {
|
||||
setDetail(reason);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (shouldReconnectRef.current(ev.code)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setStatus("reconnecting");
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
if (disposed) return;
|
||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
setDetail(String(ev.code));
|
||||
setStatus("closed");
|
||||
onClosedRef.current?.();
|
||||
};
|
||||
}
|
||||
|
||||
reconnectRef.current = () => {
|
||||
if (disposed) return;
|
||||
setStatus("reconnecting");
|
||||
connect();
|
||||
};
|
||||
|
||||
setStatus("connecting");
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
reconnectRef.current = () => {};
|
||||
cancelTimer();
|
||||
closeSocket();
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const reconnect = useCallback(() => reconnectRef.current(), []);
|
||||
|
||||
return useMemo(
|
||||
() => ({ rows, state, status, detail, socketGeneration, reconnect }),
|
||||
[rows, state, status, detail, socketGeneration, reconnect],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { leaves } from "../layout.js";
|
||||
import { createLayoutStore, type LayoutStoreApi } from "../stores/layoutStore.js";
|
||||
import { usePreview, type UsePreviewResult } from "./usePreview.js";
|
||||
|
||||
function fakeScheduler() {
|
||||
const timers = new Map<number, () => void>();
|
||||
let id = 0;
|
||||
return {
|
||||
scheduler: {
|
||||
setTimer: (fn: () => void) => {
|
||||
const h = ++id;
|
||||
timers.set(h, fn);
|
||||
return h;
|
||||
},
|
||||
clearTimer: (h: number) => {
|
||||
timers.delete(h);
|
||||
},
|
||||
},
|
||||
flush() {
|
||||
for (const fn of [...timers.values()]) fn();
|
||||
timers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Hand-driven clock + timer queue for the preview schedule's own throttle,
|
||||
* independent of the store's save-debounce scheduler above. */
|
||||
function fakeClock() {
|
||||
let now = 1_000;
|
||||
const timers = new Map<number, { at: number; fn: () => void }>();
|
||||
let handle = 1;
|
||||
return {
|
||||
now: () => now,
|
||||
setTimer: (fn: () => void, ms: number) => {
|
||||
const h = handle++;
|
||||
timers.set(h, { at: now + ms, fn });
|
||||
return h;
|
||||
},
|
||||
clearTimer: (h: number) => {
|
||||
timers.delete(h);
|
||||
},
|
||||
advance(ms: number) {
|
||||
now += ms;
|
||||
for (const [h, t] of [...timers]) {
|
||||
if (t.at <= now) {
|
||||
timers.delete(h);
|
||||
act(() => t.fn());
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function newStore(): LayoutStoreApi {
|
||||
return createLayoutStore({ load: () => null, save: vi.fn(), scheduler: fakeScheduler().scheduler });
|
||||
}
|
||||
|
||||
let controls: UsePreviewResult;
|
||||
|
||||
function Probe({ store, clock }: { store: LayoutStoreApi; clock: ReturnType<typeof fakeClock> }) {
|
||||
controls = usePreview({
|
||||
store,
|
||||
scheduleDeps: { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer },
|
||||
});
|
||||
return <div>{controls.previewedSessionId ?? "none"}</div>;
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("usePreview", () => {
|
||||
it("shows the first preview immediately — no settling delay", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => controls.preview("s9"));
|
||||
expect(view.container.textContent).toBe("s9");
|
||||
expect(leaves(store.getState().layout.tabs[1]?.root ?? null).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||
});
|
||||
|
||||
it("throttles a burst to one leading + one trailing pane swap", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => {
|
||||
controls.preview("s1");
|
||||
controls.preview("s8");
|
||||
controls.preview("s9");
|
||||
});
|
||||
expect(view.container.textContent).toBe("s1");
|
||||
clock.advance(120);
|
||||
expect(view.container.textContent).toBe("s9");
|
||||
});
|
||||
|
||||
it("touch never previews", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => controls.previewFromPointer("s9", "touch"));
|
||||
expect(view.container.textContent).toBe("none");
|
||||
expect(store.getState().preview).toBeNull();
|
||||
});
|
||||
|
||||
it("previewFromPointer still previews for mouse/pen", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => controls.previewFromPointer("s9", "mouse"));
|
||||
expect(view.container.textContent).toBe("s9");
|
||||
});
|
||||
|
||||
it("commit cancels a pending trailing preview and opens for real", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => {
|
||||
controls.preview("s1"); // leading edge: previews the already-open s1
|
||||
controls.preview("s9"); // queued, gap not elapsed yet
|
||||
controls.commit("s9");
|
||||
});
|
||||
expect(view.container.textContent).toBe("none"); // committed: no longer a preview
|
||||
expect(leaves(store.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||
|
||||
clock.advance(120);
|
||||
// The cancelled trailing preview never fires.
|
||||
expect(view.container.textContent).toBe("none");
|
||||
});
|
||||
|
||||
it("end drops a preview immediately, bypassing the throttle", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => controls.preview("s9"));
|
||||
expect(view.container.textContent).toBe("s9");
|
||||
|
||||
act(() => controls.end());
|
||||
expect(view.container.textContent).toBe("none");
|
||||
expect(store.getState().layout.tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cancels its pending trailing preview on unmount", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
store.getState().addTab();
|
||||
store.getState().openSession("s2"); // leading edge below will preview this, already on screen
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => {
|
||||
controls.preview("s2"); // leading edge: fires now
|
||||
controls.preview("s9"); // queued trailing preview
|
||||
});
|
||||
expect(store.getState().preview?.sessionId).toBe("s2");
|
||||
view.unmount();
|
||||
clock.advance(120);
|
||||
// The queued trailing preview must not fire after unmount.
|
||||
expect(store.getState().preview?.sessionId).toBe("s2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// React port of previewSchedule.ts + multiview.ts's preview controller
|
||||
// (issue #251). Owns the throttled schedule, hands the layout store the
|
||||
// throttled result, and exposes the three ways a preview ends for real —
|
||||
// see docs/PREVIEW.md, the contract this mirrors.
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { createPreviewSchedule, type PreviewScheduleDeps } from "../previewSchedule.js";
|
||||
import type { LayoutStore, LayoutStoreApi } from "../stores/layoutStore.js";
|
||||
import type { Uuid } from "../types.js";
|
||||
|
||||
export interface UsePreviewOptions {
|
||||
store: LayoutStoreApi;
|
||||
/** Test seam for the schedule's clock/timers. */
|
||||
scheduleDeps?: PreviewScheduleDeps;
|
||||
}
|
||||
|
||||
export interface UsePreviewResult {
|
||||
/** The session currently shown as a preview, or null. */
|
||||
previewedSessionId: Uuid | null;
|
||||
/** Throttled: show `sessionId` in the content pane, or `null` to put back
|
||||
* whatever was on screen (a highlight move, or the highlight leaving
|
||||
* every session). Never delayed on the leading edge. */
|
||||
preview(sessionId: Uuid | null): void;
|
||||
/** Pointer-hover entry point: touch has no hover, so a touch pointer never
|
||||
* previews (docs/PREVIEW.md). */
|
||||
previewFromPointer(sessionId: Uuid | null, pointerType: string): void;
|
||||
/** Choosing the previewed session — click/Enter on its row, click into its
|
||||
* pane, or handing it the keyboard (Tab). Keeps exactly what is on
|
||||
* screen: a throwaway tab becomes real, a focus move inside an existing
|
||||
* tab stands. Nothing moves a second time. */
|
||||
commit(sessionId: Uuid): void;
|
||||
/** Any other real change — opening the command bar, a dialog, navigating
|
||||
* away — ends a pending or active preview immediately, bypassing the
|
||||
* throttle. */
|
||||
end(): void;
|
||||
}
|
||||
|
||||
/** Preview state management (docs/PREVIEW.md): wraps the layout store's
|
||||
* `previewSession`/`commitPreview` with the 120ms pane-swap throttle so the
|
||||
* UI never has to schedule it by hand. */
|
||||
export function usePreview({ store, scheduleDeps }: UsePreviewOptions): UsePreviewResult {
|
||||
const previewedSessionId = useStore(store, (s: LayoutStore) => s.preview?.sessionId ?? null);
|
||||
|
||||
const scheduleRef = useRef<ReturnType<typeof createPreviewSchedule> | null>(null);
|
||||
if (!scheduleRef.current) {
|
||||
scheduleRef.current = createPreviewSchedule((id) => store.getState().previewSession(id), scheduleDeps);
|
||||
}
|
||||
useEffect(() => {
|
||||
const schedule = scheduleRef.current;
|
||||
return () => schedule?.cancel();
|
||||
}, []);
|
||||
|
||||
const preview = useCallback((sessionId: Uuid | null) => {
|
||||
scheduleRef.current?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const previewFromPointer = useCallback(
|
||||
(sessionId: Uuid | null, pointerType: string) => {
|
||||
if (pointerType === "touch") return;
|
||||
preview(sessionId);
|
||||
},
|
||||
[preview],
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(sessionId: Uuid) => {
|
||||
scheduleRef.current?.cancel();
|
||||
store.getState().openSession(sessionId);
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const end = useCallback(() => {
|
||||
scheduleRef.current?.cancel();
|
||||
store.getState().previewSession(null);
|
||||
}, [store]);
|
||||
|
||||
return { previewedSessionId, preview, previewFromPointer, commit, end };
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
|
||||
import { sessionBarCandidates } from "./commandBar.js";
|
||||
import { matchAndRank } from "./matcher.js";
|
||||
import { matchAndRank } from "./utils/matcher.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { leaves } from "../layout.js";
|
||||
import { createLayoutStore, type LayoutStoreOptions } from "./layoutStore.js";
|
||||
|
||||
function fakeScheduler() {
|
||||
const timers = new Map<number, () => void>();
|
||||
let id = 0;
|
||||
return {
|
||||
scheduler: {
|
||||
setTimer: (fn: () => void) => {
|
||||
const h = ++id;
|
||||
timers.set(h, fn);
|
||||
return h;
|
||||
},
|
||||
clearTimer: (h: number) => {
|
||||
timers.delete(h);
|
||||
},
|
||||
},
|
||||
flush() {
|
||||
for (const fn of [...timers.values()]) fn();
|
||||
timers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function store(opts: Partial<LayoutStoreOptions> = {}) {
|
||||
return createLayoutStore({
|
||||
load: () => null,
|
||||
save: vi.fn(),
|
||||
scheduler: fakeScheduler().scheduler,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
describe("layoutStore", () => {
|
||||
it("boots with a single empty tab when nothing is persisted", () => {
|
||||
const { layout } = store().getState();
|
||||
expect(layout.tabs).toHaveLength(1);
|
||||
expect(layout.tabs[0].root).toBeNull();
|
||||
});
|
||||
|
||||
it("boots from a persisted layout when one loads", () => {
|
||||
const persisted = store().getState().layout;
|
||||
persisted.tabs[0].title = "Restored";
|
||||
const s = store({ load: () => persisted });
|
||||
expect(s.getState().layout.tabs[0].title).toBe("Restored");
|
||||
});
|
||||
|
||||
it("openSession seeds the active tab from empty", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("openSession focuses an already-open session instead of moving it", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().openSessionBeside("s2");
|
||||
s.getState().openSession("s1");
|
||||
const ids = leaves(s.getState().layout.tabs[0].root)
|
||||
.map((l) => l.sessionId)
|
||||
.sort();
|
||||
expect(ids).toEqual(["s1", "s2"]);
|
||||
});
|
||||
|
||||
it("openSessionBeside splits next to the focused pane instead of replacing it", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().openSessionBeside("s2");
|
||||
expect(leaves(s.getState().layout.tabs[0].root)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("openSessionBeside(newTab) opens its own tab", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().openSessionBeside("s2", { newTab: true });
|
||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s2"]);
|
||||
});
|
||||
|
||||
it("closeSession removes the pane and closes a tab left with nothing else", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().addTab();
|
||||
s.getState().openSession("s2");
|
||||
s.getState().closeSession("s2");
|
||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("splitFocused adds a leaf beside the given one and focuses it", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
|
||||
s.getState().splitFocused(first, "row");
|
||||
const tab = s.getState().layout.tabs[0];
|
||||
expect(leaves(tab.root)).toHaveLength(2);
|
||||
expect(tab.focusedLeafId).not.toBe(first);
|
||||
});
|
||||
|
||||
it("closePane collapses the split back to the surviving leaf", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
|
||||
s.getState().splitFocused(first, "row");
|
||||
s.getState().closePane(first);
|
||||
const remaining = leaves(s.getState().layout.tabs[0].root);
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].sessionId).toBeNull();
|
||||
});
|
||||
|
||||
it("addTab/selectTab/closeTab manage the tab strip", () => {
|
||||
const s = store();
|
||||
s.getState().addTab();
|
||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||
const [first, second] = s.getState().layout.tabs;
|
||||
s.getState().selectTab(first.id);
|
||||
expect(s.getState().layout.activeTabId).toBe(first.id);
|
||||
s.getState().closeTab(second.id);
|
||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reorderTab moves a tab to sit where another one is", () => {
|
||||
const s = store();
|
||||
s.getState().addTab();
|
||||
s.getState().addTab();
|
||||
const [a, b, c] = s.getState().layout.tabs;
|
||||
s.getState().reorderTab(c.id, a.id);
|
||||
expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([c.id, a.id, b.id]);
|
||||
});
|
||||
|
||||
it("reconcile drops leaves for sessions no longer live, and closes a tab left with nothing else", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().addTab();
|
||||
s.getState().openSession("dead");
|
||||
s.getState().reconcile(new Set(["s1"]));
|
||||
const { layout } = s.getState();
|
||||
expect(layout.tabs).toHaveLength(1);
|
||||
expect(leaves(layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("reconcile leaves an intentionally empty tab alone", () => {
|
||||
const s = store();
|
||||
s.getState().addTab();
|
||||
s.getState().reconcile(new Set());
|
||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("debounces persistence to a single write once idle", () => {
|
||||
const save = vi.fn();
|
||||
const { scheduler, flush } = fakeScheduler();
|
||||
const s = store({ save, scheduler });
|
||||
s.getState().openSession("s1");
|
||||
s.getState().openSessionBeside("s2");
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
flush();
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save.mock.calls[0][0]).toBe(s.getState().layout);
|
||||
});
|
||||
|
||||
describe("previewSession (docs/PREVIEW.md)", () => {
|
||||
it("brings a session's own tab forward, then puts the layout back", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().addTab();
|
||||
s.getState().openSession("s2");
|
||||
const before = JSON.stringify(s.getState().layout);
|
||||
|
||||
s.getState().previewSession("s1");
|
||||
const tab1 = s.getState().layout.tabs.find((t) => leaves(t.root).some((l) => l.sessionId === "s1"))!;
|
||||
expect(s.getState().layout.activeTabId).toBe(tab1.id);
|
||||
expect(s.getState().preview?.sessionId).toBe("s1");
|
||||
|
||||
s.getState().previewSession(null);
|
||||
expect(s.getState().preview).toBeNull();
|
||||
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||
});
|
||||
|
||||
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
const before = JSON.stringify(s.getState().layout);
|
||||
|
||||
s.getState().previewSession("s9");
|
||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||
const tempTab = s.getState().layout.tabs[1];
|
||||
expect(leaves(tempTab.root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||
|
||||
s.getState().previewSession(null);
|
||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||
});
|
||||
|
||||
it("stepping between previews leaves only the newest one on screen", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
const before = JSON.stringify(s.getState().layout);
|
||||
|
||||
s.getState().previewSession("s9");
|
||||
s.getState().previewSession("s8");
|
||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||
expect(s.getState().preview?.sessionId).toBe("s8");
|
||||
|
||||
s.getState().previewSession(null);
|
||||
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||
});
|
||||
|
||||
it("never persists while up, and saves again once settled back", () => {
|
||||
const save = vi.fn();
|
||||
const { scheduler, flush } = fakeScheduler();
|
||||
const s = store({ save, scheduler });
|
||||
s.getState().openSession("s1");
|
||||
flush();
|
||||
save.mockClear();
|
||||
|
||||
s.getState().previewSession("s9");
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
s.getState().previewSession(null);
|
||||
flush();
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("commitPreview keeps the throwaway tab as a real one", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().previewSession("s9");
|
||||
|
||||
expect(s.getState().commitPreview("s9")).toBe(true);
|
||||
expect(s.getState().preview).toBeNull();
|
||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||
});
|
||||
|
||||
it("commitPreview discards a non-matching preview and reports false", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().previewSession("s9");
|
||||
|
||||
expect(s.getState().commitPreview("s1")).toBe(false);
|
||||
expect(s.getState().preview).toBeNull();
|
||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("openSession settles a matching preview instead of moving it a second time", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().previewSession("s9");
|
||||
|
||||
s.getState().openSession("s9");
|
||||
expect(s.getState().preview).toBeNull();
|
||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("any real layout change ends a preview first: splitFocused", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().previewSession("s9");
|
||||
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
|
||||
|
||||
s.getState().splitFocused(first, "row");
|
||||
expect(s.getState().preview).toBeNull();
|
||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId).sort()).toEqual([null, "s1"]);
|
||||
});
|
||||
|
||||
it("any real layout change ends a preview first: addTab/selectTab/closeTab", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().previewSession("s9");
|
||||
s.getState().addTab();
|
||||
expect(s.getState().preview).toBeNull();
|
||||
// The throwaway tab was discarded before the real tab was added.
|
||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("closeSession drops the preview before closing the real pane", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
s.getState().previewSession("s9");
|
||||
|
||||
s.getState().closeSession("s1");
|
||||
expect(s.getState().preview).toBeNull();
|
||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||
expect(s.getState().layout.tabs[0].root).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
// Zustand wrapper around ../layout.js's layout tree model (issue #249).
|
||||
// Actions mutate the tree in place via layout.js's own functions, then
|
||||
// re-wrap it so `useStore` selectors relying on referential equality notice.
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import {
|
||||
activeTab,
|
||||
addTab as addTabToLayout,
|
||||
closeTab as closeTabInLayout,
|
||||
emptyLayout,
|
||||
equalizeRatios,
|
||||
findLeaf,
|
||||
freshId,
|
||||
leaves,
|
||||
locateSession,
|
||||
removeLeaf,
|
||||
splitLeaf,
|
||||
type Dir,
|
||||
type Layout,
|
||||
type Tab,
|
||||
} from "../layout.js";
|
||||
import { loadLayout, saveLayout } from "../persistence.js";
|
||||
import type { Uuid } from "../types.js";
|
||||
import type { Scheduler } from "./catalogStore.js";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
const browserScheduler: Scheduler = {
|
||||
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
||||
clearTimer: (h) => window.clearTimeout(h),
|
||||
};
|
||||
|
||||
export interface LayoutStoreOptions {
|
||||
load?: () => Layout | null;
|
||||
save?: (layout: Layout) => void;
|
||||
scheduler?: Scheduler;
|
||||
}
|
||||
|
||||
/** A provisional preview (docs/PREVIEW.md): records exactly what
|
||||
* `previewSession` moved, so ending it restores the layout byte-for-byte. */
|
||||
export interface PreviewState {
|
||||
sessionId: Uuid;
|
||||
/** Tab that was active before the preview. */
|
||||
prevActiveTabId: string;
|
||||
/** Focused leaf to restore, when the preview stole focus in a real tab. */
|
||||
prevFocus: { tabId: string; leafId: string | null } | null;
|
||||
/** Throwaway tab built for a session that no pane was showing. */
|
||||
tempTabId: string | null;
|
||||
}
|
||||
|
||||
export interface LayoutState {
|
||||
layout: Layout;
|
||||
/** Session shown provisionally in the content pane, or null. A preview is
|
||||
* a view, not state: it never reaches `save` (docs/PREVIEW.md). */
|
||||
preview: PreviewState | null;
|
||||
}
|
||||
|
||||
export interface LayoutActions {
|
||||
/** Bring `sessionId` into view: focus it if already open, else fill the
|
||||
* focused pane (VS-Code-style replace), or seed an empty tab. Settles a
|
||||
* matching preview in place rather than moving it a second time. */
|
||||
openSession(sessionId: Uuid): void;
|
||||
/** Same, but never evicts a running pane: reuses an empty leaf or splits
|
||||
* beside the focused one. `newTab` opens its own tab instead. */
|
||||
openSessionBeside(sessionId: Uuid, opts?: { newTab?: boolean }): void;
|
||||
/** Drop the pane showing `sessionId` (closed/killed elsewhere). */
|
||||
closeSession(sessionId: Uuid): void;
|
||||
splitFocused(leafId: string, dir: Dir): void;
|
||||
closePane(leafId: string): void;
|
||||
focusLeaf(leafId: string): void;
|
||||
selectTab(tabId: string): void;
|
||||
addTab(): void;
|
||||
closeTab(tabId: string): void;
|
||||
/** Tab-strip drag-and-drop reordering: moves `fromId` to sit where `toId`
|
||||
* currently is. */
|
||||
reorderTab(fromId: string, toId: string): void;
|
||||
/** Persist whatever a divider drag left on a split's `ratio` — the drag
|
||||
* itself mutates the node directly (same trade as the vanilla version),
|
||||
* this just re-wraps and schedules the save. */
|
||||
commitLayout(): void;
|
||||
/** Boot reconciliation: drop every leaf whose session isn't in `liveIds`,
|
||||
* and close any tab that held nothing else. A session killed while its
|
||||
* tab was closed is pruned instead of leaving a pane that can only ever
|
||||
* show a dead socket. Tabs that were already empty are left alone. */
|
||||
reconcile(liveIds: ReadonlySet<Uuid>): void;
|
||||
/** Show `sessionId` in the content pane without committing to it
|
||||
* (docs/PREVIEW.md): its own tab comes forward, or a throwaway tab
|
||||
* carries it. `null` puts back whatever was on screen before. Never
|
||||
* schedules a save while a preview is up. */
|
||||
previewSession(sessionId: Uuid | null): void;
|
||||
/** Keep what is being previewed: its throwaway tab becomes a real one, and
|
||||
* the focus it moved stands. Any other pending preview is simply
|
||||
* discarded. Returns whether `sessionId` was the one being previewed. */
|
||||
commitPreview(sessionId: Uuid): boolean;
|
||||
}
|
||||
|
||||
export type LayoutStore = LayoutState & LayoutActions;
|
||||
export type LayoutStoreApi = ReturnType<typeof createLayoutStore>;
|
||||
|
||||
/** Builds an isolated layout store — load/save/scheduler are injectable
|
||||
* (tests), or default to the real localStorage persistence.ts + browser
|
||||
* timers. Same shape as {@link createCatalogStore}. */
|
||||
export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
||||
const load = opts.load ?? loadLayout;
|
||||
const save = opts.save ?? saveLayout;
|
||||
const scheduler = opts.scheduler ?? browserScheduler;
|
||||
|
||||
let saveTimer: number | null = null;
|
||||
|
||||
return create<LayoutStore>((set, get) => {
|
||||
function touch(): void {
|
||||
// A shallow clone, not the same reference back: `useStore` (and any
|
||||
// other Object.is-based selector) needs the identity to change to
|
||||
// notice a mutation made in place on the tree underneath.
|
||||
set({ layout: { ...get().layout } });
|
||||
}
|
||||
|
||||
function schedulePersist(): void {
|
||||
if (saveTimer !== null) scheduler.clearTimer(saveTimer);
|
||||
saveTimer = scheduler.setTimer(() => {
|
||||
saveTimer = null;
|
||||
save(get().layout);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
touch();
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
function mutate(fn: (layout: Layout) => void): void {
|
||||
fn(get().layout);
|
||||
commit();
|
||||
}
|
||||
|
||||
// ---- preview (docs/PREVIEW.md): a view, not a layout change — never
|
||||
// schedules a save while it is up. ----
|
||||
|
||||
/** Undo the preview's moves. Mutates only — the caller re-renders /
|
||||
* persists as it sees fit. Returns whether a preview was active. */
|
||||
function restorePreview(): boolean {
|
||||
const p = get().preview;
|
||||
if (!p) return false;
|
||||
set({ preview: null });
|
||||
const layout = get().layout;
|
||||
if (p.tempTabId) {
|
||||
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
|
||||
if (i >= 0) layout.tabs.splice(i, 1);
|
||||
} else if (p.prevFocus) {
|
||||
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
|
||||
if (t) t.focusedLeafId = p.prevFocus.leafId;
|
||||
}
|
||||
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
|
||||
? p.prevActiveTabId
|
||||
: layout.tabs[0].id;
|
||||
return true;
|
||||
}
|
||||
|
||||
function settlePreview(sessionId: Uuid): boolean {
|
||||
const p = get().preview;
|
||||
if (!p) return false;
|
||||
if (p.sessionId !== sessionId) {
|
||||
restorePreview();
|
||||
return false;
|
||||
}
|
||||
set({ preview: null });
|
||||
if (p.tempTabId) {
|
||||
const layout = get().layout;
|
||||
const t = layout.tabs.find((x) => x.id === p.tempTabId);
|
||||
if (t) t.title = `Tab ${layout.tabs.length}`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
layout: load() ?? emptyLayout(),
|
||||
preview: null,
|
||||
|
||||
openSession(sessionId) {
|
||||
if (get().commitPreview(sessionId)) return;
|
||||
mutate((layout) => {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
layout.activeTabId = loc.tab.id;
|
||||
loc.tab.focusedLeafId = loc.leaf.id;
|
||||
return;
|
||||
}
|
||||
const tab = activeTab(layout);
|
||||
if (!tab.root) {
|
||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||
return;
|
||||
}
|
||||
const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
|
||||
target.sessionId = sessionId;
|
||||
tab.focusedLeafId = target.id;
|
||||
});
|
||||
},
|
||||
|
||||
openSessionBeside(sessionId, placement) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
layout.activeTabId = loc.tab.id;
|
||||
loc.tab.focusedLeafId = loc.leaf.id;
|
||||
return;
|
||||
}
|
||||
if (placement?.newTab) {
|
||||
const fresh = addTabToLayout(layout);
|
||||
fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true);
|
||||
return;
|
||||
}
|
||||
const tab = activeTab(layout);
|
||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId) {
|
||||
target.sessionId = sessionId;
|
||||
} else {
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
||||
equalizeRatios(tab.root);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
closeSession(sessionId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (!loc) return;
|
||||
removeLeaf(loc.tab, loc.leaf.id);
|
||||
if (loc.tab.focusedLeafId === loc.leaf.id) {
|
||||
loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
|
||||
}
|
||||
if (!loc.tab.root) closeTabInLayout(layout, loc.tab.id);
|
||||
});
|
||||
},
|
||||
|
||||
splitFocused(leafId, dir) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
const tab = activeTab(layout);
|
||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||
equalizeRatios(tab.root);
|
||||
});
|
||||
},
|
||||
|
||||
closePane(leafId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
const tab = activeTab(layout);
|
||||
removeLeaf(tab, leafId);
|
||||
if (tab.focusedLeafId === leafId) {
|
||||
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
focusLeaf(leafId) {
|
||||
mutate((layout) => {
|
||||
const tab = activeTab(layout);
|
||||
if (tab.focusedLeafId === leafId) return;
|
||||
tab.focusedLeafId = leafId;
|
||||
});
|
||||
},
|
||||
|
||||
selectTab(tabId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
|
||||
});
|
||||
},
|
||||
|
||||
addTab() {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
addTabToLayout(layout);
|
||||
});
|
||||
},
|
||||
|
||||
closeTab(tabId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
closeTabInLayout(layout, tabId);
|
||||
});
|
||||
},
|
||||
|
||||
reorderTab(fromId, toId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
if (fromId === toId) return;
|
||||
const from = layout.tabs.findIndex((t) => t.id === fromId);
|
||||
const to = layout.tabs.findIndex((t) => t.id === toId);
|
||||
if (from < 0 || to < 0) return;
|
||||
const [moved] = layout.tabs.splice(from, 1);
|
||||
layout.tabs.splice(to, 0, moved);
|
||||
});
|
||||
},
|
||||
|
||||
commitLayout() {
|
||||
commit();
|
||||
},
|
||||
|
||||
reconcile(liveIds) {
|
||||
mutate((layout) => {
|
||||
const emptied: string[] = [];
|
||||
for (const tab of [...layout.tabs]) {
|
||||
let prunedAny = false;
|
||||
for (const leaf of leaves(tab.root)) {
|
||||
if (leaf.sessionId && !liveIds.has(leaf.sessionId)) {
|
||||
removeLeaf(tab, leaf.id);
|
||||
prunedAny = true;
|
||||
}
|
||||
}
|
||||
if (!prunedAny) continue;
|
||||
if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
|
||||
}
|
||||
if (!tab.root) emptied.push(tab.id);
|
||||
}
|
||||
for (const id of emptied) closeTabInLayout(layout, id);
|
||||
});
|
||||
},
|
||||
|
||||
previewSession(sessionId) {
|
||||
const current = get().preview;
|
||||
if (current?.sessionId === sessionId) return;
|
||||
if (sessionId === null) {
|
||||
if (restorePreview()) commit();
|
||||
return;
|
||||
}
|
||||
restorePreview();
|
||||
const layout = get().layout;
|
||||
const prevActiveTabId = layout.activeTabId;
|
||||
const loc = locateSession(layout, sessionId);
|
||||
let preview: PreviewState;
|
||||
if (loc) {
|
||||
preview = {
|
||||
sessionId,
|
||||
prevActiveTabId,
|
||||
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
|
||||
tempTabId: null,
|
||||
};
|
||||
layout.activeTabId = loc.tab.id;
|
||||
loc.tab.focusedLeafId = loc.leaf.id;
|
||||
} else {
|
||||
// Not on screen anywhere: a throwaway tab carries it, so no
|
||||
// existing pane is evicted and nothing the operator arranged moves.
|
||||
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
|
||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||
layout.tabs.push(tab);
|
||||
layout.activeTabId = tab.id;
|
||||
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
|
||||
}
|
||||
set({ layout: { ...layout }, preview });
|
||||
},
|
||||
|
||||
commitPreview(sessionId) {
|
||||
const ok = settlePreview(sessionId);
|
||||
if (ok) commit();
|
||||
return ok;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user