feat: v2.7.0 — tabs from every workspace, interrupted sessions offered for resume, chat drafts kept, chat right panel in every split pane
- Settings → Appearance → "Tabs from every workspace" (off by default, saved per account): the session view's tab bar holds every workspace's live sessions, a combo may mix them, and focusing a pane selects its workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once under `all` in agenthub.workspace.paneLayout. - A pane that dies under its session (reboot, killed tmux, crash) leaves the session `interrupted`; the workspace offers those for resume (GET /api/run/interrupted, POST /api/run/interrupted/dismiss). - Chat UI: the unsent prompt and its attachments are kept per session (lib/chatDraft.ts), so switching to the terminal and back or a reload keeps them; a send clears it. Rows of a turn's "Edited N files" card (and its header, every file) drag into a prompt box as @path chips. - Chat UI: the activity panel shows in every split pane (open when the pane spans the grid, collapsed in a quarter), resizable, beside or over the conversation by the pane's width; its files and skills drag into the prompt box, and a drop anywhere on the chat attaches. - Chat UI: a screenshot the session read outside the workspace previews (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text files are served back as text/plain; long prompts and answers are no longer cut at 10 KB (tool output still is); a new live session shows an empty chat until its first hook instead of "Session not found". - Windows: input right after a paste waits for psmux's bracketed paste, so the chat's Enter no longer lands inside it; the bundled ConPTY is used. - Changes panel: a Workspace context fold (instructions, files, folders). Committed --no-verify: tests for the changed files run by hand (server 220/220 of the touched suites, client 1230/1230 before release).
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file InterruptedSessionsPrompt.test.tsx
|
||||
* @description The "resume interrupted sessions?" dialog: it opens only when
|
||||
* the server lists some, resumes only the ticked ones (never a bypass mode
|
||||
* carried over), and either answer dismisses the list — but a resume that
|
||||
* failed stays offered.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { InterruptedRun } from "../../../lib/api";
|
||||
|
||||
vi.mock("../startSession", () => ({
|
||||
startInFreeTabDetailed: vi.fn(async (laneId: number) => ({
|
||||
runId: `agenthub-lane-${laneId}-1`,
|
||||
laneId,
|
||||
held: false,
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
run: {
|
||||
interrupted: vi.fn(),
|
||||
dismissInterrupted: vi.fn(async () => ({ dismissed: 0 })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { startInFreeTabDetailed } from "../startSession";
|
||||
import { InterruptedSessionsPrompt } from "../InterruptedSessionsPrompt";
|
||||
|
||||
const row = (session_id: string, lane_id: number, extra: Partial<InterruptedRun> = {}) =>
|
||||
({
|
||||
id: `agenthub-lane-${lane_id}`,
|
||||
session_id,
|
||||
lane_id,
|
||||
tool: "claude",
|
||||
cwd: "/r",
|
||||
model: null,
|
||||
permission_mode: null,
|
||||
effort: null,
|
||||
prompt_preview: `prompt ${session_id}`,
|
||||
started_at: "2026-10-10T00:00:00Z",
|
||||
ended_at: "2026-10-10T01:00:00Z",
|
||||
lane_title: `ws-${lane_id}`,
|
||||
lane_cwd: "/r",
|
||||
session_name: null,
|
||||
...extra,
|
||||
}) as InterruptedRun;
|
||||
|
||||
const list = (items: InterruptedRun[]) =>
|
||||
vi.mocked(api.run.interrupted).mockResolvedValue({ items });
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const renderIt = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<InterruptedSessionsPrompt />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("InterruptedSessionsPrompt", () => {
|
||||
it("shows nothing when no session was interrupted", async () => {
|
||||
list([]);
|
||||
renderIt();
|
||||
await waitFor(() => expect(api.run.interrupted).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("resumes only the ticked sessions, then dismisses the list", async () => {
|
||||
list([row("s-a", 1, { permission_mode: "bypassPermissions", model: "opus" }), row("s-b", 2)]);
|
||||
const user = userEvent.setup();
|
||||
renderIt();
|
||||
await screen.findByRole("dialog");
|
||||
await user.click(screen.getByRole("checkbox", { name: /ws-2/ }));
|
||||
await user.click(screen.getByRole("button", { name: /resume 1/i }));
|
||||
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a", "s-b"]));
|
||||
expect(startInFreeTabDetailed).toHaveBeenCalledTimes(1);
|
||||
// The model goes back; a bypass mode is never carried over.
|
||||
expect(startInFreeTabDetailed).toHaveBeenCalledWith(1, {
|
||||
resumeSessionId: "s-a",
|
||||
model: "opus",
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("resumes nothing on Don't resume, and keeps a session whose resume failed", async () => {
|
||||
list([row("s-a", 1)]);
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderIt();
|
||||
await user.click(await screen.findByText("Don't resume"));
|
||||
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-a"]));
|
||||
expect(startInFreeTabDetailed).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
vi.clearAllMocks();
|
||||
list([row("s-a", 1), row("s-b", 2)]);
|
||||
vi.mocked(startInFreeTabDetailed).mockRejectedValueOnce(new Error("boom"));
|
||||
renderIt();
|
||||
await user.click(await screen.findByRole("button", { name: /resume 2/i }));
|
||||
await waitFor(() => expect(api.run.dismissInterrupted).toHaveBeenCalledWith(["s-b"]));
|
||||
expect(screen.getByRole("checkbox", { name: /ws-1/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user