From d8a6dfd31539a86d08bd4fbd030f8252b3c405ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 15 Aug 2026 12:29:26 +0200 Subject: [PATCH 001/196] fix(desktop): app zoom no longer zooms the preview browser (#6649) Co-authored-by: Claude Opus 5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 134 ++++++++++++++++-- apps/desktop/src/preview/Manager.ts | 72 +++++++--- apps/desktop/src/window/DesktopWindow.test.ts | 47 ++++++ apps/desktop/src/window/DesktopWindow.ts | 4 + 4 files changed, 219 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index c24dca802c58..5c336eec8da4 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -979,7 +979,10 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => + // The guest reports whatever zoom level Chromium handed it from the app + // window, so the tab's own zoom is the source of truth in both directions: + // asserted onto every guest, never read back off one. + effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () => withManager((manager) => Effect.gen(function* () { let effectiveZoom = 0.9; @@ -1025,18 +1028,13 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_zoom"); yield* manager.registerWebview("tab_zoom", 42); - expect(states.at(-1)?.zoomFactor).toBe(0.9); - expect(setZoomFactor).not.toHaveBeenCalled(); + expect(states.at(-1)?.zoomFactor).toBe(1); + expect(setZoomFactor).toHaveBeenCalledWith(1); - effectiveZoom = 1.25; - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - - expect(states.at(-1)?.zoomFactor).toBe(1.25); - expect(setZoomFactor).not.toHaveBeenCalled(); - - zoomReadable = false; - url = "https://example.com/after-zoom-read-failed"; + // An app zoom leaves the guest reporting the inherited level. Navigating + // must not adopt it as the preview's zoom. + effectiveZoom = 0.8; + url = "https://example.com/after-app-zoom"; listeners.get("did-navigate")?.(); yield* Effect.yieldNow; @@ -1045,7 +1043,18 @@ describe("PreviewManager", () => { url, title: "Example", }); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(states.at(-1)?.zoomFactor).toBe(1); + + // Only the preview's own zoom controls move it. + yield* manager.zoomIn("tab_zoom"); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + + zoomReadable = false; + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + + expect(states.at(-1)?.zoomFactor).toBe(1.1); const replacementSetZoomFactor = vi.fn(); fromId.mockReturnValue({ @@ -1074,8 +1083,103 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_zoom", 43); - expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + }), + ), + ); + + // Zooming the app UI pushes the window's zoom level onto every guest, so the + // preview has to be put back at the zoom the user gave it. + effectIt.effect("re-applies each tab's own zoom when the app window zooms", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: 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_reapply"); + yield* manager.registerWebview("tab_reapply", 42); + yield* manager.zoomIn("tab_reapply"); + setZoomFactor.mockClear(); + + yield* manager.reapplyZoom(); + + expect(setZoomFactor).toHaveBeenCalledTimes(1); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + }), + ), + ); + + // did-attach and dom-ready both re-register the guest that is already + // attached, and a guest that just inherited the app window's zoom needs its + // own back — without that round trip republishing tab state. + effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: 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 states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.createTab("tab_reregister_zoom"); + yield* manager.registerWebview("tab_reregister_zoom", 42); + yield* manager.zoomIn("tab_reregister_zoom"); + setZoomFactor.mockClear(); + const publishedBefore = states.length; + + yield* manager.registerWebview("tab_reregister_zoom", 42); + + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.length).toBe(publishedBefore); + expect(states.at(-1)?.zoomFactor).toBe(1.1); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 4799a7dfac26..d48b13037398 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -647,6 +647,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (Option.isSome(next)) yield* emit(tabId, next.value); }); + /** + * Pushes a tab's zoom factor onto whichever guest it currently owns, reading + * both at call time. Anything that applies zoom after an await goes through + * here: a snapshot taken before the await can be older than a zoom action that + * landed in between, and re-applying it would roll that action back. + */ + const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || tab.webContentsId == null) return; + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () => + wc.setZoomFactor(tab.zoomFactor), + ).pipe(Effect.ignore); + }); + const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( tabId: string, ) { @@ -1305,10 +1321,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function confirmedNavigation = false, ) { if (wc.isDestroyed()) return; - const zoomFactor = yield* attempt( - { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id }, - () => wc.getZoomFactor(), - ).pipe(Effect.option); const computedNavStatus = computeNavStatus(wc); const canGoBack = wc.navigationHistory.canGoBack(); const canGoForward = wc.navigationHistory.canGoForward(); @@ -1338,7 +1350,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}), + // zoomFactor is deliberately not read back from the guest: Chromium + // reports the level it inherited from the app window, so mirroring it + // would turn an app zoom into the preview's own zoom. updatedAt, }; return [ @@ -1716,11 +1730,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { - const zoomFactor = yield* attempt( - { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, - () => wc.getZoomFactor(), - ); - yield* update(tabId, { zoomFactor }); + // The guest we already own re-announced itself, so nothing about the tab + // changed. Only push its zoom back down — Chromium may have just handed + // this guest the app window's zoom level. + yield* assertTabZoom(tabId); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); @@ -1749,18 +1762,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - const zoomFactor = - replacedWebContentsId !== null - ? yield* attempt( - { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, - () => { - wc.setZoomFactor(currentTab.zoomFactor); - return currentTab.zoomFactor; - }, - ) - : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => - wc.getZoomFactor(), - ); + // Always assert the tab's own zoom rather than reading the guest's: a guest + // attaching while the app UI is zoomed starts at the embedder's inherited + // zoom level, which is not the preview's zoom. Done before the guest is + // published so it never paints a frame at the inherited zoom. + yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => + wc.setZoomFactor(currentTab.zoomFactor), + ); yield* attachListeners(tabId, wc); const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => @@ -1784,7 +1792,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor, updatedAt: registeredAt, }; return [ @@ -1806,6 +1813,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; + // A zoom action that landed while this attach was in flight addressed the + // guest this one replaced, so settle the new guest on the committed factor. + yield* assertTabZoom(tabId); runFork(restoreControlSession(tabId, wc)); yield* emit(tabId, registered); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => @@ -2099,6 +2109,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + /** + * Chromium hands every guest `` the embedder's zoom level, so zooming + * the app UI drags the previewed page along with it. The preview browser owns + * its own zoom factor, so re-assert it on each attached guest whenever the main + * window's zoom changes (see DesktopWindow.zoomMain). + */ + const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { + const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); + yield* Effect.forEach(tabIds, assertTabZoom, { discard: true }); + }); + const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( tabId: string, transform: (current: number) => number, @@ -3476,6 +3497,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + reapplyZoom, refresh, registerWebview, resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), @@ -3774,6 +3796,9 @@ export class PreviewManager extends Context.Service< readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; + // Re-applies every attached guest's own zoom factor, undoing the zoom level + // Chromium inherits from the embedder when the app UI zooms. + readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, @@ -3874,6 +3899,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { zoomIn: operations.zoomIn, zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, + reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, openDevTools: operations.openDevTools, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3aedd2ea6c0e..ed0fbf8b5688 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -61,9 +61,14 @@ const environmentInput = { function makeFakeBrowserWindow() { const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); + let zoomLevel = 0; const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), + getZoomLevel: vi.fn(() => zoomLevel), + setZoomLevel: vi.fn((level: number) => { + zoomLevel = level; + }), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); @@ -116,6 +121,7 @@ function makeFakeBrowserWindow() { openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, + setZoomLevel: webContents.setZoomLevel, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, windowListeners, @@ -186,6 +192,7 @@ function makeTestLayer(input: { bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; + readonly previewZoomReapplies?: number[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -264,6 +271,10 @@ function makeTestLayer(input: { setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), + reapplyZoom: () => + Effect.sync(() => { + input.previewZoomReapplies?.push(input.window.webContents.getZoomLevel()); + }), }), ), ), @@ -483,6 +494,42 @@ describe("DesktopWindow", () => { }), ); + // Chromium hands the main window's zoom level down to embedded preview + // guests, so every app zoom has to put the preview browser back at its own + // zoom or zooming the UI drags the previewed page with it. + it.effect("restores the preview browser's own zoom after zooming the app", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewZoomReapplies: number[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + previewZoomReapplies, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("in"); + yield* desktopWindow.zoomMain("reset"); + + assert.deepEqual( + fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), + [-0.5, -1, -0.5, 0], + ); + // Recorded after the window level moved, so the preview is put back at + // its own zoom on every step rather than left on the inherited one. + assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index bf8c681448fe..2ae3d353279b 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -855,6 +855,10 @@ export const make = Effect.gen(function* () { webContents.setZoomLevel( direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); + // Chromium pushes the new level down to embedded guests, which would zoom + // the previewed page along with the app UI. The preview browser keeps its + // own zoom, so put each guest back where the preview left it. + yield* previewManager.reapplyZoom(); }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; From afca73d3683c99057ea8af1ad7d77511a0faf680 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 15 Aug 2026 05:43:13 -0500 Subject: [PATCH 002/196] fix(server): keep provider notification consumers alive past startSession (#6538) Co-authored-by: tsouth89 --- .../src/provider/Layers/CodexAdapter.test.ts | 58 ++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 6 +- .../src/provider/Layers/CursorAdapter.test.ts | 68 +++++++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 8 ++- .../src/provider/Layers/GrokAdapter.test.ts | 67 ++++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 8 ++- 6 files changed, 212 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7b8fbec56660..5358716aabe4 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -32,6 +32,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as CodexErrors from "effect-codex-app-server/errors"; import { ServerConfig } from "../../config.ts"; @@ -1150,6 +1151,63 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }); }), ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the runtime event consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every event the session + // emitted afterwards was dropped. The other tests here start the session from + // the test fiber, which never completes, so the consumer survived and the bug + // stayed invisible. Starting it in a fiber that finishes reproduces + // production. + it.effect("keeps consuming runtime events after the startSession fiber completes", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const startSessionFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-outlives-start"), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber); + + const runtime = lifecycleRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + yield* runtime.emit({ + id: asEventId("evt-after-start-session"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-outlives-start"), + turnId: asTurnId("turn-1"), + itemId: asItemId("msg_after_start"), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-outlives-start", + turnId: "turn-1", + item: { + type: "agentMessage", + id: "msg_after_start", + text: "emitted after startSession returned", + }, + }, + }); + + const firstEvent = yield* Fiber.join(firstEventFiber).pipe(Effect.timeout("10 seconds")); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "item.completed"); + // Live clock so the timeout above is real: under the default test clock it + // waits on virtual time that never advances, and a regression would hang + // until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6b99bf52b1e0..065156d36473 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1715,6 +1715,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); + // Fork into the session scope, not the calling fiber. `forkChild` makes + // this a child of `startSession`, and Effect interrupts a fiber's + // children when it completes, so the consumer died on return and every + // runtime event the session emitted afterwards was dropped. const eventFiber = yield* Stream.runForEach(runtime.events, (event) => Effect.gen(function* () { yield* writeNativeEvent(event); @@ -1730,7 +1734,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); }), - ).pipe(Effect.forkChild); + ).pipe(Effect.forkIn(sessionScope)); const started = yield* runtime.start().pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 491f718a9776..cd5cdb7f01aa 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1429,4 +1429,72 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }).pipe(Effect.provide(customAdapterLayer)); }, ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the notification consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every later session/update + // was dropped: the thread sat on "Working" forever while the provider + // streamed its whole turn. The other tests here call startSession directly + // from the test fiber, which never completes, so the consumer survived and + // the bug stayed invisible. Running it in a fiber that finishes is what + // reproduces production. + it.effect("keeps consuming notifications after the startSession fiber completes", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-consumer-outlives-start-session"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const sawContentDelta = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "content.delta" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sawContentDelta, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const startSessionFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber).pipe(Effect.timeout("10 seconds")); + + // Forked, and the assertion waits on the projected event rather than on + // sendTurn: with the consumer dead the turn never settles, so awaiting it + // directly would hang until the suite timeout instead of failing here. + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hello mock", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(sawContentDelta).pipe(Effect.timeout("10 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("10 seconds")); + + const delta = runtimeEvents.find( + (event) => event.type === "content.delta" && String(event.threadId) === String(threadId), + ); + assert.isDefined( + delta, + "no content.delta was projected after the startSession fiber completed", + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + // Live clock so the timeouts above are real: under the default test clock + // they wait on virtual time that never advances, and a regression would + // hang until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 80475a5c2695..30c173d8fae8 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -874,7 +874,13 @@ export function makeCursorAdapter( Effect.catch((cause) => Effect.logError("Failed to process Cursor runtime notification.", { cause }), ), - Effect.forkChild, + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), ); ctx.notificationFiber = nf; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae81..6cb71660a74c 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1197,4 +1197,71 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the notification consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every later session/update + // was dropped: the thread sat on "Working" forever while the provider + // streamed its whole turn. Every other test here calls startSession directly + // from the test fiber, which never completes, so the consumer survived and + // the bug stayed invisible. Running it in a fiber that finishes is what + // reproduces production. + it.effect("keeps consuming notifications after the startSession fiber completes", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-consumer-outlives-start-session"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const startSessionFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber).pipe(Effect.timeout("10 seconds")); + + // Forked, and the assertion waits on the projected event rather than on + // sendTurn: with the consumer dead the turn never settles, so awaiting it + // directly would hang until the suite timeout instead of failing here. + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hello grok", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("10 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("10 seconds")); + + const delta = runtimeEvents.find( + (event) => event.type === "content.delta" && String(event.threadId) === String(threadId), + ); + assert.isDefined( + delta, + "no content.delta was projected after the startSession fiber completed", + ); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + // Live clock so the timeouts above are real: under the default test clock + // they wait on virtual time that never advances, and a regression would + // hang until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caaddb..858d862e6d5f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -876,7 +876,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte Effect.catch((cause) => Effect.logError("Failed to process Grok runtime notification.", { cause }), ), - Effect.forkChild, + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), ); ctx.notificationFiber = nf; From 75472802bc5ddaba860dc652000223600e529937 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:43:20 +0200 Subject: [PATCH 003/196] fix(server): treat removed Bitbucket permissions endpoint as unknown, not blocking (#6525) --- .../BitbucketPullRequestApi.test.ts | 40 +++++++++++++++++++ .../pullRequest/BitbucketPullRequestApi.ts | 19 ++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index f57bb67a4c40..4120cf55e622 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -867,6 +867,46 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); + it.effect( + "reads a removed permissions endpoint as granted rather than failing the merge on it", + () => + Effect.gen(function* () { + // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now + // gets HTTP 410 here, whatever it may do. + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 410, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" })); + }), + ); + + it.effect("still fails the permission read on a failure that is not the removed endpoint", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 401, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" })); + + assert.strictEqual(error._tag, "BitbucketResponseError"); + }), + ); + it.effect("reads the workspace's people and marks whoever is already a reviewer", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index a20d4aaaa056..a2c57bfc5fdb 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -107,6 +107,16 @@ export type BitbucketPullRequestApiError = | BitbucketRepositoryUnsupportedError | BitbucketDiffCommitError; +/** + * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a + * permission — Bitbucket sends this for every account now, not only ones it would have refused. + */ +function isRepositoryPermissionRemovedError( + error: BitbucketPullRequestApiError, +): error is BitbucketApi.BitbucketResponseError { + return error._tag === "BitbucketResponseError" && error.status === 410; +} + /** * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no * error at all, so this is a number to respect rather than to push against. @@ -553,6 +563,13 @@ export const make = Effect.gen(function* () { // Nothing on the repository, the pull request or the workspace states what the credentials // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked // alongside the reads the detail was already making, so it costs no round trip of its own. + // + // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410 + // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a + // permission being refused, so it is read the same way an unreachable read already is + // elsewhere — as a permission that could not be learned, which grants rather than blocks, and + // leaves the actual merge or write to say why if the account may not do it. Any other failure + // (a bad token, a network fault, an unreadable body) still fails as it did before. getRepositoryPermission: (input) => withRepository(input.repository, () => readPage({ @@ -562,7 +579,7 @@ export const make = Effect.gen(function* () { )}`, decode: decodeRepositoryPermissionJson, }), - ), + ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), getPullRequestDiff: (input) => input.commit !== undefined && !isCommitSha(input.commit) From 672216d7e152241213a8757892f281e1f4434e8a Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 12:43:42 +0200 Subject: [PATCH 004/196] fix(ssh): let cold remote servers finish starting (#6168) --- packages/ssh/src/tunnel.test.ts | 34 +++++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 4 +++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 76b8ecccb304..be17b8ffaf35 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -45,6 +45,16 @@ const makeSuccessfulProcess = (stdout: string) => { }); }; +const makeDelayedSuccessfulProcess = (stdout: string, delayMs: number) => { + const process = makeSuccessfulProcess(stdout); + return { + ...process, + exitCode: Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; +}; + const makeRunningProcess = (onKill: () => void) => { let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; return ChildProcessSpawner.makeHandle({ @@ -174,6 +184,7 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); assert.notInclude(buildRemoteLaunchScript(), "server-home"); assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); + assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemotePairingScript(target), @@ -235,6 +246,29 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("allows cold remote launches to exceed the default SSH command timeout", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 75_000)), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); + + return Effect.gen(function* () { + const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(75)); + + const result = yield* Fiber.join(fiber); + assert.equal(result.remotePort, 3774); + }).pipe(Effect.provide(processLayer)); + }); + it("allows the remote port picker to run without a state file path", () => { assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 179d1fcb547d..a1611c5770f4 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -54,7 +54,8 @@ const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; -const REMOTE_READY_TIMEOUT_MS = 15_000; +const REMOTE_READY_TIMEOUT_MS = 60_000; +const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { @@ -705,6 +706,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), + timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), From 1e87029261f9b81061a2a7420849b9eeaf1a2ebe Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:43:50 +0800 Subject: [PATCH 005/196] fix(web): preserve Claude insight line breaks (#4344) --- .../components/chat/MessagesTimeline.logic.test.ts | 12 ++++++++++++ .../src/components/chat/MessagesTimeline.logic.ts | 4 ++++ apps/web/src/components/chat/MessagesTimeline.tsx | 2 ++ 3 files changed, 18 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d74204bc1ca..70a330d46303 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -5,8 +5,20 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + shouldPreserveAssistantLineBreaks, } from "./MessagesTimeline.logic"; +describe("shouldPreserveAssistantLineBreaks", () => { + it("preserves Claude insight formatting without changing regular markdown", () => { + expect( + shouldPreserveAssistantLineBreaks( + "★ Insight ─────────────────\\nFirst observation\\nSecond observation\\n─────────────────", + ), + ).toBe(true); + expect(shouldPreserveAssistantLineBreaks("A normal\\nmarkdown paragraph")).toBe(false); + }); +}); + describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { const result = computeMessageDurationStart([ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 6bc0a2a6203c..c89bbd0557d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -52,6 +52,10 @@ export function resolveTimelineIsAtEnd( return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } +export function shouldPreserveAssistantLineBreaks(text: string): boolean { + return /^★ Insight(?:\s|─)/mu.test(text); +} + export function resolveTimelineMinimapHeightStyle(itemCount: number): string { const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING); return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f9ad57ff3b83..f5c529ff315f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -83,6 +83,7 @@ import { resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, + shouldPreserveAssistantLineBreaks, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -1113,6 +1114,7 @@ function AssistantTimelineRow({ row }: { row: Extract Date: Sat, 15 Aug 2026 03:43:58 -0700 Subject: [PATCH 006/196] feat(web): accept file drops across the chat workspace (#6636) --- apps/web/src/components/ChatView.tsx | 42 +++++++++- apps/web/src/components/chat/ChatComposer.tsx | 49 ++---------- .../components/chat/workspaceFileDrop.test.ts | 78 +++++++++++++++++++ .../src/components/chat/workspaceFileDrop.ts | 54 +++++++++++++ 4 files changed, 180 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/chat/workspaceFileDrop.test.ts create mode 100644 apps/web/src/components/chat/workspaceFileDrop.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6eab33aec1c9..7a5bde6345c0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -141,6 +141,7 @@ import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, @@ -164,6 +165,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + PaperclipIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; @@ -1336,6 +1338,7 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -1356,6 +1359,17 @@ function ChatViewContent(props: ChatViewProps) { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); + + useEffect(() => { + setIsWorkspaceFileDragActive(false); + }, [draftId, routeThreadKey]); + + useEffect(() => { + if (!isWorkspaceFileDragActive) return; + const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false); + window.addEventListener("dragend", clearWorkspaceFileDrag); + return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); + }, [isWorkspaceFileDragActive]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); @@ -6149,6 +6163,11 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ + setDragActive: setIsWorkspaceFileDragActive, + addFiles: (files) => composerRef.current?.addDroppedFiles(files), + }); + return (
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null} @@ -6217,7 +6236,28 @@ function ChatViewContent(props: ChatViewProps) { {/* Main content area with optional plan sidebar */}
{/* Chat column */} -
+
+ {isWorkspaceFileDragActive ? ( +
+
+
+
+ ) : null} {/* Provider status overlays the timeline without changing its content height. */}
void; focusAt: (cursor: number) => void; + addDroppedFiles: (files: File[]) => void; insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; openModelPicker: () => void; toggleModelPicker: () => void; @@ -971,7 +972,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); - const dragDepthRef = useRef(0); const stashPulseKeyRef = useRef(0); const stashPulseTimeoutRef = useRef(null); /** @@ -1399,7 +1399,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); - dragDepthRef.current = 0; setIsDragOverComposer(false); }, [draftId, activeThreadId, promptRef]); @@ -2380,41 +2379,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void addComposerImages(imageFiles); }; - const onComposerDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragOverComposer(true); - }; - - const onComposerDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setIsDragOverComposer(true); - }; - - const onComposerDragLeave = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) { - setIsDragOverComposer(false); - } - }; - - const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - void addComposerImages(files); - focusComposer(); - }; - const insertComposerTextAtEnd = ( text: string, options?: { ensureLeadingBoundary?: boolean }, @@ -2468,7 +2432,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { if (!isDragOverComposer) return; const onWindowDragEnd = () => { - dragDepthRef.current = 0; setIsDragOverComposer(false); }; window.addEventListener("dragend", onWindowDragEnd); @@ -2537,6 +2500,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, + addDroppedFiles: (files: File[]) => { + void addComposerImages(files); + focusComposer(); + }, insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { setIsComposerModelPickerOpen(true); @@ -2619,6 +2586,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [ activeThread, + addComposerImages, composerDraftTarget, composerCursor, composerTerminalContexts, @@ -2629,6 +2597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerElementContextsRef, composerPreviewAnnotations, composerReviewComments, + focusComposer, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2660,10 +2629,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "group rounded-[22px] p-px transition-colors duration-200", composerProviderState.composerFrameClassName, )} - onDragEnter={onComposerDragEnter} - onDragOver={onComposerDragOver} - onDragLeave={onComposerDragLeave} - onDrop={onComposerDrop} onDragEnterCapture={composerMentionDragHandlers.onDragEnter} onDragOverCapture={composerMentionDragHandlers.onDragOver} onDragLeaveCapture={onComposerMentionDragLeaveCapture} diff --git a/apps/web/src/components/chat/workspaceFileDrop.test.ts b/apps/web/src/components/chat/workspaceFileDrop.test.ts new file mode 100644 index 000000000000..ec5d074a3eb7 --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + makeWorkspaceFileDropHandlers, + type WorkspaceFileDragEvent, + type WorkspaceFileDropHost, +} from "./workspaceFileDrop"; + +function makeDragEvent(options?: { + types?: string[]; + files?: File[]; + movedWithinTarget?: boolean; +}) { + const preventDefault = vi.fn(); + const event = { + dataTransfer: { + types: options?.types ?? ["Files"], + files: options?.files ?? [], + dropEffect: "none", + }, + relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, + currentTarget: { + contains: () => options?.movedWithinTarget ?? false, + }, + preventDefault, + } satisfies WorkspaceFileDragEvent; + return { event, preventDefault }; +} + +function makeHost() { + const setDragActive = vi.fn(); + const addFiles = vi.fn(); + const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; + return { host, setDragActive, addFiles }; +} + +describe("makeWorkspaceFileDropHandlers", () => { + it("activates the target for an external file drag", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent(); + + makeWorkspaceFileDropHandlers(host).onDragEnter(event); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(setDragActive).toHaveBeenCalledWith(true); + }); + + it("ignores non-file drags", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent({ types: ["text/plain"] }); + + makeWorkspaceFileDropHandlers(host).onDragOver(event); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("does not flicker when the drag moves between children", () => { + const { host, setDragActive } = makeHost(); + const { event } = makeDragEvent({ movedWithinTarget: true }); + + const handlers = makeWorkspaceFileDropHandlers(host); + handlers.onDragEnter(event); + handlers.onDragLeave(event); + + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("forwards dropped files and clears the active state", () => { + const file = new File(["contents"], "example.txt", { type: "text/plain" }); + const { host, setDragActive, addFiles } = makeHost(); + const { event } = makeDragEvent({ files: [file] }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(setDragActive).toHaveBeenCalledWith(false); + expect(addFiles).toHaveBeenCalledWith([file]); + }); +}); diff --git a/apps/web/src/components/chat/workspaceFileDrop.ts b/apps/web/src/components/chat/workspaceFileDrop.ts new file mode 100644 index 000000000000..132a8051e159 --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.ts @@ -0,0 +1,54 @@ +export interface WorkspaceFileDragEvent { + readonly dataTransfer: { + readonly types: ReadonlyArray; + readonly files: Iterable; + dropEffect: string; + }; + readonly relatedTarget: EventTarget | null; + readonly currentTarget: { + contains(target: Node | null): boolean; + }; + preventDefault(): void; +} + +export interface WorkspaceFileDropHost { + setDragActive(active: boolean): void; + addFiles(files: File[]): void; +} + +function isFileDrag(event: WorkspaceFileDragEvent): boolean { + return event.dataTransfer.types.includes("Files"); +} + +function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { + return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); +} + +export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { + return { + onDragEnter(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(true); + }, + onDragOver(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + host.setDragActive(true); + }, + onDragLeave(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(false); + }, + onDrop(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + host.setDragActive(false); + host.addFiles(Array.from(event.dataTransfer.files)); + }, + }; +} From eaa6c4712fe11f0396e549b1873f163dc202d229 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:01 +0200 Subject: [PATCH 007/196] fix(web): widen ordered-list marker gutter for 3+ digit item numbers (#6527) --- apps/web/src/components/ChatMarkdown.test.tsx | 36 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 29 +++++++++++++++ apps/web/src/index.css | 14 ++++++-- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.test.tsx diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 000000000000..9499ee5a6915 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { orderedListGutterStyle } from "./ChatMarkdown"; + +describe("orderedListGutterStyle", () => { + it("leaves the default gutter alone for single-digit lists", () => { + expect(orderedListGutterStyle(9, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for two-digit lists", () => { + expect(orderedListGutterStyle(99, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for a two-digit list that starts above 1", () => { + // start=50 + 49 items => last marker is "98", still two digits. + expect(orderedListGutterStyle(49, 50)).toBeUndefined(); + }); + + it("widens the gutter once the last marker reaches three digits", () => { + // item 100 is the bug from #6512: a 100-item list starting at 1. + expect(orderedListGutterStyle(100, undefined)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("accounts for a non-default start attribute", () => { + // start=95 + 9 items => last marker is "103", three digits. + expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("scales further for four-digit markers", () => { + expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); + }); + + it("treats a missing/zero item count as a single item", () => { + expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 53b043f3a8ff..294a9e22ad75 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -146,6 +146,26 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]); } + +/** + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits two-digit + * decimal markers. Once a list's last item reaches three digits (item 100+), + * `list-style-position: outside` paints the marker wider than that gutter and + * the leading digit gets clipped by the item's own overflow. Rather than + * widening the gutter for every list, only lists whose last marker is 3+ + * digits get a wider `--list-gutter`, sized to that marker's digit count. + */ +export function orderedListGutterStyle( + itemCount: number, + start: number | undefined, +): { "--list-gutter": string } | undefined { + const firstNumber = typeof start === "number" && Number.isFinite(start) ? start : 1; + const lastNumber = firstNumber + Math.max(itemCount - 1, 0); + const digits = String(Math.abs(lastNumber)).length; + if (digits <= 2) return undefined; + return { "--list-gutter": `${digits + 1}ch` }; +} + const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -1506,6 +1526,15 @@ function ChatMarkdown({
); }, + ol({ node, start, style, ...props }) { + const itemCount = + node?.children?.filter((child) => child.type === "element" && child.tagName === "li") + .length ?? 0; + const gutterStyle = orderedListGutterStyle(itemCount, start); + return ( +
    + ); + }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b2c914c0b69f..299506c30ad2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1630,12 +1630,22 @@ code { } .chat-markdown ul { + /* Reset for nested uls under a widened ol — --list-gutter is an inherited + custom property, so without this a task-list under a 3+ digit ordered + list would inherit the outer gutter instead of its own default. */ + --list-gutter: 1.25rem; padding-left: 1.25rem; list-style-type: disc; } +/* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but + ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose + last marker is 3+ digits, so item 100+ isn't clipped by list-style-position: + outside painting the marker past the padding box. Reset it here too so a + nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { - padding-left: 1.25rem; + --list-gutter: 1.25rem; + padding-left: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -1665,7 +1675,7 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em -1.25rem; + margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); vertical-align: middle; } From 71c6f8248775066ebaf4bfc6680d3e2acb4bb2d1 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:03 +0200 Subject: [PATCH 008/196] fix(server): bound thread activity hydration (#6153) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .../Layers/ProjectionSnapshotQuery.test.ts | 124 +++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 203 +++++++++++++++--- 2 files changed, 299 insertions(+), 28 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index be596b36b850..83ae3cfe049a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2281,6 +2281,130 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("bounds activity hydration and preserves unresolved requests", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql` + WITH RECURSIVE activity_rows(sequence) AS ( + SELECT 1 + UNION ALL + SELECT sequence + 1 FROM activity_rows WHERE sequence < 501 + ) + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + SELECT + printf('activity-%04d', sequence), + 'thread-w', + 'turn-5', + 'tool', + 'tool.completed', + 'ran tool', + printf('{"sequence":%d}', sequence), + sequence, + '2026-03-01T00:04:00.000Z' + FROM activity_rows + `; + + const fullDetail = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(fullDetail._tag, "Some"); + if (fullDetail._tag === "Some") { + assert.equal(fullDetail.value.activities.length, 500); + assert.equal(fullDetail.value.activities[0]?.id, asEventId("activity-0002")); + assert.equal(fullDetail.value.activities.at(-1)?.id, asEventId("activity-0501")); + } + + const windowedDetail = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowedDetail._tag, "Some"); + if (windowedDetail._tag === "Some") { + assert.equal(windowedDetail.value.thread.activities.length, 500); + assert.equal(windowedDetail.value.thread.activities[0]?.id, asEventId("activity-0002")); + assert.equal(windowedDetail.value.thread.activities.at(-1)?.id, asEventId("activity-0501")); + } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + VALUES + ( + 'approval-old', 'thread-w', NULL, 'approval', 'approval.requested', + 'Approve old command', '{"requestId":"approval-1"}', NULL, + '2026-03-01T00:00:01.000Z' + ), + ( + 'user-input-old', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Answer old question', '{"requestId":"input-1"}', NULL, + '2026-03-01T00:00:02.000Z' + ), + ( + 'user-input-closed', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:03.000Z' + ), + ( + 'user-input-closed-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:04.000Z' + ), + ( + 'user-input-tied-z-request', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ), + ( + 'user-input-tied-a-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ) + `; + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, thread_id, turn_id, status, decision, created_at, resolved_at + ) + VALUES ( + 'approval-1', 'thread-w', NULL, 'pending', NULL, + '2026-03-01T00:00:01.000Z', NULL + ) + `; + yield* sql` + UPDATE projection_threads + SET pending_approval_count = 1, pending_user_input_count = 1 + WHERE thread_id = 'thread-w' + `; + + const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(detailWithPinnedRequests._tag, "Some"); + if (detailWithPinnedRequests._tag === "Some") { + const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id); + assert.equal(detailWithPinnedRequests.value.activities.length, 503); + assert.equal(ids.includes(asEventId("approval-old")), true); + assert.equal(ids.includes(asEventId("user-input-old")), true); + assert.equal(ids.includes(asEventId("user-input-closed")), false); + assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + } + + const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowWithPinnedRequests._tag, "Some"); + if (windowWithPinnedRequests._tag === "Some") { + const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id); + assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503); + assert.equal(ids.includes(asEventId("approval-old")), true); + assert.equal(ids.includes(asEventId("user-input-old")), true); + assert.equal(ids.includes(asEventId("user-input-closed")), false); + assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + } + }), + ); + it.effect("a thread with no turns returns its content unwindowed on the first page", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3e77f9cf875a..c6c5ad1d7e8c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -69,6 +69,10 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +// Keep detail reads consistent with the in-memory projector's retained +// activity window. Applying the limit in SQL avoids decoding an unbounded +// payload_json set before the projector can enforce that invariant. +const THREAD_DETAIL_ACTIVITY_LIMIT = 500; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -1015,8 +1019,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -1232,6 +1253,95 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Blocking request payloads must remain available even if they predate the + // recent activity window. Each CTE returns at most one unresolved row per + // request, so the merge below stays bounded by actionable work. + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + WITH pending_approval_requests AS ( + SELECT request_id, thread_id + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + AND status = 'pending' + ), + pending_approval_activities AS ( + SELECT + activity.activity_id, + ROW_NUMBER() OVER ( + PARTITION BY pending.request_id + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_approval_requests AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') = pending.request_id + ), + pending_user_input_thread AS ( + SELECT thread_id + FROM projection_threads + WHERE thread_id = ${threadId} + AND pending_user_input_count > 0 + ), + user_input_lifecycle AS ( + SELECT + activity.activity_id, + activity.kind, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_user_input_thread AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND ( + activity.kind IN ('user-input.requested', 'user-input.resolved') + OR ( + activity.kind = 'provider.user-input.respond.failed' + AND ( + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%stale pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending codex user input request%' + ) + ) + ) + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + ), + pinned_activity_ids AS ( + SELECT activity_id + FROM pending_approval_activities + WHERE request_order = 1 + UNION ALL + SELECT activity_id + FROM user_input_lifecycle + WHERE request_order = 1 + AND kind = 'user-input.requested' + ) + SELECT + activity.activity_id AS "activityId", + activity.thread_id AS "threadId", + activity.turn_id AS "turnId", + activity.tone, + activity.kind, + activity.summary, + activity.payload_json AS "payload", + activity.sequence, + activity.created_at AS "createdAt" + FROM pinned_activity_ids AS pinned + INNER JOIN projection_thread_activities AS activity + ON activity.activity_id = pinned.activity_id + ORDER BY activity.created_at ASC, activity.activity_id ASC + `, + }); + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ Request: ThreadTurnRangeLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1247,34 +1357,51 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} - AND ( - turn_id IN ( - SELECT turn_id FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id IS NOT NULL - AND ( - requested_at > ${minAnchorAt} - OR ( - requested_at = ${minAnchorAt} - AND turn_id >= ${minTurnKey} + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) ) - ) - AND ( - requested_at < ${beforeAnchorAt} - OR ( - requested_at = ${beforeAnchorAt} - AND turn_id < ${beforeTurnKey} + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) ) - ) - ) - OR ( - turn_id IS NULL - AND created_at >= ${minAnchorAt} - AND created_at < ${beforeAnchorAt} + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) ) - ) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -2374,6 +2501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { messageRows, proposedPlanRows, activityRows, + pinnedActivityRows, checkpointRows, latestTurnRow, sessionRow, @@ -2416,6 +2544,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ), listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2446,6 +2582,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } + const selectedActivityRows = [ + ...new Map( + [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const), + ).values(), + ].toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ); + const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2483,7 +2630,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: activityRows.map((row) => { + activities: selectedActivityRows.map((row) => { const activity = { id: row.activityId, tone: row.tone, From 48cba7d93c8c63508f31cce2544d480ace86f929 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:19 +0200 Subject: [PATCH 009/196] fix(web): restore the Archive action in the default sidebar thread menu (#6526) --- apps/web/src/components/Sidebar.tsx | 34 +++++++++++++++++++ .../components/threadActionMenu.logic.test.ts | 27 ++++++++++++++- .../src/components/threadActionMenu.logic.ts | 9 +++++ apps/web/src/hooks/useThreadActionMenu.ts | 26 ++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2f0c5a221405..a7a5b638c0eb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1622,6 +1622,7 @@ export default function Sidebar() { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -1633,6 +1634,7 @@ export default function Sidebar() { pinThread, unpinThread, reorderPinnedThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -2990,6 +2992,8 @@ export default function Sidebar() { isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, + isRunning: + thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, snooze: supportsSnooze, @@ -3093,6 +3097,34 @@ export default function Sidebar() { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: didArchive + ? "Thread archived, but navigation failed" + : "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -3126,12 +3158,14 @@ export default function Sidebar() { })(); }, [ + archiveThread, attemptPin, attemptSettle, attemptSnooze, attemptUnpin, attemptUnsettle, attemptUnsnooze, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e7c0a..c839ddc3be75 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = { isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, + isRunning: false, supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, @@ -26,7 +27,7 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]); }); it("includes branch items only for threads with a branch", () => { @@ -63,4 +64,28 @@ describe("buildThreadActionMenuItems", () => { const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); }); + + it("offers archive as a non-destructive action right before delete", () => { + const items = buildThreadActionMenuItems(baseState); + const archiveItem = items.at(-2); + expect(archiveItem?.id).toBe("archive"); + expect(archiveItem?.destructive).toBeFalsy(); + expect(items.at(-1)?.id).toBe("delete"); + }); + + it("keeps archive available even when the environment lacks every other capability", () => { + expect( + ids({ + ...baseState, + supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + }), + ).toContain("archive"); + }); + + it("disables archive while the thread is running", () => { + const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( + (item) => item.id === "archive", + ); + expect(archiveItem?.disabled).toBe(true); + }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ef4b38dcdacd..44c2e907ca55 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -21,6 +21,7 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "archive" | "delete"; export interface ThreadActionMenuState { @@ -30,6 +31,8 @@ export interface ThreadActionMenuState { readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; + /** Archive rejects a thread with an active turn, so disable it here rather than let the action fail. */ + readonly isRunning: boolean; readonly supports: { readonly settlement: boolean; readonly snooze: boolean; @@ -102,6 +105,12 @@ export function buildThreadActionMenuItems( { id: "copy-path", label: "Copy path", icon: "copy" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, + // Archive removes the thread from the sidebar while keeping its + // conversation under Settings > Archived threads — distinct from Settle + // (stays visible in the Settled shelf) and Delete (clears history for + // good), so it sits beside Delete without borrowing its destructive + // styling. + { id: "archive", label: "Archive thread", disabled: state.isRunning }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d7ca2305163f..4a25df47b027 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -72,6 +72,7 @@ export function useThreadActionMenu(input: { unsnoozeThread, pinThread, unpinThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -82,6 +83,7 @@ export function useThreadActionMenu(input: { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { @@ -139,6 +141,7 @@ export function useThreadActionMenu(input: { isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, + isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports, snoozePresets, }); @@ -253,6 +256,27 @@ export function useThreadActionMenu(input: { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast( + didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", + squashAtomCommandFailure(result), + ); + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -285,9 +309,11 @@ export function useThreadActionMenu(input: { })(); }, [ + archiveThread, autoSettleAfterDays, autoSettleOnMerge, changeRequestState, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, From 9f26656cb958853f90f7215387d604c098937db8 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 12:44:22 +0200 Subject: [PATCH 010/196] fix(web): open diff files from nested projects (#6174) --- apps/web/src/components/DiffPanel.tsx | 6 +- apps/web/src/diffFileActions.test.ts | 75 ++++++++++++++++++++++++- apps/web/src/diffFileActions.ts | 79 ++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index b929d05a719b..66f0a4e111b2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -134,6 +134,9 @@ export default function DiffPanel({ : null, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; + const activeRepositoryRoot = activeThread?.worktreePath + ? undefined + : activeProject?.repositoryIdentity?.rootPath; const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); @@ -443,6 +446,7 @@ export default function DiffPanel({ threadRef: routeThreadRef, filePath, activeCwd, + repositoryRoot: activeRepositoryRoot, openInEditor: (targetPath) => { void (async () => { const result = await openInPreferredEditor(targetPath); @@ -462,7 +466,7 @@ export default function DiffPanel({ }, }); }, - [activeCwd, openInPreferredEditor, routeThreadRef], + [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef], ); const toggleDiffFileCollapsed = useCallback( (fileKey: string) => { diff --git a/apps/web/src/diffFileActions.test.ts b/apps/web/src/diffFileActions.test.ts index 9c358ab1d294..c5d3571a9c1e 100644 --- a/apps/web/src/diffFileActions.test.ts +++ b/apps/web/src/diffFileActions.test.ts @@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { openDiffFilePrimaryAction } from "./diffFileActions"; +import { openDiffFilePrimaryAction, resolveDiffPathForWorkspace } from "./diffFileActions"; import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; const THREAD_REF = scopeThreadRef( @@ -48,4 +48,77 @@ describe("openDiffFilePrimaryAction", () => { "/repo/project/apps/web/src/components/DiffPanel.tsx", ); }); + + it("opens repository-relative diff files from a nested project", () => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath: "frontend/Dockerfile", + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: "file:Dockerfile", + }); + expect(openInEditor).not.toHaveBeenCalled(); + }); + + it("preserves repository-relative paths in a separate worktree", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/Dockerfile", + workspaceRoot: "/worktrees/feature", + repositoryRoot: "/repo", + }), + ).toBe("frontend/Dockerfile"); + }); + + it("handles Windows roots and mixed diff separators", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "Frontend/src\\index.ts", + workspaceRoot: "C:\\repo\\frontend", + repositoryRoot: "C:\\repo", + }), + ).toBe("src/index.ts"); + }); + + it.each([ + { workspaceRoot: "/frontend", repositoryRoot: "/" }, + { workspaceRoot: "C:\\frontend", repositoryRoot: "C:\\" }, + ])("handles filesystem roots: $repositoryRoot", ({ workspaceRoot, repositoryRoot }) => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/index.ts", + workspaceRoot, + repositoryRoot, + }), + ).toBe("index.ts"); + }); + + it.each(["backend/server.ts", "frontend2/app.ts", "frontend/../secret.ts", "C:secret.ts"])( + "does not open an out-of-project diff path: %s", + (filePath) => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath, + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ isOpen: false }); + expect(openInEditor).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts index 335ad21fccf9..3ac22c28cf25 100644 --- a/apps/web/src/diffFileActions.ts +++ b/apps/web/src/diffFileActions.ts @@ -1,4 +1,5 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; +import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { useRightPanelStore } from "./rightPanelStore"; import { resolvePathLinkTarget } from "./terminal-links"; @@ -7,19 +8,93 @@ interface OpenDiffFilePrimaryActionInput { readonly threadRef: ScopedThreadRef | null; readonly filePath: string; readonly activeCwd: string | undefined; + readonly repositoryRoot?: string | undefined; readonly openInEditor: (targetPath: string) => void; } +function normalizedRelativePathSegments(filePath: string): ReadonlyArray | null { + if (filePath.startsWith("/") || isWindowsAbsolutePath(filePath) || /^[a-zA-Z]:/.test(filePath)) { + return null; + } + + const segments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0 && segment !== "."); + if (segments.length === 0 || segments.includes("..")) return null; + return segments; +} + +function repositoryRelativeWorkspaceSegments( + workspaceRoot: string | undefined, + repositoryRoot: string | undefined, +): ReadonlyArray | null { + if (!workspaceRoot || !repositoryRoot) return null; + + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(workspaceRoot); + const normalizedRepositoryRoot = normalizeProjectPathForComparison(repositoryRoot); + if (normalizedWorkspaceRoot === normalizedRepositoryRoot) return []; + + const separator = normalizedRepositoryRoot.includes("\\") ? "\\" : "/"; + const repositoryPrefix = normalizedRepositoryRoot.endsWith(separator) + ? normalizedRepositoryRoot + : `${normalizedRepositoryRoot}${separator}`; + if (!normalizedWorkspaceRoot.startsWith(repositoryPrefix)) return null; + + return normalizedWorkspaceRoot + .slice(repositoryPrefix.length) + .split(/[\\/]+/) + .filter(Boolean); +} + +export function resolveDiffPathForWorkspace(input: { + readonly filePath: string; + readonly workspaceRoot: string | undefined; + readonly repositoryRoot: string | undefined; +}): string | null { + const fileSegments = normalizedRelativePathSegments(input.filePath); + if (!fileSegments) return null; + + const workspaceSegments = repositoryRelativeWorkspaceSegments( + input.workspaceRoot, + input.repositoryRoot, + ); + if (!workspaceSegments || workspaceSegments.length === 0) { + return fileSegments.join("/"); + } + + const caseInsensitive = input.repositoryRoot + ? isWindowsAbsolutePath(input.repositoryRoot) + : false; + const belongsToWorkspace = workspaceSegments.every((segment, index) => { + const candidate = fileSegments[index]; + if (candidate === undefined) return false; + return caseInsensitive ? candidate.toLowerCase() === segment : candidate === segment; + }); + if (!belongsToWorkspace) return null; + + const relativeSegments = fileSegments.slice(workspaceSegments.length); + return relativeSegments.length > 0 ? relativeSegments.join("/") : null; +} + export function openDiffFilePrimaryAction({ threadRef, filePath, activeCwd, + repositoryRoot, openInEditor, }: OpenDiffFilePrimaryActionInput): void { + const workspaceFilePath = resolveDiffPathForWorkspace({ + filePath, + workspaceRoot: activeCwd, + repositoryRoot, + }); + if (!workspaceFilePath) return; + if (threadRef) { - useRightPanelStore.getState().openFile(threadRef, filePath); + useRightPanelStore.getState().openFile(threadRef, workspaceFilePath); return; } - openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath); + openInEditor(activeCwd ? resolvePathLinkTarget(workspaceFilePath, activeCwd) : workspaceFilePath); } From b277cc65e045899e7aa941e92d04f2b4996f27bb Mon Sep 17 00:00:00 2001 From: mohamedmastouri-hue Date: Sat, 15 Aug 2026 11:44:30 +0100 Subject: [PATCH 011/196] fix(mobile): use tryOpenExternalUrl for markdown links in ThreadFeed (#5872) Co-authored-by: codex Co-authored-by: Julius Marminge --- apps/mobile/src/features/threads/ThreadFeed.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d138bb0c99dd..c5edb822ae5b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -28,7 +28,6 @@ import { import { ActivityIndicator, Image, - Linking, Platform, type LayoutChangeEvent, type NativeScrollEvent, @@ -51,6 +50,7 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, @@ -283,7 +283,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { { - void Linking.openURL(props.href); + void tryOpenExternalUrl(props.href, "markdown-link"); }} style={{ color: props.color, @@ -613,7 +613,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe onPress={ linkHref ? () => { - void Linking.openURL(linkHref); + void tryOpenExternalUrl(linkHref, "markdown-link"); } : undefined } @@ -1436,7 +1436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } if (presentation.href) { - void Linking.openURL(presentation.href); + void tryOpenExternalUrl(presentation.href, "markdown-link"); } }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], From 2cb1a26f061fa9029ccbe2a614f02bb14b22dd45 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Sat, 15 Aug 2026 12:44:51 +0200 Subject: [PATCH 012/196] fix(web): open the file a bare filename reference names (#6297) Co-authored-by: Rodrigo Brechard Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/components/ChatMarkdown.tsx | 53 ++++++++++- apps/web/src/workspaceBasenameLookup.test.ts | 93 ++++++++++++++++++++ apps/web/src/workspaceBasenameLookup.ts | 48 ++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/workspaceBasenameLookup.test.ts create mode 100644 apps/web/src/workspaceBasenameLookup.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 294a9e22ad75..ec88bc912f00 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -90,6 +90,13 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { projectEnvironment } from "../state/projects"; +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, + WORKSPACE_BASENAME_LOOKUP_LIMIT, +} from "../workspaceBasenameLookup"; import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; @@ -811,6 +818,7 @@ interface MarkdownFileLinkProps { theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen: (targetPath: string) => Promise>; + onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } @@ -1116,6 +1124,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ theme, threadRef, onOpen, + onOpenInPanel, onOpenInBrowser, className, }: MarkdownFileLinkProps) { @@ -1159,8 +1168,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor(); return; } - useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); - }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); + onOpenInPanel(workspaceRelativePath, line); + }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1336,6 +1345,7 @@ function areMarkdownFileLinkPropsEqual( previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && + previous.onOpenInPanel === next.onOpenInPanel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); @@ -1355,6 +1365,9 @@ function ChatMarkdown({ const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); + const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { + reportFailure: false, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -1457,6 +1470,40 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + // A bare filename resolves to the workspace root, which is rarely where the + // file is, so ask the index before opening. + const openFileInPanel = useCallback( + (workspaceRelativePath: string, line: number | undefined) => { + if (!threadRef) return; + // Claimed on every open so a synchronous one supersedes a lookup already + // in flight. + const isLatestLookup = claimWorkspaceBasenameLookup(); + const openAt = (path: string) => + useRightPanelStore.getState().openFile(threadRef, path, line); + if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + openAt(workspaceRelativePath); + return; + } + void (async () => { + const result = await searchProjectEntries({ + environmentId: threadRef.environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + const match = + result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + if (!isLatestLookup()) return; + openAt(match ?? workspaceRelativePath); + })(); + }, + [cwd, searchProjectEntries, threadRef], + ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that * metadata changes. */ @@ -1490,6 +1537,7 @@ function ChatMarkdown({ theme={resolvedTheme} threadRef={threadRef} onOpen={openInPreferredEditor} + onOpenInPanel={openFileInPanel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1718,6 +1766,7 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, onTaskListChange, + openFileInPanel, openInPreferredEditor, openExternalLinkInPreview, openMarkdownFileInPreview, diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts new file mode 100644 index 000000000000..e96e5f18b4f7 --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, +} from "./workspaceBasenameLookup"; + +describe("needsWorkspaceBasenameLookup", () => { + it("flags bare filenames", () => { + expect(needsWorkspaceBasenameLookup("ChatView.tsx")).toBe(true); + expect(needsWorkspaceBasenameLookup("Makefile")).toBe(true); + }); + + it("leaves anything with a directory alone", () => { + expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup(" ")).toBe(false); + }); +}); + +describe("pickWorkspaceBasenameMatch", () => { + const entries = [ + { path: "apps/web/src/components/ChatView.test.tsx", kind: "file" as const }, + { path: "apps/web/src/components/ChatView.tsx", kind: "file" as const }, + ]; + + it("takes the first exact filename match, not the closest fuzzy one", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("ignores directories", () => { + expect( + pickWorkspaceBasenameMatch("components", [ + { path: "apps/web/src/components", kind: "directory" }, + { path: "apps/web/src/components/components", kind: "file" }, + ]), + ).toBe("apps/web/src/components/components"); + }); + + it("prefers the exactly-cased file over a case-only twin", () => { + expect( + pickWorkspaceBasenameMatch("foo.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBe("src/foo.ts"); + }); + + it("falls back to case-insensitive when only the casing differs", () => { + expect(pickWorkspaceBasenameMatch("chatview.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("returns null when the case-insensitive fallback is ambiguous", () => { + expect( + pickWorkspaceBasenameMatch("FOO.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBeNull(); + }); + + it("returns null when nothing matches the name", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", [])).toBeNull(); + expect( + pickWorkspaceBasenameMatch("ChatView.tsx", [ + { path: "apps/web/src/components/ChatHeader.tsx", kind: "file" }, + ]), + ).toBeNull(); + }); +}); + +describe("claimWorkspaceBasenameLookup", () => { + it("keeps only the newest claim, whatever order the lookups settle in", () => { + const first = claimWorkspaceBasenameLookup(); + const second = claimWorkspaceBasenameLookup(); + + // The older lookup answering last must not reopen the panel behind the + // newer one. + expect(second()).toBe(true); + expect(first()).toBe(false); + }); + + it("stays valid while it is the only claim", () => { + const only = claimWorkspaceBasenameLookup(); + expect(only()).toBe(true); + expect(only()).toBe(true); + }); +}); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts new file mode 100644 index 000000000000..b99d3ba4ded9 --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -0,0 +1,48 @@ +// Enough hits to look past same-named neighbours (`ChatView.test.tsx`) without +// asking for a full listing on a single click. +export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; + +// One counter for every caller: they all open the same panel, so the newest +// click wins regardless of which one started the lookup. +let latestLookupSequence = 0; + +/** Call the returned predicate when the search settles; false means a later click superseded it. */ +export function claimWorkspaceBasenameLookup(): () => boolean { + latestLookupSequence += 1; + const claimed = latestLookupSequence; + return () => claimed === latestLookupSequence; +} + +export interface WorkspaceEntryCandidate { + readonly path: string; + readonly kind: "file" | "directory"; +} + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +export function needsWorkspaceBasenameLookup(relativePath: string): boolean { + const trimmed = relativePath.trim(); + return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\"); +} + +export function pickWorkspaceBasenameMatch( + basename: string, + entries: ReadonlyArray, +): string | null { + const target = basename.trim(); + if (!target) return null; + const files = entries.filter((entry) => entry.kind === "file"); + const exact = files.find((entry) => basenameOfPath(entry.path) === target); + if (exact) return exact.path; + // Folded matching covers casing that drifted from disk, but `FOO.ts` against + // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing + // rather than opening whichever the index ranked first. + const folded = target.toLowerCase(); + const foldedMatches = files.filter( + (entry) => basenameOfPath(entry.path).toLowerCase() === folded, + ); + return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; +} From ddee418a8d6d3e242ca26a8053a886ecc3b56b53 Mon Sep 17 00:00:00 2001 From: Ulises Britos <45952970+repparw@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:31:36 -0300 Subject: [PATCH 013/196] fix(server): stop the provider title mirror from overwriting real thread titles (#5941) --- .../Layers/ProviderCommandReactor.ts | 15 +---- .../Layers/ProviderRuntimeIngestion.test.ts | 57 ++++++++++++++-- .../Layers/ProviderRuntimeIngestion.ts | 15 +++-- apps/server/src/orchestration/threadTitles.ts | 13 ++++ .../provider/Layers/OpenCodeAdapter.test.ts | 67 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 20 +++++- packages/contracts/src/provider.ts | 1 + 7 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/orchestration/threadTitles.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179f..cfc95f2613fb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -39,6 +39,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { forkParked, ServerActivation } from "../../serverActivation.ts"; +import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { resolveSourceControlWriterModelSelection, ServerSettingsService, @@ -91,7 +92,6 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const DEFAULT_THREAD_TITLE = "New thread"; const MAX_REGENERATION_ATTACHMENTS = 4; const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000; @@ -227,18 +227,6 @@ export function providerErrorLabelFromInstanceHint(input: { ); } -function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { - const trimmedCurrentTitle = currentTitle.trim(); - if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { - return true; - } - - const trimmedTitleSeed = titleSeed?.trim(); - return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 - ? trimmedCurrentTitle === trimmedTitleSeed - : false; -} - function findProviderAdapterRequestError( cause: Cause.Cause, ): ProviderAdapterRequestError | undefined { @@ -626,6 +614,7 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(thread.title ? { title: thread.title } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 258aa010e3e6..449b1fbf5136 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -48,6 +48,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; +import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -221,7 +222,10 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness(options?: { serverSettings?: Partial }) { + async function createHarness(options?: { + serverSettings?: Partial; + threadTitle?: string; + }) { const workspaceRoot = makeTempDir("t3-provider-project-"); NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); @@ -277,7 +281,7 @@ describe("ProviderRuntimeIngestion", () => { commandId: CommandId.make("cmd-thread-create"), threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), - title: "Thread", + title: options?.threadTitle ?? "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -2915,7 +2919,7 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread( harness.readModel, (entry) => - entry.title === "Renamed by provider" && + entry.title === "Thread" && entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "turn.plan.updated", ) && @@ -2930,7 +2934,7 @@ describe("ProviderRuntimeIngestion", () => { ), ); - expect(thread.title).toBe("Renamed by provider"); + expect(thread.title).toBe("Thread"); const planActivity = thread.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-plan-updated", @@ -2971,6 +2975,51 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); + it("mirrors a provider title only while the thread still has the default title", async () => { + const harness = await createHarness({ threadTitle: DEFAULT_THREAD_TITLE }); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-default"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + metadata: { source: "provider" }, + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => entry.title === "Renamed by provider", + ); + expect(thread.title).toBe("Renamed by provider"); + }); + + it("rejects a provider title once the thread has a real title", async () => { + const harness = await createHarness({ threadTitle: "User-set title" }); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-real"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + metadata: { source: "provider" }, + }, + }); + + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("User-set title"); + }); + it("projects context window updates into normalized thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242e..c942960f3c68 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -43,6 +43,7 @@ import { } from "../Services/ProviderRuntimeIngestion.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; @@ -1892,12 +1893,14 @@ const make = Effect.gen(function* () { } if (event.type === "thread.metadata.updated" && event.payload.name) { - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* providerCommandId(event, "thread-meta-update"), - threadId: thread.id, - title: event.payload.name, - }); + if (canReplaceThreadTitle(thread.title)) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* providerCommandId(event, "thread-meta-update"), + threadId: thread.id, + title: event.payload.name, + }); + } } if (event.type === "turn.diff.updated") { diff --git a/apps/server/src/orchestration/threadTitles.ts b/apps/server/src/orchestration/threadTitles.ts new file mode 100644 index 000000000000..c9a9c4f72830 --- /dev/null +++ b/apps/server/src/orchestration/threadTitles.ts @@ -0,0 +1,13 @@ +export const DEFAULT_THREAD_TITLE = "New thread"; + +export function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { + const trimmedCurrentTitle = currentTitle.trim(); + if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { + return true; + } + + const trimmedTitleSeed = titleSeed?.trim(); + return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 + ? trimmedCurrentTitle === trimmedTitleSeed + : false; +} diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaabec..eea328e05d1e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1191,6 +1191,73 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("passes the thread title to session.create when provided", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-title-provided"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + title: "Investigate reconnect failures", + }); + + NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); + NodeAssert.equal( + runtimeMock.state.sessionCreateInputs[0]?.title, + "Investigate reconnect failures", + ); + }), + ); + + it.effect("does not mirror OpenCode's default placeholder session titles", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-placeholder-title"); + runtimeMock.state.subscribedEvents = [ + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "New session - 2026-08-09T10:20:30.456Z", + }, + }, + }, + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "Investigate reconnect failures", + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + 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"))); + const metadataUpdated = events.filter((event) => event.type === "thread.metadata.updated"); + NodeAssert.equal(metadataUpdated.length, 1); + if (metadataUpdated[0]?.type === "thread.metadata.updated") { + NodeAssert.equal(metadataUpdated[0].payload.name, "Investigate reconnect failures"); + } + }), + ); + it.effect("writes provider-native observability records using the session thread id", () => Effect.gen(function* () { const nativeEvents: Array<{ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 73c23b77e686..8f7e42c11d7c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -201,7 +201,24 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und return undefined; } - return trimText(event.properties.info.title); + const title = trimText(event.properties.info.title); + // OpenCode mints a placeholder title at session.create when no title was + // provided, and re-emits it on every `session.updated`. Mirroring it would + // overwrite the thread's real title (openCodeEventSessionTitle feeds the + // `thread.metadata.updated` mirror). Ignore OpenCode's auto-generated + // placeholders so the thread isn't locked onto them. + if (!title || isOpenCodeDefaultTitle(title)) { + return undefined; + } + + return title; +} + +const OPENCODE_DEFAULT_TITLE_PATTERN = + /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function isOpenCodeDefaultTitle(title: string): boolean { + return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } interface OpenCodeSessionContext { @@ -1302,6 +1319,7 @@ export function makeOpenCodeAdapter( } const createdSession = yield* runOpenCodeSdk("session.create", () => client.session.create({ + ...(input.title ? { title: input.title } : {}), permission: buildOpenCodePermissionRules(input.runtimeMode), }), ); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a7bc2..c84ad43c4e78 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -56,6 +56,7 @@ export const ProviderSessionStartInput = Schema.Struct({ // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), + title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), From 178da6bc3210b82c4a83f33c8b149f623a3375e1 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 13:31:56 +0200 Subject: [PATCH 014/196] fix(shared): match source-control providers by DNS label (#6175) --- packages/shared/src/sourceControl.test.ts | 29 +++++++++++++++++++++++ packages/shared/src/sourceControl.ts | 10 +++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index bfee883dd9f5..3842fa84b5a7 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -91,4 +91,33 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { baseUrl: "https://self-hosted.example.test:8443", }); }); + + it("matches self-hosted providers by complete DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://github.example.com/owner/repo.git")?.kind, + ).toBe("github"); + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.example.com/group/repo.git")?.kind, + ).toBe("gitlab"); + expect( + detectSourceControlProviderFromRemoteUrl("https://bitbucket.example.com/workspace/repo.git") + ?.kind, + ).toBe("bitbucket"); + }); + + it("does not match provider names embedded in unrelated DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://notgithub.example.com/owner/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl("https://notgitlab.example.com/group/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl( + "https://notbitbucket.example.com/workspace/repo.git", + )?.kind, + ).toBe("unknown"); + }); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index a29fe968e44d..ad6fa890bf25 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -167,12 +167,16 @@ function toBaseUrl(host: string): string { return `https://${host}`; } +function hasDnsLabel(host: string, label: string): boolean { + return host.split(".").includes(label); +} + function isGitHubHost(host: string): boolean { - return host === "github.com" || host.includes("github"); + return host === "github.com" || hasDnsLabel(host, "github"); } function isGitLabHost(host: string): boolean { - return host === "gitlab.com" || host.includes("gitlab"); + return host === "gitlab.com" || hasDnsLabel(host, "gitlab"); } function isAzureDevOpsHost(host: string): boolean { @@ -188,7 +192,7 @@ function isAzureDevOpsHost(host: string): boolean { } function isBitbucketHost(host: string): boolean { - return host === "bitbucket.org" || host.includes("bitbucket"); + return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } export function detectSourceControlProviderFromRemoteUrl( From b7dbbbaf6c394621cba57cf58dfcc1845f445ef6 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:33:29 +0300 Subject: [PATCH 015/196] feat(desktop): Chrome-style hold-to-quit (#5508) --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/wsl.test.ts | 12 ++ apps/desktop/src/preload.ts | 11 + .../settings/DesktopClientSettings.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 14 ++ apps/desktop/src/window/DesktopWindow.ts | 40 +++- apps/desktop/src/window/QuitHold.test.ts | 201 ++++++++++++++++++ apps/desktop/src/window/QuitHold.ts | 148 +++++++++++++ apps/web/src/AppRoot.test.tsx | 4 +- apps/web/src/AppRoot.tsx | 2 + apps/web/src/components/QuitHoldOverlay.tsx | 47 ++++ .../components/settings/SettingsPanels.tsx | 29 +++ .../settings/settingsSearch.test.ts | 5 + .../src/components/settings/settingsSearch.ts | 17 +- packages/contracts/src/ipc.ts | 6 + packages/contracts/src/settings.ts | 4 + 16 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/window/QuitHold.test.ts create mode 100644 apps/desktop/src/window/QuitHold.ts create mode 100644 apps/web/src/components/QuitHoldOverlay.tsx diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0e31082afb5f..ac1ee8792806 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -5,6 +5,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 3e07ae7f39bf..38435e286fa7 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; import * as DesktopState from "../../app/DesktopState.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ElectronTheme.ElectronTheme, ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), ), + Layer.succeed( + ElectronDialog.ElectronDialog, + ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]), + ), + Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]), + ), + DesktopClientSettings.layerTest(), ); describe("WSL IPC", () => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 61e345b90848..cbbadb708ab7 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -117,6 +117,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + onQuitShortcut: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { + if (state !== "down" && state !== "up") return; + listener(state); + }; + + ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + }; + }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 44c12cc554ad..23a75eb3f79d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + confirmQuit: true, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ed0fbf8b5688..42ba818acf5f 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -128,6 +130,14 @@ function makeFakeBrowserWindow() { }; } +const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ + get: Effect.succeed(Option.none()), +}); + +const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ + quit: Effect.void, +}); + const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { iconPaths: Effect.succeed({ ico: Option.none(), @@ -253,8 +263,10 @@ function makeTestLayer(input: { desktopAssetsLayer, desktopEnvironmentLayer, desktopAppSettingsLayer, + desktopClientSettingsLayer, desktopServerExposureLayer, DesktopState.layer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: (url) => @@ -356,7 +368,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n desktopAssetsLayer, desktopEnvironmentLayer, DesktopAppSettings.layerTest(), + desktopClientSettingsLayer, desktopServerExposureLayer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 2ae3d353279b..9018b9b92c2a 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,6 +8,8 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; @@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + QUIT_SHORTCUT_CHANNEL, + WINDOW_FULLSCREEN_STATE_CHANNEL, +} from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import { makeQuitHoldHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings + | DesktopClientSettings.DesktopClientSettings + | ElectronApp.ElectronApp | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -261,6 +272,8 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const electronApp = yield* ElectronApp.ElectronApp; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -533,7 +546,32 @@ export const make = Effect.gen(function* () { // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. // Deliberate presses still flow through the renderer or native menu. + // Chrome-style hold-to-quit: intercept the quit accelerator before the + // native menu sees it and only quit after the shortcut is held. The + // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. + const quitHoldHandler = makeQuitHoldHandler({ + platform: environment.platform, + isEnabled: () => + runPromise( + Effect.map( + clientSettings.get, + Option.match({ + onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, + onSome: (settings) => settings.confirmQuit, + }), + ), + ), + notify: (state) => { + if (!window.isDestroyed()) { + window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + } + }, + quit: () => { + void runPromise(electronApp.quit); + }, + }); window.webContents.on("before-input-event", (event, input) => { + quitHoldHandler(event, input); if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts new file mode 100644 index 000000000000..c900a865439e --- /dev/null +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + makeQuitHoldHandler, + QUIT_DOUBLE_TAP_MS, + QUIT_HOLD_DURATION_MS, + QUIT_HOLD_RELEASE_GRACE_MS, +} from "./QuitHold.ts"; +import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; + +function makeInput(overrides: Partial): QuitHoldKeyInput { + return { + type: "keyDown", + key: "q", + meta: true, + control: false, + alt: false, + shift: false, + isAutoRepeat: false, + ...overrides, + }; +} + +function makeHarness(options?: { + enabled?: boolean; + platform?: NodeJS.Platform; + isEnabled?: () => Promise; +}) { + const notifications: Array = []; + const quit = vi.fn(); + const handler = makeQuitHoldHandler({ + platform: options?.platform ?? "darwin", + isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), + notify: (state) => notifications.push(state), + quit, + }); + const preventDefault = vi.fn(); + const send = async (input: QuitHoldKeyInput) => { + handler({ preventDefault }, input); + // Let the isEnabled promise settle. + await Promise.resolve(); + await Promise.resolve(); + }; + // Simulates the OS auto-repeating the held shortcut every `intervalMs`. + const holdFor = async ( + durationMs: number, + repeatOverrides: Partial = {}, + intervalMs = 100, + ) => { + for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) { + vi.advanceTimersByTime(intervalMs); + await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); + } + }; + return { notifications, quit, preventDefault, send, holdFor }; +} + +describe("makeQuitHoldHandler", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows the hint on a tap without quitting, even when the release is never seen", async () => { + // macOS suppresses the letter's keyUp while Cmd is held, so a tap may + // produce no keyUp at all. Quit must still not fire. + const harness = makeHarness(); + await harness.send(makeInput({})); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual(["down"]); + + vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + expect(harness.quit).not.toHaveBeenCalled(); + // The watchdog dismisses the hint once the press is clearly over. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("quits once the shortcut auto-repeats past the hold duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(QUIT_HOLD_DURATION_MS - 200); + expect(harness.quit).not.toHaveBeenCalled(); + await harness.holdFor(400); + expect(harness.quit).toHaveBeenCalledTimes(1); + // Exactly one hint cycle for the whole hold. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("does not quit when the hold stops before the duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("cancels the hold when the modifier is released first", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("quits immediately on a single press when disabled", async () => { + const harness = makeHarness({ enabled: false }); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + // The hint is dismissed in case the quit gets cancelled downstream. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("discards a stale isEnabled resolution from a superseded press", async () => { + // Press #1's isEnabled is still pending when the user releases and + // presses again; its late resolution must not act for press #2. + const resolvers: Array<(enabled: boolean) => void> = []; + const harness = makeHarness({ + isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + // Outside the double-tap window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(resolvers).toHaveLength(2); + + // Press #1 resolves late with "disabled" — it must not quit press #2. + resolvers[0]?.(false); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.quit).not.toHaveBeenCalled(); + + // Press #2 resolves enabled and completes a full hold. + resolvers[1]?.(true); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("quits on a quick double tap, even when the first release was never seen", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("treats two slow taps as separate presses", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("cancels the hold when another key interrupts it", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + // Shift pressed mid-hold breaks the gesture... + await harness.send(makeInput({ shift: true })); + expect(harness.notifications).toEqual(["down", "up"]); + // ...so later repeats past the threshold must not quit. + await harness.holdFor(QUIT_HOLD_DURATION_MS); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("does not count an interrupted press toward a double tap", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ shift: true })); + // A fresh press right after the interruption starts a new hold, not a + // double-tap quit. + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("ignores other shortcuts", async () => { + const harness = makeHarness(); + await harness.send(makeInput({ key: "w" })); + await harness.send(makeInput({ shift: true })); + await harness.send(makeInput({ meta: false })); + expect(harness.preventDefault).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it("uses control on non-mac platforms", async () => { + const harness = makeHarness({ platform: "linux" }); + await harness.send(makeInput({ meta: false, control: true })); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts new file mode 100644 index 000000000000..ea2fc7854ac5 --- /dev/null +++ b/apps/desktop/src/window/QuitHold.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. + +// Chrome-style hold-to-quit. The quit accelerator is intercepted in +// before-input-event (which runs before the native menu accelerator), and the +// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS. +// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap +// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application +// menu itself is untouched and quits immediately. +export const QUIT_HOLD_DURATION_MS = 1200; +// A second quick tap of the shortcut is the user insisting: quit immediately. +export const QUIT_DOUBLE_TAP_MS = 500; +// "Still held" is proven by auto-repeat keydowns, not by the absence of a +// release: macOS suppresses a letter's keyUp while the command key is down, so +// a tap's release can go completely unseen and a release-based timer would +// quit anyway. The press is treated as released once no key event has arrived +// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with +// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit. +export const QUIT_HOLD_RELEASE_GRACE_MS = 600; + +export type QuitHoldState = "down" | "up"; + +export interface QuitHoldKeyInput { + readonly type: string; + readonly key: string; + readonly meta: boolean; + readonly control: boolean; + readonly alt: boolean; + readonly shift: boolean; + readonly isAutoRepeat: boolean; +} + +export interface QuitHoldOptions { + readonly platform: NodeJS.Platform; + readonly isEnabled: () => Promise; + readonly notify: (state: QuitHoldState) => void; + readonly quit: () => void; +} + +export function makeQuitHoldHandler( + options: QuitHoldOptions, +): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { + const modifierKey = options.platform === "darwin" ? "meta" : "control"; + let watchdog: NodeJS.Timeout | undefined; + let holding = false; + // Set once isEnabled resolves true; auto-repeats may only quit when armed. + let armed = false; + let heldSince = 0; + let lastPressAt = 0; + // Incremented on every new press and every release/quit so a pending + // isEnabled() resolution from a superseded press cannot arm (or quit for) + // the current one. + let generation = 0; + + const clearWatchdog = () => { + if (watchdog !== undefined) { + clearTimeout(watchdog); + watchdog = undefined; + } + }; + + const release = () => { + if (!holding) return; + generation += 1; + holding = false; + armed = false; + clearWatchdog(); + options.notify("up"); + }; + + // Dismisses the overlay first: if the quit is cancelled downstream the + // renderer must not be left with a stuck "Hold to Quit" hint. + const quitNow = () => { + release(); + options.quit(); + }; + + return (event, input) => { + const key = input.key.toLowerCase(); + if (input.type === "keyUp") { + if (key === "q" || key === modifierKey) release(); + return; + } + if (input.type !== "keyDown") return; + + const modifierDown = options.platform === "darwin" ? input.meta : input.control; + if (!modifierDown || input.alt || input.shift || key !== "q") { + // Any other key (or an extra modifier) pressed mid-hold breaks the + // gesture; without this the hold timer keeps running through the + // interruption and the next qualifying repeat would quit early. The + // interrupted press also stops counting toward a double tap — but only + // here, not in release(), which runs mid-restart on an unseen-release + // re-press and must not wipe that press's own tap timestamp. + if (holding && !input.isAutoRepeat) { + lastPressAt = 0; + release(); + } + return; + } + + event.preventDefault(); + + if (input.isAutoRepeat) { + if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + quitNow(); + } + return; + } + + const now = Date.now(); + const previousPressAt = lastPressAt; + lastPressAt = now; + // A fresh keydown while "holding" means the key came back down after a + // release macOS never delivered — so both branches below see real taps. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { + quitNow(); + return; + } + if (holding) release(); + + generation += 1; + const pressGeneration = generation; + holding = true; + heldSince = now; + options.notify("down"); + void options.isEnabled().then( + (enabled) => { + if (generation !== pressGeneration) return; + if (!enabled) { + // Hold-to-quit disabled: a single press quits immediately. + quitNow(); + return; + } + armed = true; + // No auto-repeat by then means the key was released (possibly with a + // suppressed keyUp) or repeat is disabled; either way, don't quit. + watchdog = setTimeout(() => { + watchdog = undefined; + release(); + }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + }, + // A failed settings read must never strand the quit request. + () => { + if (generation !== pressGeneration) return; + quitNow(); + }, + ); + }; +} diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index d6d7434769e4..791004b74fad 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; import { AppRoot } from "./AppRoot"; @@ -16,9 +17,10 @@ describe("AppRoot", () => { const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(3); + expect(children).toHaveLength(4); expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); + expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index b1fd21f84fa9..857125c9fdaf 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { + ); } diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx new file mode 100644 index 000000000000..29c044015212 --- /dev/null +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; + +import { isMacPlatform } from "../lib/utils"; + +// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint +// from a quick tap lingers for as long as a full hold would have taken. +const HIDE_AFTER_RELEASE_MS = 1200; + +/** + * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts + * the quit accelerator and pushes press/release states; a quick tap shows + * this pill while a full hold quits the app. + */ +export function QuitHoldOverlay() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const subscribe = window.desktopBridge?.onQuitShortcut; + if (!subscribe) return; + let hideTimer: number | undefined; + const unsubscribe = subscribe((state) => { + window.clearTimeout(hideTimer); + if (state === "down") { + setVisible(true); + return; + } + hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); + }); + return () => { + window.clearTimeout(hideTimer); + unsubscribe(); + }; + }, []); + + if (!visible) return null; + const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; + return ( +
    +
    + Hold {shortcut} to Quit +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9df7f88ab1dd..d57a4da1c2f0 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -526,11 +526,15 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit + ? ["Quit confirmation"] + : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ], [ isTextGenerationModelDirty, isBackgroundActivityDirty, + settings.confirmQuit, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -644,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, @@ -2234,6 +2239,30 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + } + /> + ) : null + } + control={ + updateSettings({ confirmQuit: Boolean(checked) })} + aria-label="Hold to quit" + /> + } + /> + ) : null} + { expect(searchSettings(" ", ITEMS)).toEqual([]); }); + it("hides desktop-only settings from browser search", () => { + expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); + expect(searchSettings("quit confirmation")).toEqual([]); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e0fc3d2f07e9..e3aef6705665 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,3 +1,5 @@ +import { isElectron } from "~/env"; + export type SettingsPath = | "/settings/general" | "/settings/appearance" @@ -12,6 +14,9 @@ export interface SettingsSearchItem { readonly title: string; readonly to: SettingsPath; readonly targetId?: string; + // Its row only renders in the desktop app, so a browser result would land on + // an anchor that isn't there. + readonly desktopOnly?: boolean; } /** @@ -149,6 +154,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Delete confirmation", to: "/settings/general", }, + { + id: "quit-confirmation", + title: "Hold to quit", + to: "/settings/general", + desktopOnly: true, + }, { id: "text-generation-model", title: "Text generation model", @@ -236,5 +247,9 @@ export function searchSettings( const normalizedQuery = normalizeSearchText(query); if (normalizedQuery.length === 0) return []; - return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery)); + return items.filter( + (item) => + (isElectron || item.desktopOnly !== true) && + normalizeSearchText(item.title).includes(normalizedQuery), + ); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 09d7d7a4602a..3341c0bb062f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1080,6 +1080,12 @@ export interface DesktopBridge { */ probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + /** + * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, + * "up" when it is released before the hold completes. Optional: older + * desktop builds never emit it. + */ + onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ee1970639adf..22ce210ed898 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -112,6 +112,9 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ + // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the + // app quits; a quick tap only shows a hint. Browser clients ignore it. + confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -756,6 +759,7 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + confirmQuit: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), From d94fbda344398bd266b9f6645e531eb17d3c9e4e Mon Sep 17 00:00:00 2001 From: Taras Date: Sat, 15 Aug 2026 14:37:02 +0300 Subject: [PATCH 016/196] fix(gitlab): submit review comments on context lines (#6348) --- .../BitbucketPullRequestApi.test.ts | 8 +- .../pullRequest/BitbucketPullRequestApi.ts | 16 +- .../pullRequest/GitHubPullRequestCli.test.ts | 2 +- .../pullRequest/GitLabPullRequestCli.test.ts | 9 +- .../src/pullRequest/GitLabPullRequestCli.ts | 21 +- .../pullRequest/PullRequestService.test.ts | 2 +- .../pullRequest/gitHubPullRequestJson.test.ts | 12 +- .../src/pullRequest/gitHubPullRequestJson.ts | 20 +- .../pullRequest/PullRequestCodeTab.tsx | 45 +++- .../pullRequestReviewStore.test.ts | 2 +- .../pullRequest/pullRequestReviewStore.ts | 11 +- apps/web/src/reviewCommentContext.ts | 221 ++++++++++++++++-- packages/contracts/src/pullRequest.ts | 23 +- 13 files changed, 338 insertions(+), 54 deletions(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 4120cf55e622..8945ecc5e1e2 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -776,7 +776,13 @@ layer("BitbucketPullRequestApi.layer", (it) => { number: 7, verdict: "request-changes", body: "Two things.", - comments: [{ path: "src/a.ts", line: 12, side: "left", body: "why remove?" }], + comments: [ + { + path: "src/a.ts", + position: { kind: "deleted", oldLine: 12 }, + body: "why remove?", + }, + ], }); expect(callAt(0).url).toContain("/pullrequests/7/comments"); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index a2c57bfc5fdb..5b3149b0d75c 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -12,6 +12,7 @@ import type { PullRequestMergeMethod, PullRequestMergeability, PullRequestReviewCommentDraft, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -364,6 +365,19 @@ function mergeStrategy(method: PullRequestMergeMethod | undefined): string { } } +function bitbucketReviewPosition( + position: PullRequestReviewPosition, +): { readonly from: number } | { readonly to: number } { + switch (position.kind) { + case "added": + return { to: position.newLine }; + case "deleted": + return { from: position.oldLine }; + case "context": + return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; + } +} + export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; @@ -794,7 +808,7 @@ export const make = Effect.gen(function* () { content: { raw: comment.body }, inline: { path: comment.path, - ...(comment.side === "left" ? { from: comment.line } : { to: comment.line }), + ...bitbucketReviewPosition(comment.position), }, }), }), diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 848c4cd5ebc3..d1af03db9d7e 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1624,7 +1624,7 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, verdict: "approve", body: "Looks right.", - comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }); expect(callAt(0).args).toEqual([ diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index c33e01c2d721..014d91a02740 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1018,7 +1018,12 @@ layer("GitLabPullRequestCli.layer", (it) => { verdict: "approve", body: "Looks right.", comments: [ - { path: "src/b.ts", oldPath: "src/a.ts", line: 4, side: "left", body: "why remove?" }, + { + path: "src/b.ts", + oldPath: "src/a.ts", + position: { kind: "deleted", oldLine: 4 }, + body: "why remove?", + }, ], }); @@ -1197,7 +1202,7 @@ layer("GitLabPullRequestCli.layer", (it) => { number: 7, verdict: "comment", body: "", - comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }), ); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 17c23bf86f48..9f968dddbbc8 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -14,6 +14,7 @@ import type { PullRequestReaction, PullRequestReactionContent, PullRequestReviewCommentDraft, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -400,6 +401,22 @@ function projectPath(repository: string): string { return encodeURIComponent(repository.trim()); } +function gitLabReviewPositionLines( + position: PullRequestReviewPosition, +): + | { readonly new_line: number } + | { readonly old_line: number } + | { readonly old_line: number; readonly new_line: number } { + switch (position.kind) { + case "added": + return { new_line: position.newLine }; + case "deleted": + return { old_line: position.oldLine }; + case "context": + return { old_line: position.oldLine, new_line: position.newLine }; + } +} + function stateParam(state: PullRequestListState): string { // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, // and it spans every state under `all`. @@ -1324,9 +1341,7 @@ export const make = Effect.gen(function* () { // draft carries the name the file had before the change. old_path: comment.oldPath ?? comment.path, new_path: comment.path, - ...(comment.side === "left" - ? { old_line: comment.line } - : { new_line: comment.line }), + ...gitLabReviewPositionLines(comment.position), }, }), }), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 243cfe06c21d..456a5023b16d 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1534,7 +1534,7 @@ it.effect("refuses line comments on a host that takes only a summary", () => number: 1, verdict: "comment", body: "", - comments: [{ path: "src/a.ts", line: 1, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 1 }, body: "nit" }], }), ); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index a3c3524a6d38..946394dcda8d 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -1147,8 +1147,16 @@ describe("review submission payload", () => { verdict: "request-changes", body: "Two things.", comments: [ - { path: "src/a.ts", line: 12, side: "right", body: "rename this" }, - { path: "src/b.ts", line: 3, side: "left", body: "why remove?" }, + { + path: "src/a.ts", + position: { kind: "added", newLine: 12 }, + body: "rename this", + }, + { + path: "src/b.ts", + position: { kind: "deleted", oldLine: 3 }, + body: "why remove?", + }, ], }), ) as Record; diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index e113b87d81da..7b9ff9d41fe6 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -17,6 +17,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewDecision, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidate, @@ -933,6 +934,22 @@ export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: St } }`; +function gitHubReviewPosition(position: PullRequestReviewPosition): { + readonly line: number; + readonly side: "LEFT" | "RIGHT"; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "RIGHT" }; + case "deleted": + return { line: position.oldLine, side: "LEFT" }; + case "context": + return position.side === "left" + ? { line: position.oldLine, side: "LEFT" } + : { line: position.newLine, side: "RIGHT" }; + } +} + /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ export function buildReviewSubmissionJson(input: { readonly verdict: PullRequestReviewVerdict; @@ -944,8 +961,7 @@ export function buildReviewSubmissionJson(input: { body: input.body, comments: input.comments.map((comment) => ({ path: comment.path, - line: comment.line, - side: comment.side === "left" ? ("LEFT" as const) : ("RIGHT" as const), + ...gitHubReviewPosition(comment.position), body: comment.body, })), }); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 71f3ffc7f378..776a4d671368 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -6,6 +6,7 @@ import type { PullRequestDiffSide, PullRequestOmittedFileStat, PullRequestRef, + PullRequestReviewPosition, PullRequestReviewThread, } from "@t3tools/contracts"; import { @@ -43,7 +44,11 @@ import { } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; -import { buildDiffReviewComment, type ReviewCommentContext } from "~/reviewCommentContext"; +import { + buildDiffReviewComment, + resolveDiffReviewPosition, + type ReviewCommentContext, +} from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -129,8 +134,7 @@ interface DraftAnchor { readonly path: string; /** What the file was called before the change, for the hosts that resolve a position by both. */ readonly oldPath: string | null; - readonly line: number; - readonly side: PullRequestDiffSide; + readonly position: PullRequestReviewPosition; /** The whole selection, which the comment collapses to one line but a question keeps. */ readonly range: SelectedLineRange; } @@ -148,8 +152,21 @@ function toViewerSide(side: PullRequestDiffSide) { return side === "left" ? ("deletions" as const) : ("additions" as const); } -function fromViewerSide(side: string | undefined): PullRequestDiffSide { - return side === "deletions" ? "left" : "right"; +function getReviewPositionAnchor(position: PullRequestReviewPosition): { + line: number; + side: PullRequestDiffSide; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "right" }; + case "deleted": + return { line: position.oldLine, side: "left" }; + case "context": + return { + line: position.side === "left" ? position.oldLine : position.newLine, + side: position.side, + }; + } } /** @@ -442,10 +459,14 @@ export function PullRequestCodeTab({ if (commit === null) { for (const comment of pendingComments) { if (comment.path !== path) continue; - groupAt(comment.side, comment.line).pending.push(comment); + const anchor = getReviewPositionAnchor(comment.position); + groupAt(anchor.side, anchor.line).pending.push(comment); } } - if (draft?.fileKey === fileKey) groupAt(draft.side, draft.line).draft = true; + if (draft?.fileKey === fileKey) { + const anchor = getReviewPositionAnchor(draft.position); + groupAt(anchor.side, anchor.line).draft = true; + } const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); @@ -594,12 +615,13 @@ export function PullRequestCodeTab({ // that silently lost its first line on the other hosts would be worse than one line. const path = resolveFileDiffPath(file); const previousPath = resolveFileDiffPreviousPath(file); + const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); + if (position === null) return; setDraft({ fileKey: item.id, path, oldPath: previousPath === path ? null : previousPath, - line: range.end, - side: fromViewerSide(range.endSide ?? range.side), + position, range, }); }, @@ -842,7 +864,7 @@ export function PullRequestCodeTab({ {annotation.metadata.draft && draft ? ( { diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts index 8e207c2529b8..41906a710fc8 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts @@ -6,17 +6,10 @@ * hosts that have no pending review of their own. That also means a draft lives only as long * as the tab does, which is why this is deliberately not persisted. */ -import type { ProjectId, PullRequestDiffSide, PullRequestRef } from "@t3tools/contracts"; +import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; import { create } from "zustand"; -export interface PendingReviewComment { - readonly id: string; - readonly path: string; - /** The line in the file the comment's side names: the new file on the right, the old on the left. */ - readonly line: number; - readonly side: PullRequestDiffSide; - readonly body: string; -} +export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string }; /** * A counter rather than anything derived from the comment: two remarks on one line can be the diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 7ce319973511..41f75eb384f1 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -1,6 +1,15 @@ import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs"; +import type { PullRequestReviewPosition } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +const ReviewCommentSelectionSchema = Schema.Struct({ + start: Schema.Number, + side: Schema.Literals(["additions", "deletions"]), + end: Schema.Number, + endSide: Schema.Literals(["additions", "deletions"]), +}); +type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type; + export const ReviewCommentContextSchema = Schema.Struct({ id: Schema.String, sectionId: Schema.String, @@ -12,6 +21,7 @@ export const ReviewCommentContextSchema = Schema.Struct({ text: Schema.String, diff: Schema.String, fenceLanguage: Schema.optional(Schema.String), + selection: Schema.optional(ReviewCommentSelectionSchema), }); export interface ReviewCommentContext { @@ -25,6 +35,7 @@ export interface ReviewCommentContext { readonly text: string; readonly diff: string; readonly fenceLanguage?: string | undefined; + readonly selection?: ReviewCommentSelection | undefined; } interface DiffReviewLine { @@ -267,10 +278,44 @@ function stripTrailingNewline(value: string): string { return value.endsWith("\n") ? value.slice(0, -1) : value; } -function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray { +function buildDiffReviewLines( + fileDiff: FileDiffMetadata, + includeExpandedContext: boolean, + slice?: { readonly startIndex: number; readonly endIndex: number }, +): ReadonlyArray { const rows: DiffReviewLine[] = []; + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const pushRow = (row: DiffReviewLine) => { + if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) { + rows.push(row); + } + rowIndex += 1; + }; + const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0; + const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1; + for (let offset = firstOffset; offset <= lastOffset; offset += 1) { + rows.push({ + change: "context", + oldLineNumber: oldStart + offset, + newLineNumber: newStart + offset, + content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""), + }); + } + rowIndex += count; + }; for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart); + pushContextGap(oldContextStart, newContextStart, contextLines); + } + let oldLineNumber = hunk.deletionStart; let newLineNumber = hunk.additionStart; let deletionLineIndex = hunk.deletionLineIndex; @@ -279,7 +324,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray, + fileDiff: FileDiffMetadata, lineNumber: number, side: SelectionSide | undefined, + includeExpandedContext = !fileDiff.isPartial, ): number { - const preferredKey = side === "deletions" ? "oldLineNumber" : "newLineNumber"; - const preferredIndex = lines.findIndex((line) => line[preferredKey] === lineNumber); - if (preferredIndex >= 0) return preferredIndex; - const fallbackKey = preferredKey === "oldLineNumber" ? "newLineNumber" : "oldLineNumber"; - return lines.findIndex((line) => line[fallbackKey] === lineNumber); + const findOnSide = (selectedSide: "left" | "right") => { + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const selectedStart = selectedSide === "left" ? oldStart : newStart; + const offset = lineNumber - selectedStart; + return offset >= 0 && offset < count ? rowIndex + offset : -1; + }; + + for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min( + oldContextEnd - oldContextStart, + newContextEnd - newContextStart, + ); + const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines); + if (contextIndex >= 0) return contextIndex; + rowIndex += Math.max(0, contextLines); + } + + let oldLineNumber = hunk.deletionStart; + let newLineNumber = hunk.additionStart; + for (const segment of hunk.hunkContent) { + if (segment.type === "context") { + const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines); + if (contextIndex >= 0) return contextIndex; + rowIndex += segment.lines; + oldLineNumber += segment.lines; + newLineNumber += segment.lines; + continue; + } + + if ( + selectedSide === "left" && + lineNumber >= oldLineNumber && + lineNumber < oldLineNumber + segment.deletions + ) { + return rowIndex + lineNumber - oldLineNumber; + } + rowIndex += segment.deletions; + oldLineNumber += segment.deletions; + + if ( + selectedSide === "right" && + lineNumber >= newLineNumber && + lineNumber < newLineNumber + segment.additions + ) { + return rowIndex + lineNumber - newLineNumber; + } + rowIndex += segment.additions; + newLineNumber += segment.additions; + } + + oldContextStart = hunk.deletionStart + hunk.deletionCount; + newContextStart = hunk.additionStart + hunk.additionCount; + if (hunk.deletionCount === 0) oldContextStart += 1; + if (hunk.additionCount === 0) newContextStart += 1; + } + + if (!includeExpandedContext) return -1; + const trailingLines = Math.min( + fileDiff.deletionLines.length - oldContextStart + 1, + fileDiff.additionLines.length - newContextStart + 1, + ); + return findContextIndex(oldContextStart, newContextStart, trailingLines); + }; + + const selectedSide = side === "deletions" ? "left" : "right"; + const preferredIndex = findOnSide(selectedSide); + return preferredIndex >= 0 + ? preferredIndex + : findOnSide(selectedSide === "left" ? "right" : "left"); +} + +/** Resolve the host-facing coordinates of a line selected in the diff viewer. */ +export function resolveDiffReviewPosition( + fileDiff: FileDiffMetadata, + lineNumber: number, + side: SelectionSide | undefined, +): PullRequestReviewPosition | null { + const lineIndex = findDiffReviewLineIndex(fileDiff, lineNumber, side); + if (lineIndex < 0) return null; + const line = buildDiffReviewLines(fileDiff, !fileDiff.isPartial, { + startIndex: lineIndex, + endIndex: lineIndex, + })[0]; + if (line === undefined) return null; + + switch (line.change) { + case "add": + return line.newLineNumber === null ? null : { kind: "added", newLine: line.newLineNumber }; + case "delete": + return line.oldLineNumber === null ? null : { kind: "deleted", oldLine: line.oldLineNumber }; + case "context": + return line.oldLineNumber === null || line.newLineNumber === null + ? null + : { + kind: "context", + oldLine: line.oldLineNumber, + newLine: line.newLineNumber, + side: side === "deletions" ? "left" : "right", + }; + } } function getDiffRange( @@ -416,18 +588,27 @@ export function buildDiffReviewComment(input: { range: SelectedLineRange; text: string; }): ReviewCommentContext | null { - const lines = buildDiffReviewLines(input.fileDiff); - const startIndex = findDiffReviewLineIndex(lines, input.range.start, input.range.side); + const includeExpandedContext = !input.fileDiff.isPartial; + const startIndex = findDiffReviewLineIndex( + input.fileDiff, + input.range.start, + input.range.side, + includeExpandedContext, + ); const endIndex = findDiffReviewLineIndex( - lines, + input.fileDiff, input.range.end, input.range.endSide ?? input.range.side, + includeExpandedContext, ); if (startIndex < 0 || endIndex < 0) return null; const normalizedStartIndex = Math.min(startIndex, endIndex); const normalizedEndIndex = Math.max(startIndex, endIndex); - const selectedLines = lines.slice(normalizedStartIndex, normalizedEndIndex + 1); + const selectedLines = buildDiffReviewLines(input.fileDiff, includeExpandedContext, { + startIndex: normalizedStartIndex, + endIndex: normalizedEndIndex, + }); const oldRange = getDiffRange(selectedLines, "oldLineNumber"); const newRange = getDiffRange(selectedLines, "newLineNumber"); @@ -445,6 +626,12 @@ export function buildDiffReviewComment(input: { ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`), ].join("\n"), fenceLanguage: "diff", + selection: { + start: input.range.start, + side: input.range.side ?? "additions", + end: input.range.end, + endSide: input.range.endSide ?? input.range.side ?? "additions", + }, }; } diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index d1b2ba705f5e..dea49ea8fa59 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -856,6 +856,26 @@ export const PullRequestCommentUpdateInput = Schema.Struct({ }); export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type; +/** The coordinates of one line in a pull request diff. */ +export const PullRequestReviewPosition = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("added"), + newLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("deleted"), + oldLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("context"), + oldLine: PositiveInt, + newLine: PositiveInt, + /** Which copy of an unchanged line the reviewer selected in a split diff. */ + side: PullRequestDiffSide, + }), +]); +export type PullRequestReviewPosition = typeof PullRequestReviewPosition.Type; + /** One remark in a review that has not been sent yet, anchored to a line of the diff. */ export const PullRequestReviewCommentDraft = Schema.Struct({ path: TrimmedNonEmptyString, @@ -865,8 +885,7 @@ export const PullRequestReviewCommentDraft = Schema.Struct({ * the hosts that address a comment by one path ignore this. */ oldPath: Schema.optional(TrimmedNonEmptyString), - line: PositiveInt, - side: PullRequestDiffSide, + position: PullRequestReviewPosition, body: CommentBody, }); export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type; From db3278f97721f89b8b11a28bf59e59ce1fb68598 Mon Sep 17 00:00:00 2001 From: Nicolas Layne <49288482+NicL9923@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:39:05 -0500 Subject: [PATCH 017/196] fix(marketing): keep Grok mark clear of mobile hero copy (#4542) --- apps/marketing/src/pages/index.astro | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 20fae288279c..4d43fc595c0b 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -704,7 +704,7 @@ const mobileEndorsementRows = [ height: 44px; } - /* Three stacked on the left, two on the right — keeps the center CTA clear. */ + /* Three above the headline, two beside the CTA — keeps the copy clear. */ .hero-float-mark.hf-claude { top: 44px; left: 10px; @@ -712,8 +712,8 @@ const mobileEndorsementRows = [ } .hero-float-mark.hf-grok { - top: 240px; - left: 4px; + top: 44px; + left: calc(50% - 39px); right: auto; transform: rotate(-4deg); } @@ -741,8 +741,8 @@ const mobileEndorsementRows = [ @media (max-width: 340px) { .hero-float-mark.hf-grok { - top: 220px; - left: 0; + top: 57px; + left: calc(50% - 26px); width: 52px; height: 52px; border-radius: 14px; From 3bc4fdf05b6b748a7b506c81dc125f3504f35278 Mon Sep 17 00:00:00 2001 From: JJ <93147993+hey-jj@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:41:51 -0600 Subject: [PATCH 018/196] fix(mobile): recover the QR pairing scanner when camera access is denied (#6487) --- .../connection/ConnectionsNewRouteScreen.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 37d53cbd8eea..7fa3c691b447 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -3,7 +3,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Platform, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -95,9 +95,21 @@ export function ConnectionsNewRouteScreen({ return; } + if (permission.canAskAgain) { + Alert.alert( + "Camera access needed", + "Allow camera access to scan an environment pairing QR code.", + ); + return; + } + Alert.alert( "Camera access needed", - "Allow camera access to scan an environment pairing QR code.", + "Camera access was denied for this app. Open Settings to enable it.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], ); }, [cameraPermission?.granted, requestCameraPermission]); From a38cac81d82b82a6967eaf8cb90ed2770c514f3c Mon Sep 17 00:00:00 2001 From: Simon Doba Date: Sat, 15 Aug 2026 13:42:08 +0200 Subject: [PATCH 019/196] fix(web): keep a long path from running under the folder picker button (#4823) Co-authored-by: Sy-D <8460326+Sy-D@users.noreply.github.com> Co-authored-by: Claude Opus 5 Co-authored-by: Julius Marminge Co-authored-by: codex --- .../components/CommandPalette.logic.test.ts | 30 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 13 ++++++++ apps/web/src/components/CommandPalette.tsx | 12 +++++--- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 06dabc5e8490..17949b7c97cb 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + browseInputEndPaddingClass, buildBrowseGroups, buildThreadActionItems, enumerateCommandPaletteItems, @@ -10,6 +11,35 @@ import { type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("browseInputEndPaddingClass", () => { + it("reserves the widest space for the create action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: true, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-38"); + }); + + it("reserves space for the wider highlighted-item shortcut", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: true, + }), + ).toContain("pe-30"); + }); + + it("keeps the compact reserve for the normal add action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-24"); + }); +}); + describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 07e0e520d84e..95d7a91b7805 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,6 +15,19 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; +export function browseInputEndPaddingClass(input: { + readonly willCreateProjectPath: boolean; + readonly hasHighlightedBrowseItem: boolean; +}): string { + if (input.willCreateProjectPath) { + return "*:data-[slot=autocomplete-input]:pe-38!"; + } + if (input.hasHighlightedBrowseItem) { + return "*:data-[slot=autocomplete-input]:pe-30!"; + } + return "*:data-[slot=autocomplete-input]:pe-24!"; +} + /** * The global search overlay hosts three mutually exclusive surfaces: the * command palette (⌘K), the project file picker (⌘P), and project content diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 48471accb995..413ebca305f9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -98,6 +98,7 @@ import { } from "../wslPaths"; import { ADDON_ICON_CLASS, + browseInputEndPaddingClass, buildBrowseGroups, buildProjectActionItems, buildRootGroups, @@ -2345,13 +2346,16 @@ function OpenCommandPaletteDialog(props: { footerTrailing={footerTrailing} inputAccessory={inputAccessory} inputProps={{ + // The submit button is absolutely positioned over the field, so the + // inner input must reserve enough room for the full action label. className: addProjectCloneFlow?.step === "repository" - ? "pe-32" + ? "*:data-[slot=autocomplete-input]:pe-32!" : isBrowsing - ? willCreateProjectPath - ? "pe-36" - : "pe-16" + ? browseInputEndPaddingClass({ + willCreateProjectPath, + hasHighlightedBrowseItem, + }) : undefined, placeholder: inputPlaceholder, wrapperClassName: isSubmenu From 270489b887420db3319898ab4046516e4c457711 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:44:37 +0200 Subject: [PATCH 020/196] fix(terminal): right-click paste works in the terminal (#5240) --- .../src/components/ThreadTerminalDrawer.tsx | 189 +++++++++++++++--- apps/web/src/hooks/useCopyToClipboard.ts | 44 ++++ apps/web/src/terminal/ghostty/surface.ts | 30 +++ 3 files changed, 230 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1266e5ed7e94..cf2adaca2cf4 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -13,6 +13,7 @@ import { XIcon, } from "lucide-react"; import { + type ContextMenuItem, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -32,7 +33,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; -import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -255,6 +256,49 @@ export function terminalSelectionLineRange(position: { }; } +export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; + +/** Post-selection popup: just the two selection actions, always enabled. */ +export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { + return [ + { id: "add-to-chat", label: "Add to chat" }, + { id: "copy", label: "Copy" }, + ]; +} + +/** + * Right-click menu for the terminal canvas: the selection actions (disabled + * until a selection exists) plus Paste. Paste is always offered: the browser + * (and Electron's default editing menu) can only paste into an editable + * element, so a canvas terminal never gets a usable entry from them. + */ +export function terminalContextMenuItems(options: { + hasSelection: boolean; +}): ContextMenuItem[] { + return [ + ...terminalSelectionMenuItems().map((item) => ({ + ...item, + disabled: !options.hasSelection, + })), + { id: "paste", label: "Paste" }, + ]; +} + +/** + * An empty selection change may only cancel a selection-action flow that is + * still current: a pending popup timer, or an open popup whose request id has + * not been superseded. A popup already superseded by a right-click keeps its + * menu promise unsettled for a moment; treating it as active would cancel the + * newer context-menu flow instead. + */ +export function shouldClearTerminalSelectionAction(options: { + timerPending: boolean; + openMenuRequestId: number | null; + currentRequestId: number; +}): boolean { + return options.timerPending || options.openMenuRequestId === options.currentRequestId; +} + export function shouldHandleTerminalExit( current: TerminalSessionState["status"], synchronized: TerminalSessionState["status"], @@ -328,7 +372,10 @@ export function TerminalViewport({ const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); - const selectionActionMenuOpenRef = useRef(false); + // Holds the request id of the selection popup currently on screen, so a + // popup that was superseded (but whose menu promise has not settled yet) + // cannot be mistaken for the active flow. + const openSelectionMenuRequestIdRef = useRef(null); const selectionActionTimerRef = useRef(null); const keybindingsRef = useRef(keybindings); const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); @@ -443,6 +490,12 @@ export function TerminalViewport({ onSelectionChange: () => handleSelectionChange(), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), + // The surface listens from construction, so a right-click can land + // while `create` is still awaiting WASM — before the handler below it + // exists. The ref is only assigned once that setup has run. + onContextMenu: (event) => { + if (terminalRef.current) void showTerminalContextMenu(event); + }, }; const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); if (cancelled) { @@ -518,12 +571,98 @@ export function TerminalViewport({ }; }; + const addSelectionToChat = (selection: TerminalContextSelection) => { + handleAddTerminalContext(selection); + terminalRef.current?.clearSelection(); + terminalRef.current?.focus(); + }; + + // A selection-action flow that was superseded while its async work ran + // must go silent: no error message, no focus steal. + const reportIfCurrent = (requestId: number, error: unknown, fallback: string) => { + if (requestId !== selectionActionRequestIdRef.current) return; + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallback); + } + }; + + const focusIfCurrent = (requestId: number) => { + if (requestId === selectionActionRequestIdRef.current) { + terminalRef.current?.focus(); + } + }; + + const copySelection = async (text: string, requestId: number) => { + try { + await writeTextToClipboard(text, "terminal selection"); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to copy terminal selection"); + } + focusIfCurrent(requestId); + }; + + const pasteFromClipboard = async (requestId: number) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + try { + // The surface owns the read so it can claim the paste race before it + // starts: a paste shortcut fired while the menu read is in flight + // supersedes this paste instead of landing alongside it. + await activeTerminal.pasteFromClipboard( + () => readTextFromClipboard("terminal input"), + () => requestId === selectionActionRequestIdRef.current, + ); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to read the clipboard"); + return; + } + focusIfCurrent(requestId); + }; + + const showTerminalContextMenu = async (event: MouseEvent) => { + if (!localApi || !terminalRef.current) return; + // Own the gesture before anything async: leaving the default alive lets + // the browser (or Electron's editing menu) answer with a Paste entry + // that is permanently disabled over the terminal canvas. + event.preventDefault(); + // A right-click supersedes a selection popup that is pending or open. + clearSelectionAction(); + const selectionAction = readSelectionAction(); + const requestId = selectionActionRequestIdRef.current; + let clicked: TerminalContextMenuAction | null; + try { + clicked = await localApi.contextMenu.show( + terminalContextMenuItems({ hasSelection: selectionAction !== null }), + { x: event.clientX, y: event.clientY }, + ); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to open the terminal context menu"); + focusIfCurrent(requestId); + return; + } + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { + return; + } + switch (clicked) { + case "add-to-chat": + if (selectionAction) addSelectionToChat(selectionAction.selection); + return; + case "copy": + if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); + return; + case "paste": + await pasteFromClipboard(requestId); + return; + } + }; + const showSelectionAction = async () => { if (!localApi) { clearSelectionAction(); return; } - if (selectionActionMenuOpenRef.current) { + if (openSelectionMenuRequestIdRef.current !== null) { return; } const nextAction = readSelectionAction(); @@ -532,45 +671,23 @@ export function TerminalViewport({ return; } const requestId = ++selectionActionRequestIdRef.current; - selectionActionMenuOpenRef.current = true; + openSelectionMenuRequestIdRef.current = requestId; const clicked = await localApi.contextMenu - .show( - [ - { id: "add-to-chat", label: "Add to chat" }, - { id: "copy", label: "Copy" }, - ], - nextAction.position, - ) + .show(terminalSelectionMenuItems(), nextAction.position) .finally(() => { - selectionActionMenuOpenRef.current = false; + if (openSelectionMenuRequestIdRef.current === requestId) { + openSelectionMenuRequestIdRef.current = null; + } }); if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } switch (clicked) { case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); + addSelectionToChat(nextAction.selection); return; case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.current) { - return; - } - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - } - } - if (requestId === selectionActionRequestIdRef.current) { - terminalRef.current?.focus(); - } + await copySelection(nextAction.clipboardText, requestId); return; } }; @@ -684,11 +801,17 @@ export function TerminalViewport({ if (terminalRef.current?.hasSelection()) { return; } + const shouldClear = shouldClearTerminalSelectionAction({ + timerPending: selectionActionTimerRef.current !== null, + openMenuRequestId: openSelectionMenuRequestIdRef.current, + currentRequestId: selectionActionRequestIdRef.current, + }); + if (!shouldClear) return; clearSelectionAction(); // A copy shortcut that clears the selection (Ctrl+C) must also close // the context menu that appears with the selection, but a clear that // never opened a menu must not dismiss an unrelated one. - if (selectionActionMenuOpenRef.current) { + if (openSelectionMenuRequestIdRef.current !== null) { void localApi?.contextMenu.close(); } } diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 0129f2d6593d..ef66410f7db4 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardReadUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while reading ${this.target}.`; + } +} + +export class ClipboardReadError extends Schema.TaggedErrorClass()( + "ClipboardReadError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.target} from the clipboard.`; + } +} + export async function writeTextToClipboard(value: string, target = "text") { if ( typeof window === "undefined" || @@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") { } } +export async function readTextFromClipboard(target = "text"): Promise { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.readText + ) { + throw new ClipboardReadUnavailableError({ + target, + }); + } + + try { + return await navigator.clipboard.readText(); + } catch (cause) { + throw new ClipboardReadError({ + target, + cause, + }); + } +} + export function useCopyToClipboard({ timeout = 2000, target = "text", diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 0bb33875568f..2ac3c68d1586 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -465,6 +465,12 @@ export interface GhosttyTerminalSurfaceOptions { readonly onSelectionChange: () => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; + /** + * A right-click the running application did not claim through mouse + * reporting. The host owns the menu, so it also owns preventing the browser + * default — whose Paste entry can never reach a canvas terminal. + */ + readonly onContextMenu?: (event: MouseEvent) => void; } export class GhosttyTerminalSurface { @@ -801,6 +807,28 @@ export class GhosttyTerminalSurface { this.input.focus({ preventScroll: true }); } + /** + * Pastes clipboard text read by the host (context menu) with the same + * bracketed-paste encoding as a native paste event. The read joins the same + * race the paste shortcut uses — the token is claimed before it starts — so + * a shortcut or native paste arriving during the read supersedes this one + * instead of both reaching the shell. + */ + async pasteFromClipboard( + readText: () => Promise, + isCurrent: () => boolean = () => true, + ): Promise { + const token = ++this.pasteShortcutToken; + const text = await readText(); + if (this.disposed || this.pasteShortcutToken !== token || !isCurrent()) return; + // As in every paste path, delivering bumps the token so a clipboard read + // still in flight cannot land after this text reaches the shell. + this.pasteShortcutToken += 1; + if (text.length === 0) return; + const encoded = this.core.encodePaste(text); + if (encoded.length > 0) this.options.onData(encoded); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1373,7 +1401,9 @@ export class GhosttyTerminalSurface { private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); + return; } + this.options.onContextMenu?.(event); }; private readonly onScrollbarPointerDown = (event: PointerEvent) => { From 4db50757c0b618293997a7f81bcfe30b68356969 Mon Sep 17 00:00:00 2001 From: Daniel Vernon Date: Sat, 15 Aug 2026 13:00:18 +0100 Subject: [PATCH 021/196] fix(mobile): explain iOS-only settings on Android (#4981) --- .../SettingsRouteScreen.logic.test.ts | 19 +++++++++++++++++++ .../settings/SettingsRouteScreen.logic.ts | 8 ++++++++ .../features/settings/SettingsRouteScreen.tsx | 6 ++++++ .../settings/components/SettingsSwitchRow.tsx | 8 +++++++- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts create mode 100644 apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts new file mode 100644 index 000000000000..aec583d67f73 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; + +describe("resolveAgentAwarenessPlatformPresentation", () => { + it("explains that agent awareness settings are unavailable on Android", () => { + expect(resolveAgentAwarenessPlatformPresentation("android")).toEqual({ + supported: false, + subtitle: "iOS only", + }); + }); + + it("leaves supported iOS settings unchanged", () => { + expect(resolveAgentAwarenessPlatformPresentation("ios")).toEqual({ + supported: true, + subtitle: undefined, + }); + }); +}); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts new file mode 100644 index 000000000000..94fa5965e994 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts @@ -0,0 +1,8 @@ +export function resolveAgentAwarenessPlatformPresentation(platform: string): { + readonly supported: boolean; + readonly subtitle: string | undefined; +} { + return platform === "ios" + ? { supported: true, subtitle: undefined } + : { supported: false, subtitle: "iOS only" }; +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index bcf2ce386d9c..b0e851b59d88 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -46,6 +46,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -144,6 +145,7 @@ function ConfiguredSettingsRouteScreen() { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); + const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); @@ -473,10 +475,12 @@ function ConfiguredSettingsRouteScreen() { icon="bell.badge" label="Device Notifications" disabled={ + !agentAwarenessPlatform.supported || !agentAwarenessPushAvailable || notificationStatus === "checking" || notificationStatus === "unsupported" } + subtitle={agentAwarenessPlatform.subtitle} // Only reads as on when this device is actually registered with the // relay; otherwise notifications cannot be delivered regardless of // the local iOS permission. @@ -487,6 +491,7 @@ function ConfiguredSettingsRouteScreen() { /> void; }) { @@ -27,7 +28,12 @@ export function SettingsSwitchRow(props: { } > - {props.label} + + {props.label} + {props.subtitle ? ( + {props.subtitle} + ) : null} + Date: Sat, 15 Aug 2026 17:30:51 +0530 Subject: [PATCH 022/196] fix(web): stop counting a workflow coordinator as a working agent (#6672) --- .../src/state/subagentRuntime.test.ts | 42 +++++++++++++++++-- .../src/state/subagentRuntime.ts | 11 ++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index ceb40517550e..ff0aea7c8a51 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -383,13 +383,49 @@ describe("deriveAgentPanelModel", () => { it("counts idle deliberately and waiting as active", () => { const model = deriveAgentPanelModel({ agents: roster }); expect(model.idleCount).toBe(1); - // wf-1 coordinator + member 1 running. - expect(model.runningCount).toBeGreaterThanOrEqual(1); + // Member 1 is running; the wf-1 coordinator is a container, not a worker. + expect(model.runningCount).toBe(1); + // Every agent lands in exactly one bucket, except coordinators that stand + // in for their members. expect(model.idleCount + model.runningCount + model.waitingCount + model.settledCount).toBe( - roster.length, + roster.length - 1, ); }); + it("omits a workflow coordinator from the working-agent count", () => { + const model = deriveAgentPanelModel({ agents: roster }); + // One member still running plus one idle direct spawn. The coordinator + // reports running for the whole workflow and must not inflate the banner. + expect(model.liveCount).toBe(1); + }); + + it("omits a finished workflow coordinator from the settled count", () => { + const finished = fold([ + activity("task.started", { taskId: "wf-2", taskType: "local_workflow", title: "sweep" }), + activity("task.progress", { + taskId: "wf-2:wf:0", + title: "sweep:a", + status: "completed", + parentAgentId: "wf-2", + agentIndex: 0, + phaseIndex: 0, + }), + activity("task.completed", { + taskId: "wf-2:wf:0", + status: "completed", + parentAgentId: "wf-2", + }), + activity("task.completed", { taskId: "wf-2", status: "completed" }), + ]); + + const model = deriveAgentPanelModel({ agents: finished }); + + // Only the member settled. The coordinator stands in for it, so counting + // both would report two finished agents where one ran. + expect(model.settledCount).toBe(1); + expect(model.liveCount).toBe(0); + }); + it("keeps direct spawns in first-seen order as their activity changes", () => { const directRoster = fold([ activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"), diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index e5f2b586b8c4..c1ea1cc2b15d 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -826,15 +826,16 @@ export function deriveAgentPanelModel({ let settledCount = 0; let totalTokens = 0; for (const agent of source) { + // A workflow coordinator with members is a container for those members, not + // work of its own: it reports running for the whole run and aggregates their + // usage upstream in some providers. Counting it would report one more agent + // working than there are, and double count tokens. + if (agent.kind === "workflow" && (members.get(agent.id) ?? []).length > 0) continue; if (agent.status === "running" || agent.status === "pending") runningCount += 1; else if (agent.status === "waiting") waitingCount += 1; else if (agent.status === "idle") idleCount += 1; else settledCount += 1; - // Workflow coordinators aggregate member usage upstream in some providers; - // avoid double counting by only summing leaf agents when members exist. - if (agent.kind !== "workflow" || (members.get(agent.id) ?? []).length === 0) { - totalTokens += agent.usage?.totalTokens ?? 0; - } + totalTokens += agent.usage?.totalTokens ?? 0; } return { From 6e6d1b49412d064ccbde7daae2e287f9b62efd7d Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:00:53 -0400 Subject: [PATCH 023/196] fix(web): keep floating preview anchored after panel closes (#6547) --- .../preview/ThreadPreviewMiniPlayer.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 3e7c46ef0e0a..2bdba1afe9e3 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; -import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react"; +import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -17,6 +17,7 @@ import { clampPreviewMiniPlayerPosition, clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_DEFAULT_SIZE, + PREVIEW_MINI_PLAYER_EDGE_GAP, } from "./previewMiniPlayerLayout"; interface DragState { @@ -31,6 +32,8 @@ interface ResizeState { readonly pointerId: number; readonly pointerX: number; readonly pointerY: number; + readonly playerX: number; + readonly playerY: number; readonly width: number; readonly height: number; } @@ -45,6 +48,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const rootRef = useRef(null); const dragRef = useRef(null); const resizeRef = useRef(null); + const [defaultLayoutVersion, setDefaultLayoutVersion] = useState(""); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); @@ -91,8 +95,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props bottomInset, ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); + if (!position) { + setDefaultLayoutVersion(`${parent.clientWidth}:${parent.clientHeight}`); + return; + } const next = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + position, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -159,11 +167,16 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; const root = rootRef.current; - if (!root) return; + const parent = root?.offsetParent; + if (!root || !(parent instanceof HTMLElement)) return; + const rootRect = root.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); resizeRef.current = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, + playerX: rootRect.left - parentRect.left, + playerY: rootRect.top - parentRect.top, width: root.offsetWidth, height: root.offsetHeight, }; @@ -194,7 +207,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); const nextPosition = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + { x: resize.playerX, y: resize.playerY }, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -222,8 +235,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props position ? { left: position.x, top: position.y, width: size.width, height: size.height } : { - right: 16, - top: 16, + right: PREVIEW_MINI_PLAYER_EDGE_GAP, + top: PREVIEW_MINI_PLAYER_EDGE_GAP, width: size.width, height: size.height, } @@ -290,7 +303,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props visible={Boolean(desktopOverlay?.hasWebContents)} cornerRadius={12} fitSourceContent - layoutVersion={position ? `${position.x}:${position.y}` : `initial:${bottomInset}`} + layoutVersion={ + position + ? `${position.x}:${position.y}` + : `initial:${bottomInset}:${defaultLayoutVersion}` + } className="absolute inset-0" />
    From a7c5ad5db167b3a172ccb26408b0638c99b2a459 Mon Sep 17 00:00:00 2001 From: Torben Wetter Date: Sat, 15 Aug 2026 14:01:08 +0200 Subject: [PATCH 024/196] fix(web): unstick /connect after in-modal sign-in by redirecting to the authorize endpoint (#5133) --- apps/web/src/cloud/connectCliAuth.test.ts | 24 +++++++++++++++ apps/web/src/cloud/connectCliAuth.ts | 17 +++++++++++ .../src/components/clerk/authRedirect.test.ts | 5 +++- apps/web/src/components/clerk/authRedirect.ts | 4 ++- .../cloud/ConnectCliAuthSurface.tsx | 29 +++++++++++++------ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 59b443a49d93..3d41c4166332 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, hasConnectCliAuthConfig, readConnectCliCallbackResult, } from "./connectCliAuth"; @@ -69,6 +70,29 @@ describe("connectCliAuth", () => { ).toBeNull(); }); + it("sends the sign-in redirect to the authorize endpoint, not back to /connect", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + const redirectUrl = connectCliSignInRedirectUrl( + { state: "state-1", challenge: "challenge-1" }, + connectUrl, + ); + + expect(redirectUrl).not.toBe(connectUrl); + expect(new URL(redirectUrl).pathname).toBe("/oauth/authorize"); + }); + + it("falls back to the current URL when the authorize URL cannot be built", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + expect( + connectCliSignInRedirectUrl({ state: "state-1", challenge: "challenge-1" }, connectUrl), + ).toBe(connectUrl); + }); + it("reads the code and state Clerk echoes back to the callback", () => { expect( readConnectCliCallbackResult( diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 969215d97ad3..815715da2499 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -60,6 +60,23 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques }); } +/** + * Where Clerk sends the browser once the sign-in modal on /connect completes. + * It has to be the authorize endpoint rather than this page: /connect carries + * the CLI request in its fragment, so navigating back to the same URL is a + * same-document fragment navigation the browser never reloads — and Clerk + * treats any post-sign-in navigation as a page unload and skips the state emit + * that would otherwise re-render the surface, so the session never arrives + * either. Falls back to the current URL when the authorize URL cannot be + * built, which only happens on a deployment without the CLI OAuth config. + */ +export function connectCliSignInRedirectUrl( + request: ConnectAuthorizeRequest, + currentHref: string, +): string { + return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref; +} + export function rememberConnectCliAuthState(state: string): void { try { window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state); diff --git a/apps/web/src/components/clerk/authRedirect.test.ts b/apps/web/src/components/clerk/authRedirect.test.ts index 140474120cca..e948d1d9c049 100644 --- a/apps/web/src/components/clerk/authRedirect.test.ts +++ b/apps/web/src/components/clerk/authRedirect.test.ts @@ -5,7 +5,10 @@ import { resolveClerkSignInProps } from "./authRedirect"; describe("resolveClerkSignInProps", () => { it("returns to the current browser URL on the web", () => { const href = "https://app.t3.codes/connect?state=state-1#details"; - expect(resolveClerkSignInProps(href, false)).toEqual({ forceRedirectUrl: href }); + expect(resolveClerkSignInProps(href, false)).toEqual({ + forceRedirectUrl: href, + signUpForceRedirectUrl: href, + }); }); it("removes a Clerk virtual pathname and callback params while preserving the desktop route", () => { diff --git a/apps/web/src/components/clerk/authRedirect.ts b/apps/web/src/components/clerk/authRedirect.ts index 251c5ee36502..e0b07241c068 100644 --- a/apps/web/src/components/clerk/authRedirect.ts +++ b/apps/web/src/components/clerk/authRedirect.ts @@ -15,5 +15,7 @@ export function resolveClerkSignInProps(href: string, isElectron: boolean): Cler signUpForceRedirectUrl: redirectUrl.toString(), }; } - return { forceRedirectUrl: href }; + // The sign-in modal can switch to sign-up, which follows its own redirect + // target; without one Clerk falls back to the URL the modal was opened from. + return { forceRedirectUrl: href, signUpForceRedirectUrl: href }; } diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index e47d8ddf7f7c..5d5c280bb81c 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,9 +1,10 @@ import { useAuth, useClerk, useUser } from "@clerk/react"; import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, readConnectCliAuthState, readConnectCliCallbackResult, rememberConnectCliAuthState, @@ -56,6 +57,21 @@ export function ConnectCliAuthorizeSurface() { const signInOpened = useRef(false); const redirecting = useRef(false); + const openSignIn = useCallback(() => { + if (!request) { + return; + } + // Clerk redirects to the authorize endpoint itself once sign-in completes, + // so the callback's state check has to be armed before handing off. + rememberConnectCliAuthState(request.state); + clerk.openSignIn( + resolveClerkSignInProps( + connectCliSignInRedirectUrl(request, window.location.href), + isElectron, + ), + ); + }, [clerk, request]); + useEffect(() => { if (!request || !isLoaded || redirecting.current) { return; @@ -63,7 +79,7 @@ export function ConnectCliAuthorizeSurface() { if (!isSignedIn) { if (!signInOpened.current) { signInOpened.current = true; - clerk.openSignIn(resolveClerkSignInProps(window.location.href, isElectron)); + openSignIn(); } return; } @@ -74,7 +90,7 @@ export function ConnectCliAuthorizeSurface() { redirecting.current = true; rememberConnectCliAuthState(request.state); window.location.assign(authorizeUrl); - }, [clerk, isLoaded, isSignedIn, request]); + }, [isLoaded, isSignedIn, openSignIn, request]); if (!request) { return ( @@ -101,12 +117,7 @@ export function ConnectCliAuthorizeSurface() { /> {isLoaded && !isSignedIn ? (
    -
    From 7afa184a99b266d466cc9517c147a75c3d839ad7 Mon Sep 17 00:00:00 2001 From: BootesVoid <78485654+AMohamedAakhil@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:31:11 +0530 Subject: [PATCH 025/196] fix(web): keep send reachable while a turn is running on mobile (#4781) Co-authored-by: AMohamedAakhil Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/chat/ChatComposer.tsx | 3 ++ ...est.ts => ComposerPrimaryActions.test.tsx} | 45 +++++++++++++++++++ .../chat/ComposerPrimaryActions.tsx | 27 ++++++++--- 3 files changed, 69 insertions(+), 6 deletions(-) rename apps/web/src/components/chat/{ComposerPrimaryActions.test.ts => ComposerPrimaryActions.test.tsx} (80%) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5072c5870a73..afba1e086b84 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -407,6 +407,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -435,6 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} + showSendWhileRunning={props.showSendWhileRunning ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} @@ -3166,6 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} + showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx similarity index 80% rename from apps/web/src/components/chat/ComposerPrimaryActions.test.ts rename to apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 3dbcd39e9d13..c48f029f7f9b 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -65,6 +65,28 @@ function renderStandaloneStop() { ); } +function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: true, + showPlanFollowUpPrompt: false, + promptHasText: hasSendableContent, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent, + showSendWhileRunning, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + function renderSendButton() { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { @@ -215,4 +237,27 @@ describe("ComposerPrimaryActions", () => { expect(markup).not.toContain("stage-nightly"); expect(markup).toContain("bg-message-action text-message-action-foreground"); }); + + it("only renders stop while running when Enter-to-send is available", () => { + const markup = renderRunningActions(false, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); + + it("renders send alongside stop while running when Enter-to-send is unavailable", () => { + const markup = renderRunningActions(true, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).toContain('aria-label="Send message"'); + expect(markup).toContain('type="submit"'); + expect(markup).toContain("size-9 sm:size-8"); + }); + + it("keeps stop as the only action while running with an empty composer", () => { + const markup = renderRunningActions(true, false); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index d8626496ae7d..2a27796d92a5 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -28,6 +28,9 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + /** Enter-to-send is disabled on mobile viewports, where stop would otherwise + * be the only primary action and a running turn could not be steered. */ + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, + showSendWhileRunning = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, @@ -86,7 +90,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="button" className={cn( "flex cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none", - insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8", + insidePendingAction + ? "size-8 sm:size-7" + : showSendWhileRunning && hasSendableContent + ? "size-9 sm:size-8" + : "size-8 sm:h-8 sm:w-8", )} {...pointerFocusProps} onClick={onInterrupt} @@ -153,10 +161,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - if (isRunning) { - return renderStopGenerationButton(false); - } - if (showPlanFollowUpPrompt) { if (promptHasText) { return ( @@ -214,7 +218,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - return ( + const sendButton = (
    ) : null} {terminalStatus ? ( @@ -867,6 +885,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -884,7 +905,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} - driverKind={driverKind} + providerEntry={providerEntry} + showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} @@ -1481,11 +1503,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} {driverKind ? ( - + ) : null} @@ -1542,7 +1572,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { }); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -1600,7 +1632,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} - driverKind={driverKind} + providerEntry={providerEntry} + showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 05b44dcb7327..df35cbd90e54 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,10 +1,14 @@ import { type ProviderInstanceId } from "@t3tools/contracts"; -import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { memo, useLayoutEffect, useRef, useState } from "react"; import { SparklesIcon, StarIcon } from "lucide-react"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; +import { + isProviderInstancePickerReady, + shouldShowInstanceBadge, + type ProviderInstanceEntry, +} from "../../providerInstances"; /** * Build the hover tooltip for an instance button. Mirrors the old @@ -65,14 +69,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const [hoveredInstanceId, setHoveredInstanceId] = useState(null); const sidebarContentRef = useRef(null); const [selectedIndicatorTop, setSelectedIndicatorTop] = useState(null); - const duplicateDriverCounts = useMemo(() => { - const counts = new Map(); - for (const entry of props.instanceEntries) { - counts.set(entry.driverKind, (counts.get(entry.driverKind) ?? 0) + 1); - } - return counts; - }, [props.instanceEntries]); - useLayoutEffect(() => { const content = sidebarContentRef.current; if (!content) { @@ -143,8 +139,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const isSelected = props.selectedInstanceId === entry.instanceId; const isHovered = hoveredInstanceId === entry.instanceId; const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; - const showInstanceBadge = - Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; + const showInstanceBadge = shouldShowInstanceBadge(entry, props.instanceEntries); const tooltip = isUnavailable ? describeUnavailableInstance(entry) diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index a9b3a398115b..bd374a0fd6f5 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -16,7 +16,7 @@ import { getTriggerDisplayModelLabel, getTriggerDisplayModelName, } from "./providerIconUtils"; -import type { ProviderInstanceEntry } from "../../providerInstances"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { @@ -67,10 +67,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { selectedInstanceOptions[0]; const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; - const duplicateDriverCount = props.instanceEntries.filter( - (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, - ).length; - const showInstanceBadge = Boolean(activeEntry?.accentColor) || duplicateDriverCount > 1; + const showInstanceBadge = + activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); const setIsMenuOpen = (open: boolean) => { props.onOpenChange?.(open); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 337e68d44d0a..fd4ca7da92da 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -109,6 +109,23 @@ function driverKindLabel(driverKind: ProviderDriverKind): string { return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind); } +/** + * Whether an instance's icon carries the account badge: accent color set, or + * several instances sharing a driver so the brand glyph alone is ambiguous. + * Shared by the composer trigger, the picker rail, and sidebar rows. + */ +export function shouldShowInstanceBadge( + entry: ProviderInstanceEntry, + entries: Iterable, +): boolean { + if (entry.accentColor) return true; + let sharedDriverCount = 0; + for (const candidate of entries) { + if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true; + } + return false; +} + export function normalizeProviderAccentColor(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; From c0f9d917c1ab08d30f2b3715dd25d2175a6d2ecf Mon Sep 17 00:00:00 2001 From: Ostap <33957189+ostapondo@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:06:53 +0200 Subject: [PATCH 047/196] fix(server): wait for concurrent SQLite writers instead of failing with SQLITE_BUSY (#5134) --- .../src/persistence/Layers/Sqlite.test.ts | 66 +++++++++++++++++++ apps/server/src/persistence/Layers/Sqlite.ts | 2 + 2 files changed, 68 insertions(+) create mode 100644 apps/server/src/persistence/Layers/Sqlite.test.ts diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts new file mode 100644 index 000000000000..0b64e4f7fdcb --- /dev/null +++ b/apps/server/src/persistence/Layers/Sqlite.test.ts @@ -0,0 +1,66 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts"; + +const lockHolderSource = ` +const { DatabaseSync } = require("node:sqlite"); +const db = new DatabaseSync(process.argv[1]); +db.exec("BEGIN IMMEDIATE"); +process.stdout.write("locked\\n"); +setTimeout(() => { + db.exec("COMMIT"); + db.close(); +}, Number(process.argv[2])); +`; + +const spawnWriteLockHolder = (dbPath: string, holdMs: number) => + Effect.promise( + () => + new Promise((resolve, reject) => { + const holder = NodeChildProcess.spawn( + process.execPath, + ["-e", lockHolderSource, dbPath, String(holdMs)], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + holder.stdout.once("data", () => resolve()); + holder.on("error", reject); + holder.on("exit", () => + reject(new Error("lock holder exited before acquiring the write lock")), + ); + }), + ); + +it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`; + yield* spawnWriteLockHolder(dbPath, 300); + yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`; + const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`; + assert.deepEqual([...rows], [{ id: 1 }]); + }).pipe( + Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + +it.effect("applies busy_timeout in the shared persistence setup", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`; + assert.equal(rows[0]?.timeout, 5000); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index d1e002501263..ec1ffdefac0f 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -33,6 +33,8 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. + yield* sql`PRAGMA busy_timeout = 5000;`; yield* sql`PRAGMA foreign_keys = ON;`; yield* sql`PRAGMA journal_mode = WAL;`; yield* runMigrations(); From 7c55e86320aac9c68ae53a7bc15682b7e14f98bf Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sat, 15 Aug 2026 17:07:07 +0500 Subject: [PATCH 048/196] fix(web): reject oversized prompts before provider turn start (#6602) --- apps/web/src/components/ChatView.tsx | 57 +++--- apps/web/src/components/chat/ChatComposer.tsx | 67 ++++++- .../ComposerPromptLengthValidation.test.tsx | 23 +++ .../chat/ComposerPromptLengthValidation.tsx | 13 ++ .../chat/composerSubmission.test.ts | 170 ++++++++++++++++++ .../src/components/chat/composerSubmission.ts | 44 +++++ docs/user/composer.md | 5 + 7 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.tsx create mode 100644 apps/web/src/components/chat/composerSubmission.test.ts create mode 100644 apps/web/src/components/chat/composerSubmission.ts create mode 100644 docs/user/composer.md diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5bde6345c0..cb79f1f7e215 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4994,6 +4994,16 @@ function ChatViewContent(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); + const outgoingFollowUpText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: followUp.text.trim(), + }); + if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { + return; + } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5063,24 +5073,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { - let resolveDockStarted: (() => void) | undefined; - const dockStarted = new Promise((resolve) => { - resolveDockStarted = resolve; - }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }); - void dockTransition.catch(() => resolveDockStarted?.()); - await dockStarted; - } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; @@ -5098,8 +5090,6 @@ function ChatViewContent(props: ChatViewProps) { messageTextWithPreviewAnnotations, composerReviewCommentsSnapshot, ); - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -5107,6 +5097,30 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); + if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { + return; + } + + sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -5723,6 +5737,9 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: implementationPrompt, }); + if (composerRef.current?.validateProviderInput(outgoingImplementationPrompt) === false) { + return; + } const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5a01c5c76435..293767a7390d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -107,6 +107,12 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; +import { + getComposerPromptLengthValidationMessage, + getComposerSubmissionValidationMessage, + submitComposerDraft, +} from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; type ComposerCommandMenuPosition = { bottom: number; @@ -488,6 +494,8 @@ export interface ChatComposerHandle { selectedModel: string; selectedProviderModels: ReadonlyArray; }; + /** Validate the fully composed text immediately before a provider turn starts. */ + validateProviderInput: (providerInput: string) => boolean; } // -------------------------------------------------------------------------- @@ -951,6 +959,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [composerSubmissionError, setComposerSubmissionError] = useState(null); + const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( + null, + ); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ @@ -967,6 +979,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); + const providerInputRejectedRef = useRef(false); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -1309,6 +1322,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt, promptRef]); + useEffect(() => { + if (composerSubmissionError === null) return; + const nextError = getComposerPromptLengthValidationMessage(prompt); + if (nextError !== composerSubmissionError) { + setComposerSubmissionError(nextError); + } + }, [composerSubmissionError, prompt]); + + useEffect(() => { + setProviderInputSubmissionError(null); + }, [ + composerElementContexts, + composerPreviewAnnotations, + composerReviewComments, + composerTerminalContexts, + prompt, + selectedModel, + selectedPromptEffort, + selectedProvider, + ]); + useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); @@ -1400,6 +1434,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ useEffect(() => { setComposerHighlightedItemId(null); + setComposerSubmissionError(null); + setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); @@ -1826,17 +1862,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } - onSend(event); + const submission = submitComposerDraft({ + prompt: promptRef.current, + submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", + event, + onSend: (sendEvent) => { + // ChatView reports its final composed-input preflight through the + // composer handle before its first asynchronous send step. + providerInputRejectedRef.current = false; + onSend(sendEvent); + return !providerInputRejectedRef.current; + }, + }); + setComposerSubmissionError(submission.validationMessage); + if (!submission.didDispatch) return; if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, [ activeThreadId, + activePendingProgress, blurMobileComposerAfterSend, isSendDisabled, noProviderAvailable, onSend, + promptRef, shouldBlurMobileComposerOnSubmit, ], ); @@ -2590,6 +2641,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedProviderModels, }), + validateProviderInput: (providerInput: string) => { + const validationMessage = getComposerSubmissionValidationMessage({ + prompt: promptRef.current, + providerInput, + submissionTarget: "provider-turn", + }); + providerInputRejectedRef.current = validationMessage !== null; + setProviderInputSubmissionError(validationMessage); + return validationMessage === null; + }, }), [ activeThread, @@ -3058,6 +3119,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
+ + {/* Bottom toolbar */} {isComposerCollapsedMobile ? null : activePendingApproval ? (
diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx new file mode 100644 index 000000000000..3ffb4fa9c20a --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx @@ -0,0 +1,23 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { getComposerPromptLengthValidationMessage } from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; + +describe("ComposerPromptLengthValidation", () => { + it("renders oversized prompt feedback as an actionable composer alert", () => { + const message = getComposerPromptLengthValidationMessage( + "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), + ); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain('data-chat-composer-validation="prompt-length"'); + expect(markup).toContain( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(markup).not.toContain("ProviderValidationError"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx new file mode 100644 index 000000000000..88e4c3b813eb --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx @@ -0,0 +1,13 @@ +export function ComposerPromptLengthValidation({ message }: { message: string | null }) { + if (!message) return null; + + return ( +

+ {message} +

+ ); +} diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts new file mode 100644 index 000000000000..239db28a6002 --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -0,0 +1,170 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { submitComposerDraft } from "./composerSubmission"; + +describe("submitComposerDraft", () => { + it("keeps an oversized draft editable and sends a corrected follow-up", () => { + let draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + let validationMessage: string | null = null; + const dispatchedDrafts: string[] = []; + const preventDefault = vi.fn(); + + const submit = () => { + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => { + dispatchedDrafts.push(draft); + }, + }); + validationMessage = result.validationMessage; + }; + + submit(); + + expect(dispatchedDrafts).toEqual([]); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + expect(validationMessage).toBe( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(preventDefault).toHaveBeenCalledOnce(); + + draft = "Corrected prompt"; + submit(); + + expect(dispatchedDrafts).toEqual(["Corrected prompt"]); + expect(validationMessage).toBeNull(); + }); + + it("allows a draft at the shared character limit through the normal send path", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("blocks when appended context pushes the provider input over the shared limit", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + providerInput: `${draft}\n\nTerminal context`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ + validationMessage: + "Prompt is 18 characters over the 120,000-character limit. Shorten or split it before sending.", + didDispatch: false, + }); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + expect(onSend).not.toHaveBeenCalled(); + + const correctedResult = submitComposerDraft({ + prompt: "Corrected prompt", + providerInput: "Corrected prompt\n\nShort terminal context", + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(correctedResult).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("does not finish submission when the send boundary rejects composed provider input", () => { + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Sendable raw draft", + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => false, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: false }); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("allows fully composed provider input at the shared character limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Short draft", + providerInput: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("blocks a generated plan follow-up that exceeds the shared limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "", + providerInput: `PLEASE IMPLEMENT THIS PLAN:\n${"x".repeat( + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + )}`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result.didDispatch).toBe(false); + expect(result.validationMessage).toContain("over the 120,000-character limit"); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("allows surrounding whitespace that the provider turn contract trims", () => { + const draft = ` ${"x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)} `; + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("dispatches pending user input answers on their separate response path", () => { + const answer = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: answer, + submissionTarget: "pending-user-input", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts new file mode 100644 index 000000000000..528ac75bcabe --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -0,0 +1,44 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; + +type ComposerSubmitEvent = { preventDefault: () => void }; + +type ComposerSubmissionInput = { + prompt: string; + providerInput?: string; + submissionTarget: "provider-turn" | "pending-user-input"; +}; + +export function getComposerPromptLengthValidationMessage(prompt: string): string | null { + const excessCharacters = prompt.trim().length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + if (excessCharacters <= 0) return null; + + const characterLabel = excessCharacters === 1 ? "character" : "characters"; + return `Prompt is ${excessCharacters.toLocaleString("en-US")} ${characterLabel} over the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")}-character limit. Shorten or split it before sending.`; +} + +export function getComposerSubmissionValidationMessage( + options: ComposerSubmissionInput, +): string | null { + return options.submissionTarget === "provider-turn" + ? getComposerPromptLengthValidationMessage(options.providerInput ?? options.prompt) + : null; +} + +export function submitComposerDraft( + options: ComposerSubmissionInput & { + event: ComposerSubmitEvent | undefined; + onSend: (event?: ComposerSubmitEvent) => boolean | void; + }, +): { validationMessage: string | null; didDispatch: boolean } { + const validationMessage = getComposerSubmissionValidationMessage(options); + if (validationMessage) { + options.event?.preventDefault(); + return { validationMessage, didDispatch: false }; + } + + if (options.onSend(options.event) === false) { + options.event?.preventDefault(); + return { validationMessage: null, didDispatch: false }; + } + return { validationMessage: null, didDispatch: true }; +} diff --git a/docs/user/composer.md b/docs/user/composer.md new file mode 100644 index 000000000000..d2e49db247b0 --- /dev/null +++ b/docs/user/composer.md @@ -0,0 +1,5 @@ +# Message composer + +Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the +composer and shows how many characters need to be removed. Shorten the draft or split it into +multiple messages, then send again in the same thread. From 40ab7bf32a81a66b20571ed280dc238c2276dc61 Mon Sep 17 00:00:00 2001 From: Jaroslav Brtis <6890442+Jardo-51@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:07:09 +0200 Subject: [PATCH 049/196] feat(web): collapse the question prompt from its header (#6773) Co-authored-by: Claude Opus 5 --- .../ComposerPendingUserInputPanel.test.tsx | 61 ++++++ .../chat/ComposerPendingUserInputPanel.tsx | 196 +++++++++++------- 2 files changed, 186 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx new file mode 100644 index 000000000000..817182190b79 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx @@ -0,0 +1,61 @@ +import { ApprovalRequestId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; +import type { PendingUserInput } from "../../session-logic"; + +const prompt: PendingUserInput = { + requestId: ApprovalRequestId.make("request-1"), + createdAt: "2026-08-15T00:00:00.000Z", + questions: [ + { + id: "question-1", + header: "Approach", + question: "Which approach should the migration take?", + options: [ + { label: "Incremental", description: "Move one module at a time" }, + { label: "Big bang", description: "Move everything in one release" }, + ], + multiSelect: false, + }, + ], +}; + +function renderPanel() { + return renderToStaticMarkup( + {}} + onAdvance={() => {}} + />, + ); +} + +describe("ComposerPendingUserInputPanel", () => { + it("renders the header as a disclosure control for the question body", () => { + const markup = renderPanel(); + + const toggle = markup.match(/]*data-pending-user-input-toggle="[^"]*"[^>]*>/)?.[0]; + expect(toggle).toBeDefined(); + expect(toggle).toContain('data-pending-user-input-toggle="expanded"'); + expect(toggle).toContain('aria-expanded="true"'); + expect(toggle).toContain('type="button"'); + + const controlledId = toggle?.match(/aria-controls="([^"]+)"/)?.[1]; + expect(controlledId).toBeDefined(); + expect(markup).toMatch(new RegExp(`]*\\sid="${controlledId}"`)); + }); + + it("starts expanded so the question and its options are visible", () => { + const markup = renderPanel(); + + expect(markup).toContain("Approach"); + expect(markup).toContain("Which approach should the migration take?"); + expect(markup).toContain("Incremental"); + expect(markup).toContain("Big bang"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index ceac45c9411d..75dc5a6f5472 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -5,7 +5,8 @@ import { derivePendingUserInputProgress, type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { cn } from "~/lib/utils"; interface PendingUserInputPanelProps { @@ -65,6 +66,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionId: string; optionLabel: string; } | null>(null); + // Collapsing hides everything but the header so a tall prompt stops covering + // the thread the user is trying to read. Scoped to a single question: the card + // is keyed by request id so the next prompt starts expanded, and storing the + // collapsed question's id (rather than a bare flag) reopens the card when the + // prompt advances to its next question, which can happen without a click — + // sending from the composer advances the active question. + const [collapsedQuestionId, setCollapsedQuestionId] = useState(null); + const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id; useEffect(() => { onAdvanceRef.current = onAdvance; @@ -118,9 +127,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( // Keyboard shortcut: number keys 1-9 select corresponding options when focus is // outside editable fields. Multi-select prompts toggle options in place; single- - // select prompts keep the existing auto-advance behavior. + // select prompts keep the existing auto-advance behavior. Collapsed prompts opt + // out, since the numbers they refer to are not on screen. useEffect(() => { - if (!activeQuestion || isResponding) return; + if (!activeQuestion || isResponding || isCollapsed) return; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; @@ -144,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, isResponding]); + }, [activeQuestion, isCollapsed, isResponding]); if (!activeQuestion) { return null; @@ -153,75 +163,119 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const customAnswerActive = progress.customAnswer.trim().length > 0; return ( -
-
- - {activeQuestion.header} - - {prompt.questions.length > 1 ? ( - - {questionIndex + 1}/{prompt.questions.length} + { + setCollapsedQuestionId(open ? null : activeQuestion.id); + }} + > + {/* The trigger's wrapper is inset less than the card's text column, and + the trigger pays the difference back as padding: the hover background + and focus ring bleed 10px past that column on both sides, while the + header label and the chevron still line up with the left and right + edges of the question text below. The negative block margin keeps the + taller hit area from pushing the panel down. */} +
+ + + {activeQuestion.header} - ) : null} + {prompt.questions.length > 1 ? ( + + {questionIndex + 1}/{prompt.questions.length} + + ) : null} + {/* Collapsed, the header is otherwise just a section label and a + counter, so the question itself is echoed here as a one-line + reminder of what is being asked. */} + {isCollapsed ? ( + + {activeQuestion.question} + + ) : null} + {/* The chevron points at the body: down while it is open below the + header, up while it is collapsed into it. */} +
-

{activeQuestion.question}

- {activeQuestion.multiSelect ? ( -

Select one or more options.

- ) : null} -
- {activeQuestion.options.map((option, index) => { - const isOptimisticallySelected = - optimisticSingleSelect?.questionId === activeQuestion.id && - optimisticSingleSelect.optionLabel === option.label; - const isSelected = - isOptimisticallySelected || - (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); - const shortcutKey = index < 9 ? index + 1 : null; - const className = cn( - "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", - isSelected - ? "border-primary/30 bg-primary/8 text-foreground" - : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", - isResponding && "opacity-50 cursor-not-allowed", - !isResponding && "cursor-pointer", - ); - const content = ( - <> -
- {option.label} - {option.description && option.description !== option.label ? ( - {option.description} - ) : null} -
- {isSelected ? ( - - ) : shortcutKey !== null ? ( - +
+

{activeQuestion.question}

+ {activeQuestion.multiSelect ? ( +

Select one or more options.

+ ) : null} +
+ {activeQuestion.options.map((option, index) => { + const isOptimisticallySelected = + optimisticSingleSelect?.questionId === activeQuestion.id && + optimisticSingleSelect.optionLabel === option.label; + const isSelected = + isOptimisticallySelected || + (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); + const shortcutKey = index < 9 ? index + 1 : null; + const className = cn( + "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", + isSelected + ? "border-primary/30 bg-primary/8 text-foreground" + : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", + isResponding && "opacity-50 cursor-not-allowed", + !isResponding && "cursor-pointer", + ); + const content = ( + <> +
+ {option.label} + {option.description && option.description !== option.label ? ( + {option.description} + ) : null} +
+ {isSelected ? ( + + ) : shortcutKey !== null ? ( + + {shortcutKey} + + ) : null} + + ); + return ( + - ); - })} -
-
+ {content} + + ); + })} +
+
+ + ); }); From 684d703b0a8a0632a18c8453277f7e5e6312b200 Mon Sep 17 00:00:00 2001 From: Rishet11 <154429365+Rishet11@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:37:24 +0530 Subject: [PATCH 050/196] fix(shared): degrade an unknown system time zone to UTC in usage windows (#6670) --- packages/shared/src/usageFormat.test.ts | 18 ++++++++++++++++- packages/shared/src/usageFormat.ts | 26 ++++++++++++++++++------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index cecc07c6e670..fb231fbacb20 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics globalDate:off -- A fixed instant keeps calendar-window assertions deterministic. -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { enumerateHourStarts, @@ -54,4 +54,20 @@ describe("hourly usage formatting", () => { expect(window.sinceTime).toBe("2026-08-10T12:37:00.000Z"); expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); }); + + it("degrades an unknown resolved zone to UTC instead of crashing", () => { + const resolved = new Intl.DateTimeFormat().resolvedOptions(); + const resolvedOptions = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...resolved, timeZone: "Etc/Unknown" }); + + try { + const now = new Date("2026-08-11T12:37:42.123Z"); + + expect(makeWindow(1, now, "hour").timeZone).toBe("UTC"); + expect(makeWindow(30, now).timeZone).toBe("UTC"); + } finally { + resolvedOptions.mockRestore(); + } + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index ef2b2bcf21a1..bd751829dd87 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -179,13 +179,25 @@ export function makeWindow( now = new Date(), resolution: UsageResolution = "day", ): UsageSummaryInput { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const format = new Intl.DateTimeFormat("en-CA", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); + let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + // An unknown zone should degrade to UTC rather than crash the page. + timeZone = "UTC"; + format = new Intl.DateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } const untilDay = format.format(now); if (resolution === "hour") { // Minute-aligned bounds keep labels readable while still representing an From ad47d2347c6917f7db33e6e3902e1e8e5d5281ec Mon Sep 17 00:00:00 2001 From: Roshan Mhatre Date: Sat, 15 Aug 2026 17:37:31 +0530 Subject: [PATCH 051/196] fix(claude): discover repo-local .agents/skills in skill discovery (#5488) --- .../src/provider/Drivers/ClaudeSkills.test.ts | 99 +++++++++++++++++++ .../src/provider/Drivers/ClaudeSkills.ts | 31 +++--- docs/user/providers-claude.md | 7 ++ 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 1ad843d7573e..60db1d0c5e26 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -66,6 +66,105 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("discovers project skills from the workspace .agents directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "review", + ["---", "name: review", "description: Review the changes.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "review", + path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"), + enabled: true, + scope: "project", + description: "Review the changes.", + }, + ]); + }), + ); + + it.effect("prefers workspace .claude skills on three-way name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Claude deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "deploy", + path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Claude deploy.", + }, + ]); + }), + ); + + it.effect("prefers workspace .agents skills over user skills on name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "deploy", + path: path.join(workspace, ".agents", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Agents deploy.", + }, + ]); + }), + ); + it.effect("prefers project skills over user skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 335c3d4681df..5c33fba0b9e9 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -1,12 +1,13 @@ /** * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. * - * Claude Code loads skills from `/skills` (user scope) and - * `/.claude/skills` (project scope), one directory per skill with a - * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces - * skills only as slash commands without their filesystem paths, so the - * provider snapshot scans the same locations directly, mirroring how the - * Codex app-server reports its skills. + * Claude Code loads skills from `/skills` (user scope), then + * `/.agents/skills` and `/.claude/skills` (project scope), one + * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots + * win on name collisions, so precedence is user, `.agents`, then `.claude`. + * The Agent SDK init handshake surfaces skills only as slash commands without + * their filesystem paths, so the provider snapshot scans the same locations + * directly, mirroring how the Codex app-server reports its skills. * * @module provider/Drivers/ClaudeSkills */ @@ -84,11 +85,12 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct }); /** - * Enumerate Claude Code skills from the user config dir and the workspace. - * Discovery is best-effort: unreadable roots and malformed skill entries are - * skipped so a broken skill never degrades the provider snapshot. On name - * collisions the project-scoped skill wins, matching Claude Code's - * most-specific-wins resolution. + * Enumerate Claude Code skills from the user config dir, workspace + * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery + * is best-effort: unreadable roots and malformed skill entries are skipped so + * a broken skill never degrades the provider snapshot. On name collisions, + * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching + * Claude Code's resolution. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick, @@ -101,7 +103,12 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, - ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), + ...(cwd + ? [ + { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, + { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, + ] + : []), ]; const skillsByName = new Map(); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 79f1211cf40d..f9699388b7db 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -34,6 +34,13 @@ When you set this field, T3 Code points Claude Code at that directory with the `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and the rest of your environment stay as they are. +## Where Claude Skills Are Loaded + +T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then +`/.agents/skills`, then `/.claude/skills`. + +If the same skill name exists in more than one folder, the later folder wins. + ## I Want Work And Personal Claude Accounts Use a different Claude config directory for each account. From d715c2e56bb718d2225cc0f07cc65e6c637dc229 Mon Sep 17 00:00:00 2001 From: Carlos Jimenez Date: Sat, 15 Aug 2026 05:07:38 -0700 Subject: [PATCH 052/196] fix(server): let slow provider CLIs raise their discovery probe budget (#6223) Co-authored-by: Julius Marminge --- .../AzureDevOpsSourceControlProvider.ts | 4 ++++ .../SourceControlProviderDiscovery.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac9829275..2f147452f9ec 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -45,6 +45,10 @@ export const discovery = { executable: "az", versionArgs: ["--version"], authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + // `az` boots a fresh Python interpreter on every invocation, so even `az --version` + // takes ~6s on Windows and overruns the default budget, leaving the provider reported + // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. + probeTimeoutMs: 20_000, parseAuth: parseAzureAuth, installHint: "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb205..b2b9e4513378 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -33,6 +33,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; + readonly probeTimeoutMs?: number; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, @@ -52,6 +53,14 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { readonly refineUnknownRemote: NonNullable; }; +// Most provider CLIs answer `--version` in well under a second, so a short budget keeps +// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs. +const DEFAULT_PROBE_TIMEOUT_MS = 5_000; + +function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { + return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; +} + interface DiscoveryProbeResult { readonly kind: SourceControlProviderKind; readonly label: string; @@ -167,7 +176,7 @@ function probeCli(input: { command: input.spec.executable, args: input.spec.versionArgs, cwd: input.cwd, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(input.spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -244,7 +253,7 @@ export function probeSourceControlProvider(input: { args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -287,7 +296,7 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) From d5465aebf2746b8d5f327be3b2424d9412a29075 Mon Sep 17 00:00:00 2001 From: sebbonit <36650750+sebbonit@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:07:51 +0200 Subject: [PATCH 053/196] fix(web): retain terminal PR badges after checkout switch (#4755) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/ChatView.tsx | 11 +- apps/web/src/components/Sidebar.tsx | 81 ++-- .../components/ThreadStatusIndicators.test.ts | 388 +++++++++++++++++- .../src/components/ThreadStatusIndicators.tsx | 174 ++++++++ 4 files changed, 618 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb79f1f7e215..d2d3e908c1cb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -276,7 +276,10 @@ import { shouldShowThreadErrorBanner, ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; -import { resolveThreadPr } from "./ThreadStatusIndicators"; +import { + resolveDisplayedThreadPr, + threadChangeRequestSnapshotsAtom, +} from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { @@ -1596,6 +1599,7 @@ function ChatViewContent(props: ChatViewProps) { [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -4124,9 +4128,11 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); - const activeThreadPr = resolveThreadPr({ + const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, + snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, + retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, }); // The right panel offers the thread's own change request, so it can only offer it once the // branch has one; until then the picker says so rather than opening an empty panel. @@ -4208,6 +4214,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadShell, autoSettleAfterDays, autoSettleOnMerge, + changeRequestSnapshotByKey, nowMinute, supportsSettlement, ]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 016a4c682754..5ae583b66bb2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -143,10 +143,15 @@ import { import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, + nextThreadChangeRequestSnapshot, prStatusIndicator, - resolveThreadPr, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, + setThreadChangeRequestSnapshot, settledPrHoverColorClass, terminalStatusFromRunningIds, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { @@ -729,11 +734,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; + changeRequestSnapshot: ThreadChangeRequestSnapshot | null; + onChangeRequestSnapshot: ( + threadKey: string, + snapshot: ThreadChangeRequestSnapshot | null, + ) => void; }) { const { isRenaming, - onChangeRequestState, + changeRequestSnapshot, + onChangeRequestSnapshot, onCancelRename, onCommitRename, onContextMenu, @@ -778,9 +788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }) : null, ); - const pr = resolveThreadPr({ + const retainTerminalOnBranchMismatch = thread.worktreePath === null; + const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, }); const prState = pr?.state ?? null; @@ -874,13 +887,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prProvider = resolveDisplayedThreadPrProvider({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state so the parent can apply the configured merge rule - // and the always-on close rule during partitioning. useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + const nextSnapshot = nextThreadChangeRequestSnapshot({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + if (nextSnapshot === undefined) return; + onChangeRequestSnapshot(threadKey, nextSnapshot); + }, [ + changeRequestSnapshot, + gitStatus.data, + onChangeRequestSnapshot, + retainTerminalOnBranchMismatch, + thread.branch, + threadKey, + ]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1858,26 +1889,7 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. @@ -1993,7 +2005,11 @@ export default function Sidebar() { const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + const snapshot = changeRequestSnapshotByKey.get(threadKey); + const changeRequestState = + snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) + ? snapshot.pr.state + : null; // Snooze outranks everything, including a pin: "hide until Tuesday" // temporarily suspends "keep on top". The pin survives underneath — // and so does its pinOrderKey, so on wake the thread reappears at @@ -2051,7 +2067,7 @@ export default function Sidebar() { }, [ autoSettleAfterDays, autoSettleOnMerge, - changeRequestStateByKey, + changeRequestSnapshotByKey, nowMinute, scopedProjectKeys, serverConfigs, @@ -3686,7 +3702,8 @@ export default function Sidebar() { onUnsnooze={attemptUnsnooze} onUnpin={attemptUnpin} onAcknowledgeWoke={acknowledgeWoke} - onChangeRequestState={handleChangeRequestState} + changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} + onChangeRequestSnapshot={setThreadChangeRequestSnapshot} /> ); }; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3eb8e4f710f1..f77959d9f42b 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,10 +1,19 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { + nextThreadChangeRequestSnapshot, prStatusIndicator, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; function status(overrides: Partial = {}): VcsStatusResult { @@ -30,6 +39,25 @@ function status(overrides: Partial = {}): VcsStatusResult { }; } +function mergedFeaturePr(): NonNullable { + return { + number: 42, + title: "Feature PR", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/current", + state: "merged", + }; +} + +function snapshotFor( + branch: string, + pr: NonNullable, + sourceControlProvider?: VcsStatusResult["sourceControlProvider"], +): ThreadChangeRequestSnapshot { + return { branch, pr, sourceControlProvider }; +} + describe("resolveThreadPr", () => { it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { expect( @@ -70,6 +98,362 @@ describe("resolveThreadPr", () => { }); }); +describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { + const featureBranch = "feature/current"; + const mergedPr = mergedFeaturePr(); + const provider = { + kind: "github" as const, + name: "GitHub", + baseUrl: "https://github.com", + }; + + it("returns the live merged PR when the checkout matches the feature branch", () => { + const gitStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBe(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("after caching a merged PR, resolves main status back to the cached feature PR", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); + + const mainStatus = status({ + refName: "main", + isDefaultRef: true, + pr: { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "main", + headRef: "main", + state: "open", + }, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("never attaches a PR reported by main to the feature thread", () => { + const mainPr = { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "develop", + headRef: "main", + state: "merged" as const, + }; + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("does not show a cached open PR across a branch mismatch", () => { + const openSnapshot = snapshotFor(featureBranch, { + ...mergedPr, + state: "open", + title: "Still open", + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("retains a cached closed PR across a branch mismatch", () => { + const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; + const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: closedSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(closedPr); + }); + + it("does not retain or display a terminal PR when a worktree switches branches", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + const mismatchedStatus = status({ refName: "feature/other", pr: null }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeUndefined(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + }); + + it("retains a local terminal snapshot when thread metadata follows the new branch", () => { + const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: otherBranchSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("clears an open snapshot when a local thread moves to a branch with no PR", () => { + const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("clears an open snapshot when a local checkout moves to a different branch", () => { + const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("clears a retained snapshot when the thread branch is cleared", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPr({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + }); + + it("does not erase a terminal snapshot when VCS data is missing", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).not.toBeNull(); + expect(cached).not.toBeUndefined(); + + const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); + const displayed = resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }); + expect(displayed?.state).toBe("merged"); + + const shell = { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Feature thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + session: null, + createdAt: "2026-04-09T00:00:00.000Z", + updatedAt: "2026-04-09T00:00:00.000Z", + archivedAt: null, + settledAt: null, + settledOverride: null, + latestUserMessageAt: "2026-04-09T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } as OrchestrationThreadShell; + + expect( + effectiveSettled(shell, { + now: "2026-04-10T00:00:00.000Z", + autoSettleAfterDays: null, + changeRequestState: displayed?.state ?? null, + }), + ).toBe(true); + }); +}); + +describe("threadChangeRequestSnapshotsAtom", () => { + it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => + Effect.gen(function* () { + const registry = AtomRegistry.make(); + const threadKey = "environment-1:thread-1"; + const snapshot = snapshotFor("feature/current", mergedFeaturePr()); + + const unmount = registry.mount(threadChangeRequestSnapshotsAtom); + registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); + unmount(); + + yield* Effect.yieldNow; + + const remount = registry.mount(threadChangeRequestSnapshotsAtom); + expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); + + remount(); + registry.dispose(); + }), + ); +}); + describe("prStatusIndicator", () => { it("formats PR tooltips with number, uppercase status, and title", () => { expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index af53d1a78b20..a6ea2e7fd962 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,8 +4,10 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import type { VcsStatusResult } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -126,6 +128,178 @@ export function resolveThreadPr(input: { return gitStatus.pr ?? null; } +/** + * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement + * partitions move them, so terminal PR metadata must live above the row. + */ +export interface ThreadChangeRequestSnapshot { + readonly branch: string; + readonly pr: NonNullable; + readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; +} + +export const threadChangeRequestSnapshotsAtom = Atom.make< + ReadonlyMap +>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); + +function isTerminalChangeRequestState( + state: NonNullable["state"], +): state is "merged" | "closed" { + return state === "merged" || state === "closed"; +} + +function sourceControlProvidersEqual( + left: VcsStatusResult["sourceControlProvider"] | undefined, + right: VcsStatusResult["sourceControlProvider"] | undefined, +): boolean { + if (left === right) return true; + if (left == null || right == null) return left == null && right == null; + return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; +} + +export function threadChangeRequestSnapshotsEqual( + left: ThreadChangeRequestSnapshot, + right: ThreadChangeRequestSnapshot, +): boolean { + return ( + left.branch === right.branch && + left.pr.number === right.pr.number && + left.pr.title === right.pr.title && + left.pr.url === right.pr.url && + left.pr.baseRef === right.pr.baseRef && + left.pr.headRef === right.pr.headRef && + left.pr.state === right.pr.state && + sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) + ); +} + +export function setThreadChangeRequestSnapshot( + threadKey: string, + snapshot: ThreadChangeRequestSnapshot | null, +): void { + appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { + const existing = current.get(threadKey); + if (snapshot === null) { + if (existing === undefined) return [false, current]; + const next = new Map(current); + next.delete(threadKey); + return [true, next]; + } + if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { + return [false, current]; + } + const next = new Map(current); + next.set(threadKey, snapshot); + return [true, next]; + }); +} + +/** + * Authoritative snapshot update from live VCS status. + * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone + * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear + * - snapshot: matching branch reports a PR — store/replace + */ +export function nextThreadChangeRequestSnapshot(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): ThreadChangeRequestSnapshot | null | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if (gitStatus === null) { + return undefined; + } + if (threadBranch === null) { + return null; + } + if (gitStatus.refName !== threadBranch) { + return retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ? undefined + : null; + } + if (gitStatus.pr == null) { + if ( + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return undefined; + } + return null; + } + return { + branch: threadBranch, + pr: gitStatus.pr, + sourceControlProvider: gitStatus.sourceControlProvider, + }; +} + +/** + * Live PR when the checkout matches the thread branch; otherwise, for local + * checkouts only, a cached merged/closed PR for the thread. Local thread + * metadata follows the shared checkout, so the cached branch intentionally + * survives that metadata changing to the newly checked-out branch. Open PRs + * are never retained — their state can still change. + */ +export function resolveDisplayedThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): ThreadPr | null { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.pr; + } + + if ( + threadBranch !== null && + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.pr; + } + + return null; +} + +export function resolveDisplayedThreadPrProvider(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): VcsStatusResult["sourceControlProvider"] | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.sourceControlProvider; + } + + if ( + threadBranch !== null && + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.sourceControlProvider; + } + + return undefined; +} + export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray, ): TerminalStatusIndicator | null { From ca37b19cf8d3882f0b4eee1b9e49050494f30422 Mon Sep 17 00:00:00 2001 From: nqrwhal <81386789+nqrwhal@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:07:53 -0700 Subject: [PATCH 054/196] fix(web): show selected model in context window tooltip (#4772) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/chat/ChatComposer.tsx | 28 ++++----- .../chat/ContextWindowMeter.logic.test.ts | 58 +++++++++++++++++++ .../chat/ContextWindowMeter.logic.ts | 25 ++++++++ .../components/chat/ContextWindowMeter.tsx | 7 ++- 4 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/chat/ContextWindowMeter.logic.test.ts create mode 100644 apps/web/src/components/chat/ContextWindowMeter.logic.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 293767a7390d..a92bf439ae13 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -103,6 +103,7 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; +import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; @@ -210,7 +211,7 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; +import { getProviderInteractionModeToggle } from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -225,10 +226,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; -import { - deriveLatestContextWindowSnapshot, - formatProviderDisplayName, -} from "../../lib/contextWindow"; +import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; @@ -396,7 +394,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ReturnType; - activeThreadProviderDisplayName: string | null; + activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { questionIndex: number; @@ -424,7 +422,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( {props.activeContextWindow ? ( ) : null} {props.isPreparingWorktree ? ( @@ -930,16 +928,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), [activeThreadActivities], ); - const activeThreadProviderDisplayName = useMemo(() => { - if (!activeThreadModelSelection) return null; - const entry = providerStatuses.find( - (p) => p.instanceId === activeThreadModelSelection.instanceId, - ); - if (entry) { - return getProviderDisplayName(providerStatuses, entry.driver); - } - return formatProviderDisplayName(activeThreadModelSelection.instanceId); - }, [providerStatuses, activeThreadModelSelection]); + const activeThreadModelDisplayName = useMemo( + () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), + [activeThreadModelSelection, modelOptionsByInstance], + ); // ------------------------------------------------------------------ // Composer-local state @@ -3222,7 +3214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + it("uses the selected model from the exact provider instance", () => { + const primaryInstanceId = ProviderInstanceId.make("codex"); + const selectedInstanceId = ProviderInstanceId.make("codex-work"); + const modelOptionsByInstance = new Map([ + [ + primaryInstanceId, + [{ slug: "gpt-5.6-sol", name: "Primary profile model", shortName: "Primary" }], + ], + [selectedInstanceId, [{ slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", shortName: "5.6 Sol" }]], + ]); + + expect( + resolveContextWindowModelDisplayName( + { + instanceId: selectedInstanceId, + model: "gpt-5.6-sol", + }, + modelOptionsByInstance, + ), + ).toBe("5.6 Sol"); + }); + + it("falls back to the selected model slug when model metadata is unavailable", () => { + const selectedInstanceId = ProviderInstanceId.make("codex-work"); + + expect( + resolveContextWindowModelDisplayName( + { + instanceId: selectedInstanceId, + model: "custom-model", + }, + new Map(), + ), + ).toBe("custom-model"); + }); +}); + +describe("formatContextWindowCompactionMessage", () => { + it("describes compaction in terms of the selected model", () => { + expect(formatContextWindowCompactionMessage("GPT-5.6 Sol")).toBe( + "Context for GPT-5.6 Sol compacts automatically when needed.", + ); + }); + + it("uses neutral copy when the model is unavailable", () => { + expect(formatContextWindowCompactionMessage(null)).toBe( + "Context compacts automatically when needed.", + ); + }); +}); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts new file mode 100644 index 000000000000..c87170ffe610 --- /dev/null +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -0,0 +1,25 @@ +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; + +export function resolveContextWindowModelDisplayName( + selection: ModelSelection | null | undefined, + modelOptionsByInstance: ReadonlyMap>, +): string | null { + if (!selection) { + return null; + } + + const selectedModel = modelOptionsByInstance + .get(selection.instanceId) + ?.find((model) => model.slug === selection.model); + + return selectedModel ? getTriggerDisplayModelName(selectedModel) : selection.model; +} + +export function formatContextWindowCompactionMessage( + modelDisplayName: string | null | undefined, +): string { + return modelDisplayName + ? `Context for ${modelDisplayName} compacts automatically when needed.` + : "Context compacts automatically when needed."; +} diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index f377c893ae2c..6e42dcadd8b9 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,6 +1,7 @@ import { Button } from "../ui/button"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -14,9 +15,9 @@ function formatPercentage(value: number | null): string | null { export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; - providerDisplayName?: string | null; + modelDisplayName?: string | null; }) { - const { usage, providerDisplayName } = props; + const { usage, modelDisplayName } = props; const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -127,7 +128,7 @@ export function ContextWindowMeter(props: { ) : null} {usage.compactsAutomatically ? (
- {providerDisplayName ?? "It"} automatically compacts its context when needed. + {formatContextWindowCompactionMessage(modelDisplayName)}
) : null}
From 5e147371527154be385f28e57339a71521e528c6 Mon Sep 17 00:00:00 2001 From: CursedApple <36764254+Serendeep@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:08:10 +0200 Subject: [PATCH 055/196] fix(web): scale command details with code font (#6510) --- .github/pr-assets/6424-after.svg | 1 + .github/pr-assets/6424-before.svg | 1 + apps/web/src/components/chat/MessagesTimeline.test.tsx | 8 +++++++- apps/web/src/components/chat/MessagesTimeline.tsx | 7 ++++--- 4 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .github/pr-assets/6424-after.svg create mode 100644 .github/pr-assets/6424-before.svg diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg new file mode 100644 index 000000000000..dbeb594a09da --- /dev/null +++ b/.github/pr-assets/6424-after.svg @@ -0,0 +1 @@ + diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg new file mode 100644 index 000000000000..6b365bad6e69 --- /dev/null +++ b/.github/pr-assets/6424-before.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 194edc0bd5bb..dfdfd1169653 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -134,6 +134,7 @@ function matchMedia() { } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; +let toolCallExpandedBodyClassName: typeof import("./MessagesTimeline").toolCallExpandedBodyClassName; beforeAll(async () => { const classList = { @@ -167,7 +168,7 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline } = await import("./MessagesTimeline")); + ({ MessagesTimeline, toolCallExpandedBodyClassName } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -226,6 +227,11 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("sizes expanded tool details with the configured code font size", () => { + expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); + expect(toolCallExpandedBodyClassName).not.toContain("text-[11px]"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f5c529ff315f..9fe392d76a21 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2059,6 +2059,9 @@ function buildToolCallExpandedBody( return blocks.length > 0 ? blocks.join("\n\n") : null; } +export const toolCallExpandedBodyClassName = + "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text"; + function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || @@ -2369,9 +2372,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { onClick={stopRowToggle} onPointerDown={stopRowToggle} > -
-            {expandedBody}
-          
+
{expandedBody}
) : null}
From cf7bfd1c93974428262ab1419d11c972d01d65fa Mon Sep 17 00:00:00 2001 From: John Surles Date: Sat, 15 Aug 2026 08:08:29 -0400 Subject: [PATCH 056/196] fix(web): preserve XML-like tags in user messages (#4133) Co-authored-by: codex Co-authored-by: Julius Marminge --- apps/web/src/components/ChatMarkdown.tsx | 14 +- .../components/chat/MessagesTimeline.test.tsx | 148 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 4 + 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ec88bc912f00..c4548540e2ce 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -117,6 +117,8 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Parse sanitized raw HTML instead of displaying its source text. */ + parseRawHtml?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -1360,6 +1362,7 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + parseRawHtml = true, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1622,7 +1625,7 @@ function ChatMarkdown({ /> ); }, - a({ node, href, children, ...props }) { + a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { @@ -1707,6 +1710,9 @@ function ChatMarkdown({ props.className, ); }, + img({ node: _node, title: _title, ...props }) { + return ; + }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); @@ -1777,6 +1783,9 @@ function ChatMarkdown({ ]); /* eslint-enable react/no-unstable-nested-components */ + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. + // Keep that behavior explicit because literal mode depends on escaping the + // complete source token instead of dropping it from the rendered message. return (
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index dfdfd1169653..e51095bb00ad 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -226,6 +226,17 @@ function buildUserTimelineEntry(text: string) { }; } +function buildAssistantTimelineEntry(text: string) { + const entry = buildUserTimelineEntry(text); + return { + ...entry, + message: { + ...entry.message, + role: "assistant" as const, + }, + }; +} + describe("MessagesTimeline", () => { it("sizes expanded tool details with the configured code font size", () => { expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); @@ -470,7 +481,142 @@ describe("MessagesTimeline", () => { expect(markup).toContain("rounded-2xl bg-message p-3"); }); - it("renders inline terminal labels with the composer chip UI", () => { + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + 'Before inside after', + " in your context?", + "Comparison: 2 < 3 and 5 > 4.", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("<global-agent-instructions scope="workspace">"); + expect(markup).toContain( + "Before <nested data-value="a&b">inside</nested> after", + ); + expect(markup).toContain("</global-agent-instructions> in your context?"); + expect(markup).toContain("Comparison: 2 < 3 and 5 > 4."); + }); + + it("preserves XML-like source inside user code spans and fences", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + `', + "", + "```xml", + '', + "```", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain('<tag attr="x">'); + expect(markup).toContain("<root><child enabled="true" /></root>"); + }); + + it("does not render markdown title attributes in user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('href="https://example.com"'); + expect(markup).toContain('src="https://example.com/image.png"'); + expect(markup).not.toContain('title="link tip"'); + expect(markup).not.toContain('title="image tip"'); + }); + + it("renders unsafe user HTML as inert source text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + globalThis.__t3Xss = 1', + ), + ]} + />, + ); + + expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>"); + expect(markup).toContain( + "<img src="x" onerror="globalThis.__t3Xss = 2">", + ); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toMatch(/)/i); + }); + + it("continues to render sanitized raw HTML in assistant messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + MoreDetails"), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("More"); + expect(markup).not.toContain("<details>"); + }); + + it("sanitizes executable HTML while preserving supported assistant markup", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + "Safe details", + "", + '', + 'Unsafe link', + "", + ].join(""), + ), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("Safe details"); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toContain("onclick="); + expect(markup).not.toContain("onerror="); + expect(markup).not.toContain("javascript:"); + expect(markup).not.toContain("globalThis.__t3Xss"); + }); + + it("renders inline terminal labels with the composer chip UI", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( ) : null} {trailingWhitespace ? : null} @@ -1714,6 +1715,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />
) : null @@ -1802,6 +1804,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />, ); } else if (inlinePrefix.length === 0) { @@ -1827,6 +1830,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} /> ); }); From 7c8848ebb054c1f4c1279f634633cddc37cb1fac Mon Sep 17 00:00:00 2001 From: Akos Balogh Date: Sat, 15 Aug 2026 14:10:21 +0200 Subject: [PATCH 057/196] fix(desktop): route mouse thumb buttons to the in-app browser (#4459) Co-authored-by: Claude Opus 4.8 --- apps/desktop/src/preview/GuestProtocol.ts | 1 + apps/desktop/src/preview/Manager.test.ts | 63 +++++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 19 +++++++ apps/desktop/src/preview/PickPreload.ts | 35 +++++++++++++ 4 files changed, 118 insertions(+) diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 00616c6a4761..e63597b71efc 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; +export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 5c336eec8da4..c4297a69c260 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2239,6 +2239,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => + withManager((manager) => + Effect.gen(function* () { + let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; + const goBack = vi.fn(); + const goForward = vi.fn(); + let canGoBack = true; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof mouseNavigate) => { + if (channel === "preview:mouse-navigate") mouseNavigate = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + 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_nav"); + yield* manager.registerWebview("tab_nav", 42); + expect(mouseNavigate).toBeDefined(); + + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + + mouseNavigate?.({}, { direction: "forward" }); + yield* Effect.yieldNow; + expect(goForward).toHaveBeenCalledOnce(); + + // Ignores unknown payloads and never navigates when history is exhausted. + mouseNavigate?.({}, { direction: "sideways" }); + canGoBack = false; + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d48b13037398..e5a08e7da8c1 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -58,6 +58,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; @@ -1506,6 +1507,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; + const mouseNavigate = (_event: unknown, payload?: unknown): void => { + const direction = + typeof payload === "object" && payload !== null && "direction" in payload + ? (payload as { direction?: unknown }).direction + : undefined; + if (direction !== "back" && direction !== "forward") return; + runFork( + attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { + if (direction === "back") { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + } else if (wc.navigationHistory.canGoForward()) { + wc.navigationHistory.goForward(); + } + }).pipe(Effect.ignore), + ); + }; const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( event: Electron.Event, input: Electron.Input, @@ -1552,6 +1569,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("did-fail-load", failed as never); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { @@ -1565,6 +1583,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler(({ url }) => { runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index d03673400ab5..f315bdcec738 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -22,6 +22,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; @@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const MOUSE_BUTTON_BACK = 3; +const MOUSE_BUTTON_FORWARD = 4; + +const navigationDirectionForButton = (button: number): "back" | "forward" | null => { + if (button === MOUSE_BUTTON_BACK) return "back"; + if (button === MOUSE_BUTTON_FORWARD) return "forward"; + return null; +}; + +// Chromium routes thumb-button history navigation to the *focused* WebContents, +// so hovering this guest without focusing it sends the host app's router back +// instead of the preview. Suppress Chromium's default here and drive this tab's +// history explicitly so the buttons always navigate the browser the pointer is +// over — never the host app. +const suppressNavigationButton = (event: MouseEvent): void => { + if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); +}; + +const requestNavigationForButton = (event: MouseEvent): void => { + if (!event.isTrusted) return; + const direction = navigationDirectionForButton(event.button); + if (direction === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); + ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); +}; + +window.addEventListener("mousedown", suppressNavigationButton, true); +window.addEventListener("mouseup", requestNavigationForButton, true); +window.addEventListener("auxclick", suppressNavigationButton, true); + const nextId = (prefix: string): string => { idSequence += 1; return `${prefix}_${idSequence.toString(36)}`; From f915320914d1bc446e60cbcfe4cd7d75bad4dc2a Mon Sep 17 00:00:00 2001 From: jorvarea <47249803+jorvarea@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:10:32 +0200 Subject: [PATCH 058/196] fix(web): keep the final segment of directory paths with a trailing separator (#5460) Co-authored-by: jorvarea --- apps/web/src/markdown-links.test.ts | 25 +++++++++++++++++++++++++ apps/web/src/markdown-links.ts | 8 ++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc296138672..f7c507c178f9 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -273,3 +273,28 @@ describe("resolveInlineCodeFileLinkMeta", () => { expect(resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md")).toBeNull(); }); }); + +describe("directory paths with a trailing separator", () => { + it("keeps the final segment for a POSIX directory path", () => { + expect(resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project")).toMatchObject({ + basename: "favicons", + }); + }); + + it("keeps the final segment for a Windows directory path", () => { + expect( + resolveMarkdownFileLinkMeta("C:\\Users\\kelchm\\.claude\\", "/repo/project"), + ).toMatchObject({ basename: ".claude" }); + }); + + it("matches the label of the same path without a trailing separator", () => { + const withSlash = resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project"); + const withoutSlash = resolveMarkdownFileLinkMeta("/tmp/favicons", "/repo/project"); + expect(withSlash?.basename).toBe(withoutSlash?.basename); + }); + + it("does not produce an empty label for the filesystem root", () => { + const meta = resolveMarkdownFileLinkMeta("/tmp/", "/repo/project"); + expect(meta?.basename).not.toBe(""); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8ac..e74bd170117f 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -359,8 +359,12 @@ export function resolveInlineCodeFileLinkMeta( } function basenameOfPath(path: string): string { - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + // A trailing separator is a valid way to write a directory, so trim it before + // taking the final segment. Without this the segment reads as empty and the + // chip renders with no label at all. + const trimmed = path.replace(/[/\\]+$/, "") || path; + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; } function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { From 7083bce26aa89fedfc482ad44cf61c5508a58db7 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:42:15 +0530 Subject: [PATCH 059/196] Keep block code plain when copying from rendered markdown (#4468) --- apps/web/src/markdown-clipboard.test.ts | 95 +++++++++++++++++++++++++ apps/web/src/markdown-clipboard.ts | 22 +++++- 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/markdown-clipboard.test.ts diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts new file mode 100644 index 000000000000..7265e8b60430 --- /dev/null +++ b/apps/web/src/markdown-clipboard.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; + +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +class FakeText { + readonly nodeType = TEXT_NODE; + readonly childNodes: ReadonlyArray = []; + + constructor(readonly textContent: string) {} +} + +class FakeElement { + readonly nodeType = ELEMENT_NODE; + readonly childNodes: Array = []; + readonly classList = { + contains: (name: string) => this.classNames.includes(name), + }; + + constructor( + readonly tagName: string, + private readonly classNames: ReadonlyArray = [], + ) {} + + get localName(): string { + return this.tagName.toLowerCase(); + } + + get textContent(): string { + return this.childNodes.map((child) => child.textContent).join(""); + } + + append(...children: Array): this { + this.childNodes.push(...children); + return this; + } + + getAttribute(): string | null { + return null; + } + + hasAttribute(): boolean { + return false; + } +} + +function asNode(element: FakeElement): Node { + return element as unknown as Node; +} + +function shikiCodeLine(text: string): FakeElement { + const token = new FakeElement("SPAN").append(new FakeText(text)); + return new FakeElement("SPAN", ["line"]).append(token); +} + +describe("serializeRenderedMarkdownFragment", () => { + beforeEach(() => { + vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("wraps inline code in backticks", () => { + const paragraph = new FakeElement("P").append( + new FakeText("run "), + new FakeElement("CODE").append(new FakeText("git status")), + new FakeText(" first"), + ); + const container = new FakeElement("DIV").append(paragraph); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); + }); + + it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => { + const code = new FakeElement("CODE").append( + shikiCodeLine("git show-ref --verify refs/remotes/origin/opt/deploy/dev"), + ); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "git show-ref --verify refs/remotes/origin/opt/deploy/dev", + ); + }); + + it("keeps a multi-line code selection plain instead of inline-wrapping it", () => { + const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line")); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index f56b3a4920e4..069d161a188c 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string { return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`; } +/** + * A code element whose pre wrapper fell outside the copied range is still + * block code, recognizable by its highlighter line spans or embedded + * newlines. Wrapping it like inline code produces backtick-surrounded + * shell commands on paste. + */ +function isBlockCodeElement(element: Element, content: string): boolean { + if (content.includes("\n")) return true; + for (const child of element.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) { + return true; + } + } + return false; +} + function wrapInlineCode(code: string): string { const longestRun = [...(code.match(/`+/g) ?? [])].reduce( (max, run) => Math.max(max, run.length), @@ -201,8 +217,10 @@ function serializeNode(node: Node): string { return `${serializeChildren(element).trim()}\n\n`; case "PRE": return serializeCodeBlock(element); - case "CODE": - return wrapInlineCode(element.textContent ?? ""); + case "CODE": { + const content = element.textContent ?? ""; + return isBlockCodeElement(element, content) ? content : wrapInlineCode(content); + } case "STRONG": case "B": return wrapInlineMarker(serializeChildren(element), "**"); From 21b6fb528d6b2d3b3e333b2bd4455d6cdf7d7a41 Mon Sep 17 00:00:00 2001 From: Alex Brodsky <122503996+Albro3459@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:12:42 -0500 Subject: [PATCH 060/196] fix(web): add web app manifest so installed app keeps its scope (#4306) --- apps/web/index.html | 1 + apps/web/public/manifest.webmanifest | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 apps/web/public/manifest.webmanifest diff --git a/apps/web/index.html b/apps/web/index.html index 8f49fd32c829..8aef3a4286f2 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,7 @@ +