feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)

- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
This commit is contained in:
2026-10-06 12:36:06 +07:00
parent 6c605849d8
commit f0500dc592
41 changed files with 1201 additions and 249 deletions
+218 -47
View File
@@ -2,16 +2,16 @@
* @file The Changes panel: the lane's code beside its console, in the five
* shapes a review actually needs —
*
* overview — every changed file with its +/- counts and its stage/discard
* actions; click one to open it
* overview — every changed file, in a folder tree (JetBrains' commit view),
* with its +/- counts and its stage/discard actions; click one
* to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the
* Workspace right panel's Files tab; the other four are its
* Changes tab)
* mode switcher's Files button; at full width the sidebar)
*
* and three ranges: `work` (uncommitted, what the agent has touched right
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
@@ -65,6 +65,7 @@ import {
Plus,
RefreshCw,
Rows3,
Sparkles,
Trash2,
UnfoldVertical,
} from "lucide-react";
@@ -81,6 +82,7 @@ import { LaneConflictView } from "./LaneConflictView";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import type {
LaneChangeFile,
LaneChangeRange,
@@ -421,8 +423,13 @@ function ChangeRow({
onArm,
onResolve,
onMenu,
name,
depth = 0,
}: {
f: LaneChangeFile;
/** In the folder tree: the file's own name, and how deep it sits. */
name?: string;
depth?: number;
range: LaneChangeRange;
onResolve: Resolve;
onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void;
@@ -435,7 +442,11 @@ function ChangeRow({
}) {
const { t } = useTranslation(["lanes"]);
return (
<li className="group flex items-baseline gap-1" onContextMenu={(e) => onMenu(e, f)}>
<li
className="group flex items-baseline gap-1"
style={depth ? { paddingLeft: `${depth * 0.9}rem` } : undefined}
onContextMenu={(e) => onMenu(e, f)}
>
<button
type="button"
onClick={() => onOpen(f.path)}
@@ -453,7 +464,7 @@ function ChangeRow({
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary"
title={f.path}
>
{f.path}
{name ?? f.path}
</span>
{f.conflicted && (
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
@@ -470,6 +481,80 @@ function ChangeRow({
);
}
/** One folder of the overview's change tree: its sub-folders (each a
* collapsible row with its file count), then its files. */
function ChangeTreeNodes({
dir,
depth,
group,
collapsed,
onToggle,
renderFile,
folderAction,
}: {
dir: ChangeTreeDir<LaneChangeFile>;
depth: number;
/** The overview group, so the same folder folds apart in each. */
group: string;
collapsed: Set<string>;
onToggle: (key: string) => void;
renderFile: (f: LaneChangeFile, name: string, depth: number) => React.ReactNode;
/** The folder row's own action, e.g. stage every file in it. */
folderAction?: (files: LaneChangeFile[]) => React.ReactNode;
}) {
const { t } = useTranslation(["lanes"]);
return (
<>
{dir.dirs.map((d) => {
const key = `${group}:${d.path}`;
const open = !collapsed.has(key);
return (
<li key={d.path}>
<div
className="group flex items-center gap-1"
style={{ paddingLeft: `${depth * 0.9}rem` }}
>
<button
type="button"
aria-expanded={open}
onClick={() => onToggle(key)}
title={d.path}
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{open ? (
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<Folder className="h-3 w-3 shrink-0 text-accent" />
<span className="min-w-0 truncate">{d.name}</span>
<span className="shrink-0 font-sans text-[10px] text-fg-muted">
{t("changes.fileCount", { count: d.count })}
</span>
</button>
{folderAction?.(treeFiles(d))}
</div>
{open && (
<ul className="space-y-0.5">
<ChangeTreeNodes
dir={d}
depth={depth + 1}
group={group}
collapsed={collapsed}
onToggle={onToggle}
renderFile={renderFile}
folderAction={folderAction}
/>
</ul>
)}
</li>
);
})}
{dir.files.map(({ name, file }) => renderFile(file, name, depth))}
</>
);
}
/** History's page size, and the most the server reads (`listCommits`). */
const HISTORY_PAGE = 50;
const HISTORY_MAX = 200;
@@ -479,7 +564,6 @@ export function LaneChangesPanel({
session,
onNewLane,
full = false,
tab = "changes",
onDirtyChange,
}: {
laneId: number;
@@ -489,10 +573,6 @@ export function LaneChangesPanel({
* instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */
session?: string;
/** The Workspace right panel's activity-bar tab: `files` is the browse mode
* on its own (no git header, no mode switcher), `changes` the other four.
* At full width both show the explorer sidebar beside the changes. */
tab?: "files" | "changes";
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a
@@ -603,6 +683,15 @@ export function LaneChangesPanel({
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// The overview tree's folded groups and folders (`<group>:<folder path>`);
// everything starts open.
const [treeCollapsed, setTreeCollapsed] = useState<Set<string>>(new Set());
const toggleTreeDir = (key: string) =>
setTreeCollapsed((prev) => {
const next = new Set(prev);
if (!next.delete(key)) next.add(key);
return next;
});
// Where opening a file came FROM: its mode and how far the list was scrolled.
// Reviewing a change-set is a walk down a long list, and landing back at the
// top of it after every file is the whole cost of opening one.
@@ -610,6 +699,7 @@ export function LaneChangesPanel({
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
const [suggesting, setSuggesting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Every diff read gets a ticket; only the newest may land. A lane switch (or
@@ -687,8 +777,8 @@ export function LaneChangesPanel({
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
}, [mode, laneId, reread, historyLimit]);
// Re-run when a lane switch empties the cache too: the Files tab stays in
// `browse` across the switch, so the mode alone never changes.
// Re-run when a lane switch empties the cache too: the full-width sidebar
// stays across the switch, so the mode alone never changes.
const treeRootLoaded = "" in treeChildren;
useEffect(() => {
const wanted = full || (mode === "browse" && !query.trim());
@@ -814,17 +904,16 @@ export function LaneChangesPanel({
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
// After the lane reset above: each tab opens on its own mode.
// After the lane reset above: a workspace opens on its changes.
// Leaving the open file this way (the Workspace asked first when it was
// edited) drops it whole: a draft left behind would keep `dirty` true.
useEffect(() => {
if (full) return;
setMode(tab === "files" ? "browse" : "overview");
setMode("overview");
setOpenFile(null);
setWhole(false);
setDraft(null);
}, [tab, laneId, full]);
const filesTab = tab === "files" && !full;
}, [laneId, full]);
// Restoring has to wait for the list to be TALL enough again: leaving a file
// re-fetches the mode it came from, so the first render back is an empty
@@ -1167,6 +1256,21 @@ export function LaneChangesPanel({
}
};
// ~10 s: the server runs `claude -p` on the staged diff. Its own flag, not
// `busy`, so staging stays usable while it thinks.
const suggestMessage = async () => {
const lane = laneId;
setSuggesting(true);
try {
const { message: suggested } = await api.lanes.suggestCommitMessage(target);
if (laneNow.current === lane) setMessage(suggested);
} catch (err) {
if (laneNow.current === lane) toast.error(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setSuggesting(false);
}
};
const rereadAll = () => {
refreshGit();
// The tree cache is dropped whole: after a switch any folder may differ.
@@ -1219,7 +1323,7 @@ export function LaneChangesPanel({
};
// The Files mode's body. At full width it lives in the sidebar instead, so
// the Files tab is gone there and the main area never shows it twice.
// the Files button is gone there and the main area never shows it twice.
const browsePane = (
<div data-testid="lane-changes-browse">
{/* Pinned: the finder is what you reach for after scrolling a
@@ -1303,15 +1407,13 @@ export function LaneChangesPanel({
</aside>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{!filesTab && (
<LaneGitHeader
laneId={laneId}
session={session}
git={git}
onChanged={rereadAll}
onNewLane={onNewLane}
/>
)}
<LaneGitHeader
laneId={laneId}
session={session}
git={git}
onChanged={rereadAll}
onNewLane={onNewLane}
/>
{/* ONE wrapping row: where you are (mode) then how you are looking at it
(range, layout, context, whole file), refresh last. Each group is
its own inline box, so a narrow panel wraps BETWEEN groups — never
@@ -1327,9 +1429,14 @@ export function LaneChangesPanel({
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : filesTab ? null : (
) : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{(["overview", "review", "history"] as const).map((m) => (
{/* Files is the whole repo's tree; at full width it is the
sidebar already, so no button. */}
{(full
? (["overview", "review", "history"] as const)
: (["overview", "browse", "review", "history"] as const)
).map((m) => (
<button
key={m}
type="button"
@@ -1595,7 +1702,22 @@ export function LaneChangesPanel({
/>
<button
type="button"
disabled={busy || !message.trim()}
data-testid="lane-changes-suggest-message"
disabled={suggesting}
onClick={() => void suggestMessage()}
title={t("changes.suggestMessage")}
aria-label={t("changes.suggestMessage")}
className="shrink-0 flex items-center rounded border border-border-light px-1 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{suggesting ? (
<RefreshCw className="h-3 w-3 animate-spin" aria-hidden />
) : (
<Sparkles className="h-3 w-3" aria-hidden />
)}
</button>
<button
type="button"
disabled={busy || suggesting || !message.trim()}
onClick={() => void commitStaged()}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
@@ -1668,29 +1790,78 @@ export function LaneChangesPanel({
data-testid={g.key === "conflicts" ? "lane-changes-conflicts" : undefined}
>
{g.key !== "all" && (
<div className="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-fg-secondary">
<button
type="button"
aria-expanded={!treeCollapsed.has(`${g.key}:`)}
onClick={() => toggleTreeDir(`${g.key}:`)}
className="flex w-full items-center gap-1.5 px-1 py-0.5 text-left text-[10px] font-semibold uppercase tracking-wide text-fg-secondary hover:text-fg-primary"
>
{treeCollapsed.has(`${g.key}:`) ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
{t(`changes.${g.key}Group`)}
<span className="rounded-full bg-surface-4 px-1.5 font-mono text-fg-primary">
{g.rows.length}
</span>
</div>
</button>
)}
<ul data-group={g.key} className="space-y-0.5">
{g.rows.map((f) => (
<ChangeRow
key={f.path}
f={f}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
onResolve={resolve}
onMenu={fileCtx.open}
{!treeCollapsed.has(`${g.key}:`) && (
<ul data-group={g.key} className="space-y-0.5">
{/* Folders like JetBrains' commit view: a long
change-set reads by area, not as one flat list. */}
<ChangeTreeNodes
dir={changeTree(g.rows)}
depth={g.key === "all" ? 0 : 1}
group={g.key}
collapsed={treeCollapsed}
onToggle={toggleTreeDir}
renderFile={(f, name, depth) => (
<ChangeRow
key={f.path}
f={f}
name={name}
depth={depth}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
onResolve={resolve}
onMenu={fileCtx.open}
/>
)}
folderAction={
g.key === "staged" || g.key === "unstaged"
? (rows) => (
<IconButton
label={t(
g.key === "staged"
? "changes.unstageFolder"
: "changes.stageFolder"
)}
disabled={busy}
onClick={() =>
void act(
g.key === "staged" ? "unstage" : "stage",
rows.map((f) => f.path)
)
}
>
{g.key === "staged" ? (
<Minus className="h-3 w-3" />
) : (
<Plus className="h-3 w-3" />
)}
</IconButton>
)
: undefined
}
/>
))}
</ul>
</ul>
)}
</div>
)
)}