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:
@@ -42,6 +42,8 @@ describe("multi-session navigation", () => {
|
||||
it("renders directory aggregates and accessible keyboard session tabs", async () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
const onSelect = vi.fn();
|
||||
const onNew = vi.fn();
|
||||
const onRenew = vi.fn();
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<DirectorySidebar
|
||||
@@ -58,7 +60,8 @@ describe("multi-session navigation", () => {
|
||||
selectedId="a"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onRenew={onRenew}
|
||||
onNew={onNew}
|
||||
onResume={vi.fn()}
|
||||
/>
|
||||
</>,
|
||||
@@ -66,6 +69,15 @@ describe("multi-session navigation", () => {
|
||||
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
|
||||
const closeFirst = screen.getByRole("button", { name: "Close a" });
|
||||
const renewFirst = screen.getByRole("button", { name: "Renew a" });
|
||||
expect(tabs[0].parentElement?.lastElementChild).toBe(closeFirst);
|
||||
expect(closeFirst.previousElementSibling).toBe(renewFirst);
|
||||
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
|
||||
const newSession = screen.getByRole("button", { name: "New session" });
|
||||
expect(tablist.nextElementSibling).toBe(newSession);
|
||||
await userEvent.click(newSession);
|
||||
expect(onNew).toHaveBeenCalledOnce();
|
||||
await userEvent.click(tabs[0]);
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(onSelect).toHaveBeenCalledWith("b");
|
||||
@@ -75,12 +87,31 @@ describe("multi-session navigation", () => {
|
||||
selectedId="b"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onRenew={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
||||
});
|
||||
it("announces close progress only on the closing session", () => {
|
||||
render(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
|
||||
selectedId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRenew={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
closingRuntimeIds={new Set(["b"])}
|
||||
/>,
|
||||
);
|
||||
const closing = screen.getByRole("button", { name: "Closing b" });
|
||||
expect(closing).toBeDisabled();
|
||||
expect(closing).toHaveAttribute("aria-busy", "true");
|
||||
expect(screen.getByRole("button", { name: "Close a" })).toBeEnabled();
|
||||
});
|
||||
it("restores focus to the selected adjacent tab after close", () => {
|
||||
const onFocusRestored = vi.fn();
|
||||
render(
|
||||
@@ -89,6 +120,7 @@ describe("multi-session navigation", () => {
|
||||
selectedId="b"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRenew={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
restoreFocus
|
||||
@@ -98,29 +130,93 @@ describe("multi-session navigation", () => {
|
||||
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
|
||||
expect(onFocusRestored).toHaveBeenCalled();
|
||||
});
|
||||
it("collapses a tool result until its summary is expanded", async () => {
|
||||
it("shows two-line tool results and previews longer collapsed results", async () => {
|
||||
const user = userEvent.setup();
|
||||
const runtime = runtimeView(summary("tool-result"));
|
||||
runtime.messages = [
|
||||
{ role: "toolResult", toolName: "read", content: "Detailed tool output" },
|
||||
{ role: "toolResult", toolName: "read", content: "first\nsecond\n" },
|
||||
];
|
||||
const { container } = render(
|
||||
<ConversationWorkspace
|
||||
runtime={runtime}
|
||||
onDraft={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
onScroll={vi.fn()}
|
||||
onModel={vi.fn()}
|
||||
onThinking={vi.fn()}
|
||||
onCommand={vi.fn()}
|
||||
/>,
|
||||
const props = {
|
||||
onDraft: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onAbort: vi.fn(),
|
||||
onScroll: vi.fn(),
|
||||
onModel: vi.fn().mockResolvedValue(true),
|
||||
onThinking: vi.fn().mockResolvedValue(true),
|
||||
onCommand: vi.fn(),
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<ConversationWorkspace runtime={runtime} {...props} />,
|
||||
);
|
||||
expect(container.querySelector("details")).toBeNull();
|
||||
expect(container.querySelector(".tool-result-visible pre")).toHaveTextContent(
|
||||
"first second",
|
||||
);
|
||||
|
||||
const longRuntime = {
|
||||
...runtime,
|
||||
messages: [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolName: "read",
|
||||
content: "first\nsecond\nthird\n",
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(<ConversationWorkspace runtime={longRuntime} {...props} />);
|
||||
const details = container.querySelector("details");
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(container.querySelector(".tool-result-preview")?.textContent).toBe(
|
||||
"first\nsecond",
|
||||
);
|
||||
expect(container.querySelector(".tool-result-full")?.textContent).toBe(
|
||||
"first\nsecond\nthird\n",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("1 more line hidden · Expand to view"),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByText("Tool result · read"));
|
||||
expect(details).toHaveAttribute("open");
|
||||
});
|
||||
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onScroll = vi.fn();
|
||||
const atBottom = runtimeView(summary("scroll"));
|
||||
const props = {
|
||||
onDraft: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onAbort: vi.fn(),
|
||||
onScroll,
|
||||
onModel: vi.fn().mockResolvedValue(true),
|
||||
onThinking: vi.fn().mockResolvedValue(true),
|
||||
onCommand: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<ConversationWorkspace runtime={atBottom} {...props} />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Scroll to latest message" }),
|
||||
).toBeNull();
|
||||
|
||||
const scrolledUp = {
|
||||
...atBottom,
|
||||
scroll: { atBottom: false, scrollTop: 125 },
|
||||
};
|
||||
rerender(<ConversationWorkspace runtime={scrolledUp} {...props} />);
|
||||
const transcript = screen.getByRole("region", { name: "Conversation" });
|
||||
Object.defineProperty(transcript, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 600,
|
||||
});
|
||||
transcript.scrollTop = 125;
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Scroll to latest message" }),
|
||||
);
|
||||
|
||||
expect(transcript.scrollTop).toBe(600);
|
||||
expect(onScroll).toHaveBeenCalledWith(true, 600);
|
||||
expect(transcript).toHaveFocus();
|
||||
});
|
||||
it("shows an aborting control and labels an accepted follow-up", () => {
|
||||
const runtime = runtimeView(summary("working", "streaming"));
|
||||
runtime.progress = {
|
||||
@@ -191,9 +287,7 @@ describe("multi-session navigation", () => {
|
||||
|
||||
await user.type(search, "release");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("1 saved session");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Planning/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Planning/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull();
|
||||
await user.clear(search);
|
||||
await user.type(search, "INCIDENT-42");
|
||||
@@ -235,8 +329,7 @@ describe("multi-session navigation", () => {
|
||||
extension={{
|
||||
method: "confirm",
|
||||
title: "Run a shell command",
|
||||
message:
|
||||
"rm -rf ./build\nThis removes the generated build directory.",
|
||||
message: "rm -rf ./build\nThis removes the generated build directory.",
|
||||
}}
|
||||
onRespond={onRespond}
|
||||
/>,
|
||||
@@ -264,9 +357,10 @@ describe("multi-session navigation", () => {
|
||||
"Existing request",
|
||||
);
|
||||
});
|
||||
it("routes new, saved-open, and close controls through callbacks", async () => {
|
||||
it("routes new, saved-open, renew, and close controls through callbacks", async () => {
|
||||
const onNew = vi.fn(),
|
||||
onResume = vi.fn(),
|
||||
onRenew = vi.fn(),
|
||||
onClose = vi.fn();
|
||||
render(
|
||||
<SessionTabs
|
||||
@@ -274,15 +368,22 @@ describe("multi-session navigation", () => {
|
||||
selectedId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={onClose}
|
||||
onRenew={onRenew}
|
||||
onNew={onNew}
|
||||
onResume={onResume}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Load" }));
|
||||
await userEvent.click(screen.getByLabelText("Renew a"));
|
||||
await userEvent.click(screen.getByLabelText("Close a"));
|
||||
expect(onNew).toHaveBeenCalled();
|
||||
expect(onResume).toHaveBeenCalled();
|
||||
expect(onRenew).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
summary: expect.objectContaining({ runtimeId: "a" }),
|
||||
}),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user