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:
2026-08-25 14:56:42 +02:00
parent 9f2c840ae7
commit 166b065ad4
20 changed files with 2211 additions and 430 deletions
+345 -36
View File
@@ -158,6 +158,18 @@ select:focus-visible {
flex: 0 0 auto;
padding: 4px 8px;
}
.command-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border: 1px solid #a65b67;
border-radius: 5px;
background: #3d252b;
color: #ffd7db;
font-size: 13px;
}
.workspace-layout {
flex: 1;
min-height: 0;
@@ -255,35 +267,46 @@ select:focus-visible {
}
.session-tabs {
min-width: 0;
flex: 1;
flex: 0 1 auto;
display: flex;
overflow-x: auto;
gap: 2px;
gap: 4px;
padding-left: 4px;
}
.session-tab {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) 28px 28px;
align-items: stretch;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid #555;
border-bottom: 0;
border-radius: 7px 7px 0 0;
background: #303136;
}
.session-tab.selected {
border-color: #d88735;
background: #3b3935;
box-shadow: inset 0 2px #f0a347;
}
.session-tab > button[role="tab"] {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 124px;
max-width: 190px;
padding: 6px 8px 5px;
border: 1px solid #555;
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: #303136;
min-width: 112px;
max-width: 176px;
padding: 6px 9px 5px;
border: 0;
border-radius: 0;
background: transparent;
color: #d5d7dc;
text-align: left;
}
.session-tab > button[role="tab"]:hover {
background: #383a40;
}
.session-tab.selected > button[role="tab"] {
border-color: #d88735;
background: #3b3935;
color: #fff5e7;
box-shadow: inset 0 2px #f0a347;
}
.session-tab > button span {
overflow: hidden;
@@ -299,16 +322,71 @@ select:focus-visible {
.session-tab small.recovering {
color: #ffc170;
}
.renew-session,
.close-session {
display: grid;
place-items: center;
width: 28px;
min-width: 0;
align-self: stretch;
border: 1px solid #505257;
border-bottom: 0;
border-left: 0;
border-radius: 0 8px 0 0;
padding: 4px 8px;
background: #303136;
border: 0;
border-radius: 0;
padding: 0;
background: transparent;
color: #b8bbc1;
}
.renew-session {
color: #a9f2df;
}
.renew-session-icon {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.tab-action-spinner {
width: 12px;
height: 12px;
border: 2px solid rgba(169, 242, 223, 0.35);
border-top-color: #a9f2df;
border-radius: 50%;
animation: tab-action-spin 0.8s linear infinite;
}
.close-session {
font-size: 16px;
}
.session-tab.selected .renew-session,
.session-tab.selected .close-session {
color: #fff5e7;
}
.renew-session:hover {
background: #294139;
color: #c8ffef;
}
.close-session:hover {
background: #4a3131;
color: #ffd7db;
}
.new-session-tab {
align-self: stretch;
flex: 0 0 32px;
margin-left: 4px;
padding: 0;
border: 1px solid #d88735;
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: #4a3827;
color: #ffc170;
font-size: 18px;
line-height: 1;
}
.new-session-tab:hover {
background: #5a432d;
color: #ffe0b6;
}
.unread-dot {
width: 7px;
height: 7px;
@@ -321,7 +399,8 @@ select:focus-visible {
.session-tab-actions {
display: flex;
gap: 3px;
padding: 0 0 2px 5px;
margin-left: auto;
padding: 0 0 2px 8px;
}
.session-tab-actions button {
white-space: nowrap;
@@ -336,11 +415,6 @@ select:focus-visible {
background: #4a4540;
color: #ffc170;
}
.session-tab-actions .new-session {
border-color: #d88735;
background: #4a3827;
color: #ffc170;
}
.session-tab-actions span {
margin-right: 5px;
font-size: 15px;
@@ -356,18 +430,68 @@ select:focus-visible {
padding: 7px;
}
.workflow {
position: relative;
flex: 1;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 7px;
}
.session-lock {
position: absolute;
z-index: 18;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: rgba(20, 18, 16, 0.86);
backdrop-filter: blur(3px);
}
.session-lock-card {
display: flex;
align-items: center;
gap: 12px;
width: min(420px, 92%);
padding: 15px 17px;
border: 1px solid #f0a347;
border-radius: 8px;
background: #33271d;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.48);
color: #ffd9aa;
}
.session-lock-card > div {
display: grid;
gap: 3px;
}
.session-lock-card strong {
color: #fff2df;
font-size: 14px;
}
.session-lock-card p {
margin: 0;
font-weight: 700;
}
.session-lock-card small {
color: #d8b892;
}
.session-lock-bars {
flex: 0 0 auto;
transform: scale(1.3);
}
.workflow-main {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 190px;
gap: 5px;
}
.transcript-container {
position: relative;
min-width: 0;
min-height: 0;
}
.transcript {
height: 100%;
}
.transcript,
.todos-pane,
.settings,
@@ -385,6 +509,33 @@ select:focus-visible {
inset 3px 0 #f0a347,
0 0 16px rgba(240, 163, 71, 0.25);
}
.transcript:focus-visible {
outline: 3px solid #f0a347;
outline-offset: -3px;
}
.scroll-to-latest {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid #f0a347;
border-radius: 50%;
background: #3d2d1d;
box-shadow: 0 5px 14px rgba(0, 0, 0, 0.4);
color: #ffd39a;
font-size: 20px;
line-height: 1;
}
.scroll-to-latest:hover {
background: #4b3420;
color: #fff0da;
}
.agent-working {
display: flex;
align-items: center;
@@ -489,30 +640,62 @@ select:focus-visible {
font-size: 13px;
line-height: 1.32;
}
.tool-result summary {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
list-style: none;
.tool-result-visible > strong,
.tool-result-label {
color: #a9f2df;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.tool-result summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 4px 8px;
cursor: pointer;
list-style: none;
}
.tool-result summary::-webkit-details-marker {
display: none;
}
.tool-result summary::after {
grid-column: 2;
grid-row: 1;
content: "▾";
color: #a9f2df;
font-size: 14px;
}
.tool-result-preview {
grid-column: 1 / -1;
display: block;
padding: 5px 6px;
border-left: 2px solid #547d72;
background: #222b29;
color: #c9d8d4;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 11px;
font-weight: 400;
line-height: 1.32;
text-transform: none;
white-space: pre-wrap;
}
.tool-result-hidden-hint {
grid-column: 1 / -1;
color: #91a69f;
font-size: 10px;
font-weight: 600;
text-transform: none;
}
.tool-result[open] summary::after {
content: "▴";
}
.tool-result[open] summary {
margin-bottom: 6px;
}
.tool-result[open] .tool-result-preview,
.tool-result[open] .tool-result-hidden-hint {
display: none;
}
.pending-message {
border-style: dashed;
}
@@ -566,14 +749,16 @@ select:focus-visible {
border-radius: 5px;
background: #303030;
}
.pi-controls label {
.pi-controls label,
.model-control {
display: grid;
grid-template-columns: auto 1fr;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px;
align-items: center;
font-size: 10px;
}
.pi-controls select {
.pi-controls select,
.model-trigger {
min-width: 0;
appearance: none;
color-scheme: dark;
@@ -585,11 +770,13 @@ select:focus-visible {
no-repeat right 7px center;
color: #f2ece4;
}
.pi-controls select:hover {
.pi-controls select:hover,
.model-trigger:hover {
border-color: #d88735;
background-color: #383129;
}
.pi-controls select:focus {
.pi-controls select:focus,
.model-trigger:focus {
border-color: #f0a347;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
}
@@ -601,6 +788,101 @@ select:focus-visible {
background: #4b3420;
color: #fff2df;
}
.model-selector {
position: relative;
min-width: 0;
}
.model-trigger {
width: 100%;
overflow: hidden;
text-align: left;
font-weight: 650;
}
.model-trigger > span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 30;
display: grid;
width: min(400px, calc(100vw - 40px));
max-height: min(420px, calc(100vh - 80px));
gap: 6px;
padding: 8px;
border: 1px solid #70522f;
border-radius: 7px;
background: #292724;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
}
.model-menu input {
width: 100%;
padding: 7px 9px;
border: 1px solid #5b5146;
border-radius: 4px;
background: #1f1e1c;
color: #f2ece4;
}
.model-result-count {
margin: 0;
color: #aaa39a;
font-size: 10px;
}
.model-results {
display: grid;
gap: 7px;
min-height: 0;
overflow-y: auto;
}
.model-provider-group {
display: grid;
gap: 2px;
}
.model-provider-heading {
position: sticky;
top: 0;
z-index: 1;
padding: 4px 5px 3px;
background: #292724;
color: #f0a347;
font-size: 10px;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.model-option {
display: grid;
gap: 1px;
width: 100%;
padding: 6px 7px;
border: 1px solid transparent;
background: #35322e;
color: #f2ece4;
text-align: left;
}
.model-option:hover {
border-color: #8c6338;
background: #44382d;
}
.model-option[aria-selected="true"] {
border-color: #f0a347;
background: #4b3420;
color: #fff2df;
}
.model-option small {
overflow-wrap: anywhere;
color: #aaa39a;
font-size: 9px;
font-weight: 500;
}
.model-empty {
margin: 5px 0;
text-align: center;
}
.composer-progress {
display: flex;
align-items: center;
@@ -654,6 +936,25 @@ select:focus-visible {
display: flex;
gap: 7px;
}
.restart-progress {
display: flex;
align-items: center;
gap: 8px;
width: fit-content;
margin-top: 8px;
padding: 7px 9px;
border: 1px solid #b76f26;
border-radius: 5px;
background: #41301f;
color: #ffd39a;
}
.restart-progress > span:last-child {
display: grid;
gap: 1px;
}
.restart-progress small {
color: #d9b987;
}
.default-worktree-control {
display: grid;
gap: 4px;
@@ -820,6 +1121,9 @@ select:focus-visible {
min-width: 0;
gap: 3px;
}
.session-row-content > strong {
color: #f1f3f4;
}
.session-preview,
.session-metadata {
overflow: hidden;
@@ -883,6 +1187,11 @@ select:focus-visible {
.notification {
padding: 6px;
}
@keyframes tab-action-spin {
to {
transform: rotate(360deg);
}
}
@keyframes work-pulse {
70% {
box-shadow: 0 0 0 7px rgba(240, 163, 71, 0);
+372
View File
@@ -0,0 +1,372 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeSummary, Workspace } from "./workspace/types";
import {
initialWorkspaceState,
reconcileWorkspace,
workspaceReducer,
} from "./workspace/reducer";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
loadSnapshot: vi.fn(),
refresh: vi.fn(),
dispatch: vi.fn(),
useWorkspace: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({ startDragging: vi.fn() }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
vi.mock("./workspace/useWorkspace", () => ({
useWorkspace: mocks.useWorkspace,
}));
import App from "./App";
const summary: RuntimeSummary = {
runtimeId: "runtime-a",
agentId: "agent-a",
worktreePath: "/repo",
state: "idle",
label: "Session A",
attention: false,
queueCount: 0,
lastActivity: "2026-08-24T20:00:00.000Z",
openedAt: "2026-08-24T20:00:00.000Z",
};
const otherSummary: RuntimeSummary = {
...summary,
runtimeId: "runtime-b",
agentId: "agent-b",
label: "Session B",
};
function workspaceState() {
const workspace: Workspace = {
bridgeInstanceId: "epoch",
latestSeq: 0,
directories: [
{
worktreePath: "/repo",
isHome: true,
openCount: 2,
workingCount: 0,
attentionCount: 0,
recoveringCount: 0,
errorCount: 0,
runtimes: [summary, otherSummary],
},
],
};
const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById[summary.runtimeId].models = [
{ provider: "openai", id: "gpt-5", name: "GPT 5" },
];
return state;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe("App command progress and errors", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.refresh.mockResolvedValue(undefined);
mocks.loadSnapshot.mockResolvedValue(true);
mocks.useWorkspace.mockReturnValue({
state: workspaceState(),
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
});
});
it("shows restart progress until the replacement snapshot is restored", async () => {
const user = userEvent.setup();
const restart = deferred<unknown>();
const snapshot = deferred<boolean>();
mocks.invoke.mockImplementation((command: string) =>
command === "restart" ? restart.promise : Promise.resolve(undefined),
);
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
render(<App />);
await user.click(screen.getByRole("button", { name: "Controls" }));
await user.click(screen.getByRole("button", { name: "Restart Pi" }));
const restarting = screen.getByRole("button", { name: "Restarting Pi…" });
expect(restarting).toBeDisabled();
expect(
screen.getByText("Restoring this session and its extensions…"),
).toBeInTheDocument();
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
await user.click(restarting);
expect(mocks.invoke).toHaveBeenCalledTimes(1);
await act(async () => restart.resolve(undefined));
expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled();
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", {
throwOnError: true,
});
await act(async () => snapshot.resolve(true));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Restart Pi" })).toBeEnabled(),
);
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "false");
});
it("renews a background session from its tab without changing selection", async () => {
const user = userEvent.setup();
const renewal = deferred<unknown>();
const snapshot = deferred<boolean>();
mocks.invoke.mockImplementation((command: string) =>
command === "renew_session" ? renewal.promise : Promise.resolve(undefined),
);
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
render(<App />);
await user.click(screen.getByRole("button", { name: "Renew Session B" }));
expect(mocks.invoke).toHaveBeenCalledWith("renew_session", {
agentId: "agent-b",
});
expect(
screen.getByRole("button", { name: "Renewing Session B" }),
).toBeDisabled();
expect(screen.getByRole("tab", { name: /Session A/ })).toHaveAttribute(
"aria-selected",
"true",
);
await act(async () => renewal.resolve(undefined));
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-b", {
throwOnError: true,
});
await act(async () => snapshot.resolve(true));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Renew Session B" }),
).toBeEnabled(),
);
});
it("keeps parallel prompt submission state attached to its session", async () => {
const user = userEvent.setup();
const submissionA = deferred<{ delivery: "prompt" }>();
const submissionB = deferred<{ delivery: "prompt" }>();
let state = workspaceState();
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "runtime-a",
draft: "Prompt A",
});
mocks.dispatch.mockImplementation((action) => {
state = workspaceReducer(state, action);
});
mocks.useWorkspace.mockImplementation(() => ({
state,
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
}));
mocks.invoke.mockImplementation(
(command: string, args?: { agentId?: string }) => {
if (command !== "submit_prompt") return Promise.resolve(undefined);
return args?.agentId === "agent-a"
? submissionA.promise
: submissionB.promise;
},
);
const rendered = render(<App />);
await user.click(screen.getByRole("button", { name: "Send" }));
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-b",
});
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "runtime-b",
draft: "Prompt B",
});
rendered.rerender(<App />);
await user.click(screen.getByRole("button", { name: "Send" }));
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-a",
});
rendered.rerender(<App />);
expect(screen.getByLabelText("Prompt")).toBeDisabled();
expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled();
expect(screen.getByText("Sending your prompt…")).toBeInTheDocument();
expect(screen.getByText("Working", { selector: ".state-pill" })).toHaveClass(
"working",
);
await act(async () => {
submissionA.resolve({ delivery: "prompt" });
submissionB.resolve({ delivery: "prompt" });
});
});
it("keeps prompt failures attached to the session that submitted them", async () => {
const user = userEvent.setup();
const submission = deferred<{ delivery: "prompt" }>();
let state = workspaceState();
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "runtime-a",
draft: "Fail in A",
});
mocks.dispatch.mockImplementation((action) => {
state = workspaceReducer(state, action);
});
mocks.useWorkspace.mockImplementation(() => ({
state,
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
}));
mocks.invoke.mockReturnValue(submission.promise);
const rendered = render(<App />);
await user.click(screen.getByRole("button", { name: "Send" }));
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-b",
});
rendered.rerender(<App />);
await act(async () => submission.reject(new Error("session A failed")));
expect(
screen.queryByText("Pi command failed: Error: session A failed"),
).toBeNull();
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-a",
});
rendered.rerender(<App />);
expect(
screen.getByText("Pi command failed: Error: session A failed"),
).toBeInTheDocument();
});
it("keeps command forms bound to the session that opened them", async () => {
const user = userEvent.setup();
let state = workspaceState();
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "runtime-a",
draft: "/bash",
});
mocks.dispatch.mockImplementation((action) => {
state = workspaceReducer(state, action);
});
mocks.useWorkspace.mockImplementation(() => ({
state,
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
}));
mocks.invoke.mockResolvedValue(undefined);
const rendered = render(<App />);
await user.click(screen.getByRole("button", { name: /\/bash/ }));
expect(screen.getByRole("dialog", { name: "/bash" })).toBeInTheDocument();
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-b",
});
rendered.rerender(<App />);
expect(screen.queryByRole("dialog", { name: "/bash" })).toBeNull();
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-a",
});
rendered.rerender(<App />);
await user.type(screen.getByLabelText("command"), "echo session-a");
await user.click(screen.getByRole("button", { name: "Run command" }));
expect(mocks.invoke).toHaveBeenCalledWith("pi_rpc_command", {
agentId: "agent-a",
command: "bash",
input: { command: "echo session-a" },
});
});
it("locks selected-session input and output throughout renewal", async () => {
const user = userEvent.setup();
const renewal = deferred<unknown>();
const snapshot = deferred<boolean>();
mocks.invoke.mockImplementation((command: string) =>
command === "renew_session" ? renewal.promise : Promise.resolve(undefined),
);
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
render(<App />);
const prompt = screen.getByLabelText("Prompt");
expect(prompt).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Renew Session A" }));
expect(prompt).toBeDisabled();
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-busy", "true");
expect(
screen.getByText("Input and output are temporarily locked."),
).toBeInTheDocument();
expect(
screen
.getByRole("region", { name: "Conversation" })
.closest(".workflow-main"),
).toHaveAttribute("inert");
expect(
screen.getByRole("button", { name: /Model Current model/i }),
).toBeDisabled();
await act(async () => renewal.resolve(undefined));
expect(prompt).toBeDisabled();
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", {
throwOnError: true,
});
await act(async () => snapshot.resolve(true));
await waitFor(() => expect(prompt).toBeEnabled());
expect(
screen.queryByText("Input and output are temporarily locked."),
).toBeNull();
});
it("keeps the model picker open and shows a persistent alert on RPC failure", async () => {
const user = userEvent.setup();
mocks.invoke.mockRejectedValue(new Error("provider rejected the model"));
render(<App />);
await user.click(
screen.getByRole("button", { name: /Model Current model/i }),
);
await user.click(screen.getByRole("option", { name: /GPT 5/ }));
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
agentId: "agent-a",
provider: "openai",
modelId: "gpt-5",
});
const modelError = await screen.findByText(
"Pi model command failed: Error: provider rejected the model",
);
expect(modelError.closest('[role="alert"]')).not.toBeNull();
expect(
screen.getByRole("dialog", { name: "Choose model" }),
).toBeInTheDocument();
expect(mocks.loadSnapshot).not.toHaveBeenCalled();
});
});
+330 -114
View File
@@ -31,15 +31,57 @@ type LocalOperation = {
| "closing"
| "listing"
| "submitting"
| "command";
| "command"
| "restarting"
| "renewing";
detail: string;
runtimeId?: string;
command?: string;
};
type CommandNotice = {
kind: "error";
message: string;
runtimeId: string;
};
const modelRpcCommands = new Set([
"get_available_models",
"set_model",
"cycle_model",
"set_thinking_level",
"cycle_thinking_level",
]);
function requestedCommand(command: string, args: Record<string, unknown>) {
return command === "pi_rpc_command" && typeof args.command === "string"
? args.command
: command;
}
function commandProgress(command: string) {
if (command === "restart") return "Restarting Pi and restoring the session…";
if (command === "renew_session")
return "Clearing session history and starting anew…";
if (command === "get_available_models") return "Loading available Pi models…";
if (command === "set_model") return "Changing Pi model…";
if (command === "cycle_model") return "Cycling Pi model…";
if (command === "set_thinking_level") return "Updating thinking level…";
if (command === "cycle_thinking_level") return "Cycling thinking level…";
return "Updating Pi session…";
}
export default function App() {
const { state, dispatch, refresh, loadSnapshot } = useWorkspace();
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
const [commandForm, setCommandForm] = useState<RpcCommand | undefined>();
const [runtimeStatuses, setRuntimeStatuses] = useState<Record<string, string>>(
{},
);
const [commandForms, setCommandForms] = useState<Record<string, RpcCommand>>(
{},
);
const [commandNotices, setCommandNotices] = useState<
Record<string, CommandNotice>
>({});
const [view, setView] = useState<"conversation" | "settings">("conversation");
const [adding, setAdding] = useState(false);
const [folderPath, setFolderPath] = useState("");
@@ -63,24 +105,50 @@ export default function App() {
state.runtimesById,
],
);
const previousSelected = useRef<string | undefined>(undefined);
const mountedRef = useRef(true);
const operationSequence = useRef(0);
const [operation, setOperation] = useState<LocalOperation | undefined>();
const [abortingRuntimeId, setAbortingRuntimeId] = useState<string>();
const activeAgentCommands = useRef(new Set<string>());
const [operations, setOperations] = useState<Record<number, LocalOperation>>(
{},
);
const [abortingRuntimeIds, setAbortingRuntimeIds] = useState<Set<string>>(
() => new Set(),
);
const [restoreTabFocus, setRestoreTabFocus] = useState(false);
const setRuntimeStatus = (runtimeId: string, detail: string) =>
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
const clearRuntimeStatus = (runtimeId: string) =>
setRuntimeStatuses((current) => {
if (!(runtimeId in current)) return current;
const next = { ...current };
delete next[runtimeId];
return next;
});
const startOperation = (
kind: LocalOperation["kind"],
detail: string,
runtimeId?: string,
command?: string,
) => {
const id = ++operationSequence.current;
setOperation({ id, kind, detail, runtimeId });
setStatus(detail);
return id;
const operation = {
id: ++operationSequence.current,
kind,
detail,
runtimeId,
command,
};
setOperations((current) => ({ ...current, [operation.id]: operation }));
if (runtimeId) clearRuntimeStatus(runtimeId);
else setStatus(detail);
return operation.id;
};
const finishOperation = (id: number) =>
setOperation((current) => (current?.id === id ? undefined : current));
setOperations((current) => {
if (!(id in current)) return current;
const next = { ...current };
delete next[id];
return next;
});
useEffect(() => {
mountedRef.current = true;
@@ -89,34 +157,29 @@ export default function App() {
};
}, []);
const selectedRuntimeId = selected?.summary.runtimeId;
useEffect(() => {
if (!selected || operation) return;
setStatus(
selected.summary.state === "streaming"
? "Pi working"
: (selected.error ?? "Pi ready"),
);
if (previousSelected.current !== selected.summary.runtimeId)
previousSelected.current = selected.summary.runtimeId;
if (!selectedRuntimeId) return;
requestAnimationFrame(() =>
dispatch({ type: "markRead", runtimeId: selected.summary.runtimeId }),
dispatch({ type: "markRead", runtimeId: selectedRuntimeId }),
);
}, [selected, dispatch, operation]);
}, [selectedRuntimeId, dispatch]);
useEffect(() => {
if (
abortingRuntimeId &&
state.runtimesById[abortingRuntimeId]?.summary.state !== "streaming"
)
setAbortingRuntimeId(undefined);
}, [abortingRuntimeId, state.runtimesById]);
setAbortingRuntimeIds((current) => {
const next = new Set(
[...current].filter(
(runtimeId) =>
state.runtimesById[runtimeId]?.summary.state === "streaming",
),
);
return next.size === current.size ? current : next;
});
}, [state.runtimesById]);
async function createRuntime(path = state.selectedDirectoryPath) {
if (!path || !mountedRef.current) return;
const operationId = startOperation(
"creating",
"Starting a new Pi session…",
);
const operationId = startOperation("creating", "Starting a new Pi session…");
try {
const result = await invoke<{ runtime: RuntimeView["summary"] }>(
"create_session_runtime",
@@ -157,7 +220,10 @@ export default function App() {
if (mountedRef.current) setRestoreTabFocus(true);
} catch (error) {
if (mountedRef.current)
setStatus(`Could not close session: ${String(error)}`);
setRuntimeStatus(
runtime.summary.runtimeId,
`Could not close session: ${String(error)}`,
);
} finally {
if (mountedRef.current) finishOperation(operationId);
}
@@ -237,20 +303,50 @@ export default function App() {
const runtimeId = target?.summary.runtimeId,
agentId = target?.summary.agentId;
if (!runtimeId || !agentId) {
setStatus("This session is not currently available");
if (runtimeId)
setRuntimeStatus(runtimeId, "This session is not currently available");
else setStatus("This session is not currently available");
return false;
}
if (activeAgentCommands.current.has(runtimeId)) return false;
const operationCommand = requestedCommand(command, args);
const restarting = operationCommand === "restart";
const renewing = operationCommand === "renew_session";
activeAgentCommands.current.add(runtimeId);
setCommandNotices((current) => {
if (!(runtimeId in current)) return current;
const next = { ...current };
delete next[runtimeId];
return next;
});
const operationId = showOperation
? startOperation("command", "Updating Pi session…", runtimeId)
? startOperation(
renewing ? "renewing" : restarting ? "restarting" : "command",
commandProgress(operationCommand),
runtimeId,
operationCommand,
)
: undefined;
try {
await invoke(command, { agentId, ...args });
if (reload) await loadSnapshot(runtimeId);
if (reload) await loadSnapshot(runtimeId, { throwOnError: true });
return true;
} catch (error) {
setStatus(`Pi command failed: ${String(error)}`);
const subject = modelRpcCommands.has(operationCommand)
? "Pi model command failed"
: renewing
? "Session renewal failed"
: restarting
? "Pi restart failed"
: "Pi command failed";
const message = `${subject}: ${String(error)}`;
setCommandNotices((current) => ({
...current,
[runtimeId]: { kind: "error", message, runtimeId },
}));
return false;
} finally {
activeAgentCommands.current.delete(runtimeId);
if (mountedRef.current && operationId) finishOperation(operationId);
}
}
@@ -279,7 +375,10 @@ export default function App() {
const name = text.match(/^\/name\s+([\s\S]+)$/)?.[1]?.trim();
if (text === "/name" || name) {
if (!name) {
setStatus("Enter a session name after /name");
setRuntimeStatus(
selected.summary.runtimeId,
"Enter a session name after /name",
);
return;
}
await agentCommand("set_session_name", { name });
@@ -334,13 +433,9 @@ export default function App() {
);
const delivery = result.delivery ?? initialDelivery;
dispatch({ type: "submissionSent", runtimeId, id, delivery });
setStatus(
delivery === "follow_up"
? "Follow-up queued · waiting for Pi…"
: "Prompt accepted · waiting for Pi…",
);
clearRuntimeStatus(runtimeId);
} catch (error) {
setStatus(`Pi command failed: ${String(error)}`);
setRuntimeStatus(runtimeId, `Pi command failed: ${String(error)}`);
dispatch({ type: "submissionRemoved", runtimeId, id });
dispatch({ type: "draftChanged", runtimeId, draft: text });
} finally {
@@ -350,22 +445,39 @@ export default function App() {
async function abortSelected() {
const target = selected;
if (!target?.summary.agentId) return;
setAbortingRuntimeId(target.summary.runtimeId);
setStatus("Aborting Pi…");
const runtimeId = target.summary.runtimeId;
setAbortingRuntimeIds((current) => new Set(current).add(runtimeId));
clearRuntimeStatus(runtimeId);
try {
await invoke("abort", { agentId: target.summary.agentId });
} catch (error) {
setStatus(`Pi command failed: ${String(error)}`);
setAbortingRuntimeId(undefined);
setRuntimeStatus(runtimeId, `Pi command failed: ${String(error)}`);
setAbortingRuntimeIds((current) => {
const next = new Set(current);
next.delete(runtimeId);
return next;
});
}
}
async function runRpcCommand(
command: RpcCommand,
input: Record<string, unknown> = {},
target = selected,
) {
if (!selected) return;
await agentCommand("pi_rpc_command", { command: command.command, input });
setCommandForm(undefined);
if (!target) return;
const runtimeId = target.summary.runtimeId;
const succeeded = await agentCommand(
"pi_rpc_command",
{ command: command.command, input },
true,
target,
);
if (succeeded)
setCommandForms((current) => {
const next = { ...current };
delete next[runtimeId];
return next;
});
}
async function respond(response: Record<string, unknown>) {
const target = selected;
@@ -379,11 +491,54 @@ export default function App() {
);
}
const closePicker = useCallback(() => setSessions(undefined), []);
const activeOperations = Object.values(operations);
const globalOperation = activeOperations
.filter((entry) => !entry.runtimeId)
.reduce<LocalOperation | undefined>(
(latest, entry) => (!latest || entry.id > latest.id ? entry : latest),
undefined,
);
const selectedOperations = selectedRuntimeId
? activeOperations.filter((entry) => entry.runtimeId === selectedRuntimeId)
: [];
const selectedOperation = selectedOperations.reduce<
LocalOperation | undefined
>(
(latest, entry) => (!latest || entry.id > latest.id ? entry : latest),
undefined,
);
const pendingSubmissions = selected?.pendingSubmissions ?? [];
const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1];
const isSubmitting =
pendingSubmissions.some((entry) => entry.phase === "sending") ||
selectedOperations.some((entry) => entry.kind === "submitting");
const isRestarting = selectedOperations.some(
(entry) => entry.kind === "restarting",
);
const isRenewing = selectedOperations.some(
(entry) => entry.kind === "renewing",
);
const isAborting = selectedRuntimeId
? abortingRuntimeIds.has(selectedRuntimeId)
: false;
const isSessionLocked = isRestarting || isRenewing;
const visibleCommandForm = selectedRuntimeId
? commandForms[selectedRuntimeId]
: undefined;
const commandOperation = selectedOperations.find(
(entry) => entry.kind === "command",
);
const commandFormPending = Boolean(commandOperation);
const modelCommandPending =
commandFormPending && modelRpcCommands.has(commandOperation?.command ?? "");
const visibleCommandNotice = selectedRuntimeId
? commandNotices[selectedRuntimeId]
: undefined;
const displayedStatus =
operation?.detail ??
state.newSessionLaunch?.detail ??
globalOperation?.detail ??
(isAborting ? "Aborting Pi…" : undefined) ??
selectedOperation?.detail ??
(pendingSubmission
? pendingSubmission.phase === "sending"
? pendingSubmission.delivery === "follow_up"
@@ -393,33 +548,54 @@ export default function App() {
? "Follow-up queued · waiting for Pi…"
: "Prompt accepted · waiting for Pi…"
: undefined) ??
(selectedRuntimeId ? runtimeStatuses[selectedRuntimeId] : undefined) ??
(selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering"
? selected.progress.detail
: undefined) ??
selected?.error ??
selected?.summary.error?.message ??
selected?.progress.detail ??
status;
const isWorking =
state.newSessionLaunch?.phase === "starting" ||
isSessionLocked ||
isSubmitting ||
selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering";
const isStartingSession =
state.newSessionLaunch?.phase === "starting" ||
operation?.kind === "creating";
activeOperations.some((entry) => entry.kind === "creating");
const sessionTabsBusy = activeOperations.some(
(entry) =>
!entry.runtimeId &&
(entry.kind === "creating" ||
entry.kind === "opening" ||
entry.kind === "listing"),
);
const closingRuntimeIds = new Set(
activeOperations
.filter((entry) => entry.kind === "closing" && entry.runtimeId)
.map((entry) => entry.runtimeId as string),
);
const renewingRuntimeIds = new Set(
activeOperations
.filter((entry) => entry.kind === "renewing" && entry.runtimeId)
.map((entry) => entry.runtimeId as string),
);
const sameDirectoryCollision = directory && directory.openCount > 1;
return (
<main
className="app-shell"
style={{ zoom: state.interfaceScale }}
aria-busy={isStartingSession}
aria-busy={isStartingSession || isSessionLocked}
>
<header
className="workflow-header"
onMouseDown={(event) => {
if (
event.button === 0 &&
!(event.target as HTMLElement).closest(
"button,input,select,textarea",
)
!(event.target as HTMLElement).closest("button,input,select,textarea")
)
void getCurrentWindow().startDragging();
}}
@@ -429,15 +605,19 @@ export default function App() {
<span className="brand">PI</span>
<h1>{selected?.summary.label ?? "Pi workspace"}</h1>
{selected && (
<span className={`state-pill ${selected.progress.phase}`}>
{isWorking && (
<span className="working-pulse" aria-hidden="true" />
)}
<span
className={`state-pill ${isWorking ? "working" : selected.progress.phase}`}
>
{isWorking && <span className="working-pulse" aria-hidden="true" />}
{state.newSessionLaunch?.phase === "starting"
? "Starting"
: isWorking
? "Working"
: selected.summary.state}
: isRenewing
? "Renewing"
: isRestarting
? "Restarting"
: isWorking
? "Working"
: selected.summary.state}
</span>
)}
</div>
@@ -447,7 +627,11 @@ export default function App() {
<span className="working-pulse" aria-hidden="true" />{" "}
{state.newSessionLaunch?.phase === "starting"
? "Starting new session"
: "Pi is working"}
: isRenewing
? "Renewing session"
: isRestarting
? "Restarting Pi"
: "Pi is working"}
</span>
)}
<span role="status" aria-live="polite">
@@ -478,8 +662,8 @@ export default function App() {
{sameDirectoryCollision && !state.dismissedCollisionWarning && (
<div className="workspace-warning collision" role="alert">
<span>
Multiple sessions are open in the same directory. Concurrent work
can collide in files and Git; separate worktrees are safer.
Multiple sessions are open in the same directory. Concurrent work can
collide in files and Git; separate worktrees are safer.
</span>
<button
type="button"
@@ -490,6 +674,30 @@ export default function App() {
</button>
</div>
)}
{visibleCommandNotice && !visibleCommandForm && (
<div className="command-error" role="alert">
<span>{visibleCommandNotice.message}</span>
<button
type="button"
className="quiet warning-dismiss"
onClick={() => {
if (!selectedRuntimeId) return;
setCommandNotices((current) => {
const next = { ...current };
delete next[selectedRuntimeId];
return next;
});
}}
>
Dismiss
</button>
</div>
)}
{selected?.modelError && (
<div className="command-error" role="alert">
<span>{selected.modelError}</span>
</div>
)}
{selected?.notifications.map((notice, index) => (
<div
className={`notification ${notice.notifyType ?? "info"}`}
@@ -533,11 +741,7 @@ export default function App() {
Browse…
</button>
<button type="submit">Add</button>
<button
type="button"
className="quiet"
onClick={() => setAdding(false)}
>
<button type="button" className="quiet" onClick={() => setAdding(false)}>
Cancel
</button>
</form>
@@ -545,23 +749,21 @@ export default function App() {
<SessionTabs
runtimes={runtimes}
selectedId={state.selectedRuntimeId}
onSelect={(runtimeId) =>
dispatch({ type: "selectRuntime", runtimeId })
}
onSelect={(runtimeId) => dispatch({ type: "selectRuntime", runtimeId })}
onClose={(runtime) => void closeRuntime(runtime)}
onRenew={(runtime) =>
void agentCommand("renew_session", {}, true, runtime)
}
onNew={() => void createRuntime()}
onResume={() => void showSessions()}
busy={
operation?.kind === "creating" || operation?.kind === "listing"
}
closingRuntimeId={
operation?.kind === "closing" ? operation.runtimeId : undefined
}
busy={sessionTabsBusy}
closingRuntimeIds={closingRuntimeIds}
renewingRuntimeIds={renewingRuntimeIds}
restoreFocus={restoreTabFocus}
onFocusRestored={() => setRestoreTabFocus(false)}
/>
{view === "settings" ? (
<section className="settings">
<section className="settings" aria-busy={isSessionLocked}>
<h2>Session controls</h2>
<label className="default-worktree-control">
Default new-session directory
@@ -582,8 +784,7 @@ export default function App() {
))}
</select>
<small>
Used by Ctrl+Super+Space. Only currently open directories are
listed.
Used by Ctrl+Super+Space. Only currently open directories are listed.
</small>
</label>
{selected ? (
@@ -594,16 +795,29 @@ export default function App() {
{selected.summary.queueCount} queued
</p>
<div className="settings-actions">
<button onClick={() => void agentCommand("retry")}>
<button
disabled={isSessionLocked}
onClick={() => void agentCommand("retry")}
>
Retry
</button>
<button
className="danger"
disabled={isSessionLocked}
onClick={() => void agentCommand("restart")}
>
Restart Pi
{isRestarting ? "Restarting Pi…" : "Restart Pi"}
</button>
</div>
{isRestarting && (
<div className="restart-progress" role="status" aria-live="polite">
<span className="working-pulse" aria-hidden="true" />
<span>
<strong>Restarting Pi</strong>
<small>Restoring this session and its extensions…</small>
</span>
</div>
)}
<label>
Interface scale{" "}
<input
@@ -653,35 +867,32 @@ export default function App() {
);
}}
onModel={(model: Model) =>
void agentCommand("set_model", {
agentCommand("set_model", {
provider: model.provider,
modelId: model.id,
})
}
onThinking={(level) =>
void agentCommand("set_thinking_level", { level })
}
isSubmitting={
operation?.kind === "submitting" &&
operation.runtimeId === selected.summary.runtimeId
}
isAborting={abortingRuntimeId === selected.summary.runtimeId}
onCommand={(command) =>
command.input?.length
? setCommandForm(command)
: void runRpcCommand(command)
}
onThinking={(level) => agentCommand("set_thinking_level", { level })}
isModelUpdating={modelCommandPending}
locked={isSessionLocked}
lockReason={isRenewing ? "renewing" : "restarting"}
isSubmitting={isSubmitting}
isAborting={isAborting}
onCommand={(command) => {
if (command.input?.length)
setCommandForms((current) => ({
...current,
[selected.summary.runtimeId]: command,
}));
else void runRpcCommand(command, {}, selected);
}}
/>
) : (
<section className="empty-workspace">
<h2>No open sessions</h2>
<p>
Start a session in this directory, or add another directory.
</p>
<p>Start a session in this directory, or add another directory.</p>
{state.selectedDirectoryPath && (
<button onClick={() => void createRuntime()}>
New session
</button>
<button onClick={() => void createRuntime()}>New session</button>
)}
</section>
)}
@@ -695,10 +906,20 @@ export default function App() {
/>
)}
<CommandFormDialog
command={commandForm}
onCancel={() => setCommandForm(undefined)}
command={visibleCommandForm}
pending={commandFormPending}
commandError={visibleCommandNotice?.message}
onCancel={() => {
if (!selectedRuntimeId) return;
setCommandForms((current) => {
const next = { ...current };
delete next[selectedRuntimeId];
return next;
});
}}
onSubmit={(input) =>
commandForm && void runRpcCommand(commandForm, input)
visibleCommandForm &&
void runRpcCommand(visibleCommandForm, input, selected)
}
/>
<ExtensionDialog
@@ -722,13 +943,8 @@ export default function App() {
</span>
<div>
<strong id="startup-blocker-title">Starting new session</strong>
<p>
{state.newSessionLaunch?.detail ??
"Creating a fresh Pi runtime…"}
</p>
<small>
Pi is loading its session and extensions. Please wait.
</small>
<p>{state.newSessionLaunch?.detail ?? "Creating a fresh Pi runtime…"}</p>
<small>Pi is loading its session and extensions. Please wait.</small>
</div>
</div>
</div>
+23 -12
View File
@@ -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>
+144 -85
View File
@@ -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>
);
}
+94
View File
@@ -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();
});
});
+234
View File
@@ -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>
);
}
+47 -17
View File
@@ -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>
+122 -21
View File
@@ -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();
});
});
+160 -5
View File
@@ -74,6 +74,126 @@ describe("workspace reducer", () => {
expect(state.runtimesById.b.messages[0].content).toBe("b");
expect(state.runtimesById.b.unread).toBe(true);
});
it("uses the runtime summary as the authoritative snapshot working state", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: { ...runtime("a"), state: "streaming" },
state: { data: { isStreaming: false } },
extensions: [],
},
});
expect(state.runtimesById.a.progress.phase).toBe("working");
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 2,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 2,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 2,
runtime: runtime("a"),
state: { data: { isStreaming: true } },
extensions: [],
},
});
expect(state.runtimesById.a.progress.phase).toBe("idle");
});
it("requests another snapshot when a live event overtakes the current load", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
state = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_event",
data: {
runtimeId: "a",
eventType: "queue",
eventData: { event: { pendingMessageCount: 1 } },
},
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 0,
runtime: runtime("a"),
extensions: [],
},
});
expect(state.runtimesById.a.loading).toBe(false);
expect(state.runtimesById.a.snapshotRetry).toBe(true);
expect(state.runtimesById.a.summary.queueCount).toBe(1);
});
it("preserves known models and exposes model discovery failures", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: runtime("a"),
models: {
data: { models: [{ provider: "openai", id: "gpt-5" }] },
},
extensions: [],
},
});
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 2,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 2,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: runtime("a"),
models: {
data: { models: [] },
error: "RPC timed out",
},
extensions: [],
},
});
expect(state.runtimesById.a.models).toEqual([
{ provider: "openai", id: "gpt-5" },
]);
expect(state.runtimesById.a.modelError).toContain("RPC timed out");
});
it("removes the optimistic submission when Pi returns its structured user message", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
@@ -259,8 +379,8 @@ describe("workspace reducer", () => {
],
};
expect(
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed
.summary.agentId,
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed.summary
.agentId,
).toBeUndefined();
});
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
@@ -318,9 +438,7 @@ describe("workspace reducer", () => {
it("clears stale extension and transcript state for every runtime after an epoch change", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
state.runtimesById.b.extensions = [
{ id: "dead-request", method: "confirm" },
];
state.runtimesById.b.extensions = [{ id: "dead-request", method: "confirm" }];
state.runtimesById.b.unread = true;
const reset = workspaceReducer(state, {
type: "hostEvent",
@@ -340,6 +458,43 @@ describe("workspace reducer", () => {
expect(restored.runtimesById.b.extensions).toEqual([]);
expect(restored.runtimesById.b.unread).toBe(false);
});
it("clears active history and input when a runtime is renewed", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById.a.messages = [
{ role: "user", content: "old conversation" },
];
state.runtimesById.a.pendingSubmissions = [
{
id: 1,
text: "old pending input",
userMessageCount: 1,
phase: "sent",
},
];
state.runtimesById.a.extensions = [
{ id: "old-extension", method: "confirm" },
];
state.runtimesById.a.models = [{ provider: "openai", id: "old-model" }];
state.runtimesById.a.draft = "old draft";
state.runtimesById.a.scroll = { atBottom: false, scrollTop: 88 };
state.runtimesById.a.unread = true;
const renewed = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_renewed",
data: { runtimeId: "a", runtime: runtime("a") },
});
expect(renewed.runtimesById.a.messages).toEqual([]);
expect(renewed.runtimesById.a.pendingSubmissions).toEqual([]);
expect(renewed.runtimesById.a.extensions).toEqual([]);
expect(renewed.runtimesById.a.models).toEqual([]);
expect(renewed.runtimesById.a.draft).toBe("");
expect(renewed.runtimesById.a.scroll).toEqual({ atBottom: true });
expect(renewed.runtimesById.a.unread).toBe(false);
expect(renewed.bridge.status).toBe("resyncing");
});
it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
const next = applyWorkspaceEvent(state, {
+68 -19
View File
@@ -16,6 +16,28 @@ const idleProgress = {
toolCount: 0,
};
function snapshotProgress(runtime: RuntimeSummary) {
if (runtime.state === "streaming")
return {
phase: "working" as const,
detail: "Thinking and preparing a response",
toolCount: 0,
};
if (runtime.state === "recovering")
return {
phase: "recovering" as const,
detail: "Restoring this Pi session",
toolCount: 0,
};
if (runtime.state === "failed")
return {
phase: "error" as const,
detail: runtime.error?.message ?? "This Pi session failed",
toolCount: 0,
};
return idleProgress;
}
function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
@@ -57,6 +79,7 @@ export function runtimeView(
messages: previous?.messages ?? [],
commands: previous?.commands ?? [],
models: previous?.models ?? [],
modelError: previous?.modelError,
extensions: previous?.extensions ?? [],
extensionStatuses: previous?.extensionStatuses ?? {},
extensionWidgets: previous?.extensionWidgets ?? {},
@@ -67,6 +90,7 @@ export function runtimeView(
scroll: previous?.scroll ?? { atBottom: true },
unread: previous?.unread ?? false,
loading: previous?.loading ?? false,
snapshotRetry: previous?.snapshotRetry ?? false,
error: previous?.error,
requestGeneration: previous?.requestGeneration ?? 0,
loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
@@ -84,6 +108,7 @@ function resetRuntimeView(
messages: [],
commands: [],
models: [],
modelError: undefined,
extensions: [],
extensionStatuses: {},
extensionWidgets: {},
@@ -92,6 +117,7 @@ function resetRuntimeView(
unread: false,
progress: idleProgress,
loading: false,
snapshotRetry: false,
error: undefined,
requestGeneration: 0,
loadedThroughSeq: 0,
@@ -147,9 +173,7 @@ export function reconcileWorkspace(
ids,
);
}
const paths = workspace.directories.map(
(directory) => directory.worktreePath,
);
const paths = workspace.directories.map((directory) => directory.worktreePath);
const directoryOrder = ordered(
persisted?.directoryOrder ?? state.directoryOrder,
paths,
@@ -158,9 +182,7 @@ export function reconcileWorkspace(
const selectedRuntimeId =
preferred && runtimesById[preferred]
? preferred
: directoryOrder.flatMap(
(path) => runtimeOrderByDirectory[path] ?? [],
)[0];
: directoryOrder.flatMap((path) => runtimeOrderByDirectory[path] ?? [])[0];
const selectedDirectoryPath = selectedRuntimeId
? runtimesById[selectedRuntimeId]?.summary.worktreePath
: directoryOrder[0];
@@ -261,6 +283,26 @@ export function applyWorkspaceEvent(
},
};
const id = event.data?.runtimeId;
if (
event.type === "runtime_renewed" &&
id &&
state.runtimesById[id] &&
event.data?.runtime
) {
const renewed = resetRuntimeView(event.data.runtime, state.runtimesById[id]);
return {
...state,
bridge: { ...state.bridge, latestSeq: event.seq, status: "resyncing" },
runtimesById: {
...state.runtimesById,
[id]: {
...renewed,
draft: "",
scroll: { atBottom: true },
},
},
};
}
if (
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
event.type ?? "",
@@ -456,8 +498,7 @@ export function workspaceReducer(
return {
...state,
selectedDirectoryPath: action.worktreePath,
selectedRuntimeId:
state.runtimeOrderByDirectory[action.worktreePath]?.[0],
selectedRuntimeId: state.runtimeOrderByDirectory[action.worktreePath]?.[0],
};
if (action.type === "selectRuntime")
return {
@@ -483,20 +524,29 @@ export function workspaceReducer(
const view = { ...state.runtimesById[runtimeId] };
if (action.type === "snapshotRequested") {
view.loading = true;
view.snapshotRetry = false;
view.error = undefined;
view.requestGeneration = action.generation;
} else if (action.type === "snapshotFailed") {
if (view.requestGeneration !== action.generation) return state;
view.loading = false;
view.snapshotRetry = false;
view.error = action.error;
} else if (action.type === "snapshotLoaded") {
if (view.requestGeneration !== action.generation) return state;
if (
view.requestGeneration !== action.generation ||
action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
action.snapshot.latestSeq < view.loadedThroughSeq
)
return state;
) {
view.loading = false;
view.snapshotRetry = true;
return {
...state,
runtimesById: { ...state.runtimesById, [runtimeId]: view },
};
}
view.loading = false;
view.snapshotRetry = false;
view.summary = action.snapshot.runtime;
view.state = action.snapshot.state?.data ?? {};
view.stats = action.snapshot.stats?.data ?? {};
@@ -506,16 +556,15 @@ export function workspaceReducer(
view.messages,
);
view.commands = action.snapshot.commands?.data?.commands ?? [];
view.models = action.snapshot.models?.data?.models ?? [];
if (action.snapshot.models?.error) {
view.modelError = `Could not load available Pi models: ${action.snapshot.models.error}`;
} else {
view.models = action.snapshot.models?.data?.models ?? [];
view.modelError = undefined;
}
view.extensions = action.snapshot.extensions ?? [];
view.loadedThroughSeq = action.snapshot.latestSeq;
view.progress = view.state.isStreaming
? {
phase: "working",
detail: "Thinking and preparing a response",
toolCount: 0,
}
: idleProgress;
view.progress = snapshotProgress(action.snapshot.runtime);
} else if (action.type === "draftChanged") view.draft = action.draft;
else if (action.type === "scrollChanged")
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
+3 -1
View File
@@ -129,7 +129,7 @@ export type RuntimeSnapshot = {
stats?: { data?: SessionStats };
transcript?: { data?: { messages?: Message[] } };
commands?: { data?: { commands?: Command[] } };
models?: { data?: { models?: Model[] } };
models?: { data?: { models?: Model[] }; error?: string };
extensions: Extension[];
};
export type DirectorySession = {
@@ -180,6 +180,7 @@ export type RuntimeView = {
messages: Message[];
commands: Command[];
models: Model[];
modelError?: string;
extensions: Extension[];
extensionStatuses: Record<string, string>;
extensionWidgets: Record<
@@ -193,6 +194,7 @@ export type RuntimeView = {
scroll: { atBottom: boolean; scrollTop?: number };
unread: boolean;
loading: boolean;
snapshotRetry: boolean;
error?: string;
requestGeneration: number;
loadedThroughSeq: number;
+41 -22
View File
@@ -29,28 +29,46 @@ export function useWorkspace() {
const disposed = useRef(true);
const refreshGeneration = useRef(0);
const loadSnapshot = useCallback(async (runtimeId: string) => {
if (disposed.current) return;
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
loadGeneration.current[runtimeId] = generation;
dispatch({ type: "snapshotRequested", runtimeId, generation });
try {
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{ runtimeId },
);
if (!disposed.current)
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
} catch (error) {
if (!disposed.current)
dispatch({
type: "snapshotFailed",
runtimeId,
generation,
error: String(error),
});
}
}, []);
const loadSnapshot = useCallback(
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
if (disposed.current) return false;
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
loadGeneration.current[runtimeId] = generation;
dispatch({ type: "snapshotRequested", runtimeId, generation });
try {
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{ runtimeId },
);
if (!disposed.current)
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
return true;
} catch (error) {
if (!disposed.current)
dispatch({
type: "snapshotFailed",
runtimeId,
generation,
error: String(error),
});
if (options.throwOnError) throw error;
return false;
}
},
[],
);
const snapshotRetryKey = Object.values(state.runtimesById)
.flatMap((runtime) =>
runtime.snapshotRetry ? [runtime.summary.runtimeId] : [],
)
.sort((left, right) => left.localeCompare(right))
.join("\n");
useEffect(() => {
if (!snapshotRetryKey) return;
for (const runtimeId of snapshotRetryKey.split("\n"))
void loadSnapshot(runtimeId);
}, [snapshotRetryKey, loadSnapshot]);
const scheduleSnapshot = useCallback(
(runtimeId: string) => {
@@ -177,6 +195,7 @@ export function useWorkspace() {
"runtime_opened",
"runtime_closed",
"runtime_recovered",
"runtime_renewed",
"runtime_restore_failed",
].includes(eventType ?? "")
) {