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
+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)];
}