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
@@ -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);
});
});
+13
View File
@@ -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),
+51
View File
@@ -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)];
}