feat(ui): finish the workspace-sessions plan — tool filter, session search, explicit Resume

- Sidebar: a tool filter (shown once workspaces of two tools exist, kept in
  agenthub.sidebar.projects → tool; reordering under it keeps the hidden
  workspaces' places), and search that also finds a workspace by one of its
  session names, showing only the matching sessions.
- Workspace home: the repository it belongs to (root, main branch, worktree
  count) under the title, and each recent's cost.
- Transcript page: an explicit Resume for a Claude session started in a
  workspace's own folder (SessionResumeButton). It warns that a resume re-reads
  the whole transcript, re-reads the live panes, then starts in the first free
  pane and opens it; a live session offers Open pane instead.
This commit is contained in:
2026-10-02 01:35:34 +07:00
parent d0535af626
commit 5b269698f6
15 changed files with 597 additions and 36 deletions
@@ -0,0 +1,141 @@
/**
* @file SessionResumeButton.tsx
* @description The transcript page's explicit Resume for a Claude session
* inside a workspace. Opening a session never resumes it; this is one of the
* two places that do (the session view's Sessions tab is the other), behind a
* warning, because a resume re-reads the whole transcript into context. A
* session that still has a live pane offers that pane instead. A resume
* starts in the workspace's first free pane slot (`POST /api/lanes/:id/start
* {slot, resumeSessionId}`, panes re-read first) and opens it — offered only
* for a session whose folder IS the workspace's, because the pane starts in
* the workspace's folder and Claude finds a session by the folder it ran in.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { History, SquareTerminal } from "lucide-react";
import { api, type RunHandle } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal";
import { freeSlot, paneRunId } from "./WorkspaceHome";
import { liveRunFor } from "./WorkspaceSessionList";
/** The workspace of this tool whose folder holds `cwd` most deeply — the
* same path-boundary rule the server binds hook sessions with. */
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
let best: Lane | null = null;
for (const l of lanes) {
if (l.tool !== tool) continue;
const root = l.cwd.replace(/\/+$/, "");
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
}
return best;
}
export function SessionResumeButton({
session,
}: {
session: { id: string; cwd: string | null; tool?: string };
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const { lanes } = useLanes();
const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
api.run
.list()
.then((r) => live && setRuns(r.items ?? []))
.catch(() => live && setRuns([]));
return () => {
live = false;
};
}, [session.id]);
// Claude only, like the Sessions tab: a Codex session id is not what
// `codex resume` takes for every kind of thread.
const tool = session.tool || "claude";
const lane = tool === "claude" && session.cwd ? owningLane(session.cwd, tool, lanes) : null;
if (!lane || runs === null) return null;
const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
const resumable = !!session.cwd && same(session.cwd, lane.cwd);
if (!run && !resumable) return null;
const open = (runId: string) =>
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
const resume = async () => {
// Busy first: the Confirm button is disabled only through it, and the
// re-read below is a round trip a second click could land in.
setBusy(true);
setError(null);
try {
// Re-read: a pane started since this page loaded must not be picked,
// and a session resumed elsewhere meanwhile is opened, not resumed twice.
const fresh = await api.run
.list()
.then((r) => r.items ?? [])
.catch(() => runs);
setRuns(fresh);
const live = liveRunFor(lane, fresh, session.id);
if (live) return open(live.id);
const slot = freeSlot(lane.id, fresh);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
const r = await api.lanes.action(lane.id, "start", { slot, resumeSessionId: session.id });
open(r.run_id ?? paneRunId(lane.id, slot));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setConfirming(false);
}
};
return (
<div className="flex flex-col items-end gap-1">
{run ? (
<button type="button" onClick={() => open(run.id)} className="btn-ghost text-xs">
<SquareTerminal className="h-4 w-4" />
{t("resume.openPane")}
</button>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
title={t("resume.hint")}
className="btn-ghost text-xs"
>
<History className="h-4 w-4" />
{t("sessions.resume")}
</button>
)}
{error && (
<p role="alert" className="max-w-xs text-right text-[11px] text-status-danger">
{error}
</p>
)}
<ConfirmModal
open={confirming}
title={t("resume.title")}
message={t("resume.body", { workspace: lane.title || lane.cwd })}
confirmLabel={t("resume.confirm")}
cancelLabel={t("home.cancel")}
destructive={false}
busy={busy}
onConfirm={() => void resume()}
onCancel={() => setConfirming(false)}
/>
</div>
);
}
+37 -2
View File
@@ -22,8 +22,8 @@ import {
type PermissionMode,
type RunHandle,
} from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import type { Lane, Project, Session } from "../../lib/types";
import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor } from "../run/RunSetup";
@@ -137,6 +137,22 @@ function HomeBody({
};
}, [lane.id]);
// The repository this workspace belongs to (Settings → Projects used to
// show this): read once per visit, from the server's cached project list.
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
let live = true;
api.projects
.list()
.then(({ projects }) => {
if (live) setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [lane.id]);
// Suggestions are read when the folder form opens, not on every visit.
useEffect(() => {
if (!folderOpen) return;
@@ -268,6 +284,20 @@ function HomeBody({
<MoreHorizontal className="h-5 w-5" />
</button>
</div>
{project?.root && (
<p
data-testid="workspace-home-facts"
className="mt-1 flex flex-wrap gap-x-3 font-mono text-[11px] text-fg-muted"
>
<span title={t("home.repoRoot")}>{project.root}</span>
{project.worktrees.find((w) => w.main)?.branch && (
<span title={t("home.mainBranch")}>
{project.worktrees.find((w) => w.main)?.branch}
</span>
)}
<span>{t("home.worktreeCount", { count: project.worktrees.length })}</span>
</p>
)}
<div className="card mt-5 p-3">
<PromptEditor
@@ -340,6 +370,11 @@ function HomeBody({
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
{!!s.cost && s.cost > 0 && (
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
{fmtCost(s.cost)}
</span>
)}
<span className="shrink-0 text-xs text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at)}
</span>
@@ -36,6 +36,8 @@ export function WorkspaceSessionList({
liveRuns,
query,
selectedRunId,
onMatches,
hidden = false,
}: {
lane: Lane;
/** Every live dashboard pane, from `GET /api/run`. */
@@ -44,6 +46,11 @@ export function WorkspaceSessionList({
query: string;
/** The `run` the Workspace page is attached to, to mark its row. */
selectedRunId: string | null;
/** How many sessions match `query`, once they are loaded — lets the
* sidebar search find a workspace by a session's name. */
onMatches?: (count: number) => void;
/** Mounted only to load and count, during a search that hides the row. */
hidden?: boolean;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -67,7 +74,12 @@ export function WorkspaceSessionList({
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
);
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
const count = sessions === null ? null : shown.length;
useEffect(() => {
if (count !== null) onMatches?.(count);
}, [count, onMatches]);
if (hidden) return null;
if (sessions !== null && shown.length === 0) {
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
}
@@ -0,0 +1,202 @@
/**
* @file SessionResumeButton.test.tsx
* @description The transcript page's explicit Resume: it shows only for a
* Claude session inside a workspace, a live session offers its pane instead,
* and a resume asks first (it re-reads the whole transcript), then starts in
* the first free pane slot and opens that pane — or refuses when all four are
* busy.
* @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, Route, Routes, useLocation } from "react-router-dom";
import type { Lane } from "../../../lib/types";
import type { RunHandle } from "../../../lib/api";
let lanes: Lane[];
let runs: RunHandle[];
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
list: vi.fn(async () => ({ lanes })),
action: vi.fn(async () => ({ ok: true, run_id: "agenthub-lane-3-1" })),
},
run: { list: vi.fn(async () => ({ items: runs })) },
},
};
});
import { api } from "../../../lib/api";
import { SessionResumeButton } from "../SessionResumeButton";
const lane = (extra: Partial<Lane>) =>
({ id: 3, cwd: "/r", tool: "claude", run_id: null, session_id: null, ...extra }) as Lane;
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderButton(session: { id: string; cwd: string | null; tool?: string }) {
return render(
<MemoryRouter initialEntries={["/sessions/x"]}>
<Routes>
<Route
path="*"
element={
<>
<SessionResumeButton session={session} />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
vi.clearAllMocks();
lanes = [lane({ id: 3, cwd: "/r" }), lane({ id: 4, cwd: "/r/sub" })];
runs = [{ id: "agenthub-lane-3", laneId: 3, slot: 0, status: "running" } as RunHandle];
});
describe("SessionResumeButton", () => {
it("asks before resuming, then starts in the first free pane and opens it", async () => {
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
expect(api.lanes.action).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toHaveTextContent("whole transcript");
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
slot: 1,
resumeSessionId: "s-1",
});
await waitFor(() =>
expect(screen.getByTestId("where").textContent).toBe(
"/workspace?lane=3&run=agenthub-lane-3-1"
)
);
});
it("resumes in the workspace whose folder IS the session's, not a parent", async () => {
const user = userEvent.setup();
renderButton({ id: "s-2", cwd: "/r/sub/", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(4, "start", {
slot: 0,
resumeSessionId: "s-2",
});
});
it("opens a live session's pane instead of resuming it", async () => {
runs = [
{
id: "agenthub-lane-3",
laneId: 3,
slot: 0,
status: "running",
sessionId: "s-1",
} as RunHandle,
];
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Open pane" }));
expect(api.lanes.action).not.toHaveBeenCalled();
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3");
});
it("offers no Resume for a session started in a subfolder, but opens its live pane", async () => {
const { unmount } = renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Resume" })).toBeNull();
unmount();
runs = [
{
id: "agenthub-lane-3",
laneId: 3,
slot: 0,
status: "running",
sessionId: "s-9",
} as RunHandle,
];
renderButton({ id: "s-9", cwd: "/r/lib", tool: "claude" });
expect(await screen.findByRole("button", { name: "Open pane" })).toBeInTheDocument();
});
it("re-reads the panes before starting, so a pane started meanwhile is skipped", async () => {
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
runs = [0, 1].map(
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
);
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { slot: 2, resumeSessionId: "s-1" });
});
it("starts once on a double click while the panes are being re-read", async () => {
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
let release: (v: { items: RunHandle[] }) => void = () => undefined;
vi.mocked(api.run.list).mockImplementationOnce(
() => new Promise((r) => (release = r)) as ReturnType<typeof api.run.list>
);
const confirm = screen.getByRole("button", { name: "Resume session" });
await user.click(confirm);
await user.click(confirm);
release({ items: runs });
await waitFor(() => expect(api.lanes.action).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 50));
expect(api.lanes.action).toHaveBeenCalledTimes(1);
});
it("opens the session's pane when it went live meanwhile, starting nothing", async () => {
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
runs = [
{
id: "agenthub-lane-3-2",
laneId: 3,
slot: 2,
status: "running",
sessionId: "s-1",
} as RunHandle,
];
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(api.lanes.action).not.toHaveBeenCalled();
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
});
it("refuses when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
);
const user = userEvent.setup();
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("shows nothing outside a workspace or for a non-Claude session", async () => {
const { unmount } = renderButton({ id: "s-1", cwd: "/elsewhere", tool: "claude" });
await waitFor(() => expect(api.lanes.list).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Resume" })).toBeNull();
unmount();
renderButton({ id: "s-1", cwd: "/r", tool: "codex" });
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Resume" })).toBeNull();
});
});
@@ -36,7 +36,7 @@ vi.mock("../../run/RunSetup", () => ({
let view: LaneSourcesView;
let runs: RunHandle[];
const sessions = [
{ id: "s-live", name: "Live one", started_at: "2026-09-30T10:00:00Z" },
{ id: "s-live", name: "Live one", started_at: "2026-09-30T10:00:00Z", cost: 1.234 },
{ id: "s-old", name: "Old one", started_at: "2026-09-29T10:00:00Z" },
] as Session[];
@@ -208,6 +208,36 @@ describe("WorkspaceHome", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("names the repository it belongs to: root, main branch, worktree count", async () => {
renderHome();
const facts = await screen.findByTestId("workspace-home-facts");
expect(facts).toHaveTextContent("/r");
expect(facts).toHaveTextContent("main");
expect(facts).toHaveTextContent("3 worktrees");
});
it("shows no repository line for a workspace outside any repository", async () => {
vi.mocked(api.projects.list).mockResolvedValueOnce({
projects: [{ root: null, name: "plain", workspaces: [1], worktrees: [] }],
} as never);
renderHome();
await waitFor(() => expect(api.projects.list).toHaveBeenCalled());
expect(screen.queryByTestId("workspace-home-facts")).toBeNull();
});
it("shows no repository line for a folder in no git project", async () => {
renderHome(lane({ id: 9, cwd: "/plain" }));
await waitFor(() => expect(api.projects.list).toHaveBeenCalled());
expect(screen.queryByTestId("workspace-home-facts")).toBeNull();
});
it("shows a recent's cost when it has one", async () => {
renderHome();
const live = await screen.findByTestId("workspace-recent-s-live");
expect(live).toHaveTextContent("$1.23");
expect(screen.getByTestId("workspace-recent-s-old")).not.toHaveTextContent("$");
});
it("saves instructions", async () => {
const user = userEvent.setup();
renderHome();