Files
AgentHub/client/src/components/lanes/WorkspaceHome.tsx
T
nntrivi2001 b0dd5af22f fix: v2.7.5 — the chat shows each AskUserQuestion with the user's answer
- Chat: an AskUserQuestion is its own card (AskCard in MessageList.tsx), not
  part of the collapsed work line — each question with the option picked or
  the text typed, "declined" when refused. The transcript route passes the
  answers from Claude Code's toolUseResult (askAnswers, beside diff).
- Chat: the activity panel keeps the user's open/close per session for the
  tab, and opens by default only once the session has an output.
- Workspace home: the sent prompt's draft is dropped even though opening the
  session unmounts the home.
- Terminal: Ctrl+V with text on the clipboard pastes the text; only an empty
  clipboard still sends ^V.
- AGENTS.md / CLAUDE.md: run only the tests a change touches while iterating,
  the full suite once before committing.
2026-10-13 10:46:56 +07:00

771 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free slot (0–9, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
* `ConsoleTabs.tsx`). Inside
* the box, like Claude Desktop's: the `+` menu (`ChatAddMenu`: upload, or
* attach a project file, a context file or a recent upload), AI (the session's own tool, the workspace's
* by default; its last row, **Open terminal**, is no AI — it starts a plain
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ArchiveRestore,
ShieldAlert,
Folder,
GitBranch,
MoreHorizontal,
ArrowUp,
SquareTerminal,
} from "lucide-react";
import {
api,
type CodexApproval,
type CodexSandbox,
type EffortLevel,
type LaneSourcesView,
type ModelChoice,
type PermissionMode,
type RunHandle,
} from "../../lib/api";
import type { Lane, Project, Session } from "../../lib/types";
import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { ChatAddMenu } from "../run/ChatAddMenu";
import { addAttachments } from "../../lib/promptAttach";
import { AiToolIcon } from "../AiToolIcon";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { SessionMenu } from "./SessionMenu";
import { menuPoint, useContextMenu } from "../ContextMenu";
import {
liveRunFor,
SessionFold,
sessionDot,
sessionDotLabel,
sessionWorktreeName,
useSessionPage,
} from "./WorkspaceSessionList";
export {
freeSlot,
laneOfRun,
paneRunId,
startInFreeTab,
startInFreeTabDetailed,
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults";
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
type HomeProps = Parameters<typeof HomeBody>[0];
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
* model, an open folder form, a late upload's answer) belongs to ONE
* workspace, and the page swaps `lane` without unmounting. */
/** The AI menu row that opens a terminal; no catalog id can collide (ids are slugs). */
const OPEN_TERMINAL = "__terminal__";
export function WorkspaceHome(props: HomeProps) {
return <HomeBody key={props.lane.id} {...props} />;
}
function HomeBody({
lane,
onMenuAction,
onRenamed,
onSourcesChange,
sourcesVersion = 0,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void;
/** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void;
/** Called with the sources after a folder or file is added or removed. */
onSourcesChange?: (view: LaneSourcesView) => void;
/** Bumped when the folder list changed elsewhere (the Changes panel's
* Close repository): the Folder list re-reads. */
sourcesVersion?: number;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const aiTools = useAiTools();
// The NEW session's AI tool — its own choice, the workspace's by default.
const [tool, setTool] = useState(lane.tool);
const entry = aiTools.byId(tool);
const claude = tool === "claude";
const codex = tool === "codex";
// The half-typed prompt survives leaving the page or the workspace.
const draftKey = `agenthub.workspace.draft.${lane.id}`;
const [prompt, setPrompt] = useState(() => {
try {
return localStorage.getItem(draftKey) ?? "";
} catch {
return "";
}
});
useEffect(() => {
try {
if (prompt) localStorage.setItem(draftKey, prompt);
else localStorage.removeItem(draftKey);
} catch {
/* storage unavailable: the draft just does not survive */
}
}, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]);
const pickFiles = useRef<(() => void) | null>(null);
const [proxy, setProxy] = useSessionProxy();
const proxies = useAiProxies();
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
// A profile picked for a tool that can use one; its models replace the tool's own.
const profile = proxyCapable
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
: null;
const proxyModels: ModelChoice[] | undefined = profile
? [
...new Set(
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
(m): m is string => !!m
)
),
].map((id) => ({ id, label: id }))
: undefined;
// The tool's default model (also when the catalog arrives after mount); on
// a proxy, the proxy's own default. A new tool or proxy drops the pick.
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
const [model, setModel] = useState(defaultModel);
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
// The account's own default (first-run setup) — never bypass.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
const [bypass, setBypass] = useState(false);
const canBypass = !claude && !!entry?.bypass;
const [effort, setEffort] = useState<EffortLevel>("");
const hasEffort = claude || codex;
// Claude Desktop's worktree toggle: the new session gets a git worktree of
// its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false);
const [starting, setStarting] = useState(false);
const startingRef = useRef(false);
// An image still uploading: its path is not in the prompt yet.
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [sessions, setSessions] = useState<Session[] | null>(null);
const [sessionTotal, setSessionTotal] = useState(0);
const [recentLimit, setRecentLimit] = useState(RECENT_PAGE);
// A Recents row being renamed (its session id) and the draft.
const [renamingId, setRenamingId] = useState<string | null>(null);
const [nameDraft, setNameDraft] = useState("");
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const recentCtx = useContextMenu<Session>();
// A new pane, a pane gone, a session id learned — all move these lane fields.
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
// Bumped after a session is unarchived, so the lists re-read.
const [version, setVersion] = useState(0);
useEffect(() => {
let live = true;
api.run
.list()
.then((r) => live && setLiveRuns(r.items))
.catch(() => undefined);
api.sessions
.list({ lane: lane.id, archived: false, limit: recentLimit })
.then((r) => {
if (!live) return;
setSessions(r.sessions);
setSessionTotal(r.total ?? r.sessions.length);
})
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.id, runKey, version, recentLimit]);
const archived = useSessionPage(
{ lane: lane.id, archived: true, limit: 30 },
`${runKey}:${version}`
);
// 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]);
// The AI menu's Open terminal: a plain shell in the first free slot, opened
// at once — the same start the session view's tab bar `+` makes.
const openTerminal = async () => {
if (startingRef.current) return;
startingRef.current = true;
setStarting(true);
setError(null);
try {
const runId = await startInFreeTab(lane.id, { shell: true });
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
setError(message(err));
} finally {
startingRef.current = false;
setStarting(false);
}
};
const text = promptWithImages(prompt, images);
const start = async () => {
// Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false.
if (startingRef.current || uploading || !text) return;
startingRef.current = true;
setStarting(true);
setError(null);
try {
const runId = await startInFreeTab(lane.id, {
prompt: text,
// Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}),
...proxyArg(proxyCapable, proxy),
...(hasModel && model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}),
...(codex ? { sandbox, approval } : {}),
...(canBypass && bypass ? { bypass: true } : {}),
...(hasEffort && effort ? { effort } : {}),
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
});
setPrompt("");
// Here, not only through the effect: the navigate below unmounts this
// home in the same batch, so the effect never sees the empty prompt.
try {
localStorage.removeItem(draftKey);
} catch {
/* storage unavailable: nothing was kept */
}
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
setError(message(err));
} finally {
startingRef.current = false;
setStarting(false);
}
};
// The sidebar row's Rename and Archive, for a Recents row.
const patchSession = async (s: Session, body: { name?: string; archived?: boolean }) => {
try {
await api.sessions.patch(s.id, body);
setVersion((v) => v + 1);
} catch (err) {
setError(message(err));
}
};
const archiveSession = async (s: Session) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
const wt = sessionWorktreeName(lane, s);
const ok = await confirmDialog(
wt && !live
? {
title: t("tree.archiveWorktreeTitle"),
message: t("tree.archiveWorktreeBody", { name: wt }),
confirmLabel: t("tree.archiveWorktreeConfirm"),
}
: live
? {
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
: {
title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
);
if (!ok) return;
if (wt && !live) {
try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
});
} catch (err) {
setError(message(err));
return;
}
}
await patchSession(s, { archived: true });
};
const openSession = (s: Session) => {
const r = liveRunFor(lane, liveRuns, s.id);
navigate(
`/workspace?lane=${lane.id}&${r ? `run=${encodeURIComponent(r.id)}` : `session=${encodeURIComponent(s.id)}`}`
);
};
// The tools a session may start on: installed and enabled, plus the
// workspace's own whatever its state.
const toolOptions = [
...new Set([
lane.tool,
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
]),
];
const danger =
claude && mode === "bypassPermissions"
? t("run:hint.permissionWarning")
: canBypass && bypass
? t("run:hint.bypassWarning", { tool: aiTools.label(tool) })
: codex && sandbox === "danger-full-access"
? t("run:hint.sandboxWarning")
: null;
const name = workspaceName(lane);
const iconBtn =
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
return (
<div
data-testid="workspace-home"
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
>
<div data-prompt-drop className="min-w-0 flex-1 basis-[28rem]">
<p className="text-xs text-fg-muted">
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
</p>
<div className="mt-3 flex items-center gap-2">
{title === null ? (
<h1
title={lane.cwd}
onContextMenu={(e) => setMenuAt(menuPoint(e))}
className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary"
>
{name}
</h1>
) : (
<input
data-testid="workspace-home-rename"
aria-label={t("renameLabel")}
autoFocus
value={title}
maxLength={200}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => setTitle(null)}
onKeyDown={async (e) => {
if (e.key === "Escape") setTitle(null);
if (!isSubmitEnter(e)) return;
const next = title.trim();
setTitle(null);
if (next === (lane.title || "")) return;
try {
await api.lanes.update(lane.id, { title: next });
onRenamed?.();
} catch (err) {
setError(message(err));
}
}}
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-1 text-2xl font-semibold text-fg-primary"
/>
)}
<button
type="button"
data-testid="workspace-home-menu"
aria-label={t("row.actions")}
title={t("row.actions")}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuAt({ x: r.left - 200, y: r.bottom + 4 });
}}
className={iconBtn}
>
<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
data-testid="workspace-home-composer"
className="mt-5 rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50"
>
<PromptEditor
value={prompt}
onChange={setPrompt}
onSubmit={() => void start()}
placeholder={t("home.placeholder")}
rows={3}
slash={claude}
fileCwd={lane.cwd}
lane={lane.id}
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
pickFiles={pickFiles}
bare
/>
<div className="mt-1 flex items-center gap-1">
<ChatAddMenu
cwd={lane.cwd}
laneId={lane.id}
skills={claude}
onUpload={() => pickFiles.current?.()}
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
<div data-testid="workspace-home-tool">
<Select<string>
variant="ghost"
label={t("home.ai")}
value={tool}
onChange={(v) => {
// Not a tool: an action. The AI chosen for the prompt stays.
if (v === OPEN_TERMINAL) return void openTerminal();
setTool(v);
setBypass(false);
}}
options={[
...toolOptions.map((id) => ({
value: id,
label: aiTools.label(id),
icon: (
<AiToolIcon
tool={{ id, label: aiTools.label(id) }}
className="h-4 w-4 !p-0.5"
/>
),
})),
{
value: OPEN_TERMINAL,
label: t("home.openTerminal"),
icon: <SquareTerminal className="h-4 w-4 text-fg-muted" />,
divider: true,
},
]}
/>
</div>
<div className="ml-auto flex items-center gap-1">
{hasModel && (
<ModelMenu
value={model}
onChange={setModel}
tool={tool}
choices={proxyModels}
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
/>
)}
<button
type="button"
data-testid="workspace-home-start"
onClick={() => void start()}
disabled={starting || uploading || !text}
aria-label={t("home.start")}
title={t("home.start")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* The session's quieter choices, under the box like Claude Desktop's. */}
<div
data-testid="workspace-home-options"
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
>
<ProxyPicker
variant="ghost"
tool={tool}
proxies={proxies}
value={proxy}
onChange={setProxy}
/>
{claude && (
<Select<PermissionMode>
variant="ghost"
label={t("run:fields.permissionMode")}
value={mode}
onChange={setMode}
options={(
[
["auto", "Auto"],
["acceptEdits", "AcceptEdits"],
["default", "Default"],
["plan", "Plan"],
["bypassPermissions", "Bypass"],
] as const
).map(([value, key]) => ({
value,
label: t(`home.permission${key}`),
hint: t(`run:fields.permission${key}`),
}))}
/>
)}
{codex && (
<>
<Select<CodexSandbox>
variant="ghost"
label={t("run:fields.sandbox")}
value={sandbox}
onChange={setSandbox}
options={[
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
]}
/>
<Select<CodexApproval>
variant="ghost"
label={t("run:fields.approval")}
value={approval}
onChange={setApproval}
options={[
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
{ value: "never", label: t("run:fields.approvalNever") },
]}
/>
</>
)}
{claude && project?.root && (
<div data-testid="workspace-home-worktree">
<Select<"local" | "worktree">
variant="ghost"
label={t("home.worktreeHint")}
value={inWorktree ? "worktree" : "local"}
onChange={(v) => setInWorktree(v === "worktree")}
options={[
{
value: "local",
label: t("home.local"),
hint: t("home.localHint"),
icon: <Folder className="h-3.5 w-3.5" />,
},
{
value: "worktree",
label: t("home.worktree"),
hint: t("home.worktreeHint"),
icon: <GitBranch className="h-3.5 w-3.5" />,
},
]}
/>
</div>
)}
{canBypass && (
<span className="px-2">
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
/>
</span>
)}
</div>
{danger && (
<p className="mt-2 flex items-start gap-2 text-xs text-status-danger">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{danger}
</p>
)}
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
<h2 className="mt-8 text-sm font-medium text-fg-muted">{t("home.recents")}</h2>
{sessions !== null && sessions.length === 0 && (
<p className="mt-2 text-sm text-fg-muted">{t("home.noRecents")}</p>
)}
<ul data-testid="workspace-recents" className="mt-2 divide-y divide-border/60">
{(sessions ?? []).map((s) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
if (renamingId === s.id) {
return (
<li key={s.id} className="py-1.5">
<input
data-testid={`workspace-recent-rename-${s.id}`}
aria-label={t("tree.renameSession")}
autoFocus
value={nameDraft}
maxLength={200}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={() => setRenamingId(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenamingId(null);
if (!isSubmitEnter(e)) return;
setRenamingId(null);
const name = nameDraft.trim();
if (name && name !== s.name) void patchSession(s, { name });
}}
className="w-full rounded border border-accent bg-surface-1 px-2 py-1 text-sm text-fg-primary"
/>
</li>
);
}
return (
<li key={s.id}>
<button
type="button"
data-testid={`workspace-recent-${s.id}`}
data-live={live ? "true" : undefined}
onClick={() => openSession(s)}
onContextMenu={(e) => recentCtx.open(e, s)}
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
>
<span
aria-hidden
className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`}
/>
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, live)}`)}</span>
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
{sessionWorktreeName(lane, s) && (
<span
title={t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}
className="inline-flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
>
<GitBranch className="h-3 w-3" />
{sessionWorktreeName(lane, s)}
</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>
</button>
</li>
);
})}
</ul>
{sessions !== null && sessionTotal > sessions.length && (
<button
type="button"
data-testid="workspace-recents-more"
onClick={() => setRecentLimit((n) => n + RECENT_PAGE)}
className="mt-1 text-xs text-fg-muted hover:text-fg-primary"
>
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
</button>
)}
{recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && (
<button
type="button"
data-testid="workspace-recents-less"
onClick={() => setRecentLimit(RECENT_PAGE)}
className="ml-3 mt-1 text-xs text-fg-muted hover:text-fg-primary"
>
{t("tree.showLess")}
</button>
)}
<div className="mt-2">
<SessionFold
testId={`workspace-archived-${lane.id}`}
page={archived}
label={(count) => t("tree.archived", { count })}
actionLabel={t("tree.unarchive")}
actionIcon={<ArchiveRestore className="h-3.5 w-3.5" />}
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
onDone={() => setVersion((v) => v + 1)}
laneId={lane.id}
/>
</div>
</div>
<WorkspaceContext
lane={lane}
onSourcesChange={onSourcesChange}
sourcesVersion={sourcesVersion}
/>
{recentCtx.menu && (
<SessionMenu
x={recentCtx.menu.x}
y={recentCtx.menu.y}
laneId={lane.id}
session={recentCtx.menu.item}
run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)}
onClose={recentCtx.close}
onRename={() => {
const s = recentCtx.menu!.item;
setNameDraft(s.name || "");
setRenamingId(s.id);
}}
onArchive={() => void archiveSession(recentCtx.menu!.item)}
/>
)}
{menuAt && (
<WorkspaceRowMenu
lane={lane}
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
/>
)}
</div>
);
}