Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e98baf4d | ||
|
|
b286323c9c | ||
|
|
71ec7e073c | ||
|
|
46bae13212 | ||
|
|
20d303278d | ||
|
|
589e0144e2 | ||
|
|
0061378193 | ||
|
|
1cf4825b1d | ||
|
|
f799aaf564 | ||
|
|
5af5ceb9a8 | ||
|
|
fbdcec6f69 | ||
|
|
6af4cda650 | ||
|
|
ab4beea2d9 | ||
|
|
44ef5619bf | ||
|
|
6adb5dabf0 | ||
|
|
e9c9e1c647 | ||
|
|
4167b21fe9 | ||
|
|
b9f8f02e11 | ||
|
|
f36b7808a7 | ||
|
|
c2d427691d | ||
|
|
50b862b1fa | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 |
@@ -11,7 +11,8 @@
|
||||
"Bash(./node_modules/.bin/tsc:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)"
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo test:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +80,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_repo_dir_is_none() {
|
||||
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
||||
// the walk can't accidentally hit a real repo above the test's cwd.
|
||||
fn non_repo_dir_does_not_find_root_inside_fixture() {
|
||||
// The fixture has no `.git`, so `find_root` must not return a path
|
||||
// inside it. On hosts where an ancestor of the temp dir *is* a repo
|
||||
// (e.g. `/tmp` lives inside a checkout), the function legitimately
|
||||
// returns that ancestor — the test only asserts the fixture itself
|
||||
// was not mistaken for a repo.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sub = tmp.path().join("plain/dir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
assert_eq!(find_root(&sub), None);
|
||||
match find_root(&sub) {
|
||||
None => {} // expected on most hosts
|
||||
Some(root) => assert!(
|
||||
!root.starts_with(tmp.path()),
|
||||
"found .git inside the fixture at {root:?}, but none was created"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -805,6 +805,10 @@ impl SessionManager {
|
||||
});
|
||||
drop(stream);
|
||||
sync_structured_activity(&self.inner, id).await;
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
if let Ok(true) = self.claim_autoname(id).await {
|
||||
crate::autoname::spawn_naming(self.clone(), id, text);
|
||||
}
|
||||
return Ok(QueueAcpPrompt::Queued);
|
||||
}
|
||||
Err(message) => {
|
||||
|
||||
@@ -1003,7 +1003,9 @@ async fn names_honors_count_and_caps_it() {
|
||||
#[tokio::test]
|
||||
async fn gitroot_probes_path() {
|
||||
// #26: resolves the query against the daemon home dir and walks up for a
|
||||
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
||||
// `.git`. The tempdir itself has no `.git`, so the result must either be
|
||||
// null (no repo anywhere above) or — in sandboxed environments where /tmp
|
||||
// itself sits inside a repo — a path strictly above the tempdir.
|
||||
let h = spawn("naps-laptop").await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let resp: serde_json::Value = reqwest::get(url(
|
||||
@@ -1018,7 +1020,16 @@ async fn gitroot_probes_path() {
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.get("root").is_none() || resp["root"].is_null());
|
||||
// On hosts where an ancestor of the temp dir is a repo (e.g. `/tmp`
|
||||
// inside a checkout), the root legitimately points there. The test only
|
||||
// asserts no root was found *inside* the temp dir.
|
||||
match resp.get("root").and_then(|v| v.as_str()) {
|
||||
None => {}
|
||||
Some(root) => assert!(
|
||||
!root.starts_with(tmp.path().to_string_lossy().as_ref()),
|
||||
"found a repo root inside the fixture at {root:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -95,10 +95,7 @@ impl MockDaemon {
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||
.route(
|
||||
routes::patterns::STRUCTURED_PROMPT,
|
||||
post(structured_prompt),
|
||||
)
|
||||
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
|
||||
@@ -193,10 +193,13 @@ choices, not daemon ones.
|
||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||
appear *between* tool rows in the same turn are absorbed into the group
|
||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||
outside as the prominent final answer. The header still reads
|
||||
"N tool calls" (counting only tool rows). Opening the group reveals tools
|
||||
and intermediate text in chronological order, with the text rendered in a
|
||||
subdued style.
|
||||
outside as the prominent final answer. While a turn is still active
|
||||
(responding or thinking), the most recent intermediate `AgentText` or
|
||||
`AgentThought` in the group is kept outside as visible narration — replaced
|
||||
by the next text row when one arrives, and absorbed into the group when the
|
||||
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
||||
Opening the group reveals tools and intermediate text in chronological
|
||||
order, with the text rendered in a subdued style.
|
||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||
copy their complete source text when clicked or keyboard-activated.
|
||||
- The web client consumes `available_commands_update` notices as the live
|
||||
|
||||
+12
-2
@@ -17,12 +17,22 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"marked": "^18.0.11"
|
||||
"marked": "^18.0.11",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"happy-dom": "^20.11.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.9"
|
||||
"vitest": "^2.1.9",
|
||||
"zustand": "^5.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+984
-11
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<div id="react-root" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square
|
||||
// corners, full card height. It paints for every card, Idle included, and it
|
||||
// carries the accessible name of the state so status is never colour-only.
|
||||
|
||||
import { activityColorSlug } from "../../activity.js";
|
||||
import type { ActivityState } from "../../types.js";
|
||||
|
||||
export const STATE_WORDS: Record<string, string> = {
|
||||
active: "Active",
|
||||
idle: "Idle",
|
||||
"awaiting-input": "Awaiting input",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
/** Rail fill per state, and the motion the state earns. */
|
||||
const RAIL_CLASS: Record<string, string> = {
|
||||
active: "bg-live",
|
||||
idle: "bg-border-strong",
|
||||
"awaiting-input": "bg-warn rail-blink",
|
||||
error: "bg-danger rail-flash",
|
||||
};
|
||||
|
||||
/** Duration text inherits the rail's colour (docs/SIDEBAR.md). */
|
||||
export const DURATION_CLASS: Record<string, string> = {
|
||||
active: "text-live",
|
||||
idle: "text-muted",
|
||||
"awaiting-input": "text-warn",
|
||||
error: "text-danger",
|
||||
};
|
||||
|
||||
export function stateWordFor(activity: ActivityState | string | undefined): string {
|
||||
return STATE_WORDS[activityColorSlug(activity ?? "Idle")] ?? STATE_WORDS.idle;
|
||||
}
|
||||
|
||||
export function ActivityRail({ activity }: { activity: ActivityState | string | undefined }) {
|
||||
const slug = activityColorSlug(activity ?? "Idle");
|
||||
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
return (
|
||||
<span
|
||||
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
|
||||
data-activity={slug}
|
||||
role="img"
|
||||
title={word}
|
||||
aria-label={word}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar,
|
||||
// kill. Rendered into <body> so a scrolling sidebar cannot clip it.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
onSelect(): void;
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
const onDown = (e: Event) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("pointerdown", onDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("pointerdown", onDown, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
|
||||
item.danger ? "text-danger" : "text-ink"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// A collapsible directory in the sidebar tree. Only a *collapsed* header is a
|
||||
// keyboard-walk stop (docs/SIDEBAR.md); an expanded one is decoration in front
|
||||
// of the cards it holds, so it carries no highlight of its own.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { splitFolderLabel } from "./model.js";
|
||||
|
||||
export interface FolderNodeProps {
|
||||
name: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
collapsed: boolean;
|
||||
highlighted: boolean;
|
||||
onToggle(): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FolderNode(props: FolderNodeProps) {
|
||||
const { stem, last } = splitFolderLabel(props.name);
|
||||
return (
|
||||
<div className="tree-folder">
|
||||
<button
|
||||
type="button"
|
||||
className={`tree-folder-header flex w-full items-center gap-sp-1 py-[2px] text-fs-11 text-muted ${
|
||||
props.highlighted ? "ring-1 ring-accent-ring" : ""
|
||||
}`}
|
||||
style={{ paddingLeft: props.depth * 16 }}
|
||||
aria-expanded={!props.collapsed}
|
||||
data-nav-key={`folder:${props.path}`}
|
||||
data-highlighted={props.highlighted ? "true" : undefined}
|
||||
title={props.name}
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
{/* One glyph, rotated when collapsed, so the mark and the rotation
|
||||
never disagree about which way the folder points. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block transition-transform ${props.collapsed ? "-rotate-90" : ""}`}
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{stem && <span className="text-faint">{stem}/</span>}
|
||||
{last}
|
||||
</span>
|
||||
</button>
|
||||
{!props.collapsed && props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// docs/SIDEBAR.md "Killing a session": every kill confirms first, and the
|
||||
// confirmation names what the card last saw in the way — the daemon refuses an
|
||||
// unforced kill over such a tree, and saying so beforehand is cheaper than the
|
||||
// refusal. Cancel holds the focus so a stray Enter kills nothing.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { InTheWay } from "../../types.js";
|
||||
import { formatInTheWay } from "../../types.js";
|
||||
|
||||
export interface KillConfirmProps {
|
||||
name: string;
|
||||
blockers: InTheWay | null;
|
||||
/** Set when the daemon already refused an unforced kill: this pass forces. */
|
||||
force?: boolean;
|
||||
onConfirm(): void;
|
||||
onCancel(): void;
|
||||
}
|
||||
|
||||
export function KillConfirm(props: KillConfirmProps) {
|
||||
const cancel = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => cancel.current?.focus(), []);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={props.force ? "Force kill session" : "Kill session"}
|
||||
className="kill-confirm fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") props.onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="min-w-[280px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
|
||||
<p className="text-fs-13 text-ink">
|
||||
{props.force ? "Force kill" : "Kill"} <strong>{props.name}</strong>?
|
||||
</p>
|
||||
{props.blockers && (
|
||||
<p className="mt-sp-2 text-fs-12 text-warn">{formatInTheWay(props.blockers)}</p>
|
||||
)}
|
||||
<div className="mt-sp-3 flex justify-end gap-sp-2">
|
||||
<button
|
||||
ref={cancel}
|
||||
type="button"
|
||||
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
|
||||
onClick={props.onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
|
||||
onClick={props.onConfirm}
|
||||
>
|
||||
{props.force ? "Force kill" : "Kill"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// docs/SIDEBAR.md: a session card is always two lines — the name, then the
|
||||
// adaptive meta line — with the activity rail in the gutter beside them.
|
||||
|
||||
import { useEffect, useRef, type MouseEvent } from "react";
|
||||
|
||||
import {
|
||||
activityDurationLabel,
|
||||
activityHeldMs,
|
||||
META_LINE_MIN_HOLD_MS,
|
||||
} from "../../activity.js";
|
||||
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
|
||||
import type { InTheWay, Session } from "../../types.js";
|
||||
import {
|
||||
blockerStats,
|
||||
customSessionName,
|
||||
formatInTheWay,
|
||||
isProvisionalName,
|
||||
MAX_SESSION_NAME_LEN,
|
||||
normalizeSessionName,
|
||||
sessionBlockers,
|
||||
sessionName,
|
||||
} from "../../types.js";
|
||||
import { ActivityRail, DURATION_CLASS, stateWordFor } from "./ActivityRail.js";
|
||||
import { activityColorSlug } from "../../activity.js";
|
||||
|
||||
export interface SessionCardProps {
|
||||
session: Session;
|
||||
/** Nesting depth; the row's padding (and so the rail) shifts outward with it. */
|
||||
depth: number;
|
||||
/** Clock the duration is measured against — the sidebar ticks it. */
|
||||
now: number;
|
||||
highlighted: boolean;
|
||||
/** Showing in the focused pane. */
|
||||
active: boolean;
|
||||
/** Open in some pane, not the focused one. */
|
||||
open: boolean;
|
||||
previewing: boolean;
|
||||
renaming: boolean;
|
||||
onSelect(): void;
|
||||
onOpenMenu(x: number, y: number): void;
|
||||
onSpawnSimilar(): void;
|
||||
onKill(): void;
|
||||
onRenameCommit(name: string): void;
|
||||
onRenameCancel(): void;
|
||||
}
|
||||
|
||||
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down
|
||||
* wins. Written as one ordered run so the precedence is the order it reads in. */
|
||||
function rowFill(p: SessionCardProps): string {
|
||||
if (p.active) return "bg-accent-fill";
|
||||
if (p.previewing) return "bg-preview-tint";
|
||||
if (p.open) return "bg-accent/[0.06]";
|
||||
if (p.highlighted) return "bg-preview-tint";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function SessionCard(props: SessionCardProps) {
|
||||
const { session: s, depth, now, renaming } = props;
|
||||
const name = sessionName(s);
|
||||
const running = s.status === "Running";
|
||||
const word = stateWordFor(s.activity);
|
||||
|
||||
return (
|
||||
<a
|
||||
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
|
||||
running ? "" : "opacity-60"
|
||||
}`}
|
||||
href={`#/attach/${encodeURIComponent(s.id)}`}
|
||||
style={{ paddingLeft: depth * 16 + 8 }}
|
||||
data-session-id={s.id}
|
||||
data-nav-key={`session:${s.id}`}
|
||||
data-status={running ? "running" : "dead"}
|
||||
data-highlighted={props.highlighted ? "true" : undefined}
|
||||
aria-label={`${name}, ${running ? "running" : "exited"}, ${word}`}
|
||||
onClick={(e) => {
|
||||
if (renaming) return;
|
||||
e.preventDefault();
|
||||
props.onSelect();
|
||||
}}
|
||||
onContextMenu={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onOpenMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
>
|
||||
<ActivityRail activity={s.activity} />
|
||||
<span className="flex min-w-0 items-center gap-sp-2">
|
||||
{renaming ? (
|
||||
<RenameField
|
||||
session={s}
|
||||
onCommit={props.onRenameCommit}
|
||||
onCancel={props.onRenameCancel}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-fs-13 ${
|
||||
isProvisionalName(s) ? "text-muted" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex flex-none items-center gap-[2px]">
|
||||
<button
|
||||
type="button"
|
||||
className="leaf-clone text-faint hover:text-ink"
|
||||
title="Spawn similar"
|
||||
aria-label={`Spawn similar to ${name}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onSpawnSimilar();
|
||||
}}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="leaf-kill text-faint hover:text-danger"
|
||||
title="Kill session"
|
||||
aria-label={`Kill ${name}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onKill();
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<MetaLine session={s} now={now} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`.
|
||||
* The line itself is unconditional; only its fragments are adaptive. */
|
||||
export function MetaLine({ session: s, now }: { session: Session; now: number }) {
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
const duration =
|
||||
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
||||
const blockers = sessionBlockers(s);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="card-line2 flex min-w-0 items-center gap-sp-2 text-fs-11"
|
||||
data-activity={slug}
|
||||
>
|
||||
{duration && (
|
||||
<span className={`activity-duration flex-none ${DURATION_CLASS[slug] ?? "text-muted"}`}>
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
{s.activity_detail && (
|
||||
<span className="activity-detail min-w-0 flex-1 truncate text-faint">
|
||||
{s.activity_detail}
|
||||
</span>
|
||||
)}
|
||||
<CommandFragment command={s.command} />
|
||||
{blockers && <BlockedFragment blockers={blockers} />}
|
||||
{s.status === "Dead" && <span className="flex-none text-faint">exited</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: the card names the program only, as its tool mark when we
|
||||
* have one. The full command line stays in the tooltip either way. */
|
||||
function CommandFragment({ command }: { command: string }) {
|
||||
const { tool, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
return (
|
||||
<span
|
||||
className="leaf-cmd flex min-w-0 flex-none items-center gap-[4px] truncate text-faint"
|
||||
title={command}
|
||||
>
|
||||
{icon ? (
|
||||
<span className="contents" dangerouslySetInnerHTML={{ __html: icon }} />
|
||||
) : (
|
||||
<span className="truncate">{program}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Blocked work in diff notation — `+124 -18 ?3 ↑2`. Only `+` and `-` take
|
||||
* colour; the spelled-out breakdown survives as the tooltip. */
|
||||
function BlockedFragment({ blockers }: { blockers: InTheWay }) {
|
||||
const stats = blockerStats(blockers);
|
||||
return (
|
||||
<span
|
||||
className="leaf-blocked flex flex-none items-center gap-[4px] text-faint"
|
||||
title={`Kill will be refused unless forced — ${formatInTheWay(blockers)}`}
|
||||
>
|
||||
{stats.map((st) => (
|
||||
<span
|
||||
key={st.kind}
|
||||
data-stat={st.kind}
|
||||
className={
|
||||
st.kind === "add" ? "text-live" : st.kind === "del" ? "text-danger" : "text-muted"
|
||||
}
|
||||
>
|
||||
{st.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline rename (#125): Enter commits, Escape and blur cancel, an emptied
|
||||
* field clears the name so the command fallback resumes. It starts from the
|
||||
* session's own name, never from the command it falls back to. */
|
||||
function RenameField({
|
||||
session: s,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
session: Session;
|
||||
onCommit(name: string): void;
|
||||
onCancel(): void;
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const settled = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.focus();
|
||||
ref.current?.select();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
className="leaf-rename min-w-0 flex-1 bg-surface-2 text-fs-13 text-ink"
|
||||
defaultValue={customSessionName(s) ?? ""}
|
||||
maxLength={MAX_SESSION_NAME_LEN}
|
||||
aria-label={`Rename ${sessionName(s)}`}
|
||||
// The card is an <a> the tree also drags from: without these, clicking
|
||||
// into the field navigates away or starts a drag.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (settled.current) return;
|
||||
settled.current = true;
|
||||
onCommit(normalizeSessionName(e.currentTarget.value));
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (settled.current) return;
|
||||
settled.current = true;
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (settled.current) return;
|
||||
settled.current = true;
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Session } from "../../types.js";
|
||||
import { Sidebar } from "./Sidebar.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/n/alpha",
|
||||
command: "hx src/",
|
||||
env: {},
|
||||
metadata: { name: id },
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...over,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
/** The card element for a session, by the `data-session-id` the row carries. */
|
||||
function card(id: string): HTMLElement {
|
||||
const el = document.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||
if (!el) throw new Error(`no card for ${id}`);
|
||||
return el;
|
||||
}
|
||||
|
||||
function tree(): HTMLElement {
|
||||
return document.querySelector<HTMLElement>(".tree")!;
|
||||
}
|
||||
|
||||
describe("SessionCard layout", () => {
|
||||
it("renders two lines: the name, then a meta line naming the program", () => {
|
||||
render(<Sidebar sessions={[session("alpha")]} />);
|
||||
|
||||
const row = card("alpha");
|
||||
expect(row.textContent).toContain("alpha");
|
||||
const meta = row.querySelector(".card-line2")!;
|
||||
// The program only — never its arguments.
|
||||
expect(meta.textContent).toContain("hx");
|
||||
expect(meta.textContent).not.toContain("src/");
|
||||
});
|
||||
|
||||
it("shows no duration under the 30s hold, and one above it", () => {
|
||||
const now = Date.parse("2026-08-24T10:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
|
||||
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
|
||||
|
||||
render(<Sidebar sessions={[fresh, held]} />);
|
||||
|
||||
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
|
||||
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
|
||||
render(
|
||||
<Sidebar
|
||||
sessions={[
|
||||
session("idle"),
|
||||
session("await", { activity: "AwaitingInput" }),
|
||||
session("err", { activity: "Error" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
|
||||
expect(rail("idle").dataset.activity).toBe("idle");
|
||||
expect(rail("idle").className).not.toContain("rail-");
|
||||
expect(rail("await").className).toContain("rail-blink");
|
||||
expect(rail("err").className).toContain("rail-flash");
|
||||
// Status never rests on colour alone.
|
||||
expect(rail("await").getAttribute("aria-label")).toBe("Awaiting input");
|
||||
});
|
||||
|
||||
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
|
||||
render(
|
||||
<Sidebar
|
||||
sessions={[
|
||||
session("alpha", {
|
||||
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
|
||||
expect(blocked.textContent).toBe("+124-18?3");
|
||||
expect(blocked.title).toContain("2 uncommitted files");
|
||||
});
|
||||
|
||||
it("dims a provisional name and leaves a settled one alone", () => {
|
||||
render(
|
||||
<Sidebar
|
||||
sessions={[
|
||||
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
|
||||
session("user", { metadata: { name: "user", name_source: "User" } }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
|
||||
expect(card("user").textContent).toContain("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard walk", () => {
|
||||
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
|
||||
|
||||
it("j and k step the highlight through the cards", () => {
|
||||
render(<Sidebar sessions={two} />);
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
expect(card("a").dataset.highlighted).toBe("true");
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
expect(card("b").dataset.highlighted).toBe("true");
|
||||
fireEvent.keyDown(tree(), { key: "k" });
|
||||
expect(card("a").dataset.highlighted).toBe("true");
|
||||
});
|
||||
|
||||
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
|
||||
render(<Sidebar sessions={two} />);
|
||||
|
||||
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
|
||||
fireEvent.click(header);
|
||||
expect(header.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
expect(
|
||||
document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!.dataset.highlighted,
|
||||
).toBe("true");
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "Enter" });
|
||||
expect(card("a").dataset.highlighted).toBe("true");
|
||||
});
|
||||
|
||||
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
|
||||
const onSelect = vi.fn();
|
||||
const onFocusPane = vi.fn();
|
||||
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
fireEvent.keyDown(tree(), { key: "Enter" });
|
||||
expect(onSelect).toHaveBeenCalledWith("a");
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "Tab" });
|
||||
expect(onFocusPane).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews the session the walk lands on", () => {
|
||||
const onPreview = vi.fn();
|
||||
render(<Sidebar sessions={two} onPreview={onPreview} />);
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
expect(onPreview).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("x asks to kill the highlighted session and r renames it", () => {
|
||||
const onKill = vi.fn();
|
||||
render(<Sidebar sessions={two} onKill={onKill} />);
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "j" });
|
||||
fireEvent.keyDown(tree(), { key: "x" });
|
||||
expect(onKill).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
|
||||
|
||||
fireEvent.keyDown(tree(), { key: "r" });
|
||||
expect(card("a").querySelector(".leaf-rename")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("context menu", () => {
|
||||
it("offers rename, spawn similar and kill", () => {
|
||||
const onSpawnSimilar = vi.fn();
|
||||
const onKill = vi.fn();
|
||||
render(
|
||||
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(card("alpha"));
|
||||
const labels = [...document.querySelectorAll('[role="menuitem"]')].map((n) => n.textContent);
|
||||
expect(labels).toEqual(["Rename…", "Spawn similar", "Kill session"]);
|
||||
|
||||
fireEvent.click(screen.getByText("Spawn similar"));
|
||||
expect(onSpawnSimilar).toHaveBeenCalledWith(expect.objectContaining({ id: "alpha" }));
|
||||
});
|
||||
|
||||
it("renames in place: Enter commits the field, Escape cancels", () => {
|
||||
const onRename = vi.fn();
|
||||
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
|
||||
|
||||
fireEvent.contextMenu(card("alpha"));
|
||||
fireEvent.click(screen.getByText("Rename…"));
|
||||
|
||||
const input = card("alpha").querySelector<HTMLInputElement>(".leaf-rename")!;
|
||||
expect(input.value).toBe("alpha");
|
||||
fireEvent.change(input, { target: { value: " renamed " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onRename).toHaveBeenCalledWith("alpha", "renamed");
|
||||
expect(card("alpha").querySelector(".leaf-rename")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("filters the tree down to matching sessions", () => {
|
||||
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
|
||||
|
||||
expect(document.querySelector('[data-session-id="alpha"]')).toBeNull();
|
||||
expect(document.querySelector('[data-session-id="beta"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("offers a spawn when there are no sessions at all", () => {
|
||||
const onSpawn = vi.fn();
|
||||
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
|
||||
|
||||
fireEvent.click(screen.getByText("+ Spawn session"));
|
||||
expect(onSpawn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
// React port of the sidebar tree (catalog.ts). The contract it renders is
|
||||
// docs/SIDEBAR.md: two-line cards behind an activity rail, folder-grouped by
|
||||
// repository, walked with j/k.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { ApiRequestError, killSession, renameSession } from "../../api.js";
|
||||
import type { CatalogStoreApi } from "../../stores/catalogStore.js";
|
||||
import type { InTheWay, Session } from "../../types.js";
|
||||
import { sessionBlockers, sessionName } from "../../types.js";
|
||||
import { buildTree, type TreeNode } from "../../tree.js";
|
||||
import { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
|
||||
import { FolderNode } from "./FolderNode.js";
|
||||
import { KillConfirm } from "./KillConfirm.js";
|
||||
import { SessionCard } from "./SessionCard.js";
|
||||
import { SidebarSearch } from "./SidebarSearch.js";
|
||||
import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js";
|
||||
import { useNow } from "./useNow.js";
|
||||
import "./sidebar.css";
|
||||
|
||||
export interface SidebarProps {
|
||||
/** Live catalog. Either this or `sessions` supplies the tree. */
|
||||
store?: CatalogStoreApi;
|
||||
/** Static session list — what tests and the design kit render from. */
|
||||
sessions?: Session[];
|
||||
/** The daemon's home dir, so cwds abbreviate to `~/…`. */
|
||||
homeDir?: string;
|
||||
/** Showing in the focused pane. */
|
||||
activeId?: string | null;
|
||||
/** Open in some pane, focused one included. */
|
||||
openIds?: ReadonlySet<string>;
|
||||
/** Previewing (docs/PREVIEW.md) — a view, never durable state. */
|
||||
previewId?: string | null;
|
||||
onSelect?(id: string): void;
|
||||
/** The highlight moved onto a session, or off every session. */
|
||||
onPreview?(id: string | null): void;
|
||||
onSpawnSimilar?(session: Session): void;
|
||||
onSpawn?(): void;
|
||||
/** Fast-forward spawn (`N`). */
|
||||
onSpawnDefaults?(): void;
|
||||
/** Tab: hand the keyboard back to the pane. */
|
||||
onFocusPane?(): void;
|
||||
onHelp?(): void;
|
||||
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
|
||||
onKill?(session: Session): void;
|
||||
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
|
||||
onRename?(id: string, name: string): void;
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
session: Session;
|
||||
}
|
||||
|
||||
interface KillState {
|
||||
session: Session;
|
||||
blockers: InTheWay | null;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar(props: SidebarProps) {
|
||||
const sessions = useSessions(props);
|
||||
const now = useNow();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>());
|
||||
const [navKey, setNavKey] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||
const [kill, setKill] = useState<KillState | null>(null);
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tree = useMemo(
|
||||
() => buildTree(filterSessions(sessions, query), props.homeDir),
|
||||
[sessions, query, props.homeDir],
|
||||
);
|
||||
const rows = useMemo(() => navRows(tree, collapsed), [tree, collapsed]);
|
||||
|
||||
// A row that went away — session killed, folder collapsed over it — drops
|
||||
// the highlight rather than leaving it pointing at nothing.
|
||||
useEffect(() => {
|
||||
if (navKey !== null && !rows.some((r) => r.key === navKey)) setNavKey(null);
|
||||
}, [rows, navKey]);
|
||||
|
||||
const preview = props.onPreview;
|
||||
const previewOf = useCallback(
|
||||
(key: string | null) => {
|
||||
preview?.(key?.startsWith("session:") ? key.slice("session:".length) : null);
|
||||
},
|
||||
[preview],
|
||||
);
|
||||
|
||||
const moveNav = useCallback(
|
||||
(delta: number) => {
|
||||
if (rows.length === 0) return;
|
||||
const cur = rows.findIndex((r) => r.key === navKey);
|
||||
// Nothing highlighted: start one off the end being walked from, so the
|
||||
// first step lands on the first (or last) row.
|
||||
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
|
||||
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
|
||||
setNavKey(next.key);
|
||||
previewOf(next.key);
|
||||
},
|
||||
[rows, navKey, previewOf],
|
||||
);
|
||||
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Enter: open the highlighted session, or expand the highlighted folder and
|
||||
* land on the first row it was hiding. */
|
||||
const activateNav = useCallback(() => {
|
||||
const row = rows.find((r) => r.key === navKey);
|
||||
if (!row) return;
|
||||
if (row.kind === "session") {
|
||||
props.onSelect?.(row.id);
|
||||
return;
|
||||
}
|
||||
const expanded = new Set(collapsed);
|
||||
expanded.delete(row.path);
|
||||
setCollapsed(expanded);
|
||||
const revealed = firstRowUnder(tree, expanded, row.path);
|
||||
setNavKey(revealed?.key ?? null);
|
||||
}, [rows, navKey, collapsed, tree, props]);
|
||||
|
||||
const beginKill = useCallback(
|
||||
(s: Session) => {
|
||||
if (props.onKill) {
|
||||
props.onKill(s);
|
||||
return;
|
||||
}
|
||||
setKill({ session: s, blockers: sessionBlockers(s), force: false });
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
const commitRename = useCallback(
|
||||
(id: string, name: string) => {
|
||||
setRenamingId(null);
|
||||
if (props.onRename) props.onRename(id, name);
|
||||
else void renameSession(id, name).catch(() => {});
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
/** The session behind the highlighted row, when the highlight is on a card. */
|
||||
const highlightedSession = useCallback((): Session | undefined => {
|
||||
const row = rows.find((r) => r.key === navKey);
|
||||
if (row?.kind !== "session") return undefined;
|
||||
return sessions.find((s) => s.id === row.id);
|
||||
}, [rows, navKey, sessions]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
// The inline rename field owns its own keys; the walk stays out of it.
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
switch (e.key) {
|
||||
case "j":
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
moveNav(1);
|
||||
return;
|
||||
case "k":
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
moveNav(-1);
|
||||
return;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
activateNav();
|
||||
return;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
props.onFocusPane?.();
|
||||
return;
|
||||
case "c": {
|
||||
const row = rows.find((r) => r.key === navKey);
|
||||
if (row?.kind !== "folder") return;
|
||||
e.preventDefault();
|
||||
toggleFolder(row.path);
|
||||
return;
|
||||
}
|
||||
case "r": {
|
||||
const s = highlightedSession();
|
||||
if (!s) return;
|
||||
e.preventDefault();
|
||||
setRenamingId(s.id);
|
||||
return;
|
||||
}
|
||||
case "x": {
|
||||
const s = highlightedSession();
|
||||
if (!s) return;
|
||||
e.preventDefault();
|
||||
beginKill(s);
|
||||
return;
|
||||
}
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
props.onSpawn?.();
|
||||
return;
|
||||
case "N":
|
||||
e.preventDefault();
|
||||
props.onSpawnDefaults?.();
|
||||
return;
|
||||
case "?":
|
||||
e.preventDefault();
|
||||
props.onHelp?.();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = (s: Session): ContextMenuItem[] => [
|
||||
{ label: "Rename…", onSelect: () => setRenamingId(s.id) },
|
||||
{ label: "Spawn similar", onSelect: () => props.onSpawnSimilar?.(s) },
|
||||
{ label: "Kill session", danger: true, onSelect: () => beginKill(s) },
|
||||
];
|
||||
|
||||
const renderNode = (node: TreeNode, depth: number): React.ReactNode => {
|
||||
if (node.kind === "leaf") {
|
||||
const s = node.session;
|
||||
return (
|
||||
<SessionCard
|
||||
key={s.id}
|
||||
session={s}
|
||||
depth={depth}
|
||||
now={now}
|
||||
highlighted={navKey === sessionKey(s.id)}
|
||||
active={props.activeId === s.id}
|
||||
open={(props.openIds?.has(s.id) ?? false) && props.activeId !== s.id}
|
||||
previewing={props.previewId === s.id && props.activeId !== s.id}
|
||||
renaming={renamingId === s.id}
|
||||
onSelect={() => props.onSelect?.(s.id)}
|
||||
onOpenMenu={(x, y) => setMenu({ x, y, session: s })}
|
||||
onSpawnSimilar={() => props.onSpawnSimilar?.(s)}
|
||||
onKill={() => beginKill(s)}
|
||||
onRenameCommit={(name) => commitRename(s.id, name)}
|
||||
onRenameCancel={() => setRenamingId(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!node.name) {
|
||||
return node.children.map((c) => renderNode(c, depth));
|
||||
}
|
||||
return (
|
||||
<FolderNode
|
||||
key={node.path}
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
depth={depth}
|
||||
collapsed={collapsed.has(node.path)}
|
||||
highlighted={navKey === `folder:${node.path}`}
|
||||
onToggle={() => toggleFolder(node.path)}
|
||||
>
|
||||
{node.children.map((c) => renderNode(c, depth + 1))}
|
||||
</FolderNode>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sidebar flex h-full min-h-0 flex-col bg-surface">
|
||||
<SidebarSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onDismiss={() => treeRef.current?.focus()}
|
||||
/>
|
||||
<div
|
||||
ref={treeRef}
|
||||
className="tree min-h-0 flex-1 overflow-y-auto outline-none"
|
||||
tabIndex={-1}
|
||||
role="tree"
|
||||
aria-label="Sessions"
|
||||
onKeyDown={onKeyDown}
|
||||
// The keyboard walk's preview belongs to the list holding the
|
||||
// keyboard: once it doesn't, the pane goes back to what was opened.
|
||||
onBlur={() => preview?.(null)}
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState onSpawn={() => props.onSpawn?.()} />
|
||||
) : (
|
||||
renderNode(tree, 0)
|
||||
)}
|
||||
</div>
|
||||
{menu && (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
items={menuItems(menu.session)}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{kill && (
|
||||
<KillConfirm
|
||||
name={sessionName(kill.session)}
|
||||
blockers={kill.blockers}
|
||||
force={kill.force}
|
||||
onCancel={() => setKill(null)}
|
||||
onConfirm={() => {
|
||||
const { session: s, force } = kill;
|
||||
setKill(null);
|
||||
void killSession(s.id, force).catch((e: unknown) => {
|
||||
// The daemon refuses an unforced kill over a worktree holding
|
||||
// unsaved work; that refusal needs its own prompt
|
||||
// (docs/WORKTREES.md).
|
||||
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||
setKill({
|
||||
session: s,
|
||||
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tree's one empty state: no sessions at all. */
|
||||
function EmptyState({ onSpawn }: { onSpawn(): void }) {
|
||||
return (
|
||||
<div className="empty flex flex-col items-center gap-sp-2 p-sp-6 text-center">
|
||||
<div aria-hidden="true" className="text-fs-20 text-faint">
|
||||
+
|
||||
</div>
|
||||
<div className="text-fs-13 text-ink">No sessions yet</div>
|
||||
<p className="text-fs-12 text-muted">Start your first session — it'll show up here.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-r-1 bg-accent px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
|
||||
onClick={onSpawn}
|
||||
>
|
||||
+ Spawn session
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sessions from the store when one is wired, else the static prop. */
|
||||
function useSessions(props: SidebarProps): Session[] {
|
||||
const fromStore = useStore(
|
||||
// `useStore` needs a store every render; the no-store case is served by a
|
||||
// stable empty stand-in whose result is thrown away below.
|
||||
props.store ?? EMPTY_STORE,
|
||||
(s) => (s as { sessions: Map<string, Session> }).sessions,
|
||||
);
|
||||
return useMemo(
|
||||
() => (props.store ? [...fromStore.values()] : (props.sessions ?? [])),
|
||||
[props.store, props.sessions, fromStore],
|
||||
);
|
||||
}
|
||||
|
||||
// One frozen state object, not a fresh one per call: `useStore` reads the
|
||||
// snapshot on every render and a new Map each time would never settle.
|
||||
const EMPTY_STATE = { sessions: new Map<string, Session>() };
|
||||
const EMPTY_STORE = {
|
||||
getState: () => EMPTY_STATE,
|
||||
getInitialState: () => EMPTY_STATE,
|
||||
setState: () => {},
|
||||
subscribe: () => () => {},
|
||||
} as unknown as CatalogStoreApi;
|
||||
@@ -0,0 +1,34 @@
|
||||
// The sidebar's search field: a substring filter over the tree (name, command
|
||||
// and cwd). Escape clears it and hands the keyboard back to the list.
|
||||
|
||||
export interface SidebarSearchProps {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
/** Escape on an already-empty field — the list wants the keyboard back. */
|
||||
onDismiss?(): void;
|
||||
}
|
||||
|
||||
export function SidebarSearch({ value, onChange, onDismiss }: SidebarSearchProps) {
|
||||
return (
|
||||
<header className="sidebar-search p-sp-2">
|
||||
<input
|
||||
type="search"
|
||||
className="search w-full rounded-r-1 border border-border bg-surface-2 px-sp-2 py-sp-1 text-fs-12 text-ink"
|
||||
placeholder="Search sessions…"
|
||||
aria-label="Search sessions"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
if (value) onChange("");
|
||||
else onDismiss?.();
|
||||
}}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Sidebar, type SidebarProps } from "./Sidebar.js";
|
||||
export { SessionCard, type SessionCardProps } from "./SessionCard.js";
|
||||
export { FolderNode, type FolderNodeProps } from "./FolderNode.js";
|
||||
export { SidebarSearch, type SidebarSearchProps } from "./SidebarSearch.js";
|
||||
export { ActivityRail } from "./ActivityRail.js";
|
||||
export { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
|
||||
export { KillConfirm } from "./KillConfirm.js";
|
||||
export * from "./model.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildTree } from "../../tree.js";
|
||||
import type { Session } from "../../types.js";
|
||||
import { filterSessions, firstRowUnder, navRows, splitFolderLabel } from "./model.js";
|
||||
|
||||
function session(id: string, cwd: string, command = "claude"): Session {
|
||||
return {
|
||||
id,
|
||||
cwd,
|
||||
command,
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("navRows", () => {
|
||||
const tree = buildTree([session("a", "/one"), session("b", "/two")], undefined);
|
||||
|
||||
it("stops on every card and skips expanded folder headers", () => {
|
||||
expect(navRows(tree, new Set()).map((r) => r.key)).toEqual(["session:a", "session:b"]);
|
||||
});
|
||||
|
||||
it("stops on a collapsed folder header instead of the cards it hides", () => {
|
||||
expect(navRows(tree, new Set(["one"])).map((r) => r.key)).toEqual([
|
||||
"folder:one",
|
||||
"session:b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the first row an expanded folder reveals", () => {
|
||||
expect(firstRowUnder(tree, new Set(), "one")?.key).toBe("session:a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitFolderLabel", () => {
|
||||
it("keeps a shallow path whole", () => {
|
||||
expect(splitFolderLabel("~/maestro")).toEqual({ stem: null, last: "~/maestro" });
|
||||
});
|
||||
|
||||
it("initials the leading segments of a deep path", () => {
|
||||
expect(splitFolderLabel("~/git.naps.pt/naps62/maestro")).toEqual({
|
||||
stem: "~/g/n",
|
||||
last: "maestro",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterSessions", () => {
|
||||
const all = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
|
||||
|
||||
it("returns everything for a blank query", () => {
|
||||
expect(filterSessions(all, " ")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("matches the cwd and the command, case-insensitively", () => {
|
||||
expect(filterSessions(all, "ALPHA").map((s) => s.id)).toEqual(["a"]);
|
||||
expect(filterSessions(all, "bash").map((s) => s.id)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Pure model helpers behind the React sidebar. Kept out of the components so
|
||||
// the walk order and the folder label can be tested without a DOM.
|
||||
|
||||
import type { Session } from "../../types.js";
|
||||
import { sessionCwd, sessionName } from "../../types.js";
|
||||
import type { TreeNode } from "../../tree.js";
|
||||
|
||||
/** A row the keyboard walk can land on (docs/SIDEBAR.md "Keyboard walk"):
|
||||
* every session card, and a folder header only while it is collapsed. */
|
||||
export type NavRow =
|
||||
| { key: string; kind: "folder"; path: string }
|
||||
| { key: string; kind: "session"; id: string };
|
||||
|
||||
export function folderKey(path: string): string {
|
||||
return `folder:${path}`;
|
||||
}
|
||||
|
||||
export function sessionKey(id: string): string {
|
||||
return `session:${id}`;
|
||||
}
|
||||
|
||||
/** The walkable rows in visual order. An expanded folder contributes nothing
|
||||
* itself — its cards are one row away — while a collapsed one is the only row
|
||||
* it has, so skipping it would leave the folder unreachable. */
|
||||
export function navRows(root: TreeNode, collapsed: ReadonlySet<string>): NavRow[] {
|
||||
const out: NavRow[] = [];
|
||||
const walk = (node: TreeNode): void => {
|
||||
if (node.kind === "leaf") {
|
||||
out.push({ key: sessionKey(node.session.id), kind: "session", id: node.session.id });
|
||||
return;
|
||||
}
|
||||
if (node.name && collapsed.has(node.path)) {
|
||||
out.push({ key: folderKey(node.path), kind: "folder", path: node.path });
|
||||
return;
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
for (const child of root.kind === "folder" ? root.children : [root]) walk(child);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The first walkable row inside `path`, i.e. where the highlight lands when
|
||||
* Enter expands that folder. */
|
||||
export function firstRowUnder(
|
||||
root: TreeNode,
|
||||
collapsed: ReadonlySet<string>,
|
||||
path: string,
|
||||
): NavRow | undefined {
|
||||
const found = findFolder(root, path);
|
||||
if (!found) return undefined;
|
||||
return navRows({ ...found, name: "" }, collapsed)[0];
|
||||
}
|
||||
|
||||
function findFolder(node: TreeNode, path: string): Extract<TreeNode, { kind: "folder" }> | null {
|
||||
if (node.kind === "leaf") return null;
|
||||
if (node.path === path && node.name) return node;
|
||||
for (const child of node.children) {
|
||||
const hit = findFolder(child, path);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Structured form of `abbreviateFolderName`: a deep path shows its leading
|
||||
* segments as initials so the tail stays readable in a narrow sidebar. */
|
||||
export function splitFolderLabel(name: string): { stem: string | null; last: string } {
|
||||
const parts = name.split("/");
|
||||
if (parts.length <= 2) return { stem: null, last: name };
|
||||
const last = parts[parts.length - 1];
|
||||
const stem = parts
|
||||
.slice(0, -1)
|
||||
.map((p) => (p === "~" ? "~" : (Array.from(p)[0] ?? "")))
|
||||
.join("/");
|
||||
return { stem, last };
|
||||
}
|
||||
|
||||
/** Case-insensitive substring filter over a session's name, command and cwd —
|
||||
* what the sidebar's own search field narrows the tree with. */
|
||||
export function filterSessions(sessions: Session[], query: string): Session[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return sessions;
|
||||
return sessions.filter((s) => {
|
||||
const cwd = sessionCwd(s) ?? "";
|
||||
return (
|
||||
sessionName(s).toLowerCase().includes(q) ||
|
||||
s.command.toLowerCase().includes(q) ||
|
||||
cwd.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Motion for the activity rail (docs/SIDEBAR.md): a hard blink while
|
||||
AwaitingInput, a fast flash while Error. Active and Idle are solid and
|
||||
still — colour alone is the signal. Kept as real CSS rather than Tailwind
|
||||
utilities because both need @keyframes of their own. */
|
||||
|
||||
@keyframes maestro-rail-blink {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
50.01%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes maestro-rail-flash {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
45% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rail-blink {
|
||||
animation: maestro-rail-blink 1.2s steps(1) infinite;
|
||||
}
|
||||
|
||||
.rail-flash {
|
||||
animation: maestro-rail-flash 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rail-blink,
|
||||
.rail-flash {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** A clock that ticks at least every `intervalMs` while the page is visible.
|
||||
* docs/SIDEBAR.md asks for a re-render at least every 30s so the duration's
|
||||
* cutover points land within one tick. */
|
||||
export function useNow(intervalMs = 30_000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
setNow(Date.now());
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { PALETTES, ThemeProvider, useTheme } from "./ThemeContext";
|
||||
|
||||
// happy-dom's vitest environment ships neither localStorage nor matchMedia;
|
||||
// back both with plain in-memory fakes so persistence and system-theme
|
||||
// reactivity are observable (mirrors ../theme.test.ts's localStorage fake).
|
||||
let store = new Map<string, string>();
|
||||
let systemPrefersLight = false;
|
||||
let mediaListeners: Array<(e: MediaQueryListEvent) => void> = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
value: (query: string) => ({
|
||||
matches: query.includes("light") ? systemPrefersLight : false,
|
||||
media: query,
|
||||
addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
|
||||
mediaListeners.push(cb);
|
||||
},
|
||||
removeEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
|
||||
mediaListeners = mediaListeners.filter((l) => l !== cb);
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
function fireSystemThemeChange(matches: boolean): void {
|
||||
systemPrefersLight = matches;
|
||||
for (const cb of mediaListeners) {
|
||||
cb({ matches } as MediaQueryListEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
const { theme, palette, setTheme, toggleTheme, setPalette } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="palette">{palette}</span>
|
||||
<button onClick={() => setTheme("light")}>pin-light</button>
|
||||
<button onClick={toggleTheme}>toggle</button>
|
||||
<button onClick={() => setPalette("ember")}>set-ember</button>
|
||||
<button onClick={() => setPalette("graphite")}>set-graphite</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ThemeProvider / useTheme", () => {
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
mediaListeners = [];
|
||||
systemPrefersLight = false;
|
||||
delete document.documentElement.dataset.theme;
|
||||
delete document.documentElement.dataset.palette;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("defaults to graphite and the system theme, no data-palette attribute", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("theme").textContent).toBe("dark");
|
||||
expect(screen.getByTestId("palette").textContent).toBe("graphite");
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows a system theme change while unpinned", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
act(() => fireSystemThemeChange(true));
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
});
|
||||
|
||||
it("setTheme pins, persists, and stops following the system", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
act(() => screen.getByText("pin-light").click());
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
expect(localStorage.getItem("maestro-theme")).toBe("light");
|
||||
|
||||
act(() => fireSystemThemeChange(true));
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
});
|
||||
|
||||
it("toggleTheme flips the resolved theme", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
act(() => screen.getByText("toggle").click());
|
||||
expect(screen.getByTestId("theme").textContent).toBe("light");
|
||||
act(() => screen.getByText("toggle").click());
|
||||
expect(screen.getByTestId("theme").textContent).toBe("dark");
|
||||
});
|
||||
|
||||
it("setPalette stamps data-palette and persists; graphite clears it", () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
act(() => screen.getByText("set-ember").click());
|
||||
expect(document.documentElement.dataset.palette).toBe("ember");
|
||||
expect(localStorage.getItem("maestro-palette")).toBe("ember");
|
||||
|
||||
act(() => screen.getByText("set-graphite").click());
|
||||
expect(document.documentElement.dataset.palette).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a fresh mount re-applies a stored palette", () => {
|
||||
localStorage.setItem("maestro-palette", "periwinkle");
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Probe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("palette").textContent).toBe("periwinkle");
|
||||
expect(document.documentElement.dataset.palette).toBe("periwinkle");
|
||||
});
|
||||
|
||||
it("exposes every palette in picker order", () => {
|
||||
expect(PALETTES).toEqual([
|
||||
"graphite",
|
||||
"ice-cyan",
|
||||
"periwinkle",
|
||||
"amber-phosphor",
|
||||
"ember",
|
||||
"orchid",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when used outside a provider", () => {
|
||||
const Bare = () => {
|
||||
useTheme();
|
||||
return null;
|
||||
};
|
||||
expect(() => render(<Bare />)).toThrow(
|
||||
"useTheme must be used within a ThemeProvider",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// React port of ../theme.ts. Same storage keys and `data-theme`/`data-palette`
|
||||
// attributes on <html>, so this can sit alongside the vanilla shell (which
|
||||
// still owns theme.ts) without either one fighting the other over state.
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const THEME_KEY = "maestro-theme";
|
||||
|
||||
/** Every `data-palette` value, in picker order. Graphite is the default. */
|
||||
export const PALETTES = [
|
||||
"graphite",
|
||||
"ice-cyan",
|
||||
"periwinkle",
|
||||
"amber-phosphor",
|
||||
"ember",
|
||||
"orchid",
|
||||
] as const;
|
||||
export type Palette = (typeof PALETTES)[number];
|
||||
const PALETTE_KEY = "maestro-palette";
|
||||
|
||||
function storedTheme(): Theme | null {
|
||||
const v = localStorage.getItem(THEME_KEY);
|
||||
return v === "light" || v === "dark" ? v : null;
|
||||
}
|
||||
|
||||
function storedPalette(): Palette | null {
|
||||
const v = localStorage.getItem(PALETTE_KEY);
|
||||
return (PALETTES as readonly string[]).includes(v ?? "")
|
||||
? (v as Palette)
|
||||
: null;
|
||||
}
|
||||
|
||||
function systemPrefersLight(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
}
|
||||
|
||||
// Dev-only `?theme=` override for visual review, same as theme.ts's — never
|
||||
// persisted, just seeds the initial pin for this session.
|
||||
function devThemeOverride(): Theme | null {
|
||||
if (!import.meta.env.DEV) return null;
|
||||
const q = new URLSearchParams(location.search).get("theme");
|
||||
return q === "light" || q === "dark" ? q : null;
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
palette: Palette;
|
||||
setTheme: (t: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
setPalette: (p: Palette) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [pinnedTheme, setPinnedTheme] = useState<Theme | null>(
|
||||
() => devThemeOverride() ?? storedTheme(),
|
||||
);
|
||||
const [systemIsLight, setSystemIsLight] = useState(systemPrefersLight);
|
||||
const [palette, setPaletteState] = useState<Palette>(
|
||||
() => storedPalette() ?? "graphite",
|
||||
);
|
||||
|
||||
const theme: Theme = pinnedTheme ?? (systemIsLight ? "light" : "dark");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mql = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const onChange = (e: MediaQueryListEvent) => setSystemIsLight(e.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
// Pinning is one-way (no UI un-pins back to "follow system"), so this only
|
||||
// ever needs to set the attribute — never clear it — same as theme.ts.
|
||||
useLayoutEffect(() => {
|
||||
if (pinnedTheme) document.documentElement.dataset.theme = pinnedTheme;
|
||||
}, [pinnedTheme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (palette === "graphite") delete document.documentElement.dataset.palette;
|
||||
else document.documentElement.dataset.palette = palette;
|
||||
}, [palette]);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
setPinnedTheme(t);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setTheme]);
|
||||
|
||||
const setPalette = useCallback((p: Palette) => {
|
||||
localStorage.setItem(PALETTE_KEY, p);
|
||||
setPaletteState(p);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{ theme, palette, setTheme, toggleTheme, setPalette }}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./main.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("missing #root element");
|
||||
}
|
||||
|
||||
createRoot(container).render(<App />);
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createCatalogStore,
|
||||
INITIAL_BACKOFF_MS,
|
||||
MAX_BACKOFF_MS,
|
||||
type CatalogStore,
|
||||
type Scheduler,
|
||||
} from "./catalogStore.js";
|
||||
import type { CatalogEvent, Session } from "../types.js";
|
||||
|
||||
function session(id: string): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/n",
|
||||
command: "bash",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-22T00:00:00Z",
|
||||
} as Session;
|
||||
}
|
||||
|
||||
/** Just enough of a WebSocket for the store to drive, plus a way to push frames. */
|
||||
class FakeSocket {
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
emit(event: CatalogEvent): void {
|
||||
this.onmessage?.({ data: JSON.stringify(event) });
|
||||
}
|
||||
|
||||
drop(): void {
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduler that records delays instead of waiting them out. */
|
||||
function fakeScheduler(): Scheduler & { delays: number[]; runNext(): void } {
|
||||
const queued: Array<() => void> = [];
|
||||
return {
|
||||
delays: [] as number[],
|
||||
setTimer(fn, ms) {
|
||||
this.delays.push(ms);
|
||||
queued.push(fn);
|
||||
return queued.length;
|
||||
},
|
||||
clearTimer() {},
|
||||
runNext() {
|
||||
queued.shift()?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sockets: FakeSocket[], refetch?: () => Promise<Session[]>) {
|
||||
let i = 0;
|
||||
const scheduler = fakeScheduler();
|
||||
const store = createCatalogStore({
|
||||
socket: () => sockets[i++] as unknown as WebSocket,
|
||||
scheduler,
|
||||
refetch,
|
||||
});
|
||||
return { store, scheduler };
|
||||
}
|
||||
|
||||
describe("catalogStore reducer", () => {
|
||||
it("replaces state on Snapshot and applies deltas", () => {
|
||||
const ws = new FakeSocket();
|
||||
const { store } = storeWith([ws]);
|
||||
store.getState().start();
|
||||
|
||||
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
|
||||
expect([...store.getState().sessions.keys()]).toEqual(["a"]);
|
||||
|
||||
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
|
||||
expect([...store.getState().sessions.keys()].sort()).toEqual(["a", "b"]);
|
||||
|
||||
ws.emit({ type: "SessionRemoved", id: "a" } as CatalogEvent);
|
||||
expect([...store.getState().sessions.keys()]).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("ignores a frame that is not JSON", () => {
|
||||
const ws = new FakeSocket();
|
||||
const { store } = storeWith([ws]);
|
||||
store.getState().start();
|
||||
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
|
||||
|
||||
ws.onmessage?.({ data: "not json" });
|
||||
|
||||
expect([...store.getState().sessions.keys()]).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("refetches over REST when the server reports dropped frames", async () => {
|
||||
const ws = new FakeSocket();
|
||||
const refetch = vi.fn(async () => [session("fresh")]);
|
||||
const { store } = storeWith([ws], refetch);
|
||||
store.getState().start();
|
||||
ws.emit({ type: "Snapshot", sessions: [session("stale")] } as CatalogEvent);
|
||||
|
||||
ws.emit({ type: "Lagged", skipped: 4 } as CatalogEvent);
|
||||
await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
|
||||
|
||||
expect([...store.getState().sessions.keys()]).toEqual(["fresh"]);
|
||||
});
|
||||
|
||||
it("hands each subscriber a fresh sessions map it can diff against the previous one", () => {
|
||||
const ws = new FakeSocket();
|
||||
const { store } = storeWith([ws]);
|
||||
const seen: CatalogStore[] = [];
|
||||
store.subscribe((s) => seen.push(s));
|
||||
store.getState().start();
|
||||
|
||||
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
|
||||
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
|
||||
|
||||
const [afterSnapshot, afterAdd] = seen;
|
||||
expect(afterSnapshot.sessions).not.toBe(afterAdd.sessions);
|
||||
expect(afterSnapshot.sessions.size).toBe(1);
|
||||
expect(afterAdd.sessions.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalogStore reconnect", () => {
|
||||
it("replaces a suspended socket immediately", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const { store } = storeWith([first, second]);
|
||||
store.getState().start();
|
||||
|
||||
store.getState().reconnect();
|
||||
|
||||
expect(first.closed).toBe(true);
|
||||
second.open();
|
||||
// A late close from the old socket is deliberately ignored.
|
||||
first.drop();
|
||||
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS);
|
||||
});
|
||||
|
||||
it("doubles the delay per attempt and caps it", () => {
|
||||
const sockets = Array.from({ length: 8 }, () => new FakeSocket());
|
||||
const { store, scheduler } = storeWith(sockets);
|
||||
store.getState().start();
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
sockets[i].drop();
|
||||
scheduler.runNext();
|
||||
}
|
||||
|
||||
expect(scheduler.delays[0]).toBe(INITIAL_BACKOFF_MS);
|
||||
expect(scheduler.delays[1]).toBe(INITIAL_BACKOFF_MS * 2);
|
||||
expect(scheduler.delays.at(-1)).toBe(MAX_BACKOFF_MS);
|
||||
});
|
||||
|
||||
it("resets the delay once a connection opens", () => {
|
||||
const sockets = [new FakeSocket(), new FakeSocket(), new FakeSocket()];
|
||||
const { store, scheduler } = storeWith(sockets);
|
||||
store.getState().start();
|
||||
|
||||
sockets[0].drop();
|
||||
scheduler.runNext();
|
||||
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS * 2);
|
||||
|
||||
sockets[1].open();
|
||||
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS);
|
||||
});
|
||||
|
||||
it("stop() tears the socket down without scheduling a reconnect", () => {
|
||||
const ws = new FakeSocket();
|
||||
const { store, scheduler } = storeWith([ws]);
|
||||
store.getState().start();
|
||||
|
||||
store.getState().stop();
|
||||
ws.drop();
|
||||
|
||||
expect(ws.closed).toBe(true);
|
||||
expect(scheduler.delays).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalogStore connected state", () => {
|
||||
it("reports the socket opening and dropping", () => {
|
||||
const sock = new FakeSocket();
|
||||
const store = createCatalogStore({
|
||||
socket: () => sock as unknown as WebSocket,
|
||||
scheduler: fakeScheduler(),
|
||||
});
|
||||
const seen: boolean[] = [];
|
||||
store.subscribe((s) => seen.push(s.connected));
|
||||
store.getState().start();
|
||||
sock.open();
|
||||
sock.drop();
|
||||
|
||||
expect(seen).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { eventsWsUrl, listSessions } from "../api.js";
|
||||
import type { CatalogEvent, Session, Uuid } from "../types.js";
|
||||
|
||||
export interface CatalogState {
|
||||
sessions: Map<Uuid, Session>;
|
||||
/** The events socket's up/down state. Drives the daemon link (daemonLink.ts)
|
||||
* — the socket dying is the first sign the daemon went down or was
|
||||
* upgraded under the page. */
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
/** Opens the events socket. Injectable so the reducer and the backoff schedule
|
||||
* can be driven without a server. */
|
||||
export type SocketFactory = () => WebSocket;
|
||||
|
||||
/** Schedules a reconnect attempt; returns a handle `clearTimer` understands. */
|
||||
export interface Scheduler {
|
||||
setTimer(fn: () => void, ms: number): number;
|
||||
clearTimer(handle: number): void;
|
||||
}
|
||||
|
||||
const browserScheduler: Scheduler = {
|
||||
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
||||
clearTimer: (h) => window.clearTimeout(h),
|
||||
};
|
||||
|
||||
/** Initial reconnect delay, doubling to `MAX_BACKOFF_MS`. */
|
||||
export const INITIAL_BACKOFF_MS = 500;
|
||||
export const MAX_BACKOFF_MS = 10_000;
|
||||
|
||||
export interface CatalogStoreOptions {
|
||||
socket?: SocketFactory;
|
||||
scheduler?: Scheduler;
|
||||
/** Full-state refetch used when the server reports it dropped frames. */
|
||||
refetch?: () => Promise<Session[]>;
|
||||
}
|
||||
|
||||
export interface CatalogActions {
|
||||
start(): void;
|
||||
/** Replace a socket that may have gone stale while the browser was suspended. */
|
||||
reconnect(): void;
|
||||
stop(): void;
|
||||
/** Current reconnect delay, for tests asserting the backoff schedule. */
|
||||
backoff(): number;
|
||||
}
|
||||
|
||||
export type CatalogStore = CatalogState & CatalogActions;
|
||||
|
||||
/** Builds an isolated catalog store — a socket/scheduler/refetch can be
|
||||
* injected (tests), or defaulted to the real browser/daemon ones. The
|
||||
* returned value is both a React hook (`useCatalog(selector)`) and a plain
|
||||
* object (`useCatalog.getState()`, `useCatalog.subscribe(fn)`), so the
|
||||
* reconnect/backoff machinery works the same whether or not React is
|
||||
* driving it. */
|
||||
export function createCatalogStore(opts: CatalogStoreOptions = {}) {
|
||||
const socketFactory = opts.socket ?? (() => new WebSocket(eventsWsUrl()));
|
||||
const scheduler = opts.scheduler ?? browserScheduler;
|
||||
const refetch = opts.refetch ?? listSessions;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
let backoffMs = INITIAL_BACKOFF_MS;
|
||||
|
||||
return create<CatalogStore>((set, get) => {
|
||||
function apply(event: CatalogEvent): void {
|
||||
switch (event.type) {
|
||||
case "Snapshot":
|
||||
set({ sessions: new Map(event.sessions.map((s) => [s.id, s])) });
|
||||
return;
|
||||
case "SessionAdded":
|
||||
case "SessionUpdated": {
|
||||
const next = new Map(get().sessions);
|
||||
next.set(event.session.id, event.session);
|
||||
set({ sessions: next });
|
||||
return;
|
||||
}
|
||||
case "SessionRemoved": {
|
||||
const next = new Map(get().sessions);
|
||||
next.delete(event.id);
|
||||
set({ sessions: next });
|
||||
return;
|
||||
}
|
||||
case "Lagged":
|
||||
// Re-derive from REST to avoid showing stale state.
|
||||
void resync();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function resync(): Promise<void> {
|
||||
try {
|
||||
const sessions = await refetch();
|
||||
set({ sessions: new Map(sessions.map((s) => [s.id, s])) });
|
||||
} catch {
|
||||
// surface in UI on next reconnect cycle; nothing else useful to do
|
||||
}
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
const socket = socketFactory();
|
||||
ws = socket;
|
||||
|
||||
socket.onmessage = (ev) => {
|
||||
let event: CatalogEvent;
|
||||
try {
|
||||
event = JSON.parse(ev.data) as CatalogEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
apply(event);
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
backoffMs = INITIAL_BACKOFF_MS;
|
||||
set({ connected: true });
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
ws = null;
|
||||
set({ connected: false });
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer !== null) return;
|
||||
const delay = Math.min(backoffMs, MAX_BACKOFF_MS);
|
||||
reconnectTimer = scheduler.setTimer(() => {
|
||||
reconnectTimer = null;
|
||||
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: new Map(),
|
||||
connected: false,
|
||||
|
||||
start() {
|
||||
connect();
|
||||
},
|
||||
|
||||
reconnect() {
|
||||
if (reconnectTimer !== null) {
|
||||
scheduler.clearTimer(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
// A close event from the superseded socket must not schedule a
|
||||
// second connection alongside the new one.
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
connect();
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (reconnectTimer !== null) {
|
||||
scheduler.clearTimer(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
},
|
||||
|
||||
backoff() {
|
||||
return backoffMs;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type CatalogStoreApi = ReturnType<typeof createCatalogStore>;
|
||||
@@ -0,0 +1,414 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createConnectionStore } from "./connectionStore.js";
|
||||
import { createCatalogStore } from "./catalogStore.js";
|
||||
import type { Session } from "../types.js";
|
||||
|
||||
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
|
||||
// the pool reaches the focused connection with them.
|
||||
const sent: { id: string; data: string }[] = [];
|
||||
const disposed: string[] = [];
|
||||
const mounts: { id: string; focus: boolean | undefined }[] = [];
|
||||
function fakeConn(kind: string, id: string) {
|
||||
const el = document.createElement("div");
|
||||
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
|
||||
el.dataset.kind = kind;
|
||||
el.dataset.session = id;
|
||||
return {
|
||||
sessionId: id,
|
||||
kind,
|
||||
el,
|
||||
onMount(opts?: { focus?: boolean }) {
|
||||
mounts.push({ id, focus: opts?.focus });
|
||||
},
|
||||
onUnmount() {},
|
||||
focusTerminal() {},
|
||||
sendInput(data: string) {
|
||||
sent.push({ id, data });
|
||||
},
|
||||
updateInfo() {},
|
||||
dispose() {
|
||||
disposed.push(`${kind}:${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
vi.mock("../attach.js", () => ({
|
||||
createAttachConnection: (id: string) => fakeConn("attach", id),
|
||||
}));
|
||||
vi.mock("../structured.js", () => ({
|
||||
createStructuredConnection: (id: string) => fakeConn("structured", id),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
sent.length = 0;
|
||||
disposed.length = 0;
|
||||
mounts.length = 0;
|
||||
});
|
||||
|
||||
/** Minimal fixture for the sessions map a catalog store broadcasts. */
|
||||
function session(id: string, view?: "Terminal" | "Structured"): Session {
|
||||
return {
|
||||
id,
|
||||
cwd: "/repo",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
view,
|
||||
};
|
||||
}
|
||||
|
||||
/** A catalog store an id can be pushed into on demand, mirroring
|
||||
* catalogStore.test.ts's own injectable-socket pattern. */
|
||||
class FakeCatalogSocket {
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
close(): void {}
|
||||
push(sessions: Session[]): void {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
|
||||
}
|
||||
}
|
||||
function fakeCatalog() {
|
||||
const socket = new FakeCatalogSocket();
|
||||
const store = createCatalogStore({ socket: () => socket as unknown as WebSocket });
|
||||
store.getState().start();
|
||||
return { store, socket };
|
||||
}
|
||||
|
||||
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
|
||||
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
|
||||
|
||||
function mockFetch() {
|
||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe("connectionStore setFocus", () => {
|
||||
it("reports the new session focused, none unfocused, the first time", () => {
|
||||
const fetchMock = mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
|
||||
pool.getState().setFocus(A);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(`/api/sessions/${A}/focus`);
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ focused: true });
|
||||
expect(pool.getState().focusedId).toBe(A);
|
||||
});
|
||||
|
||||
it("unfocuses the previous session and focuses the new one on a switch", () => {
|
||||
const fetchMock = mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
pool.getState().setFocus(A);
|
||||
fetchMock.mockClear();
|
||||
|
||||
pool.getState().setFocus(B);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const bodies = fetchMock.mock.calls.map(([url, init]: unknown[]) => [
|
||||
url,
|
||||
JSON.parse((init as RequestInit).body as string),
|
||||
]);
|
||||
expect(bodies).toContainEqual([`/api/sessions/${A}/focus`, { focused: false }]);
|
||||
expect(bodies).toContainEqual([`/api/sessions/${B}/focus`, { focused: true }]);
|
||||
});
|
||||
|
||||
it("reports unfocused when focus clears to null", () => {
|
||||
const fetchMock = mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
pool.getState().setFocus(A);
|
||||
fetchMock.mockClear();
|
||||
|
||||
pool.getState().setFocus(null);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(`/api/sessions/${A}/focus`);
|
||||
expect(JSON.parse(init.body as string)).toEqual({ focused: false });
|
||||
});
|
||||
|
||||
it("is a no-op when the focused session doesn't change", () => {
|
||||
const fetchMock = mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
pool.getState().setFocus(A);
|
||||
fetchMock.mockClear();
|
||||
|
||||
pool.getState().setFocus(A);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let a fetch rejection break the pool", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new Error("offline"))),
|
||||
);
|
||||
const pool = createConnectionStore();
|
||||
|
||||
expect(() => pool.getState().setFocus(A)).not.toThrow();
|
||||
// Let the rejected promise's `.catch` settle before the test ends.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectionStore sendToFocused", () => {
|
||||
it("types the bytes into the focused session's PTY", () => {
|
||||
mockFetch();
|
||||
sent.length = 0;
|
||||
const pool = createConnectionStore();
|
||||
const pane = document.createElement("div");
|
||||
pool.getState().mount(A, pane);
|
||||
pool.getState().setFocus(A);
|
||||
|
||||
expect(pool.getState().sendToFocused("\x0b")).toBe(true);
|
||||
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
|
||||
});
|
||||
|
||||
it("is a no-op with nothing focused", () => {
|
||||
mockFetch();
|
||||
sent.length = 0;
|
||||
const pool = createConnectionStore();
|
||||
|
||||
expect(pool.getState().sendToFocused("\x0b")).toBe(false);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectionStore mount/unmount", () => {
|
||||
it("tracks mounted and LRU order reactively", () => {
|
||||
mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
const paneA = document.createElement("div");
|
||||
const paneB = document.createElement("div");
|
||||
|
||||
pool.getState().mount(A, paneA);
|
||||
pool.getState().mount(B, paneB);
|
||||
expect(pool.getState().cacheOrder).toEqual([A, B]);
|
||||
expect([...pool.getState().mounted]).toEqual([A, B]);
|
||||
|
||||
pool.getState().unmount(A);
|
||||
expect([...pool.getState().mounted]).toEqual([B]);
|
||||
// Parked, not evicted — still in LRU order.
|
||||
expect(pool.getState().cacheOrder).toEqual([A, B]);
|
||||
|
||||
pool.getState().close(A);
|
||||
expect(pool.getState().cacheOrder).toEqual([B]);
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
});
|
||||
|
||||
it("evicts the least-recently-viewed parked connection beyond MAX_PARKED", () => {
|
||||
mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
const ids = Array.from({ length: 8 }, (_, i) => `id-${i}`);
|
||||
for (const id of ids) {
|
||||
pool.getState().mount(id, document.createElement("div"));
|
||||
pool.getState().unmount(id); // park immediately
|
||||
}
|
||||
|
||||
// Each mount only has itself visible (the rest already parked), so the
|
||||
// cap-crossing eviction fires once, on the mount that pushes the parked
|
||||
// count from 6 to 7: the single oldest (id-0) is dropped.
|
||||
expect(disposed).toEqual(["attach:id-0"]);
|
||||
expect(pool.getState().cacheOrder).toEqual(ids.slice(1));
|
||||
});
|
||||
});
|
||||
|
||||
// A deep link or restored layout can `mount()` a session before the catalog
|
||||
// snapshot names its view — the daemon defaults an absent `view` to
|
||||
// `"Terminal"`, but so does an *unknown* session, and only one of those is
|
||||
// actually resolved (#140 review).
|
||||
describe("connectionStore: view resolution races", () => {
|
||||
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeCatalog();
|
||||
const pool = createConnectionStore({ store });
|
||||
const pane = document.createElement("div");
|
||||
|
||||
pool.getState().mount(A, pane); // no info: deep-link-before-snapshot race
|
||||
expect(disposed).toEqual([]);
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
// The fresh connection landed in the same pane nothing re-mounted it into.
|
||||
expect(pane.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeCatalog();
|
||||
const pool = createConnectionStore({ store });
|
||||
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
|
||||
pool.getState().mount(A, document.createElement("div"));
|
||||
pool.getState().mount(B, document.createElement("div"));
|
||||
pool.getState().unmount(A);
|
||||
pool.getState().unmount(B);
|
||||
disposed.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
// B's own element is still parked — A's repair didn't wipe its siblings
|
||||
// out of the shared hidden cache.
|
||||
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
|
||||
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
|
||||
});
|
||||
|
||||
it("does not replace a connection whose resolved view already matches", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeCatalog();
|
||||
const pool = createConnectionStore({ store });
|
||||
const pane = document.createElement("div");
|
||||
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||
disposed.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
|
||||
mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
const pane = document.createElement("div");
|
||||
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||
disposed.length = 0;
|
||||
|
||||
pool.getState().mount(A, pane); // e.g. a re-render before the catalog answers again
|
||||
|
||||
expect(disposed).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeCatalog();
|
||||
const pool = createConnectionStore({ store });
|
||||
const pane = document.createElement("div");
|
||||
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
||||
disposed.length = 0;
|
||||
|
||||
// `A` is absent from this snapshot — the embedder's own subscription
|
||||
// handles closing the pane; the pool must not race it by "fixing" the
|
||||
// view first.
|
||||
socket.push([session(B, "Terminal")]);
|
||||
|
||||
expect(disposed).toEqual([]);
|
||||
});
|
||||
|
||||
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeCatalog();
|
||||
const pool = createConnectionStore({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.getState().mount(A, pane);
|
||||
pool.getState().setFocus(A);
|
||||
(pane.firstElementChild as HTMLElement).focus();
|
||||
expect(document.activeElement).toBe(pane.firstElementChild);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([{ id: A, focus: true }]);
|
||||
pane.remove();
|
||||
});
|
||||
|
||||
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeCatalog();
|
||||
const pool = createConnectionStore({ store });
|
||||
const pane = document.createElement("div");
|
||||
document.body.appendChild(pane);
|
||||
pool.getState().mount(A, pane);
|
||||
pool.getState().setFocus(A);
|
||||
(pane.firstElementChild as HTMLElement).focus();
|
||||
|
||||
const sidebarInput = document.createElement("input");
|
||||
document.body.appendChild(sidebarInput);
|
||||
sidebarInput.focus();
|
||||
expect(document.activeElement).toBe(sidebarInput);
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([{ id: A, focus: false }]);
|
||||
pane.remove();
|
||||
sidebarInput.remove();
|
||||
});
|
||||
|
||||
it("does not focus a parked (unmounted) connection it repairs", () => {
|
||||
mockFetch();
|
||||
const { store, socket } = fakeCatalog();
|
||||
const pool = createConnectionStore({ store });
|
||||
const pane = document.createElement("div");
|
||||
pool.getState().mount(A, pane);
|
||||
pool.getState().setFocus(A);
|
||||
pool.getState().unmount(A); // parked — no longer visible, `focusedId` still names it
|
||||
mounts.length = 0;
|
||||
|
||||
socket.push([session(A, "Structured")]);
|
||||
|
||||
expect(disposed).toEqual(["attach:" + A]);
|
||||
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectionStore setVisible/activate/parkActive", () => {
|
||||
it("parks sessions not in the next visible set and mounts the rest", () => {
|
||||
mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
const paneA = document.createElement("div");
|
||||
const paneB = document.createElement("div");
|
||||
pool.getState().setVisible([
|
||||
{ id: A, pane: paneA },
|
||||
{ id: B, pane: paneB },
|
||||
]);
|
||||
expect([...pool.getState().mounted].sort()).toEqual([A, B]);
|
||||
|
||||
pool.getState().setVisible([{ id: B, pane: paneB }]);
|
||||
|
||||
expect([...pool.getState().mounted]).toEqual([B]);
|
||||
});
|
||||
|
||||
it("activate mounts+focuses alone, parkActive parks all and clears focus", () => {
|
||||
mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
const pane = document.createElement("div");
|
||||
|
||||
pool.getState().activate(A, pane);
|
||||
expect(pool.getState().focusedId).toBe(A);
|
||||
expect(pool.getState().isMounted(A)).toBe(true);
|
||||
|
||||
pool.getState().parkActive();
|
||||
expect(pool.getState().focusedId).toBeNull();
|
||||
expect(pool.getState().isMounted(A)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectionStore dispose", () => {
|
||||
it("disposes every cached connection and resets state", () => {
|
||||
mockFetch();
|
||||
const pool = createConnectionStore();
|
||||
pool.getState().mount(A, document.createElement("div"));
|
||||
pool.getState().mount(B, document.createElement("div"));
|
||||
disposed.length = 0;
|
||||
|
||||
pool.getState().dispose();
|
||||
|
||||
expect(disposed.sort()).toEqual(["attach:" + A, "attach:" + B]);
|
||||
expect(pool.getState().mounted.size).toBe(0);
|
||||
expect(pool.getState().cacheOrder).toEqual([]);
|
||||
expect(pool.getState().focusedId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { createAttachConnection } from "../attach.js";
|
||||
import type { AttachInfo } from "../paneTitle.js";
|
||||
import type { PaneConnection } from "../pane.js";
|
||||
import { createStructuredConnection } from "../structured.js";
|
||||
import { setSessionFocus } from "../api.js";
|
||||
import { sessionName, type Session, type SessionView, type Uuid } from "../types.js";
|
||||
import type { CatalogStoreApi } from "./catalogStore.js";
|
||||
|
||||
/**
|
||||
* How many *parked* (off-screen but still-connected) sessions to keep warm
|
||||
* beyond whatever is currently visible. Visible panes are never counted or
|
||||
* evicted — only this background buffer is bounded, so the browser doesn't hold
|
||||
* an unbounded fan of open sockets. Switching back to a recently-viewed session
|
||||
* is instant while it's still parked; older ones are evicted (WS closed) once
|
||||
* the buffer overflows.
|
||||
*/
|
||||
const MAX_PARKED = 6;
|
||||
|
||||
/** A session's view defaults to `"Terminal"` wherever it isn't carried —
|
||||
* matching the wire default (`SessionView`'s `#[default]`, types.ts). Only
|
||||
* meaningful once the session itself is known; an altogether-unknown
|
||||
* session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved
|
||||
* state callers must check for separately, not "Terminal". */
|
||||
function resolveView(view: SessionView | undefined): SessionView {
|
||||
return view ?? "Terminal";
|
||||
}
|
||||
|
||||
export interface PoolOptions {
|
||||
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
|
||||
onFocusChange?: (id: Uuid | null) => void;
|
||||
/** Fired when a view's explicit disconnect control is used; the embedder
|
||||
* decides how to update the layout/route (then typically calls `close`). */
|
||||
onRequestClose?: (id: Uuid) => void;
|
||||
/** Fired when a connection's socket closes on its own (session ended/dropped),
|
||||
* not via an intentional close/eviction. The embedder closes the pane. */
|
||||
onClosed?: (id: Uuid) => void;
|
||||
/** Forwarded to each connection: should a dropped socket be retried? */
|
||||
shouldReconnect?: (id: Uuid, code: number) => boolean;
|
||||
/** Threaded through to a structured connection so its rail can read live
|
||||
* worktree/git-status data (#140). Terminal connections don't need it. */
|
||||
store?: CatalogStoreApi;
|
||||
}
|
||||
|
||||
export interface ConnectionState {
|
||||
/** Sessions currently mounted into a visible pane. */
|
||||
mounted: Set<Uuid>;
|
||||
/** Every live (mounted or parked) connection, oldest first — LRU order. */
|
||||
cacheOrder: Uuid[];
|
||||
/** The focused session, or null when nothing is focused (welcome pane). */
|
||||
focusedId: Uuid | null;
|
||||
}
|
||||
|
||||
export interface ConnectionActions {
|
||||
isMounted(id: Uuid): boolean;
|
||||
/**
|
||||
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
|
||||
* exists (refreshing LRU recency). Does not touch other mounted panes.
|
||||
*/
|
||||
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void;
|
||||
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
|
||||
unmount(id: Uuid): void;
|
||||
/**
|
||||
* Reconcile the visible set to exactly `entries` (in render order): park any
|
||||
* mounted session not listed, mount/refresh each listed one in its pane.
|
||||
* `focus: false` shows the panes without taking the keyboard (preview).
|
||||
*/
|
||||
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[], opts?: { focus?: boolean }): void;
|
||||
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
|
||||
* `AwaitingInput` auto-clear) — the sidebar highlight never calls this,
|
||||
* only a pane actually becoming the visible view. Best-effort: a failed
|
||||
* report only delays the glow clearing. */
|
||||
setFocus(id: Uuid | null): void;
|
||||
/** Compat shim: show `id` alone in `pane` and focus it; park everything else. */
|
||||
activate(id: Uuid, pane: HTMLElement, info?: AttachInfo): void;
|
||||
/** Compat shim: park all visible panes and clear focus (e.g. welcome pane). */
|
||||
parkActive(): void;
|
||||
/** Put the keyboard back into the focused session's terminal. No-op when
|
||||
* nothing is focused (welcome pane) or its connection is gone. */
|
||||
focusTerminal(): void;
|
||||
/** Type raw bytes into the focused session's PTY. Returns false when there is
|
||||
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
|
||||
* is a no-op over the welcome pane rather than an error
|
||||
* (docs/COMMAND-BAR.md). */
|
||||
sendToFocused(data: string): boolean;
|
||||
/** Fully disconnect + forget a session. */
|
||||
close(id: Uuid): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type ConnectionStore = ConnectionState & ConnectionActions;
|
||||
|
||||
/**
|
||||
* Builds an isolated connection store — owns the set of live attach
|
||||
* connections, decoupled from the router/UI. The returned value is both a
|
||||
* React hook and a plain object (`getState`/`subscribe`), same shape as
|
||||
* {@link createCatalogStore}.
|
||||
*
|
||||
* Unlike a single-active pool, several connections can be **mounted**
|
||||
* (visible) at once — one per leaf of the active tab's layout tree. The
|
||||
* caller reconciles the visible set with {@link ConnectionActions.setVisible}.
|
||||
* Sessions no longer shown are **parked** in a hidden cache (still streaming)
|
||||
* and bounded by an LRU. A connection only dies on eviction, an explicit
|
||||
* {@link ConnectionActions.close}, or {@link ConnectionActions.dispose}.
|
||||
*/
|
||||
export function createConnectionStore(opts: PoolOptions = {}) {
|
||||
/** All live connections. Insertion order doubles as LRU: oldest first. */
|
||||
const cache = new Map<Uuid, PaneConnection>();
|
||||
/** The view each cached connection was actually created for — a deep link
|
||||
* or a restored layout can `mount()` a session before the catalog
|
||||
* snapshot names its view, defaulting it to `"Terminal"`; this is what
|
||||
* lets a later reconciliation notice the resolved view disagrees (#140
|
||||
* review). */
|
||||
const connViews = new Map<Uuid, SessionView>();
|
||||
// Off-screen home for parked, still-connected terminals.
|
||||
const hidden = document.createElement("div");
|
||||
hidden.className = "conn-cache";
|
||||
hidden.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(hidden);
|
||||
|
||||
let unsubscribeStore: (() => void) | undefined;
|
||||
|
||||
const store = create<ConnectionStore>((set, get) => {
|
||||
function createConn(id: Uuid, info?: AttachInfo): PaneConnection {
|
||||
const view = resolveView(info?.view);
|
||||
connViews.set(id, view);
|
||||
const connOpts = {
|
||||
onRequestClose: (sid: Uuid) => opts.onRequestClose?.(sid),
|
||||
onClosed: (sid: Uuid) => opts.onClosed?.(sid),
|
||||
shouldReconnect: (sid: Uuid, code: number) => opts.shouldReconnect?.(sid, code) ?? false,
|
||||
};
|
||||
// structured.ts's own `store` param still expects the pre-Zustand
|
||||
// `CatalogStore` class (events.ts) and doesn't use it yet (#140) — left
|
||||
// unpassed here until that rail wiring is ported too.
|
||||
return view === "Structured"
|
||||
? createStructuredConnection(id, connOpts, info)
|
||||
: createAttachConnection(id, connOpts, info);
|
||||
}
|
||||
|
||||
/** Dispose whatever is cached for `id` and cache a freshly created
|
||||
* connection in its place. Does not touch the DOM, `mounted`, or LRU
|
||||
* order — callers place the new element themselves. */
|
||||
function replace(id: Uuid, info?: AttachInfo): PaneConnection {
|
||||
cache.get(id)?.dispose();
|
||||
const fresh = createConn(id, info);
|
||||
cache.set(id, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
function insertOrder(id: Uuid): void {
|
||||
set((s) => ({ cacheOrder: [...s.cacheOrder, id] }));
|
||||
}
|
||||
|
||||
function touchOrder(id: Uuid): void {
|
||||
set((s) => ({ cacheOrder: [...s.cacheOrder.filter((x) => x !== id), id] }));
|
||||
}
|
||||
|
||||
function removeOrder(id: Uuid): void {
|
||||
set((s) => ({ cacheOrder: s.cacheOrder.filter((x) => x !== id) }));
|
||||
}
|
||||
|
||||
function setMounted(id: Uuid, isMounted: boolean): void {
|
||||
set((s) => {
|
||||
if (s.mounted.has(id) === isMounted) return {};
|
||||
const next = new Set(s.mounted);
|
||||
if (isMounted) next.add(id);
|
||||
else next.delete(id);
|
||||
return { mounted: next };
|
||||
});
|
||||
}
|
||||
|
||||
function setFocus(id: Uuid | null): void {
|
||||
const previous = get().focusedId;
|
||||
if (id === previous) return;
|
||||
set({ focusedId: id });
|
||||
if (previous !== null) void setSessionFocus(previous, false).catch(() => {});
|
||||
if (id !== null) void setSessionFocus(id, true).catch(() => {});
|
||||
opts.onFocusChange?.(id);
|
||||
}
|
||||
|
||||
function close(id: Uuid): void {
|
||||
const conn = cache.get(id);
|
||||
if (!conn) return;
|
||||
conn.dispose();
|
||||
cache.delete(id);
|
||||
connViews.delete(id);
|
||||
removeOrder(id);
|
||||
setMounted(id, false);
|
||||
if (get().focusedId === id) setFocus(null);
|
||||
}
|
||||
|
||||
/** Drop least-recently-viewed *parked* connections beyond the buffer cap. */
|
||||
function evict(): void {
|
||||
while (cache.size - get().mounted.size > MAX_PARKED) {
|
||||
const mounted = get().mounted;
|
||||
let victim: Uuid | null = null;
|
||||
for (const id of cache.keys()) {
|
||||
if (!mounted.has(id)) {
|
||||
victim = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (victim === null) break;
|
||||
close(victim);
|
||||
}
|
||||
}
|
||||
|
||||
function mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, mountOpts?: { focus?: boolean }): void {
|
||||
let conn = cache.get(id);
|
||||
if (conn) {
|
||||
// `info === undefined` means "session not known yet", not "Terminal" —
|
||||
// never downgrade a resolved connection just because a later caller
|
||||
// couldn't resolve the session. Only a *resolved* view that disagrees
|
||||
// with what this connection was built for forces a replace.
|
||||
if (info !== undefined && resolveView(info.view) !== connViews.get(id)) {
|
||||
conn = replace(id, info);
|
||||
} else {
|
||||
touchOrder(id);
|
||||
if (info) conn.updateInfo(info);
|
||||
}
|
||||
} else {
|
||||
conn = createConn(id, info);
|
||||
cache.set(id, conn);
|
||||
insertOrder(id);
|
||||
}
|
||||
if (conn.el.parentElement !== pane) {
|
||||
pane.replaceChildren(conn.el);
|
||||
conn.onMount(mountOpts);
|
||||
} else if (!get().mounted.has(id)) {
|
||||
conn.onMount(mountOpts);
|
||||
}
|
||||
setMounted(id, true);
|
||||
evict();
|
||||
}
|
||||
|
||||
function unmount(id: Uuid): void {
|
||||
if (!get().mounted.has(id)) return;
|
||||
setMounted(id, false);
|
||||
const conn = cache.get(id);
|
||||
if (conn) {
|
||||
hidden.appendChild(conn.el); // stays alive, just off-screen
|
||||
conn.onUnmount();
|
||||
}
|
||||
}
|
||||
|
||||
function setVisible(
|
||||
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
|
||||
visOpts?: { focus?: boolean },
|
||||
): void {
|
||||
const next = new Set(entries.map((e) => e.id));
|
||||
for (const id of [...get().mounted]) {
|
||||
if (!next.has(id)) unmount(id);
|
||||
}
|
||||
for (const e of entries) mount(e.id, e.pane, e.info, visOpts);
|
||||
}
|
||||
|
||||
/** Proactively fix any cached connection whose view no longer matches the
|
||||
* catalog — the only path that repairs a pane nothing will `mount()`
|
||||
* again on its own (parked, or mounted but not currently being
|
||||
* re-rendered by anything else). A session absent from `sessions` (not
|
||||
* yet known, or removed) is left alone — that's the embedder's own
|
||||
* close/eviction concern, not a view mismatch. */
|
||||
function reconcileViews(sessions: Map<Uuid, Session>): void {
|
||||
for (const id of [...cache.keys()]) {
|
||||
const session = sessions.get(id);
|
||||
if (session === undefined || resolveView(session.view) === connViews.get(id)) continue;
|
||||
const existing = cache.get(id)!;
|
||||
const parent = existing.el.parentElement;
|
||||
const wasMounted = get().mounted.has(id);
|
||||
// Capture before anything below touches the DOM: dispose()/el.remove()
|
||||
// detach `existing.el`, and once a focused descendant is detached the
|
||||
// browser moves `document.activeElement` to `<body>` — too late to
|
||||
// tell whether the keyboard was actually in this pane.
|
||||
const hadDomFocus = existing.el.contains(document.activeElement);
|
||||
const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view };
|
||||
const fresh = replace(id, info);
|
||||
existing.el.remove(); // don't rely on dispose() to clear the DOM
|
||||
if (parent && parent !== hidden) {
|
||||
parent.replaceChildren(fresh.el);
|
||||
} else {
|
||||
// Parked: `hidden` holds every parked connection's element as
|
||||
// siblings — append, never replaceChildren, or they'd all vanish.
|
||||
hidden.appendChild(fresh.el);
|
||||
}
|
||||
if (wasMounted) {
|
||||
// `focusedId` names the session the pool was last told to focus —
|
||||
// but a same-tab sidebar preview moves the keyboard into the
|
||||
// sidebar without clearing it, so `focusedId` alone can point at a
|
||||
// pane the keyboard already left. Only re-focus when the DOM
|
||||
// agrees it never did.
|
||||
fresh.onMount({ focus: get().focusedId === id && hadDomFocus });
|
||||
} else {
|
||||
fresh.onUnmount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A session `mount()`ed before its catalog entry exists gets a
|
||||
// `"Terminal"` connection by default; once the snapshot actually names
|
||||
// the view, fix any connection that guessed wrong — nothing else
|
||||
// re-`mount()`s an already-open pane just because the catalog updated.
|
||||
unsubscribeStore = opts.store?.subscribe((state) => reconcileViews(state.sessions));
|
||||
|
||||
return {
|
||||
mounted: new Set<Uuid>(),
|
||||
cacheOrder: [],
|
||||
focusedId: null,
|
||||
|
||||
isMounted(id) {
|
||||
return get().mounted.has(id);
|
||||
},
|
||||
|
||||
mount,
|
||||
unmount,
|
||||
setVisible,
|
||||
setFocus,
|
||||
|
||||
activate(id, pane, info) {
|
||||
setVisible([{ id, pane, info }]);
|
||||
setFocus(id);
|
||||
},
|
||||
|
||||
parkActive() {
|
||||
for (const id of [...get().mounted]) unmount(id);
|
||||
setFocus(null);
|
||||
},
|
||||
|
||||
focusTerminal() {
|
||||
const id = get().focusedId;
|
||||
if (id === null) return;
|
||||
cache.get(id)?.focusTerminal();
|
||||
},
|
||||
|
||||
sendToFocused(data) {
|
||||
const id = get().focusedId;
|
||||
if (id === null) return false;
|
||||
const conn = cache.get(id);
|
||||
if (!conn) return false;
|
||||
conn.sendInput(data);
|
||||
return true;
|
||||
},
|
||||
|
||||
close,
|
||||
|
||||
dispose() {
|
||||
unsubscribeStore?.();
|
||||
for (const conn of cache.values()) conn.dispose();
|
||||
cache.clear();
|
||||
connViews.clear();
|
||||
set({ mounted: new Set(), cacheOrder: [], focusedId: null });
|
||||
hidden.remove();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
export type ConnectionStoreApi = ReturnType<typeof createConnectionStore>;
|
||||
@@ -446,6 +446,62 @@ describe("structured connection: data flow", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps last intermediate text outside group while turn is active", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
const respondingState: ReducedState = {
|
||||
turn: { responding: true, thinking: false, active_tool_calls: [] },
|
||||
};
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 6,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||
toolRow("tool:1", "one"),
|
||||
row("agent:1", "agent_text", { content: { type: "text", text: "Now fixing" } }),
|
||||
toolRow("tool:2", "two"),
|
||||
row("agent:2", "agent_text", { content: { type: "text", text: "Found more" } }),
|
||||
toolRow("tool:3", "three"),
|
||||
],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: respondingState });
|
||||
// Intermediate updates stay inside the collapsed activity disclosure,
|
||||
// rather than becoming a second visible transcript alongside its summary.
|
||||
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||
// It remains collapsed when the turn ends.
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("collapses trailing thought commentary with a turn's tool activity", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 5,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||
toolRow("tool:1", "one"),
|
||||
toolRow("tool:2", "two"),
|
||||
row("thought:1", "agent_thought", { content: { type: "text", text: "Checking the next step" } }),
|
||||
row("thought:2", "agent_thought", { content: { type: "text", text: "Preparing the change" } }),
|
||||
],
|
||||
},
|
||||
});
|
||||
for (const id of ["thought:1", "thought:2"]) {
|
||||
expect(conn.el.querySelector(`[data-row-id="${id}"]`)?.closest(".st-tool-group")).not.toBeNull();
|
||||
}
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -790,7 +846,9 @@ describe("structured connection: turn/composer gating", () => {
|
||||
expect(send.disabled).toBe(false);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
|
||||
const queued = conn.el.querySelectorAll(".st-row-queued");
|
||||
expect(queued).toHaveLength(1);
|
||||
expect(queued[0].textContent).toBe("queued next");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
@@ -810,7 +868,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
submit("second");
|
||||
submit("third");
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
|
||||
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(2);
|
||||
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
@@ -819,7 +877,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
|
||||
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
|
||||
expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(0);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
|
||||
+123
-26
@@ -117,7 +117,6 @@ export function createStructuredConnection(
|
||||
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
|
||||
<svg class="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>
|
||||
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +138,6 @@ export function createStructuredConnection(
|
||||
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
|
||||
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
|
||||
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
|
||||
const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
|
||||
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
|
||||
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
|
||||
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
|
||||
@@ -253,6 +251,8 @@ export function createStructuredConnection(
|
||||
let activityTimer: number | null = null;
|
||||
const promptQueue: QueuedPrompt[] = [];
|
||||
let promptRequestPending = false;
|
||||
/** Server path → object URL for uploaded images, so user bubbles can show previews. */
|
||||
const imageObjectUrls = new Map<string, string>();
|
||||
|
||||
// Within 64px, wheel and touch imprecision should still count as following.
|
||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||
@@ -383,6 +383,9 @@ export function createStructuredConnection(
|
||||
}
|
||||
}
|
||||
|
||||
/** Pattern matching `Attached file "name": "path"` references appended by dispatchPrompt. */
|
||||
const attachedFileRe = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
|
||||
|
||||
function buildRowElement(row: TranscriptRow): HTMLElement | null {
|
||||
const el = buildTranscriptRow(
|
||||
row,
|
||||
@@ -399,6 +402,39 @@ export function createStructuredConnection(
|
||||
if (el.querySelector(".st-tool-head")?.getAttribute("aria-expanded") !== "true") {
|
||||
expandedRowIds.delete(row.id);
|
||||
}
|
||||
// Inject image previews into user message bubbles and strip the path text.
|
||||
if (row.kind === "user" && imageObjectUrls.size > 0) {
|
||||
const data = row.data as { content?: { type: string; text?: string } } | undefined;
|
||||
const text = data?.content?.type === "text" ? data.content.text ?? "" : "";
|
||||
const body = el.querySelector(".st-body");
|
||||
if (body && text) {
|
||||
const images: string[] = [];
|
||||
attachedFileRe.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = attachedFileRe.exec(text)) !== null) {
|
||||
const serverPath = JSON.parse(`"${match[1]}"`);
|
||||
const objectUrl = imageObjectUrls.get(serverPath);
|
||||
if (objectUrl) images.push(objectUrl);
|
||||
}
|
||||
if (images.length > 0) {
|
||||
// Strip attachment reference lines from the displayed text.
|
||||
const userText = body.querySelector(".st-user-text");
|
||||
if (userText) {
|
||||
const cleaned = text.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim();
|
||||
userText.textContent = cleaned;
|
||||
if (!cleaned) userText.remove();
|
||||
}
|
||||
for (const src of images) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "st-user-image";
|
||||
img.src = src;
|
||||
img.alt = "Uploaded image";
|
||||
img.loading = "lazy";
|
||||
body.appendChild(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -452,7 +488,16 @@ export function createStructuredConnection(
|
||||
groups.set(currentGid, group);
|
||||
}
|
||||
}
|
||||
// pending at end of a turn = final text/approvals, stays outside (not flushed).
|
||||
// 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.
|
||||
// Leaving that tail loose made a busy agent look like it was publishing a
|
||||
// long italic answer above a collapsed "N tool calls" summary.
|
||||
const lastGroup = groups.get(currentGid);
|
||||
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
|
||||
lastGroup.rows.push(...pending);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
@@ -467,6 +512,7 @@ export function createStructuredConnection(
|
||||
const liveGroupIds = new Set([...groups.keys()]);
|
||||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||||
|
||||
const prevGroupEls = new Map(groupEls);
|
||||
rowEls.clear();
|
||||
groupEls.clear();
|
||||
const children: HTMLElement[] = [];
|
||||
@@ -485,23 +531,37 @@ export function createStructuredConnection(
|
||||
rowEls.set(memberRow.id, memberEl);
|
||||
return [memberEl];
|
||||
});
|
||||
const groupEl = buildToolGroup(
|
||||
groupId,
|
||||
group.rows,
|
||||
memberEls,
|
||||
expandedGroupIds.has(groupId),
|
||||
(expanded) => {
|
||||
if (expanded) expandedGroupIds.add(groupId);
|
||||
else expandedGroupIds.delete(groupId);
|
||||
settleDisclosureLayout();
|
||||
},
|
||||
group.toolCount,
|
||||
);
|
||||
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
|
||||
groupEl.classList.add("st-row-enter");
|
||||
|
||||
// Reuse existing group element when possible — avoids tearing down
|
||||
// and recreating the wrapper, which restarts CSS transitions (visible
|
||||
// flash) and destroys keyboard/focus state.
|
||||
const existing = prevGroupEls.get(groupId);
|
||||
if (existing) {
|
||||
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
|
||||
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
|
||||
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
|
||||
if (inner) inner.replaceChildren(...memberEls);
|
||||
groupEls.set(groupId, existing);
|
||||
children.push(existing);
|
||||
} else {
|
||||
const groupEl = buildToolGroup(
|
||||
groupId,
|
||||
group.rows,
|
||||
memberEls,
|
||||
expandedGroupIds.has(groupId),
|
||||
(expanded) => {
|
||||
if (expanded) expandedGroupIds.add(groupId);
|
||||
else expandedGroupIds.delete(groupId);
|
||||
settleDisclosureLayout();
|
||||
},
|
||||
group.toolCount,
|
||||
);
|
||||
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
|
||||
groupEl.classList.add("st-row-enter");
|
||||
}
|
||||
groupEls.set(groupId, groupEl);
|
||||
children.push(groupEl);
|
||||
}
|
||||
groupEls.set(groupId, groupEl);
|
||||
children.push(groupEl);
|
||||
continue;
|
||||
}
|
||||
// Resolved approvals not absorbed into a tool group are hidden — the
|
||||
@@ -564,10 +624,15 @@ export function createStructuredConnection(
|
||||
// visible flashing and breaks text selection.
|
||||
const oldEl = rowEls.get(delta.row.id);
|
||||
if (oldEl) {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const hadToolFocus = active?.classList.contains("st-tool-head") && oldEl.contains(active);
|
||||
const newEl = buildRowElement(delta.row);
|
||||
if (newEl) {
|
||||
oldEl.replaceWith(newEl);
|
||||
rowEls.set(delta.row.id, newEl);
|
||||
if (hadToolFocus) {
|
||||
newEl.querySelector<HTMLButtonElement>(".st-tool-head")?.focus();
|
||||
}
|
||||
}
|
||||
syncAvailableCommands();
|
||||
return;
|
||||
@@ -616,12 +681,14 @@ export function createStructuredConnection(
|
||||
}
|
||||
}
|
||||
|
||||
let configDirty = false;
|
||||
function renderConfig(): void {
|
||||
const options = sessionConfigOptions();
|
||||
configEl.hidden = options.length === 0;
|
||||
if (options.length === 0) {
|
||||
configMenu.hidden = true;
|
||||
configTrigger.setAttribute("aria-expanded", "false");
|
||||
configDirty = false;
|
||||
return;
|
||||
}
|
||||
const ordered = [...options].sort((a, b) => {
|
||||
@@ -635,6 +702,15 @@ export function createStructuredConnection(
|
||||
configTrigger.title = summary;
|
||||
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
|
||||
configTrigger.disabled = configBusy;
|
||||
|
||||
// When the menu is open and the user is interacting (not mid-change),
|
||||
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
|
||||
// Mark dirty so the next open rebuilds with fresh data.
|
||||
if (!configMenu.hidden && !configBusy) {
|
||||
configDirty = true;
|
||||
return;
|
||||
}
|
||||
configDirty = false;
|
||||
configMenu.replaceChildren();
|
||||
for (const option of ordered) {
|
||||
const row = document.createElement("label");
|
||||
@@ -673,6 +749,22 @@ export function createStructuredConnection(
|
||||
}
|
||||
}
|
||||
|
||||
function renderQueuedPrompts(): void {
|
||||
for (const old of rowsEl.querySelectorAll(".st-row-queued")) old.remove();
|
||||
for (const queued of promptQueue) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "st-row st-row-queued";
|
||||
const body = document.createElement("div");
|
||||
body.className = "st-body";
|
||||
const line = document.createElement("div");
|
||||
line.className = "st-user-text";
|
||||
line.textContent = queued.text;
|
||||
body.appendChild(line);
|
||||
wrap.appendChild(body);
|
||||
rowsEl.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function renderComposerState(): void {
|
||||
const projectedActive = state.turn.responding || state.turn.thinking;
|
||||
input.disabled = false;
|
||||
@@ -683,10 +775,7 @@ export function createStructuredConnection(
|
||||
sendBtn.hidden = false;
|
||||
sendLabel.textContent = "Send";
|
||||
cancelBtn.hidden = !projectedActive;
|
||||
queueEl.hidden = promptQueue.length === 0;
|
||||
queueEl.textContent = promptQueue.length === 1
|
||||
? "1 message queued"
|
||||
: `${promptQueue.length} messages queued`;
|
||||
renderQueuedPrompts();
|
||||
renderActivity();
|
||||
}
|
||||
|
||||
@@ -741,15 +830,17 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function applyState(next: ReducedState, connectionGeneration: number): void {
|
||||
const wasTurnActive = state.turn.responding || state.turn.thinking;
|
||||
state = next;
|
||||
stateSocketGeneration = connectionGeneration;
|
||||
reconcileAwaitingBegin();
|
||||
const isTurnActive = state.turn.responding || state.turn.thinking;
|
||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||
if (newPendingId !== pendingApprovalRowId) {
|
||||
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
|
||||
if (needsRebuild) {
|
||||
updateTranscriptLayout(() => {
|
||||
const prev = pendingApprovalRowId;
|
||||
pendingApprovalRowId = newPendingId;
|
||||
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
||||
rebuildTranscript();
|
||||
});
|
||||
}
|
||||
renderConfig();
|
||||
@@ -764,6 +855,7 @@ export function createStructuredConnection(
|
||||
|
||||
configTrigger.addEventListener("click", () => {
|
||||
const opening = configMenu.hidden;
|
||||
if (opening && configDirty) renderConfig();
|
||||
configMenu.hidden = !opening;
|
||||
configTrigger.setAttribute("aria-expanded", String(opening));
|
||||
});
|
||||
@@ -970,6 +1062,9 @@ export function createStructuredConnection(
|
||||
for (const attachment of submittedAttachments) {
|
||||
if (disposed) return;
|
||||
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
|
||||
if (attachment.path && attachment.file.type.startsWith("image/")) {
|
||||
imageObjectUrls.set(attachment.path, URL.createObjectURL(attachment.file));
|
||||
}
|
||||
}
|
||||
if (disposed) return;
|
||||
const references = submittedAttachments.map(({ file, path }) =>
|
||||
@@ -1209,6 +1304,8 @@ export function createStructuredConnection(
|
||||
closeCurrentSocket();
|
||||
expandedRowIds.clear();
|
||||
expandedGroupIds.clear();
|
||||
for (const url of imageObjectUrls.values()) URL.revokeObjectURL(url);
|
||||
imageObjectUrls.clear();
|
||||
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||
scrollEl.removeEventListener("wheel", stopSmoothFollow);
|
||||
|
||||
@@ -315,8 +315,8 @@ describe("buildTranscriptRow: tool diffs", () => {
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
const diff = el.querySelector(".st-diff") as HTMLElement;
|
||||
expect(diff).not.toBeNull();
|
||||
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
|
||||
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
|
||||
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
|
||||
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
|
||||
});
|
||||
|
||||
it("gives the diff its own horizontally-scrolling container", () => {
|
||||
|
||||
@@ -223,15 +223,16 @@ function buildDiffBlock(diff: { path: string; oldText?: string | null; newText:
|
||||
wrap.appendChild(label);
|
||||
const scroller = el("div", "st-diff");
|
||||
const pre = document.createElement("pre");
|
||||
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
|
||||
const lines = computeDiffLines(diff.oldText, diff.newText);
|
||||
const prefixes: Record<string, string> = { add: "+ ", del: "- ", ctx: " ", hunk: "", eof: "" };
|
||||
for (const line of lines) {
|
||||
const span = document.createElement("span");
|
||||
span.className = `st-ln st-ln-${line.kind}`;
|
||||
span.textContent = line.text;
|
||||
span.textContent = (prefixes[line.kind] ?? "") + line.text;
|
||||
pre.appendChild(span);
|
||||
pre.appendChild(document.createTextNode("\n"));
|
||||
}
|
||||
scroller.appendChild(pre);
|
||||
makeCopyable(scroller, pre.textContent ?? "", "diff");
|
||||
makeCopyable(scroller, lines.map((l) => (prefixes[l.kind] ?? "") + l.text).join("\n"), "diff");
|
||||
wrap.appendChild(scroller);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
+28
-13
@@ -2375,11 +2375,36 @@ input.leaf-rename:focus {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* Queued messages: optimistic user bubbles, not yet sent. */
|
||||
.st-row-queued {
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.st-row-queued .st-body {
|
||||
flex: 0 1 auto;
|
||||
max-width: min(86%, 72ch);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--surface-3);
|
||||
opacity: 0.45;
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
|
||||
.st-user-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.st-user-image {
|
||||
display: block;
|
||||
max-width: min(100%, 480px);
|
||||
max-height: 360px;
|
||||
margin-top: var(--sp-2);
|
||||
border-radius: var(--r-1);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* agent prose: no card, no background — plain text is the point */
|
||||
.st-row-agent .st-body p {
|
||||
margin: 0 0 var(--sp-2);
|
||||
@@ -2482,7 +2507,8 @@ input.leaf-rename:focus {
|
||||
|
||||
.st-row-thought {
|
||||
color: var(--faint);
|
||||
font-style: italic;
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -2965,6 +2991,7 @@ input.leaf-rename:focus {
|
||||
display: block;
|
||||
white-space: pre;
|
||||
padding: 0 var(--sp-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.st-ln-add {
|
||||
@@ -3112,18 +3139,6 @@ input.leaf-rename:focus {
|
||||
transition: border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.structured-queue {
|
||||
position: absolute;
|
||||
right: var(--sp-2);
|
||||
bottom: calc(100% + var(--sp-1));
|
||||
padding: 3px var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-11);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.structured-composer:focus-within {
|
||||
border-top-color: var(--border);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
// Bridges Tailwind utilities to the OKLCH custom-property tokens defined in
|
||||
// src/styles.css. Every value here is `var(--token)`, never a literal, so
|
||||
// theme/palette switching (data-theme, data-palette on <html>) keeps working
|
||||
// through Tailwind classes exactly as it does through the vanilla CSS.
|
||||
export default {
|
||||
content: ["src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: "var(--bg)",
|
||||
"bg-grid": "var(--bg-grid)",
|
||||
surface: "var(--surface)",
|
||||
"surface-2": "var(--surface-2)",
|
||||
"surface-3": "var(--surface-3)",
|
||||
border: "var(--border)",
|
||||
"border-strong": "var(--border-strong)",
|
||||
|
||||
ink: "var(--ink)",
|
||||
muted: "var(--muted)",
|
||||
faint: "var(--faint)",
|
||||
|
||||
accent: "var(--accent)",
|
||||
"accent-strong": "var(--accent-strong)",
|
||||
"accent-ink": "var(--accent-ink)",
|
||||
"accent-tint": "var(--accent-tint)",
|
||||
"accent-ring": "var(--accent-ring)",
|
||||
"accent-fill": "var(--accent-fill)",
|
||||
|
||||
preview: "var(--preview)",
|
||||
"preview-ink": "var(--preview-ink)",
|
||||
"preview-tint": "var(--preview-tint)",
|
||||
"preview-ring": "var(--preview-ring)",
|
||||
|
||||
live: "var(--live)",
|
||||
"live-glow": "var(--live-glow)",
|
||||
danger: "var(--danger)",
|
||||
"danger-tint": "var(--danger-tint)",
|
||||
warn: "var(--warn)",
|
||||
|
||||
"term-bg": "var(--term-bg)",
|
||||
},
|
||||
spacing: {
|
||||
"sp-1": "var(--sp-1)",
|
||||
"sp-2": "var(--sp-2)",
|
||||
"sp-3": "var(--sp-3)",
|
||||
"sp-4": "var(--sp-4)",
|
||||
"sp-5": "var(--sp-5)",
|
||||
"sp-6": "var(--sp-6)",
|
||||
"sp-8": "var(--sp-8)",
|
||||
},
|
||||
borderRadius: {
|
||||
"r-1": "var(--r-1)",
|
||||
"r-2": "var(--r-2)",
|
||||
"r-3": "var(--r-3)",
|
||||
"r-pill": "var(--r-pill)",
|
||||
},
|
||||
fontSize: {
|
||||
"fs-11": "var(--fs-11)",
|
||||
"fs-12": "var(--fs-12)",
|
||||
"fs-13": "var(--fs-13)",
|
||||
"fs-14": "var(--fs-14)",
|
||||
"fs-15": "var(--fs-15)",
|
||||
"fs-17": "var(--fs-17)",
|
||||
"fs-20": "var(--fs-20)",
|
||||
},
|
||||
fontFamily: {
|
||||
mono: "var(--font-mono)",
|
||||
sans: "var(--font-sans)",
|
||||
},
|
||||
height: {
|
||||
"row-h": "var(--row-h)",
|
||||
"control-h": "var(--control-h)",
|
||||
tap: "var(--tap)",
|
||||
"bar-h": "var(--bar-h)",
|
||||
},
|
||||
width: {
|
||||
tap: "var(--tap)",
|
||||
},
|
||||
minHeight: {
|
||||
"control-h": "var(--control-h)",
|
||||
tap: "var(--tap)",
|
||||
},
|
||||
zIndex: {
|
||||
sticky: "var(--z-sticky)",
|
||||
backdrop: "var(--z-backdrop)",
|
||||
modal: "var(--z-modal)",
|
||||
toast: "var(--z-toast)",
|
||||
},
|
||||
transitionDuration: {
|
||||
fast: "var(--dur-fast)",
|
||||
DEFAULT: "var(--dur)",
|
||||
slow: "var(--dur-slow)",
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
out: "var(--ease-out)",
|
||||
},
|
||||
boxShadow: {
|
||||
1: "var(--shadow-1)",
|
||||
2: "var(--shadow-2)",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Config;
|
||||
@@ -4,6 +4,7 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// Where the dev proxy forwards `/api/*` (and the events WS) to. Default to a
|
||||
// local daemon on 127.0.0.1:7070. Override with:
|
||||
@@ -18,6 +20,7 @@ export default defineConfig(({ mode }) => {
|
||||
const target = resolveProxyTarget(env.MAESTRO_DAEMON_URL);
|
||||
const secure = target.startsWith("https://");
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
target: "es2022",
|
||||
outDir: "dist",
|
||||
|
||||
Reference in New Issue
Block a user