feat(ui): streamline multi-session workflows
Keep working status at the transcript tail, replace the tab strip with accessible overflow navigation, focus the prompt on activation and new sessions, and add session keyboard shortcuts.
This commit is contained in:
@@ -75,7 +75,13 @@ describe("multi-session navigation", () => {
|
||||
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);
|
||||
expect(tablist.previousElementSibling).toHaveAccessibleName(
|
||||
"Scroll sessions left",
|
||||
);
|
||||
expect(tablist.nextElementSibling).toHaveAccessibleName(
|
||||
"Scroll sessions right",
|
||||
);
|
||||
expect(newSession.closest(".session-tab-actions")).not.toBeNull();
|
||||
await userEvent.click(newSession);
|
||||
expect(onNew).toHaveBeenCalledOnce();
|
||||
await userEvent.click(tabs[0]);
|
||||
@@ -94,6 +100,61 @@ describe("multi-session navigation", () => {
|
||||
);
|
||||
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
||||
});
|
||||
it("scrolls overflowing sessions and reveals the selected tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const runtimes = ["a", "b", "c", "d", "e"].map((id) =>
|
||||
runtimeView(summary(id)),
|
||||
);
|
||||
const props = {
|
||||
runtimes,
|
||||
onSelect: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onRenew: vi.fn(),
|
||||
onNew: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<SessionTabs {...props} selectedId="a" />);
|
||||
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
|
||||
const scrollBy = vi.fn();
|
||||
Object.defineProperties(tablist, {
|
||||
scrollWidth: { configurable: true, value: 800 },
|
||||
clientWidth: { configurable: true, value: 240 },
|
||||
scrollLeft: { configurable: true, value: 0, writable: true },
|
||||
scrollBy: { configurable: true, value: scrollBy },
|
||||
});
|
||||
fireEvent.scroll(tablist);
|
||||
|
||||
const previous = screen.getByRole("button", {
|
||||
name: "Scroll sessions left",
|
||||
});
|
||||
const next = screen.getByRole("button", { name: "Scroll sessions right" });
|
||||
expect(previous).toBeDisabled();
|
||||
expect(next).toBeEnabled();
|
||||
await user.click(next);
|
||||
expect(scrollBy).toHaveBeenCalledWith({ behavior: "smooth", left: 180 });
|
||||
|
||||
(tablist as HTMLDivElement).scrollLeft = 300;
|
||||
fireEvent.wheel(tablist, { deltaX: 0, deltaY: -80 });
|
||||
expect((tablist as HTMLDivElement).scrollLeft).toBe(220);
|
||||
|
||||
(tablist as HTMLDivElement).scrollLeft = 560;
|
||||
fireEvent.scroll(tablist);
|
||||
expect(previous).toBeEnabled();
|
||||
expect(next).toBeDisabled();
|
||||
|
||||
const finalTab = screen.getByRole("tab", { name: /^e idle$/ });
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(finalTab, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
rerender(<SessionTabs {...props} selectedId="e" />);
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
});
|
||||
it("announces close progress only on the closing session", () => {
|
||||
render(
|
||||
<SessionTabs
|
||||
@@ -260,6 +321,47 @@ describe("multi-session navigation", () => {
|
||||
expect(modelControls.nextElementSibling).toBe(status);
|
||||
expect(status.nextElementSibling).toBe(composer);
|
||||
});
|
||||
it("keeps working activity at the transcript tail and follows it from the bottom", () => {
|
||||
const idle = runtimeView(summary("activity"));
|
||||
idle.messages = [{ role: "assistant", content: "Partial response" }];
|
||||
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 { rerender } = render(
|
||||
<ConversationWorkspace runtime={idle} {...props} />,
|
||||
);
|
||||
const transcript = screen.getByRole("region", { name: "Conversation" });
|
||||
Object.defineProperty(transcript, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 600,
|
||||
});
|
||||
transcript.scrollTop = 125;
|
||||
|
||||
rerender(
|
||||
<ConversationWorkspace
|
||||
runtime={{
|
||||
...idle,
|
||||
summary: { ...idle.summary, state: "streaming" },
|
||||
progress: {
|
||||
phase: "working",
|
||||
detail: "Thinking and preparing a response",
|
||||
toolCount: 0,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
const activity = screen.getByText("Pi is working").closest(".agent-working");
|
||||
expect(transcript.lastElementChild).toBe(activity);
|
||||
expect(transcript.scrollTop).toBe(600);
|
||||
});
|
||||
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onScroll = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user