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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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