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:
@@ -6,8 +6,17 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, cleanup, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||
import {
|
||||
render,
|
||||
act,
|
||||
cleanup,
|
||||
createEvent,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
@@ -61,6 +70,10 @@ const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
vi.mock("../../components/lanes/startSession", () => ({
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
laneOfRun: (runId: string) => {
|
||||
const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
|
||||
return m ? Number(m[1]) : null;
|
||||
},
|
||||
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
@@ -287,6 +300,10 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/PaneChat", () => ({
|
||||
PaneChat: () => <div data-testid="pane-chat" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
// `exit-<runId>` stands in for the server's exit frame.
|
||||
TerminalView: ({
|
||||
@@ -310,6 +327,7 @@ vi.mock("../../components/run/TerminalView", () => ({
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
import { api } from "../../lib/api";
|
||||
import { setSessionChat } from "../../lib/chatUi";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
@@ -630,6 +648,30 @@ describe("console tabs", () => {
|
||||
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("with tabs from every workspace on, shows another workspace's session and selects it on click", async () => {
|
||||
window.localStorage.setItem("agenthub.workspace.allTabs", "1");
|
||||
withRuns([live(0), { ...live(0), id: "agenthub-lane-2-1", laneId: 2, slot: 1 }]);
|
||||
function LaneParam() {
|
||||
const [params] = useSearchParams();
|
||||
return <span data-testid="lane-param">{params.get("lane")}</span>;
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
|
||||
<Workspace />
|
||||
<LaneParam />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
// Leaves are `paneKey`s: lane × 10 + slot.
|
||||
expect(screen.getByTestId("console-tab-10")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-21")).toHaveTextContent("Lane B");
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-21"));
|
||||
await waitFor(() => expect(screen.getByTestId("lane-param")).toHaveTextContent("2"));
|
||||
expect(screen.getByTestId("console-tab-21")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closing the active tab stops its session and opens the next one", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
@@ -706,6 +748,37 @@ describe("split panes", () => {
|
||||
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
|
||||
});
|
||||
|
||||
it("keeps each session's chat view when two tabs join into one combo", async () => {
|
||||
setSessionChat("sess-0", true);
|
||||
setSessionChat("sess-1", true);
|
||||
const items = [
|
||||
{ ...live(0), sessionId: "sess-0" },
|
||||
{ ...live(1), sessionId: "sess-1" },
|
||||
];
|
||||
withRuns(items as never);
|
||||
// As the server does: GET /api/run/:id is the list's own handle.
|
||||
const get = vi.mocked(api.run.get).getMockImplementation()!;
|
||||
onTestFinished(() => void vi.mocked(api.run.get).mockImplementation(get));
|
||||
vi.mocked(api.run.get).mockImplementation(
|
||||
async (id) => items.find((r) => r.id === id) as never
|
||||
);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
// Tab b dragged onto the middle of tab a (jsdom: every rect is 0 wide).
|
||||
const drop = createEvent.drop(screen.getByTestId("console-tab-0"), {
|
||||
dataTransfer: {
|
||||
types: ["application/x-agenthub-tab"],
|
||||
getData: (t: string) => (t === "application/x-agenthub-tab" ? "1" : ""),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(drop, "clientX", { value: 0 });
|
||||
fireEvent(screen.getByTestId("console-tab-0"), drop);
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
await settle();
|
||||
for (const s of [0, 1])
|
||||
expect(within(screen.getByTestId(`pane-${s}`)).getByTestId("pane-chat")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
|
||||
Reference in New Issue
Block a user