Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e98baf4d |
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key
|
|
||||||
// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides
|
|
||||||
// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one
|
|
||||||
// folds into the very next key, tapped here or typed on the soft keyboard,
|
|
||||||
// then disarms.
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js";
|
|
||||||
|
|
||||||
type Modifier = "ctrl" | "alt";
|
|
||||||
/** CSI final bytes: arrows A-D, Home `H`, End `F`. */
|
|
||||||
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
|
||||||
|
|
||||||
type KeyDef =
|
|
||||||
| { label: string; mod: Modifier }
|
|
||||||
| { label: string; cursor: CursorFinal }
|
|
||||||
| { label: string; tilde: number }
|
|
||||||
| { label: string; char: string }
|
|
||||||
| { label: string; seq: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Row-major into an 8-column / 2-row grid (#7):
|
|
||||||
*
|
|
||||||
* Esc Tab Home ↑ End PgUp - /
|
|
||||||
* Ctrl Alt ← ↓ → PgDn | ~
|
|
||||||
*/
|
|
||||||
const KEY_DEFS: KeyDef[] = [
|
|
||||||
{ label: "Esc", seq: "\x1b" },
|
|
||||||
{ label: "Tab", seq: "\t" },
|
|
||||||
{ label: "Home", cursor: "H" },
|
|
||||||
{ label: "↑", cursor: "A" },
|
|
||||||
{ label: "End", cursor: "F" },
|
|
||||||
{ label: "PgUp", tilde: 5 },
|
|
||||||
{ label: "-", char: "-" },
|
|
||||||
{ label: "/", char: "/" },
|
|
||||||
{ label: "Ctrl", mod: "ctrl" },
|
|
||||||
{ label: "Alt", mod: "alt" },
|
|
||||||
{ label: "←", cursor: "D" },
|
|
||||||
{ label: "↓", cursor: "B" },
|
|
||||||
{ label: "→", cursor: "C" },
|
|
||||||
{ label: "PgDn", tilde: 6 },
|
|
||||||
{ label: "|", char: "|" },
|
|
||||||
{ label: "~", char: "~" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard
|
|
||||||
* character before it reaches the PTY — used by the terminal's own `onData`
|
|
||||||
* handler, not by this component's own button clicks.
|
|
||||||
*/
|
|
||||||
export function useKeyBarModifiers() {
|
|
||||||
const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false });
|
|
||||||
const [, setTick] = useState(0);
|
|
||||||
const rerender = useCallback(() => setTick((n) => n + 1), []);
|
|
||||||
|
|
||||||
const setMod = useCallback(
|
|
||||||
(mod: Modifier, on: boolean) => {
|
|
||||||
armedRef.current[mod] = on;
|
|
||||||
rerender();
|
|
||||||
},
|
|
||||||
[rerender],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearMods = useCallback(() => {
|
|
||||||
if (!armedRef.current.ctrl && !armedRef.current.alt) return;
|
|
||||||
armedRef.current = { ctrl: false, alt: false };
|
|
||||||
rerender();
|
|
||||||
}, [rerender]);
|
|
||||||
|
|
||||||
const consume = useCallback((data: string): string => {
|
|
||||||
const { ctrl, alt } = armedRef.current;
|
|
||||||
if ((ctrl || alt) && data.length === 1) {
|
|
||||||
const out = applyModsToChar(data, ctrl, alt);
|
|
||||||
armedRef.current = { ctrl: false, alt: false };
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { armed: armedRef.current, setMod, clearMods, consume };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KeyBarProps {
|
|
||||||
armed: { ctrl: boolean; alt: boolean };
|
|
||||||
onToggleMod(mod: Modifier): void;
|
|
||||||
onClearMods(): void;
|
|
||||||
onSend(data: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
aria-label="Special keys"
|
|
||||||
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
|
|
||||||
>
|
|
||||||
{KEY_DEFS.map((def) => {
|
|
||||||
if ("mod" in def) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={def.label}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={armed[def.mod]}
|
|
||||||
aria-label={`${def.label} modifier`}
|
|
||||||
className="min-w-8 rounded px-2 py-1 text-xs aria-pressed:bg-emerald-500/40"
|
|
||||||
onClick={() => onToggleMod(def.mod)}
|
|
||||||
>
|
|
||||||
{def.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const bytes = () => {
|
|
||||||
if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt);
|
|
||||||
if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt);
|
|
||||||
if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt);
|
|
||||||
return def.seq;
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={def.label}
|
|
||||||
type="button"
|
|
||||||
className="min-w-8 rounded px-2 py-1 text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
onSend(bytes());
|
|
||||||
onClearMods();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{def.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { cleanup, render } from "@testing-library/react";
|
|
||||||
import { createRef } from "react";
|
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
|
||||||
import { attachWsUrl } from "../../routes.js";
|
|
||||||
import type { ClientControl } from "../../types.js";
|
|
||||||
|
|
||||||
class FakeTerminal {
|
|
||||||
static instances: FakeTerminal[] = [];
|
|
||||||
cols = 80;
|
|
||||||
rows = 24;
|
|
||||||
element: HTMLElement | undefined = undefined;
|
|
||||||
textarea: HTMLTextAreaElement | null = null;
|
|
||||||
disposed = false;
|
|
||||||
focus = vi.fn();
|
|
||||||
private dataCb: ((d: string) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(public opts: unknown) {
|
|
||||||
FakeTerminal.instances.push(this);
|
|
||||||
}
|
|
||||||
loadAddon(): void {}
|
|
||||||
open(container: HTMLElement): void {
|
|
||||||
container.appendChild(document.createElement("div"));
|
|
||||||
}
|
|
||||||
onData(cb: (d: string) => void) {
|
|
||||||
this.dataCb = cb;
|
|
||||||
return { dispose: vi.fn() };
|
|
||||||
}
|
|
||||||
emitData(d: string): void {
|
|
||||||
this.dataCb?.(d);
|
|
||||||
}
|
|
||||||
onResize() {
|
|
||||||
return { dispose: vi.fn() };
|
|
||||||
}
|
|
||||||
onSelectionChange() {
|
|
||||||
return { dispose: vi.fn() };
|
|
||||||
}
|
|
||||||
write(_data: Uint8Array, cb?: () => void): void {
|
|
||||||
cb?.();
|
|
||||||
}
|
|
||||||
scrollToBottom(): void {}
|
|
||||||
resize(cols: number, rows: number): void {
|
|
||||||
this.cols = cols;
|
|
||||||
this.rows = rows;
|
|
||||||
}
|
|
||||||
clearTextureAtlas(): void {}
|
|
||||||
getSelection(): string {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
dispose(): void {
|
|
||||||
this.disposed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeFitAddon {
|
|
||||||
fit = vi.fn();
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeWebglAddon {
|
|
||||||
static instances: FakeWebglAddon[] = [];
|
|
||||||
disposed = false;
|
|
||||||
private lossCb: (() => void) | null = null;
|
|
||||||
constructor() {
|
|
||||||
FakeWebglAddon.instances.push(this);
|
|
||||||
}
|
|
||||||
onContextLoss(cb: () => void): void {
|
|
||||||
this.lossCb = cb;
|
|
||||||
}
|
|
||||||
triggerLoss(): void {
|
|
||||||
this.lossCb?.();
|
|
||||||
}
|
|
||||||
dispose(): void {
|
|
||||||
this.disposed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const policyEnabled = vi.fn(() => true);
|
|
||||||
const policyNoteLoss = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
|
|
||||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
|
|
||||||
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
|
|
||||||
vi.mock("../../webglRenderer.js", () => ({
|
|
||||||
WebglPolicy: vi.fn().mockImplementation(() => ({
|
|
||||||
enabled: policyEnabled,
|
|
||||||
noteContextLoss: policyNoteLoss,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
class FakeWebSocket {
|
|
||||||
static OPEN = 1;
|
|
||||||
static CONNECTING = 0;
|
|
||||||
static CLOSING = 2;
|
|
||||||
static CLOSED = 3;
|
|
||||||
static instances: FakeWebSocket[] = [];
|
|
||||||
readyState = FakeWebSocket.CONNECTING;
|
|
||||||
binaryType = "";
|
|
||||||
sent: (string | ArrayBufferView)[] = [];
|
|
||||||
onopen: (() => void) | null = null;
|
|
||||||
onmessage: ((ev: { data: unknown }) => void) | null = null;
|
|
||||||
onerror: (() => void) | null = null;
|
|
||||||
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(public url: string) {
|
|
||||||
FakeWebSocket.instances.push(this);
|
|
||||||
}
|
|
||||||
send(data: string | ArrayBufferView): void {
|
|
||||||
this.sent.push(data);
|
|
||||||
}
|
|
||||||
close(): void {
|
|
||||||
this.readyState = FakeWebSocket.CLOSED;
|
|
||||||
}
|
|
||||||
open(): void {
|
|
||||||
this.readyState = FakeWebSocket.OPEN;
|
|
||||||
this.onopen?.();
|
|
||||||
}
|
|
||||||
drop(code = 1006, reason = ""): void {
|
|
||||||
this.readyState = FakeWebSocket.CLOSED;
|
|
||||||
this.onclose?.({ code, reason });
|
|
||||||
}
|
|
||||||
lastControl(): ClientControl {
|
|
||||||
return JSON.parse(this.sent.at(-1) as string) as ClientControl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"ResizeObserver",
|
|
||||||
class {
|
|
||||||
observe(): void {}
|
|
||||||
disconnect(): void {}
|
|
||||||
unobserve(): void {}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
FakeTerminal.instances = [];
|
|
||||||
FakeWebglAddon.instances = [];
|
|
||||||
FakeWebSocket.instances = [];
|
|
||||||
policyEnabled.mockReturnValue(true);
|
|
||||||
policyEnabled.mockClear();
|
|
||||||
policyNoteLoss.mockClear();
|
|
||||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
|
|
||||||
const { TerminalPane } = await import("./TerminalPane.js");
|
|
||||||
|
|
||||||
describe("TerminalPane lifecycle", () => {
|
|
||||||
it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
|
|
||||||
render(<TerminalPane sessionId="s1" />);
|
|
||||||
|
|
||||||
expect(FakeTerminal.instances).toHaveLength(1);
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
||||||
expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends the terminal size once the socket opens", () => {
|
|
||||||
render(<TerminalPane sessionId="s1" />);
|
|
||||||
const sock = FakeWebSocket.instances[0];
|
|
||||||
|
|
||||||
sock.open();
|
|
||||||
|
|
||||||
expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disposes the terminal and closes the socket on unmount", () => {
|
|
||||||
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
|
||||||
const term = FakeTerminal.instances[0];
|
|
||||||
const sock = FakeWebSocket.instances[0];
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(term.disposed).toBe(true);
|
|
||||||
expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reconnects to a fresh socket for a new sessionId", () => {
|
|
||||||
const { rerender } = render(<TerminalPane sessionId="s1" />);
|
|
||||||
rerender(<TerminalPane sessionId="s2" />);
|
|
||||||
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
||||||
expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
|
|
||||||
expect(FakeTerminal.instances[0].disposed).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TerminalPane reconnect backoff", () => {
|
|
||||||
it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
render(<TerminalPane sessionId="s1" />);
|
|
||||||
|
|
||||||
const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
|
|
||||||
const expectStillWaiting = (ms: number, countBefore: number) => {
|
|
||||||
vi.advanceTimersByTime(ms - 1);
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(countBefore);
|
|
||||||
vi.advanceTimersByTime(1);
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
||||||
drop();
|
|
||||||
expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
|
|
||||||
drop();
|
|
||||||
expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
|
|
||||||
drop();
|
|
||||||
expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
|
|
||||||
drop();
|
|
||||||
expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
|
|
||||||
drop();
|
|
||||||
expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
|
|
||||||
drop();
|
|
||||||
expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
|
|
||||||
drop();
|
|
||||||
expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets the backoff once a reconnected socket opens", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
render(<TerminalPane sessionId="s1" />);
|
|
||||||
|
|
||||||
FakeWebSocket.instances[0].drop(1006, "");
|
|
||||||
vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
|
|
||||||
FakeWebSocket.instances[1].open();
|
|
||||||
FakeWebSocket.instances[1].drop(1006, "");
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
||||||
vi.advanceTimersByTime(1);
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not retry a clean close, and reports it closed", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const onClosed = vi.fn();
|
|
||||||
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
|
||||||
|
|
||||||
FakeWebSocket.instances[0].drop(1000, "");
|
|
||||||
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
|
||||||
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
||||||
expect(onClosed).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not retry an application-level rejection, and writes it into the terminal", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const onClosed = vi.fn();
|
|
||||||
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
|
||||||
const term = FakeTerminal.instances[0];
|
|
||||||
const writeSpy = vi.spyOn(term, "write");
|
|
||||||
|
|
||||||
FakeWebSocket.instances[0].drop(4004, "unknown session");
|
|
||||||
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
|
||||||
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
||||||
expect(onClosed).not.toHaveBeenCalled();
|
|
||||||
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honors a caller-supplied shouldReconnect policy", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const onClosed = vi.fn();
|
|
||||||
render(<TerminalPane sessionId="s1" shouldReconnect={() => false} onClosed={onClosed} />);
|
|
||||||
|
|
||||||
FakeWebSocket.instances[0].drop(1006, "");
|
|
||||||
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
|
||||||
|
|
||||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
||||||
expect(onClosed).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TerminalPane WebGL context budget", () => {
|
|
||||||
it("acquires a WebGL context on mount, shared policy allowing", () => {
|
|
||||||
render(<TerminalPane sessionId="s1" />);
|
|
||||||
|
|
||||||
expect(policyEnabled).toHaveBeenCalled();
|
|
||||||
expect(FakeWebglAddon.instances).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never creates a context once the shared budget is exhausted", () => {
|
|
||||||
policyEnabled.mockReturnValue(false);
|
|
||||||
render(<TerminalPane sessionId="s1" />);
|
|
||||||
|
|
||||||
expect(FakeWebglAddon.instances).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("releases its context on unmount, handing the budget back", () => {
|
|
||||||
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
|
||||||
const addon = FakeWebglAddon.instances[0];
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(addon.disposed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts a context loss and re-acquires while still mounted", () => {
|
|
||||||
render(<TerminalPane sessionId="s1" />);
|
|
||||||
const first = FakeWebglAddon.instances[0];
|
|
||||||
|
|
||||||
first.triggerLoss();
|
|
||||||
|
|
||||||
expect(policyNoteLoss).toHaveBeenCalledOnce();
|
|
||||||
expect(first.disposed).toBe(true);
|
|
||||||
expect(FakeWebglAddon.instances).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not re-acquire a lost context once the budget is spent", () => {
|
|
||||||
render(<TerminalPane sessionId="s1" />);
|
|
||||||
const first = FakeWebglAddon.instances[0];
|
|
||||||
policyEnabled.mockReturnValue(false);
|
|
||||||
|
|
||||||
first.triggerLoss();
|
|
||||||
|
|
||||||
expect(FakeWebglAddon.instances).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TerminalPane imperative handle", () => {
|
|
||||||
it("sendInput writes bytes to the PTY only once the socket is open", () => {
|
|
||||||
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
|
||||||
render(<TerminalPane sessionId="s1" ref={ref} />);
|
|
||||||
const sock = FakeWebSocket.instances[0];
|
|
||||||
|
|
||||||
ref.current!.sendInput("nope");
|
|
||||||
expect(sock.sent).toHaveLength(0);
|
|
||||||
|
|
||||||
sock.open();
|
|
||||||
sock.sent.length = 0; // drop the resize control frame from open()
|
|
||||||
ref.current!.sendInput("hi");
|
|
||||||
|
|
||||||
expect(sock.sent).toHaveLength(1);
|
|
||||||
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("focusTerminal focuses the underlying xterm instance", () => {
|
|
||||||
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
|
||||||
render(<TerminalPane sessionId="s1" ref={ref} />);
|
|
||||||
|
|
||||||
ref.current!.focusTerminal();
|
|
||||||
|
|
||||||
expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes typed keystrokes through to the socket", () => {
|
|
||||||
render(<TerminalPane sessionId="s1" />);
|
|
||||||
const sock = FakeWebSocket.instances[0];
|
|
||||||
sock.open();
|
|
||||||
sock.sent.length = 0;
|
|
||||||
|
|
||||||
FakeTerminal.instances[0].emitData("q");
|
|
||||||
|
|
||||||
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
// React port of ../../attach.ts: an xterm terminal bound to the daemon's
|
|
||||||
// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget
|
|
||||||
// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect
|
|
||||||
// button, sessions drawer) stays out of scope here — this is the thin
|
|
||||||
// ref-based xterm wrapper the issue asked for; a header composes around it.
|
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
|
||||||
import { Terminal } from "@xterm/xterm";
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
|
||||||
import { attachWsUrl } from "../../routes.js";
|
|
||||||
import type { ClientControl, Uuid } from "../../types.js";
|
|
||||||
import { WebglPolicy } from "../../webglRenderer.js";
|
|
||||||
import { KeyBar, useKeyBarModifiers } from "./KeyBar.js";
|
|
||||||
|
|
||||||
/** One budget for the whole page — every mounted `TerminalPane` shares it, the
|
|
||||||
* same reasoning as attach.ts's own module-level policy. */
|
|
||||||
const webglPolicy = new WebglPolicy();
|
|
||||||
|
|
||||||
export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
|
|
||||||
|
|
||||||
/** Application-defined close codes (daemon rejection) and the clean-close
|
|
||||||
* code — retrying either would only loop. Mirrors reconnect.ts's
|
|
||||||
* `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */
|
|
||||||
function defaultShouldReconnect(code: number): boolean {
|
|
||||||
return code < 4000 && code !== 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalPaneProps {
|
|
||||||
sessionId: Uuid;
|
|
||||||
/** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */
|
|
||||||
focus?: boolean;
|
|
||||||
/** Asked on every close: should the socket be retried? */
|
|
||||||
shouldReconnect?: (code: number) => boolean;
|
|
||||||
/** The socket closed for good (retry declined, or the daemon rejected the
|
|
||||||
* session) — not called on an ordinary unmount. */
|
|
||||||
onClosed?: () => void;
|
|
||||||
/** Connection status changes, for a header/status badge to render. */
|
|
||||||
onStatusChange?: (status: TerminalStatus, detail?: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalPaneHandle {
|
|
||||||
focusTerminal(): void;
|
|
||||||
sendInput(data: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
|
||||||
function TerminalPane(
|
|
||||||
{ sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const sendInputRef = useRef<(data: string) => void>(() => {});
|
|
||||||
const focusTerminalRef = useRef<() => void>(() => {});
|
|
||||||
const keyBar = useKeyBarModifiers();
|
|
||||||
const [keybarActive, setKeybarActive] = useState(false);
|
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
focusTerminal: () => focusTerminalRef.current(),
|
|
||||||
sendInput: (data: string) => sendInputRef.current(data),
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const body = bodyRef.current;
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
const term = new Terminal({
|
|
||||||
cursorBlink: true,
|
|
||||||
fontFamily:
|
|
||||||
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
||||||
fontSize: 13,
|
|
||||||
convertEol: false,
|
|
||||||
theme: {
|
|
||||||
background: "#121419",
|
|
||||||
foreground: "#e6e8ec",
|
|
||||||
cursor: "#5fe3a1",
|
|
||||||
cursorAccent: "#121419",
|
|
||||||
selectionBackground: "#2b6b4f",
|
|
||||||
},
|
|
||||||
scrollback: 5000,
|
|
||||||
allowProposedApi: true,
|
|
||||||
});
|
|
||||||
const fit = new FitAddon();
|
|
||||||
term.loadAddon(fit);
|
|
||||||
term.open(body);
|
|
||||||
|
|
||||||
let webgl: WebglAddon | null = null;
|
|
||||||
let dprMedia: MediaQueryList | null = null;
|
|
||||||
|
|
||||||
const onDprChange = () => {
|
|
||||||
try {
|
|
||||||
term.clearTextureAtlas();
|
|
||||||
} catch {
|
|
||||||
// ignore — nothing to rebuild if the renderer already went away
|
|
||||||
}
|
|
||||||
watchDpr();
|
|
||||||
};
|
|
||||||
|
|
||||||
function watchDpr(): void {
|
|
||||||
dprMedia?.removeEventListener("change", onDprChange);
|
|
||||||
dprMedia = null;
|
|
||||||
try {
|
|
||||||
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
|
|
||||||
dprMedia?.addEventListener("change", onDprChange);
|
|
||||||
} catch {
|
|
||||||
// ignore — a browser without `resolution` media queries just keeps
|
|
||||||
// the atlas it has
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseWebgl(): void {
|
|
||||||
dprMedia?.removeEventListener("change", onDprChange);
|
|
||||||
dprMedia = null;
|
|
||||||
if (!webgl) return;
|
|
||||||
const addon = webgl;
|
|
||||||
webgl = null;
|
|
||||||
try {
|
|
||||||
addon.dispose();
|
|
||||||
} catch {
|
|
||||||
// ignore — a lost context can throw on the way out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function acquireWebgl(): void {
|
|
||||||
if (webgl || disposed || !webglPolicy.enabled()) return;
|
|
||||||
try {
|
|
||||||
const addon = new WebglAddon();
|
|
||||||
addon.onContextLoss(() => {
|
|
||||||
webglPolicy.noteContextLoss();
|
|
||||||
releaseWebgl();
|
|
||||||
if (mounted) acquireWebgl();
|
|
||||||
});
|
|
||||||
term.loadAddon(addon);
|
|
||||||
webgl = addon;
|
|
||||||
watchDpr();
|
|
||||||
} catch {
|
|
||||||
webglPolicy.noteContextLoss();
|
|
||||||
webgl = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mounted = false;
|
|
||||||
const tryFit = () => {
|
|
||||||
if (!mounted) return;
|
|
||||||
try {
|
|
||||||
const el2 = term.element;
|
|
||||||
const parent = el2?.parentElement;
|
|
||||||
const core = (
|
|
||||||
term as unknown as {
|
|
||||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
|
|
||||||
}
|
|
||||||
)._core;
|
|
||||||
const cell = core?._renderService?.dimensions?.css?.cell;
|
|
||||||
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
|
|
||||||
fit.fit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
|
|
||||||
let availW: number;
|
|
||||||
let availH: number;
|
|
||||||
if (vp && vp.clientWidth > 0) {
|
|
||||||
availW = vp.clientWidth;
|
|
||||||
availH = vp.clientHeight;
|
|
||||||
} else {
|
|
||||||
const ps = getComputedStyle(parent);
|
|
||||||
const ts = getComputedStyle(el2);
|
|
||||||
availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
|
|
||||||
availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
|
|
||||||
}
|
|
||||||
const cols = Math.max(2, Math.floor(availW / cell.width));
|
|
||||||
const rows = Math.max(1, Math.floor(availH / cell.height));
|
|
||||||
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
fit.fit();
|
|
||||||
} catch {
|
|
||||||
// ignore — fit fails if the container is detached or zero-sized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let ws: WebSocket;
|
|
||||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
|
||||||
let reconnectTimer: number | null = null;
|
|
||||||
let awaitingReplay = true;
|
|
||||||
let disposed = false;
|
|
||||||
|
|
||||||
const sendControl = (msg: ClientControl) => {
|
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
ws.send(JSON.stringify(msg));
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendInput = (data: string) => {
|
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
ws.send(new TextEncoder().encode(data));
|
|
||||||
};
|
|
||||||
sendInputRef.current = sendInput;
|
|
||||||
focusTerminalRef.current = () => term.focus();
|
|
||||||
|
|
||||||
const onResize = () => {
|
|
||||||
tryFit();
|
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
||||||
};
|
|
||||||
|
|
||||||
function connect(): void {
|
|
||||||
awaitingReplay = true;
|
|
||||||
ws = new WebSocket(attachWsUrl(sessionId));
|
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
reconnectMs = INITIAL_RECONNECT_MS;
|
|
||||||
onStatusChange?.("open");
|
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
||||||
if (mounted && focus) term.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
if (typeof ev.data === "string") {
|
|
||||||
try {
|
|
||||||
JSON.parse(ev.data) as ClientControl;
|
|
||||||
} catch {
|
|
||||||
// ignore unknown control frames
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.data instanceof ArrayBuffer) {
|
|
||||||
const replay = awaitingReplay;
|
|
||||||
awaitingReplay = false;
|
|
||||||
term.write(new Uint8Array(ev.data), () => {
|
|
||||||
if (replay) term.scrollToBottom();
|
|
||||||
});
|
|
||||||
} else if (ev.data instanceof Blob) {
|
|
||||||
void ev.data.arrayBuffer().then((b) => {
|
|
||||||
const replay = awaitingReplay;
|
|
||||||
awaitingReplay = false;
|
|
||||||
term.write(new Uint8Array(b), () => {
|
|
||||||
if (replay) term.scrollToBottom();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
onStatusChange?.("error");
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (ev) => {
|
|
||||||
const reason = ev.reason?.trim();
|
|
||||||
if (ev.code >= 4000 && reason && !disposed) {
|
|
||||||
onStatusChange?.("error", reason);
|
|
||||||
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (disposed) return;
|
|
||||||
|
|
||||||
if (shouldReconnect(ev.code)) {
|
|
||||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
|
||||||
onStatusChange?.("reconnecting");
|
|
||||||
reconnectTimer = window.setTimeout(() => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
|
||||||
connect();
|
|
||||||
}, delay);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatusChange?.("closed", String(ev.code));
|
|
||||||
onClosed?.();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatusChange?.("connecting");
|
|
||||||
connect();
|
|
||||||
|
|
||||||
const dataDisp = term.onData((data) => {
|
|
||||||
sendInput(keyBar.consume(data));
|
|
||||||
});
|
|
||||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
|
||||||
sendControl({ type: "Resize", cols, rows });
|
|
||||||
});
|
|
||||||
const selectionDisp = term.onSelectionChange(() => {
|
|
||||||
const text = term.getSelection();
|
|
||||||
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
const ta = term.textarea;
|
|
||||||
const onTaFocus = () => {
|
|
||||||
setKeybarActive(true);
|
|
||||||
requestAnimationFrame(tryFit);
|
|
||||||
};
|
|
||||||
const onTaBlur = () => {
|
|
||||||
setKeybarActive(false);
|
|
||||||
requestAnimationFrame(tryFit);
|
|
||||||
};
|
|
||||||
ta?.addEventListener("focus", onTaFocus);
|
|
||||||
ta?.addEventListener("blur", onTaBlur);
|
|
||||||
|
|
||||||
window.addEventListener("resize", onResize);
|
|
||||||
const visual = window.visualViewport ?? null;
|
|
||||||
visual?.addEventListener("resize", onResize);
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => tryFit());
|
|
||||||
ro.observe(body);
|
|
||||||
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
|
|
||||||
|
|
||||||
mounted = true;
|
|
||||||
acquireWebgl();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
tryFit();
|
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
||||||
if (focus && ws.readyState === WebSocket.OPEN) term.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
mounted = false;
|
|
||||||
if (reconnectTimer !== null) {
|
|
||||||
window.clearTimeout(reconnectTimer);
|
|
||||||
reconnectTimer = null;
|
|
||||||
}
|
|
||||||
ro.disconnect();
|
|
||||||
window.removeEventListener("resize", onResize);
|
|
||||||
visual?.removeEventListener("resize", onResize);
|
|
||||||
ta?.removeEventListener("focus", onTaFocus);
|
|
||||||
ta?.removeEventListener("blur", onTaBlur);
|
|
||||||
dataDisp.dispose();
|
|
||||||
resizeDisp.dispose();
|
|
||||||
selectionDisp.dispose();
|
|
||||||
releaseWebgl();
|
|
||||||
try {
|
|
||||||
ws.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
term.dispose();
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting
|
|
||||||
// a session change is a fresh connection, not a prop-driven re-render
|
|
||||||
}, [sessionId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
|
|
||||||
<div ref={bodyRef} className="min-h-0 flex-1" />
|
|
||||||
{keybarActive && (
|
|
||||||
<KeyBar
|
|
||||||
armed={keyBar.armed}
|
|
||||||
onToggleMod={(mod) => keyBar.setMod(mod, !keyBar.armed[mod])}
|
|
||||||
onClearMods={keyBar.clearMods}
|
|
||||||
onSend={(data) => sendInputRef.current(data)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Reference in New Issue
Block a user