chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions

Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
2026-10-19 17:19:41 +07:00
parent 1a6b550e89
commit 49f87c4da3
61 changed files with 2824 additions and 114 deletions
@@ -218,6 +218,7 @@ export function LaneConsolePane({
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus && !(chatable && chat)}
covered={!!(chatable && chat)}
tool={handle.tool}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of.
+5 -2
View File
@@ -476,7 +476,10 @@ export const PaneChat = memo(function PaneChat({
// Stable, so typing in the prompt box does not re-render the conversation
// (ConversationView is memoized).
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
const openTerminal = useCallback(
() => showTerminal(runId, sessionId, run.tool),
[runId, sessionId, run.tool]
);
const openFile = useCallback(
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
@@ -514,7 +517,7 @@ export const PaneChat = memo(function PaneChat({
const send = async () => {
if (!text || uploading) return;
const out = promptForPane(prompt, images);
const out = promptForPane(prompt, images, claude);
if (!(await command(out.text, out.files))) return;
setFollow((n) => n + 1);
setPrompt("");
+3 -3
View File
@@ -244,9 +244,9 @@ const ITEM =
* the draft (its files as paths) back into the input box, never sent. */
/** A session's chat switched to its terminal: the unsent prompt goes into
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
export function showTerminal(runId: string, sessionId: string, tool?: string) {
const draft = readChatDraft(sessionId);
const { text, files } = promptForPane(draft.prompt, draft.images);
const { text, files } = promptForPane(draft.prompt, draft.images, tool === "claude");
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
@@ -265,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) {
title={label}
aria-label={label}
onClick={() => {
if (chat) return showTerminal(run.id, sessionId);
if (chat) return showTerminal(run.id, sessionId, run.tool);
const draft = readChatDraft(sessionId);
const text = takeRunInput(run.id);
if (text)
+17 -8
View File
@@ -67,6 +67,7 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
import {
ATTACH_MIME,
addAttachments,
claudeMention,
droppedAttachments,
droppedSkill,
droppedText,
@@ -203,21 +204,29 @@ interface PromptEditorProps {
export type PromptImage = { path: string; url?: string; name: string };
/** The prompt as sent: the text, then each file's saved path — which is how
* the CLI is handed an image or a file. */
export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
* the CLI is handed an image or a file; for `claude`, as `@` mentions. */
export function promptWithImages(text: string, images: PromptImage[], claude = false): string {
return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))]
.filter(Boolean)
.join(" ");
}
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
/** The prompt as pasted into a live pane: the text with every non-image file
* in it (as `promptWithImages`), and the images' paths apart — each goes in
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
* platform (a Windows path inside a paste of text stayed a plain path). */
/** The prompt as pasted into a live pane. `claude`: one paste, every file an
* `@` mention, and a trailing space so a mention at the end does not leave
* the `@` file suggester open to swallow the Enter after it. Any other tool:
* the text with every non-image file in it, and the images' paths apart,
* each in a paste of its own (how `codex` attaches a pasted image path). */
export function promptForPane(
text: string,
images: PromptImage[]
images: PromptImage[],
claude = false
): { text: string; files: string[] } {
if (claude) {
const all = promptWithImages(text, images, true);
return { text: images.length ? `${all} ` : all, files: [] };
}
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
return {
text: promptWithImages(text, images.filter((i) => !isImage(i))),
+9 -3
View File
@@ -16,8 +16,8 @@
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them (an image as an image) when the prompt is sent. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -63,6 +63,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import { claudeMention } from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
@@ -160,6 +161,9 @@ interface TerminalViewProps {
* so it is moved out of the viewport with a transform, which keeps its
* layout size (no PTY resize; the chat still reads and answers its menus). */
covered?: boolean;
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped file
* as an `@` mention (`claudeMention`), any other one its plain path. */
tool?: string;
}
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
@@ -170,6 +174,7 @@ export const TerminalView = memo(function TerminalView({
onExit,
autoFocus = true,
covered = false,
tool,
}: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on
@@ -261,7 +266,8 @@ export const TerminalView = memo(function TerminalView({
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees pasted paths rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
const asText = tool === "claude" ? claudeMention : (p: string) => p;
termRef.current?.paste(saved.map((r) => asText(r.path)).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
} catch {
@@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []);
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
@@ -462,7 +462,9 @@ describe("PaneChat — the unsent prompt", () => {
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
// A trailing space: an @ mention at the end would leave claude's file
// suggester open, and it takes the Enter.
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ',
[]
);
});
@@ -606,4 +606,26 @@ describe("promptForPane — images apart, every other file in the text", () => {
],
});
});
it("hands claude every file as an @ mention in one paste, with a trailing space", () => {
// A bare pasted path stayed plain text on Windows, and a path in the
// command-line prompt is never an image; claude reads an @ mention at submit.
const images: PromptImage[] = [
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
];
expect(promptForPane(" look ", images, true)).toEqual({
text:
'look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png ' +
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
files: [],
});
expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] });
expect(promptWithImages("see", [images[1]!], true)).toBe(
"see @/data/drops/0123456789abcdef-log.txt"
);
});
});