From 19c97ea56d30b3a2de31a060f8f47d6b7404b78f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 03:08:39 -0700 Subject: [PATCH 001/106] fix(web): unlock the composer when preview capture fails (#9127) Co-authored-by: Claude Fable 5.1 --- apps/desktop/src/preview/Manager.test.ts | 158 ++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 111 +++++++++--- apps/desktop/src/preview/PickPreload.ts | 149 ++++++++++++----- .../components/preview/PreviewView.test.tsx | 51 +++++- .../src/components/preview/PreviewView.tsx | 31 +++- apps/web/src/lib/previewAnnotation.test.ts | 36 +++- apps/web/src/lib/previewAnnotation.ts | 39 ++++- packages/contracts/src/ipc.ts | 3 + 8 files changed, 495 insertions(+), 83 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 75271d76386a..d334b3635080 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -3137,6 +3137,164 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("settles the pick when the annotation screenshot never arrives", () => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isFocused: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + // A wedged compositor leaves `capturePage` pending forever. + capturePage: vi.fn(() => new Promise(() => {})), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + + onPicked?.( + {}, + { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }, + null, + "send", + ); + yield* Effect.yieldNow; + expect(pick.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust("6 seconds"); + // The pick has to give up on the crop rather than strand the renderer, + // which would leave the composer stuck on "Capturing…". + const result = yield* Fiber.join(pick); + expect(result?.annotation.screenshot).toBeNull(); + expect(result?.screenshotFailed).toBe(true); + expect(result?.submission).toBe("send"); + expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured"); + }), + ), + ); + + effectIt.effect("a stale capture from a replaced pick never touches the next pick", () => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isFocused: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + once: vi.fn(), + off: vi.fn(), + capturePage: vi.fn(() => new Promise(() => {})), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const annotation = { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }; + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + // The first pick submits and its crop hangs. + onPicked?.({}, annotation, null, "send"); + yield* Effect.yieldNow; + + // A second pick on the same tab replaces the first, which resumes null. + const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(yield* Fiber.join(firstPick)).toBeNull(); + webviewSend.mockClear(); + + // The first pick's crop times out while the second pick is live. It + // must not signal the overlay, which would tear down the second pick. + yield* TestClock.adjust("6 seconds"); + yield* Effect.yieldNow; + expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured"); + expect(secondPick.pollUnsafe()).toBeUndefined(); + + onPicked?.({}, { ...annotation, id: "annotation_2" }, null, "attach"); + yield* TestClock.adjust("6 seconds"); + const result = yield* Fiber.join(secondPick); + expect(result?.annotation.id).toBe("annotation_2"); + expect(result?.submission).toBe("attach"); + }), + ), + ); + effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 01398721dd58..8af2a460fb3e 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -306,13 +306,24 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { }; }; +/** `capturePage` never settles when the guest's compositor is wedged. */ +const ANNOTATION_SCREENSHOT_TIMEOUT = "5 seconds"; + +/** + * Crops the guest for a picked annotation. A stalled `capturePage` resolves to + * `null` after the timeout: the annotation is still sendable without its + * screenshot, and the pick session must settle either way. + */ const captureAnnotationScreenshot = ( tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, ): Effect.Effect => Effect.tryPromise({ - try: () => + // The unused abort signal is what makes this interruptible, and therefore + // what lets the timeout below fire. Drop the parameter and a stalled + // capture strands the pick session again. + try: (_signal) => wc.capturePage( cropRect ? { @@ -331,7 +342,7 @@ const captureAnnotationScreenshot = ( cause, }), }).pipe( - Effect.map((image) => { + Effect.map((image): PreviewAnnotationPayload["screenshot"] => { const size = image.getSize(); return { dataUrl: image.toDataURL(), @@ -340,6 +351,15 @@ const captureAnnotationScreenshot = ( cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, }; }), + Effect.timeoutOption(ANNOTATION_SCREENSHOT_TIMEOUT), + Effect.flatMap((screenshot) => + Option.isSome(screenshot) + ? Effect.succeed(screenshot.value) + : Effect.logWarning("preview annotation screenshot timed out").pipe( + Effect.annotateLogs({ tabId, webContentsId: wc.id }), + Effect.as(null), + ), + ), ); const findZoomStep = (current: number): number => { @@ -2370,30 +2390,52 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback( (resume) => { + // Declared first so cleanup can check slot ownership by identity + // without a type cycle through the cancel effect it builds. + const session: PickSession = { cancel: Effect.suspend(() => cancelPickSession()) }; const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); + // Only drop the slot while it is still ours. A newer session may + // already have swapped itself in before cancelling this one. yield* Ref.update(pickSessionsRef, (sessions) => - replaceMap(sessions, (copy) => { - copy.delete(tabId); - }), + sessions.get(tabId) === session + ? replaceMap(sessions, (copy) => { + copy.delete(tabId); + }) + : sessions, ); }); - const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( + // Every exit from this session runs through `claimSettle`, so the + // renderer's `pickElement` promise resolves exactly once. The previous + // identity check let a cancelled or replaced session return without + // resuming, which left the composer waiting forever. + let settled = false; + const claimSettle = (): boolean => { + if (settled) return false; + settled = true; + return true; + }; + const finishPick = Effect.fn("PreviewManager.finishPickElement")(function* ( payload: PreviewAnnotationSubmissionResult | null, ) { - const active = (yield* Ref.get(pickSessionsRef)).get(tabId); - if (!active || active.cancel !== cancel) return; yield* cleanup(); resume(Effect.succeed(payload)); }); + const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( + payload: PreviewAnnotationSubmissionResult | null, + ) { + if (!claimSettle()) return; + yield* finishPick(payload); + }); const settle = (payload: PreviewAnnotationSubmissionResult | null) => { runFork(settlePick(payload)); }; const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { + if (!claimSettle()) return; yield* cleanup(); const tabs = yield* SynchronizedRef.get(tabsRef); const activeTab = tabs.get(tabId); @@ -2412,7 +2454,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } resume(Effect.succeed(null)); }); - const cancel = cancelPickSession(); const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { const payload = args[0]; if (!isPreviewAnnotationPayload(payload)) { @@ -2423,19 +2464,32 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const submission = args[2] === "send" ? "send" : "attach"; runFork( captureAnnotationScreenshot(tabId, wc, cropRect).pipe( - Effect.matchEffect({ - onFailure: () => Effect.sync(() => settle({ annotation: payload, submission })), - onSuccess: (screenshot) => - Effect.sync(() => settle({ annotation: { ...payload, screenshot }, submission })), + // The renderer cannot tell a dropped crop from a comment-only + // pick by the null alone, so a failed or timed-out capture is + // flagged on the result. + Effect.match({ + onFailure: (): PreviewAnnotationSubmissionResult => ({ + annotation: payload, + submission, + screenshotFailed: true, + }), + onSuccess: (screenshot): PreviewAnnotationSubmissionResult => + screenshot === null + ? { annotation: payload, submission, screenshotFailed: true } + : { annotation: { ...payload, screenshot }, submission }, }), - Effect.ensuring( - attempt( + Effect.flatMap((result) => { + // A capture that outlives its session must not touch the + // overlay: the preload tears down on the captured signal, and + // by now it may be running a newer pick. + if (!claimSettle()) return Effect.void; + return attempt( { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, () => { if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); }, - ).pipe(Effect.ignore), - ), + ).pipe(Effect.ignore, Effect.andThen(finishPick(result))); + }), ), ); }; @@ -2449,6 +2503,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { + // Two picks on one tab can overlap. Swap this session in and cancel + // the previous holder in one step, so no third pick can slip into an + // empty slot in between and the session we push out still resumes + // its renderer. + const replaced = yield* Ref.modify(pickSessionsRef, (sessions) => [ + sessions.get(tabId) ?? null, + replaceMap(sessions, (copy) => { + copy.set(tabId, session); + }), + ]); + if (replaced) yield* replaced.cancel; + // A newer pick may have cancelled this session while the previous + // one was torn down. Cleanup already ran, so attaching listeners now + // would leak them and start an overlay nobody is waiting on. + if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); wc.once("destroyed", onDestroyed); @@ -2456,21 +2525,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!wc.isFocused()) wc.focus(); wc.send(START_PICK_CHANNEL, annotationTheme); }); - yield* Ref.update(pickSessionsRef, (sessions) => - replaceMap(sessions, (copy) => { - copy.set(tabId, { cancel }); - }), - ); }); runFork( registerPickElement().pipe( Effect.catch((error: PreviewManagerError) => { + if (!claimSettle()) return Effect.void; resume(Effect.fail(error)); return cleanup(); }), ), ); - return cancel; + return session.cancel; }, ); }); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index f315bdcec738..6155c4119ec8 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. +// @effect-diagnostics globalDate:off globalTimers:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; import type { @@ -30,6 +30,8 @@ const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; const MAX_MARQUEE_ELEMENTS = 20; +/** Upper bound on one element's React context lookup during submit. */ +const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; @@ -279,25 +281,67 @@ function toStackFrame(frame: { }; } -async function captureElement(element: Element): Promise { +/** + * Resolves to `null` instead of hanging when `promise` outlives `millis`. + * `getElementContext` walks the inspected page's React internals, and some + * pages leave it pending forever. Without a bound, the whole submit chain + * stalls and the overlay sits on "Capturing…". + */ +function withCaptureTimeout(promise: Promise, millis: number): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), millis); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** Truncation for the DOM-only preview used when React context is unavailable. */ +const HTML_PREVIEW_MAX_CHARS = 500; + +/** + * Describes a picked element. The React context lookup can stall or throw on + * some pages, so the element is never dropped: without context it still + * carries its tag, a short HTML preview, and its rect so the crop stays on the + * pick instead of falling back to the whole viewport. + */ +async function captureElement(element: Element): Promise { + const base = { + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + tagName: element.tagName.toLowerCase(), + pickedAt: new Date().toISOString(), + }; try { - const context = await getElementContext(element); - const stack = (context.stack ?? []).map(toStackFrame); - return { - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - tagName: element.tagName.toLowerCase(), - selector: context.selector, - htmlPreview: context.htmlPreview ?? "", - componentName: context.componentName, - source: stack[0] ?? null, - stack, - styles: context.styles ?? "", - pickedAt: new Date().toISOString(), - }; + const context = await withCaptureTimeout( + Promise.resolve(getElementContext(element)), + ELEMENT_CONTEXT_TIMEOUT_MS, + ); + if (context) { + const stack = (context.stack ?? []).map(toStackFrame); + return { + ...base, + selector: context.selector, + htmlPreview: context.htmlPreview ?? "", + componentName: context.componentName, + source: stack[0] ?? null, + stack, + styles: context.styles ?? "", + }; + } } catch { - return null; + // Fall through to the DOM-only payload. } + return { + ...base, + selector: null, + htmlPreview: element.outerHTML.slice(0, HTML_PREVIEW_MAX_CHARS), + componentName: null, + source: null, + stack: [], + styles: "", + }; } function createButton(label: string, title: string): HTMLButtonElement { @@ -1225,12 +1269,20 @@ function startAnnotation(): void { pendingCapture = true; submit.disabled = true; submit.textContent = "Capturing…"; + // Snapshot everything the annotation will carry before the capture runs. + // The element context lookup can take up to its timeout, and the user can + // keep editing meanwhile; the annotation must describe what they submitted. + const submittedComment = comment.value.trim(); + const submittedRegions = [...regions]; + const submittedStrokes = [...strokes]; + const submittedStyleChanges = Array.from(styleChanges.values(), (change) => ({ ...change })); void Promise.all( Array.from(selected.values()).map(async (target) => { const element = await captureElement(target.element); - if (!element) return null; - for (const change of styleChanges.values()) { - if (change.targetId === target.id) change.selector = element.selector; + for (const change of submittedStyleChanges) { + if (change.targetId === target.id && element.selector !== null) { + change.selector = element.selector; + } } return { id: target.id, @@ -1238,30 +1290,39 @@ function startAnnotation(): void { rect: rectFromDomRect(target.element.getBoundingClientRect()), }; }), - ).then((captured) => { - const elements = captured.filter((target) => target !== null); - const annotation: PreviewAnnotationPayload = { - id: nextId("annotation"), - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - comment: comment.value.trim(), - elements, - regions: [...regions], - strokes: [...strokes], - styleChanges: Array.from(styleChanges.values()), - screenshot: null, - createdAt: new Date().toISOString(), - }; - editor.style.display = "none"; - toolbar.style.display = "none"; - hoverOutline.style.display = "none"; - const screenshotRect = unionRects([ - ...elements.map((target) => target.rect), - ...regions.map((region) => region.rect), - ...strokes.map((stroke) => stroke.bounds), - ]); - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); - }); + ) + .then((elements) => { + // The overlay may have been cancelled or replaced while the capture + // ran. A late submit must not deliver into the next pick's listener. + if (finished) return; + const annotation: PreviewAnnotationPayload = { + id: nextId("annotation"), + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + comment: submittedComment, + elements, + regions: submittedRegions, + strokes: submittedStrokes, + styleChanges: submittedStyleChanges, + screenshot: null, + createdAt: new Date().toISOString(), + }; + editor.style.display = "none"; + toolbar.style.display = "none"; + hoverOutline.style.display = "none"; + const screenshotRect = unionRects([ + ...elements.map((target) => target.rect), + ...submittedRegions.map((region) => region.rect), + ...submittedStrokes.map((stroke) => stroke.bounds), + ]); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); + }) + .catch(() => { + // Last resort. Main is waiting on this message, so hand it an empty + // pick rather than leaving the button stuck on "Capturing…" and the + // renderer's pick promise pending. teardown is a no-op once finished. + teardown(true); + }); }; submit.addEventListener("click", () => submitAnnotation("attach")); root.addEventListener("keydown", (event) => { diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 808842044e92..bb20ee362376 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -27,7 +27,7 @@ const mocks = vi.hoisted(() => ({ openPictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), closePictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), pickElement: vi.fn(), - previewAnnotationScreenshotFile: vi.fn(), + capturePreviewAnnotationScreenshot: vi.fn(), addPreviewAnnotation: vi.fn(), addImage: vi.fn(), toggleAnnotation: null as (() => void) | null, @@ -90,7 +90,7 @@ vi.mock("~/composerDraftStore", () => ({ })); vi.mock("~/lib/previewAnnotation", () => ({ - previewAnnotationScreenshotFile: mocks.previewAnnotationScreenshotFile, + capturePreviewAnnotationScreenshot: mocks.capturePreviewAnnotationScreenshot, })); vi.mock("~/localApi", () => ({ @@ -252,6 +252,7 @@ vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); import { PreviewView, previewProfileName } from "./PreviewView"; +import { toastManager } from "~/components/ui/toast"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; const TEST_THREAD_REF = { @@ -339,8 +340,10 @@ describe("PreviewView navigation", () => { mocks.openPictureInPicture.mockClear(); mocks.closePictureInPicture.mockClear(); mocks.pickElement.mockReset(); - mocks.previewAnnotationScreenshotFile.mockReset(); + mocks.capturePreviewAnnotationScreenshot.mockReset(); + mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "none" }); mocks.addPreviewAnnotation.mockClear(); + vi.mocked(toastManager.add).mockClear(); mocks.addImage.mockClear(); mocks.toggleAnnotation = null; mocks.pictureInPicture = false; @@ -551,7 +554,39 @@ describe("PreviewView navigation", () => { expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation); }); - it("still sends when screenshot attachment conversion fails", async () => { + it("warns when main dropped the crop before handing over the pick", async () => { + const annotation = { + id: "annotation-3", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-07-27T00:00:00.000Z", + }; + const onSendAnnotation = vi.fn(); + mocks.pickElement.mockResolvedValue({ annotation, submission: "send", screenshotFailed: true }); + + renderToStaticMarkup( + , + ); + mocks.toggleAnnotation?.(); + + await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null)); + // A null screenshot alone looks like a comment-only pick; the flag is what + // separates "no crop requested" from "crop lost to a timeout". + expect(toastManager.add).toHaveBeenCalledTimes(1); + }); + + it("still sends when the picked element's crop cannot be captured", async () => { const annotation = { id: "annotation-2", pageUrl: "https://example.com/dashboard", @@ -571,7 +606,7 @@ describe("PreviewView navigation", () => { }; const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ annotation, submission: "send" }); - mocks.previewAnnotationScreenshotFile.mockRejectedValue(new Error("conversion failed")); + mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "failed" }); renderToStaticMarkup( { ); mocks.toggleAnnotation?.(); - await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null)); + // The forwarded and stored annotation both drop the screenshot, so the + // prompt does not claim a crop that was never attached. + const sent = { ...annotation, screenshot: null }; + await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(sent, null)); + expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, sent); expect(mocks.addImage).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 6d431a48e3db..640690854e53 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -20,7 +20,7 @@ import { useThreadRecentHistory, } from "~/browserHistoryStore"; import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; -import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; +import { capturePreviewAnnotationScreenshot } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; import { rememberPreviewUrl, @@ -579,15 +579,30 @@ export function PreviewView({ try { const result = await previewBridge.pickElement(runtimeTabId); if (!result) return; - const { annotation, submission } = result; + const { annotation: picked, submission, screenshotFailed = false } = result; + // The structured annotation is still sendable when its optional crop + // stalls or fails, so tell the user what they lost and keep going + // instead of holding the composer for an attachment that never lands. + // The stored copy drops the screenshot on failure, otherwise the prompt + // would tell the agent a crop is attached when none was sent. + const capture = await capturePreviewAnnotationScreenshot(picked); + // Main reports a crop that failed or timed out on its side; the local + // conversion can fail too. Either way the user should hear about it. + const cropDropped = screenshotFailed || capture.status === "failed"; + const annotation = capture.status === "failed" ? { ...picked, screenshot: null } : picked; addPreviewAnnotation(threadRef, annotation); - let screenshotFile: File | null = null; - try { - screenshotFile = await previewAnnotationScreenshotFile(annotation); - } catch { - // The structured annotation is still sendable when converting its - // optional screenshot into a composer attachment fails. + if (cropDropped) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not capture the picked element", + // The send path reports its own outcome, so only say what this + // handler knows: the crop was dropped. + description: "The annotation was kept without the screenshot.", + }), + ); } + const screenshotFile = capture.status === "captured" ? capture.file : null; const image = screenshotFile && annotation.screenshot ? ({ diff --git a/apps/web/src/lib/previewAnnotation.test.ts b/apps/web/src/lib/previewAnnotation.test.ts index 05f4b8d62731..a449de5ae846 100644 --- a/apps/web/src/lib/previewAnnotation.test.ts +++ b/apps/web/src/lib/previewAnnotation.test.ts @@ -1,9 +1,10 @@ import type { PreviewAnnotationPayload } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { appendPreviewAnnotationPrompt, buildPreviewAnnotationPrompt, + capturePreviewAnnotationScreenshot, extractTrailingPreviewAnnotation, } from "./previewAnnotation"; @@ -85,3 +86,36 @@ describe("preview annotations", () => { expect(extractedFirst.promptText).toBe("Fix this"); }); }); + +describe("preview annotation capture", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("returns the crop when the fetch resolves", async () => { + vi.stubGlobal("fetch", async () => new Response(new Blob(["png"], { type: "image/png" }))); + const capture = await capturePreviewAnnotationScreenshot(annotation); + expect(capture.status).toBe("captured"); + }); + + it("reports none when the annotation carries no crop", async () => { + const capture = await capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); + expect(capture).toEqual({ status: "none" }); + }); + + it("fails instead of hanging when the crop never arrives", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", () => new Promise(() => {})); + const capturePromise = capturePreviewAnnotationScreenshot(annotation, 1_000); + await vi.advanceTimersByTimeAsync(1_000); + expect(await capturePromise).toEqual({ status: "failed" }); + }); + + it("fails when the crop fetch throws", async () => { + vi.stubGlobal("fetch", async () => { + throw new Error("data url unreadable"); + }); + expect(await capturePreviewAnnotationScreenshot(annotation)).toEqual({ status: "failed" }); + }); +}); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index f1723dd93aad..464c8c8a94d4 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -99,7 +99,7 @@ export function extractTrailingPreviewAnnotation(prompt: string): ExtractedPrevi }; } -export async function previewAnnotationScreenshotFile( +async function previewAnnotationScreenshotFile( annotation: PreviewAnnotationPayload, ): Promise { if (!annotation.screenshot) return null; @@ -109,3 +109,40 @@ export async function previewAnnotationScreenshotFile( type: blob.type || "image/png", }); } + +/** Upper bound on turning a picked element's crop into a composer attachment. */ +export const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; + +export type PreviewAnnotationCapture = + /** The crop is ready to attach. */ + | { readonly status: "captured"; readonly file: File } + /** The pick carried no crop, which is normal for comment-only annotations. */ + | { readonly status: "none" } + /** The crop stalled or threw. Send the annotation without it. */ + | { readonly status: "failed" }; + +/** + * Bounded wrapper around `previewAnnotationScreenshotFile`. The picker holds the + * composer while this runs, so it must always settle: a stalled crop resolves as + * `failed` instead of leaving the caller waiting. + */ +export async function capturePreviewAnnotationScreenshot( + annotation: PreviewAnnotationPayload, + timeoutMs: number = PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS, +): Promise { + if (!annotation.screenshot) return { status: "none" }; + let timer: ReturnType | undefined; + try { + const file = await Promise.race([ + previewAnnotationScreenshotFile(annotation), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }), + ]); + return file ? { status: "captured", file } : { status: "failed" }; + } catch { + return { status: "failed" }; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea276f4ff92a..4c124b456239 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -934,11 +934,14 @@ export const PreviewAnnotationSubmissionSchema: Schema.Codec = Schema.Struct({ annotation: PreviewAnnotationPayloadSchema, submission: PreviewAnnotationSubmissionSchema, + screenshotFailed: Schema.optionalKey(Schema.Boolean), }); export const DesktopPreviewTabInputSchema = Schema.Struct({ From 1e051873094c0c75cd35fef89c90461c22cce76b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 03:09:29 -0700 Subject: [PATCH 002/106] fix(antigravity): refresh the model manifest so older Gemini models fold as legacy (#9397) Co-authored-by: Claude Fable 5.1 --- .../src/provider/Drivers/AntigravityDriver.ts | 4 ++ .../server/src/provider/ModelManifest.test.ts | 50 +++++++++++++++++++ apps/server/src/provider/ModelManifest.ts | 25 +++++++++- apps/server/src/provider/model-manifest.json | 1 + docs/internals/model-manifest.md | 4 +- 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 5503f1aeaa72..f9f99a5bfa38 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -248,7 +248,11 @@ export const AntigravityDriver: ProviderDriver Scope.close(processScope, exit)); return yield* authFlow diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index b636e43791c7..73049ad01c30 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -2,7 +2,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -15,6 +17,8 @@ import { make, resolveProviderCatalog, type ModelManifestData, + manifestUpdatedAtMs, + encodeManifestCache, } from "./ModelManifest.ts"; /** @@ -186,8 +190,12 @@ describe("resolveProviderCatalog", () => { }); }); +// Remote fixtures date after the bundle so a fetch still outranks it. +const REMOTE_UPDATED_AT = "2099-01-01T00:00:00Z"; + const REMOTE_MANIFEST: ModelManifestData = { version: 1, + updatedAt: REMOTE_UPDATED_AT, currentModels: { codex: ["remote-model"], claudeAgent: ["remote-agent-model"], @@ -196,6 +204,7 @@ const REMOTE_MANIFEST: ModelManifestData = { const REMOTE_CLAUDE_MANIFEST: ModelManifestData = { version: 1, + updatedAt: REMOTE_UPDATED_AT, currentModels: {}, providers: { claudeAgent: { @@ -368,6 +377,47 @@ describe("ModelManifest service", () => { ); }); + it.live("drops a disk cache of a manifest older than the bundled one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + assert.isAbove(manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST), 0); + const cachePath = path.join(config.stateDir, "model-manifest.json"); + // A cache of the manifest as it was before the release edited it. The + // fetch time is irrelevant: the remote may be unreachable now, so + // `current` must already prefer the bundle. + const { updatedAt: _undated, ...undatedManifest } = REMOTE_MANIFEST; + for (const stale of [ + undatedManifest, + { ...REMOTE_MANIFEST, updatedAt: "2000-01-01T00:00:00Z" }, + ]) { + yield* fs.writeFileString( + cachePath, + yield* encodeManifestCache({ fetchedAtMs: 0, manifest: stale }), + ); + const service = yield* make; + assert.deepStrictEqual(yield* service.current, BUNDLED_MODEL_MANIFEST); + } + + // A cache of a newer edit still outranks the bundle. + yield* fs.writeFileString( + cachePath, + yield* encodeManifestCache({ fetchedAtMs: 0, manifest: REMOTE_MANIFEST }), + ); + const later = yield* make; + assert.deepStrictEqual(yield* later.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-newer-bundle-test", + response: () => Response.json(REMOTE_MANIFEST), + }), + ), + ), + ); + it.live("does not fetch when provider update checks are disabled", () => Effect.gen(function* () { let fetchCount = 0; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index aeff3bdd1f9b..c3cb36566c02 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -82,6 +82,12 @@ const ManifestProviderCatalog = Schema.Struct({ */ const ModelManifestEnvelopeSchema = Schema.Struct({ version: Schema.Literal(1), + /** + * ISO date of the last edit. A release bundles its manifest, and a disk + * cache of an older edit must not outrank it. Optional so older remote + * files still decode; they count as older than any dated bundle. + */ + updatedAt: Schema.optional(Schema.String), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); @@ -131,6 +137,13 @@ const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); +/** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */ +export function manifestUpdatedAtMs(manifest: ModelManifestData): number { + if (manifest.updatedAt === undefined) return 0; + const parsed = Date.parse(manifest.updatedAt); + return Number.isNaN(parsed) ? 0 : parsed; +} + /** Resolve provider-neutral model presentation and capability data. */ export function resolveProviderCatalog( manifest: ModelManifestData, @@ -186,7 +199,8 @@ const decodeManifestCache = Schema.decodeUnknownEffect( ManifestCacheFile as unknown as Schema.Codec, ), ); -const encodeManifestCache = Schema.encodeEffect( +/** Exported for tests that seed the disk cache. */ +export const encodeManifestCache = Schema.encodeEffect( Schema.fromJsonString( ManifestCacheFile as unknown as Schema.Codec, ), @@ -330,7 +344,14 @@ export const make = Effect.gen(function* () { ); if (fromDisk === null) return; // The disk copy is the last-seen remote manifest, so it outranks the - // bundle even when stale: it is refreshed on the next successful fetch. + // bundle even when stale, unless the bundle's own edit date is newer + // than the cached manifest's. Then the release carries data the cache + // has not seen and the cache is dropped so the next refresh replaces + // it. Comparing edit dates, not fetch time, keeps this independent of + // when the cache was written relative to the release. + if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) { + return; + } manifest = fromDisk.manifest; fetchedAtMs = fromDisk.fetchedAtMs; }), diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index aebebce1de15..590d02c8ac03 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,5 +1,6 @@ { "version": 1, + "updatedAt": "2026-09-03T09:58:00Z", "currentModels": { "codex": [ "gpt-5.6-luna", diff --git a/docs/internals/model-manifest.md b/docs/internals/model-manifest.md index bebf5c2ee527..d5be97afe92b 100644 --- a/docs/internals/model-manifest.md +++ b/docs/internals/model-manifest.md @@ -3,7 +3,9 @@ `apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from `main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the -last successful remote manifest. The bundle is used only when no valid remote cache exists. +last successful remote manifest. The bundle is used when no valid remote cache exists, or when +the bundle's `updatedAt` is later than the cached manifest's, so a release that edits the manifest +takes effect before the next successful fetch. Bump `updatedAt` whenever you edit the file. The top-level provider catalog is generic: models contain presentation metadata, aliases, status, an optional badge, and a reusable capability profile. The profile and model `adapter` fields are From fff33f9e851912363c5b1f3ac65598be35eb5f0d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 3 Sep 2026 03:13:15 -0700 Subject: [PATCH 003/106] perf(ci): reuse dependency checks in release builds (#9399) --- .github/workflows/release.yml | 22 ++++++++++++++++++++++ docs/internals/ci.md | 5 +++++ 2 files changed, 27 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a5c691a3c7e..9c179a770a27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,6 +111,8 @@ jobs: node-version-file: package.json cache: true run-install: true + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - id: release_meta name: Resolve release version @@ -174,6 +176,14 @@ jobs: --current-tag "${{ steps.release_meta.outputs.tag }}" \ --github-output + # Share only the verification results, not the large registry metadata cache. + - name: Upload dependency verification + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl + quality: name: Release quality checks needs: [preflight] @@ -446,7 +456,18 @@ jobs: path: ${{ steps.package_cache_path.outputs.path }} key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + # pnpm checks the lockfile and policy before reusing this result. A missing + # artifact leaves the cache empty, so installation runs the checks again. + - name: Download dependency verification + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: release-dependency-verification + path: ${{ runner.temp }}/pnpm-metadata + - name: Install desktop dependencies + env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - name: Cache resource monitor @@ -583,6 +604,7 @@ jobs: - name: Build desktop artifact shell: bash env: + pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} diff --git a/docs/internals/ci.md b/docs/internals/ci.md index d67c9fbe081b..a454e62c5c59 100644 --- a/docs/internals/ci.md +++ b/docs/internals/ci.md @@ -28,4 +28,9 @@ signing only when platform credentials are present. macOS passkey builds additio `APPLE_TEAM_ID` and the `MACOS_PROVISIONING_PROFILE` secret; Windows uses Azure Trusted Signing. Without the core signing credentials, it still releases unsigned artifacts. +Preflight shares pnpm's lockfile verification results with the desktop build jobs through a small +artifact. This avoids repeating dependency checks, especially on Windows, without transferring the +large registry metadata cache. pnpm checks the current lockfile and policy before it reuses a result. +If the artifact is unavailable, installation runs the checks again. + See [Release Checklist](../operations/release.md) for the full release/signing setup checklist. From 77e35c561259733d880ab62a43aad0894d301d9b Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:22:06 +0300 Subject: [PATCH 004/106] fix(web): send cited messages with Cmd+Enter (#9307) --- .../src/components/ComposerCitationNode.tsx | 8 +++++++- .../src/components/ComposerPromptEditor.tsx | 7 ++++++- .../components/chat/AssistantCitationChip.tsx | 10 ++++++++++ .../chat/AssistantCitationCommentEditor.tsx | 18 ++++++++++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 10 ++++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ComposerCitationNode.tsx b/apps/web/src/components/ComposerCitationNode.tsx index b54a08173106..ffaae589343f 100644 --- a/apps/web/src/components/ComposerCitationNode.tsx +++ b/apps/web/src/components/ComposerCitationNode.tsx @@ -47,7 +47,8 @@ export type ComposerCitationCommentTarget = { export const ComposerCitationCommentContext = createContext<{ openComment: ComposerCitationCommentTarget | null; onOpenChange: (nodeKey: NodeKey, open: boolean) => void; -}>({ openComment: null, onOpenChange: () => {} }); + onSubmitAndSend: () => void; +}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); /** Consume a cite action once its controlled prompt has been committed to the editor. */ export function $consumeComposerCitationCommentRequest(requestRef: { @@ -127,6 +128,11 @@ function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey commentContext.onOpenChange(props.nodeKey, open); }, onSave: onSaveComment, + onSaveAndSend: (comment) => { + if (!onSaveComment(comment)) return false; + commentContext.onSubmitAndSend(); + return true; + }, }} onRemove={onRemove} /> diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 1676b4f01e7d..695c5e3d0858 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -924,6 +924,7 @@ interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; + onCitationSubmitAndSend?: () => void; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; } @@ -1571,6 +1572,7 @@ function ComposerPromptEditorInner({ onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -1603,8 +1605,9 @@ function ComposerPromptEditorInner({ open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, ); }, + onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), }), - [openCitationComment], + [onCitationSubmitAndSend, openCitationComment], ); const terminalContextActions = useMemo( () => ({ onRemoveTerminalContext }), @@ -1969,6 +1972,7 @@ export function ComposerPromptEditor({ onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -2013,6 +2017,7 @@ export function ComposerPromptEditor({ onChange={onChange} {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})} onPaste={onPaste} + {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})} editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})} diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 4afaefe3f489..7582bb19c5c6 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -42,6 +42,7 @@ export function AssistantCitationChip({ sourceAnchor?: AssistantCitationSourceAnchor | undefined; onOpenChange: (open: boolean) => void; onSave: (comment: string) => boolean; + onSaveAndSend?: (comment: string) => boolean; }; }) { const navigate = useNavigate(); @@ -158,6 +159,15 @@ export function AssistantCitationChip({ commentEditor.onOpenChange(false); return true; }} + {...(commentEditor.onSaveAndSend + ? { + onSubmitAndSend: (comment: string) => { + if (!commentEditor.onSaveAndSend?.(comment)) return false; + commentEditor.onOpenChange(false); + return true; + }, + } + : {})} onCancel={() => commentEditor.onOpenChange(false)} /> diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index 3968d4568655..4dc422210de0 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -7,11 +7,13 @@ export function AssistantCitationCommentEditor({ citation, inputRef, onSubmit, + onSubmitAndSend, onCancel, }: { citation: AssistantCitation; inputRef?: Ref; onSubmit: (comment: string) => boolean; + onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; }) { const [comment, setComment] = useState(citation.comment ?? ""); @@ -19,6 +21,14 @@ export function AssistantCitationCommentEditor({ const submit = () => { if (!commentTooLong) onSubmit(comment); }; + const submitAndSend = () => { + if (commentTooLong) return; + if (onSubmitAndSend) { + onSubmitAndSend(comment); + } else { + onSubmit(comment); + } + }; return (
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 93ae287c702a..b24fb16c523b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2824,6 +2824,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) shouldBlurMobileComposerOnSubmit, ], ); + const submitCitationAndSend = useCallback(() => { + const intent = composerSubmissionIntentForEnter({ + isMobileViewport, + shiftKey: false, + modifierKey: true, + isDraftThread: routeKind === "draft", + }); + submitComposer(undefined, intent ?? "foreground"); + }, [isMobileViewport, routeKind, submitComposer]); const compactThreadContext = useCallback(() => { if ( compactDisabled || @@ -5246,6 +5255,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} onPageScrollRelease={onPageScrollRelease} + onCitationSubmitAndSend={submitCitationAndSend} onPaste={onComposerPaste} placeholder={ isComposerApprovalState From 098bf5329727fcd7d973bf842e6b4d50d6e7b924 Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:32:40 +0800 Subject: [PATCH 005/106] fix(web): preserve explicit preview navigation URLs (#8902) --- .../src/browser/browserTargetResolver.test.ts | 46 ++++++++++++++----- apps/web/src/browser/browserTargetResolver.ts | 42 ++++++----------- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index cbce157f9a05..c2b3432402ed 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,7 +25,7 @@ describe("browser target resolver", () => { }); }); - it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + it("preserves explicit loopback URL navigation for a remote Tailscale environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -35,13 +35,29 @@ describe("browser target resolver", () => { }), ).toEqual({ requestedUrl: "http://localhost:5173/dashboard?mode=test#results", - resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", - resolutionKind: "direct-private-network", + resolvedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolutionKind: "direct", environmentId: "environment-1", }); }); - it("preserves URL credentials when mapping localhost onto a remote host", async () => { + it("preserves explicit IPv4 loopback URL navigation for a private network environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.50:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://127.0.0.1:5999/", + }), + ).toEqual({ + requestedUrl: "http://127.0.0.1:5999/", + resolvedUrl: "http://127.0.0.1:5999/", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + + it("preserves URL credentials on explicit loopback navigation", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -49,10 +65,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard", }).resolvedUrl, - ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard"); }); - it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + it("preserves credentialed loopback URLs for private IPv6 environments", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", }); @@ -62,10 +78,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", }).resolvedUrl, - ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard?mode=test#results"); }); - it("maps schemeless localhost navigation onto a remote environment host", async () => { + it("preserves schemeless localhost navigation for a remote environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -73,7 +89,7 @@ describe("browser target resolver", () => { kind: "url", url: "localhost:3000/app", }).resolvedUrl, - ).toBe("http://192.168.1.25:3000/app"); + ).toBe("localhost:3000/app"); }); it("keeps localhost navigation local for a local environment", async () => { @@ -117,12 +133,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); - expect(() => + expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { kind: "url", url: "http://localhost:5173", }), - ).toThrow(/authenticated preview gateway/); + ).toMatchObject({ resolvedUrl: "http://localhost:5173", resolutionKind: "direct" }); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -136,6 +152,14 @@ describe("browser target resolver", () => { ).toBe("http://localhost:3000/app"); }); + it("maps discovered loopback servers onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3000/app"), + ).toBe("http://192.168.1.25:3000/app"); + }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 684247e28022..c06c60b5f740 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -207,30 +207,6 @@ export function resolveBrowserNavigationTarget( target: BrowserNavigationTarget, ): PreviewUrlResolution { if (target.kind === "url") { - let parsed: URL | null = null; - try { - parsed = new URL(normalizePreviewUrl(target.url)); - } catch { - // Preserve the existing direct-navigation behavior so the preview host - // reports malformed URL errors through its normal navigation path. - } - if (parsed && isLoopbackHost(parsed.hostname)) { - const environmentUrl = readEnvironmentUrl(environmentId); - if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { - return resolveEnvironmentPortTarget( - environmentId, - { - kind: "environment-port", - port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, - }, - environmentUrl, - target.url, - parsed, - ); - } - } return { requestedUrl: target.url, resolvedUrl: target.url, @@ -244,10 +220,20 @@ export function resolveBrowserNavigationTarget( export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - return resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: normalizedUrl, - }).resolvedUrl; + const parsed = new URL(normalizedUrl); + if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + readEnvironmentUrl(environmentId), + rawUrl, + parsed, + ).resolvedUrl; } catch { return rawUrl; } From 57626eb6eaa436b22260068d23f4e3df5f389cd1 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:33:56 -0700 Subject: [PATCH 006/106] fix(web): prevent loading ssh environments from overriding navigation (#9168) --- apps/web/src/hooks/useHandleNewThread.test.ts | 154 ++++++++++++++++++ apps/web/src/hooks/useHandleNewThread.ts | 8 + 2 files changed, 162 insertions(+) create mode 100644 apps/web/src/hooks/useHandleNewThread.test.ts diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts new file mode 100644 index 000000000000..91b757f51e0d --- /dev/null +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => { + let completeProjectFileRead: (value: null) => void = () => undefined; + let projectFileRead = Promise.resolve(null); + let storedDraft: { + readonly draftId: string; + readonly environmentId: string; + readonly promotedTo: null; + readonly threadId: string; + } | null = null; + const router = { + state: { + location: { href: "/" }, + matches: [{ params: {} }], + }, + navigate: vi.fn(async (request: { readonly params: { readonly draftId: string } }) => { + router.state.location.href = `/draft/${request.params.draftId}`; + }), + }; + const draftStore = { + getComposerDraft: vi.fn(() => ({})), + getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), + getDraftSession: vi.fn(() => null), + getDraftThread: vi.fn(() => null), + applyStickyState: vi.fn(), + setDraftThreadContext: vi.fn(), + setLogicalProjectDraftThreadId: vi.fn(), + setModelSelection: vi.fn(), + }; + + return { + completeProjectFileRead: (value: null) => completeProjectFileRead(value), + draftStore, + get projectFileRead() { + return projectFileRead; + }, + reset(nextStoredDraft: typeof storedDraft) { + storedDraft = nextStoredDraft; + router.state.location.href = "/"; + router.navigate.mockClear(); + draftStore.setLogicalProjectDraftThreadId.mockClear(); + projectFileRead = new Promise((resolve) => { + completeProjectFileRead = resolve; + }); + }, + router, + }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }), +})); +vi.mock("@t3tools/client-runtime/environment", () => ({ + scopedProjectKey: () => "remote-project", + scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), + scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), +})); +vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" })); +vi.mock("@t3tools/shared/threadEnvMode", () => ({ + resolveDefaultThreadEnvMode: (input: { + readonly projectFile: "local" | "worktree" | null; + readonly globalDefault: "local" | "worktree"; + }) => input.projectFile ?? input.globalDefault, +})); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => null, + useRouter: () => testState.router, +})); +vi.mock("react", () => ({ + useCallback: (callback: T) => callback, + useMemo: (factory: () => T) => factory(), +})); +vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] })); +vi.mock("../composerDraftStore", () => { + const useComposerDraftStore = Object.assign(() => null, { + getState: () => testState.draftStore, + }); + return { + composerDraftHasUserContent: () => false, + markPromotedDraftThreadByRef: vi.fn(), + useComposerDraftStore, + }; +}); +vi.mock("../lib/chatThreadActions", () => ({ + hasExplicitComposerModelSelection: () => false, + resolveNewDraftStartFromOrigin: () => false, + resolveNewThreadModelSelectionOverride: () => null, +})); +vi.mock("../lib/t3ProjectFileDefaults", () => ({ + readT3ProjectFileDefaultThreadEnvMode: () => testState.projectFileRead, +})); +vi.mock("../lib/utils", () => ({ + newDraftId: () => "draft-delayed", + newThreadId: () => "thread-delayed", +})); +vi.mock("../logicalProject", () => ({ + deriveLogicalProjectKeyFromSettings: () => "remote-project", + getProjectOrderKey: () => "remote-project", + selectProjectGroupingSettings: () => ({}), +})); +vi.mock("../state/entities", () => ({ + readProjects: () => [ + { + id: "project-remote", + environmentId: "environment-ssh", + workspaceRoot: "/remote/project", + defaultThreadEnvMode: null, + defaultModelSelection: null, + }, + ], + readThreadShell: () => null, + useProjects: () => [], + useThread: () => null, +})); +vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} })); +vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); +vi.mock("../uiStateStore", () => ({ + legacyProjectCwdPreferenceKey: () => "remote-project", + useUiStateStore: () => [], +})); +vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); + +import { useNewThreadHandler } from "./useHandleNewThread"; + +describe("useNewThreadHandler", () => { + it.each([ + ["new", null], + [ + "reusable", + { + draftId: "draft-existing", + environmentId: "environment-ssh", + promotedTo: null, + threadId: "thread-existing", + }, + ], + ])("abandons a delayed %s draft open when the user navigates elsewhere", async (_, draft) => { + testState.reset(draft); + const openThread = useNewThreadHandler(); + const pendingOpen = openThread( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { replace: true }, + ); + + testState.router.state.location.href = "/usage"; + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.router.state.location.href).toBe("/usage"); + expect(testState.router.navigate).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 0df26f455e04..c26b25d1316b 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -93,6 +93,8 @@ export function useNewThreadHandler() { setLogicalProjectDraftThreadId, setModelSelection, } = useComposerDraftStore.getState(); + const requestingRouteHref = router.state.location.href; + const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); // A new thread carries the user's working mode from the thread being // viewed. The target project's configured model still wins; runtime and @@ -219,6 +221,9 @@ export function useNewThreadHandler() { workspaceContext = pickExplicitWorkspaceOptions(options); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); + if (routeChangedSinceRequest()) { + return null; + } // The await yields. If the draft was opened (a concurrent // invocation's navigation landed), promoted to a real thread, // remapped away (a concurrent invocation registered a fresh @@ -355,6 +360,9 @@ export function useNewThreadHandler() { const createdAt = new Date().toISOString(); return (async () => { const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); + if (routeChangedSinceRequest()) { + return null; + } // The await yields, so a concurrent invocation may have registered a // draft for this logical project in the meantime. Registering ours // too would evict that draft while its navigation is in flight — From cfddb4201df8941bbfde008919da70fe5ec7552b Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 3 Sep 2026 18:35:25 +0200 Subject: [PATCH 007/106] fix(mobile): skip unsupported shared settings targets (#9381) --- .../features/settings/SettingsRouteScreen.tsx | 21 +++---- apps/web/src/hooks/useSettings.ts | 45 +++++---------- docs/internals/overview.md | 7 ++- docs/user/thread-sidebar.md | 12 ++-- .../src/state/sharedSettings.test.ts | 57 ++++++++++++++++--- .../src/state/sharedSettings.ts | 46 +++++++++++---- 6 files changed, 119 insertions(+), 69 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 58a2779840c3..41c2076ac7b4 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -46,6 +46,7 @@ import { import { findSharedSettingsMismatches, pickSharedServerSettings, + supportsSharedSettingsSync, } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { @@ -553,9 +554,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD /** * Auto-settlement is a user preference that every server has to hold. Mobile - * has no primary environment, so the first connected environment that - * supports it is the reference value. Edits fan out to every connected - * environment, and a mismatch row lets the user push the reference out. + * has no primary environment, so the first eligible sync target provides the + * reference value. Edits fan out to every eligible target, and a mismatch row + * lets the user push the reference out. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); @@ -564,12 +565,8 @@ function AutoSettleSettingsRows() { reportFailure: true, }); - const connected = environments.filter( - (environment) => - environment.connection.phase === "connected" && - environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, - ); - const reference = connected[0] ?? null; + const syncTargets = environments.filter(supportsSharedSettingsSync); + const reference = syncTargets[0] ?? null; const referenceSettings = reference?.serverConfig?.settings ?? null; const [daysDraft, setDaysDraft] = useState(null); @@ -579,7 +576,7 @@ function AutoSettleSettingsRows() { } const writeToAll = (patch: ServerSettingsPatch) => { - for (const environment of connected) { + for (const environment of syncTargets) { void updateSettings({ environmentId: environment.environmentId, input: { patch } }); } }; @@ -590,7 +587,7 @@ function AutoSettleSettingsRows() { environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - connected: environment.connection.phase === "connected", + syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, })), }); @@ -600,7 +597,7 @@ function AutoSettleSettingsRows() { const draft = (daysDraft ?? "").trim(); setDaysDraft(null); // Whole-string check so "3.5" and "3days" are rejected instead of - // silently becoming 3 on every connected environment. + // silently becoming 3 on every eligible sync target. const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; if ( Number.isInteger(parsed) && diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 6eb571f70aac..70e16ff33d1a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -29,6 +29,7 @@ import { findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, + supportsSharedSettingsSync, } from "@t3tools/client-runtime/state/shared-settings"; import { ensureLocalApi } from "~/localApi"; import { @@ -42,11 +43,7 @@ import * as Struct from "effect/Struct"; import { toastManager } from "~/components/ui/toast"; import { isHostedStaticApp } from "~/hostedPairing"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; -import { - type EnvironmentPresentation, - useEnvironments, - usePrimaryEnvironment, -} from "~/state/environments"; +import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { useTheme } from "./useTheme"; @@ -332,26 +329,14 @@ export function usePrimarySettingsAvailable(): boolean { return primaryEnvironment !== null || !isHostedStaticApp(); } -/** - * Whether an environment can hold every shared key right now. Gated on the - * auto-settlement capability because it is the newest of the shared keys: a - * server that has it has all of them. Older servers drop unknown keys on - * write, so a mismatch against them could never clear, and their decoded - * defaults must not be treated as real values. - */ -function supportsSharedSettings(environment: EnvironmentPresentation): boolean { - return ( - environment.connection.phase === "connected" && - environment.serverConfig?.environment.capabilities.threadAutoSettlement === true - ); -} - /** Environments that can receive a shared settings write right now. */ -function useConnectedEnvironmentIds(): ReadonlyArray { +function useSharedSettingsSyncTargetIds(): ReadonlyArray { const { environments } = useEnvironments(); return useMemo( () => - environments.filter(supportsSharedSettings).map((environment) => environment.environmentId), + environments + .filter(supportsSharedSettingsSync) + .map((environment) => environment.environmentId), [environments], ); } @@ -361,16 +346,16 @@ function useConnectedEnvironmentIds(): ReadonlyArray { * * Server keys are optimistically patched in atom-backed server state, then * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`) - * are written to every connected environment, not only the target, so a user - * preference does not silently drift between machines. Client keys go through - * client persistence. + * are written to every eligible sync target, not only the selected target, so + * a user preference does not silently drift between machines. Client keys go + * through client persistence. */ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { const persistServerSettings = useAtomCommand( serverEnvironment.updateSettings, "server settings update", ); - const connectedEnvironmentIds = useConnectedEnvironmentIds(); + const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); @@ -395,7 +380,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { } } if (Object.keys(sharedPatch).length > 0) { - const targets = new Set(connectedEnvironmentIds); + const targets = new Set(sharedSettingsSyncTargetIds); if (environmentId) { targets.add(environmentId); } @@ -417,14 +402,14 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { }); } }, - [connectedEnvironmentIds, environmentId, persistServerSettings], + [environmentId, persistServerSettings, sharedSettingsSyncTargetIds], ); return updateSettings; } /** - * Connected environments whose shared settings differ from the primary's, + * Shared-settings sync targets whose values differ from the primary's, * plus an action that writes the primary's values to all of them. Drift * happens when an environment was offline during an edit or was changed by * an older client. @@ -437,7 +422,7 @@ export function useSharedSettingsSync() { // must never push defaults over real values. Same for a primary too old to // hold the shared keys: its decoded defaults are not a source of truth. const primarySettings = - primaryEnvironment !== null && supportsSharedSettings(primaryEnvironment) + primaryEnvironment !== null && supportsSharedSettingsSync(primaryEnvironment) ? (primaryEnvironment.serverConfig?.settings ?? null) : null; const { environments } = useEnvironments(); @@ -454,7 +439,7 @@ export function useSharedSettingsSync() { environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - connected: supportsSharedSettings(environment), + syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, })), }), diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 37f8c60ac114..7ad971ae745d 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -90,9 +90,10 @@ A turn is complete when its session leaves `running` status, projected by does not define turn end. Thread settlement is server-owned. Each server's own settings control PR and inactivity -settlement. Those keys are user preferences, so clients write them to every connected environment -(`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and warn -when a connected environment drifts. +settlement. Those keys are user preferences, so clients write them to every shared-settings sync +target (`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and +warn when another target drifts. A target must have an active connection and advertise the +`threadAutoSettlement` capability, which signals that the server can hold every shared key. [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and once per minute, including when no client is connected. It dispatches the guarded internal `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 3bbdc6888fdd..204678eb1d2f 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -22,11 +22,13 @@ available, the inactivity rule still applies. A manual un-settle also keeps the sorts by the moment you settled it. A thread that settled on its own sorts by its last message or turn, not by when the server noticed it was inactive. -Change these rules in **Settings > General**. The change is written to every environment you are -connected to at that moment. An environment that is offline keeps its old value. When a connected -environment holds a different value, **Settings > General** shows a warning that names it. Choose -**Apply to all** to write your current values to every connected environment. The same applies to -the new-thread workspace mode and the source control writing style. +Change these rules in **Settings > General**. The change is written to every connected environment +whose server supports shared settings. An environment that is offline or needs a server update +keeps its old value and does not appear in mismatch warnings. When a connected environment whose +server supports shared settings holds a different value, **Settings > General** shows a warning +that names it. Choose **Apply to all** to write your current values to the environments named in +the warning. The same applies to the new-thread workspace mode and the source control writing +style. A settings change affects future settlement and does not reopen a settled thread. Settings saved by older clients on one device no longer control this behavior. diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index dbdf651180d3..8c46a9f33579 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -5,12 +5,36 @@ import { findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, + supportsSharedSettingsSync, } from "./sharedSettings.ts"; const primaryId = EnvironmentId.make("env-primary"); const laptopId = EnvironmentId.make("env-laptop"); const boxId = EnvironmentId.make("env-box"); +describe("supportsSharedSettingsSync", () => { + it("accepts only connected servers that advertise the shared-settings capability", () => { + expect( + supportsSharedSettingsSync({ + connection: { phase: "connected" }, + serverConfig: { environment: { capabilities: { threadAutoSettlement: true } } }, + }), + ).toBe(true); + expect( + supportsSharedSettingsSync({ + connection: { phase: "connected" }, + serverConfig: { environment: { capabilities: {} } }, + }), + ).toBe(false); + expect( + supportsSharedSettingsSync({ + connection: { phase: "reconnecting" }, + serverConfig: { environment: { capabilities: { threadAutoSettlement: true } } }, + }), + ).toBe(false); + }); +}); + describe("splitSharedServerPatch", () => { it("routes preference keys to the shared patch and machine keys to the local patch", () => { const { sharedPatch, localPatch } = splitSharedServerPatch({ @@ -38,17 +62,27 @@ describe("pickSharedServerSettings", () => { describe("findSharedSettingsMismatches", () => { const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; - it("lists connected environments whose shared settings differ", () => { + it("lists sync-eligible environments whose shared settings differ", () => { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: primaryId, primarySettings, environments: [ - { environmentId: primaryId, label: "Desktop", connected: true, settings: primarySettings }, - { environmentId: laptopId, label: "Laptop", connected: true, settings: primarySettings }, + { + environmentId: primaryId, + label: "Desktop", + syncEligible: true, + settings: primarySettings, + }, + { + environmentId: laptopId, + label: "Laptop", + syncEligible: true, + settings: primarySettings, + }, { environmentId: boxId, label: "Remote Box", - connected: true, + syncEligible: true, settings: DEFAULT_SERVER_SETTINGS, }, ], @@ -64,7 +98,7 @@ describe("findSharedSettingsMismatches", () => { { environmentId: boxId, label: "Remote Box", - connected: true, + syncEligible: true, settings: { ...primarySettings, enableAgentBrowserAccess: false }, }, ], @@ -74,7 +108,12 @@ describe("findSharedSettingsMismatches", () => { it("reports nothing until the primary environment's settings are loaded", () => { const environments = [ - { environmentId: boxId, label: "Remote Box", connected: true, settings: primarySettings }, + { + environmentId: boxId, + label: "Remote Box", + syncEligible: true, + settings: primarySettings, + }, ]; expect( findSharedSettingsMismatches({ primaryEnvironmentId: null, primarySettings, environments }), @@ -88,7 +127,7 @@ describe("findSharedSettingsMismatches", () => { ).toEqual([]); }); - it("skips offline environments and environments without a loaded config", () => { + it("skips ineligible environments and environments without a loaded config", () => { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: primaryId, primarySettings, @@ -96,10 +135,10 @@ describe("findSharedSettingsMismatches", () => { { environmentId: laptopId, label: "Laptop", - connected: false, + syncEligible: false, settings: DEFAULT_SERVER_SETTINGS, }, - { environmentId: boxId, label: "Remote Box", connected: true, settings: null }, + { environmentId: boxId, label: "Remote Box", syncEligible: true, settings: null }, ], }); expect(mismatches).toEqual([]); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 35fa4adb46bf..f3236ef2035a 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -4,14 +4,21 @@ * Every server keeps its own `settings.json`, but some keys are user * preferences that only live on the server because the server has to act on * them (auto-settlement runs with no client attached). A user does not want - * those to differ per machine. Clients write these keys to every connected - * environment, and warn when a connected environment still holds a different - * value so the user can push their current value out. + * those to differ per machine. Clients write these keys to every shared-settings + * sync target, and warn when another target still holds a different value so + * the user can push their current value out. */ -import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; +import type { + EnvironmentId, + ExecutionEnvironmentCapabilities, + ServerSettings, + ServerSettingsPatch, +} from "@t3tools/contracts"; import * as Equal from "effect/Equal"; import * as Struct from "effect/Struct"; +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; + /** Server keys that hold a user preference rather than machine config. */ export const SHARED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleAfterDays", @@ -50,18 +57,37 @@ export function pickSharedServerSettings(settings: ServerSettings): ServerSettin return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); } +/** + * Whether an environment can participate in shared-settings sync right now. + * Auto-settlement is the newest feature backed by a shared key, so a server + * advertising `threadAutoSettlement` can hold every shared key. + */ +export function supportsSharedSettingsSync(environment: { + readonly connection: { readonly phase: EnvironmentConnectionPhase }; + readonly serverConfig: { + readonly environment: { + readonly capabilities: Pick; + }; + } | null; +}): boolean { + return ( + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.threadAutoSettlement === true + ); +} + export interface SharedSettingsEnvironment { readonly environmentId: EnvironmentId; readonly label: string; - readonly connected: boolean; + readonly syncEligible: boolean; readonly settings: ServerSettings | null; } /** - * Connected environments whose shared settings differ from the primary - * environment's. Offline environments are skipped: nothing can be read from - * or written to them, and the warning would never clear. With no primary - * settings loaded there is nothing to compare against, so nothing is + * Shared-settings sync targets whose values differ from the primary + * environment's. Other environments are skipped: nothing can be read from or + * written to them, or their server cannot hold every shared key. With no + * primary settings loaded there is nothing to compare against, so nothing is * reported. Callers must pass the real loaded settings, never a default * fallback, or "apply to all" would push defaults over real values. */ @@ -77,7 +103,7 @@ export function findSharedSettingsMismatches(input: { return input.environments.flatMap((environment) => { if ( environment.environmentId === input.primaryEnvironmentId || - !environment.connected || + !environment.syncEligible || environment.settings === null ) { return []; From 2120fbc185737b71f09de020b95224f9e636d1e5 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:06:09 +0530 Subject: [PATCH 008/106] fix(web): avoid duplicate Antigravity install status (#9419) --- .../settings/ProviderSetupSection.test.tsx | 36 ++++++++++++++++++- .../settings/ProviderSetupSection.tsx | 30 ++++++++-------- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSetupSection.test.tsx b/apps/web/src/components/settings/ProviderSetupSection.test.tsx index aea1238e423d..7e4c2695821a 100644 --- a/apps/web/src/components/settings/ProviderSetupSection.test.tsx +++ b/apps/web/src/components/settings/ProviderSetupSection.test.tsx @@ -1,4 +1,4 @@ -import type { FunctionComponent, ReactElement } from "react"; +import { isValidElement, type FunctionComponent, type ReactElement } from "react"; import { EnvironmentId, ProviderDriverKind, @@ -142,6 +142,23 @@ function button(view: unknown, label: string) { ); } +function countElements( + node: unknown, + predicate: (element: ReactElement>) => boolean, +): number { + if (Array.isArray(node)) { + return node.reduce((total, child) => total + countElements(child, predicate), 0); + } + if (!isValidElement>(node)) return 0; + return ( + Number(predicate(node)) + + Object.values(node.props).reduce( + (total, value) => total + countElements(value, predicate), + 0, + ) + ); +} + function click(view: unknown, label: string) { const target = button(view, label); if (!target) throw new Error(`Missing button: ${label}`); @@ -285,6 +302,23 @@ describe("Antigravity setup", () => { await flushPromises(); }); + it("shows a repeated runtime status message only once", () => { + setup.installation = { + ...setup.installation!, + operationId: "install-1", + phase: "verifying", + message: "Checking the downloaded runtime.", + }; + + const view = renderSetup(); + expect( + countElements( + view, + (element) => element.props.children === "Checking the downloaded runtime.", + ), + ).toBe(1); + }); + it("removes an owned damaged runtime only after confirmation", async () => { setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); setup.installation = { diff --git a/apps/web/src/components/settings/ProviderSetupSection.tsx b/apps/web/src/components/settings/ProviderSetupSection.tsx index bbc700762483..49ad14cddd92 100644 --- a/apps/web/src/components/settings/ProviderSetupSection.tsx +++ b/apps/web/src/components/settings/ProviderSetupSection.tsx @@ -169,6 +169,20 @@ function ProviderSetupActions({ const authorizationUrl = auth?.phase === "waiting" ? auth.authorizationUrl : null; const queryError = authQuery.error ?? installQuery.error; const actionsDisabled = pendingLabel !== null || queryError !== null; + const installationStatusMessage = + installation?.phase === "downloading" + ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)} MB${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)} MB`}.` + : installation?.phase === "extracting" + ? "Extracting Antigravity." + : installation?.phase === "verifying" + ? "Checking the downloaded runtime." + : installed + ? "Antigravity is installed." + : usesCustomBinary + ? enabled + ? "The configured Antigravity runtime is unavailable." + : "The configured Antigravity runtime has not been checked." + : "Install the official Antigravity runtime before signing in."; async function runCommand( label: string, @@ -252,19 +266,7 @@ function ProviderSetupActions({

Runtime

- {installation?.phase === "downloading" - ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)} MB${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)} MB`}.` - : installation?.phase === "extracting" - ? "Extracting Antigravity." - : installation?.phase === "verifying" - ? "Checking the downloaded runtime." - : installed - ? "Antigravity is installed." - : usesCustomBinary - ? enabled - ? "The configured Antigravity runtime is unavailable." - : "The configured Antigravity runtime has not been checked." - : "Install the official Antigravity runtime before signing in."} + {installationStatusMessage}

{installation?.phase === "downloading" && installation.totalBytes !== null && @@ -276,7 +278,7 @@ function ProviderSetupActions({ max={installation.totalBytes} /> ) : null} - {installation?.message ? ( + {installation?.message && installation.message !== installationStatusMessage ? (

{installation.message}

) : null} {usesCustomBinary ? ( From d4ba2a1f11498eae9683f6dc95a08b1c17c29765 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 13:29:38 -0400 Subject: [PATCH 009/106] fix(composer): mute fast icon when collapsed (#9451) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/chat/TraitsPicker.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index e21d3dd0f373..5feca2919684 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -592,7 +592,11 @@ export const TraitsPicker = memo(function TraitsPicker({ size={size} className={cn( "fill-current opacity-80", - provider === "claudeAgent" ? "text-[#d97757]" : "text-foreground", + size === "xs" + ? "text-current" + : provider === "claudeAgent" + ? "text-[#d97757]" + : "text-foreground", )} /> Fast mode on From 21b9dda5afb00a33e228a68d2ccc885bba7285dc Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 13:30:07 -0400 Subject: [PATCH 010/106] fix(web): unify skeleton loading animations on one pulse (#9448) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/PullRequestGhosts.tsx | 26 +++++++++------ apps/web/src/components/ui/skeleton.tsx | 5 +-- apps/web/src/components/usage/UsagePage.tsx | 32 ++++++++++--------- apps/web/src/index.css | 21 +++--------- 4 files changed, 39 insertions(+), 45 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 38a3ab70d642..2ae79c063c3c 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -3,11 +3,9 @@ * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing * on one composited layer. Diff loading uses the shared diff-panel skeleton instead. * - * Deliberately not the app's shimmer skeleton. The sweep is a `transform` animation per bar — - * compositor-safe, but a layer for every bar on screen — and its white highlight over the - * near-white `muted` base all but disappears in light mode. Here one `animate-ghost-pulse` on the - * container is a single opacity animation however many bars sit under it, and the bars take - * their tone from `muted-foreground` at low alpha, which reads on both themes. + * The bars share the app-wide `Skeleton` tone (`muted-foreground` at low alpha, which reads on + * both themes) and the single `animate-skeleton` pulse, applied once on the container so any + * number of bars costs one opacity animation. */ import { cn } from "~/lib/utils"; @@ -32,7 +30,7 @@ export function PullRequestListGhost({
{caption ? (

{caption}

@@ -67,7 +65,7 @@ export function PullRequestDetailGhost() {
@@ -160,7 +158,11 @@ export function PullRequestDetailGhost() { /** People-shaped: an avatar and a name, in the reviewer picker's own row height. */ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { return ( -
+
{Array.from({ length: rows }, (_, index) => (
@@ -174,7 +176,11 @@ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { /** The timeline's own shape: dots on the rail, a line and a date to each. */ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) { return ( -
+
{Array.from({ length: rows }, (_, index) => (
@@ -194,7 +200,7 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
{Array.from({ length: rows }, (_, index) => (
diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx index fb79a7d7744b..0d6e12eaa580 100644 --- a/apps/web/src/components/ui/skeleton.tsx +++ b/apps/web/src/components/ui/skeleton.tsx @@ -3,10 +3,7 @@ import { cn } from "~/lib/utils"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7474bb9d6120..9e4838c0de8b 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -23,6 +23,7 @@ import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; +import { Skeleton } from "../ui/skeleton"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { WorkspaceBreadcrumb, @@ -585,8 +586,9 @@ function UsageDeviceStrip({ } /** - * Static stand-in with the loaded page's shape. No shimmer; blocks fill in - * exactly once when the last device answers. + * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it + * breathes with the same `animate-skeleton` pulse as every other loading state. + * Blocks fill in exactly once when the last device answers. */ function UsageSkeleton() { return ( @@ -594,29 +596,29 @@ function UsageSkeleton() {
-
-
+ +
{PROVIDER_ORDER.map((provider) => (
- - -
+ + + -
+
-
+
))}
-
+
-
-
+ +
@@ -628,7 +630,7 @@ function UsageSkeleton() { (label) => (
{label} -
+
), )} @@ -638,9 +640,9 @@ function UsageSkeleton() {

Breakdown

-
+
-
+
); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 94681211fe9c..961766110647 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -145,11 +145,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil @theme inline { --color-zinc-25: oklch(99.2% 0 0); - --animate-skeleton: skeleton 2s infinite linear; + --animate-skeleton: skeleton 2.4s infinite; /* Duty-cycled indicator animations: long holds with stepped ramps, so the compositor updates discrete frames instead of every vsync. */ --animate-status-pulse: status-pulse 2s infinite; - --animate-ghost-pulse: ghost-pulse 2.4s infinite; --animate-status-ping: status-ping 2s infinite; --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); @@ -207,20 +206,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); @keyframes skeleton { - /* Transform-only so the highlight sweep stays on the compositor, then a - long hold with the band parked off-screen instead of a constant shimmer. */ - 0% { - transform: translateX(-100%); - } - 60%, - 100% { - transform: translateX(100%); - } - } - @keyframes ghost-pulse { - /* The loading ghosts' breath. Stepped like the status indicators, so however many bars a - ghost holds, the compositor draws a handful of discrete frames per cycle rather than one - per vsync — which on a 120Hz display is the difference between ~14 and ~288 updates. */ + /* The single loading-bar breath used by every skeleton: one opacity pulse + per container, stepped so however many bars sit under it, the compositor + draws a handful of discrete frames per cycle rather than one per vsync — + which on a 120Hz display is the difference between ~14 and ~288 updates. */ 0%, 42% { opacity: 1; From 645d58547d282eb2aaf6c48e5907625fc112386a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 13:47:01 -0400 Subject: [PATCH 011/106] fix(web): prioritize authored pull requests (#9453) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/pullRequestList.logic.test.ts | 66 +++++++++++++++++-- .../pullRequest/pullRequestList.logic.ts | 46 ++++++++++++- apps/web/src/routes/_chat.pull-requests.tsx | 52 +++------------ docs/user/source-control.md | 7 +- 4 files changed, 118 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 08757b275b7e..b3fc1da8d0a3 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -23,6 +23,7 @@ import { rankPullRequestMatches, rankPullRequestsByMergeReadiness, scorePullRequestMatch, + sortPullRequestGroups, retainVisiblePullRequestStatsBatches, withDiffStat, resolveProjectScope, @@ -328,8 +329,8 @@ describe("pull request grouping", () => { VIEWERS, ); expect(groups.map((group) => [group.key, group.entries.length])).toEqual([ - ["reviewRequested", 1], ["authored", 1], + ["reviewRequested", 1], ]); }); @@ -753,6 +754,63 @@ describe("default merge-readiness ranking", () => { rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), ).toEqual([2, 1, 3]); }); + + it("keeps authored work first and ranks each group by readiness", () => { + const authoredWaiting = entry({ number: 1, checksState: "pending" }); + const authoredReady = entry({ + number: 2, + checksState: "passing", + reviewDecision: "approved", + }); + const otherReady = entry({ + number: 3, + checksState: "passing", + reviewDecision: "approved", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [authoredWaiting, authoredReady] }, + { key: "others", label: "Others", entries: [otherReady] }, + ], + "ready", + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted.flatMap((group) => group.entries).map((row) => row.number)).toEqual([2, 1, 3]); + }); + + it.each([ + ["updated", [1, 2]], + ["newest", [2, 1]], + ["oldest", [1, 2]], + ["largest", [1, 2]], + ["smallest", [2, 1]], + ] as const)("keeps authored first while applying the %s sort inside groups", (sort, order) => { + const olderLarger = entry({ + number: 1, + additions: 20, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-08-01T00:00:00Z", + }); + const newerSmaller = entry({ + number: 2, + additions: 2, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [olderLarger, newerSmaller] }, + { key: "others", label: "Others", entries: [entry({ number: 3 })] }, + ], + sort, + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted[0]!.entries.map((row) => row.number)).toEqual(order); + }); }); describe("line counts that arrive after the rows", () => { @@ -843,9 +901,9 @@ describe("partitioning with the hosts' own priority reads", () => { updatedAt: "2026-06-02T00:00:00Z", }); const groups = partitionPullRequestsWithPriority([], [both], [both, requestedOlder, requested]); - expect(groups.map((group) => group.key)).toEqual(["reviewRequested", "authored"]); - expect(groups[0]!.entries.map((item) => item.number)).toEqual([2, 3]); - expect(groups[1]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups.map((group) => group.key)).toEqual(["authored", "reviewRequested"]); + expect(groups[0]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 3]); }); it("lets the feed's copy of a partitioned row replace the partition's", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 576f771e7e7e..af1bd6ab4fbe 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -18,6 +18,9 @@ import type { PullRequestListState, } from "@t3tools/contracts"; +import { toSortableTimestamp } from "../../lib/threadSort"; +import type { PullRequestListSort } from "./pullRequestListPreferences"; + /** * A listed change request with the environment that read it. Nothing on a row says which machine * it came from, and the page unions every connected one — so acting on a row, refreshing it, or @@ -415,7 +418,7 @@ export function groupPullRequestsByInvolvement( buckets.others.push(entry); } } - return (["reviewRequested", "authored", "others"] as const) + return (["authored", "reviewRequested", "others"] as const) .filter((key) => buckets[key].length > 0) .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] })); } @@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority right.updatedAt.localeCompare(left.updatedAt); return ( [ - { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) }, + { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "others", entries: others }, ] as const ) @@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness( + groups: ReadonlyArray>, + sort: PullRequestListSort, + searchText: string, + hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, +): ReadonlyArray> { + const sortWithinGroups = (rank: (entries: ReadonlyArray) => ReadonlyArray) => + groups.map((group) => ({ ...group, entries: rank(group.entries) })); + + if (sort === "ready") { + return searchText.trim().length === 0 + ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) + : groups; + } + if (sort === "updated") return groups; + + const timestamp = (entry: Entry) => + toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; + return sortWithinGroups((entries) => + entries.toSorted((left, right) => { + if (sort === "newest" || sort === "oldest") { + const leftCreated = toSortableTimestamp(left.createdAt); + const rightCreated = toSortableTimestamp(right.createdAt); + const measured = Number(rightCreated !== null) - Number(leftCreated !== null); + const dated = (leftCreated ?? 0) - (rightCreated ?? 0); + return ( + measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) + ); + } + const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); + const sized = left.additions + left.deletions - (right.additions + right.deletions); + return ( + measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) + ); + }), + ); +} + /** * A row with the line counts that arrived after it did. Only where the host left them out — a * listing that carried them is not second-guessed — and only where they have arrived, since a row diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 09701423fae0..29e6b05c0b19 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -58,7 +58,7 @@ import { pullRequestEntryKey, pullRequestEntryViewer, rankPullRequestMatches, - rankPullRequestsByMergeReadiness, + sortPullRequestGroups, pullRequestEnvironmentSetKey, readPullRequestListSnapshot, resolveProjectScope, @@ -120,7 +120,6 @@ import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; -import { toSortableTimestamp } from "../lib/threadSort"; import { pullRequestSurfaceId, selectActiveRightPanelSurface, @@ -1426,51 +1425,16 @@ function PullRequestsRouteView() { ...group, entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)), })); - if (sort === "ready" && typedParsed.text.length === 0) { - return [ - { - key: "others" as const, - label: "", - entries: rankPullRequestsByMergeReadiness( - enriched.flatMap((group) => group.entries), - (entry) => - entry.additions + entry.deletions > 0 || - statsByRow.has(pullRequestDiffStatKey(entry)), - ), - }, - ]; - } // Searching keeps its relevance order and priority groups unless the reader explicitly asks // for another sort. The readiness queue is the default browse order, not a way to bury a // closer text match. - if (sort === "ready" || sort === "updated") return enriched; - const entries = enriched.flatMap((group) => group.entries); - const hasSize = (entry: (typeof entries)[number]) => - entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)); - const timestamp = (entry: (typeof entries)[number]) => - toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; - return [ - { - key: "others" as const, - label: "", - entries: entries.toSorted((left, right) => { - if (sort === "newest" || sort === "oldest") { - const leftCreated = toSortableTimestamp(left.createdAt); - const rightCreated = toSortableTimestamp(right.createdAt); - const measured = Number(rightCreated !== null) - Number(leftCreated !== null); - const dated = (leftCreated ?? 0) - (rightCreated ?? 0); - return ( - measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) - ); - } - const measured = Number(hasSize(right)) - Number(hasSize(left)); - const sized = left.additions + left.deletions - (right.additions + right.deletions); - return ( - measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) - ); - }), - }, - ]; + return sortPullRequestGroups( + enriched, + sort, + typedParsed.text, + (entry) => + entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), + ); }, [groups, sort, statsByRow, typedParsed.text]); const linkedSelection = useMemo( diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 937cf91c9037..5727be12ae86 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -41,9 +41,10 @@ T3 Code works with the platforms your team already uses: - See if your current branch already has an open PR/MR - Open several reviews from the **Pull requests** page as tabs in the right panel -- By default, see passing and approved reviews first, passing reviews awaiting approval next, and - conflicting reviews last. Smaller changes come first within each readiness group, and finished - reviews follow open work when all states are visible. +- Your authored reviews stay at the top and use the selected sort within their group. By default, + see passing and approved reviews first, passing reviews awaiting approval next, and conflicting + reviews last. Smaller changes come first within each readiness group, and finished reviews follow + open work when all states are visible. - Filter the list by author or labels, rank authors by merges in the loaded results, see label and change-size context on each row, and sort the results currently shown by readiness, update time, creation time, or change size. Your filters, search, scope, and sort are restored when you return. From 4e89d74436a167c01f25a6a5283638843398ea3a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 13:53:16 -0400 Subject: [PATCH 012/106] fix(web): make project icons the default (#9457) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/components/ProjectFavicon.test.tsx | 24 ++++++++++---- .../settings/ProjectIconPickerDialog.test.tsx | 8 ++--- .../settings/ProjectIconPickerDialog.tsx | 8 ++--- apps/web/src/projectIconModel.test.ts | 7 ++--- apps/web/src/projectIconModel.ts | 31 +------------------ docs/user/project-settings.md | 2 +- 6 files changed, 31 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 557f4d722adc..bfb5487031b7 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -115,28 +115,40 @@ describe("ProjectFavicon", () => { testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; }); - it("shows a project-name emoji when no favicon exists", () => { + it("shows a project-name icon when no favicon exists", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace/analytics-db", projectName: "analytics-db", - }) as ReactElement<{ readonly emoji?: string }>; + }) as ReactElement<{ + readonly colorClassName?: string; + readonly emoji?: string; + readonly icon?: ComponentType<{ className?: string }>; + }>; - expect(element.props.emoji).toBe("🗄️"); + expect(element.props.icon).toBeDefined(); + expect(element.props.emoji).toBeUndefined(); + expect(element.props.colorClassName).toContain("text-cyan-600"); }); - it("chooses a deterministic semantic emoji", () => { + it("chooses a deterministic semantic icon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace/agent-runtime", projectName: "agent-runtime", - }) as ReactElement<{ readonly emoji?: string }>; + }) as ReactElement<{ + readonly colorClassName?: string; + readonly emoji?: string; + readonly icon?: ComponentType<{ className?: string }>; + }>; - expect(element.props.emoji).toBe("🤖"); + expect(element.props.icon).toBeDefined(); + expect(element.props.emoji).toBeUndefined(); + expect(element.props.colorClassName).toContain("text-violet-600"); }); it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx index 9098b359d1ea..2280395ecf40 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx @@ -39,13 +39,13 @@ vi.mock("../ui/toggle-group", () => ({ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; describe("ProjectIconPickerDialog", () => { - it("shows emoji first and selects it for an automatic project", () => { + it("shows icons first and selects them for an automatic project", () => { const markup = renderToStaticMarkup( {}} onSelect={() => {}} />, ); - expect(markup).toContain('data-current="emoji"'); - expect(markup.indexOf(">Emoji<")).toBeLessThan(markup.indexOf(">Icons<")); - expect(markup).toContain("Or paste any emoji"); + expect(markup).toContain('data-current="lucide"'); + expect(markup.indexOf(">Icons<")).toBeLessThan(markup.indexOf(">Emoji<")); + expect(markup).toContain('aria-label="Icon color"'); }); }); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx index 4ecdb0f653c5..7fce7a4fbb5b 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -45,7 +45,7 @@ export function ProjectIconPickerDialog({ readonly onSelect: (icon: ProjectIconOverride) => void; }) { const [mode, setMode] = useState<"lucide" | "emoji">( - current?.kind === "lucide" ? "lucide" : "emoji", + current?.kind === "emoji" ? "emoji" : "lucide", ); const [iconName, setIconName] = useState( current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, @@ -60,7 +60,7 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { - setMode(current?.kind === "lucide" ? "lucide" : "emoji"); + setMode(current?.kind === "emoji" ? "emoji" : "lucide"); setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR); setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); @@ -84,7 +84,7 @@ export function ProjectIconPickerDialog({ Choose project icon - Pick an emoji, or choose any Lucide icon and color. + Pick any Lucide icon and color, or use an emoji. - Emoji Icons + Emoji {mode === "lucide" ? ( diff --git a/apps/web/src/projectIconModel.test.ts b/apps/web/src/projectIconModel.test.ts index a2d6842d9695..77e0ca2acb19 100644 --- a/apps/web/src/projectIconModel.test.ts +++ b/apps/web/src/projectIconModel.test.ts @@ -19,18 +19,17 @@ describe("selectProjectIcon", () => { expect(selectProjectIcon("", "C:\\work\\mobile-app").icon).toBe("mobile"); }); - it("uses emoji for automatic project icons", () => { + it("uses Lucide icons for automatic project icons", () => { expect(selectProjectIcon("agent-runtime", "/workspace/agent-runtime")).toEqual({ - kind: "emoji", + kind: "lucide", icon: "ai", - emoji: "🤖", }); }); it("gives unknown names a stable generic icon", () => { const icon = selectProjectIcon("mercury", "/workspace/mercury"); - expect(icon.kind).toBe("emoji"); + expect(icon.kind).toBe("lucide"); expect(PROJECT_ICON_NAMES).toContain(icon.icon); expect(selectProjectIcon("mercury", "/elsewhere/mercury")).toEqual(icon); }); diff --git a/apps/web/src/projectIconModel.ts b/apps/web/src/projectIconModel.ts index 6f32e6381010..8614ff2a41ff 100644 --- a/apps/web/src/projectIconModel.ts +++ b/apps/web/src/projectIconModel.ts @@ -131,31 +131,6 @@ const GENERIC_PROJECT_ICONS: ReadonlyArray = [ "layers", ]; -const PROJECT_ICON_EMOJIS: Record = { - ai: "🤖", - book: "📚", - braces: "🧩", - circuit: "⚡", - cloud: "☁️", - code: "💻", - database: "🗄️", - desktop: "🖥️", - "folder-code": "🛠️", - game: "🎮", - image: "🖼️", - layers: "✨", - mobile: "📱", - music: "🎵", - package: "📦", - security: "🔒", - server: "⚙️", - shopping: "🛍️", - terminal: "⌨️", - test: "🧪", - video: "🎬", - web: "🌐", -}; - const projectIconCache = new Map(); function projectNameTokens(value: string): ReadonlyArray { @@ -211,11 +186,7 @@ export function selectProjectIcon( const iconName = bestIcon ?? GENERIC_PROJECT_ICONS[stableIndex(cacheKey, GENERIC_PROJECT_ICONS.length)]!; - const icon: ProjectIconSelection = { - kind: "emoji", - icon: iconName, - emoji: PROJECT_ICON_EMOJIS[iconName], - }; + const icon: ProjectIconSelection = { kind: "lucide", icon: iconName }; projectIconCache.set(cacheKey, icon); return icon; } diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 047f8baf5466..ef15a041c137 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -2,7 +2,7 @@ T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon paths, and icon links in project HTML files. If it does not find an image, it chooses a built-in -emoji from the project name. +icon from the project name. To choose a different icon or emoji: From c78f05a45e1c2b274d8b0ab2b4d2d88b4767c968 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 14:03:42 -0400 Subject: [PATCH 013/106] fix(server): reuse pr state when settling threads (#9459) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../ThreadSettlementReactor.test.ts | 42 +++++++++++++++++++ .../orchestration/ThreadSettlementReactor.ts | 34 +++++++++++---- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 382d5812c1a8..f6264200d2f5 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -17,6 +17,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; @@ -134,6 +135,7 @@ interface HarnessOptions { readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; + readonly existingWorktreePaths?: ReadonlyArray; readonly onDispatch?: ( command: AutoSettleCommand, ) => Effect.Effect; @@ -235,6 +237,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Layer.succeed(ServerSettingsService, serverSettings), Layer.succeed(ServerActivation, Deferred.await(activation)), Layer.succeed(Crypto.Crypto, testCrypto), + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), + }), ); return { @@ -647,6 +652,43 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("looks up the branch pull request from a thread's live worktree", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("live-worktree", { + branch: "feature/live", + worktreePath: "/workspace/project-root/.worktrees/live", + }), + makeThread("deleted-worktree", { + branch: "feature/deleted", + worktreePath: "/workspace/project-root/.worktrees/deleted", + }), + ], + [makeProject(PROJECT_ID, "/workspace/project-root")], + ), + existingWorktreePaths: ["/workspace/project-root/.worktrees/live"], + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + new Set(yield* Ref.get(fixture.branchCalls)), + new Set([ + { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" }, + { cwd: "/workspace/project-root", branch: "feature/deleted" }, + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("carries the snapshot guard and survives a stale dispatch rejection", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 9dd7cd5e76fd..9867a855a85e 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -5,6 +5,7 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; @@ -37,6 +38,7 @@ export const make = Effect.gen(function* () { const git = yield* GitManager.GitManager; const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* ( mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, @@ -54,6 +56,26 @@ export const make = Effect.gen(function* () { mergedPullRequest.repository.toLowerCase() && thread.linkedPullRequest.number === mergedPullRequest.number)), ); + // Use the same cwd as the sidebar so both paths share GitManager's PR cache. + const lookupCwdByThreadId = new Map(); + yield* Effect.forEach( + candidates, + (thread) => + Effect.gen(function* () { + const project = projects.get(thread.projectId); + if (project === undefined || thread.linkedPullRequest != null) return; + const worktreeExists = + thread.worktreePath !== null && + (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); + lookupCwdByThreadId.set( + thread.id, + worktreeExists && thread.worktreePath !== null + ? thread.worktreePath + : project.workspaceRoot, + ); + }), + { concurrency: 8, discard: true }, + ); const lookupKey = (thread: (typeof candidates)[number]) => { if (thread.linkedPullRequest != null) { return JSON.stringify([ @@ -64,11 +86,9 @@ export const make = Effect.gen(function* () { ]); } if (thread.branch === null) return JSON.stringify(["none", thread.id]); - const project = projects.get(thread.projectId); + const cwd = lookupCwdByThreadId.get(thread.id); return JSON.stringify( - project === undefined - ? ["missing-project", thread.id] - : ["branch", project.workspaceRoot, thread.branch], + cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch], ); }; const groups = Map.groupBy(candidates, lookupKey); @@ -100,11 +120,11 @@ export const make = Effect.gen(function* () { } satisfies SettlementPullRequest; } if (thread.branch === null) return null; - const project = projects.get(thread.projectId); - if (project === undefined) { + const cwd = lookupCwdByThreadId.get(thread.id); + if (cwd === undefined) { return yield* Effect.die(new Error("thread project not found")); } - return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch }); + return yield* git.branchPullRequest({ cwd, branch: thread.branch }); }); yield* Effect.forEach( From 8bd544cdfd22aa38ab82bc1adc0b851755a299ef Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 14:04:10 -0400 Subject: [PATCH 014/106] fix(web): keep agent images collapsed (#9460) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/chat/MessagesTimeline.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c85f85cac122..2b0219012971 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3049,16 +3049,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); const displayText = !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText; + const viewedImagePath = workEntryViewedImagePath(workEntry); const canExpand = (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || Boolean( workEntryRawCommand(workEntry) || workEntry.command?.trim() || workEntry.detail?.trim() || - workEntry.changedFiles?.length, + workEntry.changedFiles?.length || + viewedImagePath, ); const expandedBody = expanded ? buildToolCallExpandedBody(workEntry, workspaceRoot) : null; - const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = viewedImagePath && threadRef ? resolveViewedImageAsset(viewedImagePath, { @@ -3159,7 +3160,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
- {viewedImage && threadRef ? ( + {expanded && viewedImage && threadRef ? (
Date: Thu, 3 Sep 2026 11:09:51 -0700 Subject: [PATCH 015/106] fix(web): banner buttons no longer expand the resting composer (#9452) --- apps/web/src/components/chat/ChatComposer.tsx | 8 +++----- .../src/components/chat/composerEventScope.test.ts | 12 ++++++++++++ apps/web/src/components/chat/composerEventScope.ts | 10 ++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b24fb16c523b..c38dd1cbe054 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -60,6 +60,7 @@ import { } from "./composerMentionDrag"; import { composerFloatingLayerProps, + isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, } from "./composerEventScope"; @@ -4542,6 +4543,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPointerDownCapture={(event) => { const target = event.target; if (isInsideRestingComposerControlScope(target)) return; + if (isInsideCollapsedComposerControls(target)) return; if (!(target instanceof Element)) return; const isInteractive = Boolean( target.closest('button, a, input, select, [role="button"], [role="menuitem"]'), @@ -4564,11 +4566,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (composerControlsInStrip && isInsideRestingComposerControlScope(activeElement)) { return; } - if ( - isComposerCollapsedMobile && - activeElement instanceof HTMLElement && - activeElement.closest('[data-chat-composer-collapsed-controls="true"]') - ) { + if (isInsideCollapsedComposerControls(activeElement)) { return; } // Focus returning from another window or tab lands on the element diff --git a/apps/web/src/components/chat/composerEventScope.test.ts b/apps/web/src/components/chat/composerEventScope.test.ts index 7ca1e396960d..b559009ed43f 100644 --- a/apps/web/src/components/chat/composerEventScope.test.ts +++ b/apps/web/src/components/chat/composerEventScope.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { + isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, } from "./composerEventScope"; @@ -58,4 +59,15 @@ describe("composer event scopes", () => { expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(false); expect(isInsideRestingComposerControlScope(null)).toBe(false); }); + + it("recognizes banner and drawer controls docked above the surface", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement('[data-chat-composer-collapsed-controls="true"]'); + expect(isInsideCollapsedComposerControls(target as unknown as EventTarget)).toBe(true); + expect(isInsideCollapsedComposerControls(new FakeElement(null) as unknown as EventTarget)).toBe( + false, + ); + expect(isInsideCollapsedComposerControls(null)).toBe(false); + }); }); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index 2275fdd21a2e..60aedb096156 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -11,6 +11,16 @@ export function isInsideComposerFloatingLayer(target: EventTarget | null): boole return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; } +// Banners, the approval row, and the tasks badge dock above the surface. A +// pointer or focus landing on one of them acts on that control and must not +// expand a resting or collapsed composer. +export function isInsideCollapsedComposerControls(target: EventTarget | null): boolean { + return ( + target instanceof Element && + target.closest('[data-chat-composer-collapsed-controls="true"]') !== null + ); +} + export function isInsideRestingComposerControlScope(target: EventTarget | null): boolean { return ( target instanceof Element && From d5825e1d2fb1703ced2bbe2661f6a4937dd530bf Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 3 Sep 2026 20:35:49 +0200 Subject: [PATCH 016/106] fix(web): stop clipping the traits chevron on long Codex effort labels (#9433) Co-authored-by: Cursor --- apps/web/src/components/chat/TraitsPicker.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5feca2919684..c48b8eb0f6cf 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -627,11 +627,10 @@ export const TraitsPicker = memo(function TraitsPicker({ } > {isCodexStyle ? ( + // The label truncates itself; clipping the wrapper too would cut off + // the chevron, whose negative end margin overhangs the wrapper edge. {fastModeIcon} {triggerLabel} From 46e8b1a23ab14fa2c128c6b315955d8eb976d85f Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 15:09:54 -0400 Subject: [PATCH 017/106] fix(web): make right panel tabs easier to scroll (#9461) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/RightPanelTabs.tsx | 136 ++++++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5a9356a0fffd..8f1fff8dc728 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -9,6 +9,8 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Bot, ChevronDown, + ChevronLeft, + ChevronRight, FileDiff, Files, GitPullRequest, @@ -170,6 +172,12 @@ type TabContextMenuAction = | "close-to-right" | "close-all"; +const TAB_SCROLL_EDGE_TOLERANCE = 1; + +function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { + return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; +} + /** * Desktop preview tab backing a surface, or null for non-preview surfaces, the * "new browser tab" placeholder, and the web build where no desktop tab exists. @@ -720,6 +728,42 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); + const [tabScrollState, setTabScrollState] = useState({ + hasOverflow: false, + canScrollLeft: false, + canScrollRight: false, + }); + + const updateTabScrollState = useCallback(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollRight = + hasOverflow && + viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE; + setTabScrollState((current) => { + if ( + current.hasOverflow === hasOverflow && + current.canScrollLeft === canScrollLeft && + current.canScrollRight === canScrollRight + ) { + return current; + } + return { hasOverflow, canScrollLeft, canScrollRight }; + }); + }, []); + + const scrollTabs = useCallback((direction: -1 | 1) => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + viewport.scrollBy({ + left: direction * Math.max(120, viewport.clientWidth * 0.75), + behavior: reduceMotion ? "auto" : "smooth", + }); + }, []); const addSurfaceActions = [ { @@ -886,9 +930,49 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); useEffect(() => { + if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [props.activeSurfaceId]); + }, [props.activeSurfaceId, tabScrollState.hasOverflow]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const content = viewport.firstElementChild; + const resizeObserver = new ResizeObserver(updateTabScrollState); + resizeObserver.observe(viewport); + if (content) resizeObserver.observe(content); + viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); + updateTabScrollState(); + + return () => { + resizeObserver.disconnect(); + viewport.removeEventListener("scroll", updateTabScrollState); + }; + }, [updateTabScrollState]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey) return; + let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16; + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth; + if (delta === 0) return; + + const previousScrollLeft = viewport.scrollLeft; + viewport.scrollLeft += delta; + if (viewport.scrollLeft === previousScrollLeft) return; + event.preventDefault(); + updateTabScrollState(); + }; + + viewport.addEventListener("wheel", handleWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", handleWheel); + }, [updateTabScrollState]); return (
@@ -1099,6 +1187,50 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ) : null}
+ {tabScrollState.hasOverflow ? ( +
+ + + + + } + /> + Scroll tabs left + + + + + + } + /> + Scroll tabs right + +
+ ) : null} {props.layoutControls}
From db8d60f486c5fc1a80d01b591a359f0c87f0868c Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:16:28 +0530 Subject: [PATCH 018/106] fix(web): render transparent previews on white (#9463) --- apps/web/src/browser/HostedBrowserWebview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 564a2453b2be..582d5686ec0f 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -315,7 +315,7 @@ export function HostedBrowserWebview(props: { } aria-hidden={active ? undefined : true} className={cn( - "absolute flex overflow-hidden bg-background", + "absolute flex overflow-hidden bg-white", active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm", )} style={{ From de025aa69ffb0ce1a45d30aed25c60454660b62d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 12:52:50 -0700 Subject: [PATCH 019/106] fix(mobile): show loading and syncing in the working pill (#9466) Co-authored-by: Claude Fable 5 --- .../src/features/threads/ThreadComposer.tsx | 24 +----------- .../features/threads/ThreadDetailScreen.tsx | 37 +++++++++++------- .../features/threads/ThreadRouteScreen.tsx | 5 ++- .../threads/floating-working-control.tsx | 38 +++++++++++++++---- 4 files changed, 60 insertions(+), 44 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 89fc66a9375c..b4cdd43deca9 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -110,12 +110,6 @@ export interface ThreadComposerProps { readonly connectionState: RemoteClientConnectionState; readonly connectionError: string | null; readonly environmentLabel: string | null; - /** - * Message sync phase for the selected thread (drives the status pill): - * "loading" = first fetch, nothing to show yet; "syncing" = cached messages - * are on screen while they reconcile with the server. - */ - readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; @@ -225,7 +219,7 @@ export function ComposerSurface(props: { } type ComposerStatusPillState = { - readonly kind: "unavailable" | "reconnecting" | "syncing"; + readonly kind: "unavailable" | "reconnecting"; readonly label: string; }; @@ -233,7 +227,6 @@ function composerConnectionStatus(input: { readonly connectionError: string | null; readonly connectionState: RemoteClientConnectionState; readonly environmentLabel: string | null; - readonly threadSyncPhase?: "loading" | "syncing" | null; }): ComposerStatusPillState | null { const environmentLabel = input.environmentLabel ?? "Environment"; @@ -259,18 +252,6 @@ function composerConnectionStatus(input: { case "available": return { kind: "unavailable", label: `${environmentLabel} is not connected` }; case "connected": - break; - } - - // Connected: the pill is the single loading/sync indicator. One stable - // label per open — "Loading" when starting from scratch, "Syncing" when - // cached messages are already visible. - switch (input.threadSyncPhase) { - case "loading": - return { kind: "syncing", label: "Loading messages..." }; - case "syncing": - return { kind: "syncing", label: "Syncing messages..." }; - default: return null; } } @@ -279,7 +260,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( readonly onPress: () => void; readonly status: ComposerStatusPillState; }) { - const isReconnecting = props.status.kind !== "unavailable"; + const isReconnecting = props.status.kind === "reconnecting"; return ( { if (!props.serverConfig) return null; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 18a51eb834e3..bf1a55a8a6ae 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -74,6 +74,7 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl, + type FloatingWorkingStatus, } from "./floating-working-control"; import { derivePendingUserInputMaxHeight, @@ -301,27 +302,38 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no // data yet → "Loading messages", cached data reconciling → "Syncing". - const threadSyncPhase = (() => { + const threadSyncLabel = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": case "synchronizing": if (contentPresentationKind === "ready") { - return "syncing" as const; + return "Syncing messages..."; } - return contentPresentationKind === "loading" ? ("loading" as const) : null; + return contentPresentationKind === "loading" ? "Loading messages..." : null; default: return null; } })(); - const showWorkingControl = - props.activeWorkStartedAt !== null && - contentPresentationKind === "ready" && - threadSyncPhase === null && - props.connectionStateLabel === "connected" && - props.activePendingApproval === null && - props.activePendingUserInput === null; - const floatingWorkingStartedAt = showWorkingControl ? props.activeWorkStartedAt : null; + // One floating pill above the composer: it reads the sync state while + // messages load, then the working timer once the feed is settled. + const floatingStatus = ((): FloatingWorkingStatus | null => { + if ( + props.connectionStateLabel !== "connected" || + props.activePendingApproval !== null || + props.activePendingUserInput !== null + ) { + return null; + } + if (threadSyncLabel !== null) { + return { kind: "syncing", label: threadSyncLabel }; + } + if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { + return { kind: "working", startedAt: props.activeWorkStartedAt }; + } + return null; + })(); + const showWorkingControl = floatingStatus !== null; const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; @@ -748,7 +760,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread @@ -807,7 +819,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread connectionState={props.connectionStateLabel} connectionError={props.connectionError} environmentLabel={props.environmentLabel} - threadSyncPhase={threadSyncPhase} selectedThread={props.selectedThread} serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 79e898eaa1c9..53eca806cbc7 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -157,8 +157,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { // Render the full thread chrome (header, feed, composer) as soon as the // thread SHELL is known — no blocking on message detail. The feed shows a - // loading placeholder while messages fetch, and the composer's connection - // pill reports connecting/reconnecting/syncing status. + // loading placeholder while messages fetch, the floating pill above the + // composer reports loading/syncing, and the composer's connection pill + // reports connecting/reconnecting status. if (selectedThread !== null && selectedThreadKey === routeThreadKey) { return ; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index bdfa19a9eeaf..a62a3c9d17bc 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,6 +1,6 @@ import { GlassContainer, GlassView } from "expo-glass-effect"; import { useEffect, useState } from "react"; -import { Text as SystemText, View } from "react-native"; +import { ActivityIndicator, Text as SystemText, View } from "react-native"; import Animated, { Easing, FadeIn, @@ -40,9 +40,17 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; +/** + * What the floating pill says. Syncing and working share one element so the + * label swaps in place instead of one pill fading out for another. + */ +export type FloatingWorkingStatus = + | { readonly kind: "working"; readonly startedAt: string } + | { readonly kind: "syncing"; readonly label: string }; + export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; - readonly startedAt: string | null; + readonly status: FloatingWorkingStatus | null; readonly showScrollToEnd: boolean; readonly onScrollToEnd: () => void; }) { @@ -62,7 +70,7 @@ export function FloatingWorkingControl(props: { opacity: separationProgress.value, })); - if (props.startedAt === null && !props.showScrollToEnd) { + if (props.status === null && !props.showScrollToEnd) { return null; } @@ -74,7 +82,7 @@ export function FloatingWorkingControl(props: { entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING} exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING} > - {props.startedAt !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( + {props.status !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( - + - ) : props.startedAt !== null ? ( + ) : props.status !== null ? ( - + + + {props.status.label} + + ); + } + return ; +} + function WorkingDuration(props: { readonly startedAt: string }) { const [nowMs, setNowMs] = useState(() => Date.now()); From 36c4e9cf5c0123e33d65f2af9497ee090404b532 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:23:53 +0200 Subject: [PATCH 020/106] fix(server): keep a/ and b/ prefixes in rendered git patches (#9438) --- .../src/checkpointing/CheckpointStore.test.ts | 31 +++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 7 ++++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 28 +++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 7 +++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index bf332d20d0da..2f46858986aa 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -147,6 +147,37 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { }), ); + it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* git(tmp, ["config", "diff.noprefix", "true"]); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("thread-checkpoint-store-noprefix"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# changed\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + + const diff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + ignoreWhitespace: false, + }); + + expect(diff).toContain("diff --git a/README.md b/README.md"); + }), + ); + it.effect("can hide indentation churn when changes wrap existing lines", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 1ab424347637..08b474cf42da 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,7 +30,11 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + makeGitVcsDriverCore, + PATCH_RENDER_PREFIX_ARGS, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -869,6 +873,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "--no-color", "--no-ext-diff", "--no-textconv", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c0621f2c99d7..8e76413496b7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -819,6 +819,34 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "diff.noprefix", "true"]); + yield* git(cwd, ["config", "diff.mnemonicPrefix", "true"]); + yield* git(cwd, ["checkout", "-b", "feature/noprefix"]); + yield* writeTextFile(cwd, "README.md", "# committed change\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "committed change"]); + yield* writeTextFile(cwd, "README.md", "# dirty change\n"); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: false, + }); + + const workingTree = preview.sources.find((source) => source.kind === "working-tree")?.diff; + const branchRange = preview.sources.find((source) => source.kind === "branch-range")?.diff; + assert.include(workingTree, "diff --git a/README.md b/README.md"); + assert.include(workingTree, "+++ b/untracked.txt"); + assert.include(branchRange, "diff --git a/README.md b/README.md"); + }), + ); + it.effect("loads full file contents for working-tree diff expansion", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f1fb1b7a7b18..3a4a172a6436 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -52,6 +52,10 @@ const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +// Patches the clients render are parsed against git's default a/ and b/ path +// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would +// otherwise leak into the patch and leave every parsed file unnamed. +export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -2205,6 +2209,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, "--", "/dev/null", relativePath, @@ -2257,6 +2262,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), "HEAD", "--", @@ -2293,6 +2299,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${baseRef}...HEAD`, ], From d2b6f3b9296f682c6158b894ab33d98d0c4bfb2b Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:08:12 +0530 Subject: [PATCH 021/106] fix(server): full-access OpenCode threads no longer ask for approvals (#9282) Co-authored-by: Claude Fable 5.1 Co-authored-by: Julius Marminge --- .../provider/Layers/OpenCodeAdapter.test.ts | 207 ++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 71 +++++- docs/internals/providers.md | 6 + 3 files changed, 277 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 7f327cae8fb3..01baf92db73e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -89,6 +89,7 @@ const runtimeMock = { subscribedEvents: [] as Array>, eventSubscribeObserved: null as (() => void) | null, permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, + permissionReplyImplementation: null as (() => Promise) | null, questionReplyCalls: [] as Array<{ requestID: string; answers: ReadonlyArray>; @@ -139,6 +140,7 @@ const runtimeMock = { this.state.subscribedEvents = []; this.state.eventSubscribeObserved = null; this.state.permissionReplyCalls.length = 0; + this.state.permissionReplyImplementation = null; this.state.questionReplyCalls.length = 0; this.state.sessionStatus = "idle"; this.state.sessionStatusFailures = 0; @@ -377,6 +379,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); + if (runtimeMock.state.permissionReplyImplementation) { + await runtimeMock.state.permissionReplyImplementation(); + } }, }, question: { @@ -2623,6 +2628,208 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect.each([ + { + name: "a doom-loop ask on the parent session", + requestId: "per_doom_loop", + sessionID: "http://127.0.0.1:9999/session", + permission: "doom_loop", + patterns: ["bash"], + always: [] as string[], + }, + { + name: "a child-session ask", + requestId: "per_child_full", + sessionID: "ses_child_full", + permission: "read", + patterns: ["/repo/settings.env"], + always: ["/repo/settings.env"], + }, + ])( + "auto-approves $name in full access", + ({ requestId, sessionID, permission, patterns, always }) => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-full-access-${requestId}`); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-child-created", + type: "session.created", + properties: { + sessionID: "ses_child_full", + info: { + id: "ses_child_full", + parentID: "http://127.0.0.1:9999/session", + title: "Child session", + }, + }, + }, + { + id: "evt-permission", + type: "permission.asked", + properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always }, + }, + { + id: "evt-permission-replied", + type: "permission.replied", + properties: { sessionID, requestID: requestId, reply: "once" }, + }, + // The suppressed ask emits nothing, so an empty question serves as a + // sentinel that closes the collected stream once the pump is past it. + { + id: "evt-sentinel-question", + type: "question.asked", + properties: { + id: "que_sentinel", + sessionID: "http://127.0.0.1:9999/session", + questions: [], + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "user-input.requested"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: requestId, reply: "once" }, + ]); + NodeAssert.equal( + events.some((event) => event.type === "request.opened"), + false, + ); + NodeAssert.equal( + events.some((event) => event.type === "request.resolved"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces the approval when the full-access auto-reply fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-full-access-reply-failed"); + runtimeMock.state.permissionReplyImplementation = async () => { + throw new Error("reply failed"); + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-doom-loop", + type: "permission.asked", + properties: { + id: "per_doom_loop_failed", + sessionID: "http://127.0.0.1:9999/session", + permission: "doom_loop", + patterns: ["bash"], + metadata: {}, + always: [], + }, + }, + ]; + + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const opened = Option.getOrUndefined( + yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(opened?.requestId, "per_doom_loop_failed"); + // Exactly one auto-reply attempt: the fallback surfaces the dialog + // instead of retrying the reply. + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "per_doom_loop_failed", reply: "once" }, + ]); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not reopen a failed full-access auto-reply after its terminal reply", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-full-access-reply-failed-after-terminal"); + const childId = "ses_full_access_terminal_child"; + const request = permissionRequest("per_failed_after_terminal", childId); + const ancestryAttempted = promiseWithResolvers(); + const releaseReply = promiseWithResolvers(); + // The ask arrives from a child whose ancestry lookup is failing, so it + // is handled on a retry fiber. The terminal reply lands while that + // fiber's auto-reply is still in flight; the reply then fails. The + // request must neither reopen nor emit a stray resolution. + runtimeMock.state.sessionParentById.set(childId, "http://127.0.0.1:9999/session"); + runtimeMock.state.transientErrorSessionIds.add(childId); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === childId) { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.permissionReplyImplementation = async () => { + await releaseReply.promise; + throw new Error("reply failed"); + }; + const terminalEvent = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { id: "evt-ask", type: "permission.asked", properties: request }, + terminalEvent.promise, + ]; + + const requestEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + runtimeMock.state.transientErrorSessionIds.delete(childId); + yield* advanceTestClock(250); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: request.id, reply: "once" }, + ]); + + // Drain the microtask queue so the pump has consumed the terminal reply + // before the in-flight auto-reply is allowed to fail. + terminalEvent.resolve({ + id: "evt-reply", + type: "permission.replied", + properties: { sessionID: childId, requestID: request.id, reply: "once" }, + }); + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + releaseReply.resolve(undefined); + yield* advanceTestClock(250); + + NodeAssert.equal(requestEventsFiber.pollUnsafe(), undefined); + yield* Fiber.interrupt(requestEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("routes child-session questions and replies through the parent thread", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index d0b4f0de78ce..6d94e0c09a04 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -328,6 +328,7 @@ interface OpenCodeSessionContext { readonly openCodeSessionId: string; readonly relatedSessionIds: Set; readonly resolvedRequestIds: Set; + readonly autoRepliedRequestIds: Set; readonly emittedTerminalRequestIds: Set; readonly requestRelationRetries: Map; readonly pendingPermissions: Map; @@ -1000,6 +1001,12 @@ export function makeOpenCodeAdapter( const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + // Synchronous publish for callers that must not yield between a state + // check and the enqueue, e.g. reopening an approval only if its terminal + // event has not landed yet. + const emitUnsafe = (event: ProviderRuntimeEvent) => { + Queue.offerUnsafe(runtimeEvents, event); + }; const writeNativeEvent = ( threadId: ThreadId, event: { @@ -1602,6 +1609,39 @@ export function makeOpenCodeAdapter( return false; }); + // Full access means the user already granted everything, but two upstream + // paths never consult the session ruleset we send: doom-loop detection + // (evaluated against the agent ruleset only) and subagent sessions (which + // keep only deny and external-directory rules). Answer those asks here. + // + // Reply "once", not "always": OpenCode stores "always" grants per + // directory, so on a shared external server an "always" from a full-access + // thread would silently widen what a supervised thread on the same + // directory is allowed to do. + const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* ( + context: OpenCodeSessionContext, + request: PermissionRequest, + ) { + // Mark before awaiting: retry and recovery fibers re-enter the ask path, + // and the matching `permission.replied` can arrive, while the SDK call + // is in flight. Marked ids skip the ask and swallow the terminal event. + context.resolvedRequestIds.add(request.id); + context.autoRepliedRequestIds.add(request.id); + const replied = yield* runOpenCodeSdk("permission.reply", () => + context.client.permission.reply({ requestID: request.id, reply: "once" }), + ).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!replied) { + // Fall back to the dialog. The id stays resolved so a recovered copy + // of this ask cannot reopen after the user answers; + // `pendingPermissions` gates re-asks while the dialog is open. + context.autoRepliedRequestIds.delete(request.id); + } + return replied; + }); + const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* ( context: OpenCodeSessionContext, event: OpenCodeAskedRequestEvent, @@ -1615,14 +1655,27 @@ export function makeOpenCodeAdapter( if (context.pendingPermissions.has(request.id)) { return; } + if ( + context.session.runtimeMode === "full-access" && + (yield* autoReplyFullAccess(context, request)) + ) { + return; + } + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + // No yield between this check and the publish: a terminal + // `permission.replied` delivered on the pump in between would leave a + // dialog that can never close. + if (context.emittedTerminalRequestIds.has(request.id)) { + return; + } context.pendingPermissions.set(request.id, request); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId: request.id, - raw, - })), + emitUnsafe({ + ...base, type: "request.opened", payload: { requestType: mapPermissionToRequestType(request.permission), @@ -1671,6 +1724,9 @@ export function makeOpenCodeAdapter( return; } context.emittedTerminalRequestIds.add(requestId); + if (context.autoRepliedRequestIds.delete(requestId)) { + return; + } if (event.type === "permission.replied") { yield* emit({ ...(yield* buildEventBase({ @@ -2554,6 +2610,7 @@ export function makeOpenCodeAdapter( openCodeSessionId: started.openCodeSession.id, relatedSessionIds: new Set([started.openCodeSession.id]), resolvedRequestIds: new Set(), + autoRepliedRequestIds: new Set(), emittedTerminalRequestIds: new Set(), requestRelationRetries: new Map(), pendingPermissions: new Map(), diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 7f77a4c55eac..4b2267463076 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -214,6 +214,12 @@ connection, while OpenCode stores MCP connections by directory. Sharing these ch without changing MCP routing would let two threads in one directory replace each other's connection. +Chat adapters send the runtime mode as a session ruleset, but upstream OpenCode evaluates +doom-loop and subagent asks against the agent ruleset only. In full access the adapter answers +those asks itself so the user never sees an approval they already granted. It replies `once` +rather than `always` because OpenCode stores `always` grants per directory, and on a shared +external server that would widen what a supervised thread in the same directory may do. + OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The provider registry keeps the snapshot in memory and persists it in the existing per-instance cache. Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the From 493fbb58870c912b9cb2ba6c2f1dae938877a59a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 17:28:28 -0400 Subject: [PATCH 022/106] fix(web): reuse pull request list data while loading (#9467) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/PullRequestDetailPanel.tsx | 16 +- .../pullRequest/PullRequestGhosts.tsx | 141 ++++++++++++++++-- apps/web/src/routes/_chat.pull-requests.tsx | 17 +++ 3 files changed, 157 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 59aa1333896b..c94e4bf38103 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -4,6 +4,7 @@ import { type EnvironmentId, type PullRequestAction, type PullRequestMergeMethod, + type PullRequestListEntry, type PullRequestUpdateMethod, type PullRequestRef, type PullRequestState, @@ -447,6 +448,7 @@ export function PullRequestDetailPanel({ environmentId, threadRef = null, reference, + listEntry = null, refreshToken: forcedRefreshToken = 0, onActed, onClose, @@ -463,6 +465,8 @@ export function PullRequestDetailPanel({ */ threadRef?: ScopedThreadRef | null; reference: PullRequestRef; + /** Row fields already loaded by the pull-request list, used while richer detail arrives. */ + listEntry?: PullRequestListEntry | null; /** * Bumped by whatever holds the panel when a reader asks for everything on screen to be read * again. The panel owns its own reads, so the page cannot refresh them for it — it says when, @@ -491,6 +495,12 @@ export function PullRequestDetailPanel({ composerDraftTarget?: ScopedThreadRef | DraftId; }) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; + const matchingListEntry = + listEntry?.projectId === reference.projectId && + listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && + listEntry.number === reference.number + ? listEntry + : null; const [tab, setTab] = useState("summary"); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ @@ -1296,10 +1306,10 @@ export function PullRequestDetailPanel({ ).length : 0; - // A reopen already has last time's title, author, and counts. Keep them on screen - // and let the live read replace fields — especially the diff counts — in place. + // The list already has the pull request's identity and summary. Keep them on screen + // and let the richer detail read replace the remaining placeholders in place. if (detailQuery.isPending && !detail) { - return ; + return ; } return ( diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 2ae79c063c3c..f8c922356548 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -7,7 +7,19 @@ * both themes) and the single `animate-skeleton` pulse, applied once on the container so any * number of bars costs one opacity animation. */ +import type { PullRequestListEntry } from "@t3tools/contracts"; +import { ArrowLeftIcon } from "lucide-react"; + import { cn } from "~/lib/utils"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { pullRequestLabelColor } from "./pullRequestList.logic"; +import { + PullRequestActorLabel, + PullRequestDiffStat, + pullRequestChecksStatePresentation, + resolvePullRequestState, +} from "./pullRequestPresentation"; function GhostBar({ className }: { className?: string | undefined }) { return
; @@ -60,18 +72,46 @@ export function PullRequestListGhost({ * boundaries in the ghost prevents the loaded pull request from replacing one layout with * another a moment later. */ -export function PullRequestDetailGhost() { +export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | null }) { + const statePresentation = seed + ? resolvePullRequestState({ + state: seed.state, + isDraft: seed.isDraft, + }) + : null; + const checksPresentation = seed?.checksState + ? pullRequestChecksStatePresentation(seed.checksState) + : null; + return (
- - + {seed && statePresentation ? ( + <> + + {seed.repository} + + + #{seed.number} + + + ) : ( + <> + + + + )}
@@ -80,18 +120,58 @@ export function PullRequestDetailGhost() {
- + {seed ? ( +

{seed.title}

+ ) : ( + + )}
- - + {seed ? ( + <> + + + updated {formatRelativeTimeLabel(seed.updatedAt)} + + + ) : ( + <> + + + + )}
- - - + {seed ? ( + + {seed.baseBranch} + + {seed.headBranch} + + ) : ( + <> + + + + + )}
- + {seed ? ( + + ) : ( + + )}
@@ -102,7 +182,19 @@ export function PullRequestDetailGhost() {
- + {checksPresentation ? ( + + + {checksPresentation.label} + + ) : ( + + )}
@@ -125,8 +217,29 @@ export function PullRequestDetailGhost() {
- - + {seed ? ( + seed.labels.slice(0, 3).map((label) => { + const color = pullRequestLabelColor(label.color); + return ( + + + {label.name} + + ); + }) + ) : ( + <> + + + + )}
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 29e6b05c0b19..6db4f99b1502 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -220,6 +220,9 @@ const EMPTY_TERMINAL_LABELS = new Map(); const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; +const pullRequestListEntryId = (target: Parameters[0]) => + pullRequestSurfaceId({ ...target, repository: target.repository.toLowerCase() }); + function pullRequestSearchLabels(raw: unknown): Partial> { const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice( 0, @@ -1436,6 +1439,15 @@ function PullRequestsRouteView() { entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), ); }, [groups, sort, statsByRow, typedParsed.text]); + const listedPullRequestsBySurface = useMemo( + () => + new Map( + displayGroups.flatMap((group) => + group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const), + ), + ), + [displayGroups], + ); const linkedSelection = useMemo( () => @@ -1939,6 +1951,11 @@ function PullRequestsRouteView() { repository: renderedPullRequestSurface.repository, number: renderedPullRequestSurface.number, }} + listEntry={ + listedPullRequestsBySurface.get( + pullRequestListEntryId(renderedPullRequestSurface), + ) ?? null + } refreshToken={detailRefreshToken} // Merging, closing or reopening changes the row this panel was opened from, so // the list behind it is out of date the moment the host takes the action. From 03728361aa7beb9c13da320097450e6fe65aac3e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 14:28:36 -0700 Subject: [PATCH 023/106] feat(web): let users turn off composer collapse on blur and scroll (#9469) Co-authored-by: Claude Code --- .../settings/DesktopClientSettings.test.ts | 2 + apps/web/src/components/chat/ChatComposer.tsx | 22 +++--- .../components/composerFooterLayout.test.ts | 23 ++++++ .../src/components/composerFooterLayout.ts | 15 ++-- .../components/settings/SettingsPanels.tsx | 71 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 8 +++ apps/web/src/components/ui/select.tsx | 15 +++- docs/user/composer.md | 3 +- packages/contracts/src/settings.test.ts | 16 +++++ packages/contracts/src/settings.ts | 6 ++ 10 files changed, 163 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 97f4ca85c506..28cce3cfb507 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -28,6 +28,8 @@ const clientSettings: ClientSettings = { confirmThreadUnpin: false, continueThreadsAfterServerUpdate: true, contextWindowMeterEnabled: false, + composerCollapseOnBlur: false, + composerCollapseOnScroll: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffLayout: "stacked", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c38dd1cbe054..5f8376b431f7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3542,8 +3542,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isComposerResting = shouldUseRestingComposerLayout({ isExistingThread: routeKind === "server" && activeThreadId !== null, isMobileViewport, - isFocused: isComposerFocused && !isComposerScrollCollapsed, + isFocused: isComposerFocused, + isScrollCollapsed: isComposerScrollCollapsed, hasExpandedChrome: composerHasExpandedChrome, + collapseOnBlur: settings.composerCollapseOnBlur, }); // The relocated controls live in the context strip whenever the composer is // collapsed for any reason, the desktop resting layout or the phone @@ -3615,8 +3617,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const canTrackComposerScrollGesture = routeKind === "server" && activeThreadId !== null && !isMobileViewport; const canScrollCollapseComposer = - canTrackComposerScrollGesture && !composerHasExpandedChrome && !showInlineTasksBadge; - composerScrollCollapseEligibleRef.current = canScrollCollapseComposer; + canTrackComposerScrollGesture && + settings.composerCollapseOnScroll && + !composerHasExpandedChrome && + !showInlineTasksBadge; + // Scrolling only has something to collapse while the composer is expanded. + // With blur collapse off that includes an unfocused composer, so the wheel + // handler keys off this rather than editor focus. + composerScrollCollapseEligibleRef.current = canScrollCollapseComposer && !isComposerResting; useEffect(() => { if (!canScrollCollapseComposer) { @@ -3657,11 +3665,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) resetComposerScrollGesture(composerScrollGestureRef.current); }; const handleTimelineWheel = (event: WheelEvent) => { - const activeElement = document.activeElement; - const isPromptEditorFocused = - activeElement instanceof HTMLElement && - activeElement.isContentEditable && - composerFormRef.current?.contains(activeElement) === true; if (event.ctrlKey || !(event.target instanceof Element)) { return; } @@ -3695,8 +3698,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) now: window.performance.now(), deltaPx, collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX, - collapseEligible: - targetsTimeline && composerScrollCollapseEligibleRef.current && isPromptEditorFocused, + collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current, canScrollInGestureDirection, scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(), }); diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 926816508ec5..ea2a1de20641 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -79,13 +79,36 @@ describe("shouldUseRestingComposerLayout", () => { isExistingThread: true, isMobileViewport: false, isFocused: false, + isScrollCollapsed: false, hasExpandedChrome: false, + collapseOnBlur: true, }; it("uses the resting layout for an unfocused desktop composer", () => { expect(shouldUseRestingComposerLayout(resting)).toBe(true); }); + it("keeps an unfocused composer expanded when blur collapse is off", () => { + expect(shouldUseRestingComposerLayout({ ...resting, collapseOnBlur: false })).toBe(false); + }); + + it("rests a scroll-collapsed composer even while focused", () => { + expect( + shouldUseRestingComposerLayout({ ...resting, isFocused: true, isScrollCollapsed: true }), + ).toBe(true); + }); + + it("rests a scroll-collapsed composer regardless of the blur preference", () => { + expect( + shouldUseRestingComposerLayout({ + ...resting, + isFocused: true, + isScrollCollapsed: true, + collapseOnBlur: false, + }), + ).toBe(true); + }); + it("keeps new-thread composers expanded", () => { expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false); }); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 2ab3b36a1b90..56a9d43de999 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -27,7 +27,9 @@ export function shouldUseRestingComposerLayout(input: { isExistingThread: boolean; isMobileViewport: boolean; isFocused: boolean; + isScrollCollapsed: boolean; hasExpandedChrome: boolean; + collapseOnBlur: boolean; }): boolean { // Passive draft content is deliberately absent here. Resting only clamps // the prompt row and overlays its actions; non-image attachment and context @@ -37,12 +39,13 @@ export function shouldUseRestingComposerLayout(input: { // deliberately absent here: resting reclaims vertical space at every // desktop width, and where the strip is missing or too narrow the controls // simply return when the composer is focused. - return ( - input.isExistingThread && - !input.isMobileViewport && - !input.isFocused && - !input.hasExpandedChrome - ); + // + // A scroll collapse rests the composer regardless of the blur preference: + // the user asked for it with the gesture, and it lifts on the next + // composer interaction. With blur collapse off, losing focus alone never + // rests the composer. + const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused); + return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome; } export function shouldAnimateComposerRestingTransition(input: { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 35985f57a1f7..6ee1b7e2d0d6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -172,6 +172,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const COMPOSER_COLLAPSE_TRIGGER_LABELS = { + blur: "On unfocus", + scroll: "On scroll", +} as const; +type ComposerCollapseTrigger = keyof typeof COMPOSER_COLLAPSE_TRIGGER_LABELS; + const DIFF_LAYOUT_LABELS: Record = { stacked: "Stacked", split: "Split", @@ -542,6 +548,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? ["Show skills in slash menu"] : []), + ...(settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur || + settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll + ? ["Collapse composer"] + : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), @@ -599,6 +609,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.confirmThreadUnpin, + settings.composerCollapseOnBlur, + settings.composerCollapseOnScroll, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -703,6 +715,8 @@ export function useSettingsRestore(onRestored?: () => void) { diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, + composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur, + composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -2010,6 +2024,13 @@ export function GeneralSettingsPanel() { const serverProviders = useAtomValue(primaryServerProvidersAtom); const supportsAutoSettlement = useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true; + const composerCollapseTriggers = useMemo( + () => [ + ...(settings.composerCollapseOnBlur ? (["blur"] as const) : []), + ...(settings.composerCollapseOnScroll ? (["scroll"] as const) : []), + ], + [settings.composerCollapseOnBlur, settings.composerCollapseOnScroll], + ); const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -2334,6 +2355,56 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur, + composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, + }) + } + /> + ) : null + } + control={ + + } + /> + + {showCheck ? ( + + + + ) : null} { }); }); +describe("ClientSettings composer collapse", () => { + it("collapses on blur and scroll by default and accepts opting out of each", () => { + const defaults = decodeClientSettings({}); + expect(defaults.composerCollapseOnBlur).toBe(true); + expect(defaults.composerCollapseOnScroll).toBe(true); + + const blurOff = decodeClientSettings({ composerCollapseOnBlur: false }); + expect(blurOff.composerCollapseOnBlur).toBe(false); + expect(blurOff.composerCollapseOnScroll).toBe(true); + + expect( + decodeClientSettingsPatch({ composerCollapseOnScroll: false }).composerCollapseOnScroll, + ).toBe(false); + }); +}); + describe("ServerSettings thread settlement", () => { it("defaults merge settlement on and inactivity settlement to three days", () => { const settings = decodeServerSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9f1f0c846ac3..c1ee59e948df 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -319,6 +319,10 @@ export const ClientSettingsSchema = Schema.Struct({ // Legacy context window meter. The composer hides it by default; users who // still want the old usage indicator can restore it from Settings. contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Desktop resting composer. Each trigger that settles an existing thread's + // composer into its single-line layout can be turned off on its own. + composerCollapseOnBlur: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key @@ -1165,6 +1169,8 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), + composerCollapseOnBlur: Schema.optionalKey(Schema.Boolean), + composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), From 373be93e68bf3d32207471b27e976ac84bff806a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 17:44:05 -0400 Subject: [PATCH 024/106] fix(web): move workflow approval beside checks (#9465) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/PullRequestDetailPanel.tsx | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index c94e4bf38103..adce43dd3344 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1478,41 +1478,6 @@ export function PullRequestDetailPanel({ ) : null} - {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( - - - - - } - /> - - {pendingAction === "approve-workflows" - ? "Approving..." - : "Approve workflows to run"} - - - ) : null} {/* Said where the Merge button is, because it is the answer to why nobody has pressed it: the merge is already asked for, and the host is holding it. */} {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( @@ -2165,20 +2130,58 @@ export function PullRequestDetailPanel({ ))} {tab === "summary" ? ( - - {checksState !== null ? ( - + + {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( + + + + + } + /> + + {pendingAction === "approve-workflows" + ? "Approving..." + : "Approve workflows to run"} + + ) : ( - + + {checksState !== null ? ( + + ) : ( + + )} + {checksSummary} + )} - {checksSummary} ) : tab === "timeline" ? (
From 4b8b5d9e0177002c84a6f55837670aa0ef816915 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 14:57:59 -0700 Subject: [PATCH 025/106] fix(desktop): refresh generated annotation styles (#9488) --- apps/desktop/src/preview/AnnotationStyles.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index 5b6b73c8ba78..aba581ab5338 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; + '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; From 0869ad648b67a286d7a47af878f1d256d0ff689f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 15:21:07 -0700 Subject: [PATCH 026/106] fix(web): let the PR reviewer and label search boxes take keystrokes (#9479) Co-authored-by: Claude Code --- .../PullRequestCandidatePicker.tsx | 96 ++++++++++++++----- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx index b42061628d1e..c8dd196f299d 100644 --- a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx @@ -2,12 +2,22 @@ * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a * scrolling body that says when the list is loading, could not be read, is empty, or is not all * of it. The rows and the words are the caller's; the frame is the same either way. + * + * The same combobox as the project and branch pickers, and dressed the same, rather than a menu: + * a menu's typeahead claims every keypress to jump between rows, which a search box cannot share. */ +import { SearchIcon } from "lucide-react"; import type { ReactNode } from "react"; import { Button } from "../ui/button"; -import { Input } from "../ui/input"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { + Combobox, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "../ui/combobox"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestPeopleGhost } from "./PullRequestGhosts"; @@ -78,27 +88,61 @@ export function PullRequestCandidatePicker({ ); } + const keys = candidates.map(candidateKey); + return ( - - { + const candidate = candidates.find((entry) => candidateKey(entry) === key); + if (candidate) onSelect(candidate); + }} + open={open} + onOpenChange={(nextOpen, details) => { + // Stays open on a pick: a change is confirmed by the row's own check turning over, and a + // second label or reviewer is usually wanted right after the first. Cancelled rather than + // ignored, so the combobox also skips its own close work: freezing the query and returning + // focus to the trigger, either of which would take the next keystroke away from the box. + if (!nextOpen && details.reason === "item-press") { + details.cancel(); + return; + } + onOpenChange(nextOpen); + }} + > + {icon} } /> - -
- onQueryChange(event.currentTarget.value)} - placeholder={searchLabel} - aria-label={searchLabel} - size="compact" - /> + +
+
+
-
+ {isPending ? ( ) : error !== null ? ( @@ -110,18 +154,18 @@ export function PullRequestCandidatePicker({ {query.length > 0 ? noMatchLabel : emptyLabel}

) : ( - candidates.map((candidate) => ( - // Stays open on press: a change is confirmed by the row's own check turning over, - // and a second label or reviewer is usually wanted right after the first. - ( + onSelect(candidate)} className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs" + contentClassName="flex min-w-0 items-center gap-2" > {children(candidate)} - + )) )} {truncated ? ( @@ -129,8 +173,8 @@ export function PullRequestCandidatePicker({ // list is rather than offering a search that would find nothing further.

{truncatedLabel}

) : null} -
- -
+ + + ); } From 77138cf33194a303adfbbd69cc93efa69f84245d Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:23:01 +0300 Subject: [PATCH 027/106] fix(web): dont collapse composer when interacting with bottom row (#9490) --- apps/web/src/components/BranchToolbar.tsx | 3 ++- apps/web/src/components/BranchToolbarBranchSelector.tsx | 8 +++++++- apps/web/src/components/BranchToolbarEnvModeSelector.tsx | 3 ++- .../src/components/BranchToolbarEnvironmentSelector.tsx | 3 ++- apps/web/src/components/chat/ChatComposer.tsx | 5 ++--- apps/web/src/components/chat/composerEventScope.test.ts | 7 +++++++ apps/web/src/components/chat/composerEventScope.ts | 1 + 7 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 0496bef06ef6..07407bcf21b3 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -40,6 +40,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { ComposerSurface } from "./chat/ComposerSurface"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; @@ -160,7 +161,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {triggerContent} - + {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 67f6cbe7b9c0..e968954ec1d0 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -34,6 +34,7 @@ import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, @@ -788,7 +789,12 @@ export function BranchToolbarBranchSelector({
- +
- + Workspace diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 6304e37cf88d..fabda55688bc 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -3,6 +3,7 @@ import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { Select, SelectGroup, @@ -101,7 +102,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir - + Run on {availableEnvironments.map((env) => ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f8376b431f7..90da57b948c4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4283,7 +4283,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerSurface = composerSurfaceRef.current; const composerForm = composerFormRef.current; const activeElement = document.activeElement; - if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) { + if (isInsideRestingComposerControlScope(activeElement)) { return; } if ( @@ -4303,8 +4303,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isInsideDesktopComposerFocusScope = (target: EventTarget | null) => Boolean( target instanceof Node && - (composerFormRef.current?.contains(target) || - (target instanceof Element && isInsideComposerFloatingLayer(target))), + (composerFormRef.current?.contains(target) || isInsideRestingComposerControlScope(target)), ); const handleFocusIn = (event: FocusEvent) => { if (!isInsideDesktopComposerFocusScope(event.target)) { diff --git a/apps/web/src/components/chat/composerEventScope.test.ts b/apps/web/src/components/chat/composerEventScope.test.ts index b559009ed43f..365c5304aa2d 100644 --- a/apps/web/src/components/chat/composerEventScope.test.ts +++ b/apps/web/src/components/chat/composerEventScope.test.ts @@ -29,6 +29,13 @@ describe("composer event scopes", () => { expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); }); + it("recognizes events from the composer context strip controls", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement("[data-composer-context-control]"); + expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); + }); + it("keeps resting image previews focused without expanding their subtree", () => { vi.stubGlobal("Element", FakeElement); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index 60aedb096156..88e24fd89422 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -26,6 +26,7 @@ export function isInsideRestingComposerControlScope(target: EventTarget | null): target instanceof Element && (target.closest('[data-chat-composer-resting-controls="true"]') !== null || target.closest('[data-chat-composer-resting-images="true"]') !== null || + target.closest("[data-composer-context-control]") !== null || isInsideComposerFloatingLayer(target)) ); } From 678f23a69943e3eef7171f452b44f2931d2ef21f Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 18:34:23 -0400 Subject: [PATCH 028/106] fix(desktop): restore second-press quit fallback (#9485) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/desktop/src/window/QuitHold.test.ts | 6 +++--- apps/desktop/src/window/QuitHold.ts | 8 +++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index fb12be2162c1..c4bf2f34b0a1 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -368,14 +368,14 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); - it("does not treat two quick presses as a quit in hold mode", async () => { + it("quits on a quick second press in hold mode", async () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("cancels the hold when another key interrupts it", async () => { diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 7088f4f28ce8..4095e3d4354b 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -181,11 +181,9 @@ export function makeQuitShortcutHandler( quitNow(); return; } - if ( - resolvedMode === "double-click" && - previousPressAt !== 0 && - now - previousPressAt <= QUIT_DOUBLE_PRESS_MS - ) { + // Keep a second press as an escape hatch when macOS misses the events + // that would complete a hold. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { quitNow(); return; } From c726c30a148c2add6a3ec7f31f54ae48dc5d2f0c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 18:48:44 -0400 Subject: [PATCH 029/106] fix(web): keep opencode icon hollow in collapsed composer (#9492) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/Icons.tsx | 14 ++++++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..edb41868879e 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -653,9 +653,19 @@ export const AntigravityIcon: Icon = (props) => ( export const OpenCodeIcon: Icon = (props) => ( - + - + diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 90da57b948c4..df2f88f80a8e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3808,7 +3808,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeProviderIconClassName: cn( composerProviderState.modelPickerIconClassName, composerControlsInStrip && - "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70!", + "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70! [&_[data-opencode-hole]]:fill-transparent!", ), } : {})} From 12e8997e58dbca8f1bd8c63b67d662eb69cf0e0d Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 18:50:04 -0400 Subject: [PATCH 030/106] fix(web): keep agent browser preview visible (#9484) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/browser/BrowserSurfaceSlot.tsx | 10 ++-- apps/web/src/browser/HostedBrowserWebview.tsx | 2 + .../src/browser/browserSurfaceStore.test.ts | 13 +++++ apps/web/src/browser/browserSurfaceStore.ts | 20 ++++++-- .../browser/hostedBrowserWebviewStyle.test.ts | 2 + .../src/browser/hostedBrowserWebviewStyle.ts | 4 +- .../web/src/components/ChatView.logic.test.ts | 22 +++++++++ apps/web/src/components/ChatView.logic.ts | 13 +++++ apps/web/src/components/ChatView.tsx | 22 ++++----- apps/web/src/components/RightPanelSheet.tsx | 12 ++++- .../preview/PreviewAutomationHosts.tsx | 48 ++++++++++++++++++- .../preview/ThreadPreviewMiniPlayer.tsx | 12 +++-- .../previewAutomationOpenReadiness.test.ts | 47 ++++++++++++++++++ .../preview/previewAutomationOpenReadiness.ts | 15 ++++++ .../preview/previewMiniPlayerLayout.ts | 2 + .../settings/IntegrationsSettings.tsx | 2 +- apps/web/src/components/ui/sheet.tsx | 16 ++++--- apps/web/src/rightPanelLayout.ts | 2 + packages/contracts/src/settings.ts | 2 +- 19 files changed, 228 insertions(+), 38 deletions(-) diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index a9d3f541ff19..3de3ed586cb0 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -8,6 +8,7 @@ export function BrowserSurfaceSlot(props: { readonly tabId: string; readonly visible: boolean; readonly cornerRadius?: number; + readonly zIndex?: number; readonly layoutVersion?: string | number; readonly className?: string; readonly fitSourceContent?: boolean; @@ -16,12 +17,13 @@ export function BrowserSurfaceSlot(props: { tabId, visible, cornerRadius = 0, + zIndex = 30, layoutVersion, className, fitSourceContent = false, } = props; const elementRef = useRef(null); - const presentationRef = useRef({ visible, cornerRadius }); + const presentationRef = useRef({ visible, cornerRadius, zIndex }); const updateRef = useRef<(() => void) | null>(null); useLayoutEffect(() => { @@ -40,6 +42,7 @@ export function BrowserSurfaceSlot(props: { }, presentation.visible && rect.width > 0 && rect.height > 0, presentation.cornerRadius, + presentation.zIndex, ); if (presentation.visible && !presented) { lease.release(); @@ -53,6 +56,7 @@ export function BrowserSurfaceSlot(props: { }, rect.width > 0 && rect.height > 0, presentation.cornerRadius, + presentation.zIndex, ); } }; @@ -72,9 +76,9 @@ export function BrowserSurfaceSlot(props: { }, [fitSourceContent, tabId]); useLayoutEffect(() => { - presentationRef.current = { visible, cornerRadius }; + presentationRef.current = { visible, cornerRadius, zIndex }; updateRef.current?.(); - }, [cornerRadius, layoutVersion, visible]); + }, [cornerRadius, layoutVersion, visible, zIndex]); return
; } diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 582d5686ec0f..0f01960ce52b 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -83,6 +83,7 @@ export function HostedBrowserWebview(props: { fittedSourceContent: current?.fittedSourceContent ?? null, rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId), visible: current?.visible ?? false, + zIndex: current?.zIndex ?? 30, }; }), ); @@ -259,6 +260,7 @@ export function HostedBrowserWebview(props: { // suspend them, and automation continues to see the macOS guests as inactive. keepPaintableWhenInactive: isMacPlatform(navigator.platform), cornerRadius: presentation.cornerRadius, + zIndex: presentation.zIndex, rect: lastRect, hiddenSize, }); diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 249d3dcb2f44..456377a4d641 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -107,6 +107,7 @@ describe("browserSurfaceStore", () => { hidden: { rect: staleRect, visible: false, + zIndex: 30, content: null, fittedSourceContent: null, fitSourceContent: false, @@ -117,6 +118,7 @@ describe("browserSurfaceStore", () => { active: { rect: liveRect, visible: true, + zIndex: 30, content: null, fittedSourceContent: null, fitSourceContent: false, @@ -162,6 +164,17 @@ describe("browserSurfaceStore", () => { }); }); + it("keeps the requested layer with the active surface lease", () => { + const tabId = "layered-browser-surface"; + const lease = acquireBrowserSurface(tabId); + lease.present({ x: 10, y: 20, width: 320, height: 200 }, true, 12, 48); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + visible: true, + zIndex: 48, + }); + }); + it("clears fitted presentation state when its lease is released", () => { const tabId = "released-fitted-browser-surface"; const fittedLease = acquireBrowserSurface(tabId, true); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index fe85c9e38b21..a49154ed8def 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -10,6 +10,7 @@ export interface BrowserSurfaceRect { export interface BrowserSurfacePresentation { readonly rect: BrowserSurfaceRect | null; readonly visible: boolean; + readonly zIndex: number; readonly content: BrowserSurfaceContentPresentation | null; readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; readonly fitSourceContent: boolean; @@ -39,13 +40,19 @@ interface BrowserSurfaceStoreState { rect: BrowserSurfaceRect, visible: boolean, cornerRadius: number, + zIndex: number, ) => void; readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; readonly release: (tabId: string, owner: symbol) => void; } export interface BrowserSurfaceLease { - readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean; + readonly present: ( + rect: BrowserSurfaceRect, + visible: boolean, + cornerRadius?: number, + zIndex?: number, + ) => boolean; readonly release: () => void; } @@ -97,6 +104,7 @@ export const useBrowserSurfaceStore = create()((set) = [tabId]: { rect: current?.rect ?? null, visible: false, + zIndex: current?.zIndex ?? 30, content: current?.content ?? null, fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, fitSourceContent, @@ -107,7 +115,7 @@ export const useBrowserSurfaceStore = create()((set) = }, }; }), - present: (tabId, owner, rect, visible, cornerRadius) => + present: (tabId, owner, rect, visible, cornerRadius, zIndex) => set((state) => { const current = state.byTabId[tabId]; if (current?.owner !== owner) return state; @@ -115,6 +123,7 @@ export const useBrowserSurfaceStore = create()((set) = current && current.visible === visible && current.cornerRadius === cornerRadius && + current.zIndex === zIndex && rectEquals(current.rect, rect) ) { return state; @@ -122,7 +131,7 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { ...current, rect, visible, cornerRadius, updatedAt: Date.now() }, + [tabId]: { ...current, rect, visible, cornerRadius, zIndex, updatedAt: Date.now() }, }, }; }), @@ -136,6 +145,7 @@ export const useBrowserSurfaceStore = create()((set) = [tabId]: { rect: null, visible: false, + zIndex: 30, content, fittedSourceContent: null, fitSourceContent: false, @@ -206,10 +216,10 @@ export function acquireBrowserSurface( useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); return { - present: (rect, visible, cornerRadius = 0) => { + present: (rect, visible, cornerRadius = 0, zIndex = 30) => { if (released) return false; if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; - useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius); + useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius, zIndex); return true; }, release: () => { diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index 69216796af9f..831167095fa1 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -30,6 +30,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { active: true, renderingActive: true, cornerRadius: 12, + zIndex: 48, rect: { x: 12, y: 34, width: 360, height: 203 }, hiddenSize: { width: 1280, height: 800 }, }), @@ -39,6 +40,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { width: 360, height: 203, borderRadius: 12, + zIndex: 48, }); }); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index a59a4a8b0083..5bdf9b7c4f6d 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -23,6 +23,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly renderingActive: boolean; readonly keepPaintableWhenInactive?: boolean; readonly cornerRadius?: number; + readonly zIndex?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { @@ -33,6 +34,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { keepPaintableWhenInactive = false, rect, renderingActive, + zIndex = 30, } = input; if (active && rect) { return { @@ -40,7 +42,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { top: rect.y, width: rect.width, height: rect.height, - zIndex: 30, + zIndex, pointerEvents: "auto", ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}), }; diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 7649d6b50e49..4cc750d45236 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -50,6 +50,7 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, @@ -93,6 +94,27 @@ describe("agent browser close confirmation", () => { }); }); +describe("floating browser preview", () => { + it("only hides the duplicate while the same browser is rendered in the panel", () => { + expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); + expect( + shouldRenderPreviewMiniPlayer("tab-1", { + id: "browser:one", + kind: "preview", + resourceId: "tab-1", + }), + ).toBe(false); + expect( + shouldRenderPreviewMiniPlayer("tab-1", { + id: "browser:two", + kind: "preview", + resourceId: "tab-2", + }), + ).toBe(true); + expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true); + }); +}); + describe("proactive panels", () => { it("opens a pull request only after a newly observed link appears", () => { expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index cef14f240d97..46ff8c473c6d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -86,6 +86,19 @@ export function agentControlledBrowserCloseConfirmation( ].join("\n"); } +export function shouldRenderPreviewMiniPlayer( + miniPlayerTabId: string | null, + renderedRightPanelSurface: RightPanelSurface | null, +): boolean { + return ( + miniPlayerTabId !== null && + !( + renderedRightPanelSurface?.kind === "preview" && + renderedRightPanelSurface.resourceId === miniPlayerTabId + ) + ); +} + export function shouldOpenProactivePullRequest( previousTargetKey: string | null | undefined, targetKey: string | null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0d33aec7bc20..187bc1f5f984 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -366,6 +366,7 @@ import { shouldShowPlanFollowUpPrompt, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -1847,6 +1848,10 @@ function ChatViewContent(props: ChatViewProps) { rightPanelPresent && (!shouldUseRightPanelSheet || rightPanelOpen); const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; + const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.tabId ?? null, + renderedRightPanelSurface, + ); const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; @@ -1862,20 +1867,10 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (!activeThreadRef || !activePreviewMiniPlayer) return; const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); - const sameTabOpenInPanel = - previewPanelOpen && - activeRightPanelSurface?.kind === "preview" && - activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; - if (!miniTabStillExists || sameTabOpenInPanel) { + if (!miniTabStillExists) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } - }, [ - activePreviewMiniPlayer, - activePreviewState.sessions, - activeRightPanelSurface, - activeThreadRef, - previewPanelOpen, - ]); + }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); @@ -7919,7 +7914,7 @@ function ChatViewContent(props: ChatViewProps) {
- {activeThreadRef && activePreviewMiniPlayer ? ( + {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( void; }) { return ( @@ -23,6 +27,12 @@ export function RightPanelSheet(props: { side="right" showCloseButton={false} keepMounted + {...(props.underFloatingPreview + ? { + backdropClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, + viewportClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, + } + : {})} className={RIGHT_PANEL_SHEET_CLASS_NAME} > {props.children} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 1faf928b1cf5..54c2e1d9cf68 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -20,7 +20,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Atom } from "effect/unstable/reactivity"; import { @@ -29,7 +29,7 @@ import { reconcilePreviewServerSessions, updatePreviewServerSnapshot, } from "~/previewStateStore"; -import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; import { readActiveBrowserRecordingTargets, @@ -59,8 +59,10 @@ import { PreviewAutomationViewportTimeoutError, } from "./previewAutomationErrors"; import { + explicitlySuppressesPreviewMiniPlayer, previewAutomationDefaultViewport, previewAutomationOpenNeedsOverlay, + shouldAutoShowPreviewForAutomationUse, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; import { @@ -311,6 +313,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); const [automationConnectionAtom] = useState(() => Atom.make(null)); const automationConnectionId = useAtomValue(automationConnectionAtom); + const presentationSuppressedRuntimeTabsRef = useRef(new Map>()); const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { @@ -353,6 +356,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } const readyState = readThreadPreviewState(threadRef); const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId); + if (request.operation !== "open") { + const { autoShowFloatingPreview } = await resolveBrowserDefaults(); + if ( + shouldAutoShowPreviewForAutomationUse({ + operation: request.operation, + autoShowFloatingPreview, + presentationSuppressed: + presentationSuppressedRuntimeTabsRef.current + .get(request.threadId) + ?.has(runtimeTabId) ?? false, + }) + ) { + usePreviewMiniPlayerStore.getState().open(threadRef, readyTabId); + } + } browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId); await waitForDesktopOverlay( threadRef, @@ -450,6 +468,32 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) input, (await resolveBrowserDefaults()).autoShowFloatingPreview, ); + const explicitlySuppressed = explicitlySuppressesPreviewMiniPlayer(input); + const suppressedTabs = presentationSuppressedRuntimeTabsRef.current.get( + request.threadId, + ); + if (explicitlySuppressed) { + if (suppressedTabs) { + suppressedTabs.add(activeRuntimeTabId); + } else { + presentationSuppressedRuntimeTabsRef.current.set( + request.threadId, + new Set([activeRuntimeTabId]), + ); + } + const miniPlayer = selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + threadRef, + ); + if (miniPlayer?.tabId === activeTabId) { + usePreviewMiniPlayerStore.getState().close(threadRef); + } + } else if (shouldPresentPreview) { + suppressedTabs?.delete(activeRuntimeTabId); + if (suppressedTabs?.size === 0) { + presentationSuppressedRuntimeTabsRef.current.delete(request.threadId); + } + } if (shouldPresentPreview) { usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 623928d102ef..dc2d9f5a96ea 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -19,6 +19,7 @@ import { clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_DEFAULT_SIZE, PREVIEW_MINI_PLAYER_EDGE_GAP, + PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, } from "./previewMiniPlayerLayout"; interface DragState { @@ -243,7 +244,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props } } > -
+