fix(run,lanes): prompt autocomplete, per-lane Active runs, compact Changes header

- The run form's prompt box offers suggestions again: `/` lists the run
  cwd's project and user commands and skills plus the CLI built-ins
  (claude lanes only), `@` lists files under the cwd; Enter picks while
  the list is open and sends otherwise. The 2f39f4e rewrite had left a
  bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
  (sessions matched to the lane by longest path-boundary cwd), instead of
  every lane's.
- The Changes panel's git line and its actions are one compact row in one
  card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
  leaving an empty frame where the git row used to be.
This commit is contained in:
2026-09-24 13:30:30 +07:00
parent faa56f5c75
commit 428519335d
15 changed files with 583 additions and 130 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -480,7 +480,9 @@ export default function LaneCard({
<> <>
<div <div
data-testid={`lane-card-${lane.id}`} data-testid={`lane-card-${lane.id}`}
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light" // Every row below is conditional: a lane with nothing to report
// renders no frame at all rather than an empty box.
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
> >
{(lane.ci_status || lane.tool === "codex") && ( {(lane.ci_status || lane.tool === "codex") && (
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]"> <div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
@@ -630,12 +630,12 @@ export function LaneChangesPanel({
return ( return (
<div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col"> <div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col">
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} /> <LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
{/* Two rows on purpose: WHERE you are (mode, and the refresh that always {/* ONE wrapping row: where you are (mode) then how you are looking at it
applies) on top, HOW you are looking at it (range, layout, context, (range, layout, context, whole file), refresh last. Each group is
whole file) underneath. One row wrapped mid-group as soon as the its own inline box, so a narrow panel wraps BETWEEN groups — never
panel was narrow, which is every panel beside a console. */} through one, which is what made the old single row unreadable. */}
<div className="mb-2 space-y-1.5"> <div className="mb-2 flex flex-wrap items-center gap-1.5">
<div className="flex items-center gap-1.5"> <div className="contents">
{mode === "file" ? ( {mode === "file" ? (
<button <button
type="button" type="button"
@@ -677,14 +677,14 @@ export function LaneChangesPanel({
onClick={rereadAll} onClick={rereadAll}
aria-label={t("changes.refresh")} aria-label={t("changes.refresh")}
title={t("changes.refresh")} title={t("changes.refresh")}
className="ml-auto shrink-0 text-fg-muted hover:text-fg-primary" className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
> >
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</button> </button>
</div> </div>
{mode !== "history" && mode !== "browse" && ( {mode !== "history" && mode !== "browse" && (
<div className="flex flex-wrap items-center gap-1.5"> <div className="contents">
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5"> <div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(["work", "branch"] as const).map((r) => ( {(["work", "branch"] as const).map((r) => (
<button <button
+60 -41
View File
@@ -6,8 +6,8 @@
* per-file stage/unstage/discard it commits — one way to commit, and it only * per-file stage/unstage/discard it commits — one way to commit, and it only
* ever takes what the user staged. * ever takes what the user staged.
* *
* It renders at the top of the Changes panel (`LaneGitHeader`) rather than in * It renders at the top of the Changes panel (`LaneGitHeader`), its actions as
* the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset * icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
* worktree`), and these are the ordinary moves a lane needs several times an hour. * worktree`), and these are the ordinary moves a lane needs several times an hour.
* Nothing here decides what an action may do; the server does (pull is always * Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said. * --ff-only, switch refuses a dirty tree), and this only reports what it said.
@@ -30,6 +30,7 @@ function Btn({
busy, busy,
disabled, disabled,
title, title,
compact,
onClick, onClick,
}: { }: {
label: string; label: string;
@@ -37,6 +38,8 @@ function Btn({
busy?: boolean; busy?: boolean;
disabled?: boolean; disabled?: boolean;
title?: string; title?: string;
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
compact?: boolean;
onClick: () => void; onClick: () => void;
}) { }) {
return ( return (
@@ -45,10 +48,13 @@ function Btn({
onClick={onClick} onClick={onClick}
disabled={disabled || busy} disabled={disabled || busy}
title={title} title={title}
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40" aria-label={compact ? label : undefined}
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
}`}
> >
{icon} {icon}
{label} {!compact && label}
</button> </button>
); );
} }
@@ -58,9 +64,13 @@ export function LaneGitBar({
git, git,
onChanged, onChanged,
onNewLane, onNewLane,
lead,
}: { }: {
laneId: number; laneId: number;
git: LaneGitFacts; git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
lead?: React.ReactNode;
/** Called after any action that could have moved the working copy. */ /** Called after any action that could have moved the working copy. */
onChanged: () => void; onChanged: () => void;
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on /** Open the Add-lane flow for a sibling worktree of this repo, optionally on
@@ -165,8 +175,9 @@ export function LaneGitBar({
); );
return ( return (
<div data-testid="lane-git-bar" className="mb-2 space-y-1"> <div data-testid="lane-git-bar" className="space-y-1">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex min-w-0 items-center gap-2">
{lead}
{/* Ahead/behind first: it is the fact that decides which of the {/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */} buttons beside it the user actually wants. */}
{/* Null counts with an upstream named means git could not compare {/* Null counts with an upstream named means git could not compare
@@ -176,7 +187,7 @@ export function LaneGitBar({
<span <span
data-testid="lane-git-tracking" data-testid="lane-git-tracking"
title={git.upstream} title={git.upstream}
className="flex items-center gap-1 font-mono text-[11px] text-fg-muted" className="flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
> >
{(git.ahead ?? 0) > 0 && ( {(git.ahead ?? 0) > 0 && (
<span className="flex items-center text-status-warning"> <span className="flex items-center text-status-warning">
@@ -194,43 +205,51 @@ export function LaneGitBar({
</span> </span>
) : ( ) : (
git.hasRemote && ( git.hasRemote && (
<span className="font-mono text-[11px] text-fg-muted">{t("gitBar.noUpstream")}</span> <span className="shrink-0 font-mono text-[11px] text-fg-muted">
{t("gitBar.noUpstream")}
</span>
) )
)} )}
<Btn <div className="ml-auto flex shrink-0 items-center">
label={t("gitBar.fetch")} <Btn
icon={<RefreshCw className="h-3 w-3" />} compact
busy={busy === "fetch"} label={t("gitBar.fetch")}
disabled={!git.hasRemote || !!busy} icon={<RefreshCw className="h-3.5 w-3.5" />}
title={t("gitBar.fetchHint")} busy={busy === "fetch"}
onClick={() => run("fetch")} disabled={!git.hasRemote || !!busy}
/> title={t("gitBar.fetchHint")}
<Btn onClick={() => run("fetch")}
label={t("gitBar.pull")} />
icon={<ArrowDown className="h-3 w-3" />} <Btn
busy={busy === "pull"} compact
disabled={!git.hasRemote || !!busy} label={t("gitBar.pull")}
title={t("gitBar.pullHint")} icon={<ArrowDown className="h-3.5 w-3.5" />}
onClick={() => run("pull")} busy={busy === "pull"}
/> disabled={!git.hasRemote || !!busy}
<Btn title={t("gitBar.pullHint")}
label={t("gitBar.push")} onClick={() => run("pull")}
icon={<Upload className="h-3 w-3" />} />
busy={busy === "push"} <Btn
disabled={!git.hasRemote || !!busy} compact
title={t("gitBar.pushHint")} label={t("gitBar.push")}
onClick={() => run("push")} icon={<Upload className="h-3.5 w-3.5" />}
/> busy={busy === "push"}
<Btn disabled={!git.hasRemote || !!busy}
label={t("gitBar.switch")} title={t("gitBar.pushHint")}
icon={<GitBranch className="h-3 w-3" />} onClick={() => run("push")}
disabled={!!busy} />
// A dirty tree is refused server-side; saying so before the click <Btn
// beats a red box after it. compact
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")} label={t("gitBar.switch")}
onClick={openSwitch} icon={<GitBranch className="h-3.5 w-3.5" />}
/> disabled={!!busy}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={openSwitch}
/>
</div>
</div> </div>
{switching && ( {switching && (
+58 -43
View File
@@ -13,6 +13,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar"; import { LaneGitBar } from "./LaneGitBar";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame"; import { keepIfSame } from "../../lib/keepIfSame";
@@ -230,50 +231,64 @@ export function LaneGitHeader({
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []); useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
if (!git?.available) return null; if (!git?.available) return null;
return ( const lead = (
<div data-testid="lane-git-header"> <>
{/* One line, because every part of it is short and the panel is {/* One row with the actions: branch first and strongest, then the head
narrow: branch, head, subject, and the uncommitted counts read as a and its subject, which is what truncates when the panel is narrow
sentence rather than a four-row block. */} (the whole of it stays in the tooltip). */}
<div className="mb-2"> <div
<div data-testid="lane-git"
data-testid="lane-git" className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
className="flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary" title={`${git.head} ${git.subject}`}
title={git.subject} >
> <span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
<span className="shrink-0">⑂ {git.branch}</span> <GitBranch className="h-3 w-3 text-fg-muted" />
<span className="shrink-0 text-fg-muted">{git.head}</span> {git.branch}
<span className="truncate text-fg-muted">{git.subject}</span> </span>
{(git.dirty > 0 || git.untracked > 0) && ( <span className="shrink-0 text-fg-muted">{git.head}</span>
<button <span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
type="button"
ref={setCountsAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
onClick={() => setFilesOpen((v) => !v)}
className="shrink-0 cursor-help text-status-warning/80 underline decoration-dotted underline-offset-2"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</button>
)}
</div>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={countsAnchor}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</div> </div>
<LaneGitBar laneId={laneId} git={git} onChanged={onChanged} onNewLane={onNewLane} /> {(git.dirty > 0 || git.untracked > 0) && (
<button
type="button"
ref={setCountsAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
onClick={() => setFilesOpen((v) => !v)}
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</button>
)}
</>
);
return (
<div
data-testid="lane-git-header"
className="mb-2 rounded-md border border-border bg-surface-2 px-2 py-1"
>
<LaneGitBar
laneId={laneId}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}
lead={lead}
/>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={countsAnchor}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</div> </div>
); );
} }
@@ -55,7 +55,7 @@ describe("LaneGitHeader", () => {
expect(block.textContent).toContain("feat/rename-metric"); expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a"); expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form"); expect(block.textContent).toContain("free-text rule mode in the form");
expect(block.textContent).toContain("2"); expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
}); });
it("drops the previous lane's facts the moment the lane changes", async () => { it("drops the previous lane's facts the moment the lane changes", async () => {
+33 -3
View File
@@ -27,7 +27,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react"; import { Play, AlertCircle } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
@@ -47,6 +47,20 @@ import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup"; import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory"; import { ActiveRunsSwitcher } from "./RunHistory";
/** The claude lane a session's cwd belongs to: the longest path-boundary
* prefix, the same rule the server binds hook sessions with. External
* sessions are all claude — Codex emits no hooks. */
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
let best: Lane | null = null;
for (const l of lanes) {
if (l.tool !== "claude") 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?.id ?? null;
}
export interface LaneConsolePaneProps { export interface LaneConsolePaneProps {
lanes: Lane[]; lanes: Lane[];
laneId: number | null; laneId: number | null;
@@ -97,6 +111,22 @@ export function LaneConsolePane({
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null; const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const laneTool = currentLane?.tool ?? "claude"; const laneTool = currentLane?.tool ?? "claude";
// The page polls runs and sessions for EVERY lane; this pane's Active runs
// list is this lane's only. No lane selected keeps the full lists.
const laneRuns = useMemo(
() =>
laneId === null || !activeRuns
? activeRuns
: { ...activeRuns, items: activeRuns.items.filter((r) => r.laneId === laneId) },
[activeRuns, laneId]
);
const laneSessions = useMemo(
() =>
laneId === null
? externalSessions
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
[externalSessions, laneId, lanes]
);
const toolLabel = laneTool === "codex" ? "Codex" : "Claude"; const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
useEffect(() => { useEffect(() => {
@@ -487,11 +517,11 @@ export function LaneConsolePane({
</p> </p>
</div> </div>
<ActiveRunsSwitcher <ActiveRunsSwitcher
activeRuns={activeRuns} activeRuns={laneRuns}
currentHandleId={handle?.id || null} currentHandleId={handle?.id || null}
onAttach={attachToRun} onAttach={attachToRun}
runHistory={runHistory} runHistory={runHistory}
externalSessions={externalSessions} externalSessions={laneSessions}
onResumeFromHistory={onResumeFromHistory} onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory} onViewFromHistory={onViewFromHistory}
onKill={killRun} onKill={killRun}
+272 -15
View File
@@ -53,30 +53,214 @@ import type {
RunStartArgs, RunStartArgs,
CodexSandbox, CodexSandbox,
CodexApproval, CodexApproval,
CcMdItem,
} from "../../lib/api"; } from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types"; import type { Session, LaneTool } from "../../lib/types";
import { Select } from "../Select"; import { Select } from "../Select";
// Minimal SlashCommand type (from deleted RunConsole)
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;
source: "project" | "user" | "plugin" | "builtin"; source: "project" | "user" | "builtin";
description?: string; description?: string;
} }
// Minimal PromptEditor component (from deleted RunConsole) // Commands the CLI answers itself. They are typed into the pane like any
// other text, so they do run — they are just not files anyone can list.
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
"help",
"clear",
"compact",
"config",
"model",
"memory",
"hooks",
"cost",
"agents",
"review",
"permissions",
"status",
"init",
"mcp",
"plugin",
"output-style",
"exit",
].map((name) => ({ name, source: "builtin" }));
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
project: "text-status-success border-status-success/40",
user: "text-accent border-accent/40",
builtin: "text-fg-muted border-border",
};
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
* description (3+ chars only, so one keystroke drags nothing tangential in). */
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
if (!q) return 1;
const n = name.toLowerCase();
if (n === q) return 1000;
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
const idx = n.indexOf(q);
if (idx >= 0) return 400 - Math.min(idx, 100);
let i = 0;
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
if (i === q.length) return 200;
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
return 0;
}
interface Trigger {
kind: "slash" | "file";
query: string;
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
start: number;
cursor: number;
}
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
function detectTrigger(value: string, cursor: number): Trigger | null {
let start = cursor;
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
const tok = value.slice(start, cursor);
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
return null;
}
interface PromptEditorProps { interface PromptEditorProps {
value: string; value: string;
onChange: (s: string) => void; onChange: (s: string) => void;
onSubmit: () => void; onSubmit: () => void;
placeholder: string; placeholder: string;
rows?: number; rows?: number;
slashCommands: SlashCommand[]; /** Offer slash commands. Off for codex: these are Claude Code's. */
slash: boolean;
/** Where `@` looks for files and project commands/skills are read from. */
fileCwd: string; fileCwd: string;
} }
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) { function PromptEditor({
value,
onChange,
onSubmit,
placeholder,
rows = 5,
slash,
fileCwd,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
if (commandsCwd !== fileCwd) {
setCommandsCwd(fileCwd);
setCommands(null);
}
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
// Commands and skills are read the first time `/` is typed for this cwd —
// two directory walks nobody should pay for on every render of the form.
useEffect(() => {
if (kind !== "slash" || commands) return;
let live = true;
const toCmd = (c: CcMdItem): SlashCommand => ({
name: c.name,
source: c.scope,
description: c.frontmatter?.description || c.preview.slice(0, 80),
});
Promise.all([
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
]).then(([c, sk]) => {
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
});
return () => {
live = false;
};
}, [kind, commands, fileCwd]);
// Files: debounced, and only the newest query may land.
const query = trigger?.query ?? "";
useEffect(() => {
if (kind !== "file") return;
let live = true;
const tid = window.setTimeout(() => {
api.run
.files(fileCwd, query)
.then((r) => live && setFiles(r.items))
.catch(() => live && setFiles([]));
}, 120);
return () => {
live = false;
window.clearTimeout(tid);
};
}, [kind, query, fileCwd]);
const slashItems = useMemo(() => {
if (kind !== "slash" || !commands) return [];
const q = query.toLowerCase();
return commands
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
.filter((x) => x.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
a.cmd.name.localeCompare(b.cmd.name)
)
.map((x) => x.cmd);
}, [kind, commands, query]);
const count = kind === "file" ? files.length : slashItems.length;
const current = Math.min(active, Math.max(0, count - 1));
const pick = (token: string) => {
if (!trigger) return;
const before = value.slice(0, trigger.start);
const after = value.slice(trigger.cursor);
const sep = after.startsWith(" ") ? "" : " ";
onChange(before + token + sep + after);
setTrigger(null);
setActive(0);
const pos = before.length + token.length + 1;
requestAnimationFrame(() => {
taRef.current?.focus();
taRef.current?.setSelectionRange(pos, pos);
});
};
const pickAt = (i: number) => {
if (kind === "file") {
const f = files[i];
if (f) pick(`@${f}`);
} else {
const c = slashItems[i];
if (c) pick(`/${c.name}`);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (kind && count > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const step = e.key === "ArrowDown" ? 1 : -1;
setActive(Math.min(count - 1, Math.max(0, current + step)));
return;
}
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
pickAt(current);
return;
}
}
if (kind && e.key === "Escape") {
e.preventDefault();
setTrigger(null);
return;
}
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline. // Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate. // isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
@@ -85,15 +269,89 @@ function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: Prom
} }
}; };
const retrack = (ta: HTMLTextAreaElement) => {
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
};
const rowClass = (i: number) =>
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
return ( return (
<textarea <div className="relative">
value={value} <textarea
onChange={(e) => onChange(e.target.value)} ref={taRef}
onKeyDown={handleKeyDown} value={value}
placeholder={placeholder} onChange={(e) => {
rows={rows} onChange(e.target.value);
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none" retrack(e.target);
/> setActive(0);
}}
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
{kind && (
<div
role="listbox"
data-testid="prompt-autocomplete"
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
>
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
</div>
{count === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
</div>
) : kind === "slash" ? (
slashItems.map((c, i) => (
<button
key={`${c.source}:${c.name}`}
type="button"
role="option"
aria-selected={i === current}
// Keep focus in the textarea, or its blur closes the list first.
onMouseDown={(e) => e.preventDefault()}
onClick={() => pickAt(i)}
onMouseEnter={() => setActive(i)}
className={rowClass(i)}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
<span
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
>
{c.source}
</span>
</div>
{c.description && (
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
)}
</button>
))
) : (
files.map((f, i) => (
<button
key={f}
type="button"
role="option"
aria-selected={i === current}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pickAt(i)}
onMouseEnter={() => setActive(i)}
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
>
{f}
</button>
))
)}
</div>
)}
</div>
); );
} }
@@ -122,7 +380,6 @@ interface RunSetupProps {
* sessions. Undefined when no lane is selected (the picker then lists * sessions. Undefined when no lane is selected (the picker then lists
* everything, same as before lanes existed). */ * everything, same as before lanes existed). */
laneCwd?: string; laneCwd?: string;
slashCommands?: SlashCommand[];
runHistory: DashboardRunHistoryItem[]; runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void; onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
/** The selected lane's CLI. Decides which advanced fields render. */ /** The selected lane's CLI. Decides which advanced fields render. */
@@ -287,7 +544,7 @@ export function RunSetup(props: RunSetupProps) {
onSubmit={() => handleStart(props)} onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal")} placeholder={t("fields.promptPlaceholderTerminal")}
rows={5} rows={5}
slashCommands={props.slashCommands ?? []} slash={props.tool !== "codex"}
fileCwd={props.resumeSession?.cwd || props.cwd} fileCwd={props.resumeSession?.cwd || props.cwd}
/> />
<div className="mt-1 text-[10px] text-fg-muted"> <div className="mt-1 text-[10px] text-fg-muted">
@@ -331,4 +331,56 @@ describe("LaneConsolePane", () => {
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument(); expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
}); });
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
const run = (id: string, laneId: number, promptPreview: string) => ({
id,
laneId,
status: "running",
cwd: "/workspace/x",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: "2026-09-24T01:00:00Z",
promptPreview,
});
const session = (id: string, cwd: string, name: string) =>
({
id,
cwd,
name,
model: null,
source: "local",
started_at: "2026-09-24T01:00:00Z",
}) as never;
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
lanes={[
LANE,
{ ...LANE, id: 2, cwd: "/workspace/b" },
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
]}
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
externalSessions={[
session("s1", "/workspace/a/src", "my session"),
session("s2", "/workspace/b", "b session"),
session("s3", "/workspace/a/nested/x", "nested session"),
session("s4", "/workspace/ab", "sibling prefix"),
]}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
expect(screen.getByText("mine")).toBeTruthy();
expect(screen.getByText("my session")).toBeTruthy();
expect(screen.queryByText("other lane's")).toBeNull();
expect(screen.queryByText("b session")).toBeNull();
expect(screen.queryByText("nested session")).toBeNull();
expect(screen.queryByText("sibling prefix")).toBeNull();
});
}); });
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next"; import i18n from "i18next";
import { RunSetup } from "../RunSetup"; import { RunSetup } from "../RunSetup";
import type { CwdSuggestion } from "../../../lib/api"; import type { CwdSuggestion } from "../../../lib/api";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
@@ -26,7 +27,22 @@ vi.mock("../../../lib/api", async (importOriginal) => {
api: { api: {
run: { files: r({ items: [] }) }, run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) }, sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
ccConfig: { file: r({ text: "" }) }, ccConfig: {
file: r({ text: "" }),
commands: r({
items: [
{
scope: "project",
name: "ship-it",
frontmatter: { description: "Ship the change" },
preview: "",
},
],
}),
skills: r({
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
}),
},
}, },
}; };
}); });
@@ -66,7 +82,6 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
busy={false} busy={false}
activeRuns={null} activeRuns={null}
resumeSession={null} resumeSession={null}
slashCommands={[]}
runHistory={[]} runHistory={[]}
tool="claude" tool="claude"
sandbox="workspace-write" sandbox="workspace-write"
@@ -300,7 +315,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
busy={false} busy={false}
activeRuns={null} activeRuns={null}
resumeSession={null} resumeSession={null}
slashCommands={[]}
runHistory={[]} runHistory={[]}
laneCwd="/Users/tester/lane-b" laneCwd="/Users/tester/lane-b"
tool="claude" tool="claude"
@@ -364,3 +378,48 @@ describe("RunSetup — per-tool fields", () => {
expect(onResumeLast).toHaveBeenCalled(); expect(onResumeLast).toHaveBeenCalled();
}); });
}); });
describe("RunSetup — / and @ suggestions in the prompt box", () => {
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const { spies } = renderSetup({ prompt: "" });
fireEvent.change(box(), { target: { value: "/rev" } });
// Both prefix matches; the shorter name ranks first.
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual([
expect.stringContaining("/review"),
expect.stringContaining("/review-pr"),
]);
fireEvent.keyDown(box(), { key: "ArrowDown" });
// Read for the directory the run will start in, not the server's own.
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
fireEvent.keyDown(box(), { key: "Enter" });
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
// Enter picked the suggestion; it did not send the prompt.
expect(spies.onStart).not.toHaveBeenCalled();
});
it("suggests files under the cwd on @", async () => {
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
// The prompt is controlled by the page: put the cursor at the end of it.
const { spies } = renderSetup({ prompt: "look at @ma" });
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
fireEvent.select(box());
fireEvent.click(await screen.findByText("src/main.ts"));
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
});
it("offers no slash commands on a codex lane", async () => {
renderSetup({ prompt: "", tool: "codex" });
fireEvent.change(box(), { target: { value: "/rev" } });
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
expect(api.ccConfig.commands).not.toHaveBeenCalled();
});
});
+15 -4
View File
@@ -482,6 +482,15 @@ export function dashboardToken(): string | null {
} }
} }
/** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */
function ccQuery(scope?: CcScope, cwd?: string): string {
const qs = new URLSearchParams();
if (scope) qs.set("scope", scope);
if (cwd) qs.set("cwd", cwd);
const q = qs.toString();
return q ? `?${q}` : "";
}
/** /**
* Shared fetch wrapper used by every method on {@link api}. Prefixes `path` * Shared fetch wrapper used by every method on {@link api}. Prefixes `path`
* with {@link BASE} ("/api"), attaches the dashboard auth token (if any) as * with {@link BASE} ("/api"), attaches the dashboard auth token (if any) as
@@ -1390,10 +1399,11 @@ export const api = {
* list endpoints below (`agents`, `commands`, `outputStyles`). * list endpoints below (`agents`, `commands`, `outputStyles`).
* *
* @param scope Optional {@link CcScope} ("user"|"project"|"all") filter. * @param scope Optional {@link CcScope} ("user"|"project"|"all") filter.
* @param cwd Project directory to read, instead of the server's own cwd.
* @returns `{ items }` — the {@link CcMdItem} summaries for each skill. * @returns `{ items }` — the {@link CcMdItem} summaries for each skill.
*/ */
skills: (scope?: CcScope) => skills: (scope?: CcScope, cwd?: string) =>
request<{ items: CcMdItem[] }>(`/cc-config/skills${scope ? `?scope=${scope}` : ""}`), request<{ items: CcMdItem[] }>(`/cc-config/skills${ccQuery(scope, cwd)}`),
/** /**
* GET /api/cc-config/agents - user and/or project subagent definitions. * GET /api/cc-config/agents - user and/or project subagent definitions.
* @param scope Optional {@link CcScope} filter. * @param scope Optional {@link CcScope} filter.
@@ -1404,10 +1414,11 @@ export const api = {
/** /**
* GET /api/cc-config/commands - user and/or project slash commands. * GET /api/cc-config/commands - user and/or project slash commands.
* @param scope Optional {@link CcScope} filter. * @param scope Optional {@link CcScope} filter.
* @param cwd Project directory to read, instead of the server's own cwd.
* @returns `{ items }` — {@link CcMdItem} summaries for each command. * @returns `{ items }` — {@link CcMdItem} summaries for each command.
*/ */
commands: (scope?: CcScope) => commands: (scope?: CcScope, cwd?: string) =>
request<{ items: CcMdItem[] }>(`/cc-config/commands${scope ? `?scope=${scope}` : ""}`), request<{ items: CcMdItem[] }>(`/cc-config/commands${ccQuery(scope, cwd)}`),
/** /**
* GET /api/cc-config/output-styles. * GET /api/cc-config/output-styles.
* @param scope Optional {@link CcScope} filter. * @param scope Optional {@link CcScope} filter.
+2 -1
View File
@@ -874,7 +874,8 @@ export function Workspace() {
)} )}
{currentLane && showInfo && ( {currentLane && showInfo && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto"> <div className="max-h-[45vh] shrink-0 overflow-y-auto">
<div className="mb-3"> {/* No margin when the card rendered nothing (see LaneCard). */}
<div className="mb-3 has-[>div:empty]:mb-0">
<LaneCard <LaneCard
lane={currentLane} lane={currentLane}
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)} onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
@@ -5570,11 +5570,16 @@ exports[`screen snapshots > Run 1`] = `
> >
Prompt Prompt
</label> </label>
<textarea <div
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none" class="relative"
placeholder="Ask Claude anything…" >
rows="5" <textarea
/> class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
placeholder="Ask Claude anything…"
rows="5"
spellcheck="false"
/>
</div>
<div <div
class="mt-1 text-[10px] text-fg-muted" class="mt-1 text-[10px] text-fg-muted"
> >
+4 -2
View File
@@ -1838,9 +1838,9 @@ The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Cl
```http ```http
GET /api/cc-config/overview GET /api/cc-config/overview
GET /api/cc-config/skills?scope=user|project|all GET /api/cc-config/skills?scope=user|project|all&cwd=<dir>
GET /api/cc-config/agents GET /api/cc-config/agents
GET /api/cc-config/commands GET /api/cc-config/commands?scope=user|project|all&cwd=<dir>
GET /api/cc-config/output-styles GET /api/cc-config/output-styles
GET /api/cc-config/plugins GET /api/cc-config/plugins
GET /api/cc-config/marketplaces GET /api/cc-config/marketplaces
@@ -1876,6 +1876,8 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?,
POST /api/cc-config/mcp/remove Body: { name, scope? } POST /api/cc-config/mcp/remove Body: { name, scope? }
``` ```
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a lane's own project commands and skills are the ones offered.
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`. `scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts. `GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
+2 -2
View File
@@ -540,8 +540,8 @@ sync" about a comparison that was never possible. The panel renders them as
### Acting on a lane's working copy: the git action row ### Acting on a lane's working copy: the git action row
Under the git line, the Changes panel carries the per-repo git **writes** the On the same row as the git line — as icons, each naming itself in its tooltip —
dashboard offers (`POST /api/lanes/:id/git/:action`, see `docs/API.md`): the Changes panel carries the per-repo git **writes** the dashboard offers (`POST /api/lanes/:id/git/:action`, see `docs/API.md`):
| Button | Runs | Refuses when | | Button | Runs | Refuses when |
| --- | --- | --- | | --- | --- | --- |