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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user