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:
@@ -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]);
|
||||
|
||||
@@ -104,13 +104,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
return locks;
|
||||
}
|
||||
|
||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-status-success",
|
||||
// A ring: a surface-4 fill vanished on the light themes.
|
||||
idle: "border border-fg-muted",
|
||||
dead: "bg-status-danger",
|
||||
};
|
||||
|
||||
export function since(sec: number | null): string {
|
||||
if (sec === null) return "—";
|
||||
if (sec < 60) return `${sec}s`;
|
||||
|
||||
@@ -128,12 +128,12 @@ export function SessionReplay({
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} />
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
</div>
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||
<button
|
||||
@@ -148,7 +148,7 @@ export function SessionReplay({
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||
<button
|
||||
@@ -160,7 +160,7 @@ export function SessionReplay({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useLanes } from "../../hooks/useLanes";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
@@ -52,6 +53,7 @@ export function SessionResumeButton({
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const { canEditLane } = usePermissions();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -77,7 +79,8 @@ export function SessionResumeButton({
|
||||
? null
|
||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||
owningLane(session.cwd, tool, lanes));
|
||||
if (!lane || runs === null) return null;
|
||||
// Server mode: an analyst may resume only in a workspace of their own.
|
||||
if (!lane || !canEditLane(lane) || runs === null) return null;
|
||||
const run = liveRunFor(lane, runs, session.id);
|
||||
const same = (a: string, b: string) => strip(a) === strip(b);
|
||||
// Its own worktree counts too: the server resumes it there (`claude
|
||||
|
||||
@@ -776,6 +776,16 @@ function HomeBody({
|
||||
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
|
||||
</button>
|
||||
)}
|
||||
{recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-recents-less"
|
||||
onClick={() => setRecentLimit(RECENT_PAGE)}
|
||||
className="ml-3 mt-1 text-xs text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.showLess")}
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<SessionFold
|
||||
testId={`workspace-archived-${lane.id}`}
|
||||
@@ -923,6 +933,7 @@ function HomeBody({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
|
||||
{files.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
||||
* which never has a session) as **Terminal** while it is open — a closed one
|
||||
* leaves no row; Ctrl/Cmd+click opens
|
||||
* it in a split beside the focused pane) — attaching, never starting or
|
||||
* it in a split in the tab on screen) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||
* message is SENT, because a resume re-reads the whole transcript into context.
|
||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||
* loads every one. A
|
||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text.
|
||||
@@ -568,6 +568,16 @@ export function WorkspaceSessionList({
|
||||
{t("tree.seeAll", { count: more })}
|
||||
</button>
|
||||
)}
|
||||
{more === 0 && !q && own && own.total > SESSION_LIMIT && limit > SESSION_LIMIT && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-sessions-less-${lane.id}`}
|
||||
onClick={() => setLimit(SESSION_LIMIT)}
|
||||
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.showLess")}
|
||||
</button>
|
||||
)}
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
{ctx.menu && (
|
||||
|
||||
@@ -255,6 +255,39 @@ describe("DiffEditor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["unified", "split"] as const)(
|
||||
"keeps a CRLF file's text as is (%s): no edit at mount, so the bars stay",
|
||||
async (layout) => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout={layout}
|
||||
value={"a\r\nB\r\nc"}
|
||||
original={"a\r\nb\r\nc"}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={onChange}
|
||||
onSave={vi.fn()}
|
||||
onBlock={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(1)
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
const eds = container.querySelectorAll<HTMLElement>(".cm-editor");
|
||||
const view = EditorView.findFromDOM(eds[eds.length - 1]!)!;
|
||||
expect(view.state.sliceDoc()).toBe("a\r\nB\r\nc");
|
||||
// Only B changed: the CRs are not read as an edit on every line.
|
||||
expect(
|
||||
container
|
||||
.querySelectorAll(".cm-editor")
|
||||
[eds.length - 1]!.querySelectorAll(".cm-changedLine")
|
||||
).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
|
||||
const onAddToChat = vi.fn();
|
||||
const { container } = render(
|
||||
@@ -280,9 +313,10 @@ describe("DiffEditor", () => {
|
||||
expect(b).not.toBeNull();
|
||||
return b!;
|
||||
});
|
||||
// The chip keeps its own box: the tooltip theme strips the WRAPPER's, so
|
||||
// the button must not be the `.cm-tooltip` itself.
|
||||
expect(chip.classList.contains("cm-tooltip")).toBe(false);
|
||||
// Inline at the end of the first selected line, never a tooltip over the
|
||||
// line above it.
|
||||
expect(chip.closest(".cm-tooltip")).toBeNull();
|
||||
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
|
||||
fireEvent.click(chip);
|
||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||
view.dispatch({ selection: { anchor: 6 } });
|
||||
|
||||
@@ -397,6 +397,24 @@ describe("WorkspaceHome", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
|
||||
});
|
||||
|
||||
it("shows more recents a page at a time, and collapses them again", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async (params) => {
|
||||
const all = (params as { archived?: boolean }).archived
|
||||
? []
|
||||
: Array.from({ length: 35 }, (_, i) => ({ ...sessions[1]!, id: `s-${i}` }));
|
||||
const limit = (params as { limit?: number }).limit ?? 30;
|
||||
return { sessions: all.slice(0, limit), total: all.length, limit, offset: 0 };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await user.click(await screen.findByTestId("workspace-recents-more"));
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 60 });
|
||||
await user.click(await screen.findByTestId("workspace-recents-less"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 30 });
|
||||
expect(await screen.findByTestId("workspace-recents-more")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-recents-less")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
|
||||
@@ -262,8 +262,8 @@ export function splitDiff({
|
||||
collapse?: { margin: number; minSize: number };
|
||||
revertLabel: string;
|
||||
}): { a: EditorView; b: EditorView; destroy: () => void } {
|
||||
const origDoc = Text.of(original.split("\n"));
|
||||
const chunks0 = Chunk.build(origDoc, Text.of(value.split("\n")));
|
||||
const origDoc = Text.of(original.split(/\r?\n/));
|
||||
const chunks0 = Chunk.build(origDoc, Text.of(value.split(/\r?\n/)));
|
||||
|
||||
const root = parent.appendChild(document.createElement("div"));
|
||||
root.className = "flex h-full min-h-0";
|
||||
|
||||
Reference in New Issue
Block a user