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:
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user