feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Workspace.changesPanel.test.tsx
|
||||
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
|
||||
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
|
||||
* @description The Changes panel has two controls: Full screen (Restore at
|
||||
* full screen, back to the last dragged width) and Collapse; the rail reopens
|
||||
* it at the last dragged width, else 25/75. The width survives a reload.
|
||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||
* lane strip's order.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -82,7 +83,22 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
// Workspace 1 has one extra folder, its own repo (`?source=`).
|
||||
sources: vi.fn((id: number) =>
|
||||
Promise.resolve({
|
||||
sources:
|
||||
id === 1
|
||||
? [
|
||||
{ id: 7, kind: "folder", path: "/x/shop", name: "shop", missing: false },
|
||||
{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false },
|
||||
]
|
||||
: [],
|
||||
})
|
||||
),
|
||||
gitTree: r({ entries: [] }),
|
||||
// A file opened in the panel on a chat's request.
|
||||
changes: r({ available: false }),
|
||||
gitFile: r({ available: false }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
@@ -104,6 +120,8 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
@@ -150,43 +168,115 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
|
||||
it("offers Full screen and Collapse, then Restore and Collapse at full screen", async () => {
|
||||
await openPanel();
|
||||
const card = () => screen.getByTestId("lane-changes-card");
|
||||
expect(card().className).toContain("w-[30%]");
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(card().className).toContain("w-1/2");
|
||||
const size = () => screen.getByTestId("lane-changes-resize");
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
expect(size()).toHaveAttribute("aria-label", "Full screen");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Full width HIDES the console beside it — hidden, still mounted.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-full");
|
||||
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
|
||||
expect(size()).toHaveAttribute("aria-label", "Restore size");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Past the widest step the panel closes — and reopens at 30/70, not back
|
||||
// at full width, which is why closing resets the width.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
// Restore brings the console back beside it.
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
|
||||
|
||||
// Collapsing from full screen reopens at 25/75, not back at full width.
|
||||
fireEvent.click(size());
|
||||
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||
await settle();
|
||||
expect(card().className).toContain("w-[30%]");
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
});
|
||||
|
||||
it("remembers the width across a reload, and never asks the server", async () => {
|
||||
await openPanel();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
|
||||
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("full");
|
||||
|
||||
cleanup();
|
||||
await openPanel();
|
||||
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
|
||||
expect(screen.getByTestId("lane-changes-card").className).toContain("w-full");
|
||||
});
|
||||
|
||||
it("keeps the rail as the only way back in", async () => {
|
||||
it("restores, and reopens, at the width last dragged to, not the preset", async () => {
|
||||
window.localStorage.setItem("agenthub.workspace.changesSize", "full");
|
||||
window.localStorage.setItem("agenthub.workspace.changesDragged", "0.4");
|
||||
await openPanel();
|
||||
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||
await settle();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||
await settle();
|
||||
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
|
||||
});
|
||||
|
||||
it("opens on a chat's request for a file of the workspace it shows, not another's", async () => {
|
||||
window.localStorage.setItem("agenthub.workspace.changesOpen", "0");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
act(() => {
|
||||
openInChanges(2, "/workspace/beta", "/workspace/beta/a.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/a.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
|
||||
});
|
||||
|
||||
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
|
||||
await openPanel();
|
||||
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
|
||||
// The workspace folder, then its folders — never a context file.
|
||||
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
|
||||
vi.mocked(api.lanes.git).mockClear();
|
||||
fireEvent.change(pick, { target: { value: "7" } });
|
||||
await settle();
|
||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||
// A chat's file is the workspace folder's: the panel goes back there.
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(pick.value).toBe("");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
|
||||
});
|
||||
|
||||
it("has no folder picker for a workspace with no extra folders", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses from its narrow width too, back to the rail", async () => {
|
||||
await openPanel();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
expect(screen.getByTestId("lane-changes-expand")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user