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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user