feat(ui): a Claude-Desktop-style home for every workspace

/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt
box whose Start opens a new session in the first free pane slot, Recents (a
live session attaches its pane, any other opens its transcript, nothing
resumes), and Instructions / Memory / Context / Folder. Instructions live in
lanes.instructions, extra folders and uploaded files in the new lane_sources
table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads
them from the lane's rows and hands Claude --add-dir + an appended system
prompt file, Codex --add-dir + developer_instructions. Memory is read-only.

Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run
records which session the pane holds (a Claude pane follows /clear and
/resume but not a nested claude -p), so the sidebar and notifications open
the right pane (&run=). Settings loses its Projects group; Worktrees now
decides which worktrees Add folder suggests.
This commit is contained in:
2026-10-02 01:10:58 +07:00
parent 2e051e23b6
commit d0535af626
44 changed files with 2515 additions and 466 deletions
@@ -221,9 +221,13 @@ export function LaneConsolePane({
}, [laneId, slot, refreshList]);
// Runs after the lane-switch effect above, so a sidebar pick that changes
// lane AND run at once lands on the picked run, not the lane's own.
// lane AND run at once lands on the picked run, not the lane's own. Keyed on
// `laneId` too: on a cold load the pane mounts before the lane list does,
// and the switch effect then resets the pane to its own slot once `laneId`
// arrives — the request must land after that. Only this lane's own panes.
useEffect(() => {
if (!attachRequest) return;
if (!attachRequest || laneId === null) return;
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) return;
let live = true;
autoAttachedForLane.current = laneId;
api.run
@@ -239,9 +243,10 @@ export function LaneConsolePane({
return () => {
live = false;
};
// Only a new request (nonce) attaches; a lane switch never replays one.
// A new request (nonce), or the lane it names arriving, attaches; a switch
// to another lane drops `?run=` (Workspace), so it never replays one.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attachRequest?.nonce]);
}, [attachRequest?.nonce, laneId]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
+1 -1
View File
@@ -142,7 +142,7 @@ interface PromptEditorProps {
fileCwd: string;
}
function PromptEditor({
export function PromptEditor({
value,
onChange,
onSubmit,
@@ -556,4 +556,50 @@ describe("LaneConsolePane", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
expect(api.run.start).not.toHaveBeenCalled();
});
it("attaches once the lane arrives on a cold load, and ignores another lane's run", async () => {
vi.mocked(api.run.get).mockResolvedValue({
id: "agenthub-lane-1-1",
laneId: 1,
slot: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-9",
startedAt: null,
promptPreview: null,
tool: "claude",
});
const req = { runId: "agenthub-lane-1-1", nonce: "k2" };
const { rerender } = render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} laneId={null} attachRequest={req} />
</MemoryRouter>
);
// The lane list lands only after the pane mounted (and after any early
// attach would have resolved) — the switch effect must not win.
await new Promise((r) => setTimeout(r, 20));
rerender(
<MemoryRouter>
<LaneConsolePane {...baseProps()} attachRequest={req} />
</MemoryRouter>
);
await screen.findByTestId("terminal-view");
expect(api.run.get).toHaveBeenCalledWith("agenthub-lane-1-1");
vi.mocked(api.run.get).mockClear();
rerender(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
attachRequest={{ runId: "agenthub-lane-12", nonce: "k3" }}
/>
</MemoryRouter>
);
await new Promise((r) => setTimeout(r, 10));
expect(api.run.get).not.toHaveBeenCalled();
});
});