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:
+3
-2
@@ -183,8 +183,9 @@ client/
|
||||
│ │ ├── EventDetail.tsx # Inline hook payload viewer (used by ActivityFeed + SessionDetail)
|
||||
│ │ ├── EmptyState.tsx
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), row menu
|
||||
│ │ ├── lanes/WorkspaceHome.tsx # /workspace?lane=<id>: prompt box (new session in a free pane slot), Recents, Instructions / Memory / Context / Folder
|
||||
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), tool filter, search over workspace + session names, row menu
|
||||
│ │ ├── lanes/WorkspaceHome.tsx # /workspace?lane=<id>: repo facts, prompt box (new session in a free pane slot), Recents with cost, Instructions / Memory / Context / Folder
|
||||
│ │ ├── lanes/SessionResumeButton.tsx # The transcript page's explicit Resume (own-folder Claude sessions; warns, then a free pane) / Open pane
|
||||
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 : [],
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+2
-2
@@ -390,10 +390,10 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
||||
The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **The sidebar's workspace list picks the workspace** — on every page, between the nav and the footer (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has search (workspace names and paths, plus the names of the sessions listed) and `+` (Add workspace). In the collapsed rail the list is a single icon that opens the sidebar.
|
||||
- **The sidebar's workspace list picks the workspace** — on every page, between the nav and the footer (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
|
||||
- **A workspace's sessions** — the chevron expands the row to its sessions (`WorkspaceSessionList.tsx`: `GET /api/sessions?cwd=&tool=`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
|
||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's sessions, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's sessions with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`; the session view's Sessions tab is the other place that resumes): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes / Sessions panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
|
||||
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
|
||||
Reference in New Issue
Block a user