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>
)
)}
@@ -14,7 +14,8 @@
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** — and under the sessions the last
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
@@ -31,7 +32,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
@@ -42,7 +43,7 @@ import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
import { useContextMenu } from "../ContextMenu";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 6;
@@ -297,6 +298,15 @@ export function WorkspaceSessionList({
toast.error(err);
}
};
const closedCtx = useContextMenu<RunHistoryItem>();
const forgetShell = async (r: RunHistoryItem) => {
try {
await api.run.forgetHistory(r.id);
setClosedShells((all) => all.filter((x) => x.id !== r.id));
} catch (err) {
toast.error(err);
}
};
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
@@ -458,19 +468,19 @@ export function WorkspaceSessionList({
>
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span>
<span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
</span>
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{r.startedAt ? timeAgo(r.startedAt) : null}
</span>
{r.tool === "shell" ? (
<>
<span className="truncate">{t("tabs.terminal")}</span>
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
</>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
) : (
<>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
</>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
)}
</button>
</li>
@@ -554,9 +564,12 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at)}
</span>
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
/>
</button>
<button
@@ -575,11 +588,12 @@ export function WorkspaceSessionList({
);
})}
{closed.map((r) => (
<li key={r.id}>
<li key={r.id} className="group relative">
<button
type="button"
data-testid={`workspace-terminal-closed-${r.id}`}
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
onContextMenu={(e) => closedCtx.open(e, r)}
title={t("tree.closedTerminal")}
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
@@ -592,7 +606,20 @@ export function WorkspaceSessionList({
<span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at)}
</span>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
<SquareTerminal
aria-hidden
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
/>
</button>
<button
type="button"
data-testid={`workspace-terminal-forget-${r.id}`}
onClick={() => void forgetShell(r)}
title={t("tree.forgetTerminal")}
aria-label={t("tree.forgetTerminal")}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
@@ -619,6 +646,42 @@ export function WorkspaceSessionList({
onClose={ctx.close}
/>
)}
{closedCtx.menu && (
<ContextMenu
x={closedCtx.menu.x}
y={closedCtx.menu.y}
onClose={closedCtx.close}
testId={`terminal-menu-${closedCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(false);
}}
>
{t("tree.reopenTerminal")}
</MenuItem>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(true);
}}
>
{t("sessionMenu.openSplit")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const item = closedCtx.menu!.item;
closedCtx.close();
void forgetShell(item);
}}
>
{t("tree.forgetTerminal")}
</MenuItem>
</ContextMenu>
)}
{sessionCtx.menu && (
<SessionMenu
x={sessionCtx.menu.x}
@@ -13,7 +13,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
@@ -32,6 +31,7 @@ vi.mock("../../../lib/api", () => ({
gitBranches: vi.fn(),
changes: vi.fn(),
gitAction: vi.fn(),
suggestCommitMessage: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitRawUrl: (id: number, path: string, rev: string) =>
@@ -92,7 +92,7 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
/** The overview row's discard button for one path. */
function discardOf(path: string) {
return within(screen.getByText(path).closest("li")!).getByRole("button", {
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
name: "Discard this file's changes",
});
}
@@ -140,8 +140,8 @@ beforeEach(() => {
});
});
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
/** The panel as the Workspace renders it: Files and Changes are its own mode
* buttons now, no activity-bar tab around it. */
function Tabbed({
laneId,
onDirtyChange,
@@ -149,18 +149,7 @@ function Tabbed({
laneId: number;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
<button type="button" onClick={() => setTab("files")}>
Files
</button>
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
</>
);
return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
}
describe("LaneChangesPanel", () => {
@@ -169,7 +158,7 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(screen.getByText("+12")).toBeInTheDocument();
expect(screen.queryByText(/new line/)).toBeNull();
});
@@ -194,7 +183,7 @@ describe("LaneChangesPanel", () => {
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
@@ -258,6 +247,26 @@ describe("LaneChangesPanel", () => {
});
});
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
// `server/lib` holds nothing but `lib`: one compacted row.
const folder = within(list).getByTitle("server/lib");
expect(folder).toHaveTextContent("1 file");
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
await user.click(folder);
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
await user.click(
within(folder.parentElement!).getByRole("button", {
name: "Stage every file in this folder",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
vi.mocked(api.lanes.git).mockResolvedValue({
available: true,
@@ -341,16 +350,26 @@ describe("LaneChangesPanel", () => {
});
});
it("fills the commit box with the AI-suggested message", async () => {
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-suggest-message"));
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
});
it("lists staged files apart from the changes the next commit leaves behind", async () => {
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(staged).queryByText("notes.txt")).toBeNull();
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
});
@@ -468,7 +487,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
@@ -486,7 +505,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByText("server/lib/git-ops.js"));
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
const view = await screen.findByTestId("lane-file-view");
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
toString: () => "copy",
@@ -517,7 +536,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByText("server/lib/git-ops.js");
const row = within(list).getByTitle("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
await right();
@@ -577,9 +596,9 @@ describe("LaneChangesPanel", () => {
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
// A plain refresh (the poll's twin), not a git action — an action already disarms.
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
await user.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByText("server/lib/git-ops.js");
await screen.findByTitle("server/lib/git-ops.js");
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
@@ -612,7 +631,7 @@ describe("LaneChangesPanel", () => {
resolveOld(changes());
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
it("opens a commit from history as a commit-range review", async () => {
@@ -784,33 +803,6 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
// The Workspace asked before switching tab; the edit must then be GONE, or
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
it("drops an edited file when the tab switches away from it", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "src/main.ts",
rev: "work",
binary: false,
truncated: false,
lines: ["const whole = 1;"],
sha: "b".repeat(40),
});
const dirty = vi.fn();
const user = userEvent.setup();
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
await user.click(await screen.findByTestId("lane-file-view"));
await user.type(await screen.findByTestId("lane-file-editor"), "x");
expect(dirty).toHaveBeenLastCalledWith(true);
await user.click(screen.getByRole("button", { name: "Changes" }));
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
@@ -892,7 +884,7 @@ describe("LaneChangesPanel", () => {
],
})
);
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
@@ -903,7 +895,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await screen.findByRole("button", { name: "Show more surrounding lines" });
// Collapse is dead at the tightest step, and live once expanded — an
@@ -933,7 +925,7 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByText("server/lib/git-ops.js"));
await user.click(screen.getByTitle("server/lib/git-ops.js"));
await screen.findByText(/server\/lib\/git-ops\.js/);
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
@@ -995,11 +987,10 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("leaves Files out of the mode row: it is the right panel's own tab", async () => {
it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
for (const name of ["Overview", "Review", "History"]) {
for (const name of ["Changes", "Files", "Review", "History"]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
@@ -1351,9 +1342,9 @@ describe("LaneChangesPanel", () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
const before = screen.getByText("server/lib/git-ops.js");
const before = screen.getByTitle("server/lib/git-ops.js");
await user.type(screen.getByLabelText("Commit message"), "x");
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
});
it("expands all folders with ONE request, not one per folder", async () => {
@@ -1418,7 +1409,7 @@ describe("LaneChangesPanel", () => {
lane7Reads
);
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
});
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {