feat: add multi-session desktop workspace
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
|
||||
|
||||
export function messageText(message: RuntimeView["messages"][number]) {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
return (message.content ?? [])
|
||||
.map((part) => {
|
||||
if (part.type === "text") return part.text ?? "";
|
||||
if (part.type === "thinking") return `Thinking: ${part.thinking ?? ""}`;
|
||||
if (part.type === "toolCall") return `Tool: ${part.name ?? "running"}`;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
function todos(messages: RuntimeView["messages"]) {
|
||||
const order: Record<TodoTask["status"], number> = {
|
||||
in_progress: 0,
|
||||
pending: 1,
|
||||
completed: 2,
|
||||
deleted: 3,
|
||||
};
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (
|
||||
messages[index].role === "toolResult" &&
|
||||
messages[index].toolName === "todo" &&
|
||||
Array.isArray(messages[index].details?.tasks)
|
||||
)
|
||||
return messages[index]
|
||||
.details!.tasks!.filter((task) => task.status !== "deleted")
|
||||
.sort((a, b) => order[a.status] - order[b.status]);
|
||||
}
|
||||
return [] as TodoTask[];
|
||||
}
|
||||
export function ConversationWorkspace({
|
||||
runtime,
|
||||
onDraft,
|
||||
onSubmit,
|
||||
onAbort,
|
||||
onScroll,
|
||||
onModel,
|
||||
onThinking,
|
||||
isSubmitting = false,
|
||||
}: {
|
||||
runtime: RuntimeView;
|
||||
onDraft: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onAbort: () => void;
|
||||
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
||||
onModel: (model: Model) => void;
|
||||
onThinking: (level: string) => void;
|
||||
isSubmitting?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
||||
const isWorking =
|
||||
isSubmitting ||
|
||||
runtime.progress.phase === "working" ||
|
||||
runtime.progress.phase === "recovering";
|
||||
useEffect(() => {
|
||||
if (runtime.scroll.atBottom && ref.current)
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
|
||||
return (
|
||||
<div
|
||||
className="workflow"
|
||||
id="session-workspace"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
|
||||
>
|
||||
<div className="workflow-main">
|
||||
<section
|
||||
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
|
||||
aria-label="Conversation"
|
||||
ref={ref}
|
||||
onScroll={(event) => {
|
||||
const node = event.currentTarget;
|
||||
onScroll(
|
||||
node.scrollHeight - node.scrollTop - node.clientHeight <= 4,
|
||||
node.scrollTop,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isWorking && (
|
||||
<div className="agent-working" role="status" aria-live="polite">
|
||||
<span className="working-bars" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<div>
|
||||
<strong>Pi is working</strong>
|
||||
<span>
|
||||
{isSubmitting
|
||||
? "Sending your prompt…"
|
||||
: runtime.progress.detail}
|
||||
</span>
|
||||
{runtime.progress.tool && (
|
||||
<small>Using {runtime.progress.tool}</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{runtime.loading && !runtime.messages.length && (
|
||||
<p className="muted">Loading session…</p>
|
||||
)}
|
||||
{!runtime.loading && !runtime.messages.length && (
|
||||
<p className="muted">No messages yet.</p>
|
||||
)}
|
||||
{runtime.messages.map((message, index) => (
|
||||
<article
|
||||
className={`message ${message.role ?? "system"}`}
|
||||
key={index}
|
||||
>
|
||||
<strong>{message.role ?? "message"}</strong>
|
||||
<pre>{messageText(message)}</pre>
|
||||
</article>
|
||||
))}
|
||||
{runtime.pendingSubmissions.map((pending) => (
|
||||
<article
|
||||
className={`message user pending-message ${pending.phase}`}
|
||||
key={pending.id}
|
||||
>
|
||||
<strong>
|
||||
You{" "}
|
||||
<span>
|
||||
{pending.phase === "sending"
|
||||
? "Sending…"
|
||||
: "Sent · waiting for Pi"}
|
||||
</span>
|
||||
</strong>
|
||||
<pre>{pending.text}</pre>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<aside className="todos-pane" aria-label="Current todos">
|
||||
<div className="todos-heading">
|
||||
<strong>Todos</strong>
|
||||
<span>
|
||||
{tasks.filter((task) => task.status !== "completed").length}
|
||||
</span>
|
||||
</div>
|
||||
{tasks.length ? (
|
||||
<ol className="todo-list">
|
||||
{tasks.map((task) => (
|
||||
<li className={task.status} key={task.id}>
|
||||
<div>
|
||||
<strong>{task.subject}</strong>
|
||||
{task.activeForm && <small>{task.activeForm}</small>}
|
||||
</div>
|
||||
<span>{task.status.replace("_", " ")}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="muted">No active todos yet.</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<footer className="workflow-footer">
|
||||
{Object.values(runtime.extensionWidgets)
|
||||
.flatMap((widget) => widget.lines)
|
||||
.map((line, index) => (
|
||||
<div className="extension-widget" key={`${line}-${index}`}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<section
|
||||
className="pi-controls"
|
||||
aria-label="Model and thinking controls"
|
||||
>
|
||||
<label>
|
||||
Model
|
||||
<select
|
||||
value={`${runtime.state.model?.provider ?? ""}/${runtime.state.model?.id ?? ""}`}
|
||||
onChange={(event) => {
|
||||
const [provider, id] = event.currentTarget.value.split("/");
|
||||
onModel({ provider, id });
|
||||
}}
|
||||
>
|
||||
<option value="">Current model</option>
|
||||
{runtime.models.map((model) => (
|
||||
<option
|
||||
key={`${model.provider}/${model.id}`}
|
||||
value={`${model.provider}/${model.id}`}
|
||||
>
|
||||
{model.provider}/{model.id ?? model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Thinking
|
||||
<select
|
||||
value={runtime.state.thinkingLevel ?? "medium"}
|
||||
onChange={(event) => onThinking(event.currentTarget.value)}
|
||||
>
|
||||
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
|
||||
(level) => (
|
||||
<option key={level}>{level}</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
<div
|
||||
className={`composer-progress ${isSubmitting ? "working" : runtime.progress.phase}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="work-dot" />
|
||||
<span>
|
||||
{isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail}
|
||||
</span>
|
||||
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
|
||||
</div>
|
||||
<div className="composer">
|
||||
<textarea
|
||||
aria-label="Prompt"
|
||||
value={runtime.draft}
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => onDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask Pi…"
|
||||
/>
|
||||
<div className="composer-actions">
|
||||
{runtime.state.isStreaming && (
|
||||
<button type="button" className="quiet" onClick={onAbort}>
|
||||
Abort
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!runtime.summary.agentId ||
|
||||
!runtime.draft.trim()
|
||||
}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSubmitting ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DirectoryWorkspace, WorkspaceState } from "../workspace/types";
|
||||
|
||||
type Props = {
|
||||
state: WorkspaceState;
|
||||
onSelect: (path: string) => void;
|
||||
onAdd: () => void;
|
||||
onForget: (directory: DirectoryWorkspace) => void;
|
||||
};
|
||||
export function DirectorySidebar({ state, onSelect, onAdd, onForget }: Props) {
|
||||
return <aside className="directory-sidebar" aria-label="Directories">
|
||||
<div className="sidebar-heading"><strong>Directories</strong><button onClick={onAdd} aria-label="Add directory">+</button></div>
|
||||
<nav>{state.directoryOrder.map((path) => {
|
||||
const directory = state.directoriesByPath[path]; if (!directory) return null;
|
||||
const selected = path === state.selectedDirectoryPath;
|
||||
return <div className={`directory-row ${selected ? "selected" : ""}`} key={path}>
|
||||
<button className="directory-select" aria-current={selected ? "page" : undefined} onClick={() => onSelect(path)} title={path}>
|
||||
<span>{path.split("/").filter(Boolean).slice(-1)[0] || path}</span>
|
||||
<small>{directory.workingCount ? `${directory.workingCount} working` : `${directory.openCount} open`}</small>
|
||||
<span className="directory-badges" aria-label={`${directory.attentionCount} attention, ${directory.errorCount} errors`}>
|
||||
{directory.attentionCount > 0 && <b className="attention">!{directory.attentionCount}</b>}
|
||||
{directory.errorCount > 0 && <b className="error">×{directory.errorCount}</b>}
|
||||
</span>
|
||||
</button>
|
||||
{!directory.isHome && <button className="forget-directory" aria-label={`Forget ${path}`} onClick={() => onForget(directory)}>×</button>}
|
||||
</div>;
|
||||
})}</nav>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Extension } from "../workspace/types";
|
||||
|
||||
function controls(container: HTMLElement | null) {
|
||||
return [...(container?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [])];
|
||||
}
|
||||
|
||||
export function ExtensionDialog({ extension, onRespond }: { extension?: Extension; onRespond: (response: Record<string, unknown>) => void }) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const respond = useRef(onRespond);
|
||||
const [value, setValue] = useState("");
|
||||
respond.current = onRespond;
|
||||
|
||||
useEffect(() => {
|
||||
if (!extension) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValue(extension.initialValue ?? extension.text ?? "");
|
||||
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
respond.current({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls(ref.current);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", keydown);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("keydown", keydown);
|
||||
opener.current?.focus();
|
||||
};
|
||||
}, [extension]);
|
||||
|
||||
if (!extension) return null;
|
||||
return <div className="extension-backdrop"><section ref={ref} className="extension" role="dialog" aria-modal="true" aria-labelledby="extension-title">
|
||||
<h2 id="extension-title">{extension.title ?? "Pi needs input"}</h2>{extension.message && <p>{extension.message}</p>}
|
||||
{extension.method === "select" && <div className="extension-options">{extension.options?.map((option) => <button key={option} onClick={() => onRespond({ value: option })}>{option}</button>)}</div>}
|
||||
{extension.method === "confirm" && <div className="extension-options"><button onClick={() => onRespond({ confirmed: true })}>Confirm</button><button onClick={() => onRespond({ confirmed: false })}>Decline</button></div>}
|
||||
{extension.method === "input" && <><input aria-label="Extension value" value={value} placeholder={extension.placeholder} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
|
||||
{extension.method === "editor" && <><textarea aria-label="Extension value" value={value} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
|
||||
<button className="quiet" onClick={() => onRespond({ cancelled: true })}>Cancel</button>
|
||||
</section></div>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { DirectorySession } from "../workspace/types";
|
||||
export function SessionPicker({ sessions, onOpen, onClose }: { sessions: DirectorySession[]; onOpen: (session: DirectorySession) => void; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLElement>(null);
|
||||
const openerRef = useRef<HTMLElement | null>(document.activeElement as HTMLElement | null);
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
const controls = () => [...(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), [href], [tabindex]:not([tabindex="-1"])') ?? [])];
|
||||
controls()[0]?.focus();
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") { event.preventDefault(); onClose(); return; }
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls(); const first = focusable[0]; const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
};
|
||||
window.addEventListener("keydown", keydown);
|
||||
return () => { window.removeEventListener("keydown", keydown); openerRef.current?.focus(); };
|
||||
}, [onClose]);
|
||||
return <div className="modal-backdrop"><section ref={dialogRef} className="session-picker" role="dialog" aria-modal="true" aria-labelledby="session-picker-title">
|
||||
<div className="picker-heading"><h2 id="session-picker-title">Saved sessions</h2><button onClick={onClose}>Close</button></div>
|
||||
{sessions.length ? <div className="session-list">{sessions.map((session) => <button key={session.path} onClick={() => onOpen(session)}>
|
||||
<span>{session.name ?? session.firstMessage ?? "Untitled session"}</span><small>{session.runtimeId ? "Already open" : `${session.messageCount} messages · ${new Date(session.modified).toLocaleDateString()}`}</small>
|
||||
</button>)}</div> : <p className="muted">No saved sessions for this directory.</p>}
|
||||
</section></div>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||
import type { RuntimeView } from "../workspace/types";
|
||||
|
||||
type Props = {
|
||||
runtimes: RuntimeView[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: (runtime: RuntimeView) => void;
|
||||
onNew: () => void;
|
||||
onResume: () => void;
|
||||
busy?: boolean;
|
||||
closingRuntimeId?: string;
|
||||
restoreFocus?: boolean;
|
||||
onFocusRestored?: () => void;
|
||||
};
|
||||
export function SessionTabs({
|
||||
runtimes,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onNew,
|
||||
onResume,
|
||||
busy,
|
||||
closingRuntimeId,
|
||||
restoreFocus,
|
||||
onFocusRestored,
|
||||
}: Props) {
|
||||
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const newSessionRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!restoreFocus) return;
|
||||
(selectedId ? refs.current[selectedId] : newSessionRef.current)?.focus();
|
||||
onFocusRestored?.();
|
||||
}, [restoreFocus, selectedId, onFocusRestored]);
|
||||
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
let next: number | undefined;
|
||||
if (!runtimes.length) return;
|
||||
if (event.key === "ArrowRight") next = (index + 1) % runtimes.length;
|
||||
else if (event.key === "ArrowLeft")
|
||||
next = (index - 1 + runtimes.length) % runtimes.length;
|
||||
else if (event.key === "Home") next = 0;
|
||||
else if (event.key === "End") next = runtimes.length - 1;
|
||||
if (next === undefined) return;
|
||||
event.preventDefault();
|
||||
refs.current[runtimes[next].summary.runtimeId]?.focus();
|
||||
onSelect(runtimes[next].summary.runtimeId);
|
||||
}
|
||||
return (
|
||||
<div className="session-strip">
|
||||
<div className="session-tab-bar" aria-label="Sessions">
|
||||
<div className="session-tabs" role="tablist" aria-label="Open sessions">
|
||||
{runtimes.map((runtime, index) => {
|
||||
const summary = runtime.summary;
|
||||
const selected = summary.runtimeId === selectedId;
|
||||
return (
|
||||
<div
|
||||
className={`session-tab ${selected ? "selected" : ""}`}
|
||||
key={summary.runtimeId}
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
id={`session-tab-${summary.runtimeId}`}
|
||||
aria-controls="session-workspace"
|
||||
aria-selected={selected}
|
||||
tabIndex={selected || (!selectedId && index === 0) ? 0 : -1}
|
||||
ref={(element) => {
|
||||
refs.current[summary.runtimeId] = element;
|
||||
}}
|
||||
onKeyDown={(event) => keydown(event, index)}
|
||||
onClick={() => onSelect(summary.runtimeId)}
|
||||
>
|
||||
<span>{summary.label}</span>
|
||||
<small className={summary.state}>
|
||||
{summary.state}
|
||||
{summary.queueCount
|
||||
? ` · ${summary.queueCount} queued`
|
||||
: ""}
|
||||
</small>
|
||||
{runtime.unread && (
|
||||
<i className="unread-dot" aria-label="Unread activity" />
|
||||
)}
|
||||
{summary.attention && (
|
||||
<b className="attention-dot" aria-label="Needs attention">
|
||||
!
|
||||
</b>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="close-session"
|
||||
aria-label={`Close ${summary.label}`}
|
||||
disabled={closingRuntimeId === summary.runtimeId}
|
||||
onClick={() => onClose(runtime)}
|
||||
>
|
||||
{closingRuntimeId === summary.runtimeId ? "…" : "×"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="session-tab-actions" aria-label="Session actions">
|
||||
<button className="load-session" disabled={busy} onClick={onResume}>
|
||||
<span aria-hidden="true">⌄</span>
|
||||
{busy ? "Working…" : "Load"}
|
||||
</button>
|
||||
<button
|
||||
className="new-session"
|
||||
ref={newSessionRef}
|
||||
disabled={busy}
|
||||
onClick={onNew}
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
{busy ? "Starting…" : "New"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DirectorySidebar } from "./DirectorySidebar";
|
||||
import { SessionTabs } from "./SessionTabs";
|
||||
import { SessionPicker } from "./SessionPicker";
|
||||
import {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
runtimeView,
|
||||
} from "../workspace/reducer";
|
||||
import type { Workspace } from "../workspace/types";
|
||||
const summary = (id: string, state = "idle") => ({
|
||||
runtimeId: id,
|
||||
worktreePath: "/repo",
|
||||
state,
|
||||
label: id,
|
||||
attention: id === "b",
|
||||
queueCount: id === "b" ? 2 : 0,
|
||||
lastActivity: id,
|
||||
openedAt: id,
|
||||
});
|
||||
const workspace: Workspace = {
|
||||
bridgeInstanceId: "e",
|
||||
latestSeq: 0,
|
||||
directories: [
|
||||
{
|
||||
worktreePath: "/repo",
|
||||
isHome: false,
|
||||
openCount: 2,
|
||||
workingCount: 1,
|
||||
attentionCount: 1,
|
||||
recoveringCount: 0,
|
||||
errorCount: 1,
|
||||
runtimes: [summary("a"), summary("b", "streaming")],
|
||||
},
|
||||
],
|
||||
};
|
||||
describe("multi-session navigation", () => {
|
||||
it("renders directory aggregates and accessible keyboard session tabs", async () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
const onSelect = vi.fn();
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<DirectorySidebar
|
||||
state={state}
|
||||
onSelect={vi.fn()}
|
||||
onAdd={vi.fn()}
|
||||
onForget={vi.fn()}
|
||||
/>
|
||||
<SessionTabs
|
||||
runtimes={[
|
||||
runtimeView(summary("a")),
|
||||
runtimeView(summary("b", "streaming")),
|
||||
]}
|
||||
selectedId="a"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
|
||||
await userEvent.click(tabs[0]);
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(onSelect).toHaveBeenCalledWith("b");
|
||||
rerender(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("b", "streaming"))]}
|
||||
selectedId="b"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
||||
});
|
||||
it("restores focus to the selected adjacent tab after close", () => {
|
||||
const onFocusRestored = vi.fn();
|
||||
render(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
|
||||
selectedId="b"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
restoreFocus
|
||||
onFocusRestored={onFocusRestored}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
|
||||
expect(onFocusRestored).toHaveBeenCalled();
|
||||
});
|
||||
it("traps and restores focus for the saved-session picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
const opener = document.createElement("button");
|
||||
document.body.append(opener);
|
||||
opener.focus();
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(
|
||||
<SessionPicker
|
||||
sessions={[
|
||||
{
|
||||
path: "/s",
|
||||
id: "s",
|
||||
cwd: "/repo",
|
||||
modified: new Date().toISOString(),
|
||||
messageCount: 0,
|
||||
},
|
||||
]}
|
||||
onOpen={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
const close = screen.getByRole("button", { name: "Close" });
|
||||
expect(close).toHaveFocus();
|
||||
await user.keyboard("{Tab}");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Untitled session/ }),
|
||||
).toHaveFocus();
|
||||
await user.keyboard("{Tab}");
|
||||
expect(close).toHaveFocus();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(opener).toHaveFocus();
|
||||
document.body.removeChild(opener);
|
||||
});
|
||||
it("routes new, saved-open, and close controls through callbacks", async () => {
|
||||
const onNew = vi.fn(),
|
||||
onResume = vi.fn(),
|
||||
onClose = vi.fn();
|
||||
render(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("a"))]}
|
||||
selectedId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={onClose}
|
||||
onNew={onNew}
|
||||
onResume={onResume}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Load" }));
|
||||
await userEvent.click(screen.getByLabelText("Close a"));
|
||||
expect(onNew).toHaveBeenCalled();
|
||||
expect(onResume).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user