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:
@@ -29,8 +29,9 @@
|
||||
* ## Dismissal persistence
|
||||
* Dismissals are keyed in `localStorage` by the release tag when there is one,
|
||||
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
|
||||
* a dismissed one stays dismissed. Settings can reset dismissal via the
|
||||
* `dashboard:reset-update-dismissal` window event.
|
||||
* a dismissed one stays dismissed. The connection modal's "Check for updates"
|
||||
* fires `dashboard:reset-update-dismissal`, which clears both the skip and the
|
||||
* page-load close; `z-[110]` keeps this modal above that `z-[100]` one.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -184,7 +185,11 @@ export function UpdateNotifier() {
|
||||
}, [syncFromPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setDismissedSha(null);
|
||||
// An explicit "Check for updates" undoes both kinds of close.
|
||||
const handler = () => {
|
||||
setDismissedSha(null);
|
||||
setClosedKey(null);
|
||||
};
|
||||
window.addEventListener("dashboard:reset-update-dismissal", handler);
|
||||
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
||||
}, []);
|
||||
@@ -313,7 +318,7 @@ export function UpdateNotifier() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-notifier-title"
|
||||
|
||||
@@ -238,6 +238,16 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
|
||||
});
|
||||
|
||||
it("Check for updates re-opens a modal closed with Later", async () => {
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
window.dispatchEvent(new Event("dashboard:reset-update-dismissal"));
|
||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores Escape while an update is applying", async () => {
|
||||
applyMock.mockReturnValue(new Promise(() => {}));
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
@@ -82,6 +82,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
cwds: vi.fn(async () => ({ items: [] })),
|
||||
kill: vi.fn(async () => ({ ok: true })),
|
||||
history: vi.fn(async () => ({ items: [] })),
|
||||
forgetHistory: vi.fn(async () => ({ removed: "" })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||
@@ -417,6 +418,30 @@ describe("WorkspaceTree", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
|
||||
});
|
||||
|
||||
it("removes a closed terminal from its hover X and its right-click menu", async () => {
|
||||
const shell = (id: string) => ({
|
||||
id,
|
||||
tool: "shell",
|
||||
status: "exited",
|
||||
started_at: "2026-10-06T08:00:00Z",
|
||||
ended_at: "2026-10-06T08:05:00Z",
|
||||
isLive: false,
|
||||
});
|
||||
vi.mocked(api.run.history).mockImplementation(
|
||||
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
|
||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
|
||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
|
||||
await user.click(screen.getByText("Remove from list"));
|
||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
|
||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
|
||||
});
|
||||
|
||||
it("selects by URL, and a click selects and expands", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree("/workspace?lane=2");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"changes.collapseFile": "Collapse {{path}}",
|
||||
"changes.commitPlaceholder": "Commit message",
|
||||
"changes.commitStaged": "Commit {{count}} staged",
|
||||
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)",
|
||||
"changes.conflicted": "conflict",
|
||||
"changes.discard": "Discard this file's changes",
|
||||
"changes.discardConfirm": "Click again to discard — this cannot be undone",
|
||||
@@ -82,7 +83,7 @@
|
||||
"changes.loading": "Reading the working copy…",
|
||||
"changes.mode.browse": "Files",
|
||||
"changes.mode.history": "History",
|
||||
"changes.mode.overview": "Overview",
|
||||
"changes.mode.overview": "Changes",
|
||||
"changes.mode.review": "Review",
|
||||
"changes.noCommits": "No commits yet.",
|
||||
"changes.noHunks": "No textual change.",
|
||||
@@ -98,6 +99,10 @@
|
||||
"changes.size.compact": "Narrow",
|
||||
"changes.size.half": "Medium",
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.fileCount_one": "{{count}} file",
|
||||
"changes.fileCount_other": "{{count}} files",
|
||||
"changes.stageFolder": "Stage every file in this folder",
|
||||
"changes.unstageFolder": "Unstage every file in this folder",
|
||||
"changes.stageAll": "Stage all ({{count}})",
|
||||
"changes.unstageAll": "Unstage all ({{count}})",
|
||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||
@@ -299,7 +304,6 @@
|
||||
"menu.copyPath": "Copy path",
|
||||
"menu.reset": "Reset worktree…",
|
||||
"menu.remove": "Delete workspace…",
|
||||
"panel.files": "Files",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"replay.placeholder": "Message to continue this session…",
|
||||
@@ -323,6 +327,8 @@
|
||||
"tree.newSession": "New session",
|
||||
"tree.noSessions": "No sessions yet",
|
||||
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
|
||||
"tree.reopenTerminal": "Open a new terminal",
|
||||
"tree.forgetTerminal": "Remove from list",
|
||||
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
||||
"tree.renameSession": "Rename session",
|
||||
|
||||
@@ -587,6 +587,8 @@
|
||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
||||
"fieldBehavesAs": "Behaves as (Claude Code)",
|
||||
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.",
|
||||
"perTier": "Per-tier overrides",
|
||||
"perTierHint": "Leave a field blank to use the model above.",
|
||||
"tier": {
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"changes.collapseFile": "Thu gọn {{path}}",
|
||||
"changes.commitPlaceholder": "Nội dung commit",
|
||||
"changes.commitStaged": "Commit {{count}} file đã stage",
|
||||
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)",
|
||||
"changes.conflicted": "xung đột",
|
||||
"changes.discard": "Bỏ thay đổi của file này",
|
||||
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
|
||||
@@ -82,7 +83,7 @@
|
||||
"changes.loading": "Đang đọc working copy…",
|
||||
"changes.mode.browse": "Tệp",
|
||||
"changes.mode.history": "Lịch sử",
|
||||
"changes.mode.overview": "Tổng quan",
|
||||
"changes.mode.overview": "Thay đổi",
|
||||
"changes.mode.review": "Xem toàn bộ",
|
||||
"changes.noCommits": "Chưa có commit nào.",
|
||||
"changes.noHunks": "Không có thay đổi nội dung.",
|
||||
@@ -98,6 +99,10 @@
|
||||
"changes.size.compact": "Hẹp",
|
||||
"changes.size.half": "Vừa",
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.fileCount_one": "{{count}} tệp",
|
||||
"changes.fileCount_other": "{{count}} tệp",
|
||||
"changes.stageFolder": "Stage mọi tệp trong thư mục này",
|
||||
"changes.unstageFolder": "Bỏ stage mọi tệp trong thư mục này",
|
||||
"changes.stageAll": "Stage tất cả ({{count}})",
|
||||
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
|
||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||
@@ -299,7 +304,6 @@
|
||||
"menu.copyPath": "Sao chép đường dẫn",
|
||||
"menu.reset": "Đặt lại worktree…",
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"panel.files": "Tệp",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||
@@ -323,6 +327,8 @@
|
||||
"tree.newSession": "Session mới",
|
||||
"tree.noSessions": "Chưa có session",
|
||||
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
|
||||
"tree.reopenTerminal": "Mở terminal mới",
|
||||
"tree.forgetTerminal": "Xóa khỏi danh sách",
|
||||
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
||||
"tree.renameSession": "Đổi tên session",
|
||||
|
||||
@@ -587,6 +587,8 @@
|
||||
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
|
||||
"fieldBehavesAs": "Hoạt động như (Claude Code)",
|
||||
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của router này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model router thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
|
||||
"perTier": "Ghi đè theo từng bậc",
|
||||
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
||||
"tier": {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's folder tree: grouping by folder,
|
||||
* compacting single-child chains, counts, ordering and the per-folder file list.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { changeTree, treeFiles } from "../changeTree";
|
||||
|
||||
const rows = [
|
||||
"app2/Services/TwitterService.php",
|
||||
"app2/Services/Redis/SlowAlertRedisService.php",
|
||||
"app2/Enums/Redis/RedisSlowAlertKey.php",
|
||||
"config/monitoring.php",
|
||||
"config/app.php",
|
||||
"README.md",
|
||||
].map((path) => ({ path }));
|
||||
|
||||
describe("changeTree", () => {
|
||||
const root = changeTree(rows);
|
||||
|
||||
it("puts root files at the top level and counts every file", () => {
|
||||
expect(root.count).toBe(6);
|
||||
expect(root.files.map((f) => f.name)).toEqual(["README.md"]);
|
||||
expect(root.dirs.map((d) => d.name)).toEqual(["app2", "config"]);
|
||||
});
|
||||
|
||||
it("compacts a folder chain with nothing else in it", () => {
|
||||
const app2 = root.dirs[0]!;
|
||||
expect(app2.count).toBe(3);
|
||||
expect(app2.dirs.map((d) => [d.name, d.path])).toEqual([
|
||||
["Enums/Redis", "app2/Enums/Redis"],
|
||||
["Services", "app2/Services"],
|
||||
]);
|
||||
// Services holds a file, so it is not folded into Redis.
|
||||
expect(app2.dirs[1]!.dirs[0]!.name).toBe("Redis");
|
||||
});
|
||||
|
||||
it("sorts files by name and lists a folder's files", () => {
|
||||
expect(root.dirs[1]!.files.map((f) => f.name)).toEqual(["app.php", "monitoring.php"]);
|
||||
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -2186,6 +2186,9 @@ export const api = {
|
||||
if (opts.limit) qs.set("limit", String(opts.limit));
|
||||
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
|
||||
},
|
||||
/** DELETE /api/run/history/:id - forget a closed Terminal tab (shell rows only). */
|
||||
forgetHistory: (id: string) =>
|
||||
request<{ removed: string }>(`/run/history/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
|
||||
binary: (tool: LaneTool = "claude") =>
|
||||
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
|
||||
@@ -2757,6 +2760,12 @@ export const api = {
|
||||
current: string | null;
|
||||
worktrees: LaneWorktree[];
|
||||
}>(gitUrl(id, "/branches")),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
|
||||
* written by the user's `claude` CLI (no tools, nothing persisted).
|
||||
*/
|
||||
suggestCommitMessage: (id: GitTarget) =>
|
||||
request<{ message: string }>(gitUrl(id, "/commit-message"), { method: "POST" }),
|
||||
/**
|
||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||
* @param id The lane id.
|
||||
@@ -3286,6 +3295,8 @@ export interface AiRouter {
|
||||
hasKey: boolean;
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
/** Claude model id the router's own model names behave as; "" when unset. */
|
||||
behavesAs?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
@@ -3318,6 +3329,8 @@ export interface AiRouterInput {
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file Folds the Changes panel's flat list of changed files into a folder
|
||||
* tree, JetBrains' commit-view shape: folders first, then files, each sorted
|
||||
* by name, and a chain of folders that hold nothing but one sub-folder drawn
|
||||
* as ONE row (`src/lib`), so a deep path does not cost a row per level.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export interface ChangeTreeDir<T> {
|
||||
/** The row's label — one segment, or several joined by `/` when compacted. */
|
||||
name: string;
|
||||
/** The folder's repo-relative path ("" for the root). */
|
||||
path: string;
|
||||
dirs: ChangeTreeDir<T>[];
|
||||
files: { name: string; file: T }[];
|
||||
/** Every file anywhere below. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
|
||||
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
|
||||
for (const file of rows) {
|
||||
const parts = file.path.split("/");
|
||||
let dir = root;
|
||||
dir.count++;
|
||||
for (const seg of parts.slice(0, -1)) {
|
||||
const path = dir.path ? `${dir.path}/${seg}` : seg;
|
||||
let next = dir.dirs.find((d) => d.path === path);
|
||||
if (!next) dir.dirs.push((next = { name: seg, path, dirs: [], files: [], count: 0 }));
|
||||
next.count++;
|
||||
dir = next;
|
||||
}
|
||||
dir.files.push({ name: parts[parts.length - 1] ?? file.path, file });
|
||||
}
|
||||
const finish = (d: ChangeTreeDir<T>): ChangeTreeDir<T> => {
|
||||
// Never the root: its children are the group's top level.
|
||||
let only: ChangeTreeDir<T> | undefined;
|
||||
while (d.path && d.files.length === 0 && d.dirs.length === 1 && (only = d.dirs[0])) {
|
||||
d = { ...only, name: `${d.name}/${only.name}` };
|
||||
}
|
||||
d.dirs = d.dirs.map(finish).sort((a, b) => a.name.localeCompare(b.name));
|
||||
d.files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return d;
|
||||
};
|
||||
return finish(root);
|
||||
}
|
||||
|
||||
/** Every file under a folder, for its stage/unstage-all button. */
|
||||
export function treeFiles<T>(d: ChangeTreeDir<T>): T[] {
|
||||
return [...d.files.map((f) => f.file), ...d.dirs.flatMap(treeFiles)];
|
||||
}
|
||||
@@ -39,8 +39,9 @@ import { useDialog } from "../hooks/useDialog";
|
||||
|
||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models" | "behavesAs"> & {
|
||||
apiKey: string;
|
||||
behavesAs: string;
|
||||
models: Partial<AiRouterModels>;
|
||||
};
|
||||
|
||||
@@ -50,6 +51,7 @@ const emptyForm = (): FormState => ({
|
||||
apiKey: "",
|
||||
model: "",
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
});
|
||||
|
||||
export function AiRouterConfig() {
|
||||
@@ -121,6 +123,7 @@ export function AiRouterConfig() {
|
||||
apiKey: "",
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
behavesAs: item.behavesAs ?? "",
|
||||
});
|
||||
setFormError(null);
|
||||
setOpen(true);
|
||||
@@ -175,6 +178,7 @@ export function AiRouterConfig() {
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
models,
|
||||
behavesAs: form.behavesAs.trim(),
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
|
||||
@@ -370,6 +374,15 @@ export function AiRouterConfig() {
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-behaves-as"
|
||||
label={t("aiRouter.fieldBehavesAs")}
|
||||
hint={t("aiRouter.fieldBehavesAsHint")}
|
||||
value={form.behavesAs}
|
||||
mono
|
||||
placeholder="claude-opus-5-5"
|
||||
onChange={(v) => setForm((f) => ({ ...f, behavesAs: v }))}
|
||||
/>
|
||||
|
||||
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-secondary">
|
||||
|
||||
@@ -61,8 +61,6 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
FolderTree,
|
||||
GitCompare,
|
||||
ChartColumn,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -120,17 +118,9 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the right panel's activity-bar tab. */
|
||||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
|
||||
const PANEL_TABS = [
|
||||
{ id: "files", Icon: FolderTree },
|
||||
{ id: "changes", Icon: GitCompare },
|
||||
] as const;
|
||||
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||||
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
* "work" changes several times an hour — hence ONE control that steps
|
||||
@@ -600,21 +590,6 @@ export function Workspace() {
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
|
||||
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
|
||||
} catch {
|
||||
return "changes";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||
} catch {
|
||||
/* the panel just forgets its tab between visits */
|
||||
}
|
||||
}, [panelTab]);
|
||||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||||
// localStorage and never reaches the server — same rule as the lane strip's
|
||||
// order (client/src/lib/laneOrderStorage.ts).
|
||||
@@ -1231,36 +1206,17 @@ export function Workspace() {
|
||||
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
||||
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
||||
>
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize
|
||||
control — the same height as the console's tab bar, so the
|
||||
two read as one header strip. */}
|
||||
{/* Header: the title, Analyst, then the resize control — the
|
||||
same height as the console's tab bar, so the two read as
|
||||
one header strip. Files and Changes are ONE panel now; its
|
||||
own mode switcher moves between them. */}
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||
role="toolbar"
|
||||
>
|
||||
{PANEL_TABS.map(({ id, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-testid={`panel-tab-${id}`}
|
||||
aria-pressed={panelTab === id}
|
||||
// Narrow, a tab switch leaves the open file (and its
|
||||
// edit); at full width the editor stays put.
|
||||
onClick={() =>
|
||||
panelTab !== id &&
|
||||
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
|
||||
}
|
||||
title={tLanes(`panel.${id}`)}
|
||||
aria-label={tLanes(`panel.${id}`)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||||
panelTab === id
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1318,7 +1274,6 @@ export function Workspace() {
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
tab={panelTab}
|
||||
full={changesFull}
|
||||
onDirtyChange={setChangesDirty}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
|
||||
@@ -104,11 +104,30 @@ describe("AiRouterConfig", () => {
|
||||
// Every tier equals the headline model, so none is sent: the server
|
||||
// fills them, which is what makes one field enough.
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
apiKey: "sk-typed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("sends only the tiers that differ from the headline model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -6981,7 +6981,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.1.0 (rebuild client to match)
|
||||
UI build v2.2.0 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user