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
+60 -41
View File
@@ -6,8 +6,8 @@
* per-file stage/unstage/discard it commits — one way to commit, and it only
* ever takes what the user staged.
*
* It renders at the top of the Changes panel (`LaneGitHeader`) rather than in
* the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
* It renders at the top of the Changes panel (`LaneGitHeader`), its actions as
* 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.
* 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.
@@ -30,6 +30,7 @@ function Btn({
busy,
disabled,
title,
compact,
onClick,
}: {
label: string;
@@ -37,6 +38,8 @@ function Btn({
busy?: boolean;
disabled?: boolean;
title?: string;
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
compact?: boolean;
onClick: () => void;
}) {
return (
@@ -45,10 +48,13 @@ function Btn({
onClick={onClick}
disabled={disabled || busy}
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}
{label}
{!compact && label}
</button>
);
}
@@ -58,9 +64,13 @@ export function LaneGitBar({
git,
onChanged,
onNewLane,
lead,
}: {
laneId: number;
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. */
onChanged: () => void;
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
@@ -165,8 +175,9 @@ export function LaneGitBar({
);
return (
<div data-testid="lane-git-bar" className="mb-2 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<div data-testid="lane-git-bar" className="space-y-1">
<div className="flex min-w-0 items-center gap-2">
{lead}
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
{/* Null counts with an upstream named means git could not compare
@@ -176,7 +187,7 @@ export function LaneGitBar({
<span
data-testid="lane-git-tracking"
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 && (
<span className="flex items-center text-status-warning">
@@ -194,43 +205,51 @@ export function LaneGitBar({
</span>
) : (
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
label={t("gitBar.fetch")}
icon={<RefreshCw className="h-3 w-3" />}
busy={busy === "fetch"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.fetchHint")}
onClick={() => run("fetch")}
/>
<Btn
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3 w-3" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
label={t("gitBar.push")}
icon={<Upload className="h-3 w-3" />}
busy={busy === "push"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pushHint")}
onClick={() => run("push")}
/>
<Btn
label={t("gitBar.switch")}
icon={<GitBranch className="h-3 w-3" />}
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 className="ml-auto flex shrink-0 items-center">
<Btn
compact
label={t("gitBar.fetch")}
icon={<RefreshCw className="h-3.5 w-3.5" />}
busy={busy === "fetch"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.fetchHint")}
onClick={() => run("fetch")}
/>
<Btn
compact
label={t("gitBar.pull")}
icon={<ArrowDown className="h-3.5 w-3.5" />}
busy={busy === "pull"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pullHint")}
onClick={() => run("pull")}
/>
<Btn
compact
label={t("gitBar.push")}
icon={<Upload className="h-3.5 w-3.5" />}
busy={busy === "push"}
disabled={!git.hasRemote || !!busy}
title={t("gitBar.pushHint")}
onClick={() => run("push")}
/>
<Btn
compact
label={t("gitBar.switch")}
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>
{switching && (