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
+74 -26
View File
@@ -19,6 +19,7 @@ import { Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools";
import {
moveLane,
readLaneOrder,
@@ -90,19 +91,45 @@ export function WorkspaceTree() {
writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } });
const order = useMemo(() => readLaneOrder(), [orderTick]); // eslint-disable-line react-hooks/exhaustive-deps
const sorted = useMemo(() => sortByStoredOrder(lanes, order), [lanes, order]);
// The tool filter, offered once workspaces of two tools exist; a stored
// tool no workspace has any more filters nothing.
const aiTools = useAiTools();
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
[lanes, order, toolFilter]
);
const q = query.trim().toLowerCase();
const shown = q
? sorted.filter(
(l) => workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q)
)
: sorted;
const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
// A search also finds a workspace by a session's name: every workspace's
// list stays mounted (hidden) to load and count its matches.
const [hits, setHits] = useState<Record<number, number>>({});
const onMatches = useCallback(
(id: number, n: number) => setHits((h) => (h[id] === n ? h : { ...h, [id]: n })),
[]
);
// A cleared search unmounts the collapsed lists; their old counts must not
// surface workspaces in the next search before its fetch answers.
useEffect(() => {
if (!q) setHits({});
}, [q]);
const visible = (l: Lane) => !q || nameHit(l) || (hits[l.id] ?? 0) > 0;
const reorder = (from: number, to: number) => {
const ids = sorted.map((l) => l.id);
const moved = moveLane(ids, from, to);
if (moved === ids) return;
writeLaneOrder(moved);
// Under a tool filter the shown ids go back into the slots they held in
// the full order, so the hidden workspaces keep their places.
const shownIds = new Set(ids);
let k = 0;
const full = sortByStoredOrder(lanes, order).map((l) =>
shownIds.has(l.id) ? (moved[k++] ?? l.id) : l.id
);
writeLaneOrder(full);
setOrderTick((n) => n + 1);
};
@@ -148,6 +175,22 @@ export function WorkspaceTree() {
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
{t("tree.title")}
</span>
{tools.length > 1 && (
<select
value={toolFilter ?? ""}
onChange={(e) => writeProjectTreePrefs({ tool: e.target.value || null })}
aria-label={t("tree.toolFilter")}
title={t("tree.toolFilter")}
className="max-w-[6.5rem] rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] text-fg-secondary"
>
<option value="">{t("tree.allTools")}</option>
{tools.map((id) => (
<option key={id} value={id}>
{aiTools.label(id)}
</option>
))}
</select>
)}
<button
type="button"
onClick={() => {
@@ -191,31 +234,36 @@ export function WorkspaceTree() {
)}
<div className="space-y-px px-1">
{shown.map((lane, i) => (
{sorted.map((lane, i) => (
<div key={lane.id}>
<WorkspaceRow
lane={lane}
index={i}
selected={selected.lane === lane.id && !selected.run}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={reorder}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
{visible(lane) && (
<WorkspaceRow
lane={lane}
index={i}
selected={selected.lane === lane.id && !selected.run}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={reorder}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
query={query}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
@@ -190,6 +190,64 @@ describe("WorkspaceTree", () => {
expect(rowIds()).toEqual(["workspace-row-3"]);
});
it("filters by tool, remembered, and offers the filter only with two tools", async () => {
const user = userEvent.setup();
const { unmount } = renderTree();
await screen.findByTestId("workspace-row-1");
await user.selectOptions(screen.getByLabelText("Filter by tool"), "codex");
expect(rowIds()).toEqual(["workspace-row-3"]);
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).tool).toBe("codex");
await user.selectOptions(screen.getByLabelText("Filter by tool"), "");
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-2", "workspace-row-3"]);
unmount();
// One tool only: no filter, and a stale stored one is ignored.
localStorage.setItem("agenthub.sidebar.projects", JSON.stringify({ tool: "codex" }));
lanes = lanes.filter((l) => l.tool === "claude");
renderTree();
await screen.findByTestId("workspace-row-1");
expect(screen.queryByLabelText("Filter by tool")).toBeNull();
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-2"]);
});
it("reorders within a tool filter without losing the hidden workspaces' places", async () => {
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([1, 3, 2]));
localStorage.setItem("agenthub.sidebar.projects", JSON.stringify({ tool: "claude" }));
renderTree();
fireEvent.keyDown(await screen.findByTestId("workspace-open-2"), {
key: "ArrowUp",
altKey: true,
});
expect(JSON.parse(localStorage.getItem("agenthub.workspace.laneOrder")!)).toEqual([2, 3, 1]);
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-1"]);
});
it("finds a workspace by one of its session names, showing only the matches", async () => {
vi.mocked(api.sessions.list).mockImplementation(async (params) => ({
sessions:
(params as { cwd?: string }).cwd === "/r/sub"
? [session("s-x", "Fix login bug"), session("s-y", "Docs")]
: [session("s-live", "Live one"), session("s-old", "Old one")],
total: 2,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
await user.click(screen.getByRole("button", { name: "Search workspaces and sessions" }));
await user.type(screen.getByRole("searchbox"), "login");
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
expect(screen.getByTestId("workspace-session-s-x")).toBeVisible();
expect(screen.queryByTestId("workspace-session-s-y")).toBeNull();
// A workspace whose NAME matches keeps all of its sessions.
await user.clear(screen.getByRole("searchbox"));
await user.type(screen.getByRole("searchbox"), "sub");
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
expect(screen.getByTestId("workspace-session-s-y")).toBeInTheDocument();
});
it("offers Reset only on a managed workspace and the readme run only on claude", async () => {
const user = userEvent.setup();
renderTree();
@@ -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();
+11
View File
@@ -329,13 +329,24 @@
"sessions.empty": "No sessions in this workspace yet",
"sessions.resume": "Resume",
"sessions.open": "Open",
"resume.openPane": "Open pane",
"resume.hint": "Resume this session in a workspace pane — re-reads its whole transcript into context",
"resume.title": "Resume this session?",
"resume.body": "Resuming re-reads the session's whole transcript into the model's context, so its next turns pay for all of it again. It opens in the first free pane of {{workspace}}. To only read it, stay on this page.",
"resume.confirm": "Resume session",
"sessions.search": "Filter sessions…",
"tree.title": "Workspaces",
"tree.search": "Search workspaces and sessions",
"tree.toolFilter": "Filter by tool",
"tree.allTools": "All tools",
"tree.newSession": "New session",
"tree.noSessions": "No sessions yet",
"tree.transcriptHint": "No live pane — opens the transcript",
"home.breadcrumb": "Workspaces",
"home.repoRoot": "Repository root",
"home.mainBranch": "Main worktree's branch",
"home.worktreeCount_one": "{{count}} worktree",
"home.worktreeCount_other": "{{count}} worktrees",
"home.placeholder": "How can I help you today?",
"home.start": "Start",
"home.allBusy": "All four panes of this workspace are busy — stop one before starting another session.",
+11
View File
@@ -329,13 +329,24 @@
"sessions.empty": "Chưa có phiên nào trong workspace này",
"sessions.resume": "Tiếp tục",
"sessions.open": "Mở",
"resume.openPane": "Mở pane",
"resume.hint": "Tiếp tục session này trong một pane của workspace — nạp lại toàn bộ transcript vào context",
"resume.title": "Tiếp tục session này?",
"resume.body": "Tiếp tục sẽ nạp lại toàn bộ transcript của session vào context của model, nên các lượt sau phải trả lại chi phí cho tất cả phần đó. Session mở ở pane trống đầu tiên của {{workspace}}. Nếu chỉ cần đọc, hãy ở lại trang này.",
"resume.confirm": "Tiếp tục session",
"sessions.search": "Lọc phiên…",
"tree.title": "Không gian làm việc",
"tree.search": "Tìm workspace và session",
"tree.toolFilter": "Lọc theo tool",
"tree.allTools": "Mọi tool",
"tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session",
"tree.transcriptHint": "Không còn pane — mở transcript",
"home.breadcrumb": "Không gian làm việc",
"home.repoRoot": "Thư mục gốc của repo",
"home.mainBranch": "Branch của worktree chính",
"home.worktreeCount_one": "{{count}} worktree",
"home.worktreeCount_other": "{{count}} worktree",
"home.placeholder": "Tôi có thể giúp gì cho bạn hôm nay?",
"home.start": "Bắt đầu",
"home.allBusy": "Cả bốn pane của workspace này đang bận — hãy dừng một pane trước khi mở session mới.",
+6 -1
View File
@@ -1,7 +1,8 @@
/**
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
* expanded to their sessions (by lane id), and — Settings → Worktrees —
* expanded to their sessions (by lane id), the tool the list is filtered to,
* and — Settings → Worktrees —
* whether a workspace home's Add folder suggests the repo's untracked
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
@@ -17,12 +18,15 @@ export const PROJECT_TREE_KEY = "agenthub.sidebar.projects";
export interface ProjectTreePrefs {
/** Lane id → its sessions are shown under it in the sidebar. */
expanded: Record<string, boolean>;
/** Show only this tool's workspaces; null = every tool. */
tool: string | null;
showUntracked: boolean;
hideSources: string[];
}
const DEFAULTS: ProjectTreePrefs = {
expanded: {},
tool: null,
showUntracked: true,
hideSources: [],
};
@@ -45,6 +49,7 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
const parsed = raw ? (JSON.parse(raw) as Partial<ProjectTreePrefs>) : {};
value = {
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null,
showUntracked: parsed.showUntracked !== false,
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
};
+2
View File
@@ -86,6 +86,7 @@ import { SessionOverview } from "../components/SessionOverview";
import { ConversationView } from "../components/conversation/ConversationView";
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { SessionResumeButton } from "../components/lanes/SessionResumeButton";
import { CopyButton } from "../components/event-views/primitives";
import {
effectiveSessionStatus,
@@ -633,6 +634,7 @@ export function SessionDetail() {
</div>
)}
</div>
<SessionResumeButton session={session} />
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" />
</button>
@@ -48,6 +48,11 @@ const mockSession: Session = {
let mockAgents: Agent[] = [];
let mockCost: { total_cost: number; breakdown: unknown[] } = { total_cost: 0, breakdown: [] };
// The header's Resume reads lanes and runs; this file is about the agent tree.
vi.mock("../../components/lanes/SessionResumeButton", () => ({
SessionResumeButton: () => null,
}));
vi.mock("../../lib/api", () => ({
api: {
sessions: {