From e49ef5b1d9b3afe60fcadf624b2a248818f530ef Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:58:19 +0100 Subject: [PATCH 01/12] Fix GitLab inbox console popups on Windows --- src-tauri/src/gitlab.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/gitlab.rs b/src-tauri/src/gitlab.rs index 6085b520..9730176c 100644 --- a/src-tauri/src/gitlab.rs +++ b/src-tauri/src/gitlab.rs @@ -861,7 +861,9 @@ fn encode_path_component(value: &str) -> String { } fn gitlab_repo_for(root: &Path, gitlab_url: &str) -> Result { - let output = Command::new("git") + let mut cmd = Command::new("git"); + crate::hide_window_console(&mut cmd); + let output = cmd .args(["config", "--get-regexp", r"^remote\..*\.url$"]) .current_dir(root) .output() From 3e835336c38f8e605bd986cf052b7d0bc2c3c2a0 Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:28:50 +0100 Subject: [PATCH 02/12] Repair OpenCode attachment handling and recover stuck sessions - Send unsupported files as readable local paths - Revert failed attachment turns when resuming affected sessions - Add protocol and live recovery coverage --- CHANGELOG.md | 4 + src/lib/harness/fileAttachments.test.ts | 39 +++++-- src/lib/harness/opencode.ts | 92 +++++++++++++--- src/lib/harness/opencodeClient.ts | 18 ++++ src/lib/harness/opencodeLive.test.ts | 133 ++++++++++++++++++++++++ src/lib/harness/opencodeProtocol.ts | 33 +++++- 6 files changed, 294 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19aea5a1..2c0158f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- OpenCode falls back to readable local paths for unsupported attachment formats instead of sending provider-rejected file parts, and repairs sessions already stuck on an unsupported file turn. Fixes #211. + ## [0.1.45] - 2026-09-13 ### Added diff --git a/src/lib/harness/fileAttachments.test.ts b/src/lib/harness/fileAttachments.test.ts index 3646702c..58e85d1f 100644 --- a/src/lib/harness/fileAttachments.test.ts +++ b/src/lib/harness/fileAttachments.test.ts @@ -4,7 +4,7 @@ import { attachmentPathText, promptBlocks } from "../attachments"; import { buildClaudeUserMessage } from "./claudeProtocol"; import { buildPiPrompt, buildPiSteer } from "./piProtocol"; import { grokPromptBlocks } from "./grokProtocol"; -import { toOpenCodeFileParts } from "./opencodeProtocol"; +import { toOpenCodePromptParts } from "./opencodeProtocol"; const document: Attachment = { id: "document", @@ -51,15 +51,21 @@ describe("native file attachment formats", () => { }); }); - it("keeps OpenCode's native file parts for documents and pasted data", () => { + it("keeps OpenCode's native file parts for supported text and images", () => { + const text = { + ...document, + name: "notes.md", + mimeType: "text/markdown", + path: "/tmp/notes.md", + }; expect( - toOpenCodeFileParts([document, { ...image, path: undefined }]), + toOpenCodePromptParts("", [text, { ...image, path: undefined }]), ).toEqual([ { type: "file", - mime: "application/pdf", - filename: "report.pdf", - url: "file:///tmp/report.pdf", + mime: "text/markdown", + filename: "notes.md", + url: "file:///tmp/notes.md", }, { type: "file", @@ -69,6 +75,25 @@ describe("native file attachment formats", () => { }, ]); }); + + it("gives OpenCode unsupported and provider-dependent files as local paths", () => { + const plist = { + ...document, + name: "Info.plist", + mimeType: "application/octet-stream", + path: "/tmp/Info.plist", + }; + expect(toOpenCodePromptParts("Inspect these", [plist, document])).toEqual([ + { + type: "text", + text: [ + "Inspect these", + 'Attached file (read from disk): "/tmp/Info.plist"', + 'Attached file (read from disk): "/tmp/report.pdf"', + ].join("\n\n"), + }, + ]); + }); }); describe("file paths in native harness prompts", () => { @@ -155,7 +180,7 @@ describe("file paths in native harness prompts", () => { () => buildPiPrompt({ text: "Review", attachments: files }), () => buildPiSteer({ text: "Review", attachments: files }), () => promptBlocks("Review", files), - () => toOpenCodeFileParts(files), + () => toOpenCodePromptParts("Review", files), ]; for (const build of builders) expect(build).toThrow(/report\.pdf.*no local file path/); diff --git a/src/lib/harness/opencode.ts b/src/lib/harness/opencode.ts index 00f6444e..f83db762 100644 --- a/src/lib/harness/opencode.ts +++ b/src/lib/harness/opencode.ts @@ -10,7 +10,11 @@ import { unwatchChild, watchChild, } from "./child"; -import { OpenCodeClient, OpenCodeHttpError } from "./opencodeClient"; +import { + OpenCodeClient, + OpenCodeHttpError, + type OpenCodeMessage, +} from "./opencodeClient"; import { appendOpenCodeAssistantTextDelta, asRecord, @@ -32,7 +36,7 @@ import { sessionErrorMessage, stringField, textDeltaEvent, - toOpenCodeFileParts, + toOpenCodePromptParts, toOpenCodePermissionReply, toolKindFromName, type OpenCodePart, @@ -192,12 +196,7 @@ export async function steerOpenCodeTurn(input: SteerTurnInput): Promise { ); } - const parts = [ - ...(input.text.trim() - ? [{ type: "text" as const, text: input.text.trim() }] - : []), - ...toOpenCodeFileParts(input.attachments), - ]; + const parts = toOpenCodePromptParts(input.text, input.attachments); if (parts.length === 0) return; await live.client.promptAsync({ @@ -359,6 +358,12 @@ async function ensureLive(input: HarnessSessionInput): Promise { runtimeMode: input.runtimeMode, cwd: input.cwd, }); + if (canResume) { + await repairUnsupportedFileTurn(client, openCodeSession.id).catch( + (error: unknown) => + console.debug("[monocode] opencode attachment recovery", error), + ); + } const live: Live = { client, @@ -488,12 +493,7 @@ async function runTurn(live: Live, input: SendTurnInput): Promise { "OpenCode models use provider/model ids. Wait for the catalog to load, then pick a model.", ); } - const parts = [ - ...(input.text.trim() - ? [{ type: "text" as const, text: input.text.trim() }] - : []), - ...toOpenCodeFileParts(input.attachments), - ]; + const parts = toOpenCodePromptParts(input.text, input.attachments); if (parts.length === 0) return; const turnPromise = new Promise((resolve, reject) => { @@ -1147,6 +1147,70 @@ function isHttpNotFound(error: unknown): boolean { return error instanceof OpenCodeHttpError && error.status === 404; } +/** + * A rejected native file remains in OpenCode's durable history and can make + * every later prompt fail while converting that history for the provider. + * Revert the original attachment turn before resuming; OpenCode removes the + * reverted tail when the next prompt starts. + */ +async function repairUnsupportedFileTurn( + client: OpenCodeClient, + sessionID: string, +): Promise { + const messages = await client.getMessages(sessionID); + if (!Array.isArray(messages)) return; + const byId = new Map(); + for (const message of messages) { + const id = stringField(asRecord(message.info), "id"); + if (id) byId.set(id, message); + } + const failures = messages + .map((message) => { + const info = asRecord(message.info); + if (stringField(info, "role") !== "assistant") return null; + const mime = unsupportedFileMediaType(info?.error); + const parentID = stringField(info, "parentID"); + if (!mime || !parentID) return null; + const parent = byId.get(parentID); + const hasRejectedFile = (parent?.parts ?? []).some((part) => { + const record = asRecord(part); + return ( + stringField(record, "type") === "file" && + stringField(record, "mime")?.toLowerCase() === mime + ); + }); + if (!hasRejectedFile) return null; + const time = asRecord(info?.time)?.created; + if (typeof time !== "number") return null; + return { messageID: parentID, created: time }; + }) + .filter( + (failure): failure is { messageID: string; created: number } => + failure !== null, + ) + .filter(({ created }) => + messages.every((message) => { + const info = asRecord(message.info); + if (stringField(info, "role") !== "assistant" || info?.error) { + return true; + } + const time = asRecord(info?.time)?.created; + return typeof time !== "number" || time <= created; + }), + ) + .sort((left, right) => left.created - right.created); + const first = failures[0]; + if (first) await client.revertSession(sessionID, first.messageID); +} + +function unsupportedFileMediaType(error: unknown): string | undefined { + const message = sessionErrorMessage(error); + if (!/functionality not supported/i.test(message)) return undefined; + return message + .match(/file part media type\s+([^\s'"`]+)/i)?.[1] + ?.toLowerCase(); +} + async function assertOpenCodeVersion(path: string, cwd: string): Promise { const output = await execChild(path, ["--version"], cwd).catch(() => ""); const version = parseOpenCodeVersion(output); diff --git a/src/lib/harness/opencodeClient.ts b/src/lib/harness/opencodeClient.ts index be60ab70..531a3095 100644 --- a/src/lib/harness/opencodeClient.ts +++ b/src/lib/harness/opencodeClient.ts @@ -29,6 +29,11 @@ export type OpenCodePromptPart = | { type: "text"; text: string } | { type: "file"; mime: string; filename: string; url: string }; +export type OpenCodeMessage = { + info?: Record; + parts?: unknown[]; +}; + export class OpenCodeClient { constructor( readonly baseUrl: string, @@ -39,6 +44,13 @@ export class OpenCodeClient { return this.request("GET", `/session/${enc(sessionID)}`); } + async getMessages(sessionID: string): Promise { + return this.request( + "GET", + `/session/${enc(sessionID)}/message`, + ); + } + async createSession(input: { title?: string; permission?: unknown; @@ -84,6 +96,12 @@ export class OpenCodeClient { }).catch(() => undefined); } + async revertSession(sessionID: string, messageID: string): Promise { + await this.request("POST", `/session/${enc(sessionID)}/revert`, { + body: { messageID }, + }); + } + async summarizeSession( sessionID: string, model: { providerID: string; modelID: string }, diff --git a/src/lib/harness/opencodeLive.test.ts b/src/lib/harness/opencodeLive.test.ts index f0a56ba3..fee484fe 100644 --- a/src/lib/harness/opencodeLive.test.ts +++ b/src/lib/harness/opencodeLive.test.ts @@ -5,6 +5,7 @@ import { applyHarnessEvent } from "./apply"; let onStdout: ((line: string) => void) | undefined; let onSseEvent: ((event: Record) => void) | undefined; let onSseEnd: ((error?: string) => void) | undefined; +let sessionMessages: unknown[] = []; const spawnChild = vi.fn(async () => { onStdout?.("opencode server listening on http://127.0.0.1:4096"); }); @@ -25,6 +26,12 @@ const harnessHttp = vi.fn( body: JSON.stringify({ id: "session_1", directory: "/repo" }), }; } + if ( + input.method === "GET" && + url.pathname === "/session/session_1/message" + ) { + return { status: 200, body: JSON.stringify(sessionMessages) }; + } return { status: 204, body: "" }; }, ); @@ -54,6 +61,7 @@ vi.mock("./child", () => ({ const { __openCodeTestReset, + bindOpenCodeSession, cancelOpenCodeTurn, respondOpenCodeApproval, respondOpenCodeQuestion, @@ -126,6 +134,7 @@ beforeEach(() => { onStdout = undefined; onSseEvent = undefined; onSseEnd = undefined; + sessionMessages = []; spawnChild.mockClear(); killChild.mockClear(); harnessHttp.mockClear(); @@ -213,6 +222,130 @@ describe("OpenCode subagent trails", () => { }); describe("OpenCode event stream recovery", () => { + it("reverts a rejected file turn before continuing a resumed session", async () => { + sessionMessages = [ + { + info: { + id: "message_bad_file", + sessionID: "session_1", + role: "user", + time: { created: 1 }, + }, + parts: [ + { + type: "file", + mime: "application/octet-stream", + filename: "Info.plist", + }, + ], + }, + { + info: { + id: "message_bad_reply", + parentID: "message_bad_file", + sessionID: "session_1", + role: "assistant", + time: { created: 2 }, + error: { + data: { + message: + "'file part media type application/octet-stream' functionality not supported.", + }, + }, + }, + parts: [], + }, + ]; + bindOpenCodeSession("opencode-live", "session_1", "/repo"); + + const events: HarnessEvent[] = []; + const done = turn(events); + await waitFor( + () => + harnessHttp.mock.calls.some(([input]) => + input.url.includes("/session/session_1/revert"), + ), + "attachment turn recovery", + ); + await waitFor( + () => + harnessHttp.mock.calls.some(([input]) => + input.url.includes("/prompt_async"), + ), + "resumed prompt", + ); + + const revertIndex = harnessHttp.mock.calls.findIndex(([input]) => + input.url.includes("/session/session_1/revert"), + ); + const promptIndex = harnessHttp.mock.calls.findIndex(([input]) => + input.url.includes("/prompt_async"), + ); + expect(revertIndex).toBeGreaterThanOrEqual(0); + expect(promptIndex).toBeGreaterThan(revertIndex); + expect(harnessHttp.mock.calls[revertIndex]?.[0]).toMatchObject({ + method: "POST", + body: JSON.stringify({ messageID: "message_bad_file" }), + }); + + idle(); + await done; + expect(events).toContainEqual({ type: "message.completed" }); + }); + + it("preserves history when a later assistant turn succeeded", async () => { + sessionMessages = [ + { + info: { + id: "message_bad_file", + role: "user", + time: { created: 1 }, + }, + parts: [{ type: "file", mime: "application/octet-stream" }], + }, + { + info: { + id: "message_bad_reply", + parentID: "message_bad_file", + role: "assistant", + time: { created: 2 }, + error: { + data: { + message: + "'file part media type application/octet-stream' functionality not supported.", + }, + }, + }, + parts: [], + }, + { + info: { + id: "message_recovered_reply", + role: "assistant", + time: { created: 3 }, + }, + parts: [{ type: "text", text: "Recovered" }], + }, + ]; + bindOpenCodeSession("opencode-live", "session_1", "/repo"); + + const events: HarnessEvent[] = []; + const done = turn(events); + await waitFor( + () => + harnessHttp.mock.calls.some(([input]) => + input.url.includes("/prompt_async"), + ), + "resumed prompt", + ); + expect( + harnessHttp.mock.calls.some(([input]) => input.url.includes("/revert")), + ).toBe(false); + + idle(); + await done; + }); + it("fails a cleanly-ended stream and reconnects on the next turn", async () => { const firstEvents: HarnessEvent[] = []; const first = turn(firstEvents); diff --git a/src/lib/harness/opencodeProtocol.ts b/src/lib/harness/opencodeProtocol.ts index 1202bc9a..f7b2f133 100644 --- a/src/lib/harness/opencodeProtocol.ts +++ b/src/lib/harness/opencodeProtocol.ts @@ -1,5 +1,9 @@ import type { Attachment, RuntimeMode, ToolPreview } from "../session"; -import { attachmentPath } from "../attachments"; +import { + attachmentPath, + attachmentPathText, + isVisionImage, +} from "../attachments"; import { isTaskListToolName } from "../taskList"; import { extractToolPreview } from "./preview"; import type { HarnessEvent } from "./types"; @@ -155,9 +159,20 @@ export function toFileUrl(path: string): string { return `file://${abs.split("/").map(encodeURIComponent).join("/")}`; } -export function toOpenCodeFileParts( +export type OpenCodePromptPart = + | { type: "text"; text: string } + | { type: "file"; mime: string; filename: string; url: string }; + +/** + * OpenCode forwards native file parts to the selected model provider. Keep + * those parts to formats its provider adapters consistently support; local + * files of every other type remain available to the agent through their path. + */ +export function toOpenCodePromptParts( + text: string, attachments: Attachment[] | undefined, -): Array<{ type: "file"; mime: string; filename: string; url: string }> { +): OpenCodePromptPart[] { + const textParts = text.trim() ? [text.trim()] : []; const parts: Array<{ type: "file"; mime: string; @@ -165,6 +180,11 @@ export function toOpenCodeFileParts( url: string; }> = []; for (const attachment of attachments ?? []) { + const mime = attachment.mimeType.trim().toLowerCase(); + if (!mime.startsWith("text/") && !isVisionImage(mime)) { + textParts.push(attachmentPathText(attachment)); + continue; + } const url = !attachment.path && attachment.data ? `data:${attachment.mimeType};base64,${attachment.data}` @@ -176,7 +196,12 @@ export function toOpenCodeFileParts( url, }); } - return parts; + return [ + ...(textParts.length > 0 + ? [{ type: "text" as const, text: textParts.join("\n\n") }] + : []), + ...parts, + ]; } export function mergeOpenCodeAssistantText( From 8e1e3b038bf655fe462bd9ff340e637188d458c4 Mon Sep 17 00:00:00 2001 From: notsapinho <52896767+notsapinho@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:47:54 -0300 Subject: [PATCH 03/12] Mount Inbox cards progressively (#209) * Window inbox card rendering * Simplify progressive list windowing --- src/chrome/Sidebar.tsx | 10 +++---- src/lib/listWindow.test.ts | 28 ++++++++++++++++++++ src/lib/listWindow.ts | 15 +++++++++++ src/lib/sessionListWindow.test.ts | 30 --------------------- src/lib/sessionListWindow.ts | 19 ------------- src/surfaces/InboxView.tsx | 44 +++++++++++++++++++++++++++++-- 6 files changed, 90 insertions(+), 56 deletions(-) create mode 100644 src/lib/listWindow.test.ts create mode 100644 src/lib/listWindow.ts delete mode 100644 src/lib/sessionListWindow.test.ts delete mode 100644 src/lib/sessionListWindow.ts diff --git a/src/chrome/Sidebar.tsx b/src/chrome/Sidebar.tsx index b25c11fa..2f178f10 100644 --- a/src/chrome/Sidebar.tsx +++ b/src/chrome/Sidebar.tsx @@ -86,7 +86,7 @@ import { type SessionFolder, type SessionListDropTarget, } from "../lib/sessionFolders"; -import { SESSION_LIST_PAGE, sessionListWindow } from "../lib/sessionListWindow"; +import { LIST_PAGE_SIZE, listWindowSize } from "../lib/listWindow"; import { filterSessionsByHarness, filterSessionsByStatus, @@ -396,7 +396,7 @@ function SidebarComponent({ null, ); const [searchQuery, setSearchQuery] = useState(""); - const [sessionListLimit, setSessionListLimit] = useState(SESSION_LIST_PAGE); + const [sessionListLimit, setSessionListLimit] = useState(LIST_PAGE_SIZE); const loadMoreRef = useRef(null); const searchInputRef = useRef(null); const pendingFolderSessionIds = useRef(new Set()); @@ -467,7 +467,7 @@ function SidebarComponent({ const activeUngroupedIndex = ungroupedVisible.findIndex( (session) => session.id === activeSessionId, ); - const shownUngroupedCount = sessionListWindow( + const shownUngroupedCount = listWindowSize( ungroupedVisible.length, sessionListLimit, activeUngroupedIndex, @@ -551,7 +551,7 @@ function SidebarComponent({ const changeStats = useProjectDiffStats(gitRoot, open); useEffect(() => { - setSessionListLimit(SESSION_LIST_PAGE); + setSessionListLimit(LIST_PAGE_SIZE); const scroller = sessionsScrollRef.current; if (scroller) scroller.scrollTop = 0; }, [sessionListKey]); @@ -564,7 +564,7 @@ function SidebarComponent({ const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; - setSessionListLimit((current) => current + SESSION_LIST_PAGE); + setSessionListLimit((current) => current + LIST_PAGE_SIZE); }, { root, rootMargin: "240px" }, ); diff --git a/src/lib/listWindow.test.ts b/src/lib/listWindow.test.ts new file mode 100644 index 00000000..fbd0fc9a --- /dev/null +++ b/src/lib/listWindow.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { LIST_PAGE_SIZE, listWindowSize } from "./listWindow"; + +describe("listWindowSize", () => { + it("returns 0 for an empty list", () => { + expect(listWindowSize(0, LIST_PAGE_SIZE)).toBe(0); + }); + + it("returns the full list when it fits in one page", () => { + expect(listWindowSize(8, LIST_PAGE_SIZE)).toBe(8); + }); + + it("caps the first page", () => { + expect(listWindowSize(200, LIST_PAGE_SIZE)).toBe(LIST_PAGE_SIZE); + }); + + it("grows as more items are requested", () => { + expect(listWindowSize(200, LIST_PAGE_SIZE * 2)).toBe(LIST_PAGE_SIZE * 2); + }); + + it("cannot grow past the list", () => { + expect(listWindowSize(40, 200)).toBe(40); + }); + + it("expands far enough to include a required item", () => { + expect(listWindowSize(200, LIST_PAGE_SIZE, 80)).toBe(81); + }); +}); diff --git a/src/lib/listWindow.ts b/src/lib/listWindow.ts new file mode 100644 index 00000000..49da2978 --- /dev/null +++ b/src/lib/listWindow.ts @@ -0,0 +1,15 @@ +/** First paint of progressively mounted lists. */ +export const LIST_PAGE_SIZE = 32; + +/** Number of leading items to mount, optionally including a required item. */ +export function listWindowSize( + total: number, + requested: number, + requiredIndex = -1, +): number { + if (total <= 0) return 0; + return Math.min( + total, + Math.max(LIST_PAGE_SIZE, requested, requiredIndex + 1), + ); +} diff --git a/src/lib/sessionListWindow.test.ts b/src/lib/sessionListWindow.test.ts deleted file mode 100644 index d1520880..00000000 --- a/src/lib/sessionListWindow.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { SESSION_LIST_PAGE, sessionListWindow } from "./sessionListWindow"; - -describe("sessionListWindow", () => { - it("returns 0 for an empty list", () => { - expect(sessionListWindow(0, SESSION_LIST_PAGE, -1)).toBe(0); - }); - - it("returns the full list when it fits in one page", () => { - expect(sessionListWindow(8, SESSION_LIST_PAGE, -1)).toBe(8); - }); - - it("caps the first page", () => { - expect(sessionListWindow(200, SESSION_LIST_PAGE, -1)).toBe(SESSION_LIST_PAGE); - }); - - it("grows as more rows are requested", () => { - expect(sessionListWindow(200, SESSION_LIST_PAGE * 2, -1)).toBe( - SESSION_LIST_PAGE * 2, - ); - }); - - it("cannot grow past the list", () => { - expect(sessionListWindow(40, 200, -1)).toBe(40); - }); - - it("expands far enough to include the active session", () => { - expect(sessionListWindow(200, SESSION_LIST_PAGE, 80)).toBe(81); - }); -}); diff --git a/src/lib/sessionListWindow.ts b/src/lib/sessionListWindow.ts deleted file mode 100644 index 12b6cf55..00000000 --- a/src/lib/sessionListWindow.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** First paint of the sidebar; more rows mount as you scroll. */ -export const SESSION_LIST_PAGE = 32; - -/** - * How many sorted session cards to mount. Always at least one page, and always - * far enough to include the active session so its card is on screen. - */ -export function sessionListWindow( - total: number, - requested: number, - activeIndex: number, -): number { - if (total <= 0) return 0; - const includeActive = activeIndex >= 0 ? activeIndex + 1 : 0; - return Math.min( - total, - Math.max(SESSION_LIST_PAGE, requested, includeActive), - ); -} diff --git a/src/surfaces/InboxView.tsx b/src/surfaces/InboxView.tsx index c056395a..dc9e745a 100644 --- a/src/surfaces/InboxView.tsx +++ b/src/surfaces/InboxView.tsx @@ -21,6 +21,7 @@ import { type IconComponent, } from "../chrome/icons"; import { + useCallback, useEffect, useMemo, useRef, @@ -105,6 +106,7 @@ import { markInboxItemsSeen, useInboxSeenTick, } from "../lib/inboxSeen"; +import { LIST_PAGE_SIZE, listWindowSize } from "../lib/listWindow"; import { LINEAR_CHANGE_EVENT, linearConnected, @@ -331,6 +333,16 @@ export function InboxView({ }: Props) { const [discussionOpen, setDiscussionOpen] = useState(false); const listLock = useLockOverscroll(); + const listScrollRef = useRef(null); + const loadMoreRef = useRef(null); + const [listLimit, setListLimit] = useState(LIST_PAGE_SIZE); + const setListScrollRef = useCallback( + (element: HTMLDivElement | null) => { + listLock(element); + listScrollRef.current = element; + }, + [listLock], + ); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const logos = useTabGroupLogos(); @@ -665,6 +677,31 @@ export function InboxView({ !!targetSelectionKey && selectedKey === targetSelectionKey; const selected = selectedByKey ?? (waitingForTarget ? null : visibleItems[0]) ?? null; + const shownItemCount = listWindowSize(visibleItems.length, listLimit); + const shownItems = visibleItems.slice(0, shownItemCount); + const hasMoreItems = shownItemCount < visibleItems.length; + + useEffect(() => { + setListLimit(LIST_PAGE_SIZE); + const scroller = listScrollRef.current; + if (scroller) scroller.scrollTop = 0; + }, [activeFilters, linearHiddenTeamIds, searchInput, source]); + + useEffect(() => { + if (!hasMoreItems) return; + const sentinel = loadMoreRef.current; + const root = listScrollRef.current; + if (!sentinel || !root) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return; + setListLimit((current) => current + LIST_PAGE_SIZE); + }, + { root, rootMargin: "240px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMoreItems, shownItemCount]); useEffect(() => { if (!selected) { @@ -808,7 +845,7 @@ export function InboxView({ )}
{noSourcesConnected ? ( @@ -849,7 +886,7 @@ export function InboxView({

) : (
    - {visibleItems.map((item) => { + {shownItems.map((item) => { const key = inboxItemKey(item); const projectId = projectKey(item.projectPath); const relatedSessions = relatedSessionsForInboxItem( @@ -881,6 +918,9 @@ export function InboxView({ ); })} + {hasMoreItems ? ( +
  • + ) : null}
)}
From a5581d21f4794cd1c4b158bf35639a269bd626bf Mon Sep 17 00:00:00 2001 From: Eric Rasputin Date: Sun, 13 Sep 2026 23:41:23 +0530 Subject: [PATCH 04/12] Remove worktree-opening toast during session navigation --- src/App.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6ac7a9fb..7c4142c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2846,12 +2846,6 @@ export default function App({ void refreshHistory(sidebarCwd); return null; } - const restoringToast = loaded.worktreeCwd - ? toast.loading("Opening worktree…", { - description: - "Preparing this conversation’s working folder.", - }) - : null; try { loaded = await resumeArchivedWorktreeSession(loaded); } catch (error) { @@ -2863,8 +2857,6 @@ export default function App({ }, ); return null; - } finally { - if (restoringToast != null) toast.dismiss(restoringToast); } setHistory((current) => current.map((entry) => From e7d5623480b217fa443f57572f3a6719cc139a88 Mon Sep 17 00:00:00 2001 From: Nick <60738984+hardbeat920@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:27:07 +0100 Subject: [PATCH 05/12] Track provider token metrics on agent turns - Normalize usage and cache metrics across harness providers - Persist and display turn metrics with coverage tests --- src/chrome/icons.tsx | 5 ++ src/lib/harness/apply.test.ts | 19 ++++ src/lib/harness/apply.ts | 35 ++++++++ src/lib/harness/claude.ts | 3 + src/lib/harness/claudeProtocol.test.ts | 106 +++++++++++++++++----- src/lib/harness/claudeProtocol.ts | 27 ++++++ src/lib/harness/codexProtocol.test.ts | 7 ++ src/lib/harness/codexProtocol.ts | 43 +++++++-- src/lib/harness/fxProtocol.ts | 48 ++++++++-- src/lib/harness/grokProtocol.test.ts | 9 +- src/lib/harness/grokProtocol.ts | 52 ++++++++--- src/lib/harness/opencode.ts | 39 +++++++- src/lib/harness/opencodeProtocol.test.ts | 45 +++++++--- src/lib/harness/opencodeProtocol.ts | 35 +++++++- src/lib/harness/piFamily.ts | 3 + src/lib/harness/piProtocol.test.ts | 15 ++++ src/lib/harness/piProtocol.ts | 30 ++++++- src/lib/harness/types.ts | 5 +- src/lib/session.ts | 12 +++ src/lib/sessionStore.test.ts | 24 +++++ src/lib/sessionStore.ts | 36 ++++++++ src/surfaces/AgentTranscript.tsx | 110 +++++++++++++++++++++-- 22 files changed, 640 insertions(+), 68 deletions(-) diff --git a/src/chrome/icons.tsx b/src/chrome/icons.tsx index a6972e1b..13c2c4bd 100644 --- a/src/chrome/icons.tsx +++ b/src/chrome/icons.tsx @@ -21,6 +21,7 @@ import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon"; import CaseSensitiveIcon from "@hugeicons/core-free-icons/CaseSensitiveIcon"; import CircleArrowDown01Icon from "@hugeicons/core-free-icons/CircleArrowDown01Icon"; import CancelCircleIcon from "@hugeicons/core-free-icons/CancelCircleIcon"; +import ChartBreakoutSquareIcon from "@hugeicons/core-free-icons/ChartBreakoutSquareIcon"; import CircleDashedIcon from "@hugeicons/core-free-icons/CircleDashedIcon"; import CircleDotIcon from "@hugeicons/core-free-icons/CircleDotIcon"; import CloudUploadIcon from "@hugeicons/core-free-icons/CloudUploadIcon"; @@ -178,6 +179,10 @@ export const Eye = wrap(ViewIcon, "Eye"); export const FolderPlus = wrap(FolderAddIcon, "FolderPlus"); export const FolderTree = wrap(FolderTreeIcon, "FolderTree"); export const Gauge = wrap(GaugeIcon, "Gauge"); +export const ChartBreakoutSquare = wrap( + ChartBreakoutSquareIcon, + "ChartBreakoutSquare", +); export const GitBranch = wrap(GitBranchIcon, "GitBranch"); export const GitCompare = wrap(GitCompareIcon, "GitCompare"); export const GitMerge = wrap(GitMergeIcon, "GitMerge"); diff --git a/src/lib/harness/apply.test.ts b/src/lib/harness/apply.test.ts index 5861cecb..572ef7f1 100644 --- a/src/lib/harness/apply.test.ts +++ b/src/lib/harness/apply.test.ts @@ -494,6 +494,25 @@ describe("applyHarnessEvent context", () => { }); }); +describe("applyHarnessEvent turn metrics", () => { + it("attaches provider metrics to the latest user turn", () => { + let session = appendUser(newSession("claude", "/repo"), "Explain this"); + session = applyHarnessEvent(session, { + type: "turn.metrics", + inputTokens: 1_000, + outputTokens: 250, + cacheReadTokens: 800, + cacheHitPercent: 44.4, + }); + expect(session.blocks[0]?.turnMetrics).toEqual({ + inputTokens: 1_000, + outputTokens: 250, + cacheReadTokens: 800, + cacheHitPercent: 44.4, + }); + }); +}); + describe("tool enrichment", () => { it("retains Edit and Write previews when a tool completes without repeating its input", () => { for (const [name, input] of [ diff --git a/src/lib/harness/apply.ts b/src/lib/harness/apply.ts index ab220eb6..90ca25a9 100644 --- a/src/lib/harness/apply.ts +++ b/src/lib/harness/apply.ts @@ -105,6 +105,8 @@ export function applyHarnessEvent( window: event.window, }), }; + case "turn.metrics": + return mergeTurnMetrics(session, event); case "tasks.updated": return upsertTaskList(session, event); case "plan": @@ -137,6 +139,39 @@ export function applyHarnessEvent( } } +function mergeTurnMetrics( + session: Session, + event: Extract, +): Session { + let userIndex = -1; + for (let index = session.blocks.length - 1; index >= 0; index -= 1) { + if (session.blocks[index].role === "user") { + userIndex = index; + break; + } + } + if (userIndex < 0) return session; + + const current = session.blocks[userIndex]; + const metrics = { + ...(current.turnMetrics ?? {}), + ...(event.inputTokens != null ? { inputTokens: event.inputTokens } : {}), + ...(event.outputTokens != null ? { outputTokens: event.outputTokens } : {}), + ...(event.cacheReadTokens != null + ? { cacheReadTokens: event.cacheReadTokens } + : {}), + ...(event.cacheWriteTokens != null + ? { cacheWriteTokens: event.cacheWriteTokens } + : {}), + ...(event.cacheHitPercent != null + ? { cacheHitPercent: event.cacheHitPercent } + : {}), + }; + const blocks = session.blocks.slice(); + blocks[userIndex] = { ...current, turnMetrics: metrics }; + return { ...session, blocks }; +} + function upsertPlan( session: Session, event: Extract, diff --git a/src/lib/harness/claude.ts b/src/lib/harness/claude.ts index 01586317..0ac23949 100644 --- a/src/lib/harness/claude.ts +++ b/src/lib/harness/claude.ts @@ -18,6 +18,7 @@ import { assistantToolUses, contextFromResult, contextUsedFromAssistant, + turnMetricsFromResult, buildClaudeSpawnArgs, buildClaudeUserMessage, buildControlRequest, @@ -758,6 +759,8 @@ function handleResult(live: Live, rec: Record): void { const context = contextFromResult(rec); if (context) live.onEvent({ type: "context", ...context }); } + const metrics = turnMetricsFromResult(rec); + if (metrics) live.onEvent({ type: "turn.metrics", ...metrics }); const result = turnStatusFromResult(rec); if (result.status === "failed" && result.error && !live.cancelled) { diff --git a/src/lib/harness/claudeProtocol.test.ts b/src/lib/harness/claudeProtocol.test.ts index c894496e..6b709275 100644 --- a/src/lib/harness/claudeProtocol.test.ts +++ b/src/lib/harness/claudeProtocol.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { modelsForClaudeVersion, modelsFromClaudeListModels } from "./claudeCatalog"; +import { + modelsForClaudeVersion, + modelsFromClaudeListModels, +} from "./claudeCatalog"; import { applyClaudePromptEffortPrefix, askUserQuestionAllowInput, @@ -33,6 +36,7 @@ import { toolStartFromEvent, toolTitle, turnStatusFromResult, + turnMetricsFromResult, } from "./claudeProtocol"; describe("runtimeModeToPermission", () => { @@ -56,8 +60,12 @@ describe("runtimeModeToPermission", () => { describe("normalizeClaudeCliEffort", () => { it("drops ultrathink and maps ultracode to xhigh", () => { - expect(normalizeClaudeCliEffort("ultrathink", "claude-sonnet-5")).toBeUndefined(); - expect(normalizeClaudeCliEffort("ultracode", "claude-opus-5")).toBe("xhigh"); + expect( + normalizeClaudeCliEffort("ultrathink", "claude-sonnet-5"), + ).toBeUndefined(); + expect(normalizeClaudeCliEffort("ultracode", "claude-opus-5")).toBe( + "xhigh", + ); }); it("maps xhigh to max on older models", () => { @@ -73,17 +81,21 @@ describe("normalizeClaudeCliEffort", () => { describe("applyClaudePromptEffortPrefix", () => { it("prefixes ultrathink on the prompt", () => { - expect(applyClaudePromptEffortPrefix("Investigate the edge cases", "ultrathink")).toBe( - "Ultrathink:\nInvestigate the edge cases", - ); + expect( + applyClaudePromptEffortPrefix("Investigate the edge cases", "ultrathink"), + ).toBe("Ultrathink:\nInvestigate the edge cases"); expect(applyClaudePromptEffortPrefix("hello", "high")).toBe("hello"); }); }); describe("resolveClaudeApiModelId", () => { it("appends [1m] for the 1M context window", () => { - expect(resolveClaudeApiModelId("claude-opus-5", "1m")).toBe("claude-opus-5[1m]"); - expect(resolveClaudeApiModelId("claude-sonnet-5", "200k")).toBe("claude-sonnet-5"); + expect(resolveClaudeApiModelId("claude-opus-5", "1m")).toBe( + "claude-opus-5[1m]", + ); + expect(resolveClaudeApiModelId("claude-sonnet-5", "200k")).toBe( + "claude-sonnet-5", + ); }); }); @@ -103,7 +115,12 @@ describe("buildClaudeSpawnArgs", () => { expect(args).toContain("--include-partial-messages"); expect(args).toContain("--setting-sources=user,project,local"); expect(args).toEqual( - expect.arrayContaining(["--model", "claude-sonnet-5", "--effort", "high"]), + expect.arrayContaining([ + "--model", + "claude-sonnet-5", + "--effort", + "high", + ]), ); expect(args).toEqual( expect.arrayContaining(["--permission-mode", "acceptEdits"]), @@ -263,22 +280,30 @@ describe("turnStatusFromResult", () => { describe("modelsForClaudeVersion", () => { it("hides Opus 5 until 2.1.219", () => { - const old = modelsForClaudeVersion("2.1.100").map((model) => model.nativeId); + const old = modelsForClaudeVersion("2.1.100").map( + (model) => model.nativeId, + ); expect(old).not.toContain("claude-opus-5"); expect(old).not.toContain("claude-opus-4-8"); expect(old).toContain("claude-sonnet-4-6"); - const next = modelsForClaudeVersion("2.1.233").map((model) => model.nativeId); + const next = modelsForClaudeVersion("2.1.233").map( + (model) => model.nativeId, + ); expect(next).toContain("claude-opus-5"); expect(next).toContain("claude-fable-5"); expect(next).toContain("claude-sonnet-5"); }); it("hides Sonnet 5 until 2.1.197, and rejects a missing version", () => { - const beforeMinimum = modelsForClaudeVersion("2.1.196").map((model) => model.nativeId); + const beforeMinimum = modelsForClaudeVersion("2.1.196").map( + (model) => model.nativeId, + ); expect(beforeMinimum).not.toContain("claude-sonnet-5"); - const atMinimum = modelsForClaudeVersion("2.1.197").map((model) => model.nativeId); + const atMinimum = modelsForClaudeVersion("2.1.197").map( + (model) => model.nativeId, + ); expect(atMinimum).toContain("claude-sonnet-5"); const missing = modelsForClaudeVersion(null).map((model) => model.nativeId); @@ -388,7 +413,11 @@ describe("list_models catalog", () => { ]); const sonnet = models[0]; - expect(sonnet?.settings?.find((setting) => setting.id === "effort")?.options.map((option) => option.value)).toEqual([ + expect( + sonnet?.settings + ?.find((setting) => setting.id === "effort") + ?.options.map((option) => option.value), + ).toEqual([ "low", "medium", "high", @@ -397,10 +426,14 @@ describe("list_models catalog", () => { "ultracode", "ultrathink", ]); - expect(sonnet?.settings?.some((setting) => setting.id === "fast")).toBe(false); + expect(sonnet?.settings?.some((setting) => setting.id === "fast")).toBe( + false, + ); const fable = models[1]; - expect(fable?.settings?.find((setting) => setting.id === "context")).toMatchObject({ + expect( + fable?.settings?.find((setting) => setting.id === "context"), + ).toMatchObject({ value: "1m", }); @@ -438,7 +471,9 @@ describe("list_models catalog", () => { error: "nope", }); expect(isClaudeInitMessage({ type: "system", subtype: "init" })).toBe(true); - expect(isClaudeInitMessage({ type: "assistant", subtype: "init" })).toBe(false); + expect(isClaudeInitMessage({ type: "assistant", subtype: "init" })).toBe( + false, + ); }); }); @@ -576,7 +611,9 @@ describe("contextUsedFromAssistant", () => { }); it("ignores a message with no usage", () => { - expect(contextUsedFromAssistant({ type: "assistant", message: {} })).toBeUndefined(); + expect( + contextUsedFromAssistant({ type: "assistant", message: {} }), + ).toBeUndefined(); }); }); @@ -605,8 +642,16 @@ describe("contextFromResult", () => { cache_read_input_tokens: 90_000, output_tokens: 500, iterations: [ - { input_tokens: 5, cache_read_input_tokens: 20_000, output_tokens: 200 }, - { input_tokens: 5, cache_read_input_tokens: 70_000, output_tokens: 300 }, + { + input_tokens: 5, + cache_read_input_tokens: 20_000, + output_tokens: 200, + }, + { + input_tokens: 5, + cache_read_input_tokens: 70_000, + output_tokens: 300, + }, ], }, modelUsage: { "claude-opus-5": { contextWindow: 200000 } }, @@ -619,6 +664,27 @@ describe("contextFromResult", () => { }); }); +describe("turnMetricsFromResult", () => { + it("normalizes aggregate input, output, and cache usage", () => { + expect( + turnMetricsFromResult({ + usage: { + input_tokens: 2, + cache_creation_input_tokens: 12_941, + cache_read_input_tokens: 16_652, + output_tokens: 13, + }, + }), + ).toEqual({ + inputTokens: 2, + cacheWriteTokens: 12_941, + cacheReadTokens: 16_652, + outputTokens: 13, + cacheHitPercent: (16_652 / (2 + 12_941 + 16_652)) * 100, + }); + }); +}); + describe("subagent messages", () => { it("detects nested agent traffic by parent_tool_use_id", () => { expect(isSubagentMessage({ parent_tool_use_id: "toolu_agent" })).toBe(true); diff --git a/src/lib/harness/claudeProtocol.ts b/src/lib/harness/claudeProtocol.ts index 34722655..e175ea49 100644 --- a/src/lib/harness/claudeProtocol.ts +++ b/src/lib/harness/claudeProtocol.ts @@ -3,6 +3,7 @@ import type { RuntimeMode, TaskListItem, ToolPreview, + TurnMetrics, } from "../session"; import { attachmentPathText } from "../attachments"; import { isTaskListToolName, taskListFromToolInput } from "../taskList"; @@ -1022,6 +1023,32 @@ function contextUsedFromUsage(usage: Record | null): number { ); } +/** Aggregate token accounting for the completed Claude turn. */ +export function turnMetricsFromResult( + rec: Record, +): TurnMetrics | undefined { + const usage = asRecord(rec.usage); + if (!usage) return undefined; + const inputTokens = numberField(usage, "input_tokens"); + const outputTokens = numberField(usage, "output_tokens"); + const cacheReadTokens = numberField(usage, "cache_read_input_tokens"); + const cacheWriteTokens = numberField(usage, "cache_creation_input_tokens"); + const cacheReported = + "cache_read_input_tokens" in usage || + "cache_creation_input_tokens" in usage; + const cacheableInput = inputTokens + cacheReadTokens + cacheWriteTokens; + if (!inputTokens && !outputTokens && !cacheableInput) return undefined; + return { + ...(inputTokens ? { inputTokens } : {}), + ...(outputTokens ? { outputTokens } : {}), + ...(cacheReadTokens ? { cacheReadTokens } : {}), + ...(cacheWriteTokens ? { cacheWriteTokens } : {}), + ...(cacheReported && cacheableInput + ? { cacheHitPercent: (cacheReadTokens / cacheableInput) * 100 } + : {}), + }; +} + /** * Context level from an `assistant` message. Callers must skip subagent * messages — subagents run their own window and would make the reading jump. diff --git a/src/lib/harness/codexProtocol.test.ts b/src/lib/harness/codexProtocol.test.ts index 444073a0..fb8ba9aa 100644 --- a/src/lib/harness/codexProtocol.test.ts +++ b/src/lib/harness/codexProtocol.test.ts @@ -633,6 +633,13 @@ describe("mapCodexNotification thread/tokenUsage/updated", () => { }); expect(mapped.events).toEqual([ { type: "context", used: 42_000, window: 272_000 }, + { + type: "turn.metrics", + inputTokens: 40_000, + cacheReadTokens: 30_000, + outputTokens: 2_000, + cacheHitPercent: 75, + }, ]); }); diff --git a/src/lib/harness/codexProtocol.ts b/src/lib/harness/codexProtocol.ts index 8ccbeb82..d7faae95 100644 --- a/src/lib/harness/codexProtocol.ts +++ b/src/lib/harness/codexProtocol.ts @@ -4,6 +4,7 @@ import type { TaskListItem, ToolPreview, TurnIntent, + TurnMetrics, } from "../session"; import { attachmentPath, @@ -400,14 +401,38 @@ function mapTokenUsage(rec: Record): MappedCodexNotification { if (!last) return { events: [] }; const used = numberField(last, "totalTokens"); const window = numberField(usage, "modelContextWindow"); - if (!used && !window) return { events: [] }; + const inputTokens = numberField(last, "inputTokens"); + const cacheReadTokens = numberField(last, "cachedInputTokens"); + const cacheWriteTokens = numberField(last, "cacheWriteInputTokens"); + const outputTokens = numberField(last, "outputTokens"); + const cacheReported = + "cachedInputTokens" in last || "cacheWriteInputTokens" in last; + const metrics: TurnMetrics = { + ...(inputTokens ? { inputTokens } : {}), + ...(outputTokens ? { outputTokens } : {}), + ...(cacheReadTokens ? { cacheReadTokens } : {}), + ...(cacheWriteTokens ? { cacheWriteTokens } : {}), + ...(cacheReported && inputTokens > 0 + ? { + cacheHitPercent: + (cacheReadTokens / (inputTokens + cacheWriteTokens)) * 100, + } + : {}), + }; + const hasMetrics = Object.keys(metrics).length > 0; + if (!used && !window && !hasMetrics) return { events: [] }; return { events: [ - { - type: "context", - ...(used > 0 ? { used } : {}), - ...(window > 0 ? { window } : {}), - }, + ...(used || window + ? [ + { + type: "context" as const, + ...(used > 0 ? { used } : {}), + ...(window > 0 ? { window } : {}), + }, + ] + : []), + ...(hasMetrics ? [{ type: "turn.metrics" as const, ...metrics }] : []), ], }; } @@ -664,8 +689,7 @@ function mapSubAgentActivity( completed: boolean, ): HarnessEvent { const kind = (stringField(item, "kind") ?? "").toLowerCase(); - const path = - stringField(item, "agentPath") ?? stringField(item, "agent_path"); + const path = stringField(item, "agentPath") ?? stringField(item, "agent_path"); const leaf = path?.split(/[/\\]/).filter(Boolean).pop(); const title = leaf ? `${formatAgentType(leaf)} subagent` : "Subagent"; if (kind === "interrupted") { @@ -897,7 +921,8 @@ export function mapCodexSubagentSteps( /** The first line of a spawn's prompt, short enough to sit on a row. */ function agentBrief(item: Record): string | undefined { - const path = stringField(item, "agentPath") ?? stringField(item, "agent_path"); + const path = + stringField(item, "agentPath") ?? stringField(item, "agent_path"); const leaf = path?.split(/[/\\]/).filter(Boolean).pop(); if (leaf) return `${formatAgentType(leaf)} subagent`; const prompt = stringField(item, "prompt"); diff --git a/src/lib/harness/fxProtocol.ts b/src/lib/harness/fxProtocol.ts index 245f5cb3..80d6df12 100644 --- a/src/lib/harness/fxProtocol.ts +++ b/src/lib/harness/fxProtocol.ts @@ -262,8 +262,7 @@ export function eventsFromAcpUpdate(params: unknown): HarnessEvent[] { return event ? [event] : []; } - const usage = usageFromUpdate(update); - return usage ? [usage] : []; + return usageFromUpdate(update); } export function modelsFromFxOutput(stdout: string): AgentModel[] { @@ -518,13 +517,13 @@ function displayName(nativeId: string): string { .replace(/\b\w/g, (ch) => ch.toUpperCase()); } -function usageFromUpdate(update: Record): HarnessEvent | null { +function usageFromUpdate(update: Record): HarnessEvent[] { const usage = asRecord(update.usage) ?? asRecord(update.tokenUsage) ?? asRecord(update.token_usage) ?? (hasUsageFields(update) ? update : null); - if (!usage) return null; + if (!usage) return []; const used = numberField(usage, "used") ?? numberField(usage, "usedTokens") ?? @@ -537,8 +536,45 @@ function usageFromUpdate(update: Record): HarnessEvent | null { numberField(usage, "context_window") ?? numberField(usage, "maxTokens") ?? numberField(usage, "max_tokens"); - if (used == null && window == null) return null; - return { type: "context", used: used ?? undefined, window: window ?? undefined }; + const events: HarnessEvent[] = []; + if (used != null || window != null) { + events.push({ + type: "context", + ...(used != null ? { used } : {}), + ...(window != null ? { window } : {}), + }); + } + const inputTokens = + numberField(usage, "inputTokens") ?? numberField(usage, "input_tokens"); + const outputTokens = + numberField(usage, "outputTokens") ?? numberField(usage, "output_tokens"); + const cacheReadTokens = + numberField(usage, "cacheReadTokens") ?? + numberField(usage, "cache_read_input_tokens"); + const cacheWriteTokens = + numberField(usage, "cacheWriteTokens") ?? + numberField(usage, "cache_creation_input_tokens"); + const cacheReported = cacheReadTokens != null || cacheWriteTokens != null; + if ( + inputTokens != null || + outputTokens != null || + cacheReadTokens != null || + cacheWriteTokens != null + ) { + const cacheableInput = + (inputTokens ?? 0) + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0); + events.push({ + type: "turn.metrics", + ...(inputTokens != null ? { inputTokens } : {}), + ...(outputTokens != null ? { outputTokens } : {}), + ...(cacheReadTokens != null ? { cacheReadTokens } : {}), + ...(cacheWriteTokens != null ? { cacheWriteTokens } : {}), + ...(cacheReported && cacheableInput > 0 + ? { cacheHitPercent: ((cacheReadTokens ?? 0) / cacheableInput) * 100 } + : {}), + }); + } + return events; } function hasUsageFields(rec: Record): boolean { diff --git a/src/lib/harness/grokProtocol.test.ts b/src/lib/harness/grokProtocol.test.ts index 30a3982d..33e70036 100644 --- a/src/lib/harness/grokProtocol.test.ts +++ b/src/lib/harness/grokProtocol.test.ts @@ -212,7 +212,14 @@ describe("grok protocol", () => { totalTokens: 19798, }, }), - ).toEqual([{ type: "context", used: 19798 }]); + ).toEqual([ + { type: "context", used: 19798 }, + { + type: "turn.metrics", + inputTokens: 19762, + outputTokens: 36, + }, + ]); }); it("maps plan entries", () => { diff --git a/src/lib/harness/grokProtocol.ts b/src/lib/harness/grokProtocol.ts index 3e5bfd1f..f3858d54 100644 --- a/src/lib/harness/grokProtocol.ts +++ b/src/lib/harness/grokProtocol.ts @@ -420,8 +420,7 @@ export function eventsFromAcpUpdate(params: unknown): HarnessEvent[] { return []; } - const usage = usageFromUpdate(update); - return usage ? [usage] : []; + return usageFromUpdate(update); } export function sessionIdFromResult(result: unknown): string | undefined { @@ -688,13 +687,13 @@ function previewKind(kind?: string): ToolPreview["kind"] { return "read"; } -function usageFromUpdate(update: Record): HarnessEvent | null { +function usageFromUpdate(update: Record): HarnessEvent[] { const usage = asRecord(update.usage) ?? asRecord(update.tokenUsage) ?? asRecord(update.token_usage) ?? (hasUsageFields(update) ? update : null); - if (!usage) return null; + if (!usage) return []; const used = numberField(usage, "totalTokens") ?? numberField(usage, "used") ?? @@ -711,12 +710,45 @@ function usageFromUpdate(update: Record): HarnessEvent | null { numberField(usage, "contextWindow") ?? numberField(usage, "context_window") ?? numberField(usage, "maxTokens"); - if (used == null && window == null) return null; - return { - type: "context", - used: used ?? undefined, - window: window ?? undefined, - }; + const events: HarnessEvent[] = []; + if (used != null || window != null) { + events.push({ + type: "context", + ...(used != null ? { used } : {}), + ...(window != null ? { window } : {}), + }); + } + const inputTokens = + numberField(usage, "inputTokens") ?? numberField(usage, "input_tokens"); + const outputTokens = + numberField(usage, "outputTokens") ?? numberField(usage, "output_tokens"); + const cacheReadTokens = + numberField(usage, "cacheReadTokens") ?? + numberField(usage, "cache_read_input_tokens"); + const cacheWriteTokens = + numberField(usage, "cacheWriteTokens") ?? + numberField(usage, "cache_creation_input_tokens"); + const cacheReported = cacheReadTokens != null || cacheWriteTokens != null; + if ( + inputTokens != null || + outputTokens != null || + cacheReadTokens != null || + cacheWriteTokens != null + ) { + const cacheableInput = + (inputTokens ?? 0) + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0); + events.push({ + type: "turn.metrics", + ...(inputTokens != null ? { inputTokens } : {}), + ...(outputTokens != null ? { outputTokens } : {}), + ...(cacheReadTokens != null ? { cacheReadTokens } : {}), + ...(cacheWriteTokens != null ? { cacheWriteTokens } : {}), + ...(cacheReported && cacheableInput > 0 + ? { cacheHitPercent: ((cacheReadTokens ?? 0) / cacheableInput) * 100 } + : {}), + }); + } + return events; } function hasUsageFields(rec: Record): boolean { diff --git a/src/lib/harness/opencode.ts b/src/lib/harness/opencode.ts index f83db762..f5cf0a88 100644 --- a/src/lib/harness/opencode.ts +++ b/src/lib/harness/opencode.ts @@ -1,5 +1,5 @@ import { modelContextWindow, nativeModelId } from "../models"; -import type { RuntimeMode } from "../session"; +import type { RuntimeMode, TurnMetrics } from "../session"; import { taskListFromToolInput } from "../taskList"; import { execChild, @@ -21,6 +21,7 @@ import { buildOpenCodePermissionRules, compareSemver, contextUsedFromMessageInfo, + turnMetricsFromMessageInfo, detailFromToolPart, eventSessionId, isOpenCodeNotFound, @@ -94,6 +95,7 @@ type Live = { partById: Map; emittedTextByPartId: Map; messageRoleById: Map; + turnMetricsByMessageId: Map; cancelled: boolean; muteUpdates: boolean; turns: Promise; @@ -383,6 +385,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { partById: new Map(), emittedTextByPartId: new Map(), messageRoleById: new Map(), + turnMetricsByMessageId: new Map(), cancelled: false, muteUpdates: false, turns: Promise.resolve(), @@ -501,6 +504,7 @@ async function runTurn(live: Live, input: SendTurnInput): Promise { live.turnFailed = reject; }); live.activeTurn = true; + live.turnMetricsByMessageId.clear(); settlePendingTurn(live); try { @@ -780,6 +784,39 @@ export function openCodeAgentForTurn(input: { */ function emitContext(live: Live, info: Record | null): void { const used = contextUsedFromMessageInfo(info); + const metrics = turnMetricsFromMessageInfo(info); + const messageId = stringField(info, "id"); + if (metrics && messageId) live.turnMetricsByMessageId.set(messageId, metrics); + if (metrics && !messageId) { + live.onEvent({ type: "turn.metrics", ...metrics }); + } + const aggregate = [ + ...live.turnMetricsByMessageId.values(), + ].reduce( + (total, current) => ({ + inputTokens: (total.inputTokens ?? 0) + (current.inputTokens ?? 0), + outputTokens: (total.outputTokens ?? 0) + (current.outputTokens ?? 0), + cacheReadTokens: + (total.cacheReadTokens ?? 0) + (current.cacheReadTokens ?? 0), + cacheWriteTokens: + (total.cacheWriteTokens ?? 0) + (current.cacheWriteTokens ?? 0), + }), + {}, + ); + const aggregateInput = + (aggregate.inputTokens ?? 0) + + (aggregate.cacheReadTokens ?? 0) + + (aggregate.cacheWriteTokens ?? 0); + const hasAggregate = Object.values(aggregate).some( + (value) => typeof value === "number" && value > 0, + ); + if (hasAggregate) { + aggregate.cacheHitPercent = + aggregateInput > 0 + ? ((aggregate.cacheReadTokens ?? 0) / aggregateInput) * 100 + : undefined; + live.onEvent({ type: "turn.metrics", ...aggregate }); + } if (used === undefined) return; const providerID = stringField(info, "providerID"); const modelID = stringField(info, "modelID"); diff --git a/src/lib/harness/opencodeProtocol.test.ts b/src/lib/harness/opencodeProtocol.test.ts index 46847349..194c7ada 100644 --- a/src/lib/harness/opencodeProtocol.test.ts +++ b/src/lib/harness/opencodeProtocol.test.ts @@ -8,6 +8,7 @@ import { buildOpenCodePermissionRules, compareSemver, contextUsedFromMessageInfo, + turnMetricsFromMessageInfo, detailFromToolPart, eventSessionId, inferDefaultAgent, @@ -161,12 +162,12 @@ describe("OpenCode CLI inventory parsers", () => { "anthropic/claude-sonnet-4-6", "opencode/glm-5", ]); - expect(models[1].settings?.some((setting) => setting.id === "variant")).toBe( - true, - ); - expect(models[0].settings?.find((setting) => setting.id === "agent")?.value).toBe( - "build", - ); + expect( + models[1].settings?.some((setting) => setting.id === "variant"), + ).toBe(true); + expect( + models[0].settings?.find((setting) => setting.id === "agent")?.value, + ).toBe("build"); }); it("parses agent list headers", () => { @@ -217,9 +218,9 @@ describe("OpenCode helpers", () => { expect(inferDefaultVariant("openai", ["low", "medium", "high"])).toBe( "medium", ); - expect( - inferDefaultAgent([{ name: "plan" }, { name: "build" }]), - ).toBe("build"); + expect(inferDefaultAgent([{ name: "plan" }, { name: "build" }])).toBe( + "build", + ); }); }); @@ -248,10 +249,34 @@ describe("contextUsedFromMessageInfo", () => { it("treats an all-zero reading as nothing to report", () => { expect( contextUsedFromMessageInfo({ - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, }), ).toBeUndefined(); }); + + it("normalizes cache usage for a turn tooltip", () => { + expect( + turnMetricsFromMessageInfo({ + tokens: { + input: 1_200, + output: 800, + reasoning: 200, + cache: { read: 40_000, write: 5_000 }, + }, + }), + ).toEqual({ + inputTokens: 1_200, + outputTokens: 1_000, + cacheReadTokens: 40_000, + cacheWriteTokens: 5_000, + cacheHitPercent: (40_000 / 46_200) * 100, + }); + }); }); describe("flattenOpenCodeModels context window", () => { diff --git a/src/lib/harness/opencodeProtocol.ts b/src/lib/harness/opencodeProtocol.ts index f7b2f133..527ad0fd 100644 --- a/src/lib/harness/opencodeProtocol.ts +++ b/src/lib/harness/opencodeProtocol.ts @@ -1,4 +1,9 @@ -import type { Attachment, RuntimeMode, ToolPreview } from "../session"; +import type { + Attachment, + RuntimeMode, + ToolPreview, + TurnMetrics, +} from "../session"; import { attachmentPath, attachmentPathText, @@ -391,6 +396,34 @@ export function contextUsedFromMessageInfo( return used > 0 ? used : undefined; } +export function turnMetricsFromMessageInfo( + info: Record | null, +): TurnMetrics | undefined { + const tokens = asRecord(info?.tokens); + if (!tokens) return undefined; + const cache = asRecord(tokens.cache); + const num = (rec: Record | null, key: string): number => { + const value = rec?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : 0; + }; + const inputTokens = num(tokens, "input"); + const outputTokens = num(tokens, "output") + num(tokens, "reasoning"); + const cacheReadTokens = num(cache, "read"); + const cacheWriteTokens = num(cache, "write"); + const cacheReported = cache !== null; + const cacheableInput = inputTokens + cacheReadTokens + cacheWriteTokens; + if (!inputTokens && !outputTokens && !cacheableInput) return undefined; + return { + ...(inputTokens ? { inputTokens } : {}), + ...(outputTokens ? { outputTokens } : {}), + ...(cacheReadTokens ? { cacheReadTokens } : {}), + ...(cacheWriteTokens ? { cacheWriteTokens } : {}), + ...(cacheReported && cacheableInput + ? { cacheHitPercent: (cacheReadTokens / cacheableInput) * 100 } + : {}), + }; +} + /** * The session a `task` tool spawned, when OpenCode names it on the call. A * subagent runs as its own session, so this is what ties the child's stream diff --git a/src/lib/harness/piFamily.ts b/src/lib/harness/piFamily.ts index 17440f06..af3530e7 100644 --- a/src/lib/harness/piFamily.ts +++ b/src/lib/harness/piFamily.ts @@ -28,6 +28,7 @@ import { buildPiSteer, contextFromSessionStats, contextFromUsage, + turnMetricsFromUsage, extensionUiResponse, extensionUiTitle, isAgentSettled, @@ -746,6 +747,8 @@ function handleFrame( const context = contextFromUsage(rec, live.contextWindow); if (context) live.onEvent({ type: "context", ...context }); + const metrics = turnMetricsFromUsage(rec); + if (metrics) live.onEvent({ type: "turn.metrics", ...metrics }); const delta = assistantDeltaFromEvent(rec); if (delta) { diff --git a/src/lib/harness/piProtocol.test.ts b/src/lib/harness/piProtocol.test.ts index b34e6911..20c0f7ad 100644 --- a/src/lib/harness/piProtocol.test.ts +++ b/src/lib/harness/piProtocol.test.ts @@ -7,6 +7,7 @@ import { buildPiSteer, contextFromSessionStats, contextFromUsage, + turnMetricsFromUsage, extensionUiResponse, extensionUiTitle, isAgentSettled, @@ -480,6 +481,20 @@ describe("tools and models", () => { }), ).toEqual({ used: 60, window: 200000 }); }); + + it("normalizes cache usage from assistant frames", () => { + expect( + turnMetricsFromUsage({ + usage: { input: 100, output: 20, cacheRead: 300, cacheWrite: 50 }, + }), + ).toEqual({ + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 300, + cacheWriteTokens: 50, + cacheHitPercent: (300 / 450) * 100, + }); + }); }); describe("turnErrorFromEvent", () => { diff --git a/src/lib/harness/piProtocol.ts b/src/lib/harness/piProtocol.ts index 92c30233..181b56ae 100644 --- a/src/lib/harness/piProtocol.ts +++ b/src/lib/harness/piProtocol.ts @@ -1,4 +1,4 @@ -import type { Attachment, ToolPreview } from "../session"; +import type { Attachment, ToolPreview, TurnMetrics } from "../session"; import { attachmentPathText } from "../attachments"; import type { AgentModel, ModelSetting } from "../models"; import { isTaskListToolName } from "../taskList"; @@ -312,7 +312,7 @@ export function extensionUiTitle(request: PiExtensionUiRequest): string { const text = request.method === "confirm" ? [request.title, request.message].filter(Boolean).join(" — ") - : request.title ?? "Pi extension"; + : (request.title ?? "Pi extension"); // Pi's theme helpers emit ANSI even in RPC mode (e.g. Ponytail setStatus). // These labels use native UI styling. Strip CSI and OSC sequences only at // the display boundary: select replies must retain the original option. @@ -385,6 +385,32 @@ export function contextFromUsage( return window && window > 0 ? { used, window } : { used }; } +export function turnMetricsFromUsage( + rec: Record, +): TurnMetrics | null { + const usage = + asRecord(rec.usage) ?? + assistantMessageUsage(rec) ?? + asRecord(asRecord(asRecord(rec.assistantMessageEvent)?.partial)?.usage); + if (!usage) return null; + const inputTokens = numberField(usage, "input") ?? 0; + const outputTokens = numberField(usage, "output") ?? 0; + const cacheReadTokens = numberField(usage, "cacheRead") ?? 0; + const cacheWriteTokens = numberField(usage, "cacheWrite") ?? 0; + const cacheReported = "cacheRead" in usage || "cacheWrite" in usage; + const cacheableInput = inputTokens + cacheReadTokens + cacheWriteTokens; + if (!inputTokens && !outputTokens && !cacheableInput) return null; + return { + ...(inputTokens ? { inputTokens } : {}), + ...(outputTokens ? { outputTokens } : {}), + ...(cacheReadTokens ? { cacheReadTokens } : {}), + ...(cacheWriteTokens ? { cacheWriteTokens } : {}), + ...(cacheReported && cacheableInput + ? { cacheHitPercent: (cacheReadTokens / cacheableInput) * 100 } + : {}), + }; +} + export function contextFromSessionStats(data: unknown): { used?: number; window?: number; diff --git a/src/lib/harness/types.ts b/src/lib/harness/types.ts index 48aaea98..02c0f429 100644 --- a/src/lib/harness/types.ts +++ b/src/lib/harness/types.ts @@ -5,6 +5,7 @@ import type { TaskListItem, ToolPreview, TurnIntent, + TurnMetrics, } from "../session"; import type { UserQuestion } from "../userQuestion"; @@ -114,7 +115,9 @@ export type HarnessEvent = streaming?: boolean; } /** Context-window level after the harness's latest request. */ - | { type: "context"; used?: number; window?: number }; + | { type: "context"; used?: number; window?: number } + /** Provider token accounting for the active user turn. */ + | ({ type: "turn.metrics" } & TurnMetrics); export type ApprovalDecision = "allow" | "deny"; diff --git a/src/lib/session.ts b/src/lib/session.ts index 8f11f648..e1261e4e 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -183,6 +183,16 @@ export type TurnModel = { name: string; }; +/** Provider-reported token accounting for one user turn. */ +export type TurnMetrics = { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + /** Provider-normalized share of input served from cache, as a percentage. */ + cacheHitPercent?: number; +}; + export type Block = { id: string; role: BlockRole; @@ -195,6 +205,8 @@ export type Block = { durationMs?: number; /** Stable model label for this turn. Present on newly created user blocks. */ turnModel?: TurnModel; + /** Provider-reported token metrics for this user turn, when available. */ + turnMetrics?: TurnMetrics; tool?: { callId?: string; title?: string; diff --git a/src/lib/sessionStore.test.ts b/src/lib/sessionStore.test.ts index 79f40109..c09306e4 100644 --- a/src/lib/sessionStore.test.ts +++ b/src/lib/sessionStore.test.ts @@ -114,6 +114,30 @@ describe("sanitizeSessionForPersist", () => { }); }); + it("persists provider metrics recorded on a user turn", () => { + const session = newSession("claude", "/tmp/project"); + session.blocks = [ + { + id: "u1", + role: "user", + text: "remember this", + turnMetrics: { + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 80, + cacheHitPercent: 40, + }, + }, + ]; + + expect(sanitizeSessionForPersist(session).blocks[0]?.turnMetrics).toEqual({ + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 80, + cacheHitPercent: 40, + }); + }); + it("persists a canonical GitHub work-item identity", () => { const session = newSession("codex", "/tmp/project"); session.blocks = [{ id: "u1", role: "user", text: "fix PR #42" }]; diff --git a/src/lib/sessionStore.ts b/src/lib/sessionStore.ts index 18bb7889..e55cd640 100644 --- a/src/lib/sessionStore.ts +++ b/src/lib/sessionStore.ts @@ -17,6 +17,7 @@ import type { TaskListMeta, PlanBlockMeta, TurnModel, + TurnMetrics, } from "./session"; import { HARNESSES, RUNTIME_MODES } from "./session"; @@ -383,6 +384,8 @@ function sanitizeBlock(block: Block): Block | null { if (block.durationMs != null) next.durationMs = block.durationMs; const turnModel = sanitizeTurnModel(block.turnModel); if (block.role === "user" && turnModel) next.turnModel = turnModel; + const turnMetrics = sanitizeTurnMetrics(block.turnMetrics); + if (block.role === "user" && turnMetrics) next.turnMetrics = turnMetrics; if (block.tool) next.tool = block.tool; if (block.approval?.decided) { next.approval = { @@ -413,6 +416,39 @@ function sanitizeBlock(block: Block): Block | null { return next; } +function sanitizeTurnMetrics(value: unknown): TurnMetrics | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const rec = value as Record; + const number = (key: keyof TurnMetrics): number | undefined => { + const candidate = rec[key]; + return typeof candidate === "number" && + Number.isFinite(candidate) && + candidate >= 0 + ? candidate + : undefined; + }; + const metrics: TurnMetrics = { + ...(number("inputTokens") != null + ? { inputTokens: number("inputTokens") } + : {}), + ...(number("outputTokens") != null + ? { outputTokens: number("outputTokens") } + : {}), + ...(number("cacheReadTokens") != null + ? { cacheReadTokens: number("cacheReadTokens") } + : {}), + ...(number("cacheWriteTokens") != null + ? { cacheWriteTokens: number("cacheWriteTokens") } + : {}), + ...(number("cacheHitPercent") != null + ? { cacheHitPercent: number("cacheHitPercent") } + : {}), + }; + return Object.keys(metrics).length > 0 ? metrics : undefined; +} + function sanitizeTurnModel(value: unknown): TurnModel | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; diff --git a/src/surfaces/AgentTranscript.tsx b/src/surfaces/AgentTranscript.tsx index 90fbc132..9226b685 100644 --- a/src/surfaces/AgentTranscript.tsx +++ b/src/surfaces/AgentTranscript.tsx @@ -6,6 +6,7 @@ import { FilePlusCorner, Minus, Bot, + ChartBreakoutSquare, PenLine, Search, Terminal, @@ -36,6 +37,7 @@ import { import { SecondOpinionCard } from "../chrome/SecondOpinionCard"; import { NoteMiniCard } from "../chrome/NoteMiniCard"; import { TerminalSpinner } from "../chrome/TerminalSpinner"; +import { Popover } from "../chrome/Popover"; import { ProjectMascot } from "../chrome/ProjectMascot"; import type { ApprovalDecision } from "../lib/harness"; import { @@ -59,6 +61,7 @@ import { type HarnessId, type PlanBuildTarget, type ToolPreview, + type TurnMetrics, } from "../lib/session"; import { HarnessIcon } from "../chrome/HarnessIcon"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; @@ -587,6 +590,7 @@ function AgentTranscriptComponent({ {durationMs != null && settled ? ( ) : null} + {labelHidden ? null : ( @@ -753,6 +760,101 @@ function TurnDuration({ ); } +function TurnMetricsBadge({ + metrics, + elapsedMs, +}: { + metrics?: TurnMetrics; + elapsedMs: number | null; +}) { + const root = useRef(null); + const [hovered, setHovered] = useState(false); + if (!metrics || !hasTurnMetrics(metrics)) return null; + + const outputRate = + metrics.outputTokens != null && elapsedMs != null && elapsedMs > 0 + ? metrics.outputTokens / (elapsedMs / 1000) + : undefined; + const headline = + [ + metrics.cacheHitPercent != null + ? `Cache hit ${Math.round(metrics.cacheHitPercent)}%` + : null, + outputRate != null + ? `Output ${formatMetricCount(outputRate)} tok/s` + : null, + ] + .filter(Boolean) + .join(" · ") || "Turn tokens"; + const detail = [ + metrics.inputTokens != null + ? `${formatMetricCount(metrics.inputTokens)} input` + : null, + metrics.outputTokens != null + ? `${formatMetricCount(metrics.outputTokens)} output` + : null, + metrics.cacheReadTokens != null + ? `${formatMetricCount(metrics.cacheReadTokens)} cached` + : null, + ] + .filter(Boolean) + .join(" · "); + const label = [headline, detail].filter(Boolean).join(". "); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setHovered(true)} + onBlur={() => setHovered(false)} + > + + + + {hovered ? ( + +
{headline}
+ {detail ? ( +
+ {detail} +
+ ) : null} +
+ ) : null} +
+ ); +} + +function hasTurnMetrics(metrics: TurnMetrics): boolean { + return ( + metrics.cacheHitPercent != null || + (metrics.inputTokens ?? 0) > 0 || + (metrics.outputTokens ?? 0) > 0 || + (metrics.cacheReadTokens ?? 0) > 0 || + (metrics.cacheWriteTokens ?? 0) > 0 + ); +} + +function formatMetricCount(value: number): string { + return new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: value >= 1000 ? 1 : 0, + }).format(Math.max(0, Math.round(value))); +} + /** Wall-clock stamp for a finished turn, in the reader's own locale. */ function formatClockTime(epochMs: number): string { return new Date(epochMs).toLocaleTimeString(undefined, { @@ -2180,13 +2282,7 @@ function formatWorkingDuration( ): string { const who = modelName?.trim(); const elapsed = formatElapsed(elapsedMs); - const verb = done - ? who - ? "worked" - : "Worked" - : who - ? "working" - : "Working"; + const verb = done ? (who ? "worked" : "Worked") : who ? "working" : "Working"; if (elapsed == null) { if (done) return who ? `${who} ${verb}` : verb; return who ? `${who} ${verb}…` : `${verb}…`; From e89d3f5de1582198bb6c92fdab6dee59873bbea7 Mon Sep 17 00:00:00 2001 From: Eric Rasputin Date: Mon, 14 Sep 2026 00:23:57 +0530 Subject: [PATCH 06/12] feat: include fork upstream repositories in Inbox --- src-tauri/src/fs.rs | 194 ++++++++++++++++++++++-- src-tauri/src/lib.rs | 1 + src/App.tsx | 1 + src/lib/githubInbox.test.ts | 259 +++++++++++++++++++++++++++++++++ src/lib/githubTasks.ts | 82 +++++++---- src/surfaces/InboxView.test.ts | 32 +++- src/surfaces/InboxView.tsx | 32 ++-- 7 files changed, 544 insertions(+), 57 deletions(-) create mode 100644 src/lib/githubInbox.test.ts diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index ca37d935..2a103549 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -597,10 +597,21 @@ pub async fn git_github_repo(cwd: String) -> Result { .map_err(|e| e.to_string())? } -/// Open issues or pull requests for the current GitHub remote, via `gh`. +/// The local repository and its fork parent, independently of `gh`'s default. +#[tauri::command] +pub async fn git_github_inbox_repos(cwd: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + git_github_inbox_repos_for(&expand_home(&cwd), gh_checked) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Issues or pull requests for an explicit GitHub repository, via `gh`. #[tauri::command] pub async fn git_github_work_items( cwd: String, + repo: String, kind: String, assigned_to_me: bool, state: String, @@ -610,6 +621,7 @@ pub async fn git_github_work_items( tauri::async_runtime::spawn_blocking(move || { git_github_work_items_for( &expand_home(&cwd), + &repo, &kind, assigned_to_me, &state, @@ -652,11 +664,12 @@ pub struct GitHubWorkItemDetails { #[tauri::command] pub async fn git_github_work_item_details( cwd: String, + repo: String, kind: String, number: i64, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - git_github_work_item_details_for(&expand_home(&cwd), &kind, number) + git_github_work_item_details_for(&expand_home(&cwd), &repo, &kind, number) }) .await .map_err(|e| e.to_string())? @@ -705,11 +718,12 @@ pub struct GitHubWorkItemThread { #[tauri::command] pub async fn git_github_work_item_thread( cwd: String, + repo: String, kind: String, number: i64, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - git_github_work_item_thread_for(&expand_home(&cwd), &kind, number) + git_github_work_item_thread_for(&expand_home(&cwd), &repo, &kind, number) }) .await .map_err(|e| e.to_string())? @@ -719,13 +733,21 @@ pub async fn git_github_work_item_thread( #[tauri::command] pub async fn git_github_work_item_comment( cwd: String, + repo: String, kind: String, number: i64, body: String, in_reply_to: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - git_github_work_item_comment_for(&expand_home(&cwd), &kind, number, &body, &in_reply_to) + git_github_work_item_comment_for( + &expand_home(&cwd), + &repo, + &kind, + number, + &body, + &in_reply_to, + ) }) .await .map_err(|e| e.to_string())? @@ -753,10 +775,16 @@ const MAX_PR_DIFF_BYTES: usize = 2 * 1024 * 1024; /// Unified diff and file stats for a pull request, via `gh`. #[tauri::command] -pub async fn git_github_pr_diff(cwd: String, number: i64) -> Result { - tauri::async_runtime::spawn_blocking(move || git_github_pr_diff_for(&expand_home(&cwd), number)) - .await - .map_err(|e| e.to_string())? +pub async fn git_github_pr_diff( + cwd: String, + repo: String, + number: i64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_github_pr_diff_for(&expand_home(&cwd), &repo, number) + }) + .await + .map_err(|e| e.to_string())? } #[derive(Serialize, Clone, Debug, Default, PartialEq, Eq)] @@ -1678,14 +1706,62 @@ fn git_github_repo_for(root: &Path) -> Result { Ok(slug.to_string()) } +fn git_github_inbox_repos_for( + root: &Path, + run_gh: impl FnOnce(&Path, &[&str]) -> Result, +) -> Result, String> { + // `gh` may default to upstream. Resolve the checkout's origin explicitly so + // the fork remains in Inbox even when the default points at upstream. + let remote_url = + git_remote_name(root).and_then(|remote| git_stdout(root, &["remote", "get-url", &remote])); + let mut args = vec!["repo", "view"]; + if let Some(url) = remote_url.as_deref() { + args.push(url); + } + args.extend(["--json", "nameWithOwner,parent"]); + parse_github_inbox_repos(&run_gh(root, &args)?) +} + +fn parse_github_inbox_repos(json: &str) -> Result, String> { + #[derive(Deserialize)] + struct Owner { + login: String, + } + #[derive(Deserialize)] + struct Parent { + name: String, + owner: Owner, + } + #[derive(Deserialize)] + struct View { + #[serde(rename = "nameWithOwner")] + repo: String, + parent: Option, + } + let view: View = serde_json::from_str(json).map_err(|error| error.to_string())?; + let (owner, name) = split_github_repo(&view.repo)?; + let mut repos = vec![format!("{owner}/{name}")]; + if let Some(parent) = view.parent { + let (owner, name) = split_github_repo(&format!("{}/{}", parent.owner.login, parent.name))?; + let repo = format!("{owner}/{name}"); + if !repos[0].eq_ignore_ascii_case(&repo) { + repos.push(repo); + } + } + Ok(repos) +} + fn git_github_work_items_for( root: &Path, + repo: &str, kind: &str, assigned_to_me: bool, state: &str, search: &str, limit: u32, ) -> Result, String> { + let (owner, name) = split_github_repo(repo)?; + let repo = format!("{owner}/{name}"); let kind = kind.trim(); if kind != "issue" && kind != "pr" { return Err("Unknown GitHub task kind".into()); @@ -1704,6 +1780,8 @@ fn git_github_work_items_for( let mut args = vec![ kind.to_string(), "list".into(), + "--repo".into(), + repo.clone(), "--state".into(), state.into(), "--limit".into(), @@ -1722,7 +1800,6 @@ fn git_github_work_items_for( } let refs: Vec<&str> = args.iter().map(String::as_str).collect(); let json = gh_checked(root, &refs)?; - let repo = git_github_repo_for(root).unwrap_or_default(); parse_github_work_items(&json, kind, &repo) } @@ -1756,9 +1833,12 @@ fn git_github_work_item_for( fn git_github_work_item_details_for( root: &Path, + repo: &str, kind: &str, number: i64, ) -> Result { + let (owner, name) = split_github_repo(repo)?; + let repo = format!("{owner}/{name}"); let kind = kind.trim(); if kind != "issue" && kind != "pr" { return Err("Unknown GitHub task kind".into()); @@ -1769,7 +1849,10 @@ fn git_github_work_item_details_for( } else { "body,author" }; - let json = gh_checked(root, &[kind, "view", &number, "--json", fields])?; + let json = gh_checked( + root, + &[kind, "view", &number, "--repo", &repo, "--json", fields], + )?; parse_github_work_item_details(&json) } @@ -1910,6 +1993,7 @@ mutation InboxReviewReply($threadId: ID!, $body: String!) { fn git_github_work_item_thread_for( root: &Path, + repo: &str, kind: &str, number: i64, ) -> Result { @@ -1920,8 +2004,7 @@ fn git_github_work_item_thread_for( if number <= 0 { return Err("Invalid GitHub item number".into()); } - let repo = git_github_repo_for(root)?; - let (owner, name) = split_github_repo(&repo)?; + let (owner, name) = split_github_repo(repo)?; let query = if kind == "pr" { GITHUB_PR_THREAD_QUERY } else { @@ -1969,11 +2052,14 @@ fn github_comment_input<'a>( fn git_github_work_item_comment_for( root: &Path, + repo: &str, kind: &str, number: i64, body: &str, in_reply_to: &str, ) -> Result { + let (owner, name) = split_github_repo(repo)?; + let repo = format!("{owner}/{name}"); let (kind, body) = github_comment_input(kind, number, body)?; let reply = in_reply_to.trim(); if !reply.is_empty() { @@ -1981,7 +2067,18 @@ fn git_github_work_item_comment_for( } let number = number.to_string(); with_temp_markdown(body, |path| { - let output = gh_checked(root, &[kind, "comment", &number, "--body-file", path])?; + let output = gh_checked( + root, + &[ + kind, + "comment", + &number, + "--repo", + &repo, + "--body-file", + path, + ], + )?; github_url_from_output(&output, "GitHub did not return a comment URL") }) } @@ -2473,18 +2570,28 @@ fn github_avatar_url(login: &str) -> String { format!("https://avatars.githubusercontent.com/{encoded}?s=64") } -fn git_github_pr_diff_for(root: &Path, number: i64) -> Result { +fn git_github_pr_diff_for(root: &Path, repo: &str, number: i64) -> Result { + let (owner, name) = split_github_repo(repo)?; + let repo = format!("{owner}/{name}"); if number <= 0 { return Err("Invalid pull request number".into()); } let number = number.to_string(); let json = gh_run( root, - &["pr", "view", &number, "--json", "files,additions,deletions"], + &[ + "pr", + "view", + &number, + "--repo", + &repo, + "--json", + "files,additions,deletions", + ], false, )?; let mut diff = parse_github_pr_diff_meta(&json)?; - let patch = gh_run(root, &["pr", "diff", &number], true)?; + let patch = gh_run(root, &["pr", "diff", &number, "--repo", &repo], true)?; if patch.len() > MAX_PR_DIFF_BYTES { diff.truncated = true; } else { @@ -5112,6 +5219,61 @@ mod tests { ); } + #[test] + fn github_inbox_discovers_parent_without_an_upstream_remote() { + let dir = tmp("github-inbox-fork"); + git_run(&dir.0, &["init"]).unwrap(); + git_run( + &dir.0, + &["remote", "add", "origin", "git@github.com:me/widget.git"], + ) + .unwrap(); + let repos = git_github_inbox_repos_for(&dir.0, |root, args| { + assert_eq!(root, dir.0); + assert_eq!(args, ["repo", "view", "git@github.com:me/widget.git", "--json", "nameWithOwner,parent"]); + Ok(r#"{"nameWithOwner":"me/widget","parent":{"name":"widget","owner":{"login":"acme"}}}"#.into()) + }).unwrap(); + assert_eq!(repos, ["me/widget", "acme/widget"]); + + git_run( + &dir.0, + &[ + "remote", + "add", + "upstream", + "https://github.com/acme/widget.git", + ], + ) + .unwrap(); + git_run(&dir.0, &["config", "remote.upstream.gh-resolved", "base"]).unwrap(); + let repos_with_upstream_default = git_github_inbox_repos_for(&dir.0, |_, args| { + assert_eq!(args[2], "git@github.com:me/widget.git"); + Ok(r#"{"nameWithOwner":"me/widget","parent":{"name":"widget","owner":{"login":"acme"}}}"#.into()) + }).unwrap(); + assert_eq!(repos_with_upstream_default, repos); + } + + #[test] + fn github_inbox_keeps_repositories_without_a_visible_parent() { + for json in [ + r#"{"nameWithOwner":"acme/widget","parent":null}"#, + r#"{"nameWithOwner":"acme/widget"}"#, + ] { + assert_eq!(parse_github_inbox_repos(json).unwrap(), ["acme/widget"]); + } + } + + #[test] + fn github_inbox_validates_and_deduplicates_repository_identities() { + let json = r#"{"nameWithOwner":"acme/widget","parent":{"name":"Widget","owner":{"login":"ACME"}}}"#; + assert_eq!(parse_github_inbox_repos(json).unwrap(), ["acme/widget"]); + assert!(parse_github_inbox_repos(r#"{"nameWithOwner":"widget"}"#).is_err()); + assert!(parse_github_inbox_repos( + r#"{"nameWithOwner":"acme/widget","parent":{"name":"widget","owner":{"login":""}}}"# + ) + .is_err()); + } + #[test] fn parse_github_work_items_maps_issue_fields() { let json = r#"[{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06dc87eb..24911a02 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -239,6 +239,7 @@ pub fn run() { fs::git_pr_create, fs::git_github_status, fs::git_github_repo, + fs::git_github_inbox_repos, fs::git_github_work_item, fs::git_github_work_items, fs::git_github_work_item_details, diff --git a/src/App.tsx b/src/App.tsx index 6ac7a9fb..543e1da4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2932,6 +2932,7 @@ export default function App({ void githubWorkItemThread( session.cwd, + session.linkedWorkItem.repo, session.linkedWorkItem.kind, session.linkedWorkItem.number, { force: true }, diff --git a/src/lib/githubInbox.test.ts b/src/lib/githubInbox.test.ts new file mode 100644 index 00000000..379a51f2 --- /dev/null +++ b/src/lib/githubInbox.test.ts @@ -0,0 +1,259 @@ +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearInboxCache, + githubPrDiff, + githubWorkItemComment, + githubWorkItemDetails, + githubWorkItemThread, + listInboxItems, + peekGithubPrDiff, + peekGithubWorkItemDetails, + peekGithubWorkItemThread, + type GithubTaskKind, +} from "./githubTasks"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +const query = { assignedToMe: false, state: "open" as const, search: "" }; +const reposByPath: Record = { + "/fork": ["me/widget", "acme/widget"], + "/other-fork": ["other/widget", "Acme/Widget"], + "/upstream": ["acme/widget"], + "/plain": ["team/docs"], +}; + +type Request = { + cwd: string; + repo: string; + kind: GithubTaskKind; + number: number; +}; + +beforeEach(() => { + clearInboxCache(); + vi.mocked(invoke).mockReset(); + vi.mocked(invoke).mockImplementation(async (command, args) => { + const { cwd, repo, kind, number } = (args ?? {}) as Request; + switch (command) { + case "linear_status": + case "gitlab_status": + return { connected: false }; + case "git_github_inbox_repos": + if (!reposByPath[cwd]) throw new Error("Repository unavailable"); + return reposByPath[cwd]; + case "git_github_work_items": + return [ + { + kind, + repo, + number: 10, + title: repo, + state: "open", + draft: false, + url: `https://github.com/${repo}/${kind === "pr" ? "pull" : "issues"}/10`, + updatedAt: "2026-09-14T00:00:00Z", + labels: [], + assignees: [], + }, + ]; + case "git_github_work_item_details": + return { body: repo, author: "author" }; + case "git_github_work_item_thread": + return { + comments: [], + commits: [], + truncated: false, + reviewDecision: "", + baseRefName: repo, + headRefName: "feature", + }; + case "git_github_pr_diff": + return { + additions: 1, + deletions: 0, + files: [], + patch: repo, + truncated: false, + }; + case "git_github_work_item_comment": + return `https://github.com/${repo}/${kind === "pr" ? "pull" : "issues"}/${number}#comment`; + default: + throw new Error(`Unexpected command: ${command}`); + } + }); +}); + +describe("fork repositories in Inbox", () => { + it("lists both repositories and keeps the local fork as the work destination", async () => { + const result = await listInboxItems([{ path: "/fork" }], query); + expect(result.errors).toEqual({}); + expect(result.items).toHaveLength(4); + expect( + result.items.map(({ repo, kind, projectPath }) => ({ + repo, + kind, + projectPath, + })), + ).toEqual( + expect.arrayContaining([ + { repo: "me/widget", kind: "issue", projectPath: "/fork" }, + { repo: "me/widget", kind: "pr", projectPath: "/fork" }, + { repo: "acme/widget", kind: "issue", projectPath: "/fork" }, + { repo: "acme/widget", kind: "pr", projectPath: "/fork" }, + ]), + ); + expect(invoke).toHaveBeenCalledWith("git_github_work_items", { + cwd: "/fork", + repo: "acme/widget", + kind: "issue", + ...query, + limit: undefined, + }); + }); + + it("fetches a shared upstream once even when it is also a separate project", async () => { + const result = await listInboxItems( + ["/fork", "/other-fork", "/upstream"].map((path) => ({ path })), + query, + ); + const upstream = result.items.filter( + (item) => item.repo.toLowerCase() === "acme/widget", + ); + expect(upstream).toHaveLength(2); + expect(upstream.every((item) => item.projectPath === "/fork")).toBe(true); + const listCalls = vi + .mocked(invoke) + .mock.calls.filter(([command]) => command === "git_github_work_items"); + expect(listCalls).toHaveLength(6); + }); + + it("preserves filtering and ordinary repositories", async () => { + const result = await listInboxItems([{ path: "/plain" }], { + assignedToMe: true, + state: "all", + search: " fix ", + }); + expect(result.items).toHaveLength(2); + expect(result.items.every((item) => item.repo === "team/docs")).toBe(true); + expect(invoke).toHaveBeenCalledWith("git_github_work_items", { + cwd: "/plain", + repo: "team/docs", + kind: "pr", + assignedToMe: true, + state: "all", + search: "fix", + limit: 100, + }); + }); + + it("keeps upstream items if the fork has issues disabled", async () => { + const implementation = vi.mocked(invoke).getMockImplementation()!; + vi.mocked(invoke).mockImplementation(async (command, args) => { + const request = args as Request; + if ( + command === "git_github_work_items" && + request.repo === "me/widget" && + request.kind === "issue" + ) { + throw new Error("Issues are disabled for this repo"); + } + return implementation(command, args); + }); + const result = await listInboxItems([{ path: "/fork" }], query); + expect(result.items).toHaveLength(3); + expect( + result.items.filter((item) => item.repo === "acme/widget"), + ).toHaveLength(2); + }); + + it("keeps successful projects and reports discovery errors when none succeed", async () => { + const partial = await listInboxItems( + [{ path: "/unavailable" }, { path: "/plain" }], + query, + ); + expect(partial.items).toHaveLength(2); + expect(partial.errors).toEqual({}); + const failed = await listInboxItems([{ path: "/unavailable" }], query); + expect(failed).toEqual({ + items: [], + errors: { github: "Repository unavailable" }, + }); + }); +}); + +describe("repository identity for Inbox operations", () => { + it("keeps details for the same number in fork and upstream separate", async () => { + await Promise.all([ + githubWorkItemDetails("/fork", "me/widget", "issue", 10), + githubWorkItemDetails("/fork", "acme/widget", "issue", 10), + ]); + expect(peekGithubWorkItemDetails("me/widget", "issue", 10)?.body).toBe( + "me/widget", + ); + expect(peekGithubWorkItemDetails("ACME/Widget", "issue", 10)?.body).toBe( + "acme/widget", + ); + expect(invoke).toHaveBeenCalledWith("git_github_work_item_details", { + cwd: "/fork", + repo: "acme/widget", + kind: "issue", + number: 10, + }); + }); + + it("separates concurrent threads and diffs by repository, sharing across checkouts", async () => { + await Promise.all([ + githubWorkItemThread("/fork", "me/widget", "pr", 10), + githubWorkItemThread("/fork", "acme/widget", "pr", 10), + githubWorkItemThread("/upstream", "ACME/Widget", "pr", 10), + githubPrDiff("/fork", "me/widget", 10), + githubPrDiff("/fork", "acme/widget", 10), + githubPrDiff("/upstream", "ACME/Widget", 10), + ]); + expect(invoke).toHaveBeenCalledTimes(4); + expect(peekGithubWorkItemThread("me/widget", "pr", 10)?.baseRefName).toBe( + "me/widget", + ); + expect(peekGithubWorkItemThread("acme/widget", "pr", 10)?.baseRefName).toBe( + "acme/widget", + ); + expect(peekGithubPrDiff("me/widget", 10)?.patch).toBe("me/widget"); + expect(peekGithubPrDiff("acme/widget", 10)?.patch).toBe("acme/widget"); + expect(invoke).toHaveBeenCalledWith("git_github_work_item_thread", { + cwd: "/fork", + repo: "acme/widget", + kind: "pr", + number: 10, + }); + expect(invoke).toHaveBeenCalledWith("git_github_pr_diff", { + cwd: "/fork", + repo: "acme/widget", + number: 10, + }); + }); + + it.each(["issue", "pr"] as const)( + "targets %s comments and invalidates only that repository's thread", + async (kind) => { + await Promise.all([ + githubWorkItemThread("/fork", "me/widget", kind, 10), + githubWorkItemThread("/fork", "acme/widget", kind, 10), + ]); + const inReplyTo = kind === "pr" ? "PRRT_thread" : ""; + await githubWorkItemComment("/fork", "acme/widget", kind, 10, " hello ", { + inReplyTo, + }); + expect(invoke).toHaveBeenCalledWith("git_github_work_item_comment", { + cwd: "/fork", + repo: "acme/widget", + kind, + number: 10, + body: "hello", + inReplyTo, + }); + expect(peekGithubWorkItemThread("acme/widget", kind, 10)).toBeNull(); + expect(peekGithubWorkItemThread("me/widget", kind, 10)).not.toBeNull(); + }, + ); +}); diff --git a/src/lib/githubTasks.ts b/src/lib/githubTasks.ts index f7e3d03c..2b8b1e62 100644 --- a/src/lib/githubTasks.ts +++ b/src/lib/githubTasks.ts @@ -159,6 +159,7 @@ type InboxListCache = InboxListResult & { let inboxListCache: InboxListCache | null = null; const inboxListInflight = new Map>(); const repoByPath = new Map(); +const inboxReposByPath = new Map(); const workItemByKey = new Map(); const workItemInflight = new Map>(); const detailsByKey = new Map(); @@ -171,6 +172,7 @@ export function clearInboxCache() { inboxListCache = null; inboxListInflight.clear(); repoByPath.clear(); + inboxReposByPath.clear(); workItemByKey.clear(); workItemInflight.clear(); detailsByKey.clear(); @@ -234,12 +236,23 @@ export async function githubRepo(cwd: string): Promise { return repo; } +export async function githubInboxRepos(cwd: string): Promise { + const key = normalizeProjectPath(cwd); + const cached = inboxReposByPath.get(key); + if (cached !== undefined) return cached; + const repos = await invoke("git_github_inbox_repos", { cwd }); + inboxReposByPath.set(key, repos); + return repos; +} + export function listGithubWorkItems( cwd: string, + repo: string, query: GithubWorkItemQuery, ): Promise { return invoke("git_github_work_items", { cwd, + repo, kind: query.kind, assignedToMe: query.assignedToMe, state: query.state, @@ -351,49 +364,51 @@ export function formatRelativeTime( } export function detailsCacheKey( - cwd: string, + repo: string, kind: GithubTaskKind, number: number, ): string { - return `${normalizeProjectPath(cwd)}:${kind}:${number}`; + return workItemLookupKey(repo, kind, number); } export function peekGithubWorkItemDetails( - cwd: string, + repo: string, kind: GithubTaskKind, number: number, ): GithubWorkItemDetails | null { - return detailsByKey.get(detailsCacheKey(cwd, kind, number)) ?? null; + return detailsByKey.get(detailsCacheKey(repo, kind, number)) ?? null; } export async function githubWorkItemDetails( cwd: string, + repo: string, kind: GithubTaskKind, number: number, ): Promise { const details = await invoke( "git_github_work_item_details", - { cwd, kind, number }, + { cwd, repo, kind, number }, ); - detailsByKey.set(detailsCacheKey(cwd, kind, number), details); + detailsByKey.set(detailsCacheKey(repo, kind, number), details); return details; } export function peekGithubWorkItemThread( - cwd: string, + repo: string, kind: GithubTaskKind, number: number, ): GithubWorkItemThread | null { - return threadByKey.get(detailsCacheKey(cwd, kind, number)) ?? null; + return threadByKey.get(detailsCacheKey(repo, kind, number)) ?? null; } export async function githubWorkItemThread( cwd: string, + repo: string, kind: GithubTaskKind, number: number, options?: { force?: boolean }, ): Promise { - const key = detailsCacheKey(cwd, kind, number); + const key = detailsCacheKey(repo, kind, number); if (options?.force) { threadByKey.delete(key); threadInflight.delete(key); @@ -402,6 +417,7 @@ export async function githubWorkItemThread( if (pending) return pending; const promise = invoke("git_github_work_item_thread", { cwd, + repo, kind, number, }) @@ -418,6 +434,7 @@ export async function githubWorkItemThread( export async function githubWorkItemComment( cwd: string, + repo: string, kind: GithubTaskKind, number: number, body: string, @@ -425,12 +442,13 @@ export async function githubWorkItemComment( ): Promise { const url = await invoke("git_github_work_item_comment", { cwd, + repo, kind, number, body: body.trim(), inReplyTo: options?.inReplyTo?.trim() ?? "", }); - const key = detailsCacheKey(cwd, kind, number); + const key = detailsCacheKey(repo, kind, number); threadByKey.delete(key); threadInflight.delete(key); return url; @@ -499,25 +517,30 @@ export function gitlabAttentionLabel(reason: string): string { } } -export function prDiffCacheKey(cwd: string, number: number): string { - return `${normalizeProjectPath(cwd)}:pr:${number}`; +export function prDiffCacheKey(repo: string, number: number): string { + return workItemLookupKey(repo, "pr", number); } export function peekGithubPrDiff( - cwd: string, + repo: string, number: number, ): GithubPrDiff | null { - return prDiffByKey.get(prDiffCacheKey(cwd, number)) ?? null; + return prDiffByKey.get(prDiffCacheKey(repo, number)) ?? null; } export async function githubPrDiff( cwd: string, + repo: string, number: number, ): Promise { - const key = prDiffCacheKey(cwd, number); + const key = prDiffCacheKey(repo, number); const pending = prDiffInflight.get(key); if (pending) return pending; - const promise = invoke("git_github_pr_diff", { cwd, number }) + const promise = invoke("git_github_pr_diff", { + cwd, + repo, + number, + }) .then((diff) => { prDiffByKey.set(key, diff); return diff; @@ -558,22 +581,20 @@ async function fetchInboxItems( ): Promise { const unique = uniqueInboxProjects(projects); const preferredPaths = unique.map((project) => project.path); - const resolved = await Promise.all( + const resolved = await Promise.allSettled( unique.map(async (project) => { - try { - return { - path: project.path, - repo: (await githubRepo(project.path)).trim(), - }; - } catch { - return { path: project.path, repo: "" }; - } + const repos = await githubInboxRepos(project.path); + return repos.map((repo) => ({ path: project.path, repo })); }), ); - const grouped = groupProjectsByRepo(resolved); + const grouped = groupProjectsByRepo( + resolved.flatMap((result) => + result.status === "fulfilled" ? result.value : [], + ), + ); const githubJobs = grouped.flatMap((project) => (["issue", "pr"] as const).map(async (kind) => { - const items = await listGithubWorkItems(project.path, { + const items = await listGithubWorkItems(project.path, project.repo, { ...query, kind, }); @@ -586,11 +607,14 @@ async function fetchInboxItems( }), ); const github = collectInboxResults( - await Promise.allSettled(githubJobs), + [ + ...resolved.filter((result) => result.status === "rejected"), + ...(await Promise.allSettled(githubJobs)), + ], preferredPaths, ); const errors: InboxProviderErrors = {}; - if (github.error && grouped.length > 0) errors.github = github.error; + if (github.error && unique.length > 0) errors.github = github.error; let linearItems: InboxItem[] = []; if ((await linearConnected()).connected) { diff --git a/src/surfaces/InboxView.test.ts b/src/surfaces/InboxView.test.ts index ef20add6..51caea75 100644 --- a/src/surfaces/InboxView.test.ts +++ b/src/surfaces/InboxView.test.ts @@ -1,10 +1,22 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; -import type { InboxItem } from "../lib/githubTasks"; +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearInboxCache, + githubWorkItemDetails, + type InboxItem, +} from "../lib/githubTasks"; import type { SessionSummary } from "../lib/sessionStore"; import { InboxDetail } from "./InboxView"; +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +beforeEach(() => { + clearInboxCache(); + vi.mocked(invoke).mockReset(); +}); + function item(overrides: Partial = {}): InboxItem { return { kind: "issue", @@ -41,6 +53,22 @@ function renderDetail( } describe("InboxDetail layout", () => { + it("renders upstream details when the fork has the same issue number", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + body: "Fork description", + author: "forker", + }); + await githubWorkItemDetails("/tmp/web", "me/web", "issue", 157); + vi.mocked(invoke).mockResolvedValueOnce({ + body: "Upstream description", + author: "maintainer", + }); + await githubWorkItemDetails("/tmp/web", "acme/web", "issue", 157); + const markup = renderDetail(item()); + expect(markup).toContain("Upstream description"); + expect(markup).not.toContain("Fork description"); + }); + it("keeps issue identity and actions outside the body scroller", () => { const markup = renderDetail(item({ projectPath: "/tmp/local-project" })); const headerIndex = markup.indexOf("data-inbox-detail-header"); diff --git a/src/surfaces/InboxView.tsx b/src/surfaces/InboxView.tsx index c056395a..2cfd0439 100644 --- a/src/surfaces/InboxView.tsx +++ b/src/surfaces/InboxView.tsx @@ -1216,19 +1216,19 @@ export function InboxDetail({ : gitlabKind ? peekGitlabWorkItemDetails(item.repo, gitlabKind, item.number) : githubKind - ? peekGithubWorkItemDetails(item.projectPath, githubKind, item.number) + ? peekGithubWorkItemDetails(item.repo, githubKind, item.number) : null; const cachedDiff = isPr ? gitlab ? peekGitlabMrDiff(item.repo, item.number) - : peekGithubPrDiff(item.projectPath, item.number) + : peekGithubPrDiff(item.repo, item.number) : null; const cachedThread = linear ? peekLinearIssueThread(item.id ?? "") : gitlabKind ? peekGitlabWorkItemThread(item.repo, gitlabKind, item.number) : githubKind - ? peekGithubWorkItemThread(item.projectPath, githubKind, item.number) + ? peekGithubWorkItemThread(item.repo, githubKind, item.number) : null; const [details, setDetails] = useState(cached); const [loading, setLoading] = useState(cached == null); @@ -1296,7 +1296,7 @@ export function InboxDetail({ : gitlabKind ? peekGitlabWorkItemDetails(item.repo, gitlabKind, item.number) : githubKind - ? peekGithubWorkItemDetails(item.projectPath, githubKind, item.number) + ? peekGithubWorkItemDetails(item.repo, githubKind, item.number) : null; if (cachedDetails) { setDetails(cachedDetails); @@ -1314,7 +1314,12 @@ export function InboxDetail({ : gitlabKind ? gitlabWorkItemDetails(item.repo, gitlabKind, item.number) : githubKind - ? githubWorkItemDetails(item.projectPath, githubKind, item.number) + ? githubWorkItemDetails( + item.projectPath, + item.repo, + githubKind, + item.number, + ) : Promise.reject(new Error("Unknown inbox item")); void pending .then((next) => { @@ -1411,7 +1416,7 @@ export function InboxDetail({ } if (!githubKind) return; const cachedThread = peekGithubWorkItemThread( - item.projectPath, + item.repo, githubKind, item.number, ); @@ -1424,7 +1429,12 @@ export function InboxDetail({ setThreadError(null); setThread(null); } - void githubWorkItemThread(item.projectPath, githubKind, item.number) + void githubWorkItemThread( + item.projectPath, + item.repo, + githubKind, + item.number, + ) .then((next) => { if (cancelled) return; setThread(next); @@ -1457,7 +1467,7 @@ export function InboxDetail({ let cancelled = false; const cachedDiff = gitlab ? peekGitlabMrDiff(item.repo, item.number) - : peekGithubPrDiff(item.projectPath, item.number); + : peekGithubPrDiff(item.repo, item.number); if (cachedDiff) { setPrDiff(cachedDiff); setDiffLoading(false); @@ -1469,7 +1479,7 @@ export function InboxDetail({ } const pending = gitlab ? gitlabMrDiff(item.repo, item.number) - : githubPrDiff(item.projectPath, item.number); + : githubPrDiff(item.projectPath, item.repo, item.number); void pending .then((next) => { if (cancelled) return; @@ -1521,6 +1531,7 @@ export function InboxDetail({ if (!githubKind) throw new Error("Unknown inbox item"); await githubWorkItemComment( item.projectPath, + item.repo, githubKind, item.number, body, @@ -1531,6 +1542,7 @@ export function InboxDetail({ setThread( await githubWorkItemThread( item.projectPath, + item.repo, githubKind, item.number, { @@ -1808,7 +1820,7 @@ export function InboxDetail({

{diffError}

) : prDiff ? ( ) : ( From cda9c15b0cce7d1add22c3fe91eba4f1af693864 Mon Sep 17 00:00:00 2001 From: Eric Rasputin Date: Mon, 14 Sep 2026 00:36:16 +0530 Subject: [PATCH 07/12] feat: generate worktree branch names from initial requests --- docs/worktree-naming-failures-research.md | 40 ++ docs/worktree-naming-research.md | 113 ++++++ docs/worktrees.md | 10 +- src-tauri/src/fs.rs | 41 +- src-tauri/src/lib.rs | 2 + src-tauri/src/session_store.rs | 52 ++- src-tauri/src/worktree_naming.rs | 462 ++++++++++++++++++++++ src-tauri/src/worktree_naming_tests.rs | 457 +++++++++++++++++++++ src-tauri/src/worktree_setup.rs | 17 +- src-tauri/src/worktrees.rs | 65 ++- src/App.tsx | 80 +++- src/chrome/BranchPicker.tsx | 6 +- src/dev/worktrees.tsx | 20 + src/index.css | 63 ++- src/lib/harness/claudeTitle.ts | 3 +- src/lib/harness/codexTitle.ts | 3 +- src/lib/harness/cursorTitle.ts | 3 +- src/lib/harness/grokTitle.ts | 3 +- src/lib/harness/opencodeTitle.ts | 3 +- src/lib/harness/piTitle.ts | 5 +- src/lib/harness/registry.ts | 1 + src/lib/initialSessionMetadata.test.ts | 82 ++++ src/lib/initialSessionMetadata.ts | 40 ++ src/lib/sessionTitle.test.ts | 62 ++- src/lib/sessionTitle.ts | 83 ++-- src/lib/worktreeNaming.test.ts | 218 ++++++++++ src/lib/worktreeNaming.ts | 153 +++++++ src/lib/worktrees.test.ts | 74 ++++ src/lib/worktrees.ts | 11 +- 29 files changed, 2089 insertions(+), 83 deletions(-) create mode 100644 docs/worktree-naming-failures-research.md create mode 100644 docs/worktree-naming-research.md create mode 100644 src-tauri/src/worktree_naming.rs create mode 100644 src-tauri/src/worktree_naming_tests.rs create mode 100644 src/lib/initialSessionMetadata.test.ts create mode 100644 src/lib/initialSessionMetadata.ts create mode 100644 src/lib/worktreeNaming.test.ts create mode 100644 src/lib/worktreeNaming.ts diff --git a/docs/worktree-naming-failures-research.md b/docs/worktree-naming-failures-research.md new file mode 100644 index 00000000..28ff1f82 --- /dev/null +++ b/docs/worktree-naming-failures-research.md @@ -0,0 +1,40 @@ +# AI naming failures: T3 Code comparison + +Reviewed 2026-09-14 against official T3 Code main, commit `77bca8b2d76a1f42552e5eee7d277fcb1160347a`. The official GitHub repository was opened and `git ls-remote origin refs/heads/main` confirmed that the existing research checkout still matches main. This is a source investigation; no T3 provider quota was exhausted or live generation run. + +## What T3 does + +T3 treats first-message branch naming and thread-title generation as separate background operations. Both are forked before the main provider turn proceeds. A failed metadata request does not block worktree creation or fail the main conversation. [First-turn orchestration](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1404-L1433) + +| Behavior | Automatic branch name | Automatic thread title | +| --- | --- | --- | +| Automatic retries | None in the naming path. | Two additional attempts with exponential backoff starting at two seconds (two and four seconds). The retry does not classify quota errors separately. | +| Model failover after request failure | None. | None. | +| Final failure | Logs a warning; leaves the temporary branch intact if generation failed before rename. | Logs a warning; leaves the seeded/current title intact. | +| User-visible naming error | No error event or toast dispatched from this background path. | No error event or toast dispatched from this background path. | + +These differences are explicit in the adjacent [branch and title helpers](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L991-L1106). A focused test exercises a transient title-generation timeout followed by a successful retry. [Retry test](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts#L1583-L1645) + +There is a manual **Regenerate title** action. The client shows an error if submitting that command fails, but the background generation worker catches generation failures, logs them, and clears the pending regeneration without changing the title. This is not a branch-naming retry action or a quota-failover UI. [Client action](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/web/src/components/Sidebar.tsx#L4390-L4408), [regeneration worker](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1207-L1257) + +## Model selection and quota + +The model for branch names is the configured source-control writer selection, or the configured text-generation selection when no writer override is set. An unavailable/disabled writer instance falls back to text generation **before** requesting a name. The runtime service then resolves that single instance and calls it directly; it does not try another provider after a quota or inference error. [Selection helper](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/packages/shared/src/serverSettings.ts#L84-L100), [single-instance routing](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGeneration.ts#L124-L171) + +Both selections have settings UI. Text generation defaults to Codex `gpt-5.6-luna` with low reasoning; per-provider defaults include Claude Haiku 4.5 and Cursor Composer 2. These are source defaults, not guarantees that a user's provider supports or has quota for that model. [Defaults](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/packages/contracts/src/model.ts#L164-L188), [text-generation setting](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/web/src/components/settings/SettingsPanels.tsx#L2918-L2944), [writer setting](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/web/src/components/settings/SourceControlWritingSettings.tsx#L283-L353) + +Codex, Claude, Cursor, Grok, and Antigravity text-generation runners have 180-second timeouts. OpenCode's text-generation wrapper has no equivalent explicit timeout in that file, so a universal three-minute guarantee should not be inferred. Provider CLIs or services may implement their own retries; that is separate from T3's naming orchestration. [Codex](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CodexTextGeneration.ts#L41), [Claude](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/ClaudeTextGeneration.ts#L53), [Cursor](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CursorTextGeneration.ts#L30), [Grok](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/GrokTextGeneration.ts#L35), [Antigravity](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/AntigravityTextGeneration.ts#L36), [OpenCode](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts) + +## Why plain billing errors do not become T3 names + +T3 asks for a JSON object and validates the expected field (`branch: string` or `title: string`) before sanitizing it. Cursor extracts a JSON object from the response and schema-decodes it; empty or invalid output produces a typed `TextGenerationError`. It has no plain-text naming fallback. Therefore a plain response such as “Upgrade your plan to continue” fails parsing rather than becoming a branch or title. [Prompt/schema](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.ts#L185-L207), [Cursor validation](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CursorTextGeneration.ts#L107-L168) + +Codex additionally passes an output schema to the CLI, checks the process exit code, and schema-decodes its output file. Claude checks the exit code and validates the `structured_output` envelope. OpenCode checks `result.data.info.error` before reading text and validates its JSON. [Codex runner](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CodexTextGeneration.ts#L207-L312), [Claude runner](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/ClaudeTextGeneration.ts#L248-L312), [OpenCode validation](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts#L246-L375) + +Schema validation is structural, not semantic: T3 does not reject an otherwise schema-valid object merely because its `branch` or `title` contains billing-error words. That limitation follows from the string-only schema and subsequent sanitizer. [Schema](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.ts#L201-L207), [Cursor sanitation](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CursorTextGeneration.ts#L218-L239) + +## Implications for Monocode + +The useful T3 patterns are structured-result validation, preserved fallback names, independent background generation, and bounded retries (currently implemented for T3 titles). Monocode's combined title/branch request can reuse the retry policy while retaining its own cancellation, deadline, and native rename eligibility checks. Validate provider success/error metadata and structured fields before changing either name; arbitrary response prose must not be treated as a title or branch. + +A visible **AI naming unavailable—kept the default branch name** notice with manual retry is an explicit Monocode improvement requested in this conversation, not behavior already present in T3's branch path. Quota/account failures should remain understandable and should not silently switch to another provider/account. Avoid describing pre-request provider selection fallback as runtime quota failover. diff --git a/docs/worktree-naming-research.md b/docs/worktree-naming-research.md new file mode 100644 index 00000000..2869583b --- /dev/null +++ b/docs/worktree-naming-research.md @@ -0,0 +1,113 @@ +# AI worktree naming investigation + +Reviewed 2026-09-13 against Monocode `e080298ca5fe4b356ed4b02b1bf476d2de945547` and T3 Code `77bca8b2d76a1f42552e5eee7d277fcb1160347a`. The findings below describe the pre-implementation source. Implementation followed this investigation; current user-facing behavior is documented in [Worktrees](worktrees.md). + +## Finding + +Monocode already has the AI generation infrastructure. Worktree creation bypasses it and uses the beginning of the raw message plus the full session ID as the Git branch name. The recommended change is to generate a semantic branch name from the first-message context in the background, keeping creation and agent startup independent of AI availability. Preserve the checkout's stable directory and update the branch through the native worktree lifecycle, including its ownership metadata. + +For this request, the current algorithm produces `monocode/right-now-the-name-of-the-worktree-is-aut-`. The desired result could be `monocode/ai-worktree-naming`. The latter is an illustrative target, not a measured model response. + +## What Monocode does today + +| Area | Confirmed implementation | +| --- | --- | +| First send | `onSubmit` calls `prepareSessionWorktree(current, submittedText)`, waits for checkout preparation and setup, persists `worktreeCwd`, then starts the provider turn. [Send flow](../src/App.tsx#L4536) | +| Branch name | Native `create` calls `slug(name)`: ASCII lowercase, punctuation replaced with hyphens, truncate to 42 characters before collapsing hyphens, fallback `task`. It creates `monocode/{slug}-{full session ID}`. [Allocator](../src-tauri/src/worktrees.rs#L3401) | +| Directory | The physical directory is `/worktrees/-/`. It is already independent of the human-readable branch fragment. [Creation](../src-tauri/src/worktrees.rs#L3452) | +| Visible name | The worktree menu and Settings inventory display the Git branch. There is no separate worktree display-name field in `WorktreeEntry`. [Workspace picker](../src/chrome/WorkspacePicker.tsx#L181), [inventory](../src/chrome/WorktreeManager.tsx#L202), [type](../src/lib/worktrees.ts#L20) | +| AI session title | First send independently launches `generateHarnessTitle`. Its callback updates the conversation title and optional linked work item, but never the worktree branch. It preserves a title that the user has changed. [Title dispatch](../src/App.tsx#L4388), [metadata prompt/parser](../src/lib/sessionTitle.ts) | +| Existing AI branch API | `generateHarnessBranchName` and the optional adapter hook already exist. Codex, Claude, Cursor, OpenCode and Grok implement them. The symbol has no application call site and is not exported from the harness barrel. [Registry](../src/lib/harness/registry.ts#L298), [exports](../src/lib/harness/index.ts#L130) | +| Existing branch prompt | Requests a short, specific 2–6 word description of the work as JSON. Input is capped at 8,000 characters; output is sanitized to a branch fragment. [Prompt and parser](../src/lib/gitText.ts#L75) | + +The reusable provider runners use installed harnesses. Current utility-model choices include Codex's `gpt-5.6-luna` with low effort, Claude's discovered Haiku model (fallback `claude-haiku-4-5`), Cursor's `composer-2.5`, Grok's `grok-4.6`, and OpenCode's first usable catalog model (fallback `opencode/glm-5`). These are observed code defaults, not recommendations about current model availability. [Codex](../src/lib/harness/codexText.ts), [Claude](../src/lib/harness/claudeText.ts), [Cursor](../src/lib/harness/cursorText.ts), [Grok](../src/lib/harness/grokProtocol.ts#L23), [OpenCode](../src/lib/harness/opencodeText.ts#L165) + +Pi and OMP have title generation and text runners, but no branch-generation adapter hook. FX exposes neither title nor branch generation. Extending the existing title metadata would cover Pi/OMP without adding another provider transport. [Pi adapter](../src/lib/harness/piAdapter.ts), [OMP adapter](../src/lib/harness/ompAdapter.ts), [Pi titles](../src/lib/harness/piTitle.ts), [FX adapter](../src/lib/harness/fxAdapter.ts) + +## What T3 Code actually does + +T3's implementation names the **Git branch**, while retaining the original checkout directory: + +1. First send requests a worktree with a temporary `t3code/<8 hex characters>` branch. Server bootstrap creates the checkout and records its path/branch before dispatching the turn. [Composer bootstrap](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/web/src/components/ChatView.tsx#L7405) +2. On the first user turn, excluding its compact command, the provider reactor forks branch generation and separately forks title generation. The branch task requires an existing worktree path and a branch matching T3's temporary pattern. Provider startup proceeds independently. [First-turn dispatch](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1402) +3. Branch generation uses the configured source-control writer model when available, otherwise the configured text-generation model. This selection is independent of the conversation's model. The text service routes through that provider instance. [Model selection](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1009), [writer resolver](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/packages/shared/src/serverSettings.ts#L84), [provider routing](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGeneration.ts#L149) +4. Context comes from the first message with citation markup converted to plain text, plus attachments. The prompt asks for a short semantic description; it is not a random word-pair generator. [Generation input](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1414), [branch prompt](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.ts#L185) +5. The result is normalized to a maximum 64-character fragment, prefixed with `t3code/`, and renamed using Git. If a local name is taken, the driver tries suffixes `-1` through `-100`. It uses non-forced `git branch -m` with the explicit old name. [Normalization](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L299), [collision handling](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/vcs/GitVcsDriverCore.ts#L973), [Git rename](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/vcs/GitVcsDriverCore.ts#L3312) +6. On success, the reactor updates thread metadata and refreshes Git status. Generation or rename failure is logged and does not fail the main turn. A generation failure leaves the temporary name usable. [Completion/failure handling](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1033) + +T3 launches its configured setup program before dispatching the first turn; that interface reports that setup has started, so naming is not guaranteed to wait for setup completion. Monocode currently waits for setup completion before its main agent starts. This difference matters when choosing when to apply the name. [T3 bootstrap order](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/ws.ts#L1189), [setup launch](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/ws.ts#L1086), [Monocode prepare/setup](../src/lib/worktrees.ts#L164) + +T3 caps prompt message text at 8,000 characters and attachment metadata at 4,000. Attachment handling differs by provider: its Codex helper also supplies image files to an ephemeral, read-only `codex exec` request with a JSON output schema; the Claude branch helper includes attachment metadata in its text prompt. These utility requests are separate from the visible conversation. [Prompt construction](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.ts#L158), [Codex execution](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CodexTextGeneration.ts#L186), [Codex branch/images](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CodexTextGeneration.ts#L367), [Claude branch helper](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/ClaudeTextGeneration.ts#L370) + +The inspected AI helper checks the temporary name before inference. It does not explicitly re-read the current branch or check upstream/publication state after inference. The explicit old-name, non-forced rename supplies some protection, but this should not be described as a comprehensive user-rename or publish-race guarantee. Monocode should enforce its own eligibility checks when applying a delayed result. [AI helper](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L991), [rename implementation](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/vcs/GitVcsDriverCore.ts#L3312) + +Inspected tests cover first-turn generation and citation conversion, branch prompt attachments, temporary-name eligibility and Git rename/no-op behavior. They were not run, and this investigation did not establish coverage for a publish race or a crash between Git rename and metadata persistence. [Reactor tests](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts#L2422), [prompt tests](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts#L116), [temporary-name tests](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/packages/shared/src/git.test.ts#L135), [Git rename tests](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/vcs/GitVcsDriverCore.test.ts#L1511) + +## Recommended Monocode implementation + +These are proposed choices, not existing behavior. + +### One first-message metadata request + +Extend the existing title request to optionally return a `branch` fragment alongside `title` and `workItem`, reusing the branch prompt's semantic rules and sanitizer. Validate fields independently so a malformed branch cannot discard a usable title or work-item hint. + +This avoids two queued calls: Monocode's utility runners serialize requests through a per-provider promise chain, and the current send flow starts title generation first. Simply adding and awaiting `generateHarnessBranchName` would wait behind title generation. Existing branch-generation timeouts are 60 seconds for Cursor and 90 seconds for the other branch adapters, before accounting for queue and initialization time. [Codex queue](../src/lib/harness/codexText.ts#L84), [Claude queue](../src/lib/harness/claudeText.ts#L69), [Cursor branch timeout](../src/lib/harness/cursorGit.ts#L15), [Codex branch timeout](../src/lib/harness/codexGit.ts#L15) + +Trigger metadata generation when a new worktree needs a name even if the conversation already has a custom title. Keep title replacement eligibility separate from worktree naming eligibility. Use the selected harness's existing metadata capability; retain a deterministic fallback when unsupported or unavailable. + +Build context from the actual initial request and relevant note, linked-item or handoff context, with attachment filenames when no meaningful text is available. Do not name a plan-based task from the generic `Build approved plan` string. The current worktree path receives `submittedText`, whereas title generation receives `harnessText` or attachment names. Neither existing branch hook nor `TitleInput` accepts image payloads; visual understanding from attachments would be an explicit extension. [Submission context](../src/App.tsx#L4165), [title input](../src/App.tsx#L4388), [adapter input](../src/lib/harness/registry.ts#L14) + +### Create immediately, finalize the name independently + +Keep the current UUID directory and stable ownership ID. Create with a concise temporary/fallback branch such as `monocode/task-`, using native collision checks. Start inference independently; creation and setup continue without waiting for it. Once setup finishes, apply an available result, or let the bounded background task apply it later if the checkout is still eligible. A failed or timed-out naming task must not fail the user's turn. + +Final names should be `monocode/` with a short collision suffix only when necessary. Keep `monocode/`: the existing lifecycle uses that prefix as part of its branch-ownership checks. The full session UUID should remain an internal identity rather than a permanent suffix on every visible branch. [Ownership checks](../src-tauri/src/worktrees.rs#L781) + +Generating before creation is a smaller alternative because it avoids synchronizing a later rename, but it adds model latency to the critical path. A short deadline limits that delay at the cost of abandoning valid late names. A separate display-name field would also avoid Git renames, but leave the underlying Git branch verbose. Background branch naming most closely matches the requested T3 experience. + +### Add a metadata-aware native rename operation + +This is the main integration work. A frontend `git branch -m` alone is insufficient: setup, retirement and reopening compare the checkout's branch against `managed_worktrees.branch`. A mismatch currently blocks them. [Setup validation](../src-tauri/src/worktree_setup.rs#L79), [retirement validation](../src-tauri/src/worktrees.rs#L1294), [reopen validation](../src-tauri/src/worktrees.rs#L3777) + +The native operation should: + +- Accept managed identity, expected temporary branch and proposed fragment. Re-read repository, checkout and naming eligibility under the existing repository reservation. Only worktrees explicitly marked as awaiting an automatic name qualify; a `monocode/` prefix alone is insufficient. +- Validate and allocate the final name in Rust using Git. Never overwrite an existing branch. Preserve the directory, base ref, commit, setup state and recovery identity. +- Decline a stale result if the branch was manually changed, the checkout was retired/replaced, retirement is underway, or publication makes a rename inappropriate. Do not rename during active setup. Intentional reuse of an existing worktree must never schedule a new name from the new conversation. +- Journal rename intent before mutating Git, then update the owned branch and affected session metadata. Git and SQLite cannot commit atomically; reconcile an interrupted, explicitly journaled operation without loosening the existing checks to accept arbitrary branch changes. +- Update every session referencing the checkout, including nested project paths. Protect against a stale frontend session save restoring the old branch: current session persistence prefers a supplied branch over fresh Git metadata. [Session persistence](../src-tauri/src/session_store.rs#L717) +- Notify all relevant windows, refresh branch/worktree caches and update visible session state. Current `notifyGitChanged()` is window-local. [Refresh event](../src/lib/fs.ts#L302), [worktree cache](../src/hooks/useWorktrees.ts), [branch cache](../src/hooks/useProjectBranches.ts) + +Persist naming status per worktree, so setup retry, app restart or a second conversation cannot repeatedly rename it. Generation must run outside lifecycle/database locks. Any coordination with Monocode's own publish actions should share the naming eligibility rule; external Git operations remain a race to handle conservatively. + +## Implementation scope and validation + +| Change | Main location | +| --- | --- | +| Combined title/work-item/branch response and parsing | `src/lib/sessionTitle.ts`, `src/lib/harness/registry.ts`, provider title adapters | +| First-message context and independent naming orchestration | `src/App.tsx`, preferably with the naming lifecycle extracted into a small helper | +| Prepare result identifies created versus reused worktree and pending naming | `src/lib/worktrees.ts`, native `PrepareWorktree`/prepare result | +| Collision allocation, rename journal, native rename and recovery reconciliation | `src-tauri/src/worktrees.rs` or a focused worktree naming module, command registration in `src-tauri/src/lib.rs` | +| Branch metadata freshness and window updates | `src-tauri/src/session_store.rs`, Git/worktree event subscribers | + +The work is a contained feature, but more than wiring one AI function: the existing provider plumbing is reusable; native lifecycle consistency is the substantive part. + +Acceptance checks should cover: + +- A conversational, long initial prompt yields a semantic name rather than its opening words; a manually set conversation title remains intact. +- AI failure, invalid/empty output, unsupported provider and timeout leave creation and the agent working. Exercise the total queue/startup deadline, not only the provider response timeout. +- Identical tasks in concurrent windows get distinct valid branches without overwriting refs. Names preserve the `monocode/` prefix and do not expose the full session UUID. +- Existing/local/external/shared worktrees are not renamed by subsequent conversations; setup retry resumes the same identity without another naming cycle. +- Late results after manual branch change, setup, publication, cancellation or retirement obey the chosen eligibility policy. +- A process interruption between Git rename and SQLite completion is recoverable; stale session saves cannot undo the displayed branch metadata. +- The named worktree can still run setup, archive, retire and restore, including nested projects. Terminal/editor/provider paths remain unchanged. + +Validation performed for this investigation: traced both source flows, searched branch-generation callers and adapter coverage, inspected lifecycle invariants, and reproduced the current slug calculation for the user's message. No application code changed; no live model calls, branch mutations or test suites were run. This is source-level evidence, not a latency or model-quality benchmark. + +## Implementation notes + +The implementation combines branch generation with the existing title/work-item request and bounds acceptance of that request to 45 seconds, including queue/startup time. Unsupported providers keep a short fallback name. A unique request token registers one naming operation during native creation; the prepare API continues returning the stable path. Naming errors remain separate from the main turn. + +Suggestions are saved after setup settles. A failed setup retains its suggestion for a later successful retry. Native naming validates ownership, branch identity, setup, review/shared/archive state and known publication state, allocates a non-conflicting `monocode/` name, and journals the Git-to-SQLite transition. Startup and reopening reconcile an interrupted rename. Events refresh every window, and session saves cannot reintroduce the temporary branch. Monocode push/sync/PR and checkout actions freeze pending naming before proceeding. External Git operations are still outside Monocode's coordination. + +Code and executable coverage: [native naming](../src-tauri/src/worktree_naming.rs), [Git lifecycle tests](../src-tauri/src/worktree_naming_tests.rs), [metadata deadline/context tests](../src/lib/initialSessionMetadata.test.ts), [preparation tests](../src/lib/worktrees.test.ts), [metadata parsing tests](../src/lib/sessionTitle.test.ts). diff --git a/docs/worktrees.md b/docs/worktrees.md index 0c237b34..c2720766 100644 --- a/docs/worktrees.md +++ b/docs/worktrees.md @@ -2,12 +2,20 @@ Choose where a conversation works using the two controls above the message box: -- **New worktree · From main** — start an isolated task. Pick a different base if needed, then send your message. Monocode creates and names the branch and checkout before starting the agent. Selecting a base does not switch the source checkout. +- **New worktree · From main** — start an isolated task. Pick a different base if needed, then send your message. Monocode creates the branch and checkout and completes setup before starting the agent. Selecting a base does not switch the source checkout. - **Current checkout · main** — work directly in your project folder. - **Existing worktree** — select a checkout from the workspace menu to continue work on that branch in another conversation. New worktree is the default in this fork. Your explicit choice belongs to the draft and survives a restart; it does not change another conversation. Once the conversation starts, its checkout stays fixed. Its agent, files, changes and terminal dock use that directory. Conversations remain grouped under the original project. +New worktrees receive an AI-generated branch name based on your initial request, such as `monocode/add-history-search`. A short temporary name appears immediately; naming runs in the background alongside the conversation-title request and never delays the agent. Duplicate names receive a numeric suffix. If the provider cannot generate a name, the temporary name stays usable. A custom conversation title is preserved. + +Naming accepts structured JSON only, so provider quota or sign-in messages cannot become titles or branches. If naming fails or exceeds its 45-second deadline, a dismissible **AI naming unavailable** notice explaining that the default branch name was kept offers **Retry** when the provider supports naming. Retry uses the same provider and initial request; it does not create another worktree or automatically switch accounts/models. Repeated clicks share one attempt. The notice remains until dismissed or the retry completes; it is not restored after restarting the app. + +Retries remain subject to the original naming request's ownership and branch checks. A saved suggestion can be applied after setup succeeds. Native naming errors are shown separately from generation failures, and a branch that is no longer eligible is kept without reporting a successful rename. See the [T3 failure-handling comparison](worktree-naming-failures-research.md) for the source behavior behind this design. + +Naming keeps the checkout directory fixed. It only applies once to a newly created worktree, after successful setup; a setup retry can use an already saved suggestion. Existing worktrees and branches you switch or publish through Monocode are left as chosen. Observed external branch changes and upstream configuration also prevent a delayed automatic rename. + A new worktree starts with committed files. In **Settings → Worktrees**, choose a project and configure its environment once: - **Setup command** runs in the selected project's directory inside a newly created or restored worktree before the agent starts, for example `npm ci`. Setup failures keep the checkout and can be retried. Newly added copy paths are applied on retry without overwriting previously copied files that you edited. An existing checkout that has completed setup is not set up again on every message. diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index ca37d935..6c042d4c 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -426,10 +426,13 @@ pub async fn git_commit(cwd: String, message: String) -> Result<(), String> { /// Push the current branch to its upstream, or set upstream on first push. #[tauri::command] -pub async fn git_push(cwd: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || git_push_for(&expand_home(&cwd))) - .await - .map_err(|e| e.to_string())? +pub async fn git_push(app: tauri::AppHandle, cwd: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; + git_push_for(&expand_home(&cwd)) + }) + .await + .map_err(|e| e.to_string())? } /// Fast-forward the current branch from its upstream. @@ -444,10 +447,13 @@ pub async fn git_pull(cwd: String) -> Result<(), String> { /// Pull incoming commits, then push local commits. #[tauri::command] -pub async fn git_sync(cwd: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || git_sync_changes_for(&expand_home(&cwd))) - .await - .map_err(|e| e.to_string())? +pub async fn git_sync(app: tauri::AppHandle, cwd: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; + git_sync_changes_for(&expand_home(&cwd)) + }) + .await + .map_err(|e| e.to_string())? } #[derive(Serialize, Clone, Debug, PartialEq, Eq)] @@ -496,6 +502,7 @@ struct GitPrCreateInput { /// Create a GitHub pull request with `gh` and return its URL. #[tauri::command] pub async fn git_pr_create( + app: tauri::AppHandle, cwd: String, title: String, body: String, @@ -503,6 +510,7 @@ pub async fn git_pr_create( head: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; git_pr_create_for( &expand_home(&cwd), &GitPrCreateInput { @@ -786,11 +794,13 @@ pub async fn git_branches(cwd: String) -> Result { /// Switch to an existing local branch, or create a local tracking branch from a remote. #[tauri::command] pub async fn git_checkout( + app: tauri::AppHandle, cwd: String, name: String, remote: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; git_checkout_for(&expand_home(&cwd), &name, remote.as_deref()) }) .await @@ -799,10 +809,17 @@ pub async fn git_checkout( /// Create a branch from HEAD and switch to it. #[tauri::command] -pub async fn git_create_branch(cwd: String, name: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_create_branch_for(&expand_home(&cwd), &name)) - .await - .map_err(|e| e.to_string())? +pub async fn git_create_branch( + app: tauri::AppHandle, + cwd: String, + name: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; + git_create_branch_for(&expand_home(&cwd), &name) + }) + .await + .map_err(|e| e.to_string())? } /// Stash tracked and untracked local changes so a checkout can proceed. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06dc87eb..382c6932 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -268,6 +268,8 @@ pub fn run() { worktrees::worktree_storage_limit_set, worktrees::worktree_create, worktrees::worktree_prepare, + worktrees::naming::worktree_name, + worktrees::naming::worktree_name_status, worktrees::setup::worktree_setup, worktrees::worktree_heartbeat, worktrees::worktree_pin, diff --git a/src-tauri/src/session_store.rs b/src-tauri/src/session_store.rs index 3a141a0f..2389efe4 100644 --- a/src-tauri/src/session_store.rs +++ b/src-tauri/src/session_store.rs @@ -717,12 +717,36 @@ fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Resul let git = crate::fs::git_info_for(&crate::fs::expand_home( session.worktree_cwd.as_deref().unwrap_or(&session.cwd), )); - let branch = session + let supplied_branch = session .branch .as_deref() .map(str::trim) - .filter(|value| !value.is_empty()) - .or_else(|| git.branch.as_deref().filter(|value| !value.is_empty())); + .filter(|value| !value.is_empty()); + // A delayed save from another window must not reinstate the temporary + // branch after native naming. The journal also covers a retired checkout. + let named_branch: Option = conn + .query_row( + "SELECT managed.branch FROM managed_worktrees managed + JOIN worktree_naming naming ON naming.worktree_id = managed.id + WHERE naming.state = 'done' AND + (managed.id = ?1 OR COALESCE(?2, ?3) = managed.path OR + substr(COALESCE(?2, ?3), 1, length(managed.path) + 1) = managed.path || '/') + LIMIT 1", + params![session.id, session.worktree_cwd, session.cwd], + |row| row.get(0), + ) + .optional()?; + let observed_branch = git.branch.as_deref().filter(|value| !value.is_empty()); + let branch = if named_branch.is_some() { + // Before first checkout persistence, cwd can still be the primary repo. + if session.worktree_cwd.is_some() { + observed_branch.or(named_branch.as_deref()) + } else { + named_branch.as_deref() + } + } else { + supplied_branch.or(observed_branch) + }; let worktree_cwd = session .worktree_cwd .as_deref() @@ -1502,6 +1526,28 @@ mod tests { assert_eq!(second.provider_session_id.as_deref(), Some("acp-session-2")); } + #[test] + fn stale_session_save_cannot_restore_a_temporary_worktree_branch() { + let store = SessionStore::open_in_memory().unwrap(); + let conn = store.conn.lock().unwrap(); + let path = "/nonexistent/monocode-naming-test/worktree"; + conn.execute("INSERT INTO managed_worktrees (id, repo, common_dir, path, branch, base_ref, last_used) + VALUES ('owner', '/nonexistent/project', '/nonexistent/project/.git', ?1, 'monocode/semantic-name', 'main', 0)", [path]).unwrap(); + conn.execute("INSERT INTO worktree_naming(worktree_id, token, source_branch, target_branch, state) + VALUES ('owner', 'request-token', 'monocode/task-abcd1234', 'monocode/semantic-name', 'done')", []).unwrap(); + for (id, cwd) in [ + ("owner", path.to_string()), + ("nested", format!("{path}/apps/web")), + ] { + let mut session = sample(id, "/nonexistent/project", "Custom title"); + session.worktree_cwd = Some(cwd); + session.branch = Some("monocode/task-abcd1234".into()); + let saved = upsert_session(&conn, &session).unwrap(); + assert_eq!(saved.branch.as_deref(), Some("monocode/semantic-name")); + assert_eq!(saved.title, "Custom title"); + } + } + #[test] fn context_usage_round_trips() { let store = SessionStore::open_in_memory().unwrap(); diff --git a/src-tauri/src/worktree_naming.rs b/src-tauri/src/worktree_naming.rs new file mode 100644 index 00000000..86383fe6 --- /dev/null +++ b/src-tauri/src/worktree_naming.rs @@ -0,0 +1,462 @@ +//! One-time semantic branch naming. The checkout path never changes. A durable +//! intent bridges Git's rename and SQLite's ownership update after interruption. +use super::*; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeNamed { + pub id: String, + pub session_ids: Vec, + pub path: String, + pub branch: String, +} + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum WorktreeNameStatus { + Waiting, + Pending, + Named, + Skipped, +} + +fn status(conn: &Connection, id: &str, token: &str) -> Result { + let state: Option = conn + .query_row( + "SELECT n.state FROM worktree_naming n + JOIN managed_worktrees m ON m.id = n.worktree_id + WHERE n.worktree_id = ?1 AND n.token = ?2 AND m.removed = 0", + params![id, token], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + Ok(match state.as_deref() { + Some("waiting") => WorktreeNameStatus::Waiting, + Some("ready" | "renaming") => WorktreeNameStatus::Pending, + Some("done") => WorktreeNameStatus::Named, + _ => WorktreeNameStatus::Skipped, + }) +} + +/// A failed generation leaves the original request waiting. Explicit retries +/// can inspect it, but never reopen a request frozen by a user Git operation. +#[tauri::command(async)] +pub fn worktree_name_status( + store: State<'_, SessionStore>, + session_id: String, + token: String, +) -> Result { + status(&store.open_auxiliary_conn()?, &session_id, &token) +} + +pub(super) fn schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS worktree_naming ( + worktree_id TEXT PRIMARY KEY REFERENCES managed_worktrees(id) ON DELETE CASCADE, + token TEXT NOT NULL, source_branch TEXT NOT NULL, + state TEXT NOT NULL, target_branch TEXT, rename_oid TEXT + );", + ) +} + +pub(super) fn register(conn: &Connection, entry: &Owned, token: &str) -> Result<(), String> { + validate_id(token)?; + conn.execute( + "INSERT INTO worktree_naming (worktree_id, token, source_branch, state) + VALUES (?1, ?2, ?3, 'waiting')", + params![entry.id, token, entry.branch], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +pub(super) fn available_branch(repo: &Path, desired: &str) -> Result { + git(repo, &["check-ref-format", "--branch", desired])?; + for suffix in 0..=100 { + let name = if suffix == 0 { + desired.to_string() + } else { + format!("{desired}-{suffix}") + }; + if ref_oid(repo, &format!("refs/heads/{name}"))?.is_none() { + return Ok(name); + } + } + Err("Could not allocate an unused worktree branch name".into()) +} + +fn normalize(raw: &str) -> Option { + let lower = raw.trim().to_ascii_lowercase(); + let raw = lower.strip_prefix("refs/heads/").unwrap_or(&lower); + let raw = raw.strip_prefix("monocode/").unwrap_or(raw); + let fragment = raw + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect::>() + .join("-"); + let fragment = fragment.chars().take(64).collect::(); + let fragment = fragment.trim_end_matches('-'); + (!fragment.is_empty()).then(|| format!("monocode/{fragment}")) +} + +#[derive(Debug)] +struct Naming { + source: String, + target: Option, + oid: Option, + state: String, +} + +fn record(conn: &Connection, id: &str) -> Result, String> { + conn.query_row( + "SELECT source_branch, target_branch, rename_oid, state FROM worktree_naming + WHERE worktree_id = ?1", + [id], + |row| { + Ok(Naming { + source: row.get(0)?, + target: row.get(1)?, + oid: row.get(2)?, + state: row.get(3)?, + }) + }, + ) + .optional() + .map_err(|e| e.to_string()) +} + +fn skip(conn: &Connection, id: &str) -> Result<(), String> { + conn.execute( + "UPDATE worktree_naming SET state = 'skipped' WHERE worktree_id = ?1", + [id], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn complete(conn: &Connection, entry: &Owned, naming: &Naming) -> Result { + let target = naming + .target + .as_deref() + .ok_or("Missing worktree rename target")?; + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + let changed = tx + .execute( + "UPDATE managed_worktrees SET branch = ?1 + WHERE id = ?2 AND branch = ?3 AND removed = 0", + params![target, entry.id, naming.source], + ) + .map_err(|e| e.to_string())?; + if changed != 1 { + return Err("Worktree ownership changed during naming".into()); + } + let session_ids = { + let mut statement = tx + .prepare( + "SELECT id FROM sessions WHERE id = ?1 OR COALESCE(worktree_cwd, cwd) = ?2 OR + substr(COALESCE(worktree_cwd, cwd), 1, length(?2) + 1) = ?2 || '/'", + ) + .map_err(|e| e.to_string())?; + let rows = statement + .query_map(params![entry.id, entry.path], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + rows.collect::, _>>() + .map_err(|e| e.to_string())? + }; + tx.execute( + "UPDATE sessions SET branch = ?1 WHERE id = ?2 OR + COALESCE(worktree_cwd, cwd) = ?3 OR + substr(COALESCE(worktree_cwd, cwd), 1, length(?3) + 1) = ?3 || '/'", + params![target, entry.id, entry.path], + ) + .map_err(|e| e.to_string())?; + tx.execute( + "UPDATE worktree_naming SET state = 'done' WHERE worktree_id = ?1", + [&entry.id], + ) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(WorktreeNamed { + id: entry.id.clone(), + session_ids, + path: entry.path.clone(), + branch: target.into(), + }) +} + +/// Only recognize the exact journaled transition. Never adopt arbitrary branch +/// drift as an owned rename. The branch may have gained commits after Git ran. +fn reconcile( + conn: &Connection, + entry: &Owned, + naming: &Naming, +) -> Result, String> { + let target = naming + .target + .as_deref() + .ok_or("Missing worktree rename target")?; + let oid = naming + .oid + .as_deref() + .ok_or("Missing worktree rename commit")?; + if entry.removed || entry.branch != naming.source { + return Err("Worktree ownership changed during interrupted naming".into()); + } + let mut renamed = entry.clone(); + renamed.branch = target.into(); + let source_oid = ref_oid( + Path::new(&entry.repo), + &format!("refs/heads/{}", naming.source), + )?; + if source_oid.is_none() && setup::validate_checkout(&renamed, &entry.path).is_ok() { + let head = resolve_commit(Path::new(&entry.path), "HEAD")?; + if is_ancestor(Path::new(&entry.repo), oid, &head)? { + return complete(conn, entry, naming).map(Some); + } + } + if source_oid.is_some() && setup::validate_checkout(entry, &entry.path).is_ok() { + // Git did not complete the rename. Keep the original branch; never + // replay a delayed mutation after a restart or a user operation. + skip(conn, &entry.id)?; + return Ok(None); + } + Err("Interrupted worktree naming could not be verified; checkout was preserved".into()) +} + +/// Caller holds this repository's reservation and the short lifecycle lock. +pub(super) fn apply_pending(conn: &Connection, id: &str) -> Result, String> { + let Some(naming) = record(conn, id)? else { + return Ok(None); + }; + if !matches!(naming.state.as_str(), "ready" | "renaming") { + return Ok(None); + } + let Some(entry) = owned(conn)?.into_iter().find(|entry| entry.id == id) else { + return Ok(None); + }; + if naming.state == "renaming" { + return reconcile(conn, &entry, &naming); + } + let has_review: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM worktree_retirement_items WHERE worktree_id = ?1)", + [id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + let unavailable_session: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sessions WHERE (id = ?1 AND archived = 1) OR + (id != ?1 AND (COALESCE(worktree_cwd, cwd) = ?2 OR + substr(COALESCE(worktree_cwd, cwd), 1, length(?2) + 1) = ?2 || '/')))", + params![id, entry.path], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if entry.removed + || entry.creation_oid.is_some() + || entry.branch != naming.source + || entry.pending_retirement_plan_id.is_some() + || entry.active_retirement_plan_id.is_some() + || has_review + || unavailable_session + || setup::validate_checkout(&entry, &entry.path).is_err() + { + skip(conn, id)?; + return Ok(None); + } + let ready: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM worktree_environment_setup + WHERE worktree_id = ?1 AND status = 'ready')", + [id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if !ready { + return Ok(None); + } + if checkouts(Path::new(&entry.repo))? + .iter() + .any(|checkout| checkout.path == entry.path && checkout.locked) + { + skip(conn, id)?; + return Ok(None); + } + let repo = Path::new(&entry.repo); + let upstream = git( + repo, + &[ + "for-each-ref", + "--format=%(upstream)", + &format!("refs/heads/{}", entry.branch), + ], + )?; + let remotes = git( + repo, + &["for-each-ref", "--format=%(refname)", "refs/remotes/"], + )?; + if !upstream.trim().is_empty() + || remotes + .lines() + .any(|line| line.ends_with(&format!("/{}", entry.branch))) + { + skip(conn, id)?; + return Ok(None); + } + let desired = naming + .target + .as_deref() + .ok_or("Missing worktree name suggestion")?; + let target = available_branch(repo, desired)?; + let oid = resolve_commit(Path::new(&entry.path), "HEAD")?; + conn.execute( + "UPDATE worktree_naming SET state = 'renaming', target_branch = ?1, rename_oid = ?2 + WHERE worktree_id = ?3 AND state = 'ready'", + params![target, oid, id], + ) + .map_err(|e| e.to_string())?; + // Never force a rename: another process may have claimed the target since + // allocation. The explicit source avoids renaming a newly selected branch. + if let Err(error) = git( + Path::new(&entry.path), + &["branch", "-m", "--", &naming.source, &target], + ) { + // Reconcile even when Git reports failure: it may have partially run. + let journal = record(conn, id)?.ok_or("Missing worktree naming journal")?; + return match reconcile(conn, &entry, &journal) { + Ok(Some(changed)) => Ok(Some(changed)), + _ => Err(error), + }; + } + let journal = record(conn, id)?.ok_or("Missing worktree naming journal")?; + reconcile(conn, &entry, &journal) +} + +fn suggest( + conn: &Connection, + id: &str, + token: &str, + branch: Option<&str>, +) -> Result, String> { + let target = branch.and_then(normalize); + let changed = conn + .execute( + "UPDATE worktree_naming SET target_branch = ?1, state = ?2 + WHERE worktree_id = ?3 AND token = ?4 AND state = 'waiting'", + params![ + target, + if target.is_some() { "ready" } else { "skipped" }, + id, + token + ], + ) + .map_err(|e| e.to_string())?; + if changed == 0 { + if status(conn, id, token)? == WorktreeNameStatus::Pending { + return apply_pending(conn, id); + } + return Ok(None); + } + apply_pending(conn, id) +} + +pub(super) fn emit(app: &AppHandle, result: Result, String>) { + match result { + Ok(Some(event)) => { + let _ = app.emit("worktree-named", event); + } + Ok(None) => {} + Err(error) => eprintln!("[monocode] worktree naming: {error}"), + } +} + +#[tauri::command(async)] +pub fn worktree_name( + app: AppHandle, + store: State<'_, SessionStore>, + host: State<'_, WorktreeHost>, + session_id: String, + token: String, + branch: Option, +) -> Result { + let conn = store.open_auxiliary_conn()?; + let Some(entry) = owned(&conn)? + .into_iter() + .find(|entry| entry.id == session_id) + else { + return Ok(WorktreeNameStatus::Skipped); + }; + let _repository = host.repository_guard(&entry.common)?; + let _windows = host.operation_guard()?; + let result = suggest(&conn, &session_id, &token, branch.as_deref())?; + emit(&app, Ok(result)); + status(&conn, &session_id, &token) +} + +pub(super) fn reconcile_repository( + conn: &Connection, + common: &str, +) -> Result, String> { + let mut changes = Vec::new(); + for entry in owned(conn)? + .into_iter() + .filter(|entry| entry.common == common) + { + if let Some(naming) = record(conn, &entry.id)? { + if naming.state == "renaming" { + if let Some(changed) = reconcile(conn, &entry, &naming)? { + changes.push(changed); + } + } + } + } + Ok(changes) +} + +pub(super) fn recover(app: &AppHandle, conn: &Connection) -> Result<(), String> { + for entry in owned(conn)? { + // At startup no other app operation is running. Only finish journaled + // Git mutations, never resume model requests or start fresh renames. + if let Some(naming) = record(conn, &entry.id)? { + if naming.state == "renaming" { + emit(app, reconcile(conn, &entry, &naming)); + } + } + } + Ok(()) +} + +/// Resolve a possible interrupted rename, then freeze naming before publishing +/// or an explicit checkout change. Release locks before any network operation. +pub(crate) fn stabilize_branch(app: &AppHandle, cwd: &str) -> Result<(), String> { + let conn = app.state::().open_auxiliary_conn()?; + let Some(entry) = owned(&conn)? + .into_iter() + .find(|entry| path_inside(&expand_home(cwd), Path::new(&entry.path))) + else { + return Ok(()); + }; + let host = app.state::(); + let _repository = host.repository_guard(&entry.common)?; + let _windows = host.operation_guard()?; + emit(app, Ok(freeze_pending(&conn, &entry)?)); + Ok(()) +} + +fn freeze_pending(conn: &Connection, entry: &Owned) -> Result, String> { + let mut changed = None; + if let Some(naming) = record(conn, &entry.id)? { + if naming.state == "renaming" { + changed = reconcile(conn, entry, &naming)?; + } + conn.execute("UPDATE worktree_naming SET state = 'skipped' WHERE worktree_id = ?1 AND state IN ('waiting', 'ready')", [&entry.id]) + .map_err(|e| e.to_string())?; + } + Ok(changed) +} + +#[cfg(test)] +#[path = "worktree_naming_tests.rs"] +mod tests; diff --git a/src-tauri/src/worktree_naming_tests.rs b/src-tauri/src/worktree_naming_tests.rs new file mode 100644 index 00000000..b5c11889 --- /dev/null +++ b/src-tauri/src/worktree_naming_tests.rs @@ -0,0 +1,457 @@ +use super::*; +use crate::worktrees::tests::Fixture; + +const TOKEN: &str = "naming-request-one"; + +#[test] +fn failed_generation_can_retry_but_never_reopens_a_frozen_or_completed_request() { + let f = Fixture::new(); + let entry = draft(&f, "retry-session"); + setup_ready(&f, &entry); + // Generation failure performs no Git mutation and leaves this request + // waiting. A status read neither consumes nor renews its original token. + assert_eq!( + status(&f.conn, &entry.id, TOKEN).unwrap(), + WorktreeNameStatus::Waiting + ); + assert_eq!( + status(&f.conn, &entry.id, "stale-token").unwrap(), + WorktreeNameStatus::Skipped + ); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + let named = suggest(&f.conn, &entry.id, TOKEN, Some("retry-naming")) + .unwrap() + .unwrap(); + assert_eq!(named.branch, "monocode/retry-naming"); + assert_eq!( + status(&f.conn, &entry.id, TOKEN).unwrap(), + WorktreeNameStatus::Named + ); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("rename-again")) + .unwrap() + .is_none()); + + let frozen = draft(&f, "frozen-retry-session"); + setup_ready(&f, &frozen); + freeze_pending(&f.conn, &frozen).unwrap(); + assert_eq!( + status(&f.conn, &frozen.id, TOKEN).unwrap(), + WorktreeNameStatus::Skipped + ); + assert!(suggest(&f.conn, &frozen.id, TOKEN, Some("late-retry")) + .unwrap() + .is_none()); + assert_eq!(current(&f, &frozen.id).branch, frozen.branch); +} + +#[test] +fn explicit_retry_reapplies_only_the_original_saved_suggestion() { + let f = Fixture::new(); + let entry = draft(&f, "saved-retry-session"); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("original-name")) + .unwrap() + .is_none()); + assert_eq!( + status(&f.conn, &entry.id, TOKEN).unwrap(), + WorktreeNameStatus::Pending + ); + setup_ready(&f, &entry); + assert!( + suggest(&f.conn, &entry.id, "wrong-token", Some("wrong-name")) + .unwrap() + .is_none() + ); + let named = suggest(&f.conn, &entry.id, TOKEN, Some("replacement-name")) + .unwrap() + .unwrap(); + assert_eq!(named.branch, "monocode/original-name"); +} + +#[test] +fn naming_freezes_before_publish_or_manual_checkout_without_waiting_for_ai() { + for suggestion_ready in [false, true] { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + if suggestion_ready { + suggest(&f.conn, &entry.id, TOKEN, Some("pending-name")).unwrap(); + } + assert!(freeze_pending(&f.conn, &entry).unwrap().is_none()); + setup_ready(&f, &entry); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("late-name")) + .unwrap() + .is_none()); + assert!(apply_pending(&f.conn, &entry.id).unwrap().is_none()); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + } +} + +#[test] +fn naming_serializes_two_windows_competing_for_the_same_branch() { + let f = Fixture::new(); + let one = draft(&f, "session-one"); + let two = draft(&f, "session-two"); + setup_ready(&f, &one); + setup_ready(&f, &two); + let barrier = std::sync::Barrier::new(2); + let host = &f.host; + let db = &f.db; + let names = std::thread::scope(|scope| { + let run = |entry: &Owned| { + let conn = Connection::open(db).unwrap(); + barrier.wait(); + let _repository = host.repository_guard(&entry.common).unwrap(); + let _windows = host.operation_guard().unwrap(); + suggest(&conn, &entry.id, TOKEN, Some("same-task")) + .unwrap() + .unwrap() + .branch + }; + let first = scope.spawn(move || run(&one)); + let second = scope.spawn(move || run(&two)); + vec![first.join().unwrap(), second.join().unwrap()] + }); + assert_ne!(names[0], names[1]); + assert!(names.contains(&"monocode/same-task".to_string())); + assert!(names.contains(&"monocode/same-task-1".to_string())); +} + +fn draft(f: &Fixture, id: &str) -> Owned { + create_with_naming( + &f.conn, + &f.host, + &path_to_js(&f.repo), + id, + "Raw verbose request", + Some("main"), + Some(TOKEN), + ) + .unwrap() +} + +fn setup_ready(f: &Fixture, entry: &Owned) { + if let environment::BeginSetup::Run(operation) = + environment::begin_setup(&f.conn, &entry.path).unwrap() + { + let result = environment::run_setup(&operation, |_| {}); + environment::finish_setup(&f.conn, &operation, &result).unwrap(); + result.unwrap(); + } +} + +fn current(f: &Fixture, id: &str) -> Owned { + owned(&f.conn) + .unwrap() + .into_iter() + .find(|entry| entry.id == id) + .unwrap() +} + +#[test] +fn naming_preserves_checkout_and_full_retirement_recovery() { + let f = Fixture::new(); + let original = draft(&f, "aabbccdd-1234-5678-long-session-identity"); + assert_eq!(original.branch, "monocode/task-aabbccdd"); + setup_ready(&f, &original); + let head = resolve_commit(Path::new(&original.path), "HEAD").unwrap(); + let named = suggest(&f.conn, &original.id, TOKEN, Some("AI worktree naming")) + .unwrap() + .unwrap(); + assert_eq!(named.branch, "monocode/ai-worktree-naming"); + let entry = current(&f, &original.id); + assert_eq!(entry.path, original.path); + assert_eq!(entry.base_ref, original.base_ref); + assert_eq!( + resolve_commit(Path::new(&entry.path), "HEAD").unwrap(), + head + ); + assert!(ref_oid(&f.repo, &format!("refs/heads/{}", original.branch)) + .unwrap() + .is_none()); + setup::validate_checkout(&entry, &entry.path).unwrap(); + open_owned(&f.conn, &entry).unwrap(); + let plan = build_retirement_plan( + &f.conn, + &HashMap::new(), + &[], + Some(&path_to_js(&f.repo)), + std::slice::from_ref(&entry.id), + ) + .unwrap(); + assert_eq!(plan.entries.len(), 1); + let report = execute_retirement( + &f.conn, + &HashMap::new(), + &plan.plan_id, + &[WorktreeRetirementSelection { + id: entry.id.clone(), + delete_local_branch: false, + delete_remote_branch: false, + }], + ) + .unwrap(); + assert!( + report.results[0].worktree_removed, + "{:?}", + report.results[0].error + ); + let removed = current(&f, &entry.id); + open_owned(&f.conn, &removed).unwrap(); + let restored = current(&f, &entry.id); + assert_eq!(restored.branch, named.branch); + assert_eq!( + resolve_commit(Path::new(&restored.path), "HEAD").unwrap(), + head + ); +} + +#[test] +fn naming_survives_failed_setup_and_consumes_the_first_suggestion_once() { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + f.conn + .execute( + "UPDATE worktree_environment_setup SET status = 'failed' WHERE worktree_id = ?1", + [&entry.id], + ) + .unwrap(); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("Fix startup")) + .unwrap() + .is_none()); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + // Reopen the database to prove this is durable, not an in-memory callback. + let conn = Connection::open(&f.db).unwrap(); + assert_eq!(record(&conn, &entry.id).unwrap().unwrap().state, "ready"); + assert!(suggest(&conn, &entry.id, TOKEN, Some("Different followup")) + .unwrap() + .is_none()); + setup_ready(&f, &entry); + assert_eq!( + apply_pending(&conn, &entry.id).unwrap().unwrap().branch, + "monocode/fix-startup" + ); + assert!(apply_pending(&conn, &entry.id).unwrap().is_none()); +} + +#[test] +fn naming_allocates_collisions_without_claiming_existing_refs() { + let f = Fixture::new(); + let first = draft(&f, "session-one"); + let second = draft(&f, "session-two"); + assert_ne!(first.branch, second.branch); // identical first eight ID characters + setup_ready(&f, &first); + setup_ready(&f, &second); + git(&f.repo, &["branch", "monocode/add-search"]).unwrap(); + let one = suggest(&f.conn, &first.id, TOKEN, Some("add-search")) + .unwrap() + .unwrap(); + let two = suggest(&f.conn, &second.id, TOKEN, Some("add-search")) + .unwrap() + .unwrap(); + assert_eq!(one.branch, "monocode/add-search-1"); + assert_eq!(two.branch, "monocode/add-search-2"); + assert!(ref_oid(&f.repo, "refs/heads/monocode/add-search") + .unwrap() + .is_some()); +} + +#[test] +fn naming_rejects_unowned_reused_and_stale_requests() { + let f = Fixture::new(); + let manual = create( + &f.conn, + &f.host, + &path_to_js(&f.repo), + "manual-session", + "Chosen name", + Some("main"), + ) + .unwrap(); + assert!(suggest(&f.conn, &manual.id, TOKEN, Some("ignored")) + .unwrap() + .is_none()); + let entry = draft(&f, "session-one"); + setup_ready(&f, &entry); + assert!( + suggest(&f.conn, &entry.id, "another-token", Some("ignored")) + .unwrap() + .is_none() + ); + git(Path::new(&entry.path), &["branch", "-m", "user-chosen"]).unwrap(); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("ignored")) + .unwrap() + .is_none()); + assert_eq!( + git(Path::new(&entry.path), &["branch", "--show-current"]).unwrap(), + "user-chosen" + ); + assert_eq!( + record(&f.conn, &entry.id).unwrap().unwrap().state, + "skipped" + ); +} + +#[test] +fn naming_preserves_published_shared_archived_and_locked_worktrees() { + for reason in ["published", "shared", "archived", "locked", "retired"] { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + setup_ready(&f, &entry); + match reason { + "published" => { + git( + &f.repo, + &[ + "config", + &format!("branch.{}.remote", entry.branch), + "origin", + ], + ) + .unwrap(); + git( + &f.repo, + &[ + "config", + &format!("branch.{}.merge", entry.branch), + &format!("refs/heads/{}", entry.branch), + ], + ) + .unwrap(); + git( + &f.repo, + &[ + "remote", + "add", + "origin", + "https://example.invalid/repo.git", + ], + ) + .unwrap(); + } + "shared" => { + f.conn.execute("INSERT INTO sessions(id, cwd, worktree_cwd) VALUES ('another-session', ?1, ?2)", params![path_to_js(&f.repo), format!("{}/nested", entry.path)]).unwrap(); + } + "archived" => { + f.conn.execute("INSERT INTO sessions(id, cwd, worktree_cwd, archived) VALUES (?1, ?2, ?3, 1)", params![entry.id, path_to_js(&f.repo), entry.path]).unwrap(); + } + "locked" => { + git(&f.repo, &["worktree", "lock", &entry.path]).unwrap(); + } + "retired" => { + f.conn + .execute( + "UPDATE managed_worktrees SET removed = 1 WHERE id = ?1", + [&entry.id], + ) + .unwrap(); + } + _ => unreachable!(), + } + assert!( + suggest(&f.conn, &entry.id, TOKEN, Some("ignored")) + .unwrap() + .is_none(), + "{reason}" + ); + assert_eq!( + record(&f.conn, &entry.id).unwrap().unwrap().state, + "skipped", + "{reason}" + ); + assert_eq!( + git(Path::new(&entry.path), &["branch", "--show-current"]).unwrap(), + entry.branch + ); + } +} + +#[test] +fn naming_reconciles_git_success_after_database_failure_including_nested_sessions() { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + setup_ready(&f, &entry); + f.conn + .execute( + "INSERT INTO sessions(id, cwd, worktree_cwd, branch) VALUES (?1, ?2, ?3, ?4)", + params![entry.id, path_to_js(&f.repo), entry.path, entry.branch], + ) + .unwrap(); + // Fail the metadata transaction after the Git operation, as a disk error would. + f.conn.execute_batch("CREATE TRIGGER fail_name BEFORE UPDATE OF branch ON managed_worktrees BEGIN SELECT RAISE(FAIL, 'simulated storage failure'); END;").unwrap(); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("recover-name")).is_err()); + assert_eq!( + record(&f.conn, &entry.id).unwrap().unwrap().state, + "renaming" + ); + assert_eq!( + git(Path::new(&entry.path), &["branch", "--show-current"]).unwrap(), + "monocode/recover-name" + ); + f.conn.execute_batch("DROP TRIGGER fail_name;").unwrap(); + f.conn.execute("INSERT INTO sessions(id, cwd, worktree_cwd, branch) VALUES ('nested-session', ?1, ?2, ?3)", params![path_to_js(&f.repo), format!("{}/nested", entry.path), entry.branch]).unwrap(); + git( + Path::new(&entry.path), + &["commit", "--allow-empty", "-m", "Agent continued"], + ) + .unwrap(); + let reopened = Connection::open(&f.db).unwrap(); + let named = apply_pending(&reopened, &entry.id).unwrap().unwrap(); + assert!(named.session_ids.contains(&"nested-session".to_string())); + let branches: i64 = reopened + .query_row( + "SELECT COUNT(*) FROM sessions WHERE branch = 'monocode/recover-name'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(branches, 2); + assert!(apply_pending(&reopened, &entry.id).unwrap().is_none()); + setup::validate_checkout(¤t(&f, &entry.id), &entry.path).unwrap(); +} + +#[test] +fn naming_does_not_replay_an_interrupted_intent_or_adopt_an_unrelated_checkout() { + for renamed_elsewhere in [false, true] { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + setup_ready(&f, &entry); + let oid = resolve_commit(Path::new(&entry.path), "HEAD").unwrap(); + f.conn.execute("UPDATE worktree_naming SET state = 'renaming', target_branch = 'monocode/proposed', rename_oid = ?1 WHERE worktree_id = ?2", params![oid, entry.id]).unwrap(); + if renamed_elsewhere { + git(Path::new(&entry.path), &["branch", "-m", "user-branch"]).unwrap(); + assert!(apply_pending(&f.conn, &entry.id).is_err()); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + } else { + assert!(apply_pending(&f.conn, &entry.id).unwrap().is_none()); + assert_eq!( + record(&f.conn, &entry.id).unwrap().unwrap().state, + "skipped" + ); + } + assert!(ref_oid(&f.repo, "refs/heads/monocode/proposed") + .unwrap() + .is_none()); + } +} + +#[test] +fn naming_failure_keeps_fallback_and_normalization_is_git_safe() { + let f = Fixture::new(); + for (i, suggestion) in [None, Some("... / "), Some("")].into_iter().enumerate() { + let entry = draft(&f, &format!("session-{i}")); + setup_ready(&f, &entry); + assert!(suggest(&f.conn, &entry.id, TOKEN, suggestion) + .unwrap() + .is_none()); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("late-result")) + .unwrap() + .is_none()); + } + assert_eq!( + normalize("refs/heads/monocode/Fix `Search`... @ UI"), + Some("monocode/fix-search-ui".into()) + ); + let long = normalize(&"very-long-name-".repeat(20)).unwrap(); + assert!(long.len() <= "monocode/".len() + 64); + git(&f.repo, &["check-ref-format", "--branch", &long]).unwrap(); +} diff --git a/src-tauri/src/worktree_setup.rs b/src-tauri/src/worktree_setup.rs index 66614267..63b486dc 100644 --- a/src-tauri/src/worktree_setup.rs +++ b/src-tauri/src/worktree_setup.rs @@ -112,6 +112,9 @@ pub fn worktree_setup( let (operation, _lease) = { let _repository = host.repository_guard(&common)?; let mut windows = host.operation_guard()?; + for changed in super::naming::reconcile_repository(&conn, &common)? { + super::naming::emit(&app, Ok(Some(changed))); + } let Some(entry) = owned(&conn)?.into_iter().find(|entry| { entry.id == candidate.id && path_inside(Path::new(&path), Path::new(&entry.path)) }) else { @@ -125,7 +128,10 @@ pub fn worktree_setup( ); } let operation = match environment::begin_setup(&conn, &path)? { - environment::BeginSetup::Skip => return Ok(()), + environment::BeginSetup::Skip => { + super::naming::emit(&app, super::naming::apply_pending(&conn, &entry.id)); + return Ok(()); + } environment::BeginSetup::Run(operation) => operation, }; active.insert(operation.root_path().to_string()); @@ -154,9 +160,12 @@ pub fn worktree_setup( let _windows = host.operation_guard().map_err(|error| { format!("Setup state could not be saved. Restart Monocode before retrying: {error}") })?; - environment::finish_setup(&conn, &operation, &result).map_err(|error| { - format!("Setup state could not be saved. Restart Monocode before retrying: {error}") - })? + let completion = + environment::finish_setup(&conn, &operation, &result).map_err(|error| { + format!("Setup state could not be saved. Restart Monocode before retrying: {error}") + })?; + super::naming::emit(&app, super::naming::apply_pending(&conn, &candidate.id)); + completion }; let result = match (result, completion) { (Ok(()), environment::FinishSetup::Retry(error)) => Err(error), diff --git a/src-tauri/src/worktrees.rs b/src-tauri/src/worktrees.rs index f77c630a..2e9e8055 100644 --- a/src-tauri/src/worktrees.rs +++ b/src-tauri/src/worktrees.rs @@ -23,6 +23,8 @@ mod environment; #[cfg(all(test, unix))] #[path = "worktree_environment_integration_tests.rs"] mod environment_integration_tests; +#[path = "worktree_naming.rs"] +pub(crate) mod naming; #[path = "worktree_setup.rs"] pub(crate) mod setup; #[path = "worktree_storage.rs"] @@ -457,6 +459,7 @@ pub(crate) fn schema(conn: &Connection) -> rusqlite::Result<()> { WHERE removed = 1 AND active_retirement_plan_id IS NULL", [], )?; + naming::schema(conn)?; Ok(()) } @@ -493,6 +496,7 @@ pub fn init(app: &AppHandle) -> Result<(), String> { repositories: RepositoryReservations::default(), }); environment::reset_interrupted(&app.state::().open_auxiliary_conn()?)?; + naming::recover(app, &app.state::().open_auxiliary_conn()?)?; storage_maintenance::schedule(app); // Cleanup is only invoked with the IDs the user reviewed and confirmed. Ok(()) @@ -3429,8 +3433,23 @@ fn create( id: &str, name: &str, base_ref: Option<&str>, +) -> Result { + create_with_naming(conn, host, cwd, id, name, base_ref, None) +} + +fn create_with_naming( + conn: &Connection, + host: &WorktreeHost, + cwd: &str, + id: &str, + name: &str, + base_ref: Option<&str>, + auto_name_token: Option<&str>, ) -> Result { validate_id(id)?; + if let Some(token) = auto_name_token { + validate_id(token)?; + } let (repo, common) = repository(cwd)?; if owned(conn)?.iter().any(|v| v.id == id) { return Err("This session already owns a worktree".into()); @@ -3449,7 +3468,11 @@ fn create( } else { base_ref }; - let branch = format!("monocode/{}-{}", slug(name), id); + let branch = if auto_name_token.is_some() { + naming::available_branch(Path::new(&repo), &format!("monocode/task-{}", &id[..8]))? + } else { + format!("monocode/{}-{}", slug(name), id) + }; git(Path::new(&repo), &["check-ref-format", "--branch", &branch])?; let hash = common.bytes().fold(0xcbf29ce484222325u64, |hash, byte| { (hash ^ byte as u64).wrapping_mul(0x100000001b3) @@ -3497,6 +3520,9 @@ fn create( Some(&project_scope), None, )?; + if let Some(token) = auto_name_token { + naming::register(&tx, &entry, token)?; + } tx.commit().map_err(|e| e.to_string())?; if let Err(error) = git( Path::new(&repo), @@ -3901,6 +3927,8 @@ pub struct PrepareWorktree { use_worktree: Option, #[serde(default)] base_ref: Option, + #[serde(default)] + auto_name_token: Option, } #[tauri::command(async)] @@ -3913,7 +3941,11 @@ pub fn worktree_prepare( let common = repository_common(&request.cwd)?; let _repository = host.repository_guard(&common)?; let mut windows = host.operation_guard()?; - let work_path = prepare(&store.open_auxiliary_conn()?, &host, request)?; + let conn = store.open_auxiliary_conn()?; + for changed in naming::reconcile_repository(&conn, &common)? { + naming::emit(window.app_handle(), Ok(Some(changed))); + } + let work_path = prepare(&conn, &host, request)?; if let Some(path) = &work_path { let leases = windows.entry(window.label().into()).or_default(); let path = PathBuf::from(path); @@ -3936,6 +3968,7 @@ fn prepare( create_new, use_worktree, base_ref, + auto_name_token, } = request; let records = owned(conn)?; let entry = records.iter().find(|v| { @@ -3986,7 +4019,16 @@ fn prepare( } Some(scoped_path( &cwd, - &create(conn, host, &cwd, &session_id, &name, base_ref.as_deref())?.path, + &create_with_naming( + conn, + host, + &cwd, + &session_id, + &name, + base_ref.as_deref(), + auto_name_token.as_deref(), + )? + .path, )?) } else { None @@ -4135,16 +4177,16 @@ mod tests { use std::sync::atomic::{AtomicU64, Ordering}; static SEQUENCE: AtomicU64 = AtomicU64::new(0); - struct Fixture { - dir: PathBuf, - repo: PathBuf, - db: PathBuf, - conn: Connection, - host: WorktreeHost, + pub(super) struct Fixture { + pub(super) dir: PathBuf, + pub(super) repo: PathBuf, + pub(super) db: PathBuf, + pub(super) conn: Connection, + pub(super) host: WorktreeHost, } impl Fixture { - fn new() -> Self { + pub(super) fn new() -> Self { let dir = std::env::temp_dir().join(format!( "monocode-worktree-test-{}-{}-{}", std::process::id(), @@ -4627,6 +4669,7 @@ mod tests { fn preparation_is_idempotent_and_respects_repository_defaults() { let fixture = Fixture::new(); let request = || PrepareWorktree { + auto_name_token: None, cwd: path_to_js(&fixture.repo), session_id: "session-one".into(), path: None, @@ -4711,6 +4754,7 @@ mod tests { ) .unwrap(); let request = || PrepareWorktree { + auto_name_token: None, cwd: cwd.clone(), session_id: "draft-one".into(), path: None, @@ -4756,6 +4800,7 @@ mod tests { git(&fixture.repo, &["add", "."]).unwrap(); git(&fixture.repo, &["commit", "-m", "Subproject"]).unwrap(); let request = || PrepareWorktree { + auto_name_token: None, cwd: path_to_js(&nested), session_id: "session-one".into(), path: None, diff --git a/src/App.tsx b/src/App.tsx index 6ac7a9fb..b4306c5d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,14 @@ import { heartbeatWorktrees, prepareSessionWorktree, protectedWorktreePaths, + shouldIsolateSession, type WorktreeRetirementPlan, } from "./lib/worktrees"; +import { + initialMessageContext, + initialSessionMetadata, +} from "./lib/initialSessionMetadata"; +import { getHarness } from "./lib/harness/registry"; import { archiveSessionsWithRetirement, resumeArchivedWorktreeSession, @@ -153,7 +159,6 @@ import { canSteerHarness, compactHarnessContext, forgetHarnessSession, - generateHarnessTitle, isLiveHarness, probeHarnessAvailability, refreshHarnessCatalogs, @@ -803,6 +808,9 @@ export default function App({ canForward: false, }); const turnGen = useRef(new Map()); + // Naming belongs to the checkout's first request, not its currently running + // turn. A normal follow-up must not invalidate a still-pending suggestion. + const cancelledWorktreeNames = useRef(new Set()); const lastPersisted = useRef(new Map()); const lastBoundProvider = useRef(new Map()); const lastPersistedUserBlock = useRef(new Map()); @@ -857,6 +865,7 @@ export default function App({ const open = sessionsRef.current.find( (session) => session.id === sessionId, ); + if (open) cancelledWorktreeNames.current.add(sessionId); if (!open?.busy) return open; turnGen.current.set(sessionId, (turnGen.current.get(sessionId) ?? 0) + 1); @@ -3808,6 +3817,7 @@ export default function App({ const projectSessionIds = new Set( projectSessions.map((session) => session.id), ); + for (const id of projectSessionIds) cancelledWorktreeNames.current.add(id); if (options.purgeData) { for (const session of projectSessions) { @@ -4385,14 +4395,45 @@ export default function App({ }), ); - if (isFirstTurn && live && placeholderTitle) { - const titleMessage = - harnessText || attachments.map((file) => file.name).join(", "); - void generateHarnessTitle(current.harness, { + const nameNewWorktree = + isFirstTurn && + live && + shouldIsolateSession(current) && + !current.workspaceChoice?.path; + const titleMessage = initialMessageContext({ + message: [harnessText, current.inboxCard?.prompt] + .filter(Boolean) + .join("\n\n"), + plan: intent === "build" ? approvedPlan?.text : undefined, + handoff: handoffCard?.brief ?? queuedHandoff?.text, + attachmentNames: attachments.map((file) => file.name), + }); + const requestMetadata = () => + initialSessionMetadata(current.harness, { sessionId, cwd: workCwd, message: titleMessage, - }) + includeBranch: nameNewWorktree, + }); + const metadata = + isFirstTurn && live && (placeholderTitle || nameNewWorktree) + ? requestMetadata() + : Promise.resolve(null); + const naming = nameNewWorktree + ? { + token: crypto.randomUUID(), + result: metadata.then((generated) => generated?.branch || null), + retry: getHarness(current.harness)?.generateTitle + ? async () => (await requestMetadata())?.branch || null + : undefined, + isCurrent: () => + !cancelledWorktreeNames.current.has(sessionId) && + sessionsRef.current.some((session) => session.id === sessionId) && + !removingSessionIds.current.has(sessionId), + } + : undefined; + if (isFirstTurn && live && placeholderTitle) { + void metadata .then(async (generated) => { const linkedWorkItem = await resolveLinkedWorkItem( titleMessage, @@ -4405,7 +4446,7 @@ export default function App({ if (s.id !== sessionId) return s; let next = s; if ( - generated && + generated?.title && canReplaceSessionTitle(s.title, s.harness, titleSeed) ) { next = { @@ -4540,6 +4581,7 @@ export default function App({ worktreeCwd = await prepareSessionWorktree( current, submittedText, + naming, ); } finally { showWorktreePreparation(false); @@ -5142,6 +5184,7 @@ export default function App({ const onStop = useCallback( (sessionId: string) => { + cancelledWorktreeNames.current.add(sessionId); const session = sessionsRef.current.find((s) => s.id === sessionId); turnGen.current.set(sessionId, (turnGen.current.get(sessionId) ?? 0) + 1); flushHarnessEvents(); @@ -5809,6 +5852,29 @@ export default function App({ useEffect(() => { const unlisten: Array void>> = [ + listen<{ + id: string; + sessionIds: string[]; + path: string; + branch: string; + }>("worktree-named", ({ payload }) => { + const update = (session: Session) => + session.id === payload.id || + payload.sessionIds.includes(session.id) || + isEqualOrInside(sessionWorkCwd(session), payload.path) + ? { ...session, branch: payload.branch } + : session; + sessionsRef.current = sessionsRef.current.map(update); + setSessions((previous) => previous.map(update)); + setHistory((previous) => + previous.map((session) => + session.id === payload.id || payload.sessionIds.includes(session.id) + ? { ...session, branch: payload.branch } + : session, + ), + ); + notifyGitChanged(); + }), listen("new_tab", () => run("new", actions.current.onNew)), listen("close_other_tabs", () => run("close-others", actions.current.onCloseOtherTabs), diff --git a/src/chrome/BranchPicker.tsx b/src/chrome/BranchPicker.tsx index 6a31434a..7071b601 100644 --- a/src/chrome/BranchPicker.tsx +++ b/src/chrome/BranchPicker.tsx @@ -262,7 +262,7 @@ export function BranchPicker({ const interactive = enabled && !awaitingBranch && !missingGit; return ( -
+