f6f1bf4a76
- 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).
110 lines
3.8 KiB
TypeScript
110 lines
3.8 KiB
TypeScript
/**
|
|
* @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();
|
|
});
|
|
});
|