feat(status-ui): improve concurrent session workflows
Add in-place session renewal, searchable model selection, clearer progress feedback, tool-result previews, and per-session presentation state. Keep commands, errors, loading, and lifecycle indicators attached to their owning runtime so parallel tabs cannot interfere.
This commit is contained in:
@@ -3,9 +3,8 @@ import type { RpcCommand, RpcField } from "../commands/rpc";
|
||||
|
||||
const controls = (container: HTMLElement | null) =>
|
||||
Array.from(
|
||||
container?.querySelectorAll<HTMLElement>(
|
||||
"button, input, select, textarea",
|
||||
) ?? [],
|
||||
container?.querySelectorAll<HTMLElement>("button, input, select, textarea") ??
|
||||
[],
|
||||
).filter((node) => !node.hasAttribute("disabled"));
|
||||
|
||||
function valueFor(field: RpcField, value: string | boolean): unknown {
|
||||
@@ -19,23 +18,25 @@ export function CommandFormDialog({
|
||||
command,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
pending = false,
|
||||
commandError,
|
||||
}: {
|
||||
command?: RpcCommand;
|
||||
onSubmit: (input: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
pending?: boolean;
|
||||
commandError?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
||||
const [error, setError] = useState<string>();
|
||||
const [validationError, setValidationError] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!command) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValues({});
|
||||
setError(undefined);
|
||||
const frame = requestAnimationFrame(() =>
|
||||
controls(ref.current)[0]?.focus(),
|
||||
);
|
||||
setValidationError(undefined);
|
||||
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
opener.current?.focus();
|
||||
@@ -44,6 +45,7 @@ export function CommandFormDialog({
|
||||
if (!command) return null;
|
||||
const fields = command.input ?? [];
|
||||
const submit = () => {
|
||||
if (pending) return;
|
||||
try {
|
||||
const input: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
@@ -57,7 +59,7 @@ export function CommandFormDialog({
|
||||
}
|
||||
onSubmit(input);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Invalid input");
|
||||
setValidationError(cause instanceof Error ? cause.message : "Invalid input");
|
||||
}
|
||||
};
|
||||
return (
|
||||
@@ -68,6 +70,7 @@ export function CommandFormDialog({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="command-form-title"
|
||||
aria-busy={pending}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -99,6 +102,7 @@ export function CommandFormDialog({
|
||||
aria-label={field.label}
|
||||
type="checkbox"
|
||||
checked={values[field.name] === true}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
@@ -110,6 +114,7 @@ export function CommandFormDialog({
|
||||
<select
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
@@ -126,6 +131,7 @@ export function CommandFormDialog({
|
||||
<textarea
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
@@ -137,6 +143,7 @@ export function CommandFormDialog({
|
||||
<input
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
@@ -147,10 +154,14 @@ export function CommandFormDialog({
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{(validationError || commandError) && (
|
||||
<p role="alert">{validationError || commandError}</p>
|
||||
)}
|
||||
<div className="extension-options">
|
||||
<button onClick={submit}>Run command</button>
|
||||
<button className="quiet" onClick={onCancel}>
|
||||
<button disabled={pending} onClick={submit}>
|
||||
{pending ? "Running…" : "Run command"}
|
||||
</button>
|
||||
<button className="quiet" disabled={pending} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
|
||||
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
|
||||
export function messageText(message: RuntimeView["messages"][number]) {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
@@ -15,21 +16,47 @@ export function messageText(message: RuntimeView["messages"][number]) {
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
function visibleLines(text: string) {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
function TranscriptMessage({
|
||||
message,
|
||||
}: {
|
||||
message: RuntimeView["messages"][number];
|
||||
}) {
|
||||
const role = message.role ?? "system";
|
||||
if (role === "toolResult")
|
||||
if (role === "toolResult") {
|
||||
const text = messageText(message);
|
||||
const lines = visibleLines(text);
|
||||
const label = `Tool result · ${message.toolName ?? "tool"}`;
|
||||
if (lines.length <= 2)
|
||||
return (
|
||||
<article className={`message ${role} tool-result-visible`}>
|
||||
<strong>{label}</strong>
|
||||
<pre>{text}</pre>
|
||||
</article>
|
||||
);
|
||||
return (
|
||||
<article className={`message ${role}`}>
|
||||
<details className="tool-result">
|
||||
<summary>Tool result · {message.toolName ?? "tool"}</summary>
|
||||
<pre>{messageText(message)}</pre>
|
||||
<summary>
|
||||
<span className="tool-result-label">{label}</span>
|
||||
<span className="tool-result-preview">
|
||||
{lines.slice(0, 2).join("\n")}
|
||||
</span>
|
||||
<span className="tool-result-hidden-hint">
|
||||
{lines.length - 2} more {lines.length - 2 === 1 ? "line" : "lines"}{" "}
|
||||
hidden · Expand to view
|
||||
</span>
|
||||
</summary>
|
||||
<pre className="tool-result-full">{text}</pre>
|
||||
</details>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<article className={`message ${role}`}>
|
||||
<strong>{message.role ?? "message"}</strong>
|
||||
@@ -77,6 +104,9 @@ export function ConversationWorkspace({
|
||||
onThinking,
|
||||
isSubmitting = false,
|
||||
isAborting = false,
|
||||
isModelUpdating = false,
|
||||
locked = false,
|
||||
lockReason = "renewing",
|
||||
onCommand,
|
||||
}: {
|
||||
runtime: RuntimeView;
|
||||
@@ -84,90 +114,108 @@ export function ConversationWorkspace({
|
||||
onSubmit: () => void;
|
||||
onAbort: () => void;
|
||||
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
||||
onModel: (model: Model) => void;
|
||||
onThinking: (level: string) => void;
|
||||
onModel: (model: Model) => Promise<boolean>;
|
||||
onThinking: (level: string) => Promise<boolean>;
|
||||
isSubmitting?: boolean;
|
||||
isAborting?: boolean;
|
||||
isModelUpdating?: boolean;
|
||||
locked?: boolean;
|
||||
lockReason?: "renewing" | "restarting";
|
||||
onCommand: (command: RpcCommand) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
||||
const runtimeIsWorking =
|
||||
runtime.summary.state === "streaming" ||
|
||||
runtime.progress.phase === "working";
|
||||
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
|
||||
const isWorking =
|
||||
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
|
||||
useEffect(() => {
|
||||
if (runtime.scroll.atBottom && ref.current)
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
|
||||
const scrollToLatest = () => {
|
||||
const transcript = ref.current;
|
||||
if (!transcript) return;
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
onScroll(true, transcript.scrollTop);
|
||||
transcript.focus();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="workflow"
|
||||
id="session-workspace"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
|
||||
aria-busy={locked}
|
||||
>
|
||||
<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}
|
||||
<div className="workflow-main" inert={locked ? true : undefined}>
|
||||
<div className="transcript-container">
|
||||
<section
|
||||
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
|
||||
aria-label="Conversation"
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
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>
|
||||
{runtime.progress.tool && (
|
||||
<small>Using {runtime.progress.tool}</small>
|
||||
)}
|
||||
<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>
|
||||
</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) => (
|
||||
<TranscriptMessage key={index} message={message} />
|
||||
))}
|
||||
{runtime.pendingSubmissions.map((pending) => (
|
||||
<article
|
||||
className={`message user pending-message ${pending.phase}`}
|
||||
key={pending.id}
|
||||
)}
|
||||
{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) => (
|
||||
<TranscriptMessage key={index} message={message} />
|
||||
))}
|
||||
{runtime.pendingSubmissions.map((pending) => (
|
||||
<article
|
||||
className={`message user pending-message ${pending.phase}`}
|
||||
key={pending.id}
|
||||
>
|
||||
<strong>
|
||||
You <span>{pendingStatus(pending)}</span>
|
||||
</strong>
|
||||
<pre>{pending.text}</pre>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
{!runtime.scroll.atBottom && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-to-latest"
|
||||
aria-label="Scroll to latest message"
|
||||
title="Scroll to latest message"
|
||||
onClick={scrollToLatest}
|
||||
>
|
||||
<strong>
|
||||
You <span>{pendingStatus(pending)}</span>
|
||||
</strong>
|
||||
<pre>{pending.text}</pre>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<span aria-hidden="true">↓</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<aside className="todos-pane" aria-label="Current todos">
|
||||
<div className="todos-heading">
|
||||
<strong>Todos</strong>
|
||||
<span>
|
||||
{tasks.filter((task) => task.status !== "completed").length}
|
||||
</span>
|
||||
<span>{tasks.filter((task) => task.status !== "completed").length}</span>
|
||||
</div>
|
||||
{tasks.length ? (
|
||||
<ol className="todo-list">
|
||||
@@ -186,7 +234,7 @@ export function ConversationWorkspace({
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<footer className="workflow-footer">
|
||||
<footer className="workflow-footer" inert={locked ? true : undefined}>
|
||||
{Object.values(runtime.extensionWidgets)
|
||||
.flatMap((widget) => widget.lines)
|
||||
.map((line, index) => (
|
||||
@@ -197,32 +245,20 @@ export function ConversationWorkspace({
|
||||
<section
|
||||
className="pi-controls"
|
||||
aria-label="Model and thinking controls"
|
||||
aria-busy={isModelUpdating || locked}
|
||||
>
|
||||
<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>
|
||||
<ModelSelector
|
||||
models={runtime.models}
|
||||
current={runtime.state.model}
|
||||
onSelect={onModel}
|
||||
pending={isModelUpdating || locked}
|
||||
/>
|
||||
<label>
|
||||
Thinking
|
||||
<select
|
||||
value={runtime.state.thinkingLevel ?? "medium"}
|
||||
onChange={(event) => onThinking(event.currentTarget.value)}
|
||||
disabled={isModelUpdating || locked}
|
||||
onChange={(event) => void onThinking(event.currentTarget.value)}
|
||||
>
|
||||
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
|
||||
(level) => (
|
||||
@@ -252,7 +288,7 @@ export function ConversationWorkspace({
|
||||
<textarea
|
||||
aria-label="Prompt"
|
||||
value={runtime.draft}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || locked}
|
||||
onChange={(event) => onDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
@@ -267,7 +303,7 @@ export function ConversationWorkspace({
|
||||
<button
|
||||
type="button"
|
||||
className="quiet"
|
||||
disabled={isAborting}
|
||||
disabled={isAborting || locked}
|
||||
onClick={onAbort}
|
||||
>
|
||||
{isAborting ? "Aborting…" : "Abort"}
|
||||
@@ -276,6 +312,7 @@ export function ConversationWorkspace({
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
locked ||
|
||||
isSubmitting ||
|
||||
!runtime.summary.agentId ||
|
||||
!runtime.draft.trim()
|
||||
@@ -287,6 +324,28 @@ export function ConversationWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{locked && (
|
||||
<div className="session-lock" role="status" aria-live="polite">
|
||||
<div className="session-lock-card">
|
||||
<span className="working-bars session-lock-bars" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<div>
|
||||
<strong>
|
||||
{lockReason === "renewing" ? "Renewing session" : "Restarting Pi"}
|
||||
</strong>
|
||||
<p>
|
||||
{lockReason === "renewing"
|
||||
? "Clearing active history and starting a fresh Pi session…"
|
||||
: "Restoring this Pi session and its extensions…"}
|
||||
</p>
|
||||
<small>Input and output are temporarily locked.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Model } from "../workspace/types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
|
||||
const models: Model[] = [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4", name: "Claude Sonnet" },
|
||||
{ provider: "openai", id: "organization/gpt-5", name: "GPT 5" },
|
||||
{ provider: "openai", id: "o3" },
|
||||
{ provider: "google", name: "Missing ID" },
|
||||
];
|
||||
|
||||
describe("ModelSelector", () => {
|
||||
it("groups valid models by provider and marks the current model", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
current={{ provider: "anthropic", id: "claude-sonnet-4" }}
|
||||
onSelect={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /anthropic · Claude Sonnet/i }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("group", { name: "anthropic" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "openai" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("group", { name: "google" })).toBeNull();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("3 models");
|
||||
expect(screen.getByRole("option", { name: /Claude Sonnet/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("searches provider, name, and ID and preserves IDs containing slashes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn().mockResolvedValue(true);
|
||||
render(<ModelSelector models={models} onSelect={onSelect} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Current model/i }));
|
||||
const search = screen.getByRole("searchbox", { name: "Search models" });
|
||||
await user.type(search, "OPENAI");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("2 models");
|
||||
expect(screen.queryByRole("group", { name: "anthropic" })).toBeNull();
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "gpt 5");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("1 model");
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "ORGANIZATION/GPT-5");
|
||||
await user.click(screen.getByRole("option", { name: /GPT 5/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(models[1]);
|
||||
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an empty state and closes on Escape with focus restored", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ModelSelector models={models} onSelect={vi.fn().mockResolvedValue(true)} />,
|
||||
);
|
||||
const trigger = screen.getByRole("button", { name: /Current model/i });
|
||||
await user.click(trigger);
|
||||
const search = screen.getByRole("searchbox", { name: "Search models" });
|
||||
await waitFor(() => expect(search).toHaveFocus());
|
||||
await user.type(search, "missing-model");
|
||||
expect(screen.getByText(/No models match/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("0 models");
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it("keeps the selector open and restores controls when an update fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn().mockResolvedValue(false);
|
||||
render(<ModelSelector models={models} onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: /Current model/i }));
|
||||
await user.click(screen.getByRole("option", { name: /Claude Sonnet/ }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(models[0]);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Choose model" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("searchbox", { name: "Search models" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { AgentState, Model } from "../workspace/types";
|
||||
|
||||
type SelectableModel = Model & { provider: string; id: string };
|
||||
type ModelGroup = { provider: string; models: SelectableModel[] };
|
||||
|
||||
function isSelectableModel(model: Model): model is SelectableModel {
|
||||
return Boolean(model.provider?.trim() && model.id?.trim());
|
||||
}
|
||||
|
||||
function modelName(model: SelectableModel) {
|
||||
return model.name?.trim() || model.id;
|
||||
}
|
||||
|
||||
function modelMatches(model: SelectableModel, query: string) {
|
||||
if (!query) return true;
|
||||
return [model.provider, model.id, model.name]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.some((value) => value.toLocaleLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function groupModels(models: SelectableModel[], query: string): ModelGroup[] {
|
||||
const byProvider = new Map<string, SelectableModel[]>();
|
||||
for (const model of models) {
|
||||
if (!modelMatches(model, query)) continue;
|
||||
const providerModels = byProvider.get(model.provider) ?? [];
|
||||
providerModels.push(model);
|
||||
byProvider.set(model.provider, providerModels);
|
||||
}
|
||||
return [...byProvider.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([provider, providerModels]) => ({
|
||||
provider,
|
||||
models: providerModels.sort((left, right) =>
|
||||
modelName(left).localeCompare(modelName(right)),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function ProviderGroup({
|
||||
group,
|
||||
current,
|
||||
headingId,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
group: ModelGroup;
|
||||
current?: AgentState["model"];
|
||||
headingId: string;
|
||||
onSelect: (model: SelectableModel) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="model-provider-group"
|
||||
role="group"
|
||||
aria-labelledby={headingId}
|
||||
>
|
||||
<div className="model-provider-heading" id={headingId}>
|
||||
{group.provider}
|
||||
</div>
|
||||
{group.models.map((model) => {
|
||||
const name = modelName(model);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="model-option"
|
||||
role="option"
|
||||
aria-selected={
|
||||
model.provider === current?.provider && model.id === current?.id
|
||||
}
|
||||
key={`${model.provider}/${model.id}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(model)}
|
||||
>
|
||||
<span>{name}</span>
|
||||
{name !== model.id && <small>{model.id}</small>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
current,
|
||||
onSelect,
|
||||
pending = false,
|
||||
}: {
|
||||
models: Model[];
|
||||
current?: AgentState["model"];
|
||||
onSelect: (model: Model) => Promise<boolean>;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const labelId = useId();
|
||||
const valueId = useId();
|
||||
const menuId = useId();
|
||||
const availableModels = useMemo(
|
||||
() => models.filter(isSelectableModel),
|
||||
[models],
|
||||
);
|
||||
const groups = useMemo(
|
||||
() => groupModels(availableModels, query.trim().toLocaleLowerCase()),
|
||||
[availableModels, query],
|
||||
);
|
||||
const resultCount = groups.reduce(
|
||||
(count, group) => count + group.models.length,
|
||||
0,
|
||||
);
|
||||
const selectedModel = availableModels.find(
|
||||
(model) => model.provider === current?.provider && model.id === current?.id,
|
||||
);
|
||||
const selectedName = selectedModel
|
||||
? modelName(selectedModel)
|
||||
: current?.name?.trim() || current?.id;
|
||||
const selectedLabel = current?.provider
|
||||
? `${current.provider} · ${selectedName ?? "Current model"}`
|
||||
: "Current model";
|
||||
const busy = pending || selecting;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const focusFrame = window.requestAnimationFrame(() =>
|
||||
searchRef.current?.focus(),
|
||||
);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
};
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
window.addEventListener("pointerdown", closeOutside);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(focusFrame);
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
window.removeEventListener("pointerdown", closeOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const chooseModel = async (model: SelectableModel) => {
|
||||
if (busy) return;
|
||||
setSelecting(true);
|
||||
try {
|
||||
if (!(await onSelect(model))) return;
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
} finally {
|
||||
setSelecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="model-control" aria-busy={busy}>
|
||||
<span id={labelId}>Model</span>
|
||||
<div className="model-selector" ref={rootRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="model-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
disabled={availableModels.length === 0 || busy}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
<span id={valueId}>{selectedLabel}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
id={menuId}
|
||||
className="model-menu"
|
||||
role="dialog"
|
||||
aria-label="Choose model"
|
||||
>
|
||||
<label className="sr-only" htmlFor={`${menuId}-search`}>
|
||||
Search models
|
||||
</label>
|
||||
<input
|
||||
ref={searchRef}
|
||||
id={`${menuId}-search`}
|
||||
type="search"
|
||||
value={query}
|
||||
disabled={busy}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder="Search provider, name, or model ID"
|
||||
aria-controls={`${menuId}-results`}
|
||||
/>
|
||||
<p className="model-result-count" role="status" aria-live="polite">
|
||||
{busy
|
||||
? "Updating Pi model…"
|
||||
: `${resultCount} model${resultCount === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
{groups.length ? (
|
||||
<div
|
||||
id={`${menuId}-results`}
|
||||
className="model-results"
|
||||
role="listbox"
|
||||
aria-label="Available models"
|
||||
>
|
||||
{groups.map((group, index) => (
|
||||
<ProviderGroup
|
||||
key={group.provider}
|
||||
group={group}
|
||||
current={current}
|
||||
headingId={`${menuId}-provider-${index}`}
|
||||
onSelect={(model) => void chooseModel(model)}
|
||||
disabled={busy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="model-empty muted">No models match “{query.trim()}”.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,12 @@ type Props = {
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: (runtime: RuntimeView) => void;
|
||||
onRenew: (runtime: RuntimeView) => void;
|
||||
onNew: () => void;
|
||||
onResume: () => void;
|
||||
busy?: boolean;
|
||||
closingRuntimeId?: string;
|
||||
closingRuntimeIds?: ReadonlySet<string>;
|
||||
renewingRuntimeIds?: ReadonlySet<string>;
|
||||
restoreFocus?: boolean;
|
||||
onFocusRestored?: () => void;
|
||||
};
|
||||
@@ -18,10 +20,12 @@ export function SessionTabs({
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onRenew,
|
||||
onNew,
|
||||
onResume,
|
||||
busy,
|
||||
closingRuntimeId,
|
||||
closingRuntimeIds = new Set(),
|
||||
renewingRuntimeIds = new Set(),
|
||||
restoreFocus,
|
||||
onFocusRestored,
|
||||
}: Props) {
|
||||
@@ -52,6 +56,8 @@ export function SessionTabs({
|
||||
{runtimes.map((runtime, index) => {
|
||||
const summary = runtime.summary;
|
||||
const selected = summary.runtimeId === selectedId;
|
||||
const renewing = renewingRuntimeIds.has(summary.runtimeId);
|
||||
const closing = closingRuntimeIds.has(summary.runtimeId);
|
||||
return (
|
||||
<div
|
||||
className={`session-tab ${selected ? "selected" : ""}`}
|
||||
@@ -72,9 +78,7 @@ export function SessionTabs({
|
||||
<span>{summary.label}</span>
|
||||
<small className={summary.state}>
|
||||
{summary.state}
|
||||
{summary.queueCount
|
||||
? ` · ${summary.queueCount} queued`
|
||||
: ""}
|
||||
{summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
|
||||
</small>
|
||||
{runtime.unread && (
|
||||
<i className="unread-dot" aria-label="Unread activity" />
|
||||
@@ -85,32 +89,58 @@ export function SessionTabs({
|
||||
</b>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="renew-session"
|
||||
aria-label={
|
||||
renewing ? `Renewing ${summary.label}` : `Renew ${summary.label}`
|
||||
}
|
||||
aria-busy={renewing}
|
||||
title={`Renew ${summary.label} and clear active history`}
|
||||
disabled={renewing || closing}
|
||||
onClick={() => onRenew(runtime)}
|
||||
>
|
||||
{renewing ? (
|
||||
<span className="tab-action-spinner" aria-hidden="true" />
|
||||
) : (
|
||||
<svg
|
||||
className="renew-session-icon"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 11a8 8 0 1 1-2.34-5.66M20 4v7h-7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="close-session"
|
||||
aria-label={`Close ${summary.label}`}
|
||||
disabled={closingRuntimeId === summary.runtimeId}
|
||||
aria-label={
|
||||
closing ? `Closing ${summary.label}` : `Close ${summary.label}`
|
||||
}
|
||||
aria-busy={closing}
|
||||
disabled={renewing || closing}
|
||||
onClick={() => onClose(runtime)}
|
||||
>
|
||||
{closingRuntimeId === summary.runtimeId ? "…" : "×"}
|
||||
{closing ? "…" : "×"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="new-session-tab"
|
||||
ref={newSessionRef}
|
||||
aria-label={busy ? "Starting new session" : "New session"}
|
||||
title="New session"
|
||||
disabled={busy}
|
||||
onClick={onNew}
|
||||
>
|
||||
<span aria-hidden="true">{busy ? "…" : "+"}</span>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -42,6 +42,8 @@ describe("multi-session navigation", () => {
|
||||
it("renders directory aggregates and accessible keyboard session tabs", async () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
const onSelect = vi.fn();
|
||||
const onNew = vi.fn();
|
||||
const onRenew = vi.fn();
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<DirectorySidebar
|
||||
@@ -58,7 +60,8 @@ describe("multi-session navigation", () => {
|
||||
selectedId="a"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onRenew={onRenew}
|
||||
onNew={onNew}
|
||||
onResume={vi.fn()}
|
||||
/>
|
||||
</>,
|
||||
@@ -66,6 +69,15 @@ describe("multi-session navigation", () => {
|
||||
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
|
||||
const closeFirst = screen.getByRole("button", { name: "Close a" });
|
||||
const renewFirst = screen.getByRole("button", { name: "Renew a" });
|
||||
expect(tabs[0].parentElement?.lastElementChild).toBe(closeFirst);
|
||||
expect(closeFirst.previousElementSibling).toBe(renewFirst);
|
||||
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
|
||||
const newSession = screen.getByRole("button", { name: "New session" });
|
||||
expect(tablist.nextElementSibling).toBe(newSession);
|
||||
await userEvent.click(newSession);
|
||||
expect(onNew).toHaveBeenCalledOnce();
|
||||
await userEvent.click(tabs[0]);
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(onSelect).toHaveBeenCalledWith("b");
|
||||
@@ -75,12 +87,31 @@ describe("multi-session navigation", () => {
|
||||
selectedId="b"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onRenew={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
||||
});
|
||||
it("announces close progress only on the closing session", () => {
|
||||
render(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
|
||||
selectedId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRenew={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
closingRuntimeIds={new Set(["b"])}
|
||||
/>,
|
||||
);
|
||||
const closing = screen.getByRole("button", { name: "Closing b" });
|
||||
expect(closing).toBeDisabled();
|
||||
expect(closing).toHaveAttribute("aria-busy", "true");
|
||||
expect(screen.getByRole("button", { name: "Close a" })).toBeEnabled();
|
||||
});
|
||||
it("restores focus to the selected adjacent tab after close", () => {
|
||||
const onFocusRestored = vi.fn();
|
||||
render(
|
||||
@@ -89,6 +120,7 @@ describe("multi-session navigation", () => {
|
||||
selectedId="b"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRenew={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
restoreFocus
|
||||
@@ -98,29 +130,93 @@ describe("multi-session navigation", () => {
|
||||
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
|
||||
expect(onFocusRestored).toHaveBeenCalled();
|
||||
});
|
||||
it("collapses a tool result until its summary is expanded", async () => {
|
||||
it("shows two-line tool results and previews longer collapsed results", async () => {
|
||||
const user = userEvent.setup();
|
||||
const runtime = runtimeView(summary("tool-result"));
|
||||
runtime.messages = [
|
||||
{ role: "toolResult", toolName: "read", content: "Detailed tool output" },
|
||||
{ role: "toolResult", toolName: "read", content: "first\nsecond\n" },
|
||||
];
|
||||
const { container } = render(
|
||||
<ConversationWorkspace
|
||||
runtime={runtime}
|
||||
onDraft={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
onScroll={vi.fn()}
|
||||
onModel={vi.fn()}
|
||||
onThinking={vi.fn()}
|
||||
onCommand={vi.fn()}
|
||||
/>,
|
||||
const props = {
|
||||
onDraft: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onAbort: vi.fn(),
|
||||
onScroll: vi.fn(),
|
||||
onModel: vi.fn().mockResolvedValue(true),
|
||||
onThinking: vi.fn().mockResolvedValue(true),
|
||||
onCommand: vi.fn(),
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<ConversationWorkspace runtime={runtime} {...props} />,
|
||||
);
|
||||
expect(container.querySelector("details")).toBeNull();
|
||||
expect(container.querySelector(".tool-result-visible pre")).toHaveTextContent(
|
||||
"first second",
|
||||
);
|
||||
|
||||
const longRuntime = {
|
||||
...runtime,
|
||||
messages: [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolName: "read",
|
||||
content: "first\nsecond\nthird\n",
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(<ConversationWorkspace runtime={longRuntime} {...props} />);
|
||||
const details = container.querySelector("details");
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(container.querySelector(".tool-result-preview")?.textContent).toBe(
|
||||
"first\nsecond",
|
||||
);
|
||||
expect(container.querySelector(".tool-result-full")?.textContent).toBe(
|
||||
"first\nsecond\nthird\n",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("1 more line hidden · Expand to view"),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByText("Tool result · read"));
|
||||
expect(details).toHaveAttribute("open");
|
||||
});
|
||||
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onScroll = vi.fn();
|
||||
const atBottom = runtimeView(summary("scroll"));
|
||||
const props = {
|
||||
onDraft: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onAbort: vi.fn(),
|
||||
onScroll,
|
||||
onModel: vi.fn().mockResolvedValue(true),
|
||||
onThinking: vi.fn().mockResolvedValue(true),
|
||||
onCommand: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<ConversationWorkspace runtime={atBottom} {...props} />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Scroll to latest message" }),
|
||||
).toBeNull();
|
||||
|
||||
const scrolledUp = {
|
||||
...atBottom,
|
||||
scroll: { atBottom: false, scrollTop: 125 },
|
||||
};
|
||||
rerender(<ConversationWorkspace runtime={scrolledUp} {...props} />);
|
||||
const transcript = screen.getByRole("region", { name: "Conversation" });
|
||||
Object.defineProperty(transcript, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 600,
|
||||
});
|
||||
transcript.scrollTop = 125;
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Scroll to latest message" }),
|
||||
);
|
||||
|
||||
expect(transcript.scrollTop).toBe(600);
|
||||
expect(onScroll).toHaveBeenCalledWith(true, 600);
|
||||
expect(transcript).toHaveFocus();
|
||||
});
|
||||
it("shows an aborting control and labels an accepted follow-up", () => {
|
||||
const runtime = runtimeView(summary("working", "streaming"));
|
||||
runtime.progress = {
|
||||
@@ -191,9 +287,7 @@ describe("multi-session navigation", () => {
|
||||
|
||||
await user.type(search, "release");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("1 saved session");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Planning/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Planning/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull();
|
||||
await user.clear(search);
|
||||
await user.type(search, "INCIDENT-42");
|
||||
@@ -235,8 +329,7 @@ describe("multi-session navigation", () => {
|
||||
extension={{
|
||||
method: "confirm",
|
||||
title: "Run a shell command",
|
||||
message:
|
||||
"rm -rf ./build\nThis removes the generated build directory.",
|
||||
message: "rm -rf ./build\nThis removes the generated build directory.",
|
||||
}}
|
||||
onRespond={onRespond}
|
||||
/>,
|
||||
@@ -264,9 +357,10 @@ describe("multi-session navigation", () => {
|
||||
"Existing request",
|
||||
);
|
||||
});
|
||||
it("routes new, saved-open, and close controls through callbacks", async () => {
|
||||
it("routes new, saved-open, renew, and close controls through callbacks", async () => {
|
||||
const onNew = vi.fn(),
|
||||
onResume = vi.fn(),
|
||||
onRenew = vi.fn(),
|
||||
onClose = vi.fn();
|
||||
render(
|
||||
<SessionTabs
|
||||
@@ -274,15 +368,22 @@ describe("multi-session navigation", () => {
|
||||
selectedId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={onClose}
|
||||
onRenew={onRenew}
|
||||
onNew={onNew}
|
||||
onResume={onResume}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Load" }));
|
||||
await userEvent.click(screen.getByLabelText("Renew a"));
|
||||
await userEvent.click(screen.getByLabelText("Close a"));
|
||||
expect(onNew).toHaveBeenCalled();
|
||||
expect(onResume).toHaveBeenCalled();
|
||||
expect(onRenew).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
summary: expect.objectContaining({ runtimeId: "a" }),
|
||||
}),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user