c507d37baf
AI Router is a session's choice, not a workspace's: - an AI Router select beside the model picker in the workspace home and a pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only Native; last pick remembered in agenthub.session.router) - `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets --settings=<data dir>/router-settings/<name>.json (0600, outranks project and user settings, key never in argv), Codex -p <name> + key via tmux -e - dashboard_runs.router; a resume naming none goes back to it - profile PATCH rewrites the session files, DELETE removes them - gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace router radio, the claude-/codex-home `routers` answers; clearLaneRouters takes the old keys out of each workspace's settings.local.json at start Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked on the rail) instead of in the brand's corner. Icons: shipped SVG marks for claude, codex, openclaw and hermes (a black/white pair swapped by theme) in client/public/ai-tools/, used by AiToolIcon before the server's favicon.
732 lines
27 KiB
TypeScript
732 lines
27 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 pane slot (0–3, `POST /api/lanes/:id/start`) and
|
||
* then opens it (`&run=<runId>`), the workspace's Recents — the sessions it
|
||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
|
||
* then the archived ones folded with Unarchive; a live session opens its pane,
|
||
* any other its transcript, nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { Checkbox } from "../Checkbox";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useNavigate } from "react-router-dom";
|
||
import {
|
||
ArchiveRestore,
|
||
FileText,
|
||
Folder,
|
||
GitBranch,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Play,
|
||
Plus,
|
||
X,
|
||
} from "lucide-react";
|
||
import {
|
||
api,
|
||
type LaneMemoryFile,
|
||
type LaneSourcesView,
|
||
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 { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||
import { ModelPicker, PromptEditor } from "../run/RunSetup";
|
||
import { RouterPicker, routerArg, useSessionRouter } from "../run/RouterPicker";
|
||
import { Select } from "../Select";
|
||
import { MarkdownPreview } from "../MarkdownPreview";
|
||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||
import { workspaceName } from "./WorkspaceRow";
|
||
import {
|
||
liveRunFor,
|
||
SessionFold,
|
||
sessionDot,
|
||
sessionWorktreeName,
|
||
useSessionPage,
|
||
} from "./WorkspaceSessionList";
|
||
|
||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||
export function paneRunId(laneId: number, slot: number): string {
|
||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||
}
|
||
|
||
/** The first pane slot with no live run, or null when all four are busy. */
|
||
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
|
||
const busy = new Set(
|
||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||
);
|
||
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
|
||
}
|
||
|
||
function fmtBytes(n: number | null): string {
|
||
if (!n) return "";
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
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. */
|
||
export function WorkspaceHome(props: HomeProps) {
|
||
return <HomeBody key={props.lane.id} {...props} />;
|
||
}
|
||
|
||
function HomeBody({
|
||
lane,
|
||
onMenuAction,
|
||
onRenamed,
|
||
}: {
|
||
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;
|
||
}) {
|
||
const { t } = useTranslation("lanes");
|
||
const navigate = useNavigate();
|
||
const aiTools = useAiTools();
|
||
const prefs = useProjectTreePrefs();
|
||
const claude = lane.tool === "claude";
|
||
const [prompt, setPrompt] = useState("");
|
||
// The tool's default model, also when the catalog arrives after mount.
|
||
const defaultModel = aiTools.byId(lane.tool)?.defaultModel ?? "";
|
||
const [model, setModel] = useState(defaultModel);
|
||
useEffect(() => setModel(defaultModel), [defaultModel]);
|
||
const [mode, setMode] = useState<PermissionMode>("auto");
|
||
// 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 [router, setRouter] = useSessionRouter();
|
||
const [starting, setStarting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||
const [editing, setEditing] = useState<string | null>(null);
|
||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||
const [folderOpen, setFolderOpen] = useState(false);
|
||
const [folderPath, setFolderPath] = useState("");
|
||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||
const [title, setTitle] = useState<string | null>(null);
|
||
const fileInput = useRef<HTMLInputElement>(null);
|
||
|
||
// 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: 30 })
|
||
.then((r) => live && setSessions(r.sessions))
|
||
.catch(() => live && setSessions([]));
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [lane.id, runKey, version]);
|
||
const archived = useSessionPage(
|
||
{ lane: lane.id, archived: true, limit: 30 },
|
||
`${runKey}:${version}`
|
||
);
|
||
|
||
useEffect(() => {
|
||
let live = true;
|
||
setView(null);
|
||
setMemory(null);
|
||
setEditing(null);
|
||
setError(null);
|
||
api.lanes
|
||
.sources(lane.id)
|
||
.then((v) => live && setView(v))
|
||
.catch((err) => live && setError(message(err)));
|
||
api.lanes
|
||
.memory(lane.id)
|
||
.then((r) => live && setMemory(r.files))
|
||
.catch(() => live && setMemory([]));
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [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;
|
||
let live = true;
|
||
api.projects
|
||
.list()
|
||
.then(({ projects }) => {
|
||
if (!live) return;
|
||
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
||
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
||
setSuggestions(
|
||
!p || !prefs.showUntracked
|
||
? []
|
||
: p.worktrees
|
||
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
||
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
||
.map((w) => w.path)
|
||
);
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||
|
||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||
setError(null);
|
||
try {
|
||
setView(await fn());
|
||
return true;
|
||
} catch (err) {
|
||
setError(message(err));
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const start = async () => {
|
||
const slot = freeSlot(lane.id, liveRuns);
|
||
if (slot === null) {
|
||
setError(t("home.allBusy"));
|
||
return;
|
||
}
|
||
setStarting(true);
|
||
setError(null);
|
||
try {
|
||
const r = await api.lanes.action(lane.id, "start", {
|
||
slot,
|
||
prompt: prompt.trim(),
|
||
...(model ? { model } : {}),
|
||
...(claude ? { permissionMode: mode } : {}),
|
||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||
...routerArg(aiTools.byId(lane.tool)?.routerCapable, router),
|
||
});
|
||
setPrompt("");
|
||
const runId = r.run_id ?? paneRunId(lane.id, slot);
|
||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||
} catch (err) {
|
||
setError(message(err));
|
||
} finally {
|
||
setStarting(false);
|
||
}
|
||
};
|
||
|
||
const openSession = (s: Session) => {
|
||
const r = liveRunFor(lane, liveRuns, s.id);
|
||
navigate(
|
||
r ? `/workspace?lane=${lane.id}&run=${encodeURIComponent(r.id)}` : `/sessions/${s.id}`
|
||
);
|
||
};
|
||
|
||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||
const name = workspaceName(lane);
|
||
const card = "border-b border-border px-4 py-4 last:border-b-0";
|
||
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 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}
|
||
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 (e.key !== "Enter") 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 className="card mt-5 p-3">
|
||
<PromptEditor
|
||
value={prompt}
|
||
onChange={setPrompt}
|
||
onSubmit={() => void start()}
|
||
placeholder={t("home.placeholder")}
|
||
rows={3}
|
||
slash={claude}
|
||
fileCwd={lane.cwd}
|
||
/>
|
||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||
<div className="w-56">
|
||
<ModelPicker value={model} onChange={setModel} tool={lane.tool} />
|
||
</div>
|
||
<RouterPicker tool={lane.tool} value={router} onChange={setRouter} />
|
||
{claude && (
|
||
<div className="w-44">
|
||
<Select<PermissionMode>
|
||
value={mode}
|
||
onChange={setMode}
|
||
options={[
|
||
{ value: "auto", label: t("run:fields.permissionAuto") },
|
||
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
|
||
{ value: "default", label: t("run:fields.permissionDefault") },
|
||
{ value: "plan", label: t("run:fields.permissionPlan") },
|
||
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
|
||
]}
|
||
/>
|
||
</div>
|
||
)}
|
||
{claude && project?.root && (
|
||
<Checkbox
|
||
checked={inWorktree}
|
||
onChange={setInWorktree}
|
||
label={t("home.worktree")}
|
||
title={t("home.worktreeHint")}
|
||
className="ml-1"
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
data-testid="workspace-home-start"
|
||
onClick={() => void start()}
|
||
disabled={starting}
|
||
className="btn-primary ml-auto text-xs disabled:opacity-50"
|
||
>
|
||
<Play className="h-3.5 w-3.5" />
|
||
{t("home.start")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{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);
|
||
return (
|
||
<li key={s.id}>
|
||
<button
|
||
type="button"
|
||
data-testid={`workspace-recent-${s.id}`}
|
||
data-live={live ? "true" : undefined}
|
||
onClick={() => openSession(s)}
|
||
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
|
||
>
|
||
<span className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`} />
|
||
<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>
|
||
<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)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
|
||
{view && !view.supported && (
|
||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||
</p>
|
||
)}
|
||
|
||
<section className={card}>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
|
||
{editing === null && (
|
||
<button
|
||
type="button"
|
||
aria-label={t("home.edit")}
|
||
title={t("home.edit")}
|
||
onClick={() => setEditing(view?.instructions ?? "")}
|
||
disabled={!view}
|
||
className={iconBtn}
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{editing === null ? (
|
||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
|
||
{view?.instructions || t("home.instructionsEmpty")}
|
||
</p>
|
||
) : (
|
||
<div className="mt-2 space-y-2">
|
||
<textarea
|
||
data-testid="workspace-instructions"
|
||
aria-label={t("home.instructions")}
|
||
autoFocus
|
||
value={editing}
|
||
maxLength={view?.maxInstructions}
|
||
onChange={(e) => setEditing(e.target.value)}
|
||
rows={6}
|
||
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
||
/>
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditing(null)}
|
||
className="btn-ghost text-xs"
|
||
>
|
||
{t("home.cancel")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
||
setEditing(null);
|
||
}}
|
||
className="btn-primary text-xs"
|
||
>
|
||
{t("home.save")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className={card}>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMemoryOpen(true)}
|
||
disabled={!memory?.length}
|
||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||
>
|
||
{t("home.viewMemory")}
|
||
</button>
|
||
</div>
|
||
<p className="mt-1 text-xs text-fg-muted">
|
||
{memory?.length
|
||
? t("home.memoryCount", { count: memory.length })
|
||
: t("home.memoryEmpty")}
|
||
</p>
|
||
</section>
|
||
|
||
<section className={card}>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||
<button
|
||
type="button"
|
||
aria-label={t("home.addFile")}
|
||
title={t("home.addFile")}
|
||
onClick={() => fileInput.current?.click()}
|
||
disabled={!view}
|
||
className={iconBtn}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
</button>
|
||
<input
|
||
ref={fileInput}
|
||
data-testid="workspace-file-input"
|
||
type="file"
|
||
multiple
|
||
hidden
|
||
onChange={async (e) => {
|
||
const picked = [...(e.target.files ?? [])];
|
||
e.target.value = "";
|
||
for (const f of picked) {
|
||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
{files.length === 0 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInput.current?.click()}
|
||
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
|
||
>
|
||
{t("home.contextEmpty")}
|
||
</button>
|
||
) : (
|
||
<ul className="mt-2 space-y-1">
|
||
{files.map((f) => (
|
||
<li key={f.id} className="group flex items-center gap-2 text-xs">
|
||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||
{f.name}
|
||
</span>
|
||
<span className="shrink-0 text-fg-muted">
|
||
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={`${t("home.remove")} ${f.name}`}
|
||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section className={card}>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
|
||
<button
|
||
type="button"
|
||
aria-label={t("home.addFolder")}
|
||
title={t("home.addFolder")}
|
||
onClick={() => setFolderOpen((v) => !v)}
|
||
disabled={!view}
|
||
className={iconBtn}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<ul className="mt-2 space-y-1.5">
|
||
<li className="flex items-start gap-2 text-xs">
|
||
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||
<span className="min-w-0">
|
||
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
||
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
||
</span>
|
||
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
||
</span>
|
||
</li>
|
||
{folders.map((f) => (
|
||
<li key={f.id} className="group flex items-center gap-2 text-xs">
|
||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||
{f.name}
|
||
{f.missing && (
|
||
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
||
)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={`${t("home.remove")} ${f.name}`}
|
||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{folderOpen && (
|
||
<form
|
||
className="mt-3 space-y-2"
|
||
onSubmit={async (e) => {
|
||
e.preventDefault();
|
||
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
||
setFolderPath("");
|
||
setFolderOpen(false);
|
||
}
|
||
}}
|
||
>
|
||
<div className="flex gap-2">
|
||
<input
|
||
data-testid="workspace-folder-input"
|
||
aria-label={t("home.folderPath")}
|
||
placeholder={t("home.folderPath")}
|
||
autoFocus
|
||
value={folderPath}
|
||
onChange={(e) => setFolderPath(e.target.value)}
|
||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||
/>
|
||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||
{t("home.add")}
|
||
</button>
|
||
</div>
|
||
{suggestions.length > 0 && (
|
||
<div>
|
||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||
{t("home.suggested")}
|
||
</p>
|
||
<ul className="mt-1 space-y-0.5">
|
||
{suggestions.map((p) => (
|
||
<li key={p}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setFolderPath(p)}
|
||
title={p}
|
||
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
||
>
|
||
{p}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</form>
|
||
)}
|
||
</section>
|
||
</aside>
|
||
|
||
{memoryOpen && memory && (
|
||
<div
|
||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||
role="presentation"
|
||
onClick={() => setMemoryOpen(false)}
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={t("home.memory")}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
|
||
className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||
>
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||
<button
|
||
type="button"
|
||
autoFocus
|
||
aria-label={t("home.close")}
|
||
onClick={() => setMemoryOpen(false)}
|
||
className={iconBtn}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
{memory.map((f) => (
|
||
<section key={f.path} className="mb-5">
|
||
<p title={f.path} className="mb-1 font-mono text-xs text-fg-muted">
|
||
{f.label}
|
||
</p>
|
||
<MarkdownPreview text={f.content} />
|
||
</section>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{menuAt && (
|
||
<WorkspaceRowMenu
|
||
lane={lane}
|
||
x={menuAt.x}
|
||
y={menuAt.y}
|
||
onClose={() => setMenuAt(null)}
|
||
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|