feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+42 -28
View File
@@ -21,7 +21,8 @@
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip above it, which hands the panel the selection's 1-based lines.
* Chat chip at the end of its first line (inline, so it covers no code),
* which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -49,9 +50,7 @@ import {
highlightActiveLineGutter,
keymap,
lineNumbers,
showTooltip,
type DecorationSet,
type Tooltip,
type ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
@@ -285,23 +284,41 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
});
}
/** Add to Chat above a non-empty selection; `build` makes its DOM from the
* selection's first and last 1-based line. A selection ending at a line's
* start (a whole-line drag) does not take that line. */
class ChatChip extends WidgetType {
constructor(
readonly start: number,
readonly end: number,
readonly build: (start: number, end: number) => HTMLElement
) {
super();
}
eq(o: ChatChip) {
return this.start === o.start && this.end === o.end;
}
toDOM() {
return this.build(this.start, this.end);
}
}
/** Add to Chat inline at the end of a non-empty selection's first line —
* the empty space right of the code, so it covers no line. `build` makes its
* DOM from the selection's first and last 1-based line. A selection ending
* at a line's start (a whole-line drag) does not take that line. */
function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<Tooltip | null>({
create: () => null,
update(tip, tr) {
if (!tr.docChanged && !tr.selection) return tip;
return StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
if (!tr.docChanged && !tr.selection) return deco;
const { from, to, empty } = tr.state.selection.main;
if (empty) return null;
if (empty) return Decoration.none;
const doc = tr.state.doc;
const start = doc.lineAt(from).number;
const first = doc.lineAt(from);
const last = doc.lineAt(to);
const end = to === last.from && last.number > start ? last.number - 1 : last.number;
return { pos: from, above: true, create: () => ({ dom: build(start, end) }) };
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
const widget = new ChatChip(first.number, end, build);
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
},
provide: (f) => showTooltip.from(f),
provide: (f) => EditorView.decorations.from(f),
});
}
@@ -319,8 +336,6 @@ const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
const M = "&.cm-merge-b.cm-merge-b";
const A = "&.cm-merge-a.cm-merge-a";
const theme = EditorView.theme({
// The chip draws its own box; drop its wrapper's default tooltip one.
".cm-tooltip.cm-ah-chat": { border: "none", background: "transparent" },
"&": {
height: "100%",
fontSize: "11px",
@@ -491,7 +506,11 @@ export default function DiffEditor({
useEffect(() => {
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
const common = [lineNumbers(), diffColours(lang), theme];
// A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the
// panel would read that as an unsaved edit (no Undo / Keep bars) and Save
// would rewrite every line ending.
const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : [];
const common = [lineNumbers(), diffColours(lang), theme, ...eol];
const editing = [
...common,
highlightActiveLineGutter(),
@@ -513,9 +532,9 @@ export default function DiffEditor({
]),
EditorView.contentAttributes.of({ "aria-label": label }),
EditorView.updateListener.of((u) => {
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
if (u.docChanged) cb.current.onChange(u.state.sliceDoc());
}),
blockBars(Text.of(original.split("\n")), buildBar),
blockBars(Text.of(original.split(/\r?\n/)), buildBar),
blameSlot.of([]),
...(onAddToChat
? [
@@ -525,16 +544,11 @@ export default function DiffEditor({
b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat");
b.className =
"rounded border border-border-light bg-surface-1 px-1.5 py-0.5 font-sans text-[11px] text-fg-primary shadow-card hover:bg-surface-2";
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end);
// CodeMirror makes the returned element the `.cm-tooltip`, whose
// themed rule above would strip the button's own border and fill.
const wrap = document.createElement("div");
wrap.className = "cm-ah-chat";
wrap.append(b);
return wrap;
return b;
}),
]
: []),
@@ -628,7 +642,7 @@ export default function DiffEditor({
// A value set from outside (Cancel's revert, a save's fresh text).
useEffect(() => {
const v = view.current;
if (v && v.state.doc.toString() !== value) {
if (v && v.state.sliceDoc() !== value) {
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
}
}, [value]);