b0dd5af22f
- 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.
771 lines
29 KiB
TypeScript
771 lines
29 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|