chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted

Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
This commit is contained in:
2026-10-23 16:41:45 +07:00
parent 65a001dad4
commit c8bc4a95a9
54 changed files with 2521 additions and 112 deletions
+21 -1
View File
@@ -18,7 +18,10 @@
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them when the prompt is sent — an image as its bare path, which claude
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
* shows as `[Image #n]` like a native drop — except a PASTED image in a
* `claude` pane of a local-mode dashboard opened on its own machine, which is
* sent as ^V so `claude` reads the desktop clipboard itself, uploading nothing
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
* machine pastes a file manager's files (dropped, or copied: `file://` text)
* by their own paths instead, uploading nothing. 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
@@ -75,6 +78,8 @@ import {
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
@@ -239,6 +244,7 @@ export const TerminalView = memo(function TerminalView({
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false);
const { local: localMode } = usePermissions();
const onDrop = (e: DragEvent) => {
if (hasText(e)) {
@@ -279,6 +285,20 @@ export const TerminalView = memo(function TerminalView({
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault();
e.stopPropagation();
// On the dashboard's own machine the browser's clipboard IS the pane's:
// `claude` reads a pasted image itself on ^V, as in a native terminal —
// nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser).
const nativeImage =
tool === "claude" &&
localMode &&
isLocalBrowser() &&
readNativePaste() &&
files.length > 0 &&
files.every((f) => f.type.startsWith("image/"));
if (nativeImage) {
termRef.current?.input("\x16", true);
return;
}
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
else termRef.current?.input("\x16", true);
};
@@ -590,6 +590,37 @@ describe("TerminalView", () => {
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
});
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", () => {
dropFileMock.mockReset();
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
});
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
localStorage.setItem("agenthub.terminal.nativePaste", "off");
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
try {
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
} finally {
localStorage.removeItem("agenthub.terminal.nativePaste");
}
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.