From f5c1142d2426d314a3399a9862bdbf347ff9920d Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:19:03 +0530 Subject: [PATCH 001/898] fix(mobile): settings no longer tell you to update when you're up to date (#21688) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- .../features/settings/SettingsEnvironmentDetailRouteScreen.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index ea5eb2b8ec64..894f6c848477 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -248,7 +248,8 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm : "You are up to date."} ) : null} - {!supportsEnvironmentUpdate(config.environment.capabilities) ? ( + {checkedRelease?.targetVersion && + !supportsEnvironmentUpdate(config.environment.capabilities) ? ( {capabilities?.serverSelfUpdate === "desktop-managed" ? "Update the desktop app on this machine." From 2532e64099a2a14e18427783a79e7ede44f626ce Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:33:24 +0530 Subject: [PATCH 002/898] test(web): fix the workflow script test that times out on busy CI runners (#21699) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- .../chat/WorkflowOpeningBlock.browser.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx b/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx index 78abe0651ba9..e9ac0d4dca26 100644 --- a/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx +++ b/apps/web/src/components/chat/WorkflowOpeningBlock.browser.test.tsx @@ -22,6 +22,16 @@ vi.mock(import("../../state/server"), async (original) => { serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => noConfig) as never }, }; }); +// Loading the real Shiki WASM and grammar can take seconds on a busy runner; the block only +// needs to hand its script to the markdown code block that highlights it. +vi.mock(import("../../lib/syntaxHighlighting"), async (original) => ({ + ...(await original()), + getSyntaxHighlighterPromise: async () => + ({ + codeToHtml: (code: string) => + Object.assign(document.createElement("pre"), { textContent: code }).outerHTML, + }) as never, +})); const SHORT_SCRIPT = "export const meta = { name: 'repo-check' }\nagent('Run SCRIPTONLY with Bash')"; From 5307c233b1f0ae72b8894dd39d94019aaf9e7cb8 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:18:56 +0530 Subject: [PATCH 003/898] fix(mobile): a file shared into a new task shows in the composer before you send (#21659) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- .../src/state/use-composer-drafts.test.ts | 43 ++++++++++++++++++- apps/mobile/src/state/use-composer-drafts.ts | 28 ++++++++---- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 8d48fa129c7c..43e14f90ec74 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -148,7 +148,10 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ })); import type { DraftComposerAttachment } from "../lib/composerImages"; -import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; +import { + collectComposerContextReferences, + formatComposerContextReference, +} from "@t3tools/shared/composerContextReferences"; import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { @@ -2304,6 +2307,44 @@ describe("mobile composer drafts", () => { expect(mergeComposerDraftContentState(edited, draftKey, content)).toBe(edited); }); + it("shows a shared non-image file as a composer chip", () => { + const draftKey = "new-task:environment-1:project-1"; + const sharedFile = { + id: "share-1:file:0", + type: "file" as const, + name: "backup.json", + mimeType: "application/json", + sizeBytes: 41, + fileUri: "file:///documents/backup.json", + }; + + const existingFile = { ...sharedFile, id: "existing-file", name: "notes.txt" }; + + const merged = mergeComposerDraftContentState( + { [draftKey]: { text: "", attachments: [existingFile] } }, + draftKey, + { text: "", attachments: [sharedFile], sourceShareId: "share-1" }, + )[draftKey]!; + + expect(merged.attachments).toEqual([existingFile, sharedFile]); + const references = collectComposerContextReferences(merged.text); + expect(references).toHaveLength(1); + expect(merged.context?.records).toEqual([ + expect.objectContaining({ + kind: "file", + contextId: references[0]!.contextId, + attachmentId: sharedFile.id, + }), + ]); + + // A recovery merge carries its own text; a chip appended to it would + // break the duplicate-text check when the merge is retried. + const recovered = { text: "Retry this", attachments: [sharedFile] }; + const once = mergeComposerDraftContentState({}, draftKey, recovered); + expect(once[draftKey]?.text).toBe("Retry this"); + expect(mergeComposerDraftContentState(once, draftKey, recovered)).toBe(once); + }); + it("preserves existing images when shared content exceeds the draft attachment limit", () => { const draftKey = "new-task:environment-1:project-1"; const image = (id: string) => ({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index c60e9e2d111d..b804bb1c9269 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -489,7 +489,10 @@ function attachmentContextRecord( : { ...common, kind: "file" as const }; } -function restoreMissingComposerFileReferences(draft: ComposerDraft): ComposerDraft { +function restoreMissingComposerFileReferences( + draft: ComposerDraft, + attachments = draft.attachments, +): ComposerDraft { const records = [...(draft.context?.records ?? [])]; const usedIds = new Set(records.map((record) => record.contextId)); const referenced = new Set( @@ -499,7 +502,7 @@ function restoreMissingComposerFileReferences(draft: ComposerDraft): ComposerDra ); let text = draft.text; let changed = false; - for (const attachment of draft.attachments) { + for (const attachment of attachments) { if ( attachment.type === "image" || imageMimeType(attachment) !== null || @@ -1578,13 +1581,20 @@ export function mergeComposerDraftContentState( } return { ...current, - [draftKey]: { - ...existing, - text, - attachments, - context, - ...(importedShareIds ? { importedShareIds } : {}), - }, + // The composer shows non-image files only as inline chips, so a shared file + // merged without one would be attached but invisible until sent. Other + // merges carry their own text, and appending chips to it would defeat + // mergeComposerDraftText's duplicate check on a retried merge. + [draftKey]: restoreMissingComposerFileReferences( + { + ...existing, + text, + attachments, + context, + ...(importedShareIds ? { importedShareIds } : {}), + }, + content.sourceShareId ? attachments.slice(existing.attachments.length) : [], + ), }; } From 7e7e6d8240858d19d8e72dbcd0f0117e3d0a7f56 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:06:27 -0400 Subject: [PATCH 004/898] fix(desktop): a cold WSL distro gets 30 seconds to boot instead of 10 (#21070) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../src/wsl/DesktopWslEnvironment.test.ts | 104 ++++++++++++++++++ apps/desktop/src/wsl/DesktopWslEnvironment.ts | 3 +- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 0224eca440ea..ab4a73c544ac 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -2,22 +2,30 @@ import { describe, it } from "@effect/vitest"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, buildWslRuntimeProbeScript, DesktopWslDistroListError, + DesktopWslEnvironment, formatMissingToolsReason, + layer as wslEnvironmentLayer, parseNodePath, parseNodeVersion, parseResolvedPath, @@ -158,6 +166,102 @@ describe("probeWslDistros", () => { }); }); +describe("WSL command deadlines", () => { + const makeControlledEnvironment = Effect.gen(function* () { + const started = yield* Deferred.make(); + const exited = yield* Deferred.make(); + const released = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.acquireRelease( + Deferred.succeed(started, undefined).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Deferred.await(exited), + isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + () => Deferred.succeed(released, undefined), + ), + ); + const environment = yield* DesktopWslEnvironment.pipe( + Effect.provide( + wslEnvironmentLayer.pipe( + Layer.provide([ + DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop", + homeDirectory: "/home/test", + platform: "win32", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, + }).pipe(Layer.provide([Path.layer, DesktopConfig.layerTest({})])), + FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ]), + ), + ), + ); + return { environment, started, exited, released }; + }); + + it.effect("waits for a cold distro to finish starting after 15 seconds", () => + Effect.gen(function* () { + const { environment, started, exited, released } = yield* makeControlledEnvironment; + const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped); + yield* Deferred.await(started); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* Deferred.isDone(released)).toBe(false); + + yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); + yield* Fiber.join(fiber); + expect(yield* Deferred.isDone(released)).toBe(true); + }), + ); + + it.effect("releases a stalled warmup process at the 30-second deadline", () => + Effect.gen(function* () { + const { environment, started, released } = yield* makeControlledEnvironment; + const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped); + yield* Deferred.await(started); + yield* TestClock.adjust(Duration.seconds(29)); + expect(yield* Deferred.isDone(released)).toBe(false); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* Fiber.join(fiber); + expect(yield* Deferred.isDone(released)).toBe(true); + }), + ); + + it.effect("still gives path conversion only 10 seconds and releases its process", () => + Effect.gen(function* () { + const { environment, started, released } = yield* makeControlledEnvironment; + const fiber = yield* environment + .windowsToWslPath("Ubuntu", "C:\\project") + .pipe(Effect.forkScoped); + yield* Deferred.await(started); + yield* TestClock.adjust(Duration.seconds(9)); + expect(yield* Deferred.isDone(released)).toBe(false); + + yield* TestClock.adjust(Duration.seconds(1)); + expect(yield* Fiber.join(fiber)).toEqual(Option.none()); + expect(yield* Deferred.isDone(released)).toBe(true); + }), + ); +}); + describe("WSL runtime cache", () => { it.each([ [ diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 7e00f1b40ad1..2fb4a0aa37d3 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -17,7 +17,8 @@ import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; const PROCESS_TERMINATE_GRACE = Duration.seconds(1); const LIST_TIMEOUT = Duration.seconds(8); -const PRE_WARM_TIMEOUT = Duration.seconds(10); +// Booting a stopped distro needs more time than converting paths in a running one. +const PRE_WARM_TIMEOUT = Duration.seconds(30); const WSLPATH_TIMEOUT = Duration.seconds(10); const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); From 34c5a6d4461ff7be2c1a059c3c51c3496c1e14df Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 19 Oct 2026 10:22:12 -0500 Subject: [PATCH 005/898] fix(web): detect software keyboard before handling composer Enter (#21763) --- apps/web/src/components/chat/ChatComposer.tsx | 8 +- apps/web/src/composer-logic.test.ts | 25 ++- apps/web/src/composer-logic.ts | 5 +- apps/web/src/hooks/useSoftwareKeyboardOpen.ts | 17 ++ apps/web/src/lib/softwareKeyboard.test.ts | 187 ++++++++++++++++++ apps/web/src/lib/softwareKeyboard.ts | 83 ++++++++ third-party-licenses.config.json | 12 ++ 7 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/hooks/useSoftwareKeyboardOpen.ts create mode 100644 apps/web/src/lib/softwareKeyboard.test.ts create mode 100644 apps/web/src/lib/softwareKeyboard.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ebc30a4a1e70..0d6da7aea0f8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1103,6 +1103,7 @@ import { import { searchProviderSkills } from "../../providerSkillSearch"; import { useDelayedStatus } from "../../hooks/useDelayedStatus"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useSoftwareKeyboardOpen } from "../../hooks/useSoftwareKeyboardOpen"; import { useStableCallback } from "../../hooks/useStableCallback"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -2415,6 +2416,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); + const isSoftwareKeyboardOpen = useSoftwareKeyboardOpen(); const { isComposerFocused, setIsComposerFocused, @@ -3917,7 +3919,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const blurMobileComposerAfterSend = () => { - if (!isMobileViewport) return; + if (!isSoftwareKeyboardOpen) return; if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; @@ -3930,7 +3932,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const shouldBlurMobileComposerOnSubmit = () => { - if (!isMobileViewport) return false; + if (!isSoftwareKeyboardOpen) return false; if ( isSendBusy || isSendDisabled || @@ -4147,7 +4149,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submissionIntent = composerSubmissionIntentForKey({ event, keybindings, - isMobileViewport, + isSoftwareKeyboardOpen, isDraftThread: routeKind === "draft", isRunning: phase === "running", sendShortcut: settings.sendShortcut, diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index be114ed78d99..49424306d64a 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -72,7 +72,7 @@ describe("composerSubmissionIntentForKey", () => { const input = { keybindings: DEFAULT_RESOLVED_KEYBINDINGS, platform: "Linux", - isMobileViewport: false, + isSoftwareKeyboardOpen: false, isDraftThread: false, }; const enter = { key: "Enter", metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; @@ -292,8 +292,29 @@ describe("composerSubmissionIntentForKey", () => { }, ); + it.each(["MacIntel", "Win32", "Linux"])( + "keeps modifier submission available with the software keyboard open on %s", + (platform) => { + const modEnter = { + ...enter, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }; + const keyboardOpen = { ...input, platform, isSoftwareKeyboardOpen: true }; + expect(composerSubmissionIntentForKey({ ...keyboardOpen, event: modEnter })).toBe( + "foreground", + ); + expect( + composerSubmissionIntentForKey({ ...keyboardOpen, isRunning: true, event: modEnter }), + ).toBe("alternate"); + expect( + composerSubmissionIntentForKey({ ...keyboardOpen, isDraftThread: true, event: modEnter }), + ).toBe("background"); + }, + ); + it.each([ - { isMobileViewport: true }, + { isSoftwareKeyboardOpen: true }, { event: { ...enter, isComposing: true } }, { event: { ...enter, keyCode: 229 } }, { event: { ...enter, repeat: true } }, diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 80478a7aae5b..c82bf38d154d 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -41,14 +41,15 @@ export function composerSubmissionIntentForKey(input: { event: ShortcutEventLike & { isComposing?: boolean; keyCode?: number; repeat?: boolean }; keybindings: ResolvedKeybindingsConfig; platform?: string; - isMobileViewport: boolean; + isSoftwareKeyboardOpen: boolean; isDraftThread: boolean; isRunning?: boolean; sendShortcut?: ClientSettings["sendShortcut"]; prompt?: string; }): ComposerSubmissionIntent | null { const { event } = input; - if (input.isMobileViewport || event.isComposing || event.keyCode === 229 || event.repeat) + if (event.isComposing || event.keyCode === 229 || event.repeat) return null; + if (input.isSoftwareKeyboardOpen && event.key === "Enter" && !event.metaKey && !event.ctrlKey) return null; const command = resolveShortcutCommand(event, input.keybindings, { ...(input.platform === undefined ? {} : { platform: input.platform }), diff --git a/apps/web/src/hooks/useSoftwareKeyboardOpen.ts b/apps/web/src/hooks/useSoftwareKeyboardOpen.ts new file mode 100644 index 000000000000..8d9c3ebeb7be --- /dev/null +++ b/apps/web/src/hooks/useSoftwareKeyboardOpen.ts @@ -0,0 +1,17 @@ +import { useSyncExternalStore } from "react"; + +import { isElectron } from "../env"; +import { createSoftwareKeyboardStore } from "../lib/softwareKeyboard"; + +const store = createSoftwareKeyboardStore( + typeof window === "undefined" ? undefined : window, + isElectron, +); + +function getServerSnapshot() { + return false; +} + +export function useSoftwareKeyboardOpen() { + return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot); +} diff --git a/apps/web/src/lib/softwareKeyboard.test.ts b/apps/web/src/lib/softwareKeyboard.test.ts new file mode 100644 index 000000000000..35fd808d1e82 --- /dev/null +++ b/apps/web/src/lib/softwareKeyboard.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createSoftwareKeyboardStore } from "./softwareKeyboard"; + +function fixture(options: { native?: boolean; overlay?: boolean; desktop?: boolean } = {}) { + const viewport = Object.assign(new EventTarget(), { width: 390, height: 844, scale: 1 }); + const keyboard = Object.assign(new EventTarget(), { + boundingRect: { height: 0 }, + overlaysContent: options.overlay ?? false, + }); + const win = { + innerHeight: 844, + visualViewport: viewport, + navigator: options.native ? { virtualKeyboard: keyboard } : {}, + }; + const store = createSoftwareKeyboardStore(win as unknown as Window, options.desktop ?? false); + const resize = (height: number, width = viewport.width, scale = viewport.scale) => { + Object.assign(viewport, { height, width, scale }); + viewport.dispatchEvent(new Event("resize")); + }; + const geometry = (height: number) => { + keyboard.boundingRect.height = height; + keyboard.dispatchEvent(new Event("geometrychange")); + }; + return { store, viewport, keyboard, win, resize, geometry }; +} + +describe("software keyboard detection", () => { + it("follows native keyboard geometry even when the viewport does not resize", () => { + const { store, geometry } = fixture({ native: true, overlay: true }); + const observed: boolean[] = []; + const unsubscribe = store.subscribe(() => observed.push(store.getSnapshot())); + expect(store.getSnapshot()).toBe(false); + + geometry(300); + expect(store.getSnapshot()).toBe(true); + geometry(0); + expect(store.getSnapshot()).toBe(false); + expect(observed).toEqual([true, false]); + + unsubscribe(); + geometry(300); + expect(observed).toEqual([true, false]); + }); + + it("reads keyboard geometry that was already present before subscribing", () => { + const { store, keyboard } = fixture({ native: true }); + keyboard.boundingRect.height = 300; + expect(store.getSnapshot()).toBe(true); + }); + + it("prefers overlay geometry over a misleading viewport height", () => { + const { store, resize, geometry } = fixture({ native: true, overlay: true }); + const unsubscribe = store.subscribe(() => {}); + resize(500); + expect(store.getSnapshot()).toBe(false); + geometry(300); + expect(store.getSnapshot()).toBe(true); + geometry(0); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); + + it.each([false, true])("falls back to viewport changes with native API present: %s", (native) => { + const { store, resize, keyboard } = fixture({ native }); + const unsubscribe = store.subscribe(() => {}); + + resize(500); + expect(store.getSnapshot()).toBe(true); + resize(550); + expect(store.getSnapshot()).toBe(true); + resize(844); + expect(store.getSnapshot()).toBe(false); + expect(keyboard.overlaysContent).toBe(false); + unsubscribe(); + }); + + it("detects a docked keyboard when subscribing after it has opened", () => { + const { store, resize } = fixture(); + resize(500); + const unsubscribe = store.subscribe(() => {}); + expect(store.getSnapshot()).toBe(true); + resize(844); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); + + it("ignores small height changes such as the browser toolbar", () => { + const { store, resize } = fixture(); + const unsubscribe = store.subscribe(() => {}); + resize(750); + expect(store.getSnapshot()).toBe(false); + resize(700); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); + + it("ignores window resizing even with a large height change", () => { + const { store, resize, win } = fixture(); + const unsubscribe = store.subscribe(() => {}); + win.innerHeight = 500; + resize(500); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); + + it("ignores pinch zoom and recognizes keyboard changes at the new scale", () => { + const { store, resize } = fixture(); + const unsubscribe = store.subscribe(() => {}); + resize(422, 195, 2); + expect(store.getSnapshot()).toBe(false); + resize(300); + expect(store.getSnapshot()).toBe(true); + resize(422); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); + + it("does not mistake an already zoomed viewport for an open keyboard", () => { + const { store, resize } = fixture(); + resize(422, 195, 2); + const unsubscribe = store.subscribe(() => {}); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); + + it("does not mistake rotation for an open keyboard", () => { + const { store, resize, win } = fixture(); + const unsubscribe = store.subscribe(() => {}); + win.innerHeight = 390; + resize(390, 844); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); + + it("shares listeners and refreshes state after the final subscriber leaves", () => { + const { store, resize } = fixture(); + const first = vi.fn(); + const second = vi.fn(); + const unsubscribeFirst = store.subscribe(first); + const unsubscribeSecond = store.subscribe(second); + resize(500); + expect(store.getSnapshot()).toBe(true); + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledOnce(); + unsubscribeFirst(); + resize(550); + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledTimes(2); + unsubscribeSecond(); + resize(844); + expect(second).toHaveBeenCalledTimes(2); + + const unsubscribe = store.subscribe(first); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); + + it("recognizes keyboard closure despite viewport rounding at high zoom", () => { + const { store, resize } = fixture(); + resize(211, 98, 4); + const unsubscribe = store.subscribe(() => {}); + resize(150); + expect(store.getSnapshot()).toBe(true); + resize(210.6); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); + + it("bypasses both native geometry and viewport detection on desktop", () => { + const { store, geometry, resize } = fixture({ native: true, overlay: true, desktop: true }); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + geometry(300); + resize(500); + expect(store.getSnapshot()).toBe(false); + expect(listener).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it("does not require browser APIs during server rendering", () => { + const store = createSoftwareKeyboardStore(undefined, false); + const unsubscribe = store.subscribe(() => {}); + expect(store.getSnapshot()).toBe(false); + unsubscribe(); + }); +}); diff --git a/apps/web/src/lib/softwareKeyboard.ts b/apps/web/src/lib/softwareKeyboard.ts new file mode 100644 index 000000000000..e20d5fe00b4a --- /dev/null +++ b/apps/web/src/lib/softwareKeyboard.ts @@ -0,0 +1,83 @@ +type VirtualKeyboard = EventTarget & { + readonly boundingRect: Pick; + readonly overlaysContent: boolean; +}; + +type KeyboardWindow = Pick & { + readonly navigator: Navigator & { readonly virtualKeyboard?: VirtualKeyboard }; +}; + +const KEYBOARD_THRESHOLD = 150; + +function viewportSnapshot(viewport: VisualViewport) { + return { + width: Math.round(viewport.width), + height: Math.round(viewport.height), + scale: viewport.scale, + }; +} + +export function createSoftwareKeyboardStore(win: KeyboardWindow | undefined, isDesktop: boolean) { + const listeners = new Set<() => void>(); + let viewportOpen = false; + const viewport = isDesktop ? undefined : win?.visualViewport; + const keyboard = isDesktop ? undefined : win?.navigator.virtualKeyboard; + let previous = viewport ? viewportSnapshot(viewport) : undefined; + + const getSnapshot = () => { + // Chromium can expose the API while withholding geometry outside overlay mode. + // Keep the browser's automatic viewport resizing instead of enabling overlays. + if (keyboard && (keyboard.overlaysContent || keyboard.boundingRect.height > 0)) { + return keyboard.boundingRect.height > 0; + } + return viewportOpen; + }; + + const notify = () => { + for (const listener of listeners) listener(); + }; + + const onViewportResize = () => { + if (!viewport || !win) return; + const current = viewportSnapshot(viewport); + // Adapted from Ionic's keyboard assist, MIT, Copyright (c) 2015-present Drifty Co. + // https://github.com/ionic-team/ionic-framework/blob/main/core/src/utils/keyboard/keyboard.ts + const scaledHeightDifference = previous + ? (previous.height - current.height) * current.scale + : 0; + if (Math.abs(current.height - win.innerHeight / current.scale) <= 1) { + viewportOpen = false; + } else if ( + !viewportOpen && + previous?.width === current.width && + scaledHeightDifference > KEYBOARD_THRESHOLD + ) { + viewportOpen = true; + } + previous = current; + notify(); + }; + + const subscribe = (listener: () => void) => { + if (isDesktop || !win) return () => {}; + if (listeners.size === 0) { + if (viewport) { + previous = viewportSnapshot(viewport); + // A composer can mount after the keyboard has already opened. + viewportOpen = win.innerHeight - previous.height * previous.scale > KEYBOARD_THRESHOLD; + viewport.addEventListener("resize", onViewportResize); + } + keyboard?.addEventListener("geometrychange", notify); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + viewport?.removeEventListener("resize", onViewportResize); + keyboard?.removeEventListener("geometrychange", notify); + } + }; + }; + + return { getSnapshot, subscribe }; +} diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 25a2b74f8499..abdde60fe169 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -1,5 +1,17 @@ { "customNotices": [ + { + "bundles": ["desktop", "web"], + "license": "MIT", + "name": "Ionic keyboard assist", + "sourceUrl": "https://github.com/ionic-team/ionic-framework/blob/main/core/src/utils/keyboard/keyboard.ts", + "generatedNotices": [ + { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2015-present Drifty Co."] + } + ] + }, { "bundles": ["assets", "desktop", "web"], "license": "CC0-1.0", From c274a251510e4bb035f8479a7f1e96443cd2503c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 09:26:34 -0700 Subject: [PATCH 006/898] fix(mobile): the Git sheet uses the standard list rows (#21785) Co-authored-by: Claude Opus 5.5 --- .../features/threads/git/GitOverviewSheet.tsx | 125 ++++++++---------- .../threads/git/gitSheetComponents.tsx | 55 -------- 2 files changed, 56 insertions(+), 124 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 6e131d12dd9f..a2636f69756e 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -26,6 +26,8 @@ import { AndroidSheetHeader, } from "../../../components/ui/AndroidScreenHeader"; import { AndroidAnchoredMenu } from "../../../components/ui/AndroidAnchoredMenu"; +import { ListRow } from "../../../components/ui/ListRow"; +import { ListSection } from "../../../components/ui/ListSection"; import { ScreenContent } from "../../../components/ui/ScreenContent"; import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout"; import { AppText as Text } from "../../../components/ui/AppText"; @@ -40,7 +42,7 @@ import { useSelectedThreadGitState } from "../../../state/use-selected-thread-gi import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; import { vcsEnvironment } from "../../../state/vcs"; import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; -import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; +import { menuItemIconName, statusSummary } from "./gitSheetComponents"; import { LinkedPullRequestRow, linkedPullRequestKey, @@ -245,46 +247,34 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { ) : undefined } > - - {sheetMenuItems.map(({ item, disabledReason }, index) => ( - - {index > 0 && Platform.OS !== "android" ? ( - - ) : null} - void onPressMenuItem(item)} - /> - + + {sheetMenuItems.map(({ item, disabledReason }) => ( + void onPressMenuItem(item)} + /> ))} {behindCount > 0 ? ( - <> - {Platform.OS !== "android" ? : null} - void gitActions.onPullSelectedThreadBranch()} - /> - + void gitActions.onPullSelectedThreadBranch()} + /> ) : null} - {Platform.OS !== "android" ? : null} - { const params = { environmentId, threadId }; @@ -295,11 +285,10 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { ); }} /> - {Platform.OS !== "android" ? : null} - {linkedPrActions.canLink ? ( - <> - {Platform.OS !== "android" ? : null} - - + ) : null} - + {linkedPrLines.length > 0 ? ( - - - Linked pull requests - - - {linkedPrLines.map((line, index) => ( - - ))} - - + + {linkedPrLines.map((line, index) => ( + + ))} + ) : null} - {currentWorktreePath ? : null} + {currentWorktreePath ? ( + + + {currentWorktreePath} + + + ) : null} ); diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index 53d5acef1717..9196c9889716 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -69,61 +69,6 @@ export function SheetActionButton(props: { ); } -export function MetaCard(props: { readonly label: string; readonly value: string }) { - return ( - - - {props.label} - - - {props.value} - - - ); -} - -export function SheetListRow(props: { - readonly icon: ComponentProps["name"]; - readonly iconTintClassName?: string | undefined; - readonly title: string; - readonly subtitle?: string | null; - readonly disabled?: boolean; - readonly onPress: () => void; -}) { - return ( - - - - - - - {props.title} - - {props.subtitle ? ( - {props.subtitle} - ) : null} - - {Platform.OS !== "android" ? ( - - ) : null} - - ); -} - export function menuItemIconName( icon: "commit" | "push" | "pr", ): ComponentProps["name"] { From 771fd93fc591de5fe1bfd66cf1b4faa39eb8547c Mon Sep 17 00:00:00 2001 From: Atharvsinh Jadav Date: Mon, 19 Oct 2026 22:37:30 +0530 Subject: [PATCH 007/898] fix(server): changed files keep their real paths for renames and non-ASCII names (#20175) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 93 +++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 116 +++++++----------- .../GitActionsControl.browser.test.tsx | 99 ++++++++++++--- apps/web/src/components/GitActionsControl.tsx | 9 +- packages/contracts/src/git.ts | 3 + 5 files changed, 231 insertions(+), 89 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7e65262e79cd..f797cc7f1451 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -333,7 +333,7 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () assert.deepStrictEqual(commands, [ { args: ["rev-parse", "--git-path", "index"], lcAll: "C" }, - { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, + { args: ["status", "--porcelain=2", "--branch", "-z"], lcAll: "C" }, { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, ]); @@ -2643,6 +2643,97 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports renamed and non-ASCII files by paths that can be committed", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "src/old.ts", "one\ntwo\nthree\n"); + yield* writeTextFile(cwd, "café notes.md", "draft\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add files"]); + yield* git(cwd, ["mv", "src/old.ts", "src/new.ts"]); + yield* writeTextFile(cwd, "src/new.ts", "one\ntwo\nthree\nfour\n"); + yield* writeTextFile(cwd, "café notes.md", "draft\nedit\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const status = yield* driver.statusDetails(cwd); + + assert.deepEqual(status.workingTree.files, [ + { path: "café notes.md", insertions: 1, deletions: 0 }, + { path: "src/new.ts", insertions: 1, deletions: 0 }, + { path: "src/old.ts", insertions: 0, deletions: 0 }, + ]); + yield* driver.commit(cwd, "Commit selection", "", { + stage: { filePaths: status.workingTree.files.map((file) => file.path) }, + }); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), ""); + }), + ); + + it.effect("commits selected files from a nested project directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + // A root basename ending in whitespace catches a root lookup that trims Git's output. + const cwd = path.join(yield* makeTmpDir(), "repo "); + yield* (yield* FileSystem.FileSystem).makeDirectory(cwd); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add nested file"]); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\ntwo\n"); + yield* writeTextFile(cwd, "nested/new.txt", "new\n"); + yield* writeTextFile(cwd, "README.md", "# test\nleft out\n"); + const nestedCwd = path.join(cwd, "nested"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const status = yield* driver.statusDetails(nestedCwd); + + assert.deepEqual(status.workingTree.files, [ + { path: "nested/new.txt", insertions: 0, deletions: 0 }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + { path: "README.md", insertions: 1, deletions: 0 }, + ]); + // The root is what the paths above are relative to, not the nested cwd, and it keeps + // the trailing space so the resolved editor target is the file that exists. + assert.equal(status.workingTree.rootPath, NodeFS.realpathSync.native(cwd)); + assert.equal( + NodeFS.existsSync(path.join(status.workingTree.rootPath!, "nested", "new.txt")), + true, + ); + yield* driver.commit(nestedCwd, "Commit nested selection", "", { + stage: { filePaths: ["nested/new.txt", "nested/tracked.txt"] }, + }); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), "M README.md"); + }), + ); + + it.effect("reports the linked worktree as the root of a nested directory in it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const worktreePath = yield* makeTmpDir("git-vcs-driver-linked-"); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add nested file"]); + yield* git(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); + yield* writeTextFile(worktreePath, "nested/tracked.txt", "one\ntwo\n"); + yield* writeTextFile(worktreePath, "nested/new.txt", "new\n"); + const nestedCwd = (yield* Path.Path).join(worktreePath, "nested"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const status = yield* driver.statusDetails(nestedCwd); + + assert.deepEqual(status.workingTree.files, [ + { path: "nested/new.txt", insertions: 0, deletions: 0 }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + ]); + assert.equal( + NodeFS.realpathSync.native(status.workingTree.rootPath!), + NodeFS.realpathSync.native(worktreePath), + ); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3225a7fe4559..f3120f5ebefd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -185,30 +185,6 @@ function parseBranchAb(value: string): { ahead: number; behind: number } { }; } -function parseNumstatEntries( - stdout: string, -): Array<{ path: string; insertions: number; deletions: number }> { - const entries: Array<{ path: string; insertions: number; deletions: number }> = []; - for (const line of stdout.split(/\r?\n/g)) { - if (line.trim().length === 0) continue; - const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); - const rawPath = - pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); - if (rawPath.length === 0) continue; - const added = Number.parseInt(addedRaw ?? "0", 10); - const deleted = Number.parseInt(deletedRaw ?? "0", 10); - const renameArrowIndex = rawPath.indexOf(" => "); - const normalizedPath = - renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; - entries.push({ - path: normalizedPath.length > 0 ? normalizedPath : rawPath, - insertions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - }); - } - return entries; -} - function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { const fields = stdout.split("\0"); const files: ReviewDiffFileStat[] = []; @@ -228,25 +204,20 @@ function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { return files; } -function parsePorcelainPath(line: string): string | null { - if (line.startsWith("? ") || line.startsWith("! ")) { - const simple = line.slice(2).trim(); - return simple.length > 0 ? simple : null; - } - - if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { - return null; - } - - const tabIndex = line.indexOf("\t"); - if (tabIndex >= 0) { - const fromTab = line.slice(tabIndex + 1); - const [filePath] = fromTab.split("\t"); - return filePath?.trim().length ? filePath.trim() : null; - } +// Space-separated fields precede the path in each porcelain v2 record type. The path itself +// may contain spaces, so it is everything after them. +const PORCELAIN_V2_FIELDS_BEFORE_PATH: Record = { + "1": 8, + "2": 9, + u: 10, + "?": 1, + "!": 1, +}; - const parts = line.trim().split(/\s+/g); - const filePath = parts.at(-1) ?? ""; +function parsePorcelainPath(record: string): string | null { + const fieldsBeforePath = PORCELAIN_V2_FIELDS_BEFORE_PATH[record[0] ?? ""]; + if (fieldsBeforePath === undefined) return null; + const filePath = record.split(" ").slice(fieldsBeforePath).join(" "); return filePath.length > 0 ? filePath : null; } @@ -1347,7 +1318,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: 2 }, ); - const worktreeRootOutput = worktreeRootResult.stdout.trim(); + // Git terminates --show-toplevel with exactly one "\n" on every platform (stdout is + // binary mode on Windows). Whitespace before it belongs to the path and must survive, + // because this root becomes the editor target for every repository-relative status path. + const worktreeRootOutput = worktreeRootResult.stdout.endsWith("\n") + ? worktreeRootResult.stdout.slice(0, -1) + : worktreeRootResult.stdout; const worktreeRoot = worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 ? path.normalize( @@ -1891,6 +1867,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "status", "--porcelain=2", "--branch", + "-z", ...(includeDivergence ? [] : ["--no-ahead-behind"]), ]; const indexResult = yield* executeGitWithStableDiagnostics( @@ -1968,48 +1945,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), ); const statusCacheKey = repositoryPaths?.gitCommonDir; - const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all( + const [numstatEntries, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ // Plumbing, because porcelain `git diff` rewrites the index even with // GIT_OPTIONAL_LOCKS=0. -M keeps porcelain's rename detection. executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", cwd, - ["diff-index", "-M", "--numstat", "HEAD", "--"], + ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], { allowNonZeroExit: true }, ).pipe( Effect.flatMap((result) => { - if (result.exitCode === 0) return Effect.succeed(result.stdout); + if (result.exitCode === 0) return Effect.succeed(parseReviewNumstat(result.stdout)); if (isUnbornHeadStderr(result.stderr)) { return Effect.map( Effect.all([ runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ "diff-files", "--numstat", + "-z", ]), runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ "diff", "--cached", "--numstat", + "-z", ]), ]), - ([unstagedStdout, stagedStdout]) => { - const staged = parseNumstatEntries(stagedStdout); - const unstaged = parseNumstatEntries(unstagedStdout); - const map = new Map(); - for (const entry of [...staged, ...unstaged]) { - const existing = map.get(entry.path) ?? { - insertions: 0, - deletions: 0, - }; - existing.insertions += entry.insertions; - existing.deletions += entry.deletions; - map.set(entry.path, existing); - } - return Array.from(map.entries()) - .map(([p, s]) => `${s.insertions}\t${s.deletions}\t${p}`) - .join("\n"); - }, + ([unstagedStdout, stagedStdout]) => [ + ...parseReviewNumstat(stagedStdout), + ...parseReviewNumstat(unstagedStdout), + ], ); } return Effect.fail( @@ -2017,7 +1983,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetails.numstat", cwd, - args: ["diff-index", "-M", "--numstat", "HEAD", "--"], + args: ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], }), detail: "git diff-index HEAD --numstat failed.", exitCode: result.exitCode, @@ -2046,7 +2012,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let hasWorkingTreeChanges = false; const changedFilesWithoutNumstat = new Set(); - for (const line of statusStdout.split(/\r?\n/g)) { + const statusRecords = statusStdout.split("\0"); + for (let index = 0; index < statusRecords.length; index++) { + const line = statusRecords[index]!; if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); refName = value.startsWith("(") ? null : value; @@ -2064,10 +2032,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* behindCount = parsed.behind; continue; } - if (line.trim().length > 0 && !line.startsWith("#")) { + if (line.length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; const pathValue = parsePorcelainPath(line); if (pathValue) changedFilesWithoutNumstat.add(pathValue); + // A rename record is followed by its original path as a separate record. Listing it + // keeps the deletion selectable when committing only some files. + if (line.startsWith("2 ")) { + const originalPath = statusRecords[++index]; + if (originalPath) changedFilesWithoutNumstat.add(originalPath); + } } } @@ -2092,10 +2066,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); } - const numstatEntries = parseNumstatEntries(numstatStdout); const fileStatMap = new Map(); for (const entry of numstatEntries) { - fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions }); + const stat = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; + stat.insertions += entry.additions; + stat.deletions += entry.deletions; + fileStatMap.set(entry.path, stat); } let insertions = 0; @@ -2128,6 +2104,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* upstreamRef, hasWorkingTreeChanges, workingTree: { + ...(repositoryPaths?.worktreeRoot ? { rootPath: repositoryPaths.worktreeRoot } : {}), files, insertions, deletions, @@ -2221,7 +2198,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.commit.addSelected", cwd, - [...config, "--literal-pathspecs", "add", "-A", "--", ...filePaths], + // Status paths are repository-relative, while cwd can be a subdirectory. + [...config, "add", "-A", "--", ...filePaths.map((file) => `:(top,literal)${file}`)], env ? { env } : {}, ); } else { diff --git a/apps/web/src/components/GitActionsControl.browser.test.tsx b/apps/web/src/components/GitActionsControl.browser.test.tsx index e4641b21700b..5904d57f54df 100644 --- a/apps/web/src/components/GitActionsControl.browser.test.tsx +++ b/apps/web/src/components/GitActionsControl.browser.test.tsx @@ -29,6 +29,9 @@ const state = vi.hoisted(() => ({ metadataRequests: [] as { environmentId: string; branch: string }[], toasts: [] as Toast[], afterGitAction: undefined as (() => void) | undefined, + workingTreeRoot: undefined as string | undefined, + files: [{ path: "file.ts", insertions: 1, deletions: 0 }], + editorOpens: [] as { environmentId: string; input: { cwd: string; editor: string } }[], })); vi.mock(import("~/state/session"), async (original) => { @@ -61,7 +64,8 @@ vi.mock(import("~/state/query"), async (original) => ({ hasPrimaryRemote: true, hasWorkingTreeChanges: true, workingTree: { - files: [{ path: "file.ts", insertions: 1, deletions: 0 }], + ...(state.workingTreeRoot === undefined ? {} : { rootPath: state.workingTreeRoot }), + files: state.files, insertions: 1, deletions: 0, }, @@ -73,21 +77,45 @@ vi.mock(import("~/state/query"), async (original) => ({ error: null, })) as never, })); -vi.mock(import("~/state/use-atom-command"), async (original) => ({ - ...(await original()), - // The only command whose result matters here is the thread's branch metadata update. - useAtomCommand: ((_command: unknown, options: unknown) => - options === "thread branch metadata update" - ? async ({ environmentId, input }: { environmentId: string; input: { branch: string } }) => { - state.metadataRequests.push({ environmentId, branch: input.branch }); - if (!state.scopes.has(AuthOrchestrationOperateScope)) - return AsyncResult.failure(Cause.fail(new Error("Task denied"))); - if (state.shell) state.shell = { branch: input.branch }; - if (state.detail) state.detail = { branch: input.branch }; - return AsyncResult.success(undefined); - } - : async () => AsyncResult.success(undefined)) as never, -})); +vi.mock(import("~/state/use-atom-command"), async (original) => { + const { shellEnvironment } = await import("~/state/shell"); + return { + ...(await original()), + // Only the thread's branch metadata update and editor opens are observed here. + useAtomCommand: ((command: unknown, options: unknown) => + options === "thread branch metadata update" + ? async ({ + environmentId, + input, + }: { + environmentId: string; + input: { branch: string }; + }) => { + state.metadataRequests.push({ environmentId, branch: input.branch }); + if (!state.scopes.has(AuthOrchestrationOperateScope)) + return AsyncResult.failure(Cause.fail(new Error("Task denied"))); + if (state.shell) state.shell = { branch: input.branch }; + if (state.detail) state.detail = { branch: input.branch }; + return AsyncResult.success(undefined); + } + : command === shellEnvironment.openInEditor + ? async (request: (typeof state.editorOpens)[number]) => { + state.editorOpens.push(request); + return AsyncResult.success(undefined); + } + : async () => AsyncResult.success(undefined)) as never, + }; +}); +// The preferred-editor flow needs one editor the environment reports as installed. +vi.mock(import("~/state/server"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const config = Atom.make(() => ({ availableEditors: ["cursor"] })); + return { + ...actual, + serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => config) as never }, + }; +}); vi.mock(import("~/lib/sourceControlActions"), async (original) => ({ ...(await original()), useSourceControlActionRunning: () => false, @@ -180,6 +208,9 @@ beforeEach(() => { state.metadataRequests = []; state.toasts = []; state.afterGitAction = undefined; + state.workingTreeRoot = undefined; + state.files = [{ path: "file.ts", insertions: 1, deletions: 0 }]; + state.editorOpens = []; useComposerDraftStore.setState({ getDraftSession: () => state.draft as never, getDraftThreadByRef: () => state.draft as never, @@ -312,6 +343,42 @@ describe("Git actions while thread details load", () => { }); }); +describe("Changed-file editor targets", () => { + beforeEach(() => { + state.scopes.add(AuthOrchestrationOperateScope); + state.files = [ + { path: "nested/new.txt", insertions: 0, deletions: 0 }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + ]; + }); + + it("resolves repository-relative paths against the status root, not the project cwd", async () => { + state.workingTreeRoot = "/repo"; + await render("thread", "/repo/nested"); + await openCommitDialog(); + + await userEvent.click(button("Open nested/new.txt in editor")); + await userEvent.click(button("Open nested/tracked.txt in editor")); + + await vi.waitFor(() => expect(state.editorOpens).toHaveLength(2)); + expect(state.editorOpens).toEqual([ + { environmentId: "environment", input: { cwd: "/repo/nested/new.txt", editor: "cursor" } }, + { + environmentId: "environment", + input: { cwd: "/repo/nested/tracked.txt", editor: "cursor" }, + }, + ]); + }); + + it("does not guess a file location when the server reports no root", async () => { + await render("thread", "/repo/nested"); + await openCommitDialog(); + + await expect.element(button("Open nested/new.txt in editor")).toBeDisabled(); + expect(state.editorOpens).toEqual([]); + }); +}); + describe("Default branch prompt", () => { beforeEach(() => { state.isDefaultRef = true; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f131bf1be98e..1b60f7179d3f 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1091,6 +1091,9 @@ export default function GitActionsControl({ const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree; const allFiles = gitStatusForActions?.workingTree.files ?? []; + // Changed-file paths are repository-relative, so they must resolve against + // the worktree root from the same status snapshot, not the project cwd. + const workingTreeRoot = gitStatusForActions?.workingTree.rootPath ?? null; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; @@ -1498,7 +1501,7 @@ export default function GitActionsControl({ }; const openChangedFileInEditor = (filePath: string) => { - if (!gitCwd) { + if (workingTreeRoot === null) { toastManager.add({ type: "error", title: "Editor opening is unavailable.", @@ -1506,7 +1509,7 @@ export default function GitActionsControl({ }); return; } - const target = resolvePathLinkTarget(filePath, gitCwd); + const target = resolvePathLinkTarget(filePath, workingTreeRoot); void (async () => { const result = await openInPreferredEditor(target); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { @@ -1944,7 +1947,7 @@ export default function GitActionsControl({ + ), +})); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => AsyncResult.success({}), + useAtomSet: () => () => {}, +})); +vi.mock("../../state/preferences", () => ({ + mobilePreferencesAtom: {}, + updateMobilePreferencesAtom: {}, +})); +vi.mock("./components/AutoSettleDaysField", () => ({ AutoSettleDaysField: () => null })); vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) })); vi.mock("../../state/session", () => ({ readEnvironmentScope: () => true, - useEnvironmentsWithScope: () => new Set(["local"]), + useEnvironmentsWithScope: () => new Set(mocks.targets.map((target) => target.environmentId)), })); vi.mock("../../state/server", () => ({ serverEnvironment: { updateSettings: {} } })); vi.mock("../../state/use-atom-command", () => ({ @@ -35,11 +67,29 @@ vi.mock("./components/SettingsEnvironmentFilterHeader", () => ({ vi.mock("./components/DatabaseStorageNotice", () => ({ DatabaseStorageNotice: () => null })); vi.mock("./components/SettingsSection", () => ({ SettingsSection: Children })); vi.mock("./components/SettingsChoiceRow", () => ({ SettingsChoiceRow: () => null })); -vi.mock("./components/SettingsSwitchRow", () => ({ SettingsSwitchRow: () => null })); -vi.mock("./components/SettingsRow", () => ({ SettingsRow: () => null })); -vi.mock("./components/SettingsProjectOverridesSection", () => ({ - SettingsProjectOverridesSection: () => null, +vi.mock("./components/SettingsSwitchRow", () => ({ + SettingsSwitchRow: ({ + label, + value, + disabled, + onValueChange, + }: { + label: string; + value: boolean | null; + disabled?: boolean; + onValueChange: (value: boolean) => void; + }) => ( + + ), })); +vi.mock("./components/SettingsRow", () => ({ SettingsRow: () => null })); vi.mock("./components/BranchNamingSettings", () => ({ BranchNamingSettings: (props: { onChange: (patch: ServerSettingsPatch) => void }) => { mocks.branchNaming.onChange = props.onChange; @@ -48,28 +98,85 @@ vi.mock("./components/BranchNamingSettings", () => ({ })); vi.mock("./settings-environment-filter", () => ({ useSettingsEnvironmentFilter: () => ({ - selectedTargets: [ + selectedTargets: mocks.targets, + projectGroups: [ { - environmentId: "local", - serverConfig: { - settings: DEFAULT_SERVER_SETTINGS, - environment: { capabilities: { projectSettingsOverrides: true } }, - }, + key: "project", + label: "Project", + members: mocks.targets.map((target) => ({ + project: { environmentId: target.environmentId, id: "project" }, + })), }, ], - projectGroups: [], - selectedProjectKey: null, + selectedProjectKey: mocks.projectSelected ? "project" : null, }), })); import { SettingsEnvironmentSourceControlRouteScreen } from "./SettingsServerControlsRouteScreen"; +import { SettingsThreadsRouteScreen } from "./SettingsThreadsRouteScreen"; + +function environment(environmentId: string, label: string) { + return { + environmentId, + label, + connection: { phase: "connected" }, + serverConfig: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + defaultAutoPull: false, + sidebarAutoSettleOnMerge: false, + }, + environment: { + capabilities: { projectSettingsOverrides: true, threadAutoSettlement: true }, + }, + }, + }; +} + +function button(label: string) { + const result = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === label || element.getAttribute("aria-label") === label, + ); + if (!result) throw new Error(`Missing button: ${label}`); + return result; +} + +function expectFailures() { + const messages = mocks.alert.mock.calls + .map(([title, message]) => `${title}\n${message}`) + .join("\n"); + expect.soft(messages).toMatch(/Alpha(?:(?!Beta)[\s\S])*Permission denied/); + expect.soft(messages).toMatch(/Beta(?:(?!Alpha)[\s\S])*Disk full/); + expect.soft(messages).not.toContain("Healthy"); +} + +function failTwoEnvironments() { + mocks.targets = [ + environment("healthy", "Healthy"), + environment("alpha", "Alpha"), + environment("beta", "Beta"), + ]; + mocks.updateSettings.mockImplementation(async ({ environmentId }: { environmentId: string }) => + environmentId === "healthy" + ? AsyncResult.success(undefined) + : AsyncResult.failure( + Cause.fail(new Error(environmentId === "alpha" ? "Permission denied" : "Disk full")), + ), + ); +} let root: Root; +let container: HTMLDivElement; beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - root = createRoot(document.createElement("div")); + container = document.createElement("div"); + root = createRoot(container); + mocks.targets = [environment("local", "Local")]; + mocks.projectSelected = false; + mocks.alert.mockReset(); mocks.updateSettings.mockReset(); + mocks.updateSettings.mockResolvedValue(AsyncResult.success(undefined)); }); afterEach(() => { @@ -78,11 +185,8 @@ afterEach(() => { describe("SettingsServerControlsRouteScreen", () => { it("sends a draft saved during a pending write once that write settles", async () => { - let settle = () => {}; - mocks.updateSettings.mockImplementationOnce( - () => new Promise((resolve) => (settle = resolve)), - ); - mocks.updateSettings.mockResolvedValue(undefined); + const pending = Promise.withResolvers>(); + mocks.updateSettings.mockReturnValueOnce(pending.promise); act(() => root.render()); act(() => mocks.branchNaming.onChange({ branchNamingMode: "custom" })); @@ -90,7 +194,7 @@ describe("SettingsServerControlsRouteScreen", () => { act(() => mocks.branchNaming.onChange({ branchNameInstructions: "Use issue IDs." })); expect(mocks.updateSettings).toHaveBeenCalledOnce(); - await act(async () => settle()); + await act(async () => pending.resolve(AsyncResult.success(undefined))); expect(mocks.updateSettings).toHaveBeenCalledTimes(2); expect(mocks.updateSettings).toHaveBeenLastCalledWith({ @@ -101,3 +205,111 @@ describe("SettingsServerControlsRouteScreen", () => { }); }); }); + +describe.each([ + { + name: "source control", + Screen: SettingsEnvironmentSourceControlRouteScreen, + label: "Automatically pull", + key: "defaultAutoPull", + }, + { + name: "thread behavior", + Screen: SettingsThreadsRouteScreen, + label: "Auto-settle merged threads", + key: "sidebarAutoSettleOnMerge", + }, +] as const)("$name shared settings", ({ Screen, label, key }) => { + it("identifies each failed environment and reason, then allows a subscription-driven retry", async () => { + failTwoEnvironments(); + const pending = Promise.withResolvers>(); + mocks.updateSettings.mockReturnValueOnce(pending.promise); + act(() => root.render()); + + act(() => button(label).click()); + expect(button(label).disabled).toBe(true); + await act(async () => pending.resolve(AsyncResult.success(undefined))); + + expectFailures(); + expect(mocks.updateSettings.mock.calls.map(([input]) => input.environmentId)).toEqual([ + "healthy", + "alpha", + "beta", + ]); + expect(button(label).disabled).toBe(false); + expect(button(label).getAttribute("aria-checked")).toBe("false"); + + mocks.alert.mockClear(); + mocks.updateSettings.mockResolvedValue(AsyncResult.success(undefined)); + await act(async () => button(label).click()); + expect(mocks.updateSettings).toHaveBeenCalledTimes(6); + expect(mocks.alert).not.toHaveBeenCalled(); + expect(button(label).getAttribute("aria-checked")).toBe("false"); + + mocks.targets = mocks.targets.map((target) => ({ + ...target, + serverConfig: { + ...target.serverConfig, + settings: { ...target.serverConfig.settings, [key]: true }, + }, + })); + act(() => root.render()); + expect(button(label).getAttribute("aria-checked")).toBe("true"); + }); + + it("reports failed project resets and keeps overrides visible until a successful retry is received", async () => { + failTwoEnvironments(); + mocks.projectSelected = true; + mocks.targets = mocks.targets.map((target) => ({ + ...target, + serverConfig: { + ...target.serverConfig, + settings: { + ...target.serverConfig.settings, + projectSettingsOverrides: { project: { [key]: true } }, + }, + }, + })); + act(() => root.render()); + + await act(async () => button("Use environment defaults").click()); + + expectFailures(); + expect(button(label).getAttribute("aria-checked")).toBe("true"); + expect(button("Use environment defaults").disabled).toBe(false); + + mocks.alert.mockClear(); + mocks.updateSettings.mockResolvedValue(AsyncResult.success(undefined)); + await act(async () => button("Use environment defaults").click()); + expect(mocks.updateSettings).toHaveBeenCalledTimes(6); + expect(mocks.alert).not.toHaveBeenCalled(); + expect(button(label).getAttribute("aria-checked")).toBe("true"); + + mocks.targets = mocks.targets.map((target) => ({ + ...target, + serverConfig: { + ...target.serverConfig, + settings: { ...target.serverConfig.settings, projectSettingsOverrides: {} }, + }, + })); + act(() => root.render()); + expect(button(label).getAttribute("aria-checked")).toBe("false"); + expect(container.textContent).not.toContain("Use defaults"); + }); + + it("does not alert for an interrupted write and releases the controls for retry", async () => { + const pending = Promise.withResolvers>(); + mocks.updateSettings.mockReturnValueOnce(pending.promise); + act(() => root.render()); + + act(() => button(label).click()); + expect(button(label).disabled).toBe(true); + await act(async () => pending.resolve(AsyncResult.failure(Cause.interrupt(1)))); + + expect(mocks.alert).not.toHaveBeenCalled(); + expect(button(label).disabled).toBe(false); + await act(async () => button(label).click()); + expect(mocks.updateSettings).toHaveBeenCalledTimes(2); + expect(mocks.alert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 8ca4bb410e3d..c4e82fbe93e7 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -18,8 +18,6 @@ import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { RUNTIME_MODE_CHOICES } from "../threads/thread-settings-options"; -import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsScreen } from "./components/SettingsScreen"; import { AndroidSettingsEnvironmentFilter, @@ -32,6 +30,7 @@ import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; +import { useSettingsWriter } from "./useSettingsWriter"; import { planMobileScopedSettingsClear, planMobileScopedSettingsPatch, @@ -172,10 +171,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const isMixed = (key: keyof ServerSettings) => reference === null || displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); - const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "environment settings update", - reportFailure: true, - }); + const writeSettings = useSettingsWriter(); const write = (patch: ServerSettingsPatch) => { if (writeInFlight.current) { const queued = queuedPatch.current; @@ -197,11 +193,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { writeInFlight.current = true; setPendingTargets(targets); setPendingWrites((count) => count + 1); - void Promise.allSettled( - writes.map((entry) => - updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), - ), - ).finally(() => { + void writeSettings(targets, writes).finally(() => { writeInFlight.current = false; setPendingTargets(null); setPendingWrites((count) => count - 1); @@ -225,11 +217,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { writeInFlight.current = true; setPendingTargets(targets); setPendingWrites((count) => count + 1); - void Promise.allSettled( - writes.map((entry) => - updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), - ), - ).finally(() => { + void writeSettings(targets, writes).finally(() => { writeInFlight.current = false; // Resending a draft over these pre-clear targets would undo the clear. queuedPatch.current = null; diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index dea74b2cd5b0..d023e5c57e3b 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -12,8 +12,6 @@ import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { AppText as Text } from "../../components/ui/AppText"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; @@ -24,6 +22,7 @@ import { } from "./components/SettingsEnvironmentFilterHeader"; import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; +import { useSettingsWriter } from "./useSettingsWriter"; import { planMobileScopedSettingsClear, planMobileScopedSettingsPatch, @@ -67,10 +66,7 @@ function AutoSettleSettingsRows() { const [pendingTargets, setPendingTargets] = useState< readonly ScopedMobileSettingsTarget[] | null >(null); - const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "server settings update", - reportFailure: true, - }); + const writeSettings = useSettingsWriter(); const syncEnvironments = selectedTargets.filter(supportsSharedSettingsSync); const syncTargets = resolveMobileSettingsTargets( @@ -107,11 +103,7 @@ function AutoSettleSettingsRows() { writeInFlight.current = true; setPendingTargets(syncTargets); setPendingWrites((count) => count + 1); - void Promise.allSettled( - writes.map((entry) => - updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), - ), - ).finally(() => { + void writeSettings(syncTargets, writes).finally(() => { writeInFlight.current = false; setPendingTargets(null); setPendingWrites((count) => count - 1); @@ -161,11 +153,7 @@ function AutoSettleSettingsRows() { writeInFlight.current = true; setPendingTargets(syncTargets); setPendingWrites((count) => count + 1); - void Promise.allSettled( - writes.map((entry) => - updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }), - ), - ).finally(() => { + void writeSettings(syncTargets, writes).finally(() => { writeInFlight.current = false; setPendingTargets(null); setPendingWrites((count) => count - 1); diff --git a/apps/mobile/src/features/settings/useSettingsWriter.ts b/apps/mobile/src/features/settings/useSettingsWriter.ts new file mode 100644 index 000000000000..aebd453c2ce2 --- /dev/null +++ b/apps/mobile/src/features/settings/useSettingsWriter.ts @@ -0,0 +1,44 @@ +import type { EnvironmentId, ServerSettingsPatch } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import * as Dialog from "../../components/ui/Dialog"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import type { ScopedMobileSettingsTarget } from "./settings-scoped-server"; + +export function useSettingsWriter() { + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "environment settings update", + reportFailure: true, + }); + + return async ( + targets: readonly ScopedMobileSettingsTarget[], + writes: readonly { + readonly environmentId: EnvironmentId; + readonly patch: ServerSettingsPatch; + }[], + ) => { + const outcomes = await Promise.all( + writes.map(async (entry) => { + const result = await updateSettings({ + environmentId: entry.environmentId, + input: { patch: entry.patch }, + }); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return null; + const label = + targets.find((target) => target.environment.environmentId === entry.environmentId) + ?.environment.label ?? entry.environmentId; + const error = squashAtomCommandFailure(result); + return `${label}: ${error instanceof Error ? error.message : String(error)}`; + }), + ); + const failures = outcomes.filter((outcome) => outcome !== null); + if (failures.length > 0) { + Dialog.alert("Could not save settings", failures.join("\n\n")); + } + }; +} From fc21184afbb85604660f459ca43bbbe55ad020e4 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 19 Oct 2026 18:07:32 +0000 Subject: [PATCH 030/898] fix(claude): show automatic compaction while it runs (#15631) Co-authored-by: Julius Marminge --- apps/mobile/src/lib/threadActivity.test.ts | 22 +- .../src/state/use-thread-composer-state.ts | 13 +- .../Adapters/ClaudeAdapterV2.test.ts | 344 +++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 123 +++++-- apps/web/src/components/ChatView.tsx | 15 +- .../src/work-log/presentation.ts | 3 + 6 files changed, 477 insertions(+), 43 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index cf327f13b304..f9fccc8a394d 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1740,9 +1740,14 @@ describe("retained v2 feed presentation", () => { expect(afterPresentation[1]).toBe(beforePresentation[1]); }); - it.each(["running", "completed", "interrupted"] as const)( - "uses the compaction row as the live activity only while %s", - (status) => { + it.each([ + { status: "running", summary: "Compacting context" }, + { status: "completed", summary: "Context compacted 899K → 19K tokens" }, + { status: "failed", summary: "Context compaction failed" }, + { status: "interrupted", summary: "Context compaction stopped" }, + ] as const)( + "uses the compaction row as the live activity only while $status", + ({ status, summary }) => { const compact = projected( { ...base("compacted", "2026-06-20T00:00:02.000Z", 1), @@ -1769,16 +1774,7 @@ describe("retained v2 feed presentation", () => { ); expect(rows.some((row) => row.type === "thinking")).toBe(status !== "running"); expect(rows.find((row) => row.type === "activity-group")).toMatchObject({ - activities: [ - { - summary: - status === "running" - ? "Compacting context" - : status === "completed" - ? "Context compacted 899K → 19K tokens" - : "Context compacted", - }, - ], + activities: [{ summary }], }); }, ); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index f99b071bf36c..bb2be14282ee 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -334,6 +334,14 @@ export function useThreadComposerState() { if (queuedCompact) return true; const activeRunId = selectedThreadRuntime?.activeRunId; if (!activeRunId || !threadRuntimeIsActive(selectedThreadRuntime)) return false; + if ( + selectedThreadVisibleTurnItems.some( + ({ item }) => + item.runId === activeRunId && item.type === "compaction" && item.status === "running", + ) + ) { + return true; + } const compactMessage = selectedThreadVisibleTurnItems.findLast( ({ item }) => item.runId === activeRunId && @@ -346,7 +354,10 @@ export function useThreadComposerState() { ({ item }) => item.runId === activeRunId && item.type === "compaction" && - (item.status === "completed" || item.status === "failed"), + (item.status === "completed" || + item.status === "failed" || + item.status === "interrupted" || + item.status === "cancelled"), ); })(); // A compacting turn cannot take steering input, so follow-ups queue meanwhile. diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 2d2c787f936b..d9bf5007f7e9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3705,6 +3705,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ] : []), + claudeSdkFrame({ + type: "system", + subtype: "status", + status: "compacting", + uuid: "00000000-0000-4000-8000-000000000610", + session_id: WAKE_NATIVE_SESSION, + }), claudeSdkFrame({ type: "system", subtype: "status", @@ -3716,10 +3723,44 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000612", session_id: WAKE_NATIVE_SESSION, }), - compact ? claudeSdkFrame({ ...result, local_command: "compact" }) : result, + claudeSdkFrame({ + type: "system", + subtype: "informational", + content: "Compaction finished; turn continues.", + level: "warning", + uuid: "00000000-0000-4000-8000-000000000614", + session_id: WAKE_NATIVE_SESSION, + }), ]); + yield* Queue.take(harness.systemNoticeReceipts); + const updates = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ); + assert.lengthOf(updates, 2); + const [running, settled] = updates; + assert.equal(running?.status, "running"); + assert.equal(settled?.status, compaction === "success" ? "completed" : "failed"); + assert.equal(settled?.id, running?.id); + assert.equal(settled?.ordinal, running?.ordinal); + assert.deepEqual(settled?.startedAt, running?.startedAt); + assert.isNotNull(settled?.completedAt); + assert.isEmpty(harness.terminalEvents()); + yield* Queue.offer( + harness.sdkMessages, + compact ? claudeSdkFrame({ ...result, local_command: "compact" }) : result, + ); const terminal = yield* Queue.take(harness.terminalReceipts); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ), + updates, + ); assert.equal(terminal.status, status); if (terminal.status !== "failed") return; assert.equal(terminal.failure.class, "usage_limit"); @@ -4443,6 +4484,307 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("shows automatic compaction before its boundary and completes the same item", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const observeCompactions = Effect.fnUntraced(function* (uuid: string) { + // A later notice receipt confirms the event subscriber consumed preceding updates. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "informational", + content: "Context checkpoint", + level: "warning", + session_id: WAKE_NATIVE_SESSION, + uuid, + }), + ); + yield* Queue.take(harness.systemNoticeReceipts); + return harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ); + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("claude-auto-compact-attempt"), + text: "Continue working.", + attachments: [], + }), + ); + for (const index of [1, 2]) { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "status", + status: "compacting", + uuid: `00000000-0000-4000-8000-00000000021${index}`, + session_id: WAKE_NATIVE_SESSION, + }), + ); + } + const beforeBoundary = yield* observeCompactions("00000000-0000-4000-8000-000000000215"); + assert.lengthOf(beforeBoundary, 1); + const running = beforeBoundary[0]!; + assert.equal(running.status, "running"); + assert.isNull(running.completedAt); + assert.isNotNull(running.startedAt); + + yield* TestClock.adjust(1_000); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + compact_result: "success", + uuid: "00000000-0000-4000-8000-000000000222", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const reported = yield* observeCompactions("00000000-0000-4000-8000-000000000223"); + assert.lengthOf(reported, 2); + assert.equal(reported[1]?.id, running.id); + assert.equal(reported[1]?.status, "completed"); + assert.isUndefined(reported[1]?.afterTokenCount); + assert.isEmpty(harness.terminalEvents()); + yield* TestClock.adjust(1_000); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "auto", pre_tokens: 1500, post_tokens: 400 }, + uuid: "00000000-0000-4000-8000-000000000213", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const afterBoundary = yield* observeCompactions("00000000-0000-4000-8000-000000000216"); + assert.lengthOf(afterBoundary, 3); + const completed = afterBoundary[2]!; + assert.equal(completed.status, "completed"); + assert.equal(completed.id, running.id); + assert.equal(completed.ordinal, running.ordinal); + assert.deepEqual(completed.startedAt, running.startedAt); + assert.equal(completed.beforeTokenCount, 1500); + assert.equal(completed.afterTokenCount, 400); + assert.deepEqual(completed.completedAt, reported[1]?.completedAt); + + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "manual", pre_tokens: 2000, post_tokens: 500 }, + uuid: "00000000-0000-4000-8000-000000000217", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "status", + status: "compacting", + uuid: "00000000-0000-4000-8000-000000000218", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const nextCompactions = yield* observeCompactions("00000000-0000-4000-8000-000000000219"); + assert.lengthOf(nextCompactions, 5); + const boundaryOnly = nextCompactions[3]!; + const nextRunning = nextCompactions[4]!; + assert.equal(boundaryOnly.status, "completed"); + assert.notEqual(boundaryOnly.id, completed.id); + assert.isAbove(boundaryOnly.ordinal, completed.ordinal); + assert.equal(boundaryOnly.beforeTokenCount, 2000); + assert.equal(boundaryOnly.afterTokenCount, 500); + assert.equal(nextRunning.status, "running"); + assert.notEqual(nextRunning.id, boundaryOnly.id); + assert.isAbove(nextRunning.ordinal, boundaryOnly.ordinal); + + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + permissionMode: "default", + uuid: "00000000-0000-4000-8000-000000000233", + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.deepEqual( + yield* observeCompactions("00000000-0000-4000-8000-000000000234"), + nextCompactions, + ); + + let retryRunning = nextRunning; + for (const [index, outcome] of [ + { compact_result: "failed", compact_error: "Temporary compaction failure." }, + {}, + ].entries()) { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + ...outcome, + uuid: `00000000-0000-4000-8000-00000000024${index}`, + session_id: WAKE_NATIVE_SESSION, + }), + ); + const stopped = yield* observeCompactions(`00000000-0000-4000-8000-00000000025${index}`); + assert.lengthOf(stopped, 6 + index * 2); + const settled = stopped.at(-1)!; + assert.equal(settled.id, retryRunning.id); + assert.equal(settled.ordinal, retryRunning.ordinal); + assert.deepEqual(settled.startedAt, retryRunning.startedAt); + assert.equal( + settled.status, + outcome.compact_result === "failed" ? "failed" : "interrupted", + ); + assert.equal( + settled.title, + outcome.compact_result === "failed" + ? "Context compaction failed" + : "Context compaction stopped", + ); + assert.isNotNull(settled.completedAt); + assert.isEmpty(harness.terminalEvents()); + + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "status", + status: "compacting", + uuid: `00000000-0000-4000-8000-00000000026${index}`, + session_id: WAKE_NATIVE_SESSION, + }), + ); + const retried = yield* observeCompactions(`00000000-0000-4000-8000-00000000027${index}`); + assert.lengthOf(retried, stopped.length + 1); + retryRunning = retried.at(-1)!; + assert.equal(retryRunning.status, "running"); + assert.notEqual(retryRunning.id, settled.id); + assert.isAbove(retryRunning.ordinal, settled.ordinal); + } + + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "auto", pre_tokens: 3000, post_tokens: 600 }, + uuid: "00000000-0000-4000-8000-000000000220", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const allCompactions = yield* observeCompactions("00000000-0000-4000-8000-000000000221"); + assert.lengthOf(allCompactions, 10); + assert.equal(allCompactions[9]?.id, retryRunning.id); + assert.equal(allCompactions[9]?.ordinal, retryRunning.ordinal); + assert.equal(allCompactions[9]?.status, "completed"); + assert.equal(allCompactions[9]?.beforeTokenCount, 3000); + assert.equal(allCompactions[9]?.afterTokenCount, 600); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000214", + result: "Work complete.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ), + allCompactions, + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each([ + { outcome: "result", turnStatus: "completed", itemStatus: "failed" }, + { outcome: "interrupt", turnStatus: "interrupted", itemStatus: "interrupted" }, + { outcome: "query_exit", turnStatus: "failed", itemStatus: "failed" }, + ] as const)( + "settles unfinished compaction before the $outcome terminal receipt", + ({ outcome, turnStatus, itemStatus }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => Queue.shutdown(sdkMessages), + }); + const attemptId = RunAttemptId.make(`claude-compact-${outcome}`); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId, + text: "Continue.", + attachments: [], + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "status", + status: "compacting", + uuid: "00000000-0000-4000-8000-000000000221", + session_id: WAKE_NATIVE_SESSION, + }), + ); + if (outcome === "query_exit") { + yield* Queue.shutdown(harness.sdkMessages); + } else if (outcome === "interrupt") { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }), + }); + } else { + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000222", + result: "Stopped before the boundary.", + }), + ); + } + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, turnStatus); + const updates = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ); + assert.lengthOf(updates, 2); + const [running, settled] = updates; + assert.equal(running?.status, "running"); + assert.equal(settled?.status, itemStatus); + assert.equal(settled?.id, running?.id); + assert.equal(settled?.ordinal, running?.ordinal); + assert.deepEqual(settled?.startedAt, running?.startedAt); + assert.isNotNull(settled?.completedAt); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("titles Claude reads, searches, and skills on tool completion", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f00bd90b7af6..992215dd35f1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3044,6 +3044,8 @@ interface ActiveClaudeTurnContext { // compaction makes the usage stale (null) without forgetting the request. streamUsage: { readonly messageId: string; usage: ClaudeMessageUsage | null } | null; readonly toolCalls: Map; + // A successful status can precede the boundary that supplies its token counts. + compaction: Extract | null; readonly ignoredTaskIds: Set; readonly announcedUsageLimits: Set; authenticationFailureMessage: string | undefined; @@ -6050,6 +6052,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly result?: SDKResultMessage; }) { yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); + if (input.context.compaction?.status === "running") { + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + ...input.context.compaction, + status: input.status === "completed" ? "failed" : input.status, + title: + input.status === "completed" || input.status === "failed" + ? "Context compaction failed" + : "Context compaction stopped", + completedAt: input.completedAt, + updatedAt: input.completedAt, + }, + }); + } + input.context.compaction = null; if (input.status !== "completed") { const liveQuery = yield* Ref.get(queryContext); for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { @@ -7119,16 +7138,86 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if ( message.type === "system" && message.subtype === "status" && - message.compact_result !== undefined + (message.compact_result !== undefined || + (message.status === null && message.permissionMode === undefined)) ) { - context.compactionFailure = - message.compact_result === "failed" ? { message: message.compact_error } : null; + if (message.compact_result !== undefined) { + context.compactionFailure = + message.compact_result === "failed" ? { message: message.compact_error } : null; + } + if (context.compaction?.status === "running") { + const now = yield* DateTime.now; + context.compaction = { + ...context.compaction, + status: + message.compact_result === "success" + ? "completed" + : message.compact_result === "failed" + ? "failed" + : "interrupted", + title: + message.compact_result === "success" + ? "Context compacted" + : message.compact_result === "failed" + ? "Context compaction failed" + : "Context compaction stopped", + completedAt: now, + updatedAt: now, + }; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: context.compaction, + }); + if (message.compact_result !== "success") context.compaction = null; + } } - if (message.type === "system" && message.subtype === "compact_boundary") { - if (context.streamUsage !== null) context.streamUsage.usage = null; + if ( + message.type === "system" && + (message.subtype === "compact_boundary" || + (message.subtype === "status" && message.status === "compacting")) + ) { + if (message.subtype === "status" && context.compaction?.status === "running") return; const now = yield* DateTime.now; const nativeItemId = message.uuid; + const compaction = + (message.subtype === "compact_boundary" ? context.compaction : null) ?? + ({ + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId, + }), + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: context.input.rootNodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: nativeItemId, + strength: "strong", + }, + parentItemId: null, + ordinal: yield* resolveItemOrdinal(context, nativeItemId), + type: "compaction", + driver: CLAUDE_PROVIDER, + status: "running", + title: "Compacting context", + startedAt: now, + completedAt: null, + updatedAt: now, + } satisfies OrchestrationV2TurnItem); + if (message.subtype === "status") { + context.compaction = compaction; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: compaction, + }); + return; + } + if (context.streamUsage !== null) context.streamUsage.usage = null; const postTokens = message.compact_metadata.post_tokens; const afterTokenCount = typeof postTokens === "number" && Number.isFinite(postTokens) && postTokens > 0 @@ -7165,28 +7254,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( type: "turn_item.updated", driver: CLAUDE_PROVIDER, turnItem: { - id: idAllocator.derive.turnItemFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId, - }), - threadId: context.input.threadId, - runId: context.input.runId, - nodeId: context.input.rootNodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef: { - driver: CLAUDE_PROVIDER, - nativeId: nativeItemId, - strength: "strong", - }, - parentItemId: null, - ordinal: yield* resolveItemOrdinal(context, nativeItemId), - type: "compaction", - driver: CLAUDE_PROVIDER, + ...compaction, status: "completed", title: "Context compacted", - startedAt: now, - completedAt: now, + completedAt: compaction.completedAt ?? now, updatedAt: now, ...(message.compact_metadata.pre_tokens === undefined ? {} @@ -7194,6 +7265,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(afterTokenCount === undefined ? {} : { afterTokenCount }), }, }); + context.compaction = null; return; } @@ -8727,6 +8799,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, streamUsage: null, toolCalls: new Map(), + compaction: null, ignoredTaskIds: new Set(), announcedUsageLimits: new Set(), authenticationFailureMessage: undefined, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5096063c74d2..879d66d7712c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2518,12 +2518,21 @@ export default function ChatView(props: ChatViewProps) { ({ item }) => item.type === "compaction" && item.runId === activeActivityRun?.runId && - item.status === "completed", + (item.status === "completed" || + item.status === "failed" || + item.status === "interrupted" || + item.status === "cancelled"), + ); + const compactionRunning = serverVisibleTurnItems.some( + ({ item }) => + item.type === "compaction" && + item.runId !== null && + item.runId === activeRuntime?.activeRunId && + item.status === "running", ); const isCompacting = (isSendBusy || phase === "connecting" || phase === "running") && - compactRequestIsActive && - !compactionSettled; + (compactionRunning || (compactRequestIsActive && !compactionSettled)); const isWorking = phase === "running" || isSendBusy || diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index b3cfcc9779b8..de0fd9940841 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -67,6 +67,9 @@ export function contextCompactionTokenCounts( } export function contextCompactionLabel(item: CompactionLabelItem): string { + if (item.status === "failed") return "Context compaction failed"; + if (item.status === "cancelled" || item.status === "interrupted") + return "Context compaction stopped"; if (item.status === "running") return "Compacting context"; const tokens = contextCompactionTokenCounts(item); return tokens From a7b5d52c789c64e8c814b3e532a4b194f8d2931d Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 05:07:38 +1100 Subject: [PATCH 031/898] fix(web): keep the parent thread highlighted while viewing its subagent (#15163) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: SAPHID Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge --- apps/web/src/components/Sidebar.logic.test.ts | 41 +++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 20 +++++++ apps/web/src/components/Sidebar.tsx | 52 +++++++++++++------ 3 files changed, 97 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4883c690bb3d..de40918e4098 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -23,6 +23,7 @@ import { hasUnseenCompletion, isContextMenuPointerDown, isSidebarSubagentThread, + resolveSidebarRowThreadId, isSidebarThreadWorking, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -461,6 +462,46 @@ describe("sidebar thread lineage helpers", () => { expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false); }); + it("maps an open subagent to its nearest visible ancestor's sidebar row", () => { + const environmentId = EnvironmentId.make("environment-row"); + const otherEnvironmentId = EnvironmentId.make("environment-other"); + const subagentOf = (id: string, parentId: ThreadId, environment = environmentId) => + makeThreadFixture({ + id: ThreadId.make(id), + environmentId: environment, + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent", + }, + }); + const root = makeThreadFixture({ id: ThreadId.make("thread-root"), environmentId }); + const fork = makeThreadFixture({ + id: ThreadId.make("thread-fork"), + environmentId, + lineage: { + rootThreadId: root.id, + parentThreadId: root.id, + relationshipToParent: "fork", + }, + }); + const child = subagentOf("thread-child", root.id); + const grandchild = subagentOf("thread-grandchild", child.id); + const forkChild = subagentOf("thread-fork-child", fork.id); + const orphan = subagentOf("thread-orphan", ThreadId.make("thread-missing")); + const elsewhere = subagentOf("thread-elsewhere", root.id, otherEnvironmentId); + const threads = [root, fork, child, grandchild, forkChild, orphan, elsewhere]; + + expect(resolveSidebarRowThreadId(child.id, environmentId, threads)).toBe(root.id); + expect(resolveSidebarRowThreadId(grandchild.id, environmentId, threads)).toBe(root.id); + expect(resolveSidebarRowThreadId(forkChild.id, environmentId, threads)).toBe(fork.id); + expect(resolveSidebarRowThreadId(elsewhere.id, otherEnvironmentId, threads)).toBe(elsewhere.id); + expect(resolveSidebarRowThreadId(orphan.id, environmentId, threads)).toBe(orphan.id); + const loopA = subagentOf("thread-loop-a", ThreadId.make("thread-loop-b")); + const loopB = subagentOf("thread-loop-b", loopA.id); + expect(resolveSidebarRowThreadId(loopA.id, environmentId, [loopA, loopB])).toBe(loopA.id); + }); + it("resolves the parent thread for fork sidebar affordances", () => { const parentId = ThreadId.make("thread-parent"); const fallbackParentId = ThreadId.make("thread-fallback-parent"); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f10c58fa0de1..d0bd3b95838a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -517,6 +517,26 @@ export function isSidebarSubagentThread(thread: Pick[], +): ThreadId { + const findThread = (id: ThreadId) => + threads.find((thread) => thread.id === id && thread.environmentId === environmentId); + const visited = new Set(); + let thread = findThread(threadId); + while (thread !== undefined && isSidebarSubagentThread(thread)) { + if (thread.lineage.parentThreadId === null || visited.has(thread.id)) return threadId; + visited.add(thread.id); + thread = findThread(thread.lineage.parentThreadId); + } + return thread?.id ?? threadId; +} + export function filterSidebarV2VisibleThreads< T extends Pick & { environmentId: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ce6a9d8e88e8..454f8f45f686 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -231,6 +231,7 @@ import { type SidebarListItem, type SidebarListMarker, type SidebarSection, + resolveSidebarRowThreadId, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { @@ -449,11 +450,24 @@ function useSidebarThreads(input: { readonly scopedProjectKeys: ReadonlySet | null; readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; - readonly routeThreadKey: string | null; + readonly routeThreadRef: ScopedThreadRef | null; }) { - const { projectScopeKey, scopedProjectKeys, serverConfigs, optimisticDrop, routeThreadKey } = + const { projectScopeKey, scopedProjectKeys, serverConfigs, optimisticDrop, routeThreadRef } = input; const threads = useThreadShells(); + const routeRowThreadKey = + routeThreadRef === null + ? null + : scopedThreadKey( + scopeThreadRef( + routeThreadRef.environmentId, + resolveSidebarRowThreadId( + routeThreadRef.threadId, + routeThreadRef.environmentId, + threads, + ), + ), + ); const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); const nowMinute = useNowMinute(); // Snooze wake times are second-precise, so classifying with the quantized @@ -506,23 +520,23 @@ function useSidebarThreads(input: { const visibleSettledThreads = (() => { if (settledThreads.length <= settledVisibleCount) return settledThreads; const visible = settledThreads.slice(0, settledVisibleCount); - if (routeThreadKey !== null) { + if (routeRowThreadKey !== null) { const routeThread = settledThreads .slice(settledVisibleCount) - .find((thread) => threadShellKey(thread) === routeThreadKey); + .find((thread) => threadShellKey(thread) === routeRowThreadKey); if (routeThread !== undefined) visible.push(routeThread); } return visible; })(); const renderedSettledThreads = settledShelfExpanded ? visibleSettledThreads - : routeThreadOnly(visibleSettledThreads, routeThreadKey); + : routeThreadOnly(visibleSettledThreads, routeRowThreadKey); const visibleSnoozedThreads = snoozedShelfExpanded ? snoozedThreads - : routeThreadOnly(snoozedThreads, routeThreadKey); + : routeThreadOnly(snoozedThreads, routeRowThreadKey); const visibleWorkingThreads = workingShelfExpanded ? workingThreads - : routeThreadOnly(workingThreads, routeThreadKey); + : routeThreadOnly(workingThreads, routeRowThreadKey); const orderedThreads = [ ...pinnedThreads, ...activeThreads, @@ -541,6 +555,7 @@ function useSidebarThreads(input: { for (const thread of list) sectionByThreadKey.set(threadShellKey(thread), section); } return { + routeRowThreadKey, ...partition, threads, workingShelfEnabled, @@ -1542,6 +1557,9 @@ type SidebarThreadRowProps = { snoozeWakeLabelText: string | null; wokeAt: string | null; isActive: boolean; + // Unlike isActive, never true for the ancestor standing in for an open + // subagent: drafts and PR clicks care about which composer is on screen. + isRouteThread: boolean; openPullRequestsInRightPanel: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -1617,7 +1635,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; - const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive; + const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isRouteThread; const handleDiscardDraftClick = (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -1895,7 +1913,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP if (!url) return; const showInRightPanel = targetUrl !== undefined || openPullRequestsInRightPanel; const openedInRightPanel = openPrLink(event, url, showInRightPanel ? threadRef : undefined); - if (openedInRightPanel && showInRightPanel && !props.isActive) { + if (openedInRightPanel && showInRightPanel && !props.isRouteThread) { onThreadActivate(threadRef); } }; @@ -2013,7 +2031,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP : null; const handlePrListClick = () => { useRightPanelStore.getState().open(threadRef, "pull-requests"); - if (!props.isActive) onThreadActivate(threadRef); + if (!props.isRouteThread) onThreadActivate(threadRef); }; const prBadge = prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( @@ -2999,12 +3017,13 @@ export default function Sidebar() { activeKeys, pinnedKeysById, activeKeysById, + routeRowThreadKey, } = useSidebarThreads({ projectScopeKey, scopedProjectKeys, serverConfigs, optimisticDrop, - routeThreadKey, + routeThreadRef, }); const isSearchingThreads = threadSearchQuery.trim().length > 0; @@ -3637,7 +3656,7 @@ export default function Sidebar() { ...(draggedActiveOrder === undefined ? {} : { activeOrder: draggedActiveOrder }), settledExpanded: settledShelfExpanded, settledVisibleCount, - routeThreadKey, + routeThreadKey: routeRowThreadKey, snoozedThreadCount: snoozedThreads.length, }); const draggedThreadKey = dragState?.activeKey; @@ -4472,7 +4491,7 @@ export default function Sidebar() { navigateToThreadKey( resolveAdjacentThreadId({ threadIds: orderedThreadKeys, - currentThreadId: routeThreadKey, + currentThreadId: routeRowThreadKey, direction: traversalDirection, }), ); @@ -4488,8 +4507,8 @@ export default function Sidebar() { keybindings, navigateToThread, orderedThreadKeys, + routeRowThreadKey, routeTerminalOpen, - routeThreadKey, threadByKey, ]); @@ -4727,7 +4746,7 @@ export default function Sidebar() { EMPTY_PROVIDER_ENTRIES } isHighlighted={activeSearchResultIndex === index} - isRouteActive={routeThreadKey === threadKey} + isRouteActive={routeRowThreadKey === threadKey} resultId={`sidebar-thread-search-result-${index}`} searchMatch={ threadSearchMatchByKey.get( @@ -4848,7 +4867,8 @@ export default function Sidebar() { : null } wokeAt={threadWokeAt(thread, { now: snoozeNow })} - isActive={routeThreadKey === threadKey} + isActive={routeRowThreadKey === threadKey} + isRouteThread={routeThreadKey === threadKey} openPullRequestsInRightPanel={routeThreadRef !== null} jumpLabel={ showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null From 83cf6dbebe88f0f5dd1cf7eac9b2d5c338234932 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 05:07:48 +1100 Subject: [PATCH 032/898] fix(web): show pull request checkout error detail (#15007) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: SAPHID Co-authored-by: Julius Marminge --- .../chat/ThreadDetailsPrRow.browser.test.tsx | 97 ++++++++++++++++++- .../PullRequestDetailPanel.browser.test.tsx | 60 ++++++++++-- .../pullRequest/PullRequestDetailPanel.tsx | 12 ++- .../pullRequestDetail.logic.test.ts | 24 +++++ .../pullRequest/pullRequestDetail.logic.ts | 15 +++ .../pullRequest/usePullRequestActions.ts | 18 +++- 6 files changed, 209 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx index a1b80f2a7d6d..47adb13580c6 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.browser.test.tsx @@ -1,4 +1,13 @@ -import { EnvironmentId, type PullRequestCheck } from "@t3tools/contracts"; +import { + EnvironmentId, + GitCommandError, + GitManagerError, + ProjectId, + ThreadId, + type PullRequestCheck, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { DraftId } from "~/composerDraftStore"; import { AsyncResult } from "effect/reactivity"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -8,6 +17,11 @@ const state = vi.hoisted(() => ({ status: "success" as PullRequestCheck["status"], extraStatus: null as PullRequestCheck["status"] | null, perform: vi.fn(), + newThread: vi.fn(), + prepareThread: vi.fn(), + checkoutError: null as Error | null, + addToast: vi.fn(), + updateToast: vi.fn(), })); vi.mock(import("~/state/entities"), async (importOriginal) => ({ @@ -15,6 +29,9 @@ vi.mock(import("~/state/entities"), async (importOriginal) => ({ useServerConfigs: (() => new Map()) as never, })); const queryData = () => ({ + projectId: ProjectId.make("project"), + workspaceRoot: "/workspace", + url: "https://github.com/owner/repo/pull/1", number: 1, title: "Test PR", state: "open", @@ -45,9 +62,32 @@ vi.mock(import("~/state/query"), async (importOriginal) => ({ vi.mock(import("../pullRequest/usePullRequestActions"), async (importOriginal) => ({ ...(await importOriginal()), usePullRequestActionRunner: () => ({ actionPending: false, perform: state.perform }), - usePullRequestHandoffs: () => ({ handoff: null, startAsk: vi.fn(), startHandoff: vi.fn() }), })); +vi.mock(import("~/hooks/useHandleNewThread"), async (original) => ({ + ...(await original()), + useNewThreadHandler: (() => state.newThread) as never, +})); +vi.mock(import("~/lib/sourceControlActions"), async (original) => ({ + ...(await original()), + usePreparePullRequestThreadAction: (() => ({ + run: state.prepareThread, + isAllowed: true, + error: state.checkoutError, + })) as never, +})); +vi.mock(import("../ui/toast"), async (original) => { + const actual = await original(); + return { + ...actual, + toastManager: { + ...actual.toastManager, + add: state.addToast, + update: state.updateToast, + } as never, + }; +}); + import { ThreadDetailsPrRow } from "./ThreadDetailsPrRow"; let root: Root; @@ -57,6 +97,14 @@ beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); + state.checkoutError = null; + state.newThread.mockReset().mockResolvedValue({ + draftId: DraftId.make("new-draft"), + threadId: ThreadId.make("new-thread"), + }); + state.prepareThread.mockReset(); + state.addToast.mockReset().mockReturnValue("checkout-toast"); + state.updateToast.mockReset(); }); afterEach(() => { act(() => root.unmount()); @@ -133,3 +181,48 @@ it.each<[PullRequestCheck["status"], PullRequestCheck["status"], string]>([ .filter((value) => /^\d+\/\d+$/.test(value)); expect(new Set(counts)).toEqual(new Set(count ? [count] : [])); }); + +it("shows the current checkout recovery detail when fixing checks fails and is retried", async () => { + state.status = "failure"; + const first = new GitManagerError({ + cwd: "/workspace", + operation: "preparePullRequestThread", + detail: "This PR branch is already checked out in the main repo. Use Local.", + }); + const second = new GitCommandError({ + cwd: "/workspace", + operation: "GitVcsDriver.createWorktree", + command: "git worktree add", + detail: "The worktree directory already exists. Choose another directory.", + }); + await act(async () => { + root.render( + , + ); + }); + + for (const error of [first, second]) { + state.prepareThread.mockImplementationOnce(async () => { + state.checkoutError = error; + return { _tag: "Failure", cause: Cause.fail(error) }; + }); + await act(async () => { + [...container.querySelectorAll("button")] + .find((button) => button.textContent?.trim() === "Fix")! + .click(); + }); + } + expect(state.updateToast.mock.calls.map(([, toast]) => toast.description)).toEqual([ + expect.stringContaining(first.detail), + second.detail, + ]); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx index 5b2460ecb187..31fc9a3e4403 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.browser.test.tsx @@ -1,5 +1,7 @@ import { EnvironmentId, + GitCommandError, + GitManagerError, ProjectId, SourceControlProviderKind, ThreadId, @@ -7,6 +9,7 @@ import { type ScopedThreadRef, type ThreadPullRequestLink, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { act, type ComponentProps } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -15,11 +18,16 @@ import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import "~/index.css"; -const { newThread, prepareThread, refresh } = vi.hoisted(() => ({ - newThread: vi.fn(), - prepareThread: vi.fn(), - refresh: vi.fn(), -})); +const { newThread, prepareThread, refresh, addToast, updateToast, checkoutState } = vi.hoisted( + () => ({ + newThread: vi.fn(), + prepareThread: vi.fn(), + refresh: vi.fn(), + addToast: vi.fn(), + updateToast: vi.fn(), + checkoutState: { error: null as Error | null }, + }), +); vi.mock(import("~/state/session"), async (original) => ({ ...(await original()), @@ -39,7 +47,7 @@ vi.mock(import("~/lib/sourceControlActions"), async (original) => ({ usePreparePullRequestThreadAction: (() => ({ run: prepareThread, isAllowed: true, - error: null, + error: checkoutState.error, })) as never, })); vi.mock(import("~/state/use-atom-command"), async (original) => ({ @@ -146,6 +154,14 @@ vi.mock(import("./PullRequestCodeTab"), async (original) => ({ )) as never, })); +vi.mock(import("../ui/toast"), async (original) => { + const actual = await original(); + return { + ...actual, + toastManager: { ...actual.toastManager, add: addToast, update: updateToast } as never, + }; +}); + import { PullRequestDetailPanel } from "./PullRequestDetailPanel"; import { pullRequestPanelContext } from "./pullRequestDetail.logic"; @@ -244,6 +260,9 @@ beforeEach(() => { newThread .mockReset() .mockResolvedValue({ draftId: newDraftId, threadId: ThreadId.make("new-thread") }); + addToast.mockReset().mockReturnValue("checkout-toast"); + updateToast.mockReset(); + checkoutState.error = null; prepareThread.mockReset().mockResolvedValue({ _tag: "Success", value: { branch: "feature", worktreePath: "/workspace/pr" }, @@ -386,3 +405,32 @@ it("folds the title only for the active tab's own scroller", async () => { await tab("Code").click(); await vi.waitFor(() => expect(titleFold()).toBe(true)); }); + +it("shows the current checkout recovery detail on the first failure and on retry", async () => { + const first = new GitManagerError({ + cwd: "/workspace", + operation: "preparePullRequestThread", + detail: "This PR branch is already checked out in the main repo. Use Local.", + }); + const second = new GitCommandError({ + cwd: "/workspace", + operation: "GitVcsDriver.createWorktree", + command: "git worktree add", + detail: "The worktree directory already exists. Choose another directory.", + }); + await render(); + + for (const [index, error] of [first, second].entries()) { + prepareThread.mockImplementationOnce(async () => { + checkoutState.error = error; + return { _tag: "Failure", cause: Cause.fail(error) }; + }); + await page.getByRole("button", { name: "Check out", exact: true }).click(); + await page.getByRole("menuitem", { name: /In a separate worktree/ }).click(); + await vi.waitFor(() => expect(updateToast).toHaveBeenCalledTimes(index + 1)); + } + expect(updateToast.mock.calls.map(([, toast]) => toast.description)).toEqual([ + expect.stringContaining(first.detail), + second.detail, + ]); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 624fc420e2ca..77f92ec836a1 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -8,7 +8,10 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, @@ -155,6 +158,7 @@ import { pullRequestFindingKey, pullRequestHandoffLabels, PULL_REQUEST_MERGE_METHOD_LABELS, + checkoutFailureDescription, readableFailure, readPullRequestDetailSnapshot, resolvePullRequestReferenceHost, @@ -1214,12 +1218,12 @@ export function PullRequestDetailPanel({ }); if (prepared._tag === "Failure") { setHandoff(null); - const detailMessage = - prepareThread.error instanceof Error ? prepareThread.error.message : null; toastManager.update(toastId, { type: "error", title: "Could not prepare the pull request checkout", - ...(detailMessage ? { description: detailMessage } : {}), + description: checkoutFailureDescription( + isAtomCommandInterrupted(prepared) ? undefined : squashAtomCommandFailure(prepared), + ), }); return; } diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index ee86b2ddc25c..42a5017fcd90 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -2,6 +2,7 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { resolvePlanFollowUpSubmission } from "../../proposedPlan"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; import { + GitCommandError, ProjectId, PullRequestAction, type PullRequestCheck, @@ -49,6 +50,7 @@ import { pullRequestCheckoutCommand, pullRequestFindingKey, pullRequestReviewOutcome, + checkoutFailureDescription, readableFailure, readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, @@ -1050,6 +1052,28 @@ describe("what to say when an action fails", () => { expect(readableFailure(undefined, hint)).toBe(hint); }); + it("uses bounded git diagnostics and falls back when checkout has no useful detail", () => { + const diagnostic = (detail: string, reason?: GitCommandError["reason"]) => + new GitCommandError({ + cwd: "/workspace", + operation: "GitVcsDriver.createWorktree", + command: "git worktree add", + detail, + reason, + }); + expect( + checkoutFailureDescription(diagnostic("git worktree add failed", "path_already_exists")), + ).toBe("git worktree add failed (path_already_exists)"); + const reason = "The worktree directory already exists. Choose another directory."; + expect(checkoutFailureDescription(diagnostic(reason))).toBe(reason); + expect(checkoutFailureDescription(diagnostic("x".repeat(900)))).toBe(`${"x".repeat(319)}…`); + for (const failure of [undefined, diagnostic(""), diagnostic("exited with code 1")]) { + expect(checkoutFailureDescription(failure)).toBe( + "Check that the branch still exists and is not checked out elsewhere, then try again.", + ); + } + }); + it("bounds a host that answers with a page of output", () => { const long = readableFailure(new Error("x".repeat(900)), hint); expect(long.length).toBeLessThanOrEqual(320); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index e27659d08f8f..cc20657d5673 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -2,6 +2,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import * as Schema from "effect/Schema"; import { + GitCommandError, PullRequestDetail, PullRequestOperationError, pullRequestHostOf, @@ -1095,6 +1096,20 @@ export function readableFailure(failure: unknown, hint: string): string { return bounded; } +const isGitCommandError = Schema.is(GitCommandError); + +export function checkoutFailureDescription(failure: unknown): string { + const description = isGitCommandError(failure) + ? failure.reason === undefined + ? failure.detail + : `${failure.detail} (${failure.reason})` + : failure; + return readableFailure( + description, + "Check that the branch still exists and is not checked out elsewhere, then try again.", + ); +} + export function resolveBaseFreshness(detail: { readonly state: PullRequestState; readonly mergeability: PullRequestMergeability; diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index 4fa868f5cb13..eaaf92d77eac 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -1,6 +1,9 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ProjectId, @@ -28,7 +31,12 @@ import type { ReviewCommentContext } from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { toastManager } from "../ui/toast"; -import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic"; +import { + checkoutFailureDescription, + handoffPrompt, + handoffReviewComments, + readableFailure, +} from "./pullRequestDetail.logic"; import { pullRequestEntryKey, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; export function usePullRequestDefaultMergeMethodResolver( @@ -337,12 +345,12 @@ export function usePullRequestHandoffs({ }); if (prepared._tag === "Failure") { setHandoff(null); - const detailMessage = - prepareThread.error instanceof Error ? prepareThread.error.message : null; toastManager.update(toastId, { type: "error", title: "Could not prepare the pull request checkout", - ...(detailMessage ? { description: detailMessage } : {}), + description: checkoutFailureDescription( + isAtomCommandInterrupted(prepared) ? undefined : squashAtomCommandFailure(prepared), + ), }); return; } From 818c71d67d7870e4d88709828b3ee1aa6c4cf30c Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 19 Oct 2026 18:08:12 +0000 Subject: [PATCH 033/898] fix(server): mark unvisited thread completions unread (#15607) Co-authored-by: Julius Marminge --- .../Orchestrator.control-reads.test.ts | 62 +++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 21 +++++++ .../ThreadLaunchService.test.ts | 20 +++--- .../legacy/LegacyV1ThreadImporter.test.ts | 1 + 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index aad904a6c423..d68fd950a207 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -54,6 +54,68 @@ const layerTest = Layer.mergeAll( ), ); +it.effect("marks a never-visited thread's first completion unread", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:completion-watermark"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-completion-watermark"), + threadId, + projectId: ProjectId.make("project:completion-watermark"), + title: "Background task", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + assert.isNull((yield* projections.getThreadShell(threadId))?.lastVisitedAt); + const dispatch = (id: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(id), + text: "Run in the background", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "agent", + creationSource: "mcp", + }); + yield* TestClock.adjust("1 second"); + const dispatchedAt = yield* DateTime.now; + yield* dispatch("first-completion-watermark"); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("interrupt-completion-watermark"), + threadId, + runId: (yield* projections.getThreadProjection(threadId)).runs[0]!.id, + }); + // The run ends in the dispatch's millisecond and must still be unread. + const ended = yield* projections.getThreadShell(threadId); + assert.ok(ended?.lastVisitedAt && ended.latestRunCompletedAt); + assert.equal( + DateTime.toEpochMillis(ended.lastVisitedAt), + DateTime.toEpochMillis(dispatchedAt) - 1, + ); + assert.isAbove( + DateTime.toEpochMillis(ended.latestRunCompletedAt), + DateTime.toEpochMillis(ended.lastVisitedAt), + ); + + yield* TestClock.adjust("1 second"); + yield* dispatch("second-completion-watermark"); + assert.deepEqual( + (yield* projections.getThreadShell(threadId))?.lastVisitedAt, + ended.lastVisitedAt, + ); + }).pipe(Effect.provide(layerTest)), +); + it.effect( "dispatches metadata, queue resume and request controls without hydrating unrelated history", () => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 48df36a3a7a2..96b6bab3c033 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4703,6 +4703,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } } + if (projection.thread.lastVisitedAt === null && !isNativeMaintenanceCommand(command)) { + const now = yield* DateTime.now; + // Null keeps imported history read. The first fresh dispatch establishes + // a read watermark so its completion can be unread on every client. + // Offset it so a completion in the same millisecond is still unread. + const event = yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: projection.thread.providerInstanceId, + occurredAt: now, + payload: { + ...projection.thread, + lastVisitedAt: DateTime.subtract(now, { milliseconds: 1 }), + }, + }); + projection = applyToProjection(projection, event); + } + const continuedTaskId = projection.runs.find( (run) => run.id === command.restartContinuationOfRunId, )?.delegatedTaskId; diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 8bdf7230fd06..b10af63ce940 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -734,6 +734,7 @@ it.effect.each([" /COMPACT ", "/logout"])( assert.isUndefined( (yield* threads.getThreadProjection(launched.threadId)).thread.titleRegeneration, ); + assert.isNull((yield* threads.getThreadProjection(launched.threadId)).thread.lastVisitedAt); assert.isFalse( (yield* outbox.listByCommandId(CommandId.make("compact-title:initial-message"))).some( (effect) => effect.request.type === "thread-title.generate", @@ -3217,17 +3218,20 @@ it.effect.each( assert.equal(parent.thread.worktreePath, parentWorktree); assert.equal(parent.thread.branch, "parent-feature"); if (cancelSetup) { + const afterSequence = yield* threads.getThreadEventSequence(delegated.childThreadId); yield* service.cancelTask(scope, { taskId: delegated.taskId }); yield* Deferred.await(terminalClosed); if (!cancelAsync) { - yield* threads.streamStoredEventsFrom({ threadId: delegated.childThreadId }).pipe( - Stream.filter( - (stored) => - stored.event.type === "thread.metadata-updated" && - stored.event.payload.worktreePath === null, - ), - Stream.runHead, - ); + yield* threads + .streamStoredEventsFrom({ threadId: delegated.childThreadId, afterSequence }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "thread.metadata-updated" && + stored.event.payload.worktreePath === null, + ), + Stream.runHead, + ); } assert.equal(harness.closeTerminal.mock.calls.length, 1); assert.equal(harness.removeWorktree.mock.calls.length, cancelAsync ? 0 : 1); diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts index 2f755fdaf7f9..869cb27629ce 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts @@ -316,6 +316,7 @@ it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { }); const projection = yield* projections.getThreadProjection(threadId); assert.equal(projection.thread.title, "Renamed after shell import"); + assert.isNull(projection.thread.lastVisitedAt); assert.equal(projection.thread.runtimeMode, "approval-required"); assert.equal(projection.thread.interactionMode, "plan"); assert.deepEqual(projection.thread.archivedAt, renamedAt); From 19aebe994abc85ebdd22c7e24bf2db474d6b6172 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Mon, 19 Oct 2026 19:08:28 +0100 Subject: [PATCH 034/898] fix(web): started threads no longer offer a machine picker that does nothing (#19900) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5.1 --- .../BranchToolbar.panel.browser.test.tsx | 16 ++++++++++++++++ apps/web/src/components/BranchToolbar.tsx | 11 ++++++----- apps/web/src/components/ChatView.tsx | 11 ++++++++--- .../src/components/chat/ThreadDetailsPanel.tsx | 6 ++++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx index d551a854c262..17121e17fec1 100644 --- a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx +++ b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx @@ -106,6 +106,22 @@ it("keeps machine choices usable when the combined row's workspace is locked", a expect(onEnvironmentChange).toHaveBeenCalledWith("remote"); }); +it("shows a started thread's primary machine as a static label in the composer", async () => { + await render({ + layout: "composer", + showGitControls: false, + availableEnvironments: ["local", "remote"].map((id) => ({ + environmentId: EnvironmentId.make(id), + projectId: ProjectId.make("project"), + label: id, + isPrimary: id === "local", + machine: "server", + })), + }); + expect(container.textContent).toBe("local"); + expect(container.querySelector("button")).toBeNull(); +}); + it.each([ ["local", null, "/tmp/project"], ["worktree", null, null], diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 20bf1403e799..53d320192f3a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -645,14 +645,15 @@ export const BranchToolbar = memo(function BranchToolbar({ ], ); - const showEnvironmentPicker = Boolean( - availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, - ); + const hasMultipleEnvironments = (availableEnvironments?.length ?? 0) > 1; + const showEnvironmentPicker = hasMultipleEnvironments && onEnvironmentChange !== undefined; const activeEnvironmentOption = availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; + // Started threads get no handler but still show which machine they run on, + // matching the strip visibility ChatView computes from the same inputs. const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ activeEnvironment: activeEnvironmentOption, - canPickEnvironment: showEnvironmentPicker, + canPickEnvironment: hasMultipleEnvironments, }); const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); @@ -755,7 +756,7 @@ export const BranchToolbar = memo(function BranchToolbar({ envLocked={envLocked} environmentId={environmentId} availableEnvironments={availableEnvironments} - {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})} + {...(showEnvironmentPicker ? { onEnvironmentChange } : {})} /> {showGitControls ? ( 0 || activeRuntime !== null)); + // onEnvironmentChange only retargets an unsent draft. envLocked alone does not + // rule out a started thread: one whose machine is offline never loads its + // messages, so it would otherwise be offered a machine picker that does nothing. + const canChangeEnvironment = + hasMultipleEnvironments && !envLocked && draftId !== null && !draftThread?.promotedTo; const loadBalancingSettings = useClientSettings((settings) => ({ loadBalancingEnabled: settings.loadBalancingEnabled, @@ -5627,7 +5632,7 @@ export default function ChatView(props: ChatViewProps) { "composer.mode": openComposerControl("composer.mode"), "composer.workspace": openComposerControl("composer.workspace"), "composer.cycleHost": ({ event }) => { - if (envLocked || !draftId || !hasMultipleEnvironments) return false; + if (!canChangeEnvironment) return false; if (event.repeat) return; const currentId = readPendingEnvironmentChange()?.environmentId ?? environmentId; const index = logicalProjectEnvironments.findIndex( @@ -8393,7 +8398,7 @@ export default function ChatView(props: ChatViewProps) { {...(canCheckoutPullRequestIntoThread ? { onCheckoutPullRequestRequest: openPullRequestDialogStable } : {})} - {...(hasMultipleEnvironments + {...(canChangeEnvironment ? { onEnvironmentChange: onEnvironmentChangeStable } : {})} autoEnvironmentLabel={autoEnvironmentLabel} @@ -8480,7 +8485,7 @@ export default function ChatView(props: ChatViewProps) { ? onAutoEnvironmentStable : undefined } - onEnvironmentChange={onEnvironmentChangeStable} + {...(canChangeEnvironment ? { onEnvironmentChange: onEnvironmentChangeStable } : {})} onEnvModeChange={onEnvModeChangeStable} envMode={envMode} {...(canOverrideServerThreadEnvMode diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 0d610a5f9349..bc54f4f8a949 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -45,7 +45,7 @@ export interface ThreadDetailsPanelProps extends Pick< availableEnvironments: readonly EnvironmentOption[]; autoEnvironmentLabel?: string | undefined; onAutoEnvironment?: (() => void) | undefined; - onEnvironmentChange: (environmentId: EnvironmentId) => void; + onEnvironmentChange?: (environmentId: EnvironmentId) => void; onEnvModeChange: (mode: EnvMode) => void; envMode: EnvMode; activeThreadBranchOverride?: string | null; @@ -114,7 +114,9 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { layout="panel" panelSection="workspace" availableEnvironments={props.availableEnvironments} - onEnvironmentChange={props.onEnvironmentChange} + {...(props.onEnvironmentChange + ? { onEnvironmentChange: props.onEnvironmentChange } + : {})} autoEnvironmentLabel={props.autoEnvironmentLabel} onAutoEnvironment={props.onAutoEnvironment} {...branchToolbarProps} From 4ddc2f0f3410ecc47223ca6b07fa18168fef1755 Mon Sep 17 00:00:00 2001 From: IlyaL Date: Mon, 19 Oct 2026 11:08:30 -0700 Subject: [PATCH 035/898] fix(web): provider model names no longer get cut off by their slug (#16522) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../settings/ProviderModelsSection.tsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index c1aa614b99b7..84b35104dd58 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -501,11 +501,29 @@ function ProviderModelRow({ - {model.name} - {model.name !== model.slug ? ( - {model.slug} + + + } + > + {model.name} + {model.name !== model.slug ? ( + + {model.slug} + + ) : null} + + +
{model.name}
+ {model.name !== model.slug ? ( + {model.slug} + ) : null} +
+
+ {model.isCustom ? ( + custom ) : null} - {model.isCustom ? custom : null}
{capLabels.length > 0 ? ( From 2b00ab1679449c53eae05b731b0505c439477514 Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Mon, 19 Oct 2026 14:08:59 -0400 Subject: [PATCH 036/898] fix(claude): mid-sentence skill chips keep the full request (#15253) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- .../Adapters/ClaudeAdapterV2.test.ts | 318 ++++++++++-------- .../Adapters/ClaudeAdapterV2.ts | 7 +- .../Drivers/ClaudeSkillDispatch.test.ts | 10 +- .../provider/Drivers/ClaudeSkillDispatch.ts | 10 +- 4 files changed, 191 insertions(+), 154 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d9bf5007f7e9..6b936c01fe85 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1601,153 +1601,189 @@ describe("ClaudeAdapterV2 resume compaction", () => { }); describe("ClaudeAdapterV2 attachments", () => { - it.effect("forwards images and references generic files on sends and steering", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-attachments-", - }); - const offeredMessages: Array = []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-attachments"), - open: () => - Effect.succeed({ - messages: Stream.never, - offer: (message) => - Effect.sync(() => { - offeredMessages.push(message); - }), - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-attachments"); - const providerSessionId = ProviderSessionId.make("provider-session-claude-attachments"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const attachment = ChatImageAttachment.make({ - type: "image", - id: ChatAttachmentId.make( - "thread-claude-attachments-12345678-1234-1234-1234-123456789abc", - ), - name: "diagram.png", - mimeType: "image/png", - sizeBytes: 4, - }); - const document = ChatFileAttachment.make({ - type: "file", - id: ChatAttachmentId.make( - "thread-claude-attachments-abcdefab-1234-1234-1234-123456789abc", - ), - name: "requirements.pdf", - mimeType: "application/pdf", - sizeBytes: 4, - }); - yield* fileSystem.writeFile( - path.join(attachmentsDir, attachmentRelativePath(attachment)!), - Uint8Array.from([1, 2, 3, 4]), - ); - yield* fileSystem.writeFile( - path.join(attachmentsDir, attachmentRelativePath(document)!), - Uint8Array.from([5, 6, 7, 8]), - ); - const attemptId = RunAttemptId.make("attempt-claude-attachments"); - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ + it.effect.each([ + { + name: "ordinary text", + prompt: "What’s in this image?", + expectedText: "Ultrathink:\nWhat’s in this image?", + command: undefined, + ultrathink: true, + }, + { + name: "mid-sentence skill", + prompt: "please $implement fixes\n\nThen report the result", + expectedText: "Ultrathink:\nplease /implement fixes\n\nThen report the result", + command: "/implement fixes\n\nThen report the result", + ultrathink: true, + }, + { + name: "prompt-leading skill", + prompt: "$implement fixes", + expectedText: "/implement fixes", + command: undefined, + ultrathink: false, + }, + ])( + "preserves $name with images and files on sends and steering", + ({ prompt, expectedText, command, ultrathink }) => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const claudeHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-skills-home-", + }); + yield* fileSystem.makeDirectory(path.join(claudeHome, "skills", "implement"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(claudeHome, "skills", "implement", "SKILL.md"), + "---\ndescription: Implement the requested change\n---\nImplement it.\n", + ); + const modelSelection = { + ...CLAUDE_TEST_MODEL_SELECTION, + options: ultrathink ? CLAUDE_TEST_MODEL_SELECTION.options : [], + }; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-attachments-", + }); + const offeredMessages: Array = []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: claudeHome }, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + crypto: yield* Crypto.Crypto, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-attachments"), + open: () => + Effect.succeed({ + messages: Stream.never, + offer: (message) => + Effect.sync(() => { + offeredMessages.push(message); + }), + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-attachments"); + const providerSessionId = ProviderSessionId.make("provider-session-claude-attachments"); + const runtime = yield* adapter.openSession({ threadId, - providerThread, - now, - attemptId, - text: "What's in this image?", - attachments: [attachment, document], - }), - ); + providerSessionId, + modelSelection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const attachment = ChatImageAttachment.make({ + type: "image", + id: ChatAttachmentId.make( + "thread-claude-attachments-12345678-1234-1234-1234-123456789abc", + ), + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 4, + }); + const document = ChatFileAttachment.make({ + type: "file", + id: ChatAttachmentId.make( + "thread-claude-attachments-abcdefab-1234-1234-1234-123456789abc", + ), + name: "requirements.pdf", + mimeType: "application/pdf", + sizeBytes: 4, + }); + yield* fileSystem.writeFile( + path.join(attachmentsDir, attachmentRelativePath(attachment)!), + Uint8Array.from([1, 2, 3, 4]), + ); + yield* fileSystem.writeFile( + path.join(attachmentsDir, attachmentRelativePath(document)!), + Uint8Array.from([5, 6, 7, 8]), + ); + const attemptId = RunAttemptId.make("attempt-claude-attachments"); + const now = yield* DateTime.now; - const expectedImageBlock = { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "AQIDBA==", - }, - } as const; - const expectedAttachmentPath = path.join( - attachmentsDir, - attachmentRelativePath(attachment)!, - ); - const expectedDocumentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); - assert.deepEqual(offeredMessages[0]?.message.content, [ - expectedImageBlock, - { - type: "text", - text: `Ultrathink:\nWhat's in this image?\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]\n\n[Attached file "requirements.pdf" is saved at: ${expectedDocumentPath}]`, - }, - ]); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId, + modelSelection, + text: prompt, + attachments: [attachment, document], + }), + ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }); - yield* runtime.steerTurn({ - threadId, - runId: RunId.make("run-claude-attachments"), - providerThread, - providerTurnId, - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-claude-attachments-steer"), - text: "Focus on the diagram labels.", - attachments: [attachment, document], - }, - }); + const expectedImageBlock = { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "AQIDBA==", + }, + } as const; + const expectedAttachmentPath = path.join( + attachmentsDir, + attachmentRelativePath(attachment)!, + ); + const expectedDocumentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); + const attachmentNotes = `\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]\n\n[Attached file "requirements.pdf" is saved at: ${expectedDocumentPath}]`; + const requestBlock = { type: "text" as const, text: expectedText + attachmentNotes }; + const expectedContent = command + ? [ + requestBlock, + expectedImageBlock, + { type: "text" as const, text: command + attachmentNotes }, + ] + : [expectedImageBlock, requestBlock]; + assert.deepEqual(offeredMessages[0]?.message.content, expectedContent); - assert.equal(offeredMessages[1]?.priority, "now"); - assert.deepEqual(offeredMessages[1]?.message.content, [ - expectedImageBlock, - { - type: "text", - text: `Ultrathink:\nFocus on the diagram labels.\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]\n\n[Attached file "requirements.pdf" is saved at: ${expectedDocumentPath}]`, - }, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }); + yield* runtime.steerTurn({ + threadId, + runId: RunId.make("run-claude-attachments"), + providerThread, + providerTurnId, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-claude-attachments-steer"), + text: prompt, + attachments: [attachment, document], + }, + }); + + assert.equal(offeredMessages[1]?.priority, "now"); + assert.deepEqual(offeredMessages[1]?.message.content, expectedContent); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), ), - ), ); it.effect("rejects unsupported image types before opening a provider query", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 992215dd35f1..5a211e370542 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1596,9 +1596,10 @@ export function makeClaudeUserMessage(input: { readonly uuid?: SDKUserMessage["uuid"]; }): SDKUserMessage { // Claude Code expands a skill only from the LAST text block, and only when - // `/name` is its first character. A `$skill` chip anywhere in the prompt is - // therefore split into [leading text, "/name trailing text"] so the CLI runs - // it natively and the prose around it survives. + // `/name` is its first character. A `$skill` chip anywhere in the prompt + // therefore becomes [whole request, "/name trailing text"] so the CLI runs + // it natively and the model still reads the full request. See + // ClaudeSkillDispatch. const dispatch = input.skillNames === undefined ? undefined diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index abb80482a264..0b09f6d59466 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -10,9 +10,9 @@ describe("planClaudeSkillDispatch", () => { expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined(); }); - it("moves a mid-prompt mention into a trailing slash command", () => { + it("keeps the whole request ahead of a mid-prompt slash command", () => { expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({ - leadingText: "ok, now", + leadingText: "ok, now /implement all the tickets", commandText: "/implement all the tickets", skillName: "implement", }); @@ -28,7 +28,7 @@ describe("planClaudeSkillDispatch", () => { it("dispatches a known skill whose name begins with a digit", () => { expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({ - leadingText: "use", + leadingText: "use /2spec for this", commandText: "/2spec for this", skillName: "2spec", }); @@ -36,7 +36,7 @@ describe("planClaudeSkillDispatch", () => { it("dispatches the last mention and rewrites earlier ones inline", () => { expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ - leadingText: "/review the diff, then", + leadingText: "/review the diff, then /implement the fixes", commandText: "/implement the fixes", skillName: "implement", }); @@ -50,7 +50,7 @@ describe("planClaudeSkillDispatch", () => { SKILLS, ), ).toEqual({ - leadingText: "/review the diff, then", + leadingText: "/review the diff, then /implement the fixes", commandText: "/implement the fixes", skillName: "implement", }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index dac74b36c20b..5ab1c0744934 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -15,6 +15,7 @@ const SKILL_MENTION_PATTERN = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; export interface ClaudeSkillDispatch { + /** The whole prompt with mentions as `/name`, or `undefined` when the mention opens it. */ readonly leadingText: string | undefined; /** `/name` plus the trailing text, ready to be the message's last text block. */ readonly commandText: string; @@ -36,19 +37,18 @@ export function planClaudeSkillDispatch( return undefined; } - const leading = prompt.slice(0, last.start); const trailing = prompt.slice(last.end); - const leadingWithInlineSlashes = mentions - .slice(0, -1) + const promptWithInlineSlashes = mentions .reduceRight( (text, mention) => `${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`, - leading, + prompt, ) .trimEnd(); return { - leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined, + leadingText: + prompt.slice(0, last.start).trim().length > 0 ? promptWithInlineSlashes : undefined, commandText: `/${last.name}${trailing}`.trimEnd(), skillName: last.name, }; From f55409497774f4129bc7a1fc24dbd607512576ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= <38039015+pnupu@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:09:16 +0300 Subject: [PATCH 037/898] fix(web): preserve unsaved keybinding edits when saving another binding (#11109) Co-authored-by: Julius Marminge --- .../KeybindingsSettings.logic.test.ts | 47 ++++++++++++++++++- .../settings/KeybindingsSettings.logic.ts | 13 +++-- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 8c95bc569b89..99e8ad7d20d8 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { + compileResolvedKeybindingsConfig, + DEFAULT_RESOLVED_KEYBINDINGS, +} from "@t3tools/shared/keybindings"; import { buildKeybindingRows, @@ -115,6 +118,48 @@ describe("KeybindingsSettings.logic", () => { } }); + it("keeps sibling row identities when a saved binding moves to the end", () => { + const bindings = compileResolvedKeybindingsConfig([ + { command: "chat.new", key: "mod+n" }, + { command: "terminal.toggle", key: "mod+j" }, + { command: "terminal.toggle", key: "mod+k", when: "terminalFocus" }, + ]); + const before = buildKeybindingRows(bindings); + const after = buildKeybindingRows([ + ...bindings.slice(1), + ...compileResolvedKeybindingsConfig([{ command: "chat.new", key: "mod+shift+n" }]), + ]); + + expect(after.filter((row) => row.command === "terminal.toggle").map((row) => row.id)).toEqual( + before.filter((row) => row.command === "terminal.toggle").map((row) => row.id), + ); + expect(after.find((row) => row.command === "chat.new")?.id).not.toBe( + before.find((row) => row.command === "chat.new")?.id, + ); + }); + + it("shares a stable editor for resolved duplicates but separates commands, shortcuts, and conditions", () => { + const bindings = compileResolvedKeybindingsConfig([ + { command: "terminal.toggle", key: "mod+esc", when: "terminalFocus" }, + { command: "terminal.toggle", key: "mod+escape", when: "(terminalFocus)" }, + { command: "terminal.toggle", key: "mod+esc", when: "!terminalFocus" }, + { command: "terminal.toggle", key: "mod+j", when: "terminalFocus" }, + { command: "chat.new", key: "mod+esc", when: "terminalFocus" }, + ]); + const rows = buildKeybindingRows(bindings); + expect(rows.map(({ command, key, when }) => ({ command, key, when }))).toEqual([ + { command: "chat.new", key: "mod+esc", when: "terminalFocus" }, + { command: "terminal.toggle", key: "mod+esc", when: "terminalFocus" }, + { command: "terminal.toggle", key: "mod+esc", when: "!terminalFocus" }, + { command: "terminal.toggle", key: "mod+j", when: "terminalFocus" }, + ]); + expect(new Set(rows.map((row) => row.id)).size).toBe(4); + expect(rows.find((row) => row.command === "chat.new")?.conflicts).toEqual(["Terminal: Toggle"]); + expect(buildKeybindingRows(bindings.slice(1)).map((row) => row.id)).toEqual( + rows.map((row) => row.id), + ); + }); + it("builds searchable rows with readable key and when values", () => { const rows = filterKeybindingRows( buildKeybindingRows([ diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 18fd0e1acb95..28409786007a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -244,12 +244,14 @@ export function groupKeybindingRows( export function buildKeybindingRows( keybindings: ResolvedKeybindingsConfig, ): ReadonlyArray { - const rows = keybindings.map((binding, index) => { + const rowsById = new Map(); + for (const binding of keybindings) { const defaultBinding = defaultBindingForBinding(binding); const key = shortcutToKeybindingInput(binding.shortcut); const when = whenAstToExpression(binding.whenAst); - return { - id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`, + const id = keybindingRowId(binding.command, key, when); + rowsById.set(id, { + id, command: binding.command, key, when, @@ -258,8 +260,9 @@ export function buildKeybindingRows( defaultWhen: whenAstToExpression(defaultBinding?.whenAst), binding, conflicts: [], - } satisfies KeybindingRow; - }); + }); + } + const rows = [...rowsById.values()]; const rowsWithConflicts = rows.map((row) => { const conflicts = keybindingConflictLabels(rows, { From 9878f487fac567949f366b4cc34d97a252f56bf8 Mon Sep 17 00:00:00 2001 From: Nick Anisimov Date: Mon, 19 Oct 2026 22:09:39 +0400 Subject: [PATCH 038/898] fix(connections): retry unsupported saved direct connections (#15107) Co-authored-by: Julius Marminge --- .../connection/CloudEnvironmentRows.tsx | 12 +- .../connection/ConnectionEnvironmentRow.tsx | 8 +- .../settings/ConnectionsSettings.tsx | 6 +- .../src/connection/registry.test.ts | 153 ++++++++++++++---- .../client-runtime/src/connection/registry.ts | 10 +- .../client-runtime/src/connection/routes.ts | 4 + 6 files changed, 154 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 5ea1f5c3dc51..dc498a873cc2 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -2,7 +2,10 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { ConnectionTraceId } from "./ConnectionTraceId"; import { useAuth } from "@clerk/expo"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { + hasSavedBearerRoute, + type EnvironmentConnectionPhase, +} from "@t3tools/client-runtime/connection"; import { type EnvironmentId, type EnvironmentMachineKind, @@ -20,6 +23,7 @@ import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { serverEnvironment } from "../../state/server"; +import { environmentPresentations } from "../../state/presentation"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; @@ -185,6 +189,10 @@ function ConnectedCloudEnvironmentRow(props: { serverEnvironment.configValueAtom(props.environment.environmentId), ); const unsupported = props.environment.connectionState === "unsupported"; + const presentation = useAtomValue( + environmentPresentations.presentationAtom(props.environment.environmentId), + ); + const canRetry = presentation !== null && hasSavedBearerRoute(presentation.entry); const enabled = props.environment.isEnabled && !unsupported; const [lastDescriptor, setLastDescriptor] = useState(props.descriptor); if (props.descriptor !== undefined && props.descriptor !== lastDescriptor) { @@ -205,7 +213,7 @@ function ConnectedCloudEnvironmentRow(props: { )} onValueChange={props.onSetEnabled} onToggleError={props.onToggleError} - disabled={unsupported} + disabled={unsupported && !canRetry} {...(enabled || unsupported ? {} : { statusText: "Off" })} value={enabled} /> diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 610312f93507..e3f76ef50143 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -1,6 +1,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { ConnectionTraceId } from "./ConnectionTraceId"; import { SymbolView } from "../../components/ui/AppSymbol"; +import { hasSavedBearerRoute } from "@t3tools/client-runtime/connection"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -18,6 +19,7 @@ import { ThemedSwitch } from "../../components/ui/ThemedSwitch"; import { cn } from "../../lib/cn"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { serverEnvironment } from "../../state/server"; +import { environmentPresentations } from "../../state/presentation"; import { ConnectionFormField } from "./ConnectionFormField"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { environmentStatusText } from "./environmentStatusText"; @@ -52,6 +54,10 @@ export function ConnectionEnvironmentRow(props: { serverEnvironment.configValueAtom(props.environment.environmentId), ); const unsupported = props.environment.connectionState === "unsupported"; + const presentation = useAtomValue( + environmentPresentations.presentationAtom(props.environment.environmentId), + ); + const canRetry = presentation !== null && hasSavedBearerRoute(presentation.entry); const enabled = props.environment.isEnabled && !unsupported; const statusLabel = connectionStatusLabel(props.environment); const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null; @@ -136,7 +142,7 @@ export function ConnectionEnvironmentRow(props: { props.onSetEnabled(props.environment.environmentId, next)} value={enabled} /> diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b465b39cd2e9..364475abc10a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -47,6 +47,7 @@ import { RelayConnectionRegistration, RelayConnectionTarget, connectionRoutes, + hasSavedBearerRoute, connectionStatusText, environmentMcpUrl, } from "@t3tools/client-runtime/connection"; @@ -1467,6 +1468,7 @@ function SavedBackendListRow({ const [permissionsOpen, setPermissionsOpen] = useState(false); const environmentId = environment.environmentId; const unsupported = environment.connection.phase === "unsupported"; + const blocked = unsupported && !hasSavedBearerRoute(environment.entry); const enabled = environment.entry.enabled && !unsupported; const isConnected = environment.connection.phase === "connected"; const isRemoving = removingEnvironmentId === environmentId; @@ -1704,14 +1706,14 @@ function SavedBackendListRow({ onSetEnabled(environmentId, checked)} /> } /> - {unsupported ? "Client not supported" : enabled ? "Switch off" : "Switch on"} + {blocked ? "Client not supported" : enabled ? "Switch off" : "Switch on"} diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 279d39ecd702..35705a58188f 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -995,39 +995,49 @@ describe("EnvironmentRegistry", () => { }), ); - it.effect("discovery keeps unsupported environments off until compatibility changes", () => - Effect.gen(function* () { - const harness = yield* makeHarness([RELAY_TARGET], [], [], { - initialDisabled: [RELAY_TARGET.environmentId], - }); - yield* Effect.gen(function* () { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - yield* registry.start; - const error = new ConnectionBlockedError({ - reason: "unsupported", - detail: "Use a compatible client.", + it.effect.each([false, true])( + "discovery keeps unsupported environments off (learned route: %s)", + (learned) => + Effect.gen(function* () { + const environmentId = BEARER_TARGET.environmentId; + const relay = new RelayConnectionTarget({ ...RELAY_TARGET, environmentId }); + const profile = new BearerConnectionProfile({ + ...BEARER_PROFILE, + learned: true, + authorization: "t3-connect", }); - yield* registry.setCompatibility(RELAY_TARGET.environmentId, error); - const entry = (yield* SubscriptionRef.get(registry.entries)).get( - RELAY_TARGET.environmentId, + const harness = yield* makeHarness( + learned ? [relay, BEARER_TARGET] : [relay], + learned ? [profile] : [], + [], + { initialDisabled: [environmentId] }, ); - expect(entry).toMatchObject({ enabled: false, unsupportedReason: error.message }); - expect( - yield* Effect.flip(registry.setEnabled(RELAY_TARGET.environmentId, true)), - ).toMatchObject({ reason: "unsupported" }); - expect(yield* Ref.get(harness.sessions)).toHaveLength(0); - yield* registry.setCompatibility(RELAY_TARGET.environmentId, null); - expect( - (yield* SubscriptionRef.get(registry.entries)).get(RELAY_TARGET.environmentId)?.enabled, - ).toBe(false); - yield* registry.setEnabled(RELAY_TARGET.environmentId, true); - yield* awaitConnectionState( - registry, - RELAY_TARGET.environmentId, - (state) => state.phase === "connected", - ); - }).pipe(Effect.provide(harness.layer)); - }), + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + const error = new ConnectionBlockedError({ + reason: "unsupported", + detail: "Use a compatible client.", + }); + yield* registry.setCompatibility(environmentId, error); + const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId); + expect(entry).toMatchObject({ enabled: false, unsupportedReason: error.message }); + expect(yield* Effect.flip(registry.setEnabled(environmentId, true))).toMatchObject({ + reason: "unsupported", + }); + expect(yield* Ref.get(harness.sessions)).toHaveLength(0); + yield* registry.setCompatibility(environmentId, null); + expect((yield* SubscriptionRef.get(registry.entries)).get(environmentId)?.enabled).toBe( + false, + ); + yield* registry.setEnabled(environmentId, true); + yield* awaitConnectionState( + registry, + environmentId, + (state) => state.phase === "connected", + ); + }).pipe(Effect.provide(harness.layer)); + }), ); it.effect("a socket preflight rejection persists the connection as switched off", () => @@ -1055,6 +1065,87 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect.each([ + { outcome: "connects after a server update", compatible: true, relayFirst: false }, + { outcome: "connects with a preferred relay route", compatible: true, relayFirst: true }, + { outcome: "blocks again if still incompatible", compatible: false, relayFirst: false }, + ])("retrying an unsupported saved URL $outcome", ({ compatible, relayFirst }) => + Effect.gen(function* () { + const environmentId = BEARER_TARGET.environmentId; + const error = new ConnectionBlockedError({ + reason: "unsupported", + detail: "Update the server to use this client.", + serverUpdateRequired: true, + }); + const relay = new RelayConnectionTarget({ + environmentId, + label: "Relay route", + }); + const attempts = yield* Ref.make(0); + const retryStarted = yield* Deferred.make(); + const retryResult = yield* Deferred.make(); + const harness = yield* makeHarness( + relayFirst ? [relay, BEARER_TARGET] : [BEARER_TARGET], + [BEARER_PROFILE], + [[BEARER_TARGET.connectionId, BEARER_CREDENTIAL]], + { + beforePrepareRoute: () => + Effect.gen(function* () { + if ((yield* Ref.updateAndGet(attempts, (count) => count + 1)) === 1) return error; + yield* Deferred.succeed(retryStarted, undefined); + return yield* Deferred.await(retryResult); + }), + }, + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + const awaitDisabled = SubscriptionRef.changes(registry.entries).pipe( + Stream.filter((entries) => entries.get(environmentId)?.enabled === false), + Stream.take(1), + Stream.runDrain, + ); + yield* awaitDisabled; + expect((yield* Ref.get(harness.storedDisabled)).has(environmentId)).toBe(true); + expect((yield* SubscriptionRef.get(registry.entries)).get(environmentId)).toMatchObject({ + target: relayFirst ? relay : BEARER_TARGET, + enabled: false, + unsupportedReason: error.message, + serverUpdateRequired: true, + }); + expect(yield* Ref.get(attempts)).toBe(1); + + yield* registry.setEnabled(environmentId, true); + yield* Deferred.await(retryStarted); + const retrying = (yield* SubscriptionRef.get(registry.entries)).get(environmentId); + expect(retrying?.enabled).toBe(true); + expect(retrying?.unsupportedReason).toBeUndefined(); + expect(retrying?.serverUpdateRequired).toBeUndefined(); + expect((yield* Ref.get(harness.storedDisabled)).has(environmentId)).toBe(false); + + yield* Deferred.succeed(retryResult, compatible ? undefined : error); + if (compatible) { + yield* awaitConnectionState( + registry, + environmentId, + (state) => state.phase === "connected", + ); + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + } else { + yield* awaitDisabled; + expect((yield* SubscriptionRef.get(registry.entries)).get(environmentId)).toMatchObject({ + enabled: false, + unsupportedReason: error.message, + serverUpdateRequired: true, + }); + expect((yield* Ref.get(harness.storedDisabled)).has(environmentId)).toBe(true); + expect(yield* Ref.get(harness.sessions)).toHaveLength(0); + } + expect(yield* Ref.get(attempts)).toBe(2); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("switching an environment off disconnects it and persists the flag", () => Effect.gen(function* () { const harness = yield* makeHarness([RELAY_TARGET]); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index c3ad705e3d2a..32ad38d1f089 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -50,6 +50,7 @@ import { credentialConnectionId, entryWithRoutes, findRouteToSameAddress, + hasSavedBearerRoute, mergeLearnedRoutes, routesAfterRemoving, upsertRoute, @@ -1019,19 +1020,22 @@ export const make = Effect.gen(function* () { environmentId, Effect.gen(function* () { const entry = yield* getEntry(environmentId); - if (enabled && entry.unsupportedReason !== undefined) { + const retryUnsupported = enabled && entry.unsupportedReason !== undefined; + if (retryUnsupported && !hasSavedBearerRoute(entry)) { return yield* new ConnectionBlockedError({ reason: "unsupported", detail: entry.unsupportedReason, }); } - if (entry.enabled === enabled) { + if (entry.enabled === enabled && !retryUnsupported) { return; } if (!(yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { yield* registrations.setEnabled(environmentId, enabled); } - const next: ConnectionCatalogEntry = { ...entry, enabled }; + // A cached rejection can outlive a server update. + const { unsupportedReason: _reason, serverUpdateRequired: _update, ...rest } = entry; + const next: ConnectionCatalogEntry = { ...(retryUnsupported ? rest : entry), enabled }; // Update the lease in place so the supervisor keeps its generation and // durable streams; `installEntryLocked` would tear it down instead. const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 9d70ea8c8442..23aa1efa36f0 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -186,6 +186,10 @@ export function connectionRouteAddress(route: ConnectionRoute): string | null { return routeHttpBaseUrl(route); } +/** Learned routes alone must not make relay environments manually retryable. */ +export const hasSavedBearerRoute = (entry: ConnectionCatalogEntry) => + connectionRoutes(entry).some((r) => !isLearned(r) && r.target._tag === "BearerConnectionTarget"); + export function hasRelayRoute( entry: Pick, ): boolean { From 02c81ec5ea6287eefba23b99a0cf4439e7b68207 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 11:09:53 -0700 Subject: [PATCH 039/898] fix(web): show readable messages for structured thread errors (#21483) --- .../chat/ThreadErrorBanner.browser.test.tsx | 38 ++++++++++++++++ .../chat/ThreadErrorBanner.test.tsx | 43 +++++++++++++++++-- .../src/components/chat/ThreadErrorBanner.tsx | 21 ++++++++- .../components/settings/ExpandableText.tsx | 6 ++- 4 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx diff --git a/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx b/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx new file mode 100644 index 000000000000..3a8b3d951bff --- /dev/null +++ b/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx @@ -0,0 +1,38 @@ +import "../../index.css"; + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { ThreadErrorBanner } from "./ThreadErrorBanner"; + +it("expands even a short JSON error to its exact diagnostics and collapses back to its message", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const error = ' {"error": {"message":"Turn failed", "code":"unsupported_value"}}\t '; + + try { + await act(async () => root.render()); + + await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible(); + expect(container.textContent).not.toContain("unsupported_value"); + + await act(async () => page.getByRole("button", { name: "Show full error" }).click()); + + const diagnostics = page.getByText(error, { exact: true }); + await expect.element(diagnostics).toBeVisible(); + expect(diagnostics.element().textContent).toBe(error); + + await act(async () => page.getByRole("button", { name: "Show less" }).click()); + + await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible(); + expect(container.textContent).not.toContain("unsupported_value"); + } finally { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx index 418fdf0adb5b..d603dc8530e3 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx @@ -3,10 +3,41 @@ import { describe, expect, it } from "vite-plus/test"; import { dismissThreadErrorBannerForSession, getThreadErrorBannerKey, + getThreadErrorBannerMessage, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, } from "./ThreadErrorBanner"; +describe("getThreadErrorBannerMessage", () => { + it("extracts a readable nested message without trimming its whitespace", () => { + const message = + " This model is not supported with a ChatGPT account.\nChoose another model. "; + const error = ` \n${JSON.stringify({ error: { message, code: "unsupported_model" } })}\t `; + + expect(getThreadErrorBannerMessage(error)).toBe(message); + }); + + it.each([ + ["plain text", " Could not start the turn.\nTry again. "], + ["invalid JSON", '{"error":{"message":"Cut off"'], + ["an unfamiliar shape", '{"message":"Top-level message","status":400}'], + ])("leaves %s unchanged", (_description, error) => { + expect(getThreadErrorBannerMessage(error)).toBe(error); + }); + + it("keeps the diagnostics when the nested message is blank", () => { + const error = JSON.stringify({ error: { message: " \n\t ", code: "unsupported_model" } }); + + expect(getThreadErrorBannerMessage(error)).toBe(error); + }); + + it("does not recursively unwrap the selected message", () => { + const message = JSON.stringify({ error: { message: "Inner message" } }); + + expect(getThreadErrorBannerMessage(JSON.stringify({ error: { message } }))).toBe(message); + }); +}); + describe("ThreadErrorBanner", () => { it("stays hidden after its current error is dismissed", () => { const bannerKey = getThreadErrorBannerKey("env:thread-a", "Aborted"); @@ -21,15 +52,19 @@ describe("ThreadErrorBanner", () => { ).toBe(false); }); - it("reappears when a new error arrives on the same thread", () => { - dismissThreadErrorBannerForSession(getThreadErrorBannerKey("env:thread-b", "Turn failed")); - const newErrorKey = getThreadErrorBannerKey("env:thread-b", "Provider crashed"); + it("reappears for different diagnostics with the same headline on the same thread", () => { + const firstError = JSON.stringify({ error: { message: "Turn failed", code: "first" } }); + const secondError = JSON.stringify({ error: { message: "Turn failed", code: "second" } }); + const dismissedKey = getThreadErrorBannerKey("env:thread-b", firstError); + dismissThreadErrorBannerForSession(dismissedKey); + const newErrorKey = getThreadErrorBannerKey("env:thread-b", secondError); + expect(isThreadErrorBannerDismissedForSession(dismissedKey)).toBe(true); expect(isThreadErrorBannerDismissedForSession(newErrorKey)).toBe(false); expect( shouldShowThreadErrorBanner( "env:thread-b", - "Provider crashed", + secondError, isThreadErrorBannerDismissedForSession(newErrorKey), ), ).toBe(true); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 54f65238687b..198fbb687c61 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -7,6 +7,25 @@ import { OpenAI } from "../Icons"; import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; import { ExpandableText } from "../settings/ExpandableText"; +export function getThreadErrorBannerMessage(error: string): string { + let payload: unknown; + try { + payload = JSON.parse(error); + } catch { + return error; + } + if (typeof payload !== "object" || payload === null || !("error" in payload)) { + return error; + } + const nestedError = payload.error; + if (typeof nestedError !== "object" || nestedError === null || !("message" in nestedError)) { + return error; + } + return typeof nestedError.message === "string" && nestedError.message.trim().length > 0 + ? nestedError.message + : error; +} + export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null { return error === null ? null : `${threadKey}\u0000${error}`; } @@ -59,7 +78,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({

Review your usage settings in ChatGPT to continue.

) : ( - + )} {(chatGptUsageLimit || onDismiss) && ( diff --git a/apps/web/src/components/settings/ExpandableText.tsx b/apps/web/src/components/settings/ExpandableText.tsx index f0452eeb1eb8..a30d6f22eb76 100644 --- a/apps/web/src/components/settings/ExpandableText.tsx +++ b/apps/web/src/components/settings/ExpandableText.tsx @@ -5,18 +5,20 @@ import { cn } from "../../lib/utils"; export function ExpandableText({ text, + collapsedText = text, className, collapsedClassName = "line-clamp-3", expandLabel = "Show full error", }: { text: string; + collapsedText?: string; className?: string; collapsedClassName?: string; expandLabel?: string; }) { const textId = useId(); const [expanded, setExpanded] = useState(false); - const canExpand = text.length > 180 || text.includes("\n"); + const canExpand = collapsedText !== text || text.length > 180 || text.includes("\n"); return (
@@ -27,7 +29,7 @@ export function ExpandableText({ !expanded && canExpand ? collapsedClassName : null, )} > - {text} + {expanded ? text : collapsedText}
{canExpand ? ( Date: Mon, 19 Oct 2026 20:10:02 +0200 Subject: [PATCH 040/898] fix(web): custom shortcuts work when Local environment is off (#15825) Co-authored-by: Julius Marminge --- .../src/components/settings/scopedSettings.ts | 11 +- apps/web/src/state/server.test.ts | 157 ++++++++++++++++++ apps/web/src/state/server.ts | 59 ++++++- 3 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/state/server.test.ts diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 982a9e5c5c68..1a0c761208a1 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -49,6 +49,12 @@ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +export function isConnectedWithConfig( + environment: Pick, +) { + return environment.connection.phase === "connected" && environment.serverConfig !== null; +} + /** The representative supplies display values, never the set of write targets. */ export function selectScopedSettingsEnvironments( scope: ResolvedSettingsScope, @@ -59,10 +65,7 @@ export function selectScopedSettingsEnvironments selectedIds.has(environment.environmentId), ); - const connectedEnvironments = environments.filter( - (environment) => - environment.connection.phase === "connected" && environment.serverConfig !== null, - ); + const connectedEnvironments = environments.filter(isConnectedWithConfig); const environment = connectedEnvironments.find((candidate) => candidate.environmentId === primaryEnvironmentId) ?? connectedEnvironments[0] ?? diff --git a/apps/web/src/state/server.test.ts b/apps/web/src/state/server.test.ts new file mode 100644 index 000000000000..3be0d1f50346 --- /dev/null +++ b/apps/web/src/state/server.test.ts @@ -0,0 +1,157 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { EnvironmentId, type KeybindingRule, type ServerConfig } from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { resolveShortcutCommand } from "../keybindings"; +import { createShortcutKeybindingsAtom } from "./server"; + +const PRIMARY = EnvironmentId.make("primary"); +const A = EnvironmentId.make("a"); +const B = EnvironmentId.make("b"); + +// The reporter's swap: Cmd+N opens a local thread instead of a regular one. +const SWAPPED: KeybindingRule[] = [{ key: "mod+n", command: "chat.newLocal" }]; +const STALE: KeybindingRule[] = [{ key: "mod+n", command: "chat.newWithoutProject" }]; + +function config(rules: readonly KeybindingRule[]) { + return { + keybindings: compileResolvedKeybindingsConfig(rules), + providers: [], + } as unknown as ServerConfig; +} + +function environment(phase: EnvironmentConnectionPhase, serverConfig: ServerConfig | null) { + return { connection: { phase, error: null, traceId: null }, serverConfig }; +} + +type Environment = ReturnType; + +function makeHarness(input: { + primaryEnvironmentId?: EnvironmentId | null; + primaryConfig?: ServerConfig | null; + environments?: ReadonlyArray; +}) { + const primaryEnvironmentIdAtom = Atom.make(input.primaryEnvironmentId ?? null); + const primaryConfigAtom = Atom.make(input.primaryConfig ?? null); + const environmentsAtom = Atom.make>( + new Map(input.environments ?? []), + ); + const keybindingsAtom = createShortcutKeybindingsAtom({ + primaryEnvironmentIdAtom, + primaryConfigAtom, + environmentsAtom, + }); + const registry = AtomRegistry.make(); + registries.push(registry); + // Keep the atom mounted, like the shortcut handlers do. + registry.subscribe(keybindingsAtom, () => {}); + return { + registry, + primaryEnvironmentIdAtom, + primaryConfigAtom, + environmentsAtom, + keybindings: () => registry.get(keybindingsAtom), + cmdN: () => + resolveShortcutCommand( + { key: "n", metaKey: true, ctrlKey: false, shiftKey: false, altKey: false }, + registry.get(keybindingsAtom), + { platform: "MacIntel" }, + ), + }; +} + +const registries: AtomRegistry.AtomRegistry[] = []; +afterEach(() => { + for (const registry of registries.splice(0)) registry.dispose(); +}); + +describe("createShortcutKeybindingsAtom", () => { + it("updates shortcuts from the first connected, configured environment without a primary", () => { + const harness = makeHarness({}); + const aConfig = config(STALE); + const bConfig = config(SWAPPED); + const edited = config([{ key: "mod+n", command: "sidebar.toggle" }]); + expect(harness.cmdN()).toBe("chat.new"); + + // One mounted consumer follows config loading, edits, disconnects and reconnects. + for (const [a, b, command] of [ + [environment("offline", aConfig), environment("connected", bConfig), "chat.newLocal"], + [environment("connected", null), environment("connected", bConfig), "chat.newLocal"], + [ + environment("connected", aConfig), + environment("connected", bConfig), + "chat.newWithoutProject", + ], + [environment("connected", edited), environment("connected", bConfig), "sidebar.toggle"], + [environment("offline", edited), environment("connected", bConfig), "chat.newLocal"], + [environment("offline", edited), environment("offline", bConfig), "chat.new"], + [environment("reconnecting", edited), environment("offline", bConfig), "chat.new"], + [ + environment("connected", aConfig), + environment("offline", bConfig), + "chat.newWithoutProject", + ], + ] as const) { + harness.registry.set( + harness.environmentsAtom, + new Map([ + [A, a], + [B, b], + ]), + ); + expect(harness.cmdN()).toBe(command); + } + }); + + it("uses the primary config or defaults until the primary is removed", () => { + const harness = makeHarness({ + environments: [[B, environment("connected", config(SWAPPED))]], + }); + expect(harness.cmdN()).toBe("chat.newLocal"); + + harness.registry.set(harness.primaryEnvironmentIdAtom, PRIMARY); + expect(harness.cmdN()).toBe("chat.new"); + + harness.registry.set(harness.primaryConfigAtom, config(STALE)); + expect(harness.cmdN()).toBe("chat.newWithoutProject"); + + harness.registry.set(harness.primaryConfigAtom, null); + expect(harness.cmdN()).toBe("chat.new"); + + harness.registry.set(harness.primaryEnvironmentIdAtom, null); + expect(harness.cmdN()).toBe("chat.newLocal"); + }); + + it.each([false, true])( + "preserves the bindings array through unrelated updates (primary: %s)", + (hasPrimary) => { + const selected = config(SWAPPED); + const harness = makeHarness({ + primaryEnvironmentId: hasPrimary ? PRIMARY : null, + primaryConfig: hasPrimary ? selected : null, + environments: [ + [A, environment("connected", selected)], + [B, environment("connected", config(STALE))], + ], + }); + expect(harness.cmdN()).toBe("chat.newLocal"); + const before = harness.keybindings(); + + harness.registry.set( + harness.environmentsAtom, + new Map([ + [A, environment("connected", { ...selected, providers: [] })], + [B, environment("connected", config([]))], + ]), + ); + expect(harness.keybindings()).toBe(before); + + if (hasPrimary) { + harness.registry.set(harness.primaryConfigAtom, { ...selected, providers: [] }); + expect(harness.keybindings()).toBe(before); + } + }, + ); +}); diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index a12dfde6e0bc..5eecf117fa0a 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -10,6 +10,11 @@ import { type ServerProvider, type ServerSettings, } from "@t3tools/contracts"; +import { + AVAILABLE_CONNECTION_STATE, + presentEnvironmentConnection, + type EnvironmentPresentation, +} from "@t3tools/client-runtime/connection"; import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; import { createOutdatedServerUpdateCommand } from "@t3tools/client-runtime/state/outdatedServerUpdate"; import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; @@ -17,6 +22,7 @@ import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; +import { isConnectedWithConfig } from "../components/settings/scopedSettings"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentAtomList } from "./environmentAtomList"; @@ -109,9 +115,56 @@ export const primaryServerProvidersAtom = Atom.make( get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, ).pipe(Atom.withLabel("web-primary-server-providers")); -export const primaryServerKeybindingsAtom = Atom.make((get): ServerConfig["keybindings"] => - mergeWithDefaultKeybindings(get(primaryServerConfigAtom)?.keybindings ?? []), -).pipe(Atom.withLabel("web-primary-server-keybindings")); +/** Without a primary, global shortcuts follow unscoped Settings. */ +export function createShortcutKeybindingsAtom(input: { + readonly primaryEnvironmentIdAtom: Atom.Atom; + readonly primaryConfigAtom: Atom.Atom; + readonly environmentsAtom: Atom.Atom< + ReadonlyMap> + >; +}) { + // Selected before merging, so unrelated config updates keep the merged array. + const customAtom = Atom.make((get) => { + if (get(input.primaryEnvironmentIdAtom) !== null) { + // A primary stays authoritative while its config loads. + return get(input.primaryConfigAtom)?.keybindings ?? null; + } + const environments = [...get(input.environmentsAtom).values()]; + return environments.find(isConnectedWithConfig)?.serverConfig?.keybindings ?? null; + }); + return Atom.make((get): ServerConfig["keybindings"] => + mergeWithDefaultKeybindings(get(customAtom) ?? []), + ); +} + +// Settings' presentations import this module; mirror their catalog order and +// connection presentation here to avoid a cycle. +const environmentConnectionsAtom = Atom.make((get) => { + const environments = new Map< + EnvironmentId, + Pick + >(); + for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { + const state = Option.getOrElse( + AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))), + () => AVAILABLE_CONNECTION_STATE, + ); + environments.set(environmentId, { + connection: + entry.unsupportedReason === undefined + ? presentEnvironmentConnection(state) + : { phase: "unsupported", error: entry.unsupportedReason, traceId: null }, + serverConfig: get(serverEnvironment.configValueAtom(environmentId)), + }); + } + return environments; +}); + +export const primaryServerKeybindingsAtom = createShortcutKeybindingsAtom({ + primaryEnvironmentIdAtom, + primaryConfigAtom: primaryServerConfigAtom, + environmentsAtom: environmentConnectionsAtom, +}).pipe(Atom.withLabel("web-primary-server-keybindings")); export const primaryServerAvailableEditorsAtom = Atom.make( (get): ReadonlyArray => From 196c397b1b6130a92951a47c21472ec79577c94a Mon Sep 17 00:00:00 2001 From: Satyal Yadav <133562158+satyalyadav@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:10:17 -0700 Subject: [PATCH 041/898] fix(web): keep thread notification tags within the Windows toast limit (#12289) Co-authored-by: Julius Marminge --- ...ficationCoordinator.badge.browser.test.tsx | 49 +++++++++++++++---- ...adNotificationCoordinator.browser.test.tsx | 4 +- .../ThreadNotificationCoordinator.tsx | 3 +- apps/web/src/threadNotifications.ts | 16 ++++++ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index 695378034f4b..3f8d6d1db57f 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -145,10 +145,14 @@ function toV2ThreadShell(input: typeof thread) { }; } -function shell(overrides: Partial = {}) { +function shell(overrides: Partial = {}, ...others: Partial[]) { return { status: "live", - snapshot: Option.some({ threads: [toV2ThreadShell({ ...thread, ...overrides })] }), + snapshot: Option.some({ + threads: [overrides, ...others].map((override) => + toV2ThreadShell({ ...thread, ...override }), + ), + }), }; } function complete(environment = "one", completedAt = "2026-09-13T08:00:00Z") { @@ -197,16 +201,43 @@ afterEach(async () => { vi.unstubAllGlobals(); }); -it("counts notifying threads across environments, replaces repeat alerts, and clears on focus", async () => { +it("uses bounded tags across threads and environments, replaces repeat alerts, and clears on focus", async () => { + const environmentId = "fd6fff30-f90b-4ee7-9812-4cf497791b90"; + const otherEnvironmentId = "25b62992-b996-4ee3-8d82-d988840e45c1"; + const threadId = "a8395a98-a186-4fe7-9d4c-fb8ba63b7588"; + const otherThreadId = "257598ad-ae4f-4422-a073-e03f4b4f5f0d"; + const completed = (id: string, completedAt = "2026-09-13T08:00:00Z") => ({ + id, + latestTurn: { turnId: "turn", state: "completed", completedAt }, + }); + state.environmentIds = [environmentId, otherEnvironmentId]; + state.shells.set(environmentId, shell({ id: threadId }, { id: otherThreadId })); + state.shells.set(otherEnvironmentId, shell({ id: threadId })); await render(); - complete(); + state.shells.set(environmentId, shell(completed(threadId), { id: otherThreadId })); await render(); expect(state.badge).toHaveBeenLastCalledWith(1); - complete("one", "2026-09-13T08:01:00Z"); - complete("two"); + state.shells.set(environmentId, shell(completed(threadId), completed(otherThreadId))); + state.shells.set(otherEnvironmentId, shell(completed(threadId))); await render(); - expect(state.badge).toHaveBeenLastCalledWith(2); - expect(TestNotification.sent[0]!.close).toHaveBeenCalledOnce(); + expect(state.badge).toHaveBeenLastCalledWith(3); + state.shells.set( + environmentId, + shell(completed(threadId, "2026-09-13T08:01:00Z"), completed(otherThreadId)), + ); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(3); + expect(TestNotification.sent).toHaveLength(4); + const [first, otherThread, otherEnvironment, replacement] = TestNotification.sent; + for (const notification of TestNotification.sent) { + expect(notification.tag).toMatch(/^[0-9a-f]{16}$/); + } + expect(new Set([first!.tag, otherThread!.tag, otherEnvironment!.tag]).size).toBe(3); + expect(replacement!.tag).toBe(first!.tag); + expect(first!.close).toHaveBeenCalledOnce(); + expect(otherThread!.close).not.toHaveBeenCalled(); + expect(otherEnvironment!.close).not.toHaveBeenCalled(); + expect(replacement!.close).not.toHaveBeenCalled(); focused = true; window.dispatchEvent(new Event("focus")); expect(state.badge).toHaveBeenLastCalledWith(0); @@ -214,7 +245,7 @@ it("counts notifying threads across environments, replaces repeat alerts, and cl TestNotification.sent.every((notification) => notification.close.mock.calls.length > 0), ).toBe(true); focused = false; - complete("two", "2026-09-13T08:02:00Z"); + state.shells.set(otherEnvironmentId, shell(completed(threadId, "2026-09-13T08:02:00Z"))); await render(); expect(state.badge).toHaveBeenLastCalledWith(1); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx index 93de18b6b3e3..d106c67baa91 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.browser.test.tsx @@ -282,7 +282,7 @@ describe("thread notifications", () => { expect(state.notification).toHaveBeenCalledTimes(1); expect(state.notification).toHaveBeenCalledWith(title, { body: "Fix the login form", - tag: "env-1:thread-1", + tag: expect.any(String), silent: true, }); }); @@ -355,7 +355,7 @@ describe("thread notifications", () => { expect(state.add).not.toHaveBeenCalled(); expect(state.notification).toHaveBeenCalledWith("Thread completed", { body: "Fix the login form", - tag: "env-1:thread-1", + tag: expect.any(String), silent: true, }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index fab97abeeef0..bb7fdcc29a8b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -23,6 +23,7 @@ import { hasNotificationSound, playNotificationSound, setNotificationBadge, + threadNotificationTag, unlockNotificationAudio, } from "../threadNotifications"; import { resolveSidebarThreadStatus } from "./Sidebar.logic"; @@ -218,7 +219,7 @@ function EnvironmentNotifications({ try { const notification = new Notification(title, { body: thread.title, - tag: `${environmentId}:${thread.id}`, + tag: threadNotificationTag(environmentId, thread.id), silent: true, }); onNotification(environmentId, notification); diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 3453a2931e94..0766f977d252 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import completionUrl from "./assets/notification-completion.mp3"; @@ -19,6 +20,21 @@ export function hasDesktopNotifications(mode: NotificationMode) { return mode === "notifications" || mode === "notifications-and-sound"; } +const TAG_HASH_OFFSET_BASIS = 0xcbf29ce484222325n; +const TAG_HASH_PRIME = 0x100000001b3n; +const TAG_HASH_MASK = 0xffffffffffffffffn; + +/** Windows drops toast notifications with the 73-character environment/thread tag. */ +export function threadNotificationTag(environmentId: EnvironmentId, threadId: ThreadId): string { + const key = `${environmentId}:${threadId}`; + let hash = TAG_HASH_OFFSET_BASIS; + for (let index = 0; index < key.length; index += 1) { + hash ^= BigInt(key.charCodeAt(index)); + hash = (hash * TAG_HASH_PRIME) & TAG_HASH_MASK; + } + return hash.toString(16).padStart(16, "0"); +} + let originalFavicon: HTMLLinkElement | undefined; let badgeFavicon: HTMLLinkElement | undefined; From 9bb7d580170524d4484395ed6be97d3d37bb4711 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:11:02 -0700 Subject: [PATCH 042/898] fix(web): pinned thread marker stays put when hovering the row (#11122) Co-authored-by: Julius Marminge --- apps/web/src/components/Sidebar.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 454f8f45f686..7f912cfef5ec 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2212,7 +2212,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onPointerDown={handleActionPointerDown} className={cn( "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", - isWoke && "group-any-hover/sidebar-row:static", + isWoke && "static", )} > @@ -2229,7 +2229,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onPointerDown={handleActionPointerDown} className={cn( "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", - isWoke && "group-any-hover/sidebar-row:static", + isWoke && "static", )} /> } @@ -2246,7 +2246,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onPointerDown={handleActionPointerDown} className={cn( "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", - isWoke && "group-any-hover/sidebar-row:static", + isWoke && "static", )} > @@ -2314,12 +2314,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} {pinIndicator} + {/* Reserve both states' widths so revealing actions cannot move the pin or Woke label. */} {sortable?.isDragging ? ( dragDestination ) : ( @@ -2327,9 +2328,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={cn( isWokeStatus ? "pointer-events-auto" - : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-any-hover/sidebar-row:absolute group-any-hover/sidebar-row:right-0 group-any-hover/sidebar-row:opacity-0", - "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity", - snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", + : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-any-hover/sidebar-row:opacity-0", + "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity [grid-area:1/1]", + snoozeMenuOpen && "pointer-events-none opacity-0", )} > {topStatus ? ( @@ -2400,8 +2401,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP // would keep the controls pinned over the status label // once the pointer moves away (e.g. after a failed // settle) instead of cross-fading back. - "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:static group-any-hover/sidebar-row:opacity-100", - snoozeMenuOpen && "pointer-events-auto static opacity-100", + "pointer-events-none flex items-stretch justify-self-end opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + isWokeStatus ? "[grid-area:1/2]" : "[grid-area:1/1]", + snoozeMenuOpen && "pointer-events-auto opacity-100", )} > {hasUnsentDraft ? ( From b177a16b690954d5446e28ad835578691b2a64d7 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:12:27 -0300 Subject: [PATCH 043/898] fix(web): keep a typed question answer when you open a new thread (#13865) Co-authored-by: Julius Marminge --- ...UserInputPanel.navigation.browser.test.tsx | 165 +++++++++++++++++- .../chat/usePendingThreadRequests.ts | 76 +++++--- apps/web/src/pendingUserInput.ts | 29 +++ 3 files changed, 241 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index 5b5893b5ad53..b0d14d504726 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -1,5 +1,12 @@ -import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; -import { act, useState } from "react"; +import { + EnvironmentId, + NodeId, + RuntimeRequestId, + ThreadId, + TurnItemId, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import { act, useLayoutEffect, useRef, useState, type ComponentProps } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; @@ -13,7 +20,9 @@ vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ default: (({ text }: { text: string }) =>

{text}

) as never, })); +import { makeThreadProjectionFixture } from "../../test-fixtures"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; +import { usePendingThreadRequests } from "./usePendingThreadRequests"; const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; @@ -131,3 +140,155 @@ it("still auto-advances once after the answer draft rerenders the same question" await act(async () => vi.advanceTimersByTime(200)); expect(activeIndex()).toBe("1"); }); + +function questionProjection(threadId: ThreadId, requestIds = ["first", "second"]) { + const projection = makeThreadProjectionFixture(); + return { + ...projection, + thread: { ...projection.thread, id: threadId }, + runtimeRequests: requestIds.map((id) => ({ + id: RuntimeRequestId.make(id), + nodeId: NodeId.make(id), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input" as const, + status: "pending" as const, + responseCapability: { type: "message" as const }, + createdAt: projection.updatedAt, + resolvedAt: null, + })), + turnItems: requestIds.map((id, ordinal) => ({ + id: TurnItemId.make(id), + threadId, + runId: null, + nodeId: NodeId.make(id), + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: null, + startedAt: projection.updatedAt, + completedAt: projection.updatedAt, + updatedAt: projection.updatedAt, + type: "user_input_request" as const, + requestId: RuntimeRequestId.make(id), + questions: prompt.questions, + })), + } satisfies OrchestrationV2ThreadProjection; +} + +let requests: ReturnType; + +function PendingRequestsProbe({ + environmentId, + projection, + loading = false, +}: { + environmentId: EnvironmentId; + projection: OrchestrationV2ThreadProjection; + loading?: boolean; +}) { + const promptRef = useRef(""); + const composerRef = useRef(null); + const state = usePendingThreadRequests({ + environmentId, + activeThreadId: projection.thread.id, + activeThread: projection.thread, + serverProjection: loading ? null : projection, + supportsQuestionAttachments: false, + composerDraftTarget: { environmentId, threadId: projection.thread.id }, + setComposerDraftPrompt: () => {}, + promptRef, + composerRef, + setThreadError: () => {}, + }); + useLayoutEffect(() => { + requests = state; + }); + return null; +} + +it("retains answers, selections and question position after leaving chat, including while the projection reloads", async () => { + const environmentId = EnvironmentId.make("remount-environment"); + const projection = questionProjection(threadRef.threadId); + const render = (loading = false) => + act(() => + root.render( + , + ), + ); + await render(); + await act(() => + requests.onChangeActivePendingUserInputCustomAnswer("question-0", "Keep my text"), + ); + await act(() => requests.onAdvanceActivePendingUserInput()); + await act(() => requests.onSelectActivePendingUserInputOption("question-1", "Incremental")); + await act(() => requests.onAdvanceActivePendingUserInput()); + const expected = { + activePendingQuestionIndex: 2, + activePendingDraftAnswers: { + "question-0": { customAnswer: "Keep my text" }, + "question-1": { selectedOptionValues: ["Incremental"] }, + }, + }; + expect(requests).toMatchObject(expected); + + // New thread and Settings both unmount the chat that owns this hook. + await act(() => root.render(null)); + await render(); + expect(requests).toMatchObject(expected); + + await act(() => root.render(null)); + await render(true); + expect(requests.activePendingUserInput).toBeNull(); + await render(); + expect(requests).toMatchObject(expected); +}); + +it("isolates drafts by environment, thread and request and discards only resolved requests", async () => { + const environmentId = EnvironmentId.make("scope-environment"); + const projection = questionProjection(threadRef.threadId); + const scopes = [ + { environmentId, projection }, + { environmentId: EnvironmentId.make("other-environment"), projection }, + { environmentId, projection: questionProjection(ThreadId.make("other-thread")) }, + { environmentId, projection: questionProjection(threadRef.threadId, ["second", "first"]) }, + ]; + const show = (scope: ComponentProps) => + act(() => root.render()); + for (const [index, scope] of scopes.entries()) { + await show(scope); + expect(requests.activePendingQuestionIndex).toBe(0); + expect(requests.activePendingDraftAnswers["question-0"]?.customAnswer).toBeUndefined(); + await act(() => + requests.onChangeActivePendingUserInputCustomAnswer("question-0", `Answer ${index}`), + ); + await act(() => requests.onAdvanceActivePendingUserInput()); + } + + await show({ + environmentId, + projection: { + ...projection, + runtimeRequests: projection.runtimeRequests.map((request) => + request.id === "first" + ? { ...request, status: "resolved", resolvedAt: projection.updatedAt } + : request, + ), + }, + }); + expect(requests.activePendingUserInput?.requestId).toBe("second"); + for (const [index, scope] of scopes.entries()) { + await show(scope); + expect(requests.activePendingQuestionIndex).toBe(index === 0 ? 0 : 1); + expect(requests.activePendingDraftAnswers["question-0"]?.customAnswer).toBe( + index === 0 ? undefined : `Answer ${index}`, + ); + } +}); diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index f1aa3c081412..88e8f82dc0bd 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -16,7 +16,15 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { useEffect, useRef, useState, type RefObject, type SetStateAction } from "react"; +import { create } from "zustand"; +import { + useLayoutEffect, + useEffect, + useRef, + useState, + type RefObject, + type SetStateAction, +} from "react"; import { useShallow } from "zustand/react/shallow"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; @@ -26,6 +34,8 @@ import { carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, projectPendingUserInputAnswer, + pendingUserInputRequestKey, + pruneResolvedUserInputDrafts, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, @@ -48,6 +58,10 @@ import { useOrchestrationCommand } from "../../state/use-orchestration-command"; import type { ChatComposerHandle } from "./ChatComposer"; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const usePendingUserInputAnswers = create< + Record> +>(() => ({})); +const usePendingUserInputQuestionIndex = create>(() => ({})); const NO_PENDING_REQUESTS = { approvals: [], userInputs: [] } as const; const NO_PENDING_APPROVALS: ReturnType = []; const NO_PENDING_USER_INPUTS: ReturnType = []; @@ -94,7 +108,6 @@ function makePendingCustomAnswerSetter(input: { export function usePendingThreadRequests(input: { readonly environmentId: EnvironmentId; readonly activeThreadId: ThreadId | null; - // The effect that prunes stale question drafts re-runs when this object changes. readonly activeThread: { readonly id: ThreadId } | undefined; readonly serverProjection: OrchestrationV2ThreadProjection | null; readonly supportsQuestionAttachments: boolean; @@ -130,13 +143,13 @@ export function usePendingThreadRequests(input: { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< RuntimeRequestId[] >([]); - const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< - Record> - >({}); - const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = - useState>({}); + const setPendingUserInputAnswersByRequestId = usePendingUserInputAnswers.setState; + const setPendingUserInputQuestionIndexByRequestId = usePendingUserInputQuestionIndex.setState; + const projectedThreadId = serverProjection?.thread.id; const pendingRequests = - serverProjection === null ? NO_PENDING_REQUESTS : derivePendingThreadRequests(serverProjection); + serverProjection !== null && serverProjection.thread.id === activeThreadId + ? derivePendingThreadRequests(serverProjection) + : NO_PENDING_REQUESTS; // The projection changes on every streamed event, so shared empties keep the // composer and approval panel from re-rendering while nothing is pending. const pendingApprovals = @@ -148,24 +161,25 @@ export function usePendingThreadRequests(input: { ? NO_PENDING_USER_INPUTS : derivePendingUserInputs(pendingRequests.userInputs); const activePendingUserInput = pendingUserInputs[0] ?? null; - const activePendingRequestKey = JSON.stringify([ + const activePendingRequestKey = pendingUserInputRequestKey( environmentId, activeThreadId, activePendingUserInput?.requestId, - ]); - const activePendingAnswerDrafts = - pendingUserInputAnswersByRequestId[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; - if ( - activePendingUserInput && - seedUserInputDraftAnswers(activePendingUserInput.questions, activePendingAnswerDrafts) !== - activePendingAnswerDrafts - ) { - setPendingUserInputAnswersByRequestId((existing) => { + ); + const activePendingAnswerDrafts = usePendingUserInputAnswers( + (state) => state[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS, + ); + const activePendingQuestionIndex = usePendingUserInputQuestionIndex( + (state) => state[activePendingRequestKey] ?? 0, + ); + useLayoutEffect(() => { + if (!activePendingUserInput) return; + usePendingUserInputAnswers.setState((existing) => { const drafts = existing[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); return seeded === drafts ? existing : { ...existing, [activePendingRequestKey]: seeded }; }); - } + }, [activePendingRequestKey, activePendingUserInput]); const pendingQuestionDraftKeys = activeThreadId ? pendingUserInputs.flatMap((request) => request.questions.map((question) => @@ -203,9 +217,21 @@ export function usePendingThreadRequests(input: { ), ); useEffect(() => { - if (!activeThread) return; + // A thread shell can arrive before its requests; missing detail is not resolution. + if (!activeThread || projectedThreadId !== activeThread.id) return; const questionThread = activeThread; const currentRequests = pendingUserInputs; + const pendingRequestIds = currentRequests.map((request) => request.requestId); + usePendingUserInputAnswers.setState( + (existing) => + pruneResolvedUserInputDrafts(existing, environmentId, questionThread.id, pendingRequestIds), + true, + ); + usePendingUserInputQuestionIndex.setState( + (existing) => + pruneResolvedUserInputDrafts(existing, environmentId, questionThread.id, pendingRequestIds), + true, + ); const prefix = questionAttachmentDraftPrefix(environmentId, questionThread.id); const retained = new Set( currentRequests.flatMap((request) => @@ -227,7 +253,7 @@ export function usePendingThreadRequests(input: { if (key.startsWith(prefix) && !retained.has(DraftId.make(key))) clearQuestionAttachmentDraft(DraftId.make(key)); } - }, [environmentId, activeThread, pendingUserInputs]); + }, [environmentId, activeThread, projectedThreadId, pendingUserInputs]); const activePendingDraftAnswers = !activePendingUserInput || !activeThreadId ? EMPTY_PENDING_USER_INPUT_ANSWERS @@ -244,7 +270,7 @@ export function usePendingThreadRequests(input: { return [ question.id, { - ...pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[question.id], + ...activePendingAnswerDrafts[question.id], attachmentCount: attachments.length, attachmentsBlocked: (attachments.length > 0 && !supportsQuestionAttachments) || @@ -254,9 +280,6 @@ export function usePendingThreadRequests(input: { ]; }), ); - const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingRequestKey] ?? 0) - : 0; const activePendingProgress = activePendingUserInput ? derivePendingUserInputProgress( activePendingUserInput.questions, @@ -413,8 +436,7 @@ export function usePendingThreadRequests(input: { } // The option replaces the custom answer. Anything typed there is the // user's text, so it goes back to the thread draft instead of vanishing. - const displacedAnswer = - pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const displacedAnswer = activePendingAnswerDrafts[questionId]?.customAnswer; const currentPrompt = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index f62bd74fcad6..2b37af07b3e3 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -101,6 +101,35 @@ export function setPendingUserInputCustomAnswer( }; } +export function pendingUserInputRequestKey( + environmentId: string, + threadId: string | null, + requestId: string | undefined, +): string { + return JSON.stringify([environmentId, threadId, requestId]); +} + +/** Drops a thread's drafts for requests that are no longer pending. Unchanged input is returned as is. */ +export function pruneResolvedUserInputDrafts( + draftsByRequestKey: Record, + environmentId: string, + threadId: string, + pendingRequestIds: ReadonlyArray, +): Record { + const threadPrefix = JSON.stringify([environmentId, threadId]).slice(0, -1); + const pendingKeys = new Set( + pendingRequestIds.map((requestId) => + pendingUserInputRequestKey(environmentId, threadId, requestId), + ), + ); + const retained = Object.entries(draftsByRequestKey).filter( + ([key]) => !key.startsWith(threadPrefix) || pendingKeys.has(key), + ); + return retained.length === Object.keys(draftsByRequestKey).length + ? draftsByRequestKey + : Object.fromEntries(retained); +} + const DISPLACED_ANSWER_SEPARATOR = "\n\n"; export function carryDisplacedCustomAnswerIntoPrompt( From d407f5443be225e9ec359cc0dd16fee695d48324 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 05:12:49 +1100 Subject: [PATCH 044/898] fix(web): renumber following items when continuing a plain-Markdown numbered list (#14332) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 Co-authored-by: SAPHID Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ChatComposer.tsx | 11 +- .../src/composer-list-continuation.test.ts | 34 ++++++ apps/web/src/composer-list-continuation.ts | 113 +++++++++++++++--- 3 files changed, 132 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 80b980a76f45..146393fcc8cc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4216,14 +4216,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : listIndentForTab(snapshot.value, selection.start, selection.end); if ( edit && - applyPromptReplacement( - edit.start, - edit.end, - edit.replacement, - key === "Tab" - ? { expandedCursorAfterReplace: selection.start + edit.replacement.length } - : undefined, - ) + applyPromptReplacement(edit.start, edit.end, edit.replacement, { + expandedCursorAfterReplace: edit.cursorAfter, + }) ) { return true; } diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts index a878ed95a125..aeab28a9b360 100644 --- a/apps/web/src/composer-list-continuation.test.ts +++ b/apps/web/src/composer-list-continuation.test.ts @@ -83,3 +83,37 @@ describe("nextOrderedMarkerText", () => { expect(nextOrderedMarkerText(marker)).toBe(expected); }); }); + +describe("composer list renumbering", () => { + it("renumbers following items with their padding and leaves the caret after the inserted marker", () => { + const value = "1. @README.md go\n2. two\n03. three\n10. four"; + const edit = listContinuationForEnter(value, value.indexOf("go"))!; + expect(applyEdit(value, edit)).toBe("1. @README.md \n2. go\n3. two\n04. three\n5. four"); + expect(edit.cursorAfter).toBe("1. @README.md \n2. ".length); + }); + + it("renumbers indented paren siblings across tab-indented children and stops at a parent", () => { + const value = " 1) a\n\t1) child\n 2) b\n1) parent\n 3) untouched"; + const edit = listContinuationForEnter(value, value.indexOf("\n"))!; + expect(applyEdit(value, edit)).toBe( + " 1) a\n 2) \n\t1) child\n 3) b\n1) parent\n 3) untouched", + ); + expect(edit.cursorAfter).toBe(" 1) a\n 2) ".length); + }); + + it.each([ + ["a blank line", ""], + ["plain text", "text"], + ["a different delimiter", "1) other list"], + ])("stops renumbering at %s", (_label, boundary) => { + const value = `1. a\n2. b\n${boundary}\n3. untouched`; + const edit = listContinuationForEnter(value, value.indexOf("\n"))!; + expect(applyEdit(value, edit)).toBe(`1. a\n2. \n3. b\n${boundary}\n3. untouched`); + }); + + it("preserves multiline chip contents while renumbering surrounding items", () => { + const value = '1. a\n2. @"file\n2. name" \n3. b'; + const edit = listContinuationForEnter(value, value.indexOf("\n"))!; + expect(applyEdit(value, edit)).toBe('1. a\n2. \n3. @"file\n2. name" \n4. b'); + }); +}); diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 7ebb943ad49c..4cbf52aab7ff 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -4,6 +4,8 @@ export interface ComposerListEdit { start: number; end: number; replacement: string; + /** Expanded caret offset after the edit is applied. */ + cursorAfter: number; } type ListMarker = @@ -64,21 +66,80 @@ function nextMarkerText(marker: ListMarker): string { return `${marker.indent}${marker.bullet} `; } -function segmentSource( - segment: ReturnType[number], -): string { - if (segment.type === "text") return segment.text; - return segment.source; +// Markdown tabs advance to the next four-column stop. +function indentWidth(line: string): number { + let width = 0; + for (const char of line) { + if (char === " ") width += 1; + else if (char === "\t") width += 4 - (width % 4); + else break; + } + return width; +} + +function renumberFollowingItems( + value: string, + afterLineEnd: number, + marker: Extract, + tokens: ReturnType, +): { end: number; text: string } | null { + const baseWidth = indentWidth(marker.indent); + const parts: string[] = []; + let expected = Number.parseInt(marker.numberText, 10) + 2; + let position = afterLineEnd; + let copiedUntil = afterLineEnd; + let tokenIndex = 0; + while (position < value.length && Number.isSafeInteger(expected)) { + const lineStart = position + 1; + const nextBreak = value.indexOf("\n", lineStart); + const lineEnd = nextBreak === -1 ? value.length : nextBreak; + position = lineEnd; + let token = tokens[tokenIndex]; + while (token && token.end <= lineStart) { + tokenIndex += 1; + token = tokens[tokenIndex]; + } + // A newline inside a chip does not start a list item. + if (token && token.start < lineStart) continue; + + const line = value.slice(lineStart, lineEnd); + const width = indentWidth(line); + if (line.trim() === "" || width < baseWidth) break; + if (width > baseWidth) continue; + const parsed = parseListMarker(line); + if (parsed?.marker.kind !== "ordered" || parsed.marker.delimiter !== marker.delimiter) break; + + const current = parsed.marker; + const numberStart = lineStart + current.indent.length; + const numberEnd = numberStart + current.numberText.length; + const replacement = current.numberText.startsWith("0") + ? String(expected).padStart(current.numberText.length, "0") + : String(expected); + if (replacement !== current.numberText) { + parts.push(value.slice(copiedUntil, numberStart), replacement); + copiedUntil = numberEnd; + } + expected += 1; + } + return parts.length === 0 ? null : { end: copiedUntil, text: parts.join("") }; } -function isInsideInlineToken(value: string, cursor: number): boolean { +function inlineTokenRanges(value: string): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; let offset = 0; for (const segment of splitPromptIntoComposerSegments(value)) { - const end = offset + segmentSource(segment).length; - if (segment.type !== "text" && cursor > offset && cursor < end) return true; + const end = offset + (segment.type === "text" ? segment.text : segment.source).length; + if (segment.type !== "text") ranges.push({ start: offset, end }); offset = end; } - return false; + return ranges; +} + +function isInsideInlineToken( + tokens: ReturnType, + cursor: number, +): boolean { + return tokens.some((range) => cursor > range.start && cursor < range.end); } function currentLine(value: string, cursor: number): { start: number; end: number; text: string } { @@ -95,15 +156,31 @@ export function listContinuationForEnter(value: string, cursor: number): Compose if (!parsed) return null; const markerEnd = line.start + parsed.markerEnd; if (cursor < markerEnd) return null; - if (isInsideInlineToken(value, cursor)) return null; + const tokens = inlineTokenRanges(value); + if (isInsideInlineToken(tokens, cursor)) return null; if (value.slice(markerEnd, line.end).trim() === "") { - return { start: line.start, end: Math.max(cursor, markerEnd), replacement: "" }; + return { + start: line.start, + end: Math.max(cursor, markerEnd), + replacement: "", + cursorAfter: line.start, + }; + } + const insertion = `\n${nextMarkerText(parsed.marker)}`; + const cursorAfter = cursor + insertion.length; + const { marker } = parsed; + if (marker.kind === "ordered") { + const renumbered = renumberFollowingItems(value, line.end, marker, tokens); + if (renumbered) { + return { + start: cursor, + end: renumbered.end, + replacement: insertion + value.slice(cursor, line.end) + renumbered.text, + cursorAfter, + }; + } } - return { - start: cursor, - end: cursor, - replacement: `\n${nextMarkerText(parsed.marker)}`, - }; + return { start: cursor, end: cursor, replacement: insertion, cursorAfter }; } export function listIndentForTab( @@ -114,6 +191,6 @@ export function listIndentForTab( if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null; const line = currentLine(value, start); if (!parseListMarker(line.text)) return null; - if (isInsideInlineToken(value, start)) return null; - return { start: line.start, end: line.start, replacement: " " }; + if (isInsideInlineToken(inlineTokenRanges(value), start)) return null; + return { start: line.start, end: line.start, replacement: " ", cursorAfter: start + 2 }; } From 10b86829e8f2e1e718f6cc6e76a1a01a66d25b2c Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:13:10 +0200 Subject: [PATCH 045/898] fix(web): clicking a logged request route no longer opens it in the editor (#15838) Co-authored-by: Julius Marminge --- apps/web/src/terminal-links.test.ts | 31 +++++++++++++++++++++++++++++ apps/web/src/terminal-links.ts | 13 +++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index a148931e2db6..a4e12cd12265 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -171,6 +171,37 @@ describe("extractTerminalLinks", () => { { kind: "path", text, start, end: start + text.length }, ]); }); + + it.each([ + " GET /api/trpc/post.list,user.me?batch=1&input=%7B%220%22%3A%7B%22json%22%3Anull%7D%7D 200 in 35ms", + "GET /api?+debug 200", + 'Started GET "/posts?page=2" for ::1', + "proxying api/trpc/post.list?batch=1 to the backend", + ])("does not turn a logged request route into an editor link: %s", (line) => { + expect(extractTerminalLinks(line)).toEqual([]); + }); + + it("keeps file and URL links before and after a logged request route", () => { + const before = "src/server.ts:12"; + const after = "/app/bin/server:3:7"; + const url = "https://example.com/api/users?id=42"; + const line = `${before} GET /api/users?id=42 200 ${after} ${url}`; + const afterStart = line.indexOf(after); + const urlStart = line.indexOf(url); + + expect(extractTerminalLinks(line)).toEqual([ + { kind: "path", text: before, start: 0, end: before.length }, + { kind: "path", text: after, start: afterStart, end: afterStart + after.length }, + { kind: "url", text: url, start: urlStart, end: urlStart + url.length }, + ]); + }); + + it("keeps a question mark inside a filesystem path", () => { + const path = "/tmp/report?draft"; + expect(extractTerminalLinks(`wrote ${path}`)).toEqual([ + { kind: "path", text: path, start: 6, end: 6 + path.length }, + ]); + }); }); describe("collectWrappedTerminalLinkLine", () => { diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 628d7ab2131a..dd46062ac772 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -34,6 +34,9 @@ const QUOTED_FILE_PATH_PATTERN = const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; // Paths also drop a trailing colon: compilers end `file:line:col:` with one. const TRAILING_PATH_PUNCTUATION_PATTERN = /[.,;:!?]+$/; +// A question mark is legal in filenames; only exclude query-bearing request targets. +const REQUEST_ROUTE_PATTERN = + /(?:^|[\s"'`])(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE|proxying)\s+["'`]?(?:\/|[A-Za-z0-9_-]+\/)[^\s?]*\?\S*/g; function trimClosingDelimiters(value: string, kind: TerminalLinkKind): string { let output = value.replace( @@ -99,13 +102,21 @@ function collectMatches( } export function extractTerminalLinks(line: string): TerminalLinkMatch[] { + const requestRoutes = Array.from(line.matchAll(REQUEST_ROUTE_PATTERN), (match) => ({ + start: match.index, + end: match.index + match[0].length, + })); const urlMatches = collectMatches(line, "url", URL_PATTERN, []); const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, urlMatches); const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, [ ...urlMatches, ...quotedMatches, ]); - return [...urlMatches, ...quotedMatches, ...pathMatches].toSorted((a, b) => a.start - b.start); + return [...urlMatches, ...quotedMatches, ...pathMatches] + .filter( + (match) => match.kind === "url" || !requestRoutes.some((route) => overlaps(match, route)), + ) + .toSorted((a, b) => a.start - b.start); } export function isTerminalUrl(value: string): boolean { From 710e2255051c4e2b187a951533f840621c5ee4a5 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 19 Oct 2026 18:13:18 +0000 Subject: [PATCH 046/898] fix(desktop): reject offscreen annotation screenshot crops (#18441) Co-authored-by: Julius Marminge --- apps/desktop/src/preview/Manager.test.ts | 246 ++++++++++++++++++----- apps/desktop/src/preview/Manager.ts | 55 +++-- apps/desktop/src/preview/PickPreload.ts | 15 +- 3 files changed, 239 insertions(+), 77 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index f059d672b1f2..3c297773049b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -4,6 +4,8 @@ import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewRecordingFrame, DesktopPreviewRecordingInputEvent, + PreviewAnnotationPayload, + PreviewAnnotationRect, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; @@ -4359,68 +4361,208 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("crops the picked area out of a full-page capture at device scale", () => - withManager((manager) => - Effect.gen(function* () { - let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; - const crop = vi.fn((rect: Electron.Rectangle) => ({ - getSize: () => ({ width: rect.width, height: rect.height }), - toDataURL: () => "data:image/png;base64,cropped", - })); - const capturePage = vi.fn(async () => ({ - getSize: () => ({ width: 2000, height: 1200 }), - crop, - })); - fromId.mockReturnValue( - Object.assign(makeTestPreviewWebContents(capturePage as never), { - isFocused: () => true, - once: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof onPicked) => { - if (channel === "preview:element-picked") onPicked = listener; - }), - off: vi.fn(), - removeListener: vi.fn(), + describe("annotation screenshots", () => { + const captureAnnotation = ( + cropRect: PreviewAnnotationRect | null, + targets: Partial> = {}, + emptyImage?: "viewport" | "crop", + ) => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + const crop = vi.fn((rect: Electron.Rectangle) => ({ + isEmpty: () => emptyImage === "crop", + getSize: () => + emptyImage === "crop" + ? { width: 0, height: 0 } + : { width: rect.width, height: rect.height }, + toDataURL: () => (emptyImage === "crop" ? "data:," : "data:image/png;base64,cropped"), + })); + const capturePage = vi.fn(async () => ({ + isEmpty: () => emptyImage === "viewport", + getSize: () => + emptyImage === "viewport" ? { width: 0, height: 0 } : { width: 2000, height: 1200 }, + toJPEG: () => Buffer.from("unused"), + toDataURL: () => (emptyImage === "viewport" ? "data:," : "data:image/png;base64,full"), + crop, + })); + fromId.mockReturnValue( + Object.assign(makeTestPreviewWebContents(capturePage), { + isFocused: () => true, + once: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, + }), + ); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + onPicked?.( + {}, + { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + ...targets, + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", }, - }), - ); + cropRect, + "attach", + 2, + ); - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - onPicked?.( - {}, - { - id: "annotation_1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Tighten this spacing", - elements: [], - regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], - strokes: [], - styleChanges: [], - screenshot: null, - createdAt: "2026-06-11T00:00:00.000Z", - }, - { x: 5, y: 6, width: 20, height: 30 }, - "attach", - 2, - ); + const result = yield* Fiber.join(pick); + expect(capturePage).toHaveBeenCalledExactlyOnceWith(); + expect(result?.annotation.comment).toBe("Tighten this spacing"); + expect(result?.submission).toBe("attach"); + return { result, crop }; + }), + ); - const result = yield* Fiber.join(pick); - expect(capturePage).toHaveBeenCalledWith(); - expect(crop).toHaveBeenCalledWith({ x: 10, y: 12, width: 40, height: 60 }); + effectIt.effect("crops an on-screen region at device scale", () => + Effect.gen(function* () { + const rect = { x: 5, y: 6, width: 20, height: 30 }; + const { result, crop } = yield* captureAnnotation(rect, { + regions: [{ id: "region_1", rect }], + }); + expect(crop).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 12, width: 40, height: 60 }); expect(result?.screenshotFailed).toBeUndefined(); expect(result?.annotation.screenshot).toEqual({ dataUrl: "data:image/png;base64,cropped", width: 40, height: 60, - cropRect: { x: 5, y: 6, width: 20, height: 30 }, + cropRect: rect, }); }), - ), - ); + ); + + effectIt.effect("clips a partially visible selection to the captured viewport", () => + Effect.gen(function* () { + const { result, crop } = yield* captureAnnotation( + { x: -40, y: 570, width: 240, height: 110 }, + { regions: [{ id: "region_1", rect: { x: -20, y: 590, width: 200, height: 70 } }] }, + ); + expect(crop).toHaveBeenCalledExactlyOnceWith({ x: 0, y: 1140, width: 400, height: 60 }); + expect(result?.screenshotFailed).toBeUndefined(); + expect(result?.annotation.screenshot).toMatchObject({ + dataUrl: "data:image/png;base64,cropped", + width: 400, + height: 60, + }); + }), + ); + + effectIt.effect.each([ + { + name: "an offscreen crop without individual target bounds", + cropRect: { x: 1010, y: 40, width: 100, height: 70 }, + regions: [], + }, + { + name: "padding overlapping the viewport while its target is offscreen", + cropRect: { x: 40, y: -100, width: 240, height: 110 }, + regions: [{ id: "region_1", rect: { x: 60, y: -80, width: 200, height: 70 } }], + }, + { + name: "a union spanning the viewport with both targets offscreen", + cropRect: { x: -230, y: 40, width: 1460, height: 110 }, + regions: [ + { id: "region_1", rect: { x: -210, y: 60, width: 200, height: 70 } }, + { id: "region_2", rect: { x: 1010, y: 60, width: 200, height: 70 } }, + ], + }, + { + name: "an offscreen target when crop bounds are missing", + cropRect: null, + regions: [{ id: "region_1", rect: { x: 60, y: -80, width: 200, height: 70 } }], + }, + ])("does not attach $name", ({ cropRect, regions }) => + Effect.gen(function* () { + const { result, crop } = yield* captureAnnotation(cropRect, { regions }); + expect(crop).not.toHaveBeenCalled(); + expect(result?.annotation.screenshot).toBeNull(); + expect(result?.screenshotFailed).toBe(true); + }), + ); + + effectIt.effect.each([ + { + name: "zero-width SVG line on the left edge", + rect: { x: 0, y: 60, width: 0, height: 70 }, + cropRect: { x: -20, y: 40, width: 40, height: 110 }, + expectedCrop: { x: 0, y: 80, width: 40, height: 220 }, + }, + { + name: "zero-height SVG line on the top edge", + rect: { x: 60, y: 0, width: 70, height: 0 }, + cropRect: { x: 40, y: -20, width: 110, height: 40 }, + expectedCrop: { x: 80, y: 0, width: 220, height: 40 }, + }, + ])("captures a stroked $name", ({ rect, cropRect, expectedCrop }) => + Effect.gen(function* () { + const { result, crop } = yield* captureAnnotation(cropRect, { + elements: [ + { + id: "element_1", + rect, + element: { + pageUrl: "https://example.com", + pageTitle: "Example", + tagName: "line", + selector: "svg > line", + htmlPreview: '', + componentName: null, + source: null, + stack: [], + styles: "stroke: black; stroke-width: 2px;", + pickedAt: "2026-06-11T00:00:00.000Z", + }, + }, + ], + }); + expect(crop).toHaveBeenCalledExactlyOnceWith(expectedCrop); + expect(result?.screenshotFailed).toBeUndefined(); + expect(result?.annotation.screenshot).toMatchObject({ + dataUrl: "data:image/png;base64,cropped", + width: expectedCrop.width, + height: expectedCrop.height, + }); + }), + ); + + effectIt.effect.each(["viewport", "crop"] as const)( + "reports an empty native %s image as a failed screenshot", + (emptyImage) => + Effect.gen(function* () { + const rect = { x: 5, y: 6, width: 20, height: 30 }; + const { result, crop } = yield* captureAnnotation( + rect, + { + regions: [{ id: "region_1", rect }], + }, + emptyImage, + ); + if (emptyImage === "viewport") expect(crop).not.toHaveBeenCalled(); + else + expect(crop).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 12, width: 40, height: 60 }); + expect(result?.annotation.screenshot).toBeNull(); + expect(result?.screenshotFailed).toBe(true); + }), + ); + }); effectIt.effect("a stale capture from a replaced pick never touches the next pick", () => withManager((manager) => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 56b9d960369c..daae94c0952f 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -270,24 +270,35 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { ) { return null; } - return { - x: Math.max(0, Math.floor(x)), - y: Math.max(0, Math.floor(y)), - width: Math.max(1, Math.ceil(width)), - height: Math.max(1, Math.ceil(height)), - }; + return { x, y, width, height }; }; -/** - * Crops a full-page capture to a picked annotation. `cropRect` is in CSS px; - * the capture's pixels are CSS px × the guest's `devicePixelRatio`. - */ +// capturePage returns a 1x NativeImage containing device pixels, so crop() uses +// CSS coordinates scaled by the guest's devicePixelRatio (including page zoom). const cropAnnotationScreenshot = ( image: Electron.NativeImage, cropRect: PreviewAnnotationRect | null, devicePixelRatio: number, + targetRects: ReadonlyArray, ): PreviewAnnotationPayload["screenshot"] => { const full = image.getSize(); + if (full.width <= 0 || full.height <= 0) return null; + const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; + // Padding or a union of offscreen targets can overlap an empty viewport area. + // A zero-width/height SVG line can still paint at the top or left edge. + if ( + targetRects.length > 0 && + !targetRects.some( + (rect) => + (rect.width > 0 || rect.height > 0) && + rect.x * scale < full.width && + rect.y * scale < full.height && + (rect.width === 0 ? rect.x >= 0 : rect.x + rect.width > 0) && + (rect.height === 0 ? rect.y >= 0 : rect.y + rect.height > 0), + ) + ) { + return null; + } if (!cropRect) { return { dataUrl: image.toDataURL(), @@ -296,16 +307,21 @@ const cropAnnotationScreenshot = ( cropRect: { x: 0, y: 0, width: full.width, height: full.height }, }; } - const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; - const x = Math.min(full.width - 1, Math.max(0, Math.floor(cropRect.x * scale))); - const y = Math.min(full.height - 1, Math.max(0, Math.floor(cropRect.y * scale))); + const x = Math.max(0, Math.floor(cropRect.x * scale)); + const y = Math.max(0, Math.floor(cropRect.y * scale)); + const right = Math.min(full.width, Math.ceil((cropRect.x + cropRect.width) * scale)); + const bottom = Math.min(full.height, Math.ceil((cropRect.y + cropRect.height) * scale)); + // A target scrolled out of view has no pixels in this capture. Clamping its + // origin onto the edge would attach an unrelated strip as a successful crop. + if (right <= x || bottom <= y) return null; const cropped = image.crop({ x, y, - width: Math.max(1, Math.min(full.width - x, Math.ceil(cropRect.width * scale))), - height: Math.max(1, Math.min(full.height - y, Math.ceil(cropRect.height * scale))), + width: right - x, + height: bottom - y, }); const size = cropped.getSize(); + if (size.width <= 0 || size.height <= 0) return null; return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; }; @@ -2220,13 +2236,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const submission = args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach"; const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1; + const targetRects = [ + ...payload.elements.map((target) => target.rect), + ...payload.regions.map((target) => target.rect), + ...payload.strokes.map((target) => target.bounds), + ]; runFork( capturePageWithRetry( { operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id }, tabId, wc, ).pipe( - Effect.map((image) => cropAnnotationScreenshot(image, cropRect, devicePixelRatio)), + Effect.map((image) => + cropAnnotationScreenshot(image, cropRect, devicePixelRatio, targetRects), + ), Effect.tapError((error) => Effect.logWarning("preview annotation screenshot failed").pipe( Effect.annotateLogs({ tabId, webContentsId: wc.id, error: error.message }), diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index a1df630de4e9..e49d27d40ba9 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -252,15 +252,12 @@ function unionRects( const top = Math.min(...rects.map((rect) => rect.y)); const right = Math.max(...rects.map((rect) => rect.x + rect.width)); const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); - const x = Math.max(0, left - padding); - const y = Math.max(0, top - padding); - const maxWidth = Math.max(1, window.innerWidth - x); - const maxHeight = Math.max(1, window.innerHeight - y); + // Main clips to the captured viewport; clipping here would lose offscreen bounds. return { - x, - y, - width: Math.min(maxWidth, right - left + padding * 2), - height: Math.min(maxHeight, bottom - top + padding * 2), + x: left - padding, + y: top - padding, + width: right - left + padding * 2, + height: bottom - top + padding * 2, }; } @@ -1439,7 +1436,7 @@ function startAnnotation(sendEnabled: boolean): void { annotation, screenshotRect, submission === "send" && !sendEnabled ? "attach" : submission, - // Main crops a full-page capture, whose pixels are CSS px × this. + // Main crops a viewport capture, whose pixels are CSS px × this. window.devicePixelRatio, ); }) From d2c86c7fa50da60c1c272b76572e79cad794567e Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 19 Oct 2026 14:14:00 -0400 Subject: [PATCH 047/898] fix(web): NVDA browse mode reaches the new-thread project picker again (#17877) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/web/src/components/chat/DraftHeroHeadline.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index b5fe43363029..55e0a3bf1e09 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -314,14 +314,6 @@ export function DraftHeroHeadline({ ); - const headingLabel = isScratchDraft - ? "What should we work on?" - : hasResolvedProject - ? `What should we build in ${activeProjectDisplayName}?` - : canChooseProject - ? `${activeProjectDisplayName ?? "Choose a project"} to start` - : "Add a project to start"; - const noProjectShortcut = shortcutLabelForCommand(keybindings, "chat.newWithoutProject"); const orStartWithoutProject = scratchWorkspaceRoot !== null && !isScratchDraft && (hasResolvedProject || canChooseProject) ? ( @@ -349,10 +341,8 @@ export function DraftHeroHeadline({ return (
-

+ {/* An explicit heading label hides the inline project picker in NVDA browse mode. */} +

{isScratchDraft ? ( <>What should we work on? ) : hasResolvedProject ? ( From 0d2382950bd369ef86d605b7b257ab0226355741 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:14:18 -0700 Subject: [PATCH 048/898] fix(web): stop composer banner glass overlap (#8034) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> Co-authored-by: maria Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 3 + .../src/components/chat/MessagesTimeline.tsx | 105 ++++++++++-------- apps/web/src/index.css | 10 ++ 3 files changed, 74 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 19455dfc48f3..fd28d2dd095a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1356,6 +1356,7 @@ export default function ChatView(props: ChatViewProps) { const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerTimelineInset, setComposerTimelineInset] = useState(0); + const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const composerTimelineInsetRef = useRef(0); const composerRestingRef = useRef(false); const composerMountedRef = useRef(true); @@ -4678,6 +4679,7 @@ export default function ChatView(props: ChatViewProps) { const publishComposerOverlayHeight = (height: number) => { const nextHeight = Math.ceil(height); if (nextHeight <= 0) return; + setComposerOverlayHeight(isDraftHeroState ? 0 : nextHeight); const isResting = overlayComposerIsResting({ composerMounted: composerMountedRef.current, composerReportedResting: composerRestingRef.current, @@ -8072,6 +8074,7 @@ export default function ChatView(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReadyStable} onAnchorSizeChanged={onTimelineAnchorSizeChangedStable} contentInsetEndAdjustment={composerTimelineInset} + composerOverlayHeight={isDraftHeroState ? 0 : composerOverlayHeight} liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChangeStable} onContentOverflowChange={setTimelineOverflows} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 02ba86e463df..2e742370c42d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -84,6 +84,7 @@ import { type KeyboardEvent, type MouseEvent, type ReactNode, + type CSSProperties, } from "react"; import { LegendList, @@ -506,6 +507,7 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; + composerOverlayHeight?: number; onIsAtEndChange: (isAtEnd: boolean) => void; /** * Off while the user is reading history; LegendList's own maintainScrollAtEnd @@ -602,6 +604,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + composerOverlayHeight = 0, onIsAtEndChange, onContentOverflowChange, liveFollowEnabled, @@ -1090,6 +1093,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ {footer} ); + const timelineMaskStyle = + composerOverlayHeight === 0 + ? undefined + : ({ + "--timeline-composer-overlay-height": `${composerOverlayHeight}px`, + } as CSSProperties); const measureContentOverflow = () => timelineContentOverflowsViewport(listRef.current?.getState?.(), { @@ -1471,52 +1480,60 @@ const ConversationTimeline = memo(function ConversationTimeline({ onCite={onCiteAssistantText} /> ) : null} - - ref={setTimelineList} - data={rows} - extraData={listExtraData} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd={ - !findActive && citationRequest === null && rememberedPosition?.atEnd !== false - } - // Legend needs a data refresh to mount new pins without a scroll event. - dataVersion={readyCitationRequest?.key ?? listIdentityKey} - {...(alwaysRender ? { alwaysRender } : {})} - onLoad={handleListLoad} - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} - maintainScrollAtEnd={ - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) || - anchoredEndSpace || - !liveFollowEnabled || - fullscreenAppRowId !== null || - disclosureToggleSettling - ? false - : isWorking && !prefersReducedMotion && settlingListIdentity === null - ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH - : TIMELINE_MAINTAIN_SCROLL_AT_END - } - maintainVisibleContentPosition={ - findActive || - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) - ? false - : maintainVisibleContentPosition - } - maintainScrollAtEndThreshold={1} - onScroll={handleScroll} - onItemSizeChanged={handleItemSizeChanged} +
0 && "virtualized-scroll-fade timeline-scroll-bottom-fade", )} - ListHeaderComponent={listHeader} - ListFooterComponent={timelineListFooter} - /> + style={timelineMaskStyle} + > + + ref={setTimelineList} + data={rows} + extraData={listExtraData} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd={ + !findActive && citationRequest === null && rememberedPosition?.atEnd !== false + } + // Legend needs a data refresh to mount new pins without a scroll event. + dataVersion={readyCitationRequest?.key ?? listIdentityKey} + {...(alwaysRender ? { alwaysRender } : {})} + onLoad={handleListLoad} + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} + maintainScrollAtEnd={ + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) || + anchoredEndSpace || + !liveFollowEnabled || + fullscreenAppRowId !== null || + disclosureToggleSettling + ? false + : isWorking && !prefersReducedMotion && settlingListIdentity === null + ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END + } + maintainVisibleContentPosition={ + findActive || + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) + ? false + : maintainVisibleContentPosition + } + maintainScrollAtEndThreshold={1} + onScroll={handleScroll} + onItemSizeChanged={handleItemSizeChanged} + className={cn( + "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", + topFadeEnabled && "topbar-scroll-fade", + )} + ListHeaderComponent={listHeader} + ListFooterComponent={timelineListFooter} + /> +
Date: Mon, 19 Oct 2026 15:15:29 -0300 Subject: [PATCH 049/898] fix(web): keep pasted decorators as text (#8415) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge --- .../shared/src/composerInlineTokens.test.ts | 26 +++++++++++++++++-- packages/shared/src/composerInlineTokens.ts | 7 ++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 245157a3a303..1025fc770412 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -125,8 +125,30 @@ describe("collectComposerInlineTokens", () => { expect(collectComposerInlineTokens("入力 @expo/ui を追加")).toEqual([]); }); - it("keeps bare non-scoped file paths as mentions", () => { - expect(collectComposerInlineTokens("Inspect @README.md next")).toEqual([ + it.each(["@namespace.foo(bar)", "@écrire(option)", "@e\u0301crire(option)"])( + "keeps decorator call %s as plain text", + (reference) => { + expect(collectComposerInlineTokens(`${reference}\nclass Example {}`)).toEqual([]); + }, + ); + + it.each(['@"foo(bar)"', "[foo(bar)](foo%28bar%29)"])( + "keeps an explicit decorator-shaped file reference as a mention: %s", + (reference) => { + expect(collectComposerInlineTokens(`${reference} `)).toEqual([ + { + type: "mention", + value: "foo(bar)", + source: reference, + start: 0, + end: reference.length, + }, + ]); + }, + ); + + it("keeps bare file mentions before whitespace-separated parentheses", () => { + expect(collectComposerInlineTokens("Inspect @README.md (notes)")).toEqual([ { type: "mention", value: "README.md", diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index 90051d18e8f7..d54b39317e86 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -40,6 +40,7 @@ const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; const SCOPED_PACKAGE_REFERENCE_REGEX = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/; +const DECORATOR_CALL_REFERENCE_REGEX = /^[\p{ID_Start}_$][\p{ID_Continue}.$\u200c\u200d]*\(/u; function collectMentionTokens(text: string): ComposerInlineToken[] { const matches: ComposerInlineToken[] = []; @@ -75,7 +76,11 @@ function collectMentionTokens(text: string): ComposerInlineToken[] { const prefix = match[1] ?? ""; const quotedPath = match[2]; const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); - if (!path || (quotedPath === undefined && SCOPED_PACKAGE_REFERENCE_REGEX.test(path))) { + if ( + !path || + (quotedPath === undefined && + (SCOPED_PACKAGE_REFERENCE_REGEX.test(path) || DECORATOR_CALL_REFERENCE_REGEX.test(path))) + ) { continue; } const start = (match.index ?? 0) + prefix.length; From 7f63b10275103541660c1269c72234a16192f898 Mon Sep 17 00:00:00 2001 From: Louis Deconinck Date: Mon, 19 Oct 2026 20:21:15 +0200 Subject: [PATCH 050/898] fix(shell): avoid interactive job control in environment probes (#11275) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge --- packages/shared/src/shell.test.ts | 7 ++++++- packages/shared/src/shell.ts | 11 ++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 38c305ac1166..8343a58b8aed 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -87,7 +87,12 @@ describe("readEnvironmentFromLoginShell probe", () => { expect(args?.[1]).toContain("printenv PATH || true"); expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__"); expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__"); - expect(options).toEqual({ encoding: "utf8", timeout: 5000 }); + expect(options).toEqual({ + encoding: "utf8", + timeout: 5000, + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }); }); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 1e0a1fecf2f4..983ba0598eb9 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -20,7 +20,12 @@ const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; type ExecFileSyncLike = ( file: string, args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, + options: { + encoding: "utf8"; + timeout: number; + detached?: true; + stdio?: ["ignore", "pipe", "ignore"]; + }, ) => string; function canExecuteFile(filePath: string): boolean { @@ -268,9 +273,13 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( return {}; } + // Interactive startup loads PATH entries; detaching prevents job control on an + // inherited controlling TTY from stopping the probe while the parent waits. const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], { encoding: "utf8", timeout: 5000, + detached: true, + stdio: ["ignore", "pipe", "ignore"], }); const environment: Partial> = {}; From 7400bc574851fea53655e6e481993fe0ac655311 Mon Sep 17 00:00:00 2001 From: IlyaL Date: Mon, 19 Oct 2026 11:23:57 -0700 Subject: [PATCH 051/898] fix(web): a closed proactive diff stays closed when you return to a thread (#14705) Co-authored-by: Julius Marminge --- .../web/src/components/ChatView.logic.test.ts | 16 +- apps/web/src/components/ChatView.logic.ts | 5 - apps/web/src/hooks/useProactivePanels.ts | 9 +- apps/web/src/rightPanelStore.test.ts | 146 +++++++++++++++++- apps/web/src/rightPanelStore.ts | 41 +++-- 5 files changed, 182 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b4cc66e2b2e7..ab1e9b7adc54 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1744,7 +1744,6 @@ describe("proactive completed diff guard", () => { resolveProactiveTurnDiffAction({ checkpoint: changedCheckpoint, isGitRepo: true, - activeSurfaceKind: null, }), ).toBe(action); }, @@ -1764,41 +1763,37 @@ describe("proactive completed diff guard", () => { resolveProactiveTurnDiffAction({ checkpoint: undefined, isGitRepo: true, - activeSurfaceKind: null, }), ).toBe("defer"); expect( resolveProactiveTurnDiffAction({ checkpoint: missingCheckpoint, isGitRepo: true, - activeSurfaceKind: null, }), ).toBe("defer"); expect( resolveProactiveTurnDiffAction({ checkpoint: changedCheckpoint, isGitRepo: undefined, - activeSurfaceKind: null, }), ).toBe("defer"); }); - it("keeps an active pull request above a completed turn diff", () => { + it("delegates a qualifying completed diff to the store while a pull request is active", () => { const changedCheckpoint = { status: "ready", - files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }], + files: [{ path: "src/app.ts", kind: "modified", additions: 50, deletions: 0 }], } satisfies Pick; expect( resolveProactiveTurnDiffAction({ checkpoint: changedCheckpoint, isGitRepo: true, - activeSurfaceKind: "pull-request", }), - ).toBe("ignore"); + ).toBe("open"); }); - it("leaves an already open diff and its chosen scope alone", () => { + it("delegates a qualifying completion to the store while a diff is already open", () => { const largeCheckpoint = { status: "ready", files: Array.from({ length: 3 }, (_, index) => ({ @@ -1813,9 +1808,8 @@ describe("proactive completed diff guard", () => { resolveProactiveTurnDiffAction({ checkpoint: largeCheckpoint, isGitRepo: true, - activeSurfaceKind: "diff", }), - ).toBe("ignore"); + ).toBe("open"); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f14ce8cf71ab..567ff7cc1634 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -181,12 +181,7 @@ export function shouldOpenProactiveTurnDiff(input: { export function resolveProactiveTurnDiffAction(input: { checkpoint: Pick | undefined; isGitRepo: boolean | undefined; - activeSurfaceKind: RightPanelSurface["kind"] | null; }): "defer" | "ignore" | "open" { - // An open diff already shows the work; reopening it would reset the chosen scope. - if (input.activeSurfaceKind === "pull-request" || input.activeSurfaceKind === "diff") { - return "ignore"; - } if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; if (input.isGitRepo === undefined) return "defer"; if ( diff --git a/apps/web/src/hooks/useProactivePanels.ts b/apps/web/src/hooks/useProactivePanels.ts index 6f7e64f3fe31..aaec22ce6eea 100644 --- a/apps/web/src/hooks/useProactivePanels.ts +++ b/apps/web/src/hooks/useProactivePanels.ts @@ -169,7 +169,6 @@ export function useProactivePanels(input: { ? resolveProactiveTurnDiffAction({ checkpoint: completedCheckpoint, isGitRepo, - activeSurfaceKind: openSurface?.kind ?? null, }) : "ignore"; proactivePanelObservationRef.current = { @@ -179,7 +178,13 @@ export function useProactivePanels(input: { diffAction === "defer" || shouldDeferLink ? previousRunningTurnId : activeRunningTurnId, }; if (diffAction !== "open" || newlyCompletedTurnId === null) return; - if (!panels.openProactive(threadRef, { id: "diff", kind: "diff" }, userActionRevision)) { + if ( + !panels.openProactive( + threadRef, + { kind: "diff", turnId: newlyCompletedTurnId }, + userActionRevision, + ) + ) { return; } useDiffPanelStore.getState().selectGitScope(threadRef, "branch"); diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 0492d901b8d8..9bcddc3fab2b 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1,5 +1,5 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { useClosedViewStore } from "./closedViewStore"; @@ -29,6 +29,16 @@ beforeEach(() => { }); }); +async function reloadRightPanelStore() { + const { storage, name } = useRightPanelStore.persist.getOptions(); + const persisted = await storage!.getItem(name!); + expect(persisted).not.toBeNull(); + useRightPanelStore.setState(useRightPanelStore.getInitialState()); + await storage!.setItem(name!, persisted!); + await useRightPanelStore.persist.rehydrate(); + expect(useRightPanelStore.persist.hasHydrated()).toBe(true); +} + describe("rightPanelStore", () => { it("records single and bulk tab closes, newest first", () => { const store = useRightPanelStore.getState(); @@ -208,7 +218,7 @@ describe("rightPanelStore", () => { }, ); - const completedDiff = { id: "diff", kind: "diff" } as const; + const completedDiff = { kind: "diff", turnId: RunId.make("turn-1") } as const; const linkedPullRequest = pullRequestSurface({ projectId: "project-a", repository: "pingdotgg/t3code", @@ -281,9 +291,15 @@ describe("rightPanelStore", () => { expect( store.openProactive(refA, { id: "pull-requests", kind: "pull-requests" }, revision), ).toBe(false); - expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toBe( - chosen, + const { isOpen, activeSurfaceId, surfaces } = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + refA, ); + expect({ isOpen, activeSurfaceId, surfaces }).toEqual({ + isOpen: chosen.isOpen, + activeSurfaceId: chosen.activeSurfaceId, + surfaces: chosen.surfaces, + }); }); it("allows automatic panels for a later turn after a manual choice", () => { @@ -293,7 +309,127 @@ describe("rightPanelStore", () => { expect(store.openProactive(refA, completedDiff, firstTurnRevision)).toBe(false); const nextTurnRevision = store.getUserActionRevision(refA); - expect(store.openProactive(refA, completedDiff, nextTurnRevision)).toBe(true); + expect( + store.openProactive( + refA, + { ...completedDiff, turnId: RunId.make("turn-2") }, + nextTurnRevision, + ), + ).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + }); + + it("offers each completed diff once across dismissal, panel changes, and reload", async () => { + const store = useRightPanelStore.getState(); + expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + + store.close(refA); + await reloadRightPanelStore(); + expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + + store.open(refA, "files"); + store.openFile(refA, "src/app.ts"); + await reloadRightPanelStore(); + expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("file"); + + store.closeAllSurfaces(refA); + await reloadRightPanelStore(); + expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + + const otherEnvironment = scopeThreadRef("env-2" as EnvironmentId, refA.threadId); + for (const ref of [refB, otherEnvironment]) { + expect(store.openProactive(ref, completedDiff, store.getUserActionRevision(ref))).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, ref)).toBe("diff"); + } + + const nextTurn = { ...completedDiff, turnId: RunId.make("turn-2") }; + expect(store.openProactive(refA, nextTurn, store.getUserActionRevision(refA))).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + + const completedWhileOpen = { ...completedDiff, turnId: RunId.make("turn-3") }; + const existingDiff = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + refA, + ); + expect(store.openProactive(refA, completedWhileOpen, store.getUserActionRevision(refA))).toBe( + false, + ); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual( + existingDiff, + ); + store.closeSurface(refA, "diff"); + await reloadRightPanelStore(); + expect(store.openProactive(refA, completedWhileOpen, store.getUserActionRevision(refA))).toBe( + false, + ); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + + // A later storage migration must preserve the dismissal, just like an ordinary reload. + const { storage, name, version } = useRightPanelStore.persist.getOptions(); + const persisted = await storage!.getItem(name!); + await storage!.setItem(name!, { ...persisted!, version: version! - 1 }); + await reloadRightPanelStore(); + expect(store.openProactive(refA, completedWhileOpen, store.getUserActionRevision(refA))).toBe( + false, + ); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + + expect( + store.openProactive( + refA, + { ...completedDiff, turnId: RunId.make("turn-4") }, + store.getUserActionRevision(refA), + ), + ).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + store.closeSurface(refA, "diff"); + store.open(refA, "diff"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + }); + + it("does not retry declined diff offers after manual choices or PR priority clear", async () => { + const store = useRightPanelStore.getState(); + const revision = store.getUserActionRevision(refA); + store.openFile(refA, "src/app.ts"); + expect(store.openProactive(refA, completedDiff, revision)).toBe(false); + await reloadRightPanelStore(); + expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("file"); + + store.closeAllSurfaces(refA); + expect(store.openProactive(refA, completedDiff, store.getUserActionRevision(refA))).toBe(false); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + + const nextTurn = { ...completedDiff, turnId: RunId.make("turn-2") }; + store.openPullRequest(refA, linkedPullRequest); + expect(store.openProactive(refA, nextTurn, store.getUserActionRevision(refA))).toBe(false); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual( + linkedPullRequest, + ); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toEqual([linkedPullRequest]); + await reloadRightPanelStore(); + expect(store.openProactive(refA, nextTurn, store.getUserActionRevision(refA))).toBe(false); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual( + linkedPullRequest, + ); + store.closeSurface(refA, linkedPullRequest.id); + await reloadRightPanelStore(); + expect(store.openProactive(refA, nextTurn, store.getUserActionRevision(refA))).toBe(false); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + + expect( + store.openProactive( + refA, + { ...completedDiff, turnId: RunId.make("turn-3") }, + store.getUserActionRevision(refA), + ), + ).toBe(true); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index efdd2b8cb7fa..38251f409e61 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -3,7 +3,7 @@ import { scopedThreadKey, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { EnvironmentId, RunId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -95,6 +95,7 @@ export interface ThreadRightPanelState { surfaces: RightPanelSurface[]; dismissedDeviceSurfaceIds?: string[]; maximized?: true; + proactiveDiffTurnId?: RunId; } export interface ThreadPanelVisibility { @@ -117,7 +118,9 @@ interface RightPanelStoreState { */ openProactive: ( ref: ScopedThreadRef, - surface: Extract, + request: + | Extract + | { kind: "diff"; turnId: RunId }, expectedUserActionRevision: number, ) => boolean; open: ( @@ -310,7 +313,8 @@ const updateThreadStateMap = ( !next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0 && - !next.dismissedDeviceSurfaceIds?.length + !next.dismissedDeviceSurfaceIds?.length && + next.proactiveDiffTurnId === undefined ) { if (!(threadKey in byThreadKey)) return byThreadKey; const { [threadKey]: _removed, ...rest } = byThreadKey; @@ -545,6 +549,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): { isOpen, surfaces, activeSurfaceId, + ...(typeof validThreadState?.proactiveDiffTurnId === "string" + ? { proactiveDiffTurnId: RunId.make(validThreadState.proactiveDiffTurnId) } + : {}), ...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds) ? { dismissedDeviceSurfaceIds: @@ -593,24 +600,34 @@ export const useRightPanelStore = create()( closeRevisionByThreadKey: {}, getUserActionRevision: (ref) => get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0, - openProactive: (ref, surface, expectedUserActionRevision) => { + openProactive: (ref, request, expectedUserActionRevision) => { let opened = false; set((state) => { const threadKey = scopedThreadKey(ref); - if ( - (state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision - ) { - return state; + if (request.kind === "diff") { + const current = selectThreadRightPanelState(state.byThreadKey, ref); + if (current.proactiveDiffTurnId === request.turnId) return state; + const activePanel = selectActiveRightPanel(state.byThreadKey, ref); + const pullRequestActive = + activePanel === "pull-request" || activePanel === "pull-requests"; + opened = + (state.userActionRevisionByThreadKey[threadKey] ?? 0) === + expectedUserActionRevision && + activePanel !== "diff" && + !pullRequestActive; + // Record the offer even when refused, so a revisit does not retry this turn. + return automaticUpdate(state, threadKey, (thread) => ({ + ...(opened ? upsertSurface(thread, singletonSurface("diff")) : thread), + proactiveDiffTurnId: request.turnId, + })); } if ( - surface.kind === "diff" && - (selectActiveRightPanel(state.byThreadKey, ref) === "pull-request" || - selectActiveRightPanel(state.byThreadKey, ref) === "pull-requests") + (state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision ) { return state; } opened = true; - return automaticUpdate(state, threadKey, (current) => upsertSurface(current, surface)); + return automaticUpdate(state, threadKey, (current) => upsertSurface(current, request)); }); return opened; }, From 3982f20ce48629a215f3c3d7d7e5c0df800a5fbd Mon Sep 17 00:00:00 2001 From: SadriG91 <74971461+SadriG91@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:24:35 +0200 Subject: [PATCH 052/898] fix(web): render powerline separators at cell edges (#5607) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../web/src/terminal/ghostty/renderer.test.ts | 72 +++++++++++++++++++ apps/web/src/terminal/ghostty/renderer.ts | 50 ++++++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts index 6616f45ab489..f666858318af 100644 --- a/apps/web/src/terminal/ghostty/renderer.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -71,6 +71,78 @@ describe("ghosttyTextRunEnd", () => { }); describe("renderGhosttySnapshot", () => { + it("draws solid Powerline separators to their cell edges instead of as text", () => { + const paths: number[][][] = []; + let path: number[][] = []; + const fillTextCalls: unknown[][] = []; + const context = { + canvas: { width: 200, height: 40 }, + beginPath: () => { + path = []; + }, + clip: () => {}, + closePath: () => {}, + fill: () => paths.push(path), + fillRect: () => {}, + fillText: (...args: unknown[]) => fillTextCalls.push(args), + lineTo: (x: number, y: number) => path.push([x, y]), + moveTo: (x: number, y: number) => path.push([x, y]), + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + set fillStyle(_value: string) {}, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const snapshot: GhosttySnapshot = { + cols: 3, + rows: 1, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: -1, + cursorY: -1, + cursorVisible: false, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [ + { + cells: [cell("a"), cell("\ue0b0"), cell("\ue0b2")], + text: "a\ue0b0\ue0b2", + isWrapContinuation: false, + wrapsToNext: false, + }, + ], + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 10, height: 20, baseline: 15 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: false, + }); + + expect(fillTextCalls).toEqual([["a", 4, 19, 10]]); + expect(paths).toEqual([ + [ + [14, 4], + [24, 14], + [14, 24], + ], + [ + [34, 4], + [24, 14], + [34, 24], + ], + ]); + }); + it("underlines every cell in a hovered wrapped link", () => { const fillRectCalls: number[][] = []; const context = { diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 8be5bb873bcb..dc89dee81cae 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -18,6 +18,8 @@ export interface GhosttyCellRange { } const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; +const POWERLINE_SOLID_RIGHT = "\ue0b0"; +const POWERLINE_SOLID_LEFT = "\ue0b2"; function cssColor(color: GhosttyColor): string { return `rgb(${color.r}, ${color.g}, ${color.b})`; @@ -35,6 +37,30 @@ function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { ); } +function isSolidPowerlineSeparator(text: string): boolean { + return text === POWERLINE_SOLID_RIGHT || text === POWERLINE_SOLID_LEFT; +} + +// Fallback fonts inset or scale these private-use glyphs, leaving a gap where +// the separator should meet the next cell. Fill the triangle from the cell's +// own edges instead, as native terminals do. +function fillSolidPowerlineSeparator( + context: CanvasRenderingContext2D, + text: string, + left: number, + top: number, + metrics: GhosttyCellMetrics, +): void { + const right = left + metrics.width; + const [base, point] = text === POWERLINE_SOLID_RIGHT ? [left, right] : [right, left]; + context.beginPath(); + context.moveTo(base, top); + context.lineTo(point, top + metrics.height / 2); + context.lineTo(base, top + metrics.height); + context.closePath(); + context.fill(); +} + export function ghosttyTextRunEnd( cells: readonly GhosttyCell[], start: number, @@ -48,7 +74,7 @@ export function ghosttyTextRunEnd( end += 1; continue; } - if (next.text.length === 0 || !sameStyle(next)) break; + if (next.text.length === 0 || isSolidPowerlineSeparator(next.text) || !sameStyle(next)) break; end += 1; } return end; @@ -192,6 +218,20 @@ export function renderGhosttySnapshot(options: { runStart += 1; continue; } + if (isSolidPowerlineSeparator(first.text)) { + if (!first.invisible) { + context.fillStyle = cssColor(first.foreground); + fillSolidPowerlineSeparator( + context, + first.text, + padding + runStart * metrics.width, + top, + metrics, + ); + } + runStart += 1; + continue; + } const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first)); const text = row.cells .slice(runStart, runEnd) @@ -261,9 +301,13 @@ export function renderGhosttySnapshot(options: { context.fillRect(left, top, metrics.width, metrics.height); const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; if (cell?.text) { - context.font = fontForCell(cell, fontSize, fontFamily); context.fillStyle = cssColor(snapshot.background); - context.fillText(cell.text, left, top + metrics.baseline, metrics.width); + if (isSolidPowerlineSeparator(cell.text)) { + fillSolidPowerlineSeparator(context, cell.text, left, top, metrics); + } else { + context.font = fontForCell(cell, fontSize, fontFamily); + context.fillText(cell.text, left, top + metrics.baseline, metrics.width); + } } } } From 300a35670abc0f73662e17c9ce909bcc106be70f Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 05:27:10 +1100 Subject: [PATCH 053/898] fix(server): provider failures say why a turn could not start (#16303) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: SAPHID Co-authored-by: Julius Marminge --- .../Adapters/ClaudeAdapterV2.test.ts | 28 ++++---- .../Adapters/ClaudeAdapterV2.ts | 2 + .../Adapters/CodexAdapterV2.ts | 18 +++-- packages/provider-acp/src/server/adapter.ts | 2 + .../src/server/ProviderAdapter.ts | 8 +++ .../provider-core/src/server/failure.test.ts | 59 ++++++++++++++++ packages/provider-core/src/server/failure.ts | 67 ++++++++++++++----- .../provider-cursor/src/server/adapter.ts | 2 + packages/provider-muse/src/server/adapter.ts | 8 ++- .../provider-opencode/src/server/adapter.ts | 16 +++-- .../src/server/v2/adapter.ts | 1 + packages/provider-pi/src/server/adapter.ts | 8 ++- 12 files changed, 168 insertions(+), 51 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6b936c01fe85..5b22e2f121ae 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8509,12 +8509,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* userTurn("attempt-failed-start-1", 1); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - yield* Queue.offer(harness.sdkMessages, wakeNotification); - let notificationYields = 0; - yield* awaitUntil(() => notificationYields++ >= 50, "notification to be recorded"); + yield* harness.offerAndWait(wakeTaskStarted); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait(wakeNotification); assert.isFalse(yield* harness.hasPendingBackgroundWork); const missing = ChatImageAttachment.make({ @@ -8524,20 +8522,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { mimeType: "image/png", sizeBytes: 4, }); - const failed = yield* Effect.exit(userTurn("attempt-failed-start-2", 2, [missing])); - assert.isTrue(Exit.isFailure(failed)); + const failed = yield* userTurn("attempt-failed-start-2", 2, [missing]).pipe(Effect.flip); + assert.equal( + makeProviderFailure({ cause: failed }).message, + "The provider could not start this turn: an attachment could not be read. Retry the turn; if it keeps failing, check the provider setup and server logs.", + ); yield* userTurn("attempt-failed-start-3", 2); - yield* Queue.offer( - harness.sdkMessages, + yield* harness.offerAndWait( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000911", result: "Answered." }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); + yield* Queue.take(harness.terminalReceipts); - yield* Queue.offer(harness.sdkMessages, wakeAssistant); - yield* awaitUntil(() => harness.continuationRequests.length === 1, "command wake"); + yield* harness.offerAndWait(wakeAssistant); + const continuation = yield* Queue.take(harness.continuationReceipts); assert.equal( - harness.continuationRequests[0]?.notification?.summary, + continuation.notification?.summary, `Command "${WAKE_TASK_DESCRIPTION}" finished`, ); }).pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5a211e370542..30115329be57 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1685,6 +1685,7 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: new ProviderAdapter.ProviderAdapterProtocolError({ driver: CLAUDE_PROVIDER, detail: `Failed to read attachment '${attachment.id}'`, + reason: "attachment_unreadable", payload: cause, }), ), @@ -8760,6 +8761,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: CLAUDE_PROVIDER, detail: `Claude provider turn ${currentTurn.providerTurnId} is still active.`, + reason: "previous_turn_active", }); } yield* Ref.update(lastTurnRouteByNativeThread, (current) => { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 928ae4a7e13a..a4f465142042 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -3126,13 +3126,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (attachmentPath === null) { return yield* toProtocolError(`Invalid attachment id '${attachment.id}'`); } - const bytes = yield* fileSystem - .readFile(attachmentPath) - .pipe( - Effect.mapError((cause) => - toProtocolError(`Failed to read attachment '${attachment.id}'.`, cause), - ), - ); + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CODEX_PROVIDER, + detail: `Failed to read attachment '${attachment.id}'.`, + reason: "attachment_unreadable", + payload: cause, + }), + ), + ); return { type: "image" as const, url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index e65c2fea6119..c23f2b99a91d 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -6354,6 +6354,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( new ProviderAdapter.ProviderAdapterProtocolError({ driver, detail: `Failed to read attachment '${attachment.id}'`, + reason: "attachment_unreadable", payload: cause, }), ), @@ -6411,6 +6412,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver, detail: `ACP provider turn ${existing.providerTurnId} is still active`, + reason: "previous_turn_active", }); } useProviderThreadIdentity(turnInput.providerThread); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index d9a0f2fc95e5..d58a6c5904a2 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -360,11 +360,19 @@ export class ProviderAdapterEventStreamError extends Schema.TaggedError()( "ProviderAdapterProtocolError", { driver: ProviderDriverKind, detail: Schema.String, + reason: Schema.optional(ProviderAdapterFailureReason), cause: Schema.optional(Schema.Defect()), payload: Schema.optional(Schema.Unknown), }, diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts index a4429213db28..175c3a2526c3 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -101,6 +101,65 @@ it("preserves actionable handoff errors wrapped by turn startup", () => { ); }); +it.each([ + ["previous_turn_active", "the previous turn is still running"], + ["attachment_unreadable", "an attachment could not be read"], +] as const)("names %s without exposing provider detail or losing guidance", (reason, wording) => { + const failure = makeProviderFailure({ + cause: Cause.fail({ + _tag: "ProviderAdapterTurnStartError", + cause: Cause.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ProviderDriverKind.make("claudeAgent"), + detail: `Private configuration: ${"x".repeat(5000)}`, + reason, + }), + ), + }), + }); + assert.equal( + failure.message, + `The provider could not start this turn: ${wording}. Retry the turn; if it keeps failing, check the provider setup and server logs.`, + ); +}); + +it("keeps unrecognized reasons and provider text out of category messages", () => { + for (const cause of [ + "Private launch command", + { _tag: "ProviderAdapterProtocolError", detail: "Pi model 'private-config' is invalid" }, + { _tag: "ProviderAdapterProtocolError", reason: "private-config" }, + { _tag: "ProviderAdapterProtocolError", reason: "constructor" }, + { _tag: "constructor", cause: "private-config" }, + ]) { + assert.equal( + makeProviderFailure({ cause: { _tag: "ProviderAdapterTurnStartError", cause } }).message, + "The provider could not start this turn. Retry the turn; if it keeps failing, check the provider setup and server logs.", + ); + } + assert.equal( + makeProviderFailure({ + cause: { _tag: "ProviderAdapterProtocolError", reason: "previous_turn_active" }, + }).message, + "Provider turn failed.", + ); +}); + +it("uses the innermost category without carrying an outer reason into it", () => { + assert.equal( + makeProviderFailure({ + cause: { + _tag: "ProviderAdapterTurnStartError", + cause: { + _tag: "ProviderAdapterProtocolError", + reason: "previous_turn_active", + cause: Cause.fail({ _tag: "ProviderAdapterOpenSessionError" }), + }, + }, + }).message, + "The provider session could not be opened. Check that the provider is installed and signed in, then retry the turn.", + ); +}); + it("does not expose defect text nested inside a known error category", () => { const failure = makeProviderFailure({ cause: { diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index d5dfad31a1c2..94441085a2ad 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -14,6 +14,7 @@ import * as DateTime from "effect/DateTime"; import * as Cause from "effect/Cause"; import * as IdAllocator from "./IdAllocator.ts"; +import type { ProviderAdapterFailureReason } from "./ProviderAdapter.ts"; import * as Schema from "effect/Schema"; export class ContextHandoffBudgetError extends Schema.TaggedError()( @@ -30,9 +31,36 @@ export const MAX_PROVIDER_FAILURE_CODE_LENGTH = 128; const DEFAULT_PROVIDER_FAILURE_MESSAGE = "Provider turn failed."; +const PROVIDER_FAILURE_CATEGORIES: Partial< + Record +> = { + ProviderAdapterTurnStartError: { + summary: "The provider could not start this turn", + guidance: "Retry the turn; if it keeps failing, check the provider setup and server logs.", + }, + ProviderAdapterEventStreamError: { + summary: "The provider event stream closed unexpectedly", + guidance: "Retry the turn; if it keeps failing, check the provider and server logs.", + }, + ProviderAdapterOpenSessionError: { + summary: "The provider session could not be opened", + guidance: "Check that the provider is installed and signed in, then retry the turn.", + }, + ProviderAdapterResumeThreadError: { + summary: "The provider conversation could not be resumed", + guidance: "Retry the turn; if it keeps failing, check the provider and server logs.", + }, +}; + +const PROVIDER_FAILURE_REASONS: Record = { + previous_turn_active: "the previous turn is still running", + attachment_unreadable: "an attachment could not be read", +}; + function causeMessage(cause: unknown): string | undefined { const seen = new Set(); - let message: string | undefined; + let category: (typeof PROVIDER_FAILURE_CATEGORIES)[string]; + let reason: string | undefined; for (let depth = 0; depth < 16 && cause != null && !seen.has(cause); depth++) { seen.add(cause); try { @@ -41,7 +69,8 @@ function causeMessage(cause: unknown): string | undefined { continue; } if (typeof cause !== "object") break; - switch ((cause as Record)._tag) { + const tag = (cause as Record)._tag; + switch (tag) { case "ContextHandoffBudgetError": return new ContextHandoffBudgetError().message; case "ClaudeBackgroundWorkBlocksQueryReplacementError": @@ -49,29 +78,31 @@ function causeMessage(cause: unknown): string | undefined { return stringField(cause, "message"); case "ContextHandoffDeliveryUncertainError": return "T3 could not confirm whether conversation history reached the provider. Retry the turn to recover the session."; - case "ProviderAdapterTurnStartError": - message = - "The provider could not start this turn. Retry the turn; if it keeps failing, check the provider setup and server logs."; - break; - case "ProviderAdapterEventStreamError": - message = - "The provider event stream closed unexpectedly. Retry the turn; if it keeps failing, check the provider and server logs."; - break; - case "ProviderAdapterOpenSessionError": - message = - "The provider session could not be opened. Check that the provider is installed and signed in, then retry the turn."; - break; - case "ProviderAdapterResumeThreadError": - message = - "The provider conversation could not be resumed. Retry the turn; if it keeps failing, check the provider and server logs."; + case "ProviderAdapterProtocolError": { + const typedReason = (cause as Record).reason; + if ( + category !== undefined && + typeof typedReason === "string" && + Object.hasOwn(PROVIDER_FAILURE_REASONS, typedReason) + ) { + reason ??= PROVIDER_FAILURE_REASONS[typedReason as ProviderAdapterFailureReason]; + } break; + } + } + if (typeof tag === "string" && Object.hasOwn(PROVIDER_FAILURE_CATEGORIES, tag)) { + category = PROVIDER_FAILURE_CATEGORIES[tag]; + reason = undefined; } cause = (cause as Record).cause; } catch { break; } } - return message; + if (category === undefined) return undefined; + return reason + ? `${category.summary}: ${reason}. ${category.guidance}` + : `${category.summary}. ${category.guidance}`; } function stringField(value: unknown, key: "message" | "code"): string | undefined { diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index ae495016e9ff..c9898a507fb5 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2199,6 +2199,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( new ProviderAdapter.ProviderAdapterProtocolError({ driver: CursorAgentSdk.CURSOR_PROVIDER, detail: `Failed to read attachment '${attachment.id}'.`, + reason: "attachment_unreadable", payload: cause, }), ), @@ -2232,6 +2233,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: CursorAgentSdk.CURSOR_PROVIDER, detail: `Cursor provider turn ${current.providerTurnId} is still active.`, + reason: "previous_turn_active", }); } const agentId = nativeThreadId(turnInput.providerThread); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index d61462576b67..d762c85d976a 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -1904,9 +1904,11 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( if (!validateThread(turnInput.providerThread) || broken || closed) return yield* protocolError("Muse thread is not attached to this host"); if (active) - return yield* protocolError( - "Muse already has an active turn; queue through the orchestrator", - ); + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: MUSE_PROVIDER, + detail: "Muse already has an active turn; queue through the orchestrator", + reason: "previous_turn_active", + }); if (turnInput.runtimePolicy.interactionMode === "plan") return yield* protocolError("Muse does not support dedicated Plan mode"); if (turnInput.runtimePolicy.runtimeMode !== input.runtimePolicy.runtimeMode) diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 71144f24dd70..4a73195d2dc7 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -3094,9 +3094,11 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function return yield* protocolError(`OpenCode session ${sessionId} is not registered`); } if (state.activeTurn !== null) { - return yield* protocolError( - `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, - ); + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, + reason: "previous_turn_active", + }); } const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug( turnInput.modelSelection.model, @@ -3138,9 +3140,11 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ); } if (state.activeTurn !== null) { - return yield* protocolError( - `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, - ); + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode provider thread ${turnInput.providerThread.id} already has an active turn`, + reason: "previous_turn_active", + }); } const turn: ActiveOpenCodeTurn = { isRoot: true, diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 17214ae2116d..736a01d715c8 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -3441,6 +3441,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: OPENCODE_PROVIDER, detail: `OpenCode session ${sessionId} already has an active turn`, + reason: "previous_turn_active", }); } if (isContinuation(turnInput)) return yield* runWake(state, turnInput); diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 25c3984246e9..1d33720f3cb8 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -2418,9 +2418,11 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return yield* protocolError("Pi session has no registered thread"); } if (state.activeTurn !== null) { - return yield* protocolError( - `Pi provider thread ${turnInput.providerThread.id} already has an active turn`, - ); + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: PI_PROVIDER, + detail: `Pi provider thread ${turnInput.providerThread.id} already has an active turn`, + reason: "previous_turn_active", + }); } if ( state.providerThread.nativeThreadRef?.nativeId !== From 89424d9fa01c6bd24664315c7ccda8d6cb1eab61 Mon Sep 17 00:00:00 2001 From: Jreevo <39198375+Jreevo@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:28:28 -0700 Subject: [PATCH 054/898] fix(server): a Claude /goal no longer reads as complete while a background subagent runs (#16598) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- .../Adapters/ClaudeAdapterV2.test.ts | 132 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 25 +++- 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5b22e2f121ae..447272221f65 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -16013,6 +16013,138 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect( + "defers goal completion until an ambient subagent finishes and Claude checks again", + () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const condition = "the review finds no bugs"; + const taskId = "task-goal-review"; + const toolUseId = "toolu-goal-review"; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("goal-background-attempt"), + text: `/goal ${condition}`, + attachments: [], + }), + ); + for (const frame of [ + syntheticFrame("goal-background-set", `Goal set: ${condition}`), + claudeSdkFrame({ + ...makeSubagentTaskStartedFrame({ taskId, toolUseId, uuid: "goal-review-start" }), + ambient: true, + skip_transcript: true, + }), + makeAssistantTextFrame({ + uuid: "goal-background-wait", + text: "Waiting for the review.", + }), + makeResultFrame({ uuid: "goal-background-result", result: "Waiting for the review." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.deepEqual(goalStatuses(harness.events).at(-1), { + objective: condition, + status: "active", + checks: 0, + }); + + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId, + toolUseId, + summary: "No bugs found.", + uuid: "goal-review-done", + }), + ); + yield* harness.offerAndWait(wakeTurnInit); + yield* Queue.take(harness.continuationReceipts); + assert.equal(goalStatuses(harness.events).at(-1)?.status, "active"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("goal-background-wake"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.offerAndWait( + makeAssistantTextFrame({ uuid: "goal-background-wake-work", text: "No bugs found." }), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "goal-background-wake-result", + result: "No bugs found.", + origin: { kind: "task-notification" }, + }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.deepEqual(goalStatuses(harness.events).at(-1), { + objective: condition, + status: "complete", + checks: 0, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each([ + { taskType: "mcp_task", status: "complete" }, + { taskType: undefined, status: "active" }, + ] as const)("leaves the goal $status with a running $taskType task", ({ taskType, status }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const condition = "the docs build"; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("goal-task-type-attempt"), + text: `/goal ${condition}`, + attachments: [], + }), + ); + for (const frame of [ + syntheticFrame("goal-task-type-set", `Goal set: ${condition}`), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-goal-docs", + description: "Watch the docs", + ...(taskType === undefined ? {} : { task_type: taskType }), + uuid: "goal-task-type-start", + session_id: WAKE_NATIVE_SESSION, + }), + makeAssistantTextFrame({ uuid: "goal-task-type-work", text: "The docs build." }), + makeResultFrame({ uuid: "goal-task-type-result", result: "The docs build." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.equal(goalStatuses(harness.events).at(-1)?.status, status); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("keeps a goal active after a command turn with no model output", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 30115329be57..e969520bd05e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2078,6 +2078,15 @@ const CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS: ReadonlyMap< Exclude > = new Map([["local_bash", "command"]]); +// Claude Code 2.1.292 defers its goal Stop hook for these subagent types. +// local_bash tasks defer it through the opaque-task roster. +const CLAUDE_GOAL_DEFERRING_TASK_TYPES: ReadonlySet = new Set([ + "local_agent", + "remote_agent", + "in_process_teammate", + "local_workflow", +]); + function isClaudeOpaqueBackgroundTaskType(taskType: string | null | undefined): boolean { return typeof taskType === "string" && CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS.has(taskType); } @@ -3079,6 +3088,7 @@ interface ActiveClaudeProviderRetry { interface ActiveClaudeSubagent { task: OrchestrationV2Subagent; + readonly nativeTaskType: string | undefined; readonly confirmedOutcome: ProviderAdapter.ProviderAdapterV2SubagentOutcome | undefined; readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; readonly childThreadId: ThreadId; @@ -4504,6 +4514,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const ids = claudeSubagentIds(resume.context, resume.taskId); const now = yield* DateTime.now; const subagent: ActiveClaudeSubagent = { + nativeTaskType: undefined, confirmedOutcome: undefined, childThread: null, task: { @@ -4985,6 +4996,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly model?: string; readonly effort?: string; readonly owner?: ActiveClaudeSubagent; + readonly nativeTaskType?: string; readonly progress?: string; readonly workflowFrame?: unknown; readonly workflowName?: string; @@ -5143,6 +5155,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } satisfies OrchestrationV2Subagent; const subagent = { task, + nativeTaskType: input.nativeTaskType ?? existingSubagent?.nativeTaskType, confirmedOutcome: input.nativeOutcome !== undefined ? confirmedSubagentOutcome(task) @@ -6275,13 +6288,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId === null ? undefined : goalsByNativeThread.get(nativeThreadId); const goalChecked = goalCheckedTurns.delete(input.context.providerTurnId); const terminalReason = input.result?.terminal_reason; + const liveQuery = yield* Ref.get(queryContext); if ( nativeThreadId !== null && goal?.status === "active" && goalChecked && input.status === "completed" && (terminalReason === undefined || terminalReason === "completed") && - roster.size === 0 + roster.size === 0 && + !( + liveQuery?.nativeThreadId === nativeThreadId && + (yield* liveProcessRunsBackgroundWork(liveQuery, { forGoalCheck: true })) + ) ) { goalsByNativeThread.set(nativeThreadId, { objective: goal.objective, @@ -7533,6 +7551,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* updateClaudeSubagentNode({ context, taskId: message.task_id, + ...(message.task_type === undefined ? {} : { nativeTaskType: message.task_type }), ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), ...(message.prompt === undefined ? {} : { prompt: message.prompt }), ...(model === undefined ? {} : { model }), @@ -8472,6 +8491,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, + options?: { readonly forGoalCheck?: boolean }, ) { if ( rosterForNativeThread( @@ -8486,6 +8506,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if ( subagent.task.status === "running" && !live.subagentsFromEarlierProcesses.has(subagent) && + (options?.forGoalCheck !== true || + subagent.nativeTaskType === undefined || + CLAUDE_GOAL_DEFERRING_TASK_TYPES.has(subagent.nativeTaskType)) && !buffered.some( (message) => message.type === "system" && From b20f55248df3ccdb699c2a5903ef11f3eb8a6ac9 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 05:31:58 +1100 Subject: [PATCH 055/898] fix(clients): image threads use readable titles (#19822) Co-authored-by: SAPHID Co-authored-by: Julius Marminge --- .../features/threads/ThreadRouteScreen.tsx | 1 + .../src/lib/projectThreadStartTurn.test.ts | 66 +++++++++++++++---- apps/mobile/src/lib/projectThreadStartTurn.ts | 21 +++--- .../src/state/use-thread-outbox-drain.ts | 1 + apps/web/src/components/ChatView.tsx | 51 ++++++-------- .../src/operations/threadTitle.test.ts | 32 +++++++-- .../src/operations/threadTitle.ts | 28 +++++++- 7 files changed, 135 insertions(+), 65 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 3908d7ad9238..39f412684ff8 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -816,6 +816,7 @@ function ThreadRouteContent( projectId: selectedThread.projectId, projectCwd: selectedThreadProject.workspaceRoot, text: setupMessage.text, + titleText: selectedThread.title, ...(setupMessage.context ? { context: setupMessage.context } : {}), uploadedAttachments: setupMessage.attachments, modelSelection: selectedThread.modelSelection, diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts index f67a4649face..c71e3f7f4f80 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.test.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -8,35 +8,62 @@ import { import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { describe, expect, it } from "vite-plus/test"; +import { serializeComposerMessageForServer } from "./composerContext"; + import { buildProjectThreadStartTurnInput, deriveThreadTitleFromPrompt, + type ProjectThreadStartTurnSpec, } from "./projectThreadStartTurn"; describe("project thread title", () => { + it("normalizes draft and queued titles while keeping the mobile length limit", () => { + expect( + deriveThreadTitleFromPrompt( + "![photo.png](t3-context://v1/image/photo) Fix [src/app.ts](t3-context://v1/mention/source) so the sidebar remains readable when a thread starts with an image", + ), + ).toBe("Fix src/app.ts so the sidebar remains readable when a thread starts w..."); + }); + it("keeps ordinary titles and the empty-prompt fallback", () => { expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser"); expect(deriveThreadTitleFromPrompt(" \n ")).toBe("New thread"); }); - it("derives attachment-only titles from prepared image metadata", () => { - const uploadedAttachments = [ - { - type: "image" as const, - id: "prepared-photo", - name: "photo.png", - mimeType: "image/png", - sizeBytes: 3, - }, - ]; - const input = buildProjectThreadStartTurnInput({ + it.each([ + { + type: "image" as const, + name: "photo.png", + mimeType: "image/png", + text: "![photo.png](t3-context://v1/image/photo)", + title: "Attached image", + }, + { + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + text: "", + title: "File: report.pdf", + }, + { + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + text: "[report.pdf](t3-context://v1/file/report)", + title: "report.pdf", + }, + ])("titles a $type-only message without changing its data", ({ text, title, ...attachment }) => { + const uploadedAttachments = [{ ...attachment, id: "prepared-attachment", sizeBytes: 3 }]; + const wire = serializeComposerMessageForServer(text, undefined, true); + const spec = { projectId: ProjectId.make("project"), projectCwd: "/workspace", threadId: "image-thread", commandId: "image-command", messageId: "image-message", createdAt: "2026-09-04T00:00:00Z", - text: "", + ...wire, + titleText: text, uploadedAttachments, modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, runtimeMode: "full-access", @@ -46,11 +73,22 @@ describe("project thread title", () => { worktreePath: null, startFromOrigin: false, worktreeBranchName: "unused", - }); + } satisfies ProjectThreadStartTurnSpec; + const input = buildProjectThreadStartTurnInput(spec); - expect(input.titleSeed).toBe("Image: photo.png"); + expect(input.titleSeed).toBe(title); expect(input.bootstrap.createThread.title).toBe(input.titleSeed); expect(input.message.attachments).toEqual(uploadedAttachments); + expect(input.message.text).toBe(wire.text); + const relaunched = buildProjectThreadStartTurnInput({ + ...spec, + threadId: "local-relaunch", + text: input.message.text, + titleText: input.bootstrap.createThread.title, + }); + expect(relaunched.titleSeed).toBe(title); + expect(relaunched.message.text).toBe(wire.text); + expect(relaunched.message.attachments).toEqual(uploadedAttachments); }); it.each([ diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 030f8bee6f67..1e677d232b81 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -9,8 +9,10 @@ import { type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; -import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; -import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; +import { + deriveThreadTitleSeed, + threadTitleTextFromPrompt, +} from "@t3tools/client-runtime/operations"; import type { UploadedMobileAttachment } from "./attachmentUpload"; @@ -22,6 +24,7 @@ export interface ProjectThreadStartTurnSpec { readonly messageId: string; readonly createdAt: string; readonly text: string; + readonly titleText?: string; readonly context?: OrchestrationMessageContext; readonly uploadedAttachments: ReadonlyArray; readonly modelSelection: ModelSelection; @@ -35,7 +38,10 @@ export interface ProjectThreadStartTurnSpec { } export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpec) { - const title = deriveThreadTitleSeed({ text: spec.text, attachments: spec.uploadedAttachments }); + const title = deriveThreadTitleSeed({ + text: spec.titleText ?? spec.text, + attachments: spec.uploadedAttachments, + }); const isWorktree = spec.workspaceMode === "worktree"; return { commandId: CommandId.make(spec.commandId), @@ -80,11 +86,6 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe } export function deriveThreadTitleFromPrompt(value: string): string { - const trimmed = assistantCitationsToPlainText(value).trim(); - if (trimmed.length === 0) { - return "New thread"; - } - - const compact = trimmed.replace(/\s+/g, " "); - return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; + const title = threadTitleTextFromPrompt(value) || "New thread"; + return title.length <= 72 ? title : `${title.slice(0, 69).trimEnd()}...`; } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 5ef3d6b16cea..a97963202946 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -1019,6 +1019,7 @@ export function useThreadOutboxDrain(): void { commandId: queuedMessage.commandId, messageId: queuedMessage.messageId, createdAt: queuedMessage.createdAt, + titleText: queuedMessage.text, ...serializeComposerMessageForServer( queuedMessage.text.trim(), uploadedComposerContext( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fd28d2dd095a..694e9cf32907 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10,7 +10,6 @@ import { } from "./ChatView.logic"; import * as DateTime from "effect/DateTime"; import { restorePlanFollowUpComposer } from "./ChatView.logic"; -import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { useQueuedRunEdit } from "./chat/useQueuedRunEdit"; import { isPaintOnlyThreadTimeline, @@ -81,6 +80,7 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { wasBootstrapThreadDeleted, wasBootstrapThreadNotCreated, @@ -341,7 +341,6 @@ import { import { ensureInlineContextReferences, removeInlineContextReference, - stripInlineContextReferences, } from "../lib/composerContextReferences"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; import { @@ -6598,11 +6597,10 @@ export default function ChatView(props: ChatViewProps) { : composerAttachmentsSnapshot[index]!.id, ), ); - const title = truncate( - assistantCitationsToPlainText(stripInlineContextReferences(trimmed)).trim() || - composerAttachmentsSnapshot[0]?.name || - "New thread", - ); + const title = deriveThreadTitleSeed({ + text: messageTextForSend, + attachments: composerAttachmentsSnapshot, + }); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -6851,30 +6849,21 @@ export default function ChatView(props: ChatViewProps) { clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); - let firstComposerImageName: string | null = null; - if (composerImagesSnapshot.length > 0) { - const firstComposerImage = composerImagesSnapshot[0]; - if (firstComposerImage) { - firstComposerImageName = firstComposerImage.name; - } - } - let titleSeed = assistantCitationsToPlainText(stripInlineContextReferences(trimmed)).trim(); - if (!titleSeed) { - if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; - } else if (composerFilesSnapshot[0]) { - titleSeed = `File: ${composerFilesSnapshot[0].name}`; - } else if (composerTerminalContextsSnapshot.length > 0) { - titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); - } else if (composerReviewCommentsSnapshot.length > 0) { - titleSeed = `Review: ${reviewCommentContextLabel(composerReviewCommentsSnapshot[0]!)}`; - } else if (composerPreviewAnnotationsSnapshot.length > 0) { - titleSeed = previewAnnotationContextLabel(composerPreviewAnnotationsSnapshot[0]!); - } else { - titleSeed = "New thread"; - } - } - const title = truncate(titleSeed); + const title = deriveThreadTitleSeed({ + text: messageTextForSend, + attachments: composerAttachmentsSnapshot, + fallbackLabels: [ + composerTerminalContextsSnapshot[0] + ? formatTerminalContextLabel(composerTerminalContextsSnapshot[0]) + : undefined, + composerReviewCommentsSnapshot[0] + ? `Review: ${reviewCommentContextLabel(composerReviewCommentsSnapshot[0])}` + : undefined, + composerPreviewAnnotationsSnapshot[0] + ? previewAnnotationContextLabel(composerPreviewAnnotationsSnapshot[0]) + : undefined, + ], + }); const threadCreateModelSelection = createModelSelection( ctxSelectedModelSelection.instanceId, ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL, diff --git a/packages/client-runtime/src/operations/threadTitle.test.ts b/packages/client-runtime/src/operations/threadTitle.test.ts index ab7aaff7ff49..7a785b0cba17 100644 --- a/packages/client-runtime/src/operations/threadTitle.test.ts +++ b/packages/client-runtime/src/operations/threadTitle.test.ts @@ -3,23 +3,41 @@ import { describe, expect, it } from "@effect/vitest"; import { deriveThreadTitleSeed } from "./threadTitle.ts"; describe("deriveThreadTitleSeed", () => { - it("prefers normalized message text", () => { + const image = "![pasted-image.png](t3-context://v1/image/aadd9cc6-1234-5678-9012-abcdef123456)"; + + it.each([ + [image, "Attached image"], + [`${image}\n![other.png](t3-context://v1/image/other)`, "Attached images"], + ])("uses a readable image-only title for %s", (text, title) => { + expect(deriveThreadTitleSeed({ text, attachments: [] })).toBe(title); + }); + + it("prefers a source label when an image prompt has no prose", () => { + expect( + deriveThreadTitleSeed({ + text: `${image}\n[src/app.ts](t3-context://v1/mention/source)`, + attachments: [{ type: "image", name: "pasted-image.png" }], + }), + ).toBe("src/app.ts"); + }); + + it("prefers normalized request text and context labels over attachments", () => { expect( deriveThreadTitleSeed({ - text: " Investigate\n login failures ", - attachments: [{ name: "login-error.png" }], + text: ` Fix ${image} [src/app.ts](t3-context://v1/mention/source)\n using [Build output](t3-context://v1/terminal/build) `, + attachments: [{ type: "image", name: "login-error.png" }], fallbackLabels: ["Terminal 1"], }), - ).toBe("Investigate login failures"); + ).toBe("Fix src/app.ts using Build output"); }); - it("uses the first attachment name when text is empty", () => { + it("uses an image placeholder when text is empty", () => { expect( deriveThreadTitleSeed({ text: " \n ", - attachments: [{ name: "login-error.png" }, { name: "other.png" }], + attachments: [{ type: "image", name: "login-error.png" }], }), - ).toBe("Image: login-error.png"); + ).toBe("Attached image"); }); it("uses the first non-empty fallback label after text and attachments", () => { diff --git a/packages/client-runtime/src/operations/threadTitle.ts b/packages/client-runtime/src/operations/threadTitle.ts index 49b7b72dcaf8..2fc2da25e4fe 100644 --- a/packages/client-runtime/src/operations/threadTitle.ts +++ b/packages/client-runtime/src/operations/threadTitle.ts @@ -1,9 +1,11 @@ +import type { ChatAttachment } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; +import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { truncate } from "@t3tools/shared/String"; export interface ThreadTitleSeedInput { readonly text: string; - readonly attachments: ReadonlyArray<{ readonly name: string }>; + readonly attachments: ReadonlyArray>; readonly fallbackLabels?: ReadonlyArray; } @@ -11,15 +13,35 @@ function normalizeTitleSeed(value: string): string { return assistantCitationsToPlainText(value).trim().replace(/\s+/gu, " "); } +export function threadTitleTextFromPrompt(value: string): string { + const imageIds = new Set(); + const prose = replaceComposerContextReferences(value, (reference) => { + if (reference.kind === "image") { + imageIds.add(reference.contextId); + return ""; + } + return reference.label; + }); + return ( + normalizeTitleSeed(prose) || + (imageIds.size > 0 ? (imageIds.size === 1 ? "Attached image" : "Attached images") : "") + ); +} + export function deriveThreadTitleSeed(input: ThreadTitleSeedInput): string { - const text = normalizeTitleSeed(input.text); + const text = threadTitleTextFromPrompt(input.text); if (text.length > 0) { return truncate(text); } + const images = input.attachments.filter((attachment) => attachment.type === "image"); + if (images.length > 0) { + return images.length === 1 ? "Attached image" : "Attached images"; + } + const attachmentName = normalizeTitleSeed(input.attachments[0]?.name ?? ""); if (attachmentName.length > 0) { - return truncate(`Image: ${attachmentName}`); + return truncate(`File: ${attachmentName}`); } for (const label of input.fallbackLabels ?? []) { From 034b50616165fc25195e25bfddb188639bfefa34 Mon Sep 17 00:00:00 2001 From: Info-Cado Date: Tue, 20 Oct 2026 00:07:13 +0530 Subject: [PATCH 056/898] fix(server): storage cleanup reaches archived threads' worktrees (#16473) Co-authored-by: Khush-Koyani Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- .../orchestration-v2/ProjectionStore.test.ts | 16 ++-- .../src/orchestration-v2/ProjectionStore.ts | 12 +-- apps/server/src/storageCleanup.test.ts | 80 +++++++++++++++++-- apps/server/src/storageCleanup.ts | 12 ++- 4 files changed, 98 insertions(+), 22 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 74377160a820..34fc75a64d25 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -512,7 +512,7 @@ const restartCancelledWorkSurvivesStaleRunUpdate = Effect.gen(function* () { assert.deepEqual(updated?.restartCancelledBackgroundWork, work); }); -const threadWorktreePathsReadActiveThreads = Effect.gen(function* () { +const threadWorktreePathsIncludeArchivedOnRequest = Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; const create = (id: string, input: { worktreePath: string | null; archived?: boolean }) => { @@ -565,6 +565,10 @@ const threadWorktreePathsReadActiveThreads = Effect.gen(function* () { assert.deepEqual(yield* store.getThreadWorktreePaths(), [ { threadId: ThreadId.make("thread:active"), worktreePath: "/worktrees/active" }, ]); + assert.deepEqual(yield* store.getThreadWorktreePaths({ includeArchived: true }), [ + { threadId: ThreadId.make("thread:active"), worktreePath: "/worktrees/active" }, + { threadId: ThreadId.make("thread:archived"), worktreePath: "/worktrees/archived" }, + ]); }); const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { @@ -650,8 +654,10 @@ it.effect("memory command reads skip finished leaf nodes of finished runs", () = commandReadsSkipSettledLeafNodes.pipe(Effect.provide(ProjectionStore.layerMemory)), ); -it.effect("memory worktree path lookup reads only active threads", () => - threadWorktreePathsReadActiveThreads.pipe(Effect.provide(ProjectionStore.layerMemory)), +it.effect( + "memory worktree path lookup defaults to active threads and can include archived threads", + () => + threadWorktreePathsIncludeArchivedOnRequest.pipe(Effect.provide(ProjectionStore.layerMemory)), ); it.effect("memory projection keeps restart-cancelled work through a stale run.updated", () => @@ -878,8 +884,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { () => runlessNativeSubagentNeedsRecovery, ); it.effect( - "worktree path lookup reads only active threads", - () => threadWorktreePathsReadActiveThreads, + "worktree path lookup defaults to active threads and can include archived threads", + () => threadWorktreePathsIncludeArchivedOnRequest, ); it.effect( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 002ef7bafa95..fedd86a48414 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -422,7 +422,9 @@ export interface ProjectionStoreV2Shape { readonly getThreadsWithPullRequests: ( threadId?: ThreadId, ) => Effect.Effect, ProjectionStoreV2Error>; - readonly getThreadWorktreePaths: () => Effect.Effect< + readonly getThreadWorktreePaths: (options?: { + readonly includeArchived?: boolean; + }) => Effect.Effect< ReadonlyArray<{ readonly threadId: ThreadId; readonly worktreePath: string }>, ProjectionStoreV2Error >; @@ -5739,12 +5741,12 @@ export const layer: Layer.Layer = ); }).pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); - const getThreadWorktreePaths: ProjectionStoreV2Shape["getThreadWorktreePaths"] = () => + const getThreadWorktreePaths: ProjectionStoreV2Shape["getThreadWorktreePaths"] = (options) => sql<{ readonly thread_id: string; readonly worktree_path: string }>` SELECT thread_id, json_extract(payload_json, '$.worktreePath') AS worktree_path FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL - AND json_extract(payload_json, '$.archivedAt') IS NULL + ${options?.includeArchived === true ? sql`` : sql`AND json_extract(payload_json, '$.archivedAt') IS NULL`} AND json_extract(payload_json, '$.worktreePath') IS NOT NULL `.pipe( Effect.map((rows) => @@ -6240,12 +6242,12 @@ export const layerMemory: Layer.Layer = Layer.effect( left.id.localeCompare(right.id), ); }), - getThreadWorktreePaths: () => + getThreadWorktreePaths: (options) => Ref.get(replayState).pipe( Effect.map((state) => [...state.projections.values()].flatMap(({ thread }) => thread.deletedAt === null && - thread.archivedAt === null && + (options?.includeArchived === true || thread.archivedAt === null) && thread.worktreePath !== null ? [{ threadId: thread.id, worktreePath: thread.worktreePath }] : [], diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index e3870f8af0c4..6ef6f5b3f01b 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -306,14 +306,26 @@ const cleanupFixture = ( getShellSnapshot: (input?: { location?: string }) => Effect.sync(() => { snapshotReads++; - return { threads: input?.location === "archive" ? [] : threads }; + const visible = threads.filter((thread) => thread.deletedAt === null); + return { + threads: + input?.location === "archive" + ? [] + : visible.filter((thread) => thread.archivedAt === null), + archivedThreads: + input?.location === "active" + ? [] + : visible.filter((thread) => thread.archivedAt !== null), + }; }), - getThreadWorktreePaths: () => + getThreadWorktreePaths: (input?: { includeArchived?: boolean }) => Effect.sync(() => threads.flatMap((thread) => - thread.worktreePath === null - ? [] - : [{ threadId: thread.id, worktreePath: thread.worktreePath }], + thread.deletedAt === null && + (input?.includeArchived === true || thread.archivedAt === null) && + thread.worktreePath !== null + ? [{ threadId: thread.id, worktreePath: thread.worktreePath }] + : [], ), ), getThreadShell: (threadId: ThreadId) => @@ -1176,6 +1188,41 @@ describe("orphan worktree reports", () => { }); describe("storage cleanup reports and local file policies", () => { + it.effect("automatically removes an eligible archived thread checkout", () => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + fixture.setThreads([ + shell({ + branch: "feature", + worktreePath: fixture.worktree, + archivedAt: at(-DAY_MS), + }), + ]); + yield* fixture.activate(Stream.empty); + const [report] = yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runCollect, + ); + yield* fixture.service.drain; + expect(report).toMatchObject({ + trigger: "automatic", + counts: { removed: 1, kept: 0, failed: 0 }, + entries: [ + { + threadId: "thread-1", + path: fixture.worktree, + outcome: "removed", + reason: "Inactive for 30 days", + }, + ], + }); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }), + ), + ); it.live.each(["before-check", "while-measuring"] as const)( "keeps a worktree protected by a missing project root (%s)", (timing) => @@ -1264,6 +1311,18 @@ describe("storage cleanup reports and local file policies", () => { shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), ], ], + [ + "an archived thread starts using it", + (worktree: string) => [ + shell({ branch: "feature", worktreePath: worktree }), + shell({ + id: ThreadId.make("thread-2"), + branch: "feature", + worktreePath: worktree, + archivedAt: at(-DAY_MS), + }), + ], + ], ] as const)("keeps a worktree when %s during size measurement", ([, threadsWhileMeasuring]) => runCleanupTest( Effect.gen(function* () { @@ -1542,6 +1601,17 @@ describe("storage cleanup reports and local file policies", () => { shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), ]); expect((yield* service.runNow).entries[0]?.reason).toBe("Shared by 2 threads"); + setThreads([ + shell({ branch: "feature", worktreePath: worktree }), + shell({ + id: ThreadId.make("thread-2"), + branch: "feature", + worktreePath: worktree, + archivedAt: at(-DAY_MS), + }), + ]); + expect((yield* service.runNow).entries[0]?.reason).toBe("Shared by 2 threads"); + expect(yield* fs.exists(worktree)).toBe(true); fixture.disable(); expect((yield* service.runNow).entries).toEqual([]); yield* fs.remove(worktree, { recursive: true }); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d68444867428..697811409da6 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -291,11 +291,9 @@ const make = Effect.gen(function* () { // The snapshot takes seconds on large databases, so it runs on the reader // worker instead of the event loop. const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { - const { threads } = yield* projections - .getShellSnapshot({ location: "active" }) - .pipe(NodeSqliteClient.readOnly); + const snapshot = yield* projections.getShellSnapshot().pipe(NodeSqliteClient.readOnly); const projects = yield* projectStore.listShells(); - return { projects, threads }; + return { projects, threads: [...snapshot.threads, ...snapshot.archivedThreads] }; }); const containsProjectRoot = Effect.fn("StorageCleanup.containsProjectRoot")(function* ( @@ -740,9 +738,9 @@ const make = Effect.gen(function* () { const latestProjects = yield* projectStore.listShells(); if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects])) return "contains a project checkout"; - const latest = (yield* projections.getThreadWorktreePaths()).filter( - (entry) => path.resolve(entry.worktreePath) === worktreePath, - ); + const latest = (yield* projections.getThreadWorktreePaths({ + includeArchived: true, + })).filter((entry) => path.resolve(entry.worktreePath) === worktreePath); if (hasTerminal(worktreePath)) return "open terminal"; if (deleted) { if ( From 2b4a806fe402fe917e3f1c45f51fd67b730e3f7c Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 19 Oct 2026 18:38:57 +0000 Subject: [PATCH 057/898] fix(server): skip native sessions during bulk import (#15634) Co-authored-by: Julius Marminge --- apps/server/src/orchestration-v2/EventSink.ts | 23 + .../src/project/AgentSessionImporter.test.ts | 475 +++++++++++++----- .../src/project/AgentSessionImporter.ts | 47 +- 3 files changed, 408 insertions(+), 137 deletions(-) diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 55c2855144a0..13916b40f9de 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -67,11 +67,20 @@ export type EventSinkV2Error = typeof EventSinkV2Error.Type; export interface EventSinkV2Shape { readonly write: (input: { + /** Return no events if another thread owns this native session at commit time. */ + readonly unlessNativeThreadOwned?: Pick< + OrchestrationV2ProviderThread, + "driver" | "providerInstanceId" | "appThreadId" | "nativeThreadRef" + >; readonly guardPendingUserInputCancellations?: boolean; readonly commandId?: CommandId; readonly events: ReadonlyArray; }) => Effect.Effect, EventSinkV2Error>; readonly writeWithEffects: (input: { + readonly unlessNativeThreadOwned?: Pick< + OrchestrationV2ProviderThread, + "driver" | "providerInstanceId" | "appThreadId" | "nativeThreadRef" + >; readonly guardPendingUserInputCancellations?: boolean; readonly commandId?: CommandId; readonly events: ReadonlyArray; @@ -369,6 +378,20 @@ const layerBase: Layer.Layer< return yield* commitThenPublish( Effect.gen(function* () { + // Check ownership in the same transaction that claims the native session. + const imported = input.unlessNativeThreadOwned; + if (imported?.nativeThreadRef != null) { + const owners = yield* sql` + SELECT provider_thread_id + FROM orchestration_v2_projection_provider_threads + WHERE driver = ${imported.driver} + AND provider_instance_id = ${imported.providerInstanceId} + AND thread_id IS NOT ${imported.appThreadId} + AND json_extract(payload_json, '$.nativeThreadRef.nativeId') = ${imported.nativeThreadRef.nativeId} + LIMIT 1 + `; + if (owners.length > 0) return []; + } const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true ? yield* guardUserInputCancellations(input.events) diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 3a1ab5d0a7e6..be7457da4335 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -1,147 +1,390 @@ import { expect, it } from "@effect/vitest"; import { + EventId, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, - type OrchestrationV2DomainEvent, + type ApplicationStoredEvent, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; import * as EventSink from "../orchestration-v2/EventSink.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import { + CLAUDE_MODEL_SELECTION, + CODEX_MODEL_SELECTION, +} from "../orchestration-v2/testkit/fixtures/shared.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as AgentSessionImporter from "./AgentSessionImporter.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; import * as ProjectService from "./ProjectService.ts"; const projectId = ProjectId.make("agent-session-import-project"); -const providerInstanceId = ProviderInstanceId.make("codex"); -const providerSessionId = "native-codex-thread"; -const threadId = ThreadId.make(`import:${providerInstanceId}:${providerSessionId}`); +const timestamp = "2026-09-01T10:00:00.000Z"; +const now = DateTime.makeUnsafe(timestamp); +const stores = Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provideMerge(SqlitePersistence.layerMemory), +); +const persistence = Layer.mergeAll( + stores, + EventSink.layer.pipe(Layer.provide(stores)), + ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + IdAllocator.layer, +); + +function candidate( + modelSelection: typeof CODEX_MODEL_SELECTION, + providerSessionId: string, + provider: "codex" | "claudeAgent" = "codex", +) { + const providerInstanceId = modelSelection.instanceId; + return { + _tag: "Importable" as const, + source: { + provider, + providerInstanceId, + providerSessionId, + filePath: `/tmp/${providerInstanceId}/${providerSessionId}.jsonl`, + size: 100, + mtimeMs: 2, + device: 3, + inode: 4, + birthtimeMs: 1, + }, + thread: { + source: provider, + providerInstanceId, + providerSessionId, + title: "Imported thread", + model: modelSelection.model, + createdAt: timestamp, + updatedAt: timestamp, + messages: [ + { role: "user" as const, text: "Fix it", createdAt: timestamp }, + { role: "assistant" as const, text: "Fixed", createdAt: timestamp }, + ], + }, + } satisfies AgentSessionScanner.AgentSessionRecentThread; +} + +function importId(source: ReturnType) { + return ThreadId.make( + `import:${source.source.providerInstanceId}:${source.source.providerSessionId}`, + ); +} + +const seedOwner = Effect.fn(function* ( + source: ReturnType, + lifecycle: "active" | "archived" | "deleted" = "active", +) { + const sink = yield* EventSink.EventSinkV2; + const ids = yield* IdAllocator.IdAllocatorV2; + const { providerInstanceId, providerSessionId, provider } = source.source; + const threadId = ThreadId.make(`owner:${providerInstanceId}:${providerSessionId}`); + const driver = ProviderDriverKind.make(provider); + const providerThread: OrchestrationV2ProviderThread = { + id: ids.derive.providerThread({ + driver, + nativeThreadId: providerSessionId, + }), + driver, + providerInstanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: providerSessionId, strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const thread: OrchestrationV2AppThread = { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Native thread", + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: source.thread.model }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: lifecycle === "archived" ? now : null, + deletedAt: lifecycle === "deleted" ? now : null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + }; + yield* sink.write({ + events: [ + { + id: EventId.make(`${threadId}:created`), + type: "thread.created", + threadId, + occurredAt: now, + payload: thread, + }, + { + id: EventId.make(`${threadId}:provider`), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: providerThread, + }, + ], + }); + return threadId; +}); -it.effect("imports messages once and preserves the provider native resume binding", () => { - const writes: Array> = []; - const upserts: Array = []; - const recorded: Array = []; - let imported = false; - const scanner = AgentSessionScanner.AgentSessionScanner.of({ - scan: Effect.die("unused"), - recentThreads: () => - Stream.succeed({ - _tag: "Importable", - source: { - provider: "codex", - providerInstanceId, - providerSessionId, - filePath: "/tmp/native-codex-thread.jsonl", - size: 100, - mtimeMs: 2, - device: 3, - inode: 4, - birthtimeMs: 1, - }, - thread: { - source: "codex", - providerInstanceId, - providerSessionId, - title: "Imported thread", - model: "gpt-5.4", - createdAt: "2026-09-01T10:00:00.000Z", - updatedAt: "2026-09-01T10:01:00.000Z", - messages: [ - { role: "user", text: "Fix it", createdAt: "2026-09-01T10:00:00.000Z" }, - { role: "assistant", text: "Fixed", createdAt: "2026-09-01T10:01:00.000Z" }, - ], - }, - }), +const makeImporter = Effect.fn(function* ( + candidates: ReadonlyArray>, + beforeTransaction: Effect.Effect = Effect.void, +) { + const sql = yield* SqlClient.SqlClient; + const events = yield* EventStore.EventStoreV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const published: Array = []; + const withTransaction: typeof sql.withTransaction = (effect) => + beforeTransaction.pipe(Effect.andThen(sql.withTransaction(effect))); + const importSql = new Proxy(sql, { + get: (target, key, receiver) => + key === "withTransaction" ? withTransaction : Reflect.get(target, key, receiver), }); - const layerTest = AgentSessionImporter.layer.pipe( + const sinkLayer = Layer.fresh(EventSink.layer).pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(AgentSessionScanner.AgentSessionScanner, scanner), - Layer.mock(ProjectService.ProjectService)({ - getById: () => - Effect.succeed( - Option.some({ id: projectId, workspaceRoot: "/workspace/project" } as never), + Layer.succeed(SqlClient.SqlClient, importSql), + Layer.succeed(ProjectionStore.ProjectionStoreV2, projections), + Layer.succeed(EventStore.EventStoreV2, { + ...events, + publishCommitted: (batch) => + events.publishCommitted(batch).pipe( + Effect.tap(() => + Effect.sync(() => { + published.push(...batch); + }), + ), ), }), - Layer.mock(Orchestrator.OrchestratorV2)({ - getThreadRecords: () => - imported - ? Effect.succeed({ - thread: { id: threadId, projectId, historyOrigin: "v1_import" }, - } as never) - : Effect.fail(new Orchestrator.OrchestratorProjectionError({ threadId })), - }), - Layer.mock(EventSink.EventSinkV2)({ - write: (input) => - Effect.sync(() => { - writes.push(input.events); - imported = true; - return []; - }), - }), - Layer.mock(ProviderSessionRuntime.ProviderSessionRuntimeRepository)({ - list: () => Effect.succeed([]), - upsert: (input) => Effect.sync(() => void upserts.push(input)), - recordImportedTranscript: (input) => Effect.sync(() => void recorded.push(input)), - }), - IdAllocator.layer, ), ), ); + const context = yield* Layer.build( + AgentSessionImporter.layer.pipe( + Layer.provide( + Layer.mergeAll( + sinkLayer, + Layer.succeed(IdAllocator.IdAllocatorV2, yield* IdAllocator.IdAllocatorV2), + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository, + ), + Layer.mock(AgentSessionScanner.AgentSessionScanner)({ + recentThreads: () => Stream.fromIterable(candidates), + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeed( + Option.some({ + id: projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: null, + }), + ), + }), + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: (threadId, fields) => + projections + .getThreadRecords(threadId, fields) + .pipe( + Effect.mapError( + (cause) => new Orchestrator.OrchestratorProjectionError({ threadId, cause }), + ), + ), + }), + ), + ), + ), + ); + const importer = Context.get(context, AgentSessionImporter.AgentSessionImporter); + return { run: importer.importRecentAgentThreads({ projectId }), published }; +}); - return Effect.gen(function* () { - const importer = yield* AgentSessionImporter.AgentSessionImporter; - expect(yield* importer.importRecentAgentThreads({ projectId })).toEqual({ - importedCount: 1, - skippedCount: 0, - }); - expect(yield* importer.importRecentAgentThreads({ projectId })).toEqual({ - importedCount: 1, - skippedCount: 0, - }); +it.effect( + "ignores owned sessions, including archived and deleted threads, but imports other instances and unowned sessions", + () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* EventStore.EventStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const active = candidate(CODEX_MODEL_SELECTION, "active"); + const archived = candidate( + CLAUDE_MODEL_SELECTION, + "020e7f00-dce6-406b-8ec6-ed90498945d6", + "claudeAgent", + ); + const deleted = candidate(CODEX_MODEL_SELECTION, "deleted"); + const owners = [ + yield* seedOwner(active), + yield* seedOwner(archived, "archived"), + yield* seedOwner(deleted, "deleted"), + ]; + const before = yield* Effect.forEach(owners, (id) => + projections.getThreadRecords(id, ["providerThreads"]), + ); + const otherInstance = candidate( + { ...CLAUDE_MODEL_SELECTION, instanceId: ProviderInstanceId.make("claude-other") }, + archived.source.providerSessionId, + "claudeAgent", + ); + const unowned = candidate(CODEX_MODEL_SELECTION, "unowned"); + const importer = yield* makeImporter([active, archived, deleted, otherInstance, unowned]); - expect(writes).toHaveLength(1); - expect(writes[0]?.map((event) => event.type)).toEqual([ - "thread.created", - "message.updated", - "turn-item.updated", - "message.updated", - "turn-item.updated", - "provider-thread.updated", - ]); - const created = writes[0]?.find((event) => event.type === "thread.created"); - const providerThread = writes[0]?.find((event) => event.type === "provider-thread.updated"); - expect(created?.payload).toMatchObject({ - id: threadId, - activeProviderThreadId: providerThread?.payload.id, - historyOrigin: "v1_import", - }); - expect(providerThread?.payload).toMatchObject({ - appThreadId: threadId, - nativeThreadRef: { - driver: "codex", - nativeId: providerSessionId, - strength: "strong", - }, - }); + expect(yield* importer.run).toEqual({ importedCount: 2, skippedCount: 0 }); + for (const source of [active, archived, deleted]) { + const threadId = importId(source); + expect(yield* Effect.option(projections.getThread(threadId))).toEqual(Option.none()); + expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual([]); + expect(yield* runtimes.getByThreadId({ threadId })).toEqual(Option.none()); + } + expect( + yield* Effect.forEach(owners, (id) => + projections.getThreadRecords(id, ["providerThreads"]), + ), + ).toEqual(before); + expect( + new Set( + importer.published.flatMap((stored) => + "event" in stored ? [stored.event.threadId] : [], + ), + ), + ).toEqual(new Set([importId(otherInstance), importId(unowned)])); + for (const source of [otherInstance, unowned]) { + const threadId = importId(source); + const records = yield* projections.getThreadRecords(threadId, [ + "messages", + "providerThreads", + ]); + expect(records.messages.map((message) => message.text)).toEqual(["Fix it", "Fixed"]); + expect(records.thread.historyOrigin).toBe("v1_import"); + expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id); + expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe( + source.source.providerSessionId, + ); + expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({ + providerInstanceId: source.source.providerInstanceId, + resumeCursor: + source.source.provider === "codex" + ? { threadId: source.source.providerSessionId } + : { threadId, resume: source.source.providerSessionId }, + runtimePayload: { importedTranscripts: [source.source] }, + }); + } + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + +it.effect("rechecks ownership after the import reaches the commit transaction", () => + Effect.gen(function* () { + const enteringTransaction = yield* Deferred.make(); + const continueTransaction = yield* Deferred.make(); + const source = candidate(CODEX_MODEL_SELECTION, "claimed-during-import"); + const importer = yield* makeImporter( + [source], + Deferred.succeed(enteringTransaction, undefined).pipe( + Effect.andThen(Deferred.await(continueTransaction)), + ), + ); + const pending = yield* Effect.forkScoped(importer.run); + yield* Deferred.await(enteringTransaction); + // Commit ownership after all pre-transaction reads, before the import acquires SQLite. + const ownerId = yield* seedOwner(source); + yield* Deferred.succeed(continueTransaction, undefined); + expect(yield* Fiber.join(pending)).toEqual({ importedCount: 0, skippedCount: 0 }); + expect(yield* importer.run).toEqual({ importedCount: 0, skippedCount: 0 }); + const projections = yield* ProjectionStore.ProjectionStoreV2; + const events = yield* EventStore.EventStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + expect(yield* Effect.option(projections.getThread(importId(source)))).toEqual(Option.none()); + expect(yield* events.read({ threadId: importId(source) }).pipe(Stream.runCollect)).toEqual([]); + expect(yield* runtimes.list()).toEqual([]); + expect(importer.published).toEqual([]); expect( - writes[0] - ?.filter((event) => event.type === "message.updated") - .map((event) => event.payload.text), - ).toEqual(["Fix it", "Fixed"]); - expect(upserts).toEqual([ - expect.objectContaining({ - threadId, - providerInstanceId, - resumeCursor: { threadId: providerSessionId }, - }), - ]); - expect(recorded).toHaveLength(2); - }).pipe(Effect.provide(layerTest)); -}); + (yield* projections.getThreadRecords(ownerId, ["providerThreads"])).providerThreads[0] + ?.appThreadId, + ).toBe(ownerId); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + +it.effect( + "retries failed commits and runtime persistence without replaying committed history", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const events = yield* EventStore.EventStoreV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = candidate(CODEX_MODEL_SELECTION, "retry"); + const threadId = importId(source); + const importer = yield* makeImporter([source]); + yield* sql`CREATE TEMP TRIGGER fail_import BEFORE INSERT ON orchestration_events WHEN NEW.event_type = 'message.updated' BEGIN SELECT RAISE(ABORT, 'test publication failure'); END`; + expect(yield* importer.run).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual([]); + expect(yield* Effect.option(projections.getThread(threadId))).toEqual(Option.none()); + expect(yield* runtimes.list()).toEqual([]); + expect(importer.published).toEqual([]); + yield* sql`DROP TRIGGER fail_import`; + + yield* sql`CREATE TEMP TRIGGER fail_runtime BEFORE INSERT ON provider_session_runtime BEGIN SELECT RAISE(ABORT, 'test runtime failure'); END`; + expect(yield* importer.run).toEqual({ importedCount: 0, skippedCount: 1 }); + const committed = yield* events.read({ threadId }).pipe(Stream.runCollect); + expect(committed).toHaveLength(6); + expect(importer.published).toHaveLength(6); + expect(yield* runtimes.list()).toEqual([]); + yield* sql`DROP TRIGGER fail_runtime`; + + expect(yield* importer.run).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(yield* importer.run).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual(committed); + expect(importer.published).toHaveLength(6); + expect( + (yield* projections.getThreadRecords(threadId, ["messages"])).messages.map( + (message) => message.text, + ), + ).toEqual(["Fix it", "Fixed"]); + expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({ + providerInstanceId: source.source.providerInstanceId, + resumeCursor: { threadId: source.source.providerSessionId }, + runtimePayload: { importedTranscripts: [source.source] }, + }); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 2c8d6a49d4c7..11f168ff1d7b 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -249,6 +249,24 @@ const make = Effect.gen(function* () { providerSessionId: thread.providerSessionId, }); } + const driver = ProviderDriverKind.make(thread.source); + const persistRuntime = runtimes.upsert( + { + threadId, + providerName: driver, + providerInstanceId: thread.providerInstanceId, + adapterKey: driver, + runtimeMode: DEFAULT_RUNTIME_MODE, + status: "stopped", + lastSeenAt: thread.updatedAt, + resumeCursor: + thread.source === "codex" + ? { threadId: thread.providerSessionId } + : { threadId, resume: thread.providerSessionId }, + runtimePayload: { cwd: project.workspaceRoot }, + }, + { onConflict: "ignore" }, + ); const existing = yield* Effect.option(orchestrator.getThreadRecords(threadId, [])); if (Option.isSome(existing)) { if (existing.value.thread.projectId !== input.projectId) { @@ -261,14 +279,15 @@ const make = Effect.gen(function* () { if (existing.value.thread.historyOrigin !== "v1_import") { return yield* new AgentSessionThreadModifiedError({ threadId }); } + yield* persistRuntime; yield* runtimes.recordImportedTranscript({ threadId, source }); return true; } - const driver = ProviderDriverKind.make(thread.source); const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL; const providerThreadId = idAllocator.derive.providerThread({ driver, + providerInstanceId: thread.providerInstanceId, nativeThreadId: thread.providerSessionId, }); const createdAt = dateTime(thread.createdAt); @@ -332,24 +351,8 @@ const make = Effect.gen(function* () { updatedAt, }; - yield* runtimes.upsert( - { - threadId, - providerName: driver, - providerInstanceId: thread.providerInstanceId, - adapterKey: driver, - runtimeMode: DEFAULT_RUNTIME_MODE, - status: "stopped", - lastSeenAt: thread.updatedAt, - resumeCursor: - thread.source === "codex" - ? { threadId: thread.providerSessionId } - : { threadId, resume: thread.providerSessionId }, - runtimePayload: { cwd: project.workspaceRoot }, - }, - { onConflict: "ignore" }, - ); - yield* eventSink.write({ + const committed = yield* eventSink.write({ + unlessNativeThreadOwned: providerThread, events: [ { id: EventId.make(`${IMPORT_EVENT_PREFIX}:thread:${threadId}:created`), @@ -373,6 +376,8 @@ const make = Effect.gen(function* () { }, ], }); + if (committed.length === 0) return null; + yield* persistRuntime; yield* runtimes.recordImportedTranscript({ threadId, source }); return true; }).pipe( @@ -384,10 +389,10 @@ const make = Effect.gen(function* () { }).pipe(Effect.as(false)), ), ); - if (imported) { + if (imported === true) { importedThreadIds.add(threadId); importedCount += 1; - } else { + } else if (imported === false) { skippedCount += 1; } }), From d4a62da20fda8e79fade94579828f1ce0dcdd66a Mon Sep 17 00:00:00 2001 From: Wes Rollings <45522536+SwiggitySwerve@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:39:13 -0600 Subject: [PATCH 058/898] fix(web): settings project picker lists only the chosen environment's projects (#18503) Co-authored-by: Julius Marminge --- .../settings/SettingsScopeSentence.tsx | 3 +- .../settings/settingsScopeAxis.test.ts | 55 ++++++++++++++++++- .../components/settings/settingsScopeAxis.ts | 11 ++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index 85826d0d8dee..1b9e542bbc41 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -33,6 +33,7 @@ import { ALL_PROJECTS_VALUE, environmentAxisValue, projectAxisValue, + projectScopeMenuGroups, selectEnvironmentAxis, selectProjectAxis, settingsScopeEnvironmentAddress, @@ -287,7 +288,7 @@ function EnvironmentScopeMenu({ function ProjectScopeMenu({ value, groups, environments, onChange }: SettingsScopeMenuProps) { const selected = groups.find((group) => group.projectKey === value.project); - const projects = groups.map((group) => ({ + const projects = projectScopeMenuGroups(groups, value).map((group) => ({ group, machines: settingsScopeProjectMachines(group, environments, true).join(", "), })); diff --git a/apps/web/src/components/settings/settingsScopeAxis.test.ts b/apps/web/src/components/settings/settingsScopeAxis.test.ts index 5eecdddcea4e..5954a21ecfa3 100644 --- a/apps/web/src/components/settings/settingsScopeAxis.test.ts +++ b/apps/web/src/components/settings/settingsScopeAxis.test.ts @@ -1,9 +1,11 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; import { environmentAxisValue, projectAxisValue, + projectScopeMenuGroups, selectEnvironmentAxis, selectProjectAxis, settingsScopeEnvironmentLabel, @@ -83,3 +85,54 @@ describe("environmentAxisValue", () => { expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk"); }); }); + +describe("settings project picker", () => { + function group( + projectKey: string, + environmentIds: readonly [EnvironmentId, ...EnvironmentId[]], + ): SidebarProjectSnapshot { + const members = environmentIds.map((environmentId) => ({ + id: ProjectId.make(`${projectKey}-${environmentId}`), + environmentId, + title: projectKey, + workspaceRoot: `/repos/${projectKey}`, + physicalProjectKey: `${environmentId}:/repos/${projectKey}`, + environmentLabel: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-19T00:00:00.000Z", + updatedAt: "2026-10-19T00:00:00.000Z", + })); + return { + ...members[0]!, + projectKey, + displayName: projectKey, + memberProjects: members, + memberProjectRefs: members.map((member) => ({ + environmentId: member.environmentId, + projectId: member.id, + })), + groupedProjectCount: members.length, + environmentPresence: environmentIds.length > 1 ? "mixed" : "local-only", + allRemoteMembersAreDesktopLocal: false, + allRemoteMembersAreWsl: false, + remoteEnvironmentLabels: [], + }; + } + + it("offers groups with any checkout on the chosen environment, or all groups without one", () => { + const laptop = EnvironmentId.make("laptop"); + const server = EnvironmentId.make("server"); + const local = group("local", [laptop]); + const shared = group("shared", [laptop, server]); + const remote = group("remote", [server]); + const groups = [local, shared, remote]; + + expect(projectScopeMenuGroups(groups, { machine: server })).toEqual([shared, remote]); + expect(projectScopeMenuGroups(groups, { machine: laptop })).toEqual([local, shared]); + expect(projectScopeMenuGroups(groups, { machine: "unknown" })).toEqual([]); + expect(projectScopeMenuGroups(groups, {})).toEqual(groups); + expect(projectScopeMenuGroups([], { machine: server })).toEqual([]); + expect(projectScopeMenuGroups([], {})).toEqual([]); + }); +}); diff --git a/apps/web/src/components/settings/settingsScopeAxis.ts b/apps/web/src/components/settings/settingsScopeAxis.ts index 2d770a0b7c70..126747c5c0dd 100644 --- a/apps/web/src/components/settings/settingsScopeAxis.ts +++ b/apps/web/src/components/settings/settingsScopeAxis.ts @@ -69,6 +69,17 @@ export function projectAxisValue(search: SettingsScopeSearch): string { return search.project ?? ALL_PROJECTS_VALUE; } +export function projectScopeMenuGroups( + groups: readonly SidebarProjectSnapshot[], + search: Pick, +) { + const { machine } = search; + if (machine === undefined) return groups; + return groups.filter((group) => + group.memberProjects.some((member) => member.environmentId === machine), + ); +} + export function selectEnvironmentAxis( search: SettingsScopeSearch, value: string, From b2cc18d3f2cbb5978e1e074ac4495b9a8b52504e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 19 Oct 2026 13:39:37 -0500 Subject: [PATCH 059/898] fix(server): keep the shared Codex app-server scope alive after a tool OOM kill (#18329) Co-authored-by: Victor Vogel <263261067+only21mil@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../Adapters/CodexAdapterV2.ts | 1 + apps/server/src/process/agentScope.test.ts | 65 +++++++++++++++++-- apps/server/src/process/agentScope.ts | 37 ++++++----- packages/shared/src/AgentScope.ts | 5 ++ 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index a4f465142042..d9316c7a6915 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1366,6 +1366,7 @@ export const makeCodexAppServerSpawnCommand = Effect.fn( name: "codex", threadId: input.threadId, env: input.env, + shared: true, }); const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { ...(input.env === undefined ? {} : { env: input.env }), diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 396fc22987d4..6f9c161e95ae 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -4,13 +4,15 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; +import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as ServerConfig from "../config.ts"; +import { makeCodexAppServerSpawnCommand } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as ProcessRunner from "../processRunner.ts"; import { agentSliceMemoryLimits, @@ -68,6 +70,7 @@ const withFakeSystemd = ( readonly calls: ReadonlyArray>; readonly stateDir: string; }) => Effect.Effect, + { oomPolicy = true }: { readonly oomPolicy?: boolean } = {}, ) => Effect.gen(function* () { const bin = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agent-scope-")); @@ -76,10 +79,10 @@ const withFakeSystemd = ( } const units = new Map(); const calls: Array> = []; - const output = (stdout: string): ProcessRunner.ProcessRunOutput => ({ + const output = (stdout: string, code = 0): ProcessRunner.ProcessRunOutput => ({ stdout, stderr: "", - code: ChildProcessSpawner.ExitCode(0), + code: ChildProcessSpawner.ExitCode(code), timedOut: false, stdoutTruncated: false, stderrTruncated: false, @@ -90,6 +93,9 @@ const withFakeSystemd = ( run: ({ args }) => Effect.sync(() => { calls.push(args); + if (!oomPolicy && args.some((arg) => arg.startsWith("--property=OOMPolicy="))) { + return output("", 1); + } const [, verb, unit = ""] = args; if (verb === "reset-failed") units.delete(unit); if (verb !== "show") return output(""); @@ -125,6 +131,16 @@ const unitOf = (launch: { readonly args: ReadonlyArray }) => const sliceOf = (launch: { readonly args: ReadonlyArray }) => launch.args.find((arg) => arg.startsWith("--slice="))?.slice("--slice=".length) ?? ""; +const launchCodex = (scope: Effect.Success, threadId: string) => + makeCodexAppServerSpawnCommand({ command: "agent", args: ["app-server"], threadId }).pipe( + Effect.provideService(AgentScope, scope), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.map((command) => { + assert(ChildProcess.isStandardCommand(command)); + return command; + }), + ); + describe("AgentScope service", () => { it.effect("labels a thread from its newest scope until its next session clears it", () => withFakeSystemd(({ scope, units }) => @@ -146,6 +162,47 @@ describe("AgentScope service", () => { ), ); + it.effect( + "keeps shared Codex scopes running without attributing their OOM to the launch thread", + () => + withFakeSystemd(({ scope, units, calls }) => + Effect.gen(function* () { + const agent = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + expect(agent.args).toContain("--property=OOMPolicy=stop"); + units.set(unitOf(agent), { activeState: "failed", result: "oom-kill" }); + expect(yield* scope.oomKilled("a")).toBe(true); + + const codex = yield* launchCodex(scope, "a"); + expect(codex.args).toContain("--property=OOMPolicy=continue"); + expect(codex.args).not.toContain("--property=OOMPolicy=stop"); + expect(codex.args).toContain("--property=TimeoutStopSec=10s"); + expect(codex.args).toContain("--collect"); + units.set(unitOf(codex), { activeState: "active", result: "success" }); + expect(yield* scope.oomKilled("a")).toBe(false); + units.set(unitOf(codex), { activeState: "failed", result: "oom-kill" }); + expect(yield* scope.oomKilled("a")).toBe(false); + expect( + calls.filter(([, verb, unit]) => verb === "show" && unit === unitOf(codex)), + ).toEqual([]); + }), + ), + ); + + it.effect("labels the shared Codex app-server's OOM kill on systemd without OOMPolicy", () => + withFakeSystemd( + ({ scope, units }) => + Effect.gen(function* () { + const codex = yield* launchCodex(scope, "a"); + units.set(unitOf(codex), { activeState: "failed", result: "oom-kill" }); + expect(yield* scope.oomKilled("a")).toBe(true); + expect(codex.args.filter((arg) => arg.includes("OOMPolicy"))).toEqual([]); + expect(codex.args).not.toContain("--collect"); + expect(codex.args).toContain("--property=TimeoutStopSec=10s"); + }), + { oomPolicy: false }, + ), + ); + it.effect("keeps a running scope while hundreds of other threads launch", () => withFakeSystemd(({ scope, units }) => Effect.gen(function* () { diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index ed69eb2ad753..be92a8957fac 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -166,17 +166,9 @@ export const make = Effect.gen(function* () { "--", "true", ]).pipe(Effect.map((result) => result._tag === "Some" && result.value.code === 0)); - // Stop the whole scope when the kernel kills one of its processes, and - // record the result as oom-kill. Scopes take OOMPolicy from systemd 253; - // older user managers still get scopes, without the OOM label. - const stopOptions = [STOP_TIMEOUT_OPTION]; - const oomOptions = [...stopOptions, "--property=OOMPolicy=stop"]; - const scopeOptions = (yield* probe(oomOptions)) - ? oomOptions - : (yield* probe(stopOptions)) - ? stopOptions - : undefined; - if (scopeOptions === undefined) { + // Scopes take OOMPolicy from systemd 253; older managers still get scopes. + const supportsOomPolicy = yield* probe([STOP_TIMEOUT_OPTION, "--property=OOMPolicy=stop"]); + if (!supportsOomPolicy && !(yield* probe([STOP_TIMEOUT_OPTION]))) { yield* Effect.logInfo("Agent scopes are off: no systemd user manager"); return undefined; } @@ -197,13 +189,13 @@ export const make = Effect.gen(function* () { yield* Effect.logInfo("Agent scopes are on", { slice: AGENT_SLICE, ...limits, - oomPolicy: scopeOptions.length > 0, + oomPolicy: supportsOomPolicy, }); return { systemdRun, systemctl, runtimeDir, - scopeOptions, + supportsOomPolicy, }; }), ); @@ -318,21 +310,32 @@ export const make = Effect.gen(function* () { threadId, env, survivesRestart, + shared, }) { const resolved = yield* resolve(command, env ?? hostEnvironment); if (resolved === null) return { command, args }; const scope = yield* systemd; if (scope === undefined) return agentScopeCommand({ command: resolved, args }); const unit = `t3code-${name}-${NodeCrypto.randomUUID().slice(0, 8)}.scope`; - if (threadId !== undefined) yield* track(scope.systemctl, threadId, unit); + // A continuing shared scope cannot attribute a tool's OOM to one thread. + // Clear the launch thread's previous scope instead. + const continuing = shared === true && scope.supportsOomPolicy; + const owned = threadId !== undefined && !continuing; + if (threadId !== undefined) yield* track(scope.systemctl, threadId, owned ? unit : undefined); const systemdScope: AgentSystemdScope = { systemdRun: scope.systemdRun, runtimeDir: scope.runtimeDir, slice: survivesRestart === true ? AGENT_SLICE : ownedSlice, unit, - // Without a thread nothing reads the result, so let systemd unload - // the unit even when it fails. - options: threadId === undefined ? [...scope.scopeOptions, "--collect"] : scope.scopeOptions, + // Without an owning thread nothing reads the result, so let systemd + // unload the unit even when it fails. + options: [ + STOP_TIMEOUT_OPTION, + ...(scope.supportsOomPolicy + ? [`--property=OOMPolicy=${continuing ? "continue" : "stop"}`] + : []), + ...(owned ? [] : ["--collect"]), + ], }; return agentScopeCommand({ command: resolved, args, scope: systemdScope }); }), diff --git a/packages/shared/src/AgentScope.ts b/packages/shared/src/AgentScope.ts index 08af8f0ba23a..80902e8bbd16 100644 --- a/packages/shared/src/AgentScope.ts +++ b/packages/shared/src/AgentScope.ts @@ -14,6 +14,11 @@ export interface AgentScopeShape { * killed server left running before it resumes their sessions. */ readonly survivesRestart?: boolean | undefined; + /** + * Keep a shared process running after a tool OOM when supported. + * Falls back to ordinary scope behavior on older systemd. + */ + readonly shared?: boolean | undefined; }) => Effect.Effect; readonly oomKilled: (threadId: string) => Effect.Effect; /** From ddbc52498da34fba3bb3a4a84b8fa43eceb84446 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Mon, 19 Oct 2026 19:41:31 +0100 Subject: [PATCH 060/898] fix(server): review guides in a fork checkout cover only the pull request's change (#18312) Co-authored-by: Julius Marminge --- .../PullRequestReviewGuideService.test.ts | 105 +++++++++++++++++- .../PullRequestReviewGuideService.ts | 44 +++++++- 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index 403424cdc597..66efa337ecd1 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -3,6 +3,7 @@ import { assert, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -88,7 +89,7 @@ const makeRepositories = Effect.gen(function* () { yield* git(remote, ["checkout", "--quiet", "main"]); yield* commit("unrelated.txt", "main moved on"); yield* git(root, ["clone", "--quiet", remote, workspace]); - return { root, workspace, headSha, lowerSha, upperSha }; + return { root, remote, workspace, headSha, lowerSha, upperSha }; }); const detail = (workspaceRoot: string, headSha: string): PullRequestDetail => @@ -147,6 +148,7 @@ const stack = (repositories: { const makeLayer = (input: { readonly root: string; + readonly remote: string; readonly workspace: string; readonly headSha: string; readonly lowerSha: string; @@ -191,7 +193,10 @@ const makeLayer = (input: { Effect.succeedSome({ id: projectId, workspaceRoot: input.workspace, - repositoryIdentity: input.identity ?? ACME_APP, + repositoryIdentity: { + ...(input.identity ?? ACME_APP), + locator: { ...ACME_APP.locator, remoteUrl: input.remote }, + }, }), } as unknown as ProjectService.ProjectService["Service"]), ), @@ -655,6 +660,102 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic ), ); + it.effect("measures only the PR change using the renamed verified remote's fetch URL", () => + Effect.scoped( + Effect.gen(function* () { + const repositories = yield* makeRepositories; + const fork = `${repositories.root}/fork`; + yield* git(repositories.root, ["clone", "--quiet", repositories.remote, fork]); + yield* git(fork, ["branch", "stack-base", "main~1"]); + // The cached identity still names origin, which now belongs to the stale fork. + yield* git(repositories.workspace, ["remote", "rename", "origin", "upstream"]); + yield* git(repositories.workspace, ["remote", "add", "origin", fork]); + yield* git(repositories.workspace, [ + "config", + `url.${repositories.remote}.insteadOf`, + "review-upstream:", + ]); + yield* git(repositories.workspace, ["remote", "set-url", "upstream", "review-upstream:"]); + const changed = yield* Deferred.make(); + const driver = yield* GitVcsDriver.GitVcsDriver; + const stored = yield* Deferred.make(); + const context = yield* Layer.build( + makeLayer({ + ...repositories, + stored, + generate: (input) => + git(input.cwd, ["diff", "--name-only", `${input.baseCommit}...HEAD`]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + Effect.orDie, + Effect.flatMap((names) => Deferred.succeed(changed, names)), + Effect.as({ stops: [] }), + ), + }), + ); + const service = Context.get( + context, + PullRequestReviewGuideService.PullRequestReviewGuideService, + ); + yield* service.generate(reference); + assert.strictEqual(yield* Deferred.await(changed), "feature.txt"); + yield* Deferred.await(stored); + const result = yield* service.get(reference); + assert.strictEqual(result.guide?.headSha, repositories.headSha); + assert.deepStrictEqual(result.guide?.stops[0]?.files, [{ path: "feature.txt" }]); + }), + ), + ); + + it.effect("refuses generation when the verified remote disappeared despite a usable origin", () => + Effect.scoped( + Effect.gen(function* () { + const repositories = yield* makeRepositories; + const fork = `${repositories.root}/fork`; + yield* git(repositories.root, ["clone", "--quiet", repositories.remote, fork]); + yield* git(fork, ["branch", "stack-base", repositories.lowerSha]); + yield* git(repositories.workspace, ["remote", "set-url", "origin", fork]); + const started = yield* Deferred.make>(); + const stored = yield* Deferred.make(); + const generated = yield* Ref.make(false); + const driver = yield* GitVcsDriver.GitVcsDriver; + const context = yield* Layer.build( + makeLayer({ + ...repositories, + stored, + generate: () => Ref.set(generated, true).pipe(Effect.as({ stops: [] })), + }).pipe( + Layer.provide( + Layer.succeed(GitVcsDriver.GitVcsDriver, { + ...driver, + execute: (call) => + Effect.withFiber((fiber) => + Deferred.succeed(started, fiber).pipe(Effect.andThen(driver.execute(call))), + ), + }), + ), + ), + ); + const service = Context.get( + context, + PullRequestReviewGuideService.PullRequestReviewGuideService, + ); + const before = yield* service.get(reference); + yield* service.generate(reference); + // Join the background run so its failure is visible without polling service state. + yield* Fiber.await(yield* Deferred.await(started)); + const result = yield* service.get(reference); + assert.strictEqual(result.run?._tag, "Failed"); + assert.include( + result.run?._tag === "Failed" ? result.run.detail : "", + "no longer has a remote for the pull request's repository", + ); + assert.deepStrictEqual(result.guide, before.guide); + assert.isFalse(yield* Deferred.isDone(stored)); + assert.isFalse(yield* Ref.get(generated)); + }), + ), + ); + it.effect("refuses to write a guide while the server has review guides off", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index 8086541067fb..edd5dd2c7286 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -108,13 +108,19 @@ const make = Effect.gen(function* () { }, identity, ); - if (own === null || own.host !== key.host || own.repository !== key.repository) { + if ( + identity == null || + own === null || + own.host !== key.host || + own.repository !== key.repository + ) { return yield* new PullRequestOperationError({ operation: "generateReviewGuide", detail: "Guides are written from this project's checkout, and this pull request is in another repository. Write it from a project that is a checkout of that repository.", }); } + return identity.locator; }, ); @@ -148,12 +154,40 @@ const make = Effect.gen(function* () { const checkoutHead = Effect.fn("PullRequestReviewGuideService.checkoutHead")(function* ( workspaceRoot: string, + locator: RepositoryIdentity["locator"], detail: PullRequestDetail, headSha: string, stack: PullRequestStack | null, runId: string, ) { - const remote = yield* git.resolvePrimaryRemoteName(workspaceRoot); + // Identity names can be stale; match effective fetch URLs, including insteadOf rewrites. + const names = yield* git + .execute({ + operation: "PullRequestReviewGuideService.listRemotes", + cwd: workspaceRoot, + args: ["remote"], + }) + .pipe(Effect.map((result) => result.stdout.split("\n").filter((name) => name.length > 0))); + const remote = yield* Effect.findFirst( + [locator.remoteName, ...names.filter((name) => name !== locator.remoteName)], + (name) => + git + .execute({ + operation: "PullRequestReviewGuideService.remoteUrl", + cwd: workspaceRoot, + args: ["remote", "get-url", "--", name], + }) + .pipe( + Effect.map((result) => result.stdout.trim() === locator.remoteUrl), + Effect.orElseSucceed(() => false), + ), + ).pipe(Effect.map(Option.getOrUndefined)); + if (remote === undefined) { + return yield* new PullRequestOperationError({ + operation: "generateReviewGuide", + detail: "This project's checkout no longer has a remote for the pull request's repository.", + }); + } const fetch = (args: ReadonlyArray) => git.execute({ operation: "PullRequestReviewGuideService.fetch", @@ -327,6 +361,7 @@ const make = Effect.gen(function* () { input: PullRequestRef, key: ThreadPullRequestKey, workspaceRoot: string, + locator: RepositoryIdentity["locator"], detail: PullRequestDetail, headSha: string, runId: string, @@ -349,6 +384,7 @@ const make = Effect.gen(function* () { ); const { generated, change } = yield* checkoutHead( workspaceRoot, + locator, detail, headSha, stack, @@ -412,7 +448,7 @@ const make = Effect.gen(function* () { }); } const { key, workspaceRoot, identity } = yield* keyFor(input); - yield* requireOwnRepository(key, identity); + const locator = yield* requireOwnRepository(key, identity); const runKey = keyString(key); yield* pullRequests.invalidate({ reference: input }); const [detail, stored] = yield* Effect.all([pullRequests.detail(input), readStored(key)], { @@ -465,7 +501,7 @@ const make = Effect.gen(function* () { return updated; }); const runId = `${runCounter++}`; - yield* write(input, key, workspaceRoot, detail, currentHeadSha, runId, owns).pipe( + yield* write(input, key, workspaceRoot, locator, detail, currentHeadSha, runId, owns).pipe( Effect.andThen(finish(null)), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) From 382bf4d90623b5a62b850bcd9224f14e7959f1a9 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 05:41:55 +1100 Subject: [PATCH 061/898] fix(server): isolate pull request sync reads by project (#18854) Co-authored-by: SAPHID Co-authored-by: GPT-6.1 Sol Co-authored-by: Julius Marminge --- .../PullRequestSyncReactor.test.ts | 109 ++++++++++++++++-- .../PullRequestSyncReactor.ts | 27 +++-- 2 files changed, 118 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index ea8080f11587..9418de49d9fa 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -499,7 +499,7 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("asks the host once for a pull request shared by two threads", () => + it.effect("asks the host once for a pull request shared by two threads in one project", () => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); @@ -516,6 +516,7 @@ describe("PullRequestSyncReactor", () => { yield* startAndSweep(fixture); assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); const commands = yield* Ref.get(fixture.syncCommands); assert.deepStrictEqual( commands @@ -531,6 +532,86 @@ describe("PullRequestSyncReactor", () => { ), ); + it.effect.each(["summary", "stack"] as const)( + "isolates a failed project %s refresh and retries it after the other project succeeds", + (operation) => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const otherProject = ProjectId.make("other-project"); + const link = makeLink(42, { state: "merged", mergedAt: NOW }); + let failing = true; + const failure = new PullRequestOperationError({ operation, detail: "checkout missing" }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("failing", { pullRequests: [link] }), + makeThread("healthy", { projectId: otherProject, pullRequests: [link] }), + makeThread("same-project", { projectId: otherProject, pullRequests: [link] }), + ]), + summary: (input) => + failing && input.projectId === PROJECT_ID && operation === "summary" + ? Effect.fail(failure) + : Effect.succeed( + makeSummary(input, { + state: "merged", + mergedAt: NOW, + title: input.projectId, + }), + ), + stack: (input) => + failing && input.projectId === PROJECT_ID && operation === "stack" + ? Effect.fail(failure) + : Effect.succeed({ + id: input.projectId, + number: 42, + url: "https://github.com/owner/repository/stacks/42", + base: "main", + layers: [{ number: 42, headBranch: "feature", state: "merged" }], + }), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + yield* reactor.requestSync(link); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + const commands = yield* Ref.get(fixture.syncCommands); + assert.sameDeepMembers( + commands.map((command) => [ + command.threadId, + command.snapshot.title, + command.stack?.id, + ]), + [ + ["healthy", otherProject, otherProject], + ["same-project", otherProject, otherProject], + ], + ); + assert.sameMembers( + (yield* Ref.get(fixture.summaryCalls)).map((call) => call.projectId), + [PROJECT_ID, otherProject], + ); + assert.sameMembers( + (yield* Ref.get(fixture.stackCalls)).map((call) => call.projectId), + operation === "stack" ? [PROJECT_ID, otherProject] : [otherProject], + ); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); + failing = false; + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 3); + assert.strictEqual( + (yield* Ref.get(fixture.summaryCalls)).at(-1)?.projectId, + PROJECT_ID, + ); + const retried = (yield* Ref.get(fixture.syncCommands)).at(-1); + assert.strictEqual(retried?.threadId, ThreadId.make("failing")); + assert.strictEqual(retried?.snapshot.title, PROJECT_ID); + assert.strictEqual(retried?.stack?.id, PROJECT_ID); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("recovers the HTTP port when syncing an older Forgejo link", () => Effect.scoped( Effect.gen(function* () { @@ -580,11 +661,11 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("refreshes closed links through the reactor's project after reopening elsewhere", () => + it.effect("refreshes closed links through each project after reopening elsewhere", () => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); - const stale = yield* Ref.make(true); + const invalidated = yield* Ref.make>([]); const fixture = yield* makeHarness({ snapshot: makeSnapshot([ makeThread("first", { pullRequests: [makeLink(42, { state: "closed" })] }), @@ -594,17 +675,21 @@ describe("PullRequestSyncReactor", () => { }), ]), invalidate: ({ reference }) => - reference?.projectId === makeProject().id && reference.host === "github.com" - ? Ref.set(stale, false) + reference !== undefined + ? Ref.update(invalidated, (projects) => [...projects, reference.projectId]) : Effect.void, summary: (input) => - Ref.get(stale).pipe( - Effect.map((cached) => makeSummary(input, { state: cached ? "closed" : "open" })), + Ref.get(invalidated).pipe( + Effect.map((projects) => + makeSummary(input, { + state: projects.includes(input.projectId) ? "open" : "closed", + }), + ), ), }); yield* Effect.gen(function* () { const reactor = yield* startAndSweep(fixture); - assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); yield* reactor.requestSync({ host: "github.com", repository: "owner/repository", @@ -612,6 +697,14 @@ describe("PullRequestSyncReactor", () => { }); yield* Queue.take(fixture.snapshotReads); yield* reactor.drain; + assert.sameMembers( + [...(yield* Ref.get(invalidated))], + [PROJECT_ID, ProjectId.make("second-project")], + ); + assert.sameMembers( + (yield* Ref.get(fixture.stackCalls)).map((call) => call.projectId), + [PROJECT_ID, ProjectId.make("second-project")], + ); const commands = yield* Ref.get(fixture.syncCommands); yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); const snapshot = yield* Ref.get(fixture.snapshots); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 6bde4404fb7f..398b788c03b2 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -152,9 +152,10 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const lastSyncedAt = new Map(); - const requested = new Map(); - let requestGeneration = 0; - // Requested keys wait in `requested` for one queued sweep, so a burst of links (an agent + const requested = new Set(); + // Requests arriving during a host read wait for the next sweep. + const pendingRequests = new Set(); + // PR keys wait in `pendingRequests` for one queued sweep, so a burst of links (an agent // linking dozens of pull requests) is read together and shares the summary batches. let requestedSweepQueued = false; const retryStacks = new Set(); @@ -195,13 +196,20 @@ export const make = Effect.gen(function* () { const groups = new Map>(); for (const thread of threads) { for (const link of visibleThreadPullRequests(thread.pullRequests ?? [])) { - const key = threadPullRequestKeyOf(link); + // Reads depend on the project's checkout and credentials, including their failures. + const key = `${thread.projectId}\0${threadPullRequestKeyOf(link)}`; const entries = groups.get(key) ?? []; entries.push({ thread, link }); groups.set(key, entries); } } + // A host-level refresh reaches every project, each retaining its own pending retry. + for (const [key, entries] of groups) { + if (pendingRequests.has(threadPullRequestKeyOf(entries[0]!.link))) requested.add(key); + } + pendingRequests.clear(); + for (const key of lastSyncedAt.keys()) if (!groups.has(key)) lastSyncedAt.delete(key); for (const key of retryStacks) if (!groups.has(key)) retryStacks.delete(key); for (const key of requested.keys()) if (!groups.has(key)) requested.delete(key); @@ -278,19 +286,19 @@ export const make = Effect.gen(function* () { repository: first.link.repository, number: first.link.number, }; - const generation = requested.get(key); + const refreshRequested = requested.has(key); // A held merged summary is served without asking the host, so a snapshot still missing its // merge time has to drop it or the slow-cadence re-read would hand back the same gap. const missingMergedAt = entries.some( (entry) => entry.link.snapshot?.state === "merged" && entry.link.snapshot.mergedAt == null, ); - if (generation !== undefined || missingMergedAt) { + if (refreshRequested || missingMergedAt) { yield* pullRequests.invalidate({ reference: ref }); } const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }); const fields = snapshotFieldsOf(summary); const needsStack = - generation !== undefined || + refreshRequested || retryStacks.has(key) || entries.some( (entry) => @@ -330,8 +338,7 @@ export const make = Effect.gen(function* () { } // The host answered, so the cadence clock ticks even if a dispatch below is rejected. lastSyncedAt.set(key, nowMs); - // A refresh requested while the host read was in flight belongs to the next sweep. - if (requested.get(key) === generation) requested.delete(key); + requested.delete(key); yield* Effect.forEach( entries, (entry) => @@ -473,7 +480,7 @@ export const make = Effect.gen(function* () { const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) => Effect.suspend(() => { - requested.set(threadPullRequestKeyOf(key), ++requestGeneration); + pendingRequests.add(threadPullRequestKeyOf(key)); if (requestedSweepQueued) return Effect.void; requestedSweepQueued = true; return worker.enqueue("requested"); From a1e39174493372daca48e6d59545a7681e64d0f6 Mon Sep 17 00:00:00 2001 From: Nikita Koynov <43469098+nkoynov@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:43:31 +0300 Subject: [PATCH 062/898] fix(opencode): send pasted images with turns and steers (#16145) Co-authored-by: Julius Marminge --- .../Adapters/OpenCode2AdapterV2.test.ts | 266 ++++++++++++++++++ .../Adapters/OpenCode2AdapterV2.testkit.ts | 41 ++- .../src/server/v2/adapter.ts | 48 +++- 3 files changed, 351 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 6a56b55400af..6a9f2ffbd049 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1,6 +1,11 @@ +import * as NodeURL from "node:url"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { + ChatAttachmentId, + ChatFileAttachment, + ChatImageAttachment, CheckpointId, EnvironmentId, MessageId, @@ -23,6 +28,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; @@ -43,6 +49,7 @@ import { OPENCODE_PROVIDER, t3McpServerName, } from "@t3tools/provider-opencode/testing"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; import { makeProviderReplayGate, @@ -305,6 +312,9 @@ const resumed = ( readonly external?: boolean; readonly supervised?: boolean; readonly replayGate?: ProviderReplayGate; + readonly attachmentsDir?: string; + readonly fileSystem?: FileSystem.FileSystem; + readonly onPrompt?: (input: unknown) => void; }, ) => Effect.gen(function* () { @@ -325,8 +335,13 @@ const resumed = ( ...entries, ]), { + ...(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }), + ...(options?.onPrompt === undefined ? {} : { onPrompt: options.onPrompt }), ...(options?.external === undefined ? {} : { external: options.external }), ...(options?.replayGate === undefined ? {} : { replayGate: options.replayGate }), + ...(options?.attachmentsDir === undefined + ? {} + : { attachmentsDir: options.attachmentsDir }), }, ); const thread = yield* runtime.resumeThread({ @@ -3377,6 +3392,257 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.provide(NodeCrypto.layer)), ); + it.effect.each([ + { + external: false, + name: "sends only existing supported images as turn and steer prompt files with path text", + }, + { + external: true, + name: "keeps turn and steer attachment paths without local files for external servers", + }, + ])("$name", ({ external }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3 oc2 #images-", + }); + const image = (id: string, name: string, mimeType = "image/png") => + ChatImageAttachment.make({ + type: "image", + id: ChatAttachmentId.make(`thread-opencode2-adapter-${id}-1234-1234-1234-123456789abc`), + name, + mimeType, + sizeBytes: 4, + }); + const screenshot = image("12345678", "screen shot.png"); + const chart = image("abcdefab", "chart.webp", "image/webp"); + const missing = image("23456789", "missing.png"); + const unsupported = image("34567890", "diagram.svg", "image/svg+xml"); + const document = ChatFileAttachment.make({ + type: "file", + id: ChatAttachmentId.make("thread-opencode2-adapter-45678901-1234-1234-1234-123456789abc"), + name: "requirements.pdf", + mimeType: "application/pdf", + sizeBytes: 4, + }); + for (const attachment of [screenshot, chart, unsupported, document]) { + yield* fileSystem.writeFileString( + resolveAttachmentPath({ attachmentsDir, attachment })!, + "data", + ); + } + const prompts: Array = []; + const steerId = "msg_recorded_steer_image"; + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + out("session.prompt", { sessionID: SESSION, id: steerId, delivery: "steer" }), + replyData("session.prompt", { + id: steerId, + sessionID: SESSION, + time: { created: 1790656601500 }, + type: "user", + payload: { text: "And this one?" }, + delivery: "steer", + }), + event("session.inbox.delivered", { sessionID: SESSION, inboxID: steerId }), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { attachmentsDir, external, onPrompt: (input) => prompts.push(input) }, + ); + const ids = yield* IdAllocator.IdAllocatorV2; + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + const input = turnInput(thread); + yield* runtime.startTurn({ + ...input, + message: { + ...input.message, + text: "What is in this image?", + attachments: [screenshot, missing, unsupported, document], + }, + }); + yield* runtime.steerTurn({ + threadId, + runId: input.runId, + providerThread: thread, + providerTurnId: ids.derive.providerTurn({ + driver: OPENCODE_PROVIDER, + nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter`, + }), + message: { + ...input.message, + messageId: MessageId.make("message:opencode2-adapter:steer"), + text: "And this one?", + attachments: [chart, missing, unsupported, document], + }, + }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + assert.equal(prompts.length, 2); + for (const [index, attachment] of [screenshot, chart].entries()) { + const path = resolveAttachmentPath({ attachmentsDir, attachment })!; + assert.deepInclude(prompts[index], { + text: [ + index === 0 ? "What is in this image?" : "And this one?", + `[Attached image "${attachment.name}" is saved at: ${path}]`, + `[Attached image "missing.png" is saved at: ${attachmentsDir}/${missing.id}.png]`, + `[Attached image "diagram.svg" is saved at: ${attachmentsDir}/${unsupported.id}.svg]`, + `[Attached file "requirements.pdf" is saved at: ${attachmentsDir}/${document.id}.pdf]`, + ].join("\n\n"), + }); + if (external) { + assert.notProperty(prompts[index], "files"); + } else { + assert.deepInclude(prompts[index], { + files: [ + { + uri: NodeURL.pathToFileURL(path).href, + name: attachment.name, + }, + ], + }); + } + } + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(IdAllocator.layer, NodeServices.layer))), + ); + + it.effect.each([false, true])( + "rejects an image steer stopped during attachment resolution (held end: %s)", + (heldEnd) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-oc2-stopped-image-", + }); + const image = ChatImageAttachment.make({ + type: "image", + id: ChatAttachmentId.make( + "thread-opencode2-adapter-12345678-1234-1234-1234-123456789abc", + ), + name: "screen.png", + mimeType: "image/png", + sizeBytes: 4, + }); + const path = resolveAttachmentPath({ attachmentsDir, attachment: image })!; + yield* fileSystem.writeFileString(path, "data"); + const resolving = yield* Deferred.make(); + const release = yield* Deferred.make(); + const gate = makeProviderReplayGate(["execution-end", "interrupt-reply"]); + const executionEnded = yield* Deferred.make(); + const prompts: Array = []; + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + ...(heldEnd + ? [ + event("session.execution.succeeded", { sessionID: SESSION }, "execution-end"), + event("session.text.ended", { + sessionID: SESSION, + assistantMessageID: "msg_finished_before_stop", + ordinal: 0, + text: "Finished before Stop.", + }), + ] + : []), + out("session.interrupt", { sessionID: SESSION }), + ...(heldEnd + ? [event("session.usage.updated", { sessionID: SESSION }, "interrupt-reply")] + : []), + reply("session.interrupt", { interrupted: !heldEnd }), + ...(heldEnd ? [] : [event("session.execution.interrupted", { sessionID: SESSION })]), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.started", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { + attachmentsDir, + replayGate: gate, + onPrompt: (input) => prompts.push(input), + fileSystem: { + ...fileSystem, + exists: (file) => + file === path + ? Effect.gen(function* () { + yield* Deferred.succeed(resolving, undefined); + yield* Deferred.await(release); + return yield* fileSystem.exists(file); + }) + : fileSystem.exists(file), + }, + }, + ); + const stopped = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const terminals: Array = []; + yield* runtime.events.pipe( + Stream.tap((event) => { + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === "Finished before Stop." + ) { + return Deferred.succeed(executionEnded, undefined); + } + if (event.type !== "turn.terminal") return Effect.void; + terminals.push(event.status); + return Deferred.succeed(terminals.length === 1 ? stopped : completed, undefined); + }), + Stream.runDrain, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const steer = yield* runtime + .steerTurn({ + threadId, + runId: RunId.make("run:opencode2-adapter"), + providerThread: thread, + providerTurnId: yield* providerTurnId, + message: { + ...turnInput(thread).message, + messageId: MessageId.make("message:opencode2-adapter:steer"), + text: "Do not send this image after Stop.", + attachments: [image], + }, + }) + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(resolving); + if (heldEnd) { + gate.release("execution-end"); + yield* Deferred.await(executionEnded); + } + const interrupt = yield* runtime + .interruptTurn({ + providerThread: thread, + providerTurnId: yield* providerTurnId, + }) + .pipe(Effect.forkScoped); + if (heldEnd) { + assert.isTrue(yield* Effect.promise(() => gate.waitForReached("interrupt-reply"))); + } else { + yield* Deferred.await(stopped); + } + yield* Deferred.succeed(release, undefined); + const result = yield* Fiber.join(steer); + assert.lengthOf(prompts, 1); + assert.isTrue(Exit.isFailure(result)); + if (Exit.isFailure(result)) { + assert.deepInclude(Cause.squash(result.cause), { _tag: "ProviderAdapterProtocolError" }); + } + yield* Deferred.await(stopped); + gate.release("interrupt-reply"); + yield* Fiber.join(interrupt); + yield* runtime.startTurn(secondTurn(thread)); + yield* Deferred.await(completed); + assert.lengthOf(prompts, 2); + assert.deepEqual(terminals, ["interrupted", "completed"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("reads user and assistant text from the session's message list", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntimeWithInstructions([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 6edb8d4e1942..0b29a15d9e9b 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -7,6 +7,7 @@ import { type ProviderReplayTranscript, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; @@ -17,6 +18,8 @@ import * as UrlParams from "effect/http/UrlParams"; import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; @@ -127,6 +130,7 @@ const operationOf = ( const replayHttpClient = ( controller: OpenCodeReplayController, replayGate: ProviderReplayGate | undefined, + onPrompt: ((input: unknown) => void) | undefined, ) => HttpClient.make((request, url) => Effect.tryPromise({ @@ -148,6 +152,7 @@ const replayHttpClient = ( // The event stream and requests are separate connections: a request // is matched only once what was recorded before it was delivered. if (operation.type !== "event.subscribe") await controller.untilInboundDelivered(); + if (operation.type === "session.prompt") onPrompt?.(operation.input); await controller.expectOutbound(operation); if (operation.type === "event.subscribe") { controller.exited = false; @@ -187,6 +192,7 @@ export const replayServer = ( transcript: ProviderReplayTranscript, options?: { readonly external?: boolean; + readonly onPrompt?: (input: unknown) => void; readonly replayGate?: ProviderReplayGate; readonly borrowers?: { current: number }; }, @@ -202,7 +208,7 @@ export const replayServer = ( const opencode = yield* OpenCode2Client.make.pipe( Effect.provideService( HttpClient.HttpClient, - replayHttpClient(controller, options?.replayGate), + replayHttpClient(controller, options?.replayGate, options?.onPrompt), ), ); const connection = { @@ -233,6 +239,7 @@ const makeReplayAdapter = ( transcript: ProviderReplayTranscript, options?: { readonly external?: boolean; + readonly onPrompt?: (input: unknown) => void; readonly replayGate?: ProviderReplayGate; readonly borrowers?: { current: number }; }, @@ -246,6 +253,20 @@ const makeReplayAdapter = ( const layerReplayHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)); +const layerReplayHostWithAttachments = (attachmentsDir: string) => + Layer.effect( + ProviderHost.ProviderHost, + Effect.gen(function* () { + const host = yield* ProviderHost.ProviderHost; + return ProviderHost.ProviderHost.of({ + ...host, + paths: { ...host.paths, attachmentsDir }, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir, attachment }), + }); + }), + ).pipe(Layer.provide(layerReplayHost)); + function layerRegistry( transcript: OpenCode2ReplayTranscript, options?: { readonly replayGate?: ProviderReplayGate }, @@ -261,11 +282,15 @@ export const openCode2ReplayRuntime = ( entries: ReadonlyArray, options?: { readonly external?: boolean; + readonly onPrompt?: (input: unknown) => void; readonly borrowers?: { current: number }; readonly replayGate?: ProviderReplayGate; + readonly attachmentsDir?: string; + readonly fileSystem?: FileSystem.FileSystem; }, ) => Effect.gen(function* () { + const fileSystem = options?.fileSystem ?? (yield* FileSystem.FileSystem); const adapter = yield* makeReplayAdapter( { provider: OPENCODE_PROVIDER, @@ -275,7 +300,7 @@ export const openCode2ReplayRuntime = ( entries, }, options, - ); + ).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); return yield* adapter.openSession({ threadId: ThreadId.make("thread:opencode2-adapter"), providerSessionId: ProviderSessionId.make("provider-session:opencode2-adapter"), @@ -289,7 +314,17 @@ export const openCode2ReplayRuntime = ( cwd: "/work/opencode2", }, }); - }).pipe(Effect.provide(Layer.mergeAll(layerReplayHost, IdAllocator.layer, NodeServices.layer))); + }).pipe( + Effect.provide( + Layer.mergeAll( + options?.attachmentsDir === undefined + ? layerReplayHost + : layerReplayHostWithAttachments(options.attachmentsDir), + IdAllocator.layer, + NodeServices.layer, + ), + ), + ); export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< OpenCode2ReplayTranscript, diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 736a01d715c8..1af260b9d05c 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -1,3 +1,5 @@ +import * as NodeURL from "node:url"; + import { AbsolutePath, Agent, @@ -14,6 +16,7 @@ import { import { Mcp } from "@opencode/schema/mcp"; import { isOrchestrationV2WorkActive, + type ChatAttachment, type OrchestrationV2AppThread, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, @@ -39,6 +42,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schedule from "effect/Schedule"; @@ -59,7 +63,10 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; -import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; +import { + isProviderNativeImageAttachment, + providerMessageTextWithAttachmentPaths, +} from "@t3tools/provider-core/server/attachmentPrompt"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { backgroundWorkNotification, @@ -665,6 +672,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const idAllocator = yield* IdAllocator.IdAllocatorV2; const host = yield* ProviderHost.ProviderHost; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const fileSystem = yield* FileSystem.FileSystem; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const crypto = yield* Crypto.Crypto; const driver = OPENCODE_PROVIDER; @@ -2920,6 +2928,21 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr resolveAttachmentPath: host.resolveAttachmentPath, }).trim(); + const promptFiles = Effect.fnUntraced(function* (attachments: ReadonlyArray) { + const files: Array<{ uri: string; name: string }> = []; + // An external server may not share T3's attachment directory. + if (connection.external) return files; + for (const attachment of attachments) { + if (!isProviderNativeImageAttachment(attachment)) continue; + const path = host.resolveAttachmentPath(attachment); + if (path === null) continue; + // OpenCode rejects the whole prompt if an attached file is missing. + const exists = yield* fileSystem.exists(path).pipe(Effect.orElseSucceed(() => false)); + if (exists) files.push({ uri: NodeURL.pathToFileURL(path).href, name: attachment.name }); + } + return files; + }); + const removeMcp = (mcp: { readonly name: string; readonly directory: string }) => Effect.suspend(() => client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }), @@ -3260,12 +3283,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ) : []; + const files = yield* promptFiles(turnInput.message.attachments); if (!sending()) return; return yield* client.session .prompt({ sessionID, id, text: promptText(turnInput), + ...(files.length === 0 ? {} : { files }), ...(skills.length === 0 ? {} : { skills: skills.map((id) => ({ id: Skill.ID.make(id) })) }), @@ -3587,6 +3612,26 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (turn.settledInbox.has(inboxID)) return; turn.steers.add(inboxID); state.strandedSteers.delete(inboxID); + const files = yield* promptFiles(steerInput.message.attachments); + if (state.active !== turn || turn.interrupted) { + yield* lock.withPermit( + Effect.gen(function* () { + turn.steers.delete(inboxID); + // Stop may have stranded this steer, but it never reached OpenCode. + state.strandedSteers.delete(inboxID); + if (state.active === turn) { + if (turn.interrupted && turn.heldEnd !== undefined) { + turn.heldEnd = { status: "interrupted" }; + } + yield* endIfSettled(state, turn); + } + }), + ); + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: OPENCODE_PROVIDER, + detail: `OpenCode turn ${steerInput.providerTurnId} is not active`, + }); + } yield* client.session .prompt({ sessionID: Session.ID.make(sessionId), @@ -3596,6 +3641,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr attachments: steerInput.message.attachments, resolveAttachmentPath: host.resolveAttachmentPath, }).trim(), + ...(files.length === 0 ? {} : { files }), delivery: "steer", }) .pipe( From 88ea6e86a532eacde3cce6e175d82314e3b8d1fe Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:14:08 +0530 Subject: [PATCH 063/898] fix(server): one bad legacy message no longer stops the server from starting (#15920) Co-authored-by: Claude Sonnet 5 Co-authored-by: PR Batch Tester Co-authored-by: Julius Marminge --- .../legacy/LegacyV1ThreadImporter.test.ts | 166 +++++++++++++++++- .../legacy/LegacyV1ThreadImporter.ts | 79 ++++----- 2 files changed, 201 insertions(+), 44 deletions(-) diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts index 869cb27629ce..f10013467de3 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts @@ -1,10 +1,17 @@ import { assert, it } from "@effect/vitest"; -import { EventId, ThreadId } from "@t3tools/contracts"; +import { + ComposerContextId, + EventId, + type OrchestrationMessageContext, + ThreadId, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts"; @@ -36,6 +43,113 @@ const layerTest = Layer.mergeAll( layerProjectionMaintenanceProvided, ); +const seedPendingTranscripts = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; + const threadIds = [ThreadId.make("thread:first"), ThreadId.make("thread:later")]; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at + ) VALUES ( + 'project:background', 'Legacy project', '/tmp/legacy-background', '[]', + '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z' + ) + `; + for (const threadId of threadIds) { + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + interaction_mode, created_at, updated_at + ) VALUES ( + ${threadId}, 'project:background', 'Legacy thread', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', 'default', + '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, is_streaming, created_at, updated_at + ) VALUES + (${`${threadId}:old`}, ${threadId}, 'user', 'Old question', 0, + '2026-01-01T01:00:00.000Z', '2026-01-01T01:00:00.000Z'), + (${`${threadId}:latest`}, ${threadId}, 'user', 'Latest question', 0, + '2026-01-02T01:00:00.000Z', '2026-01-02T01:00:00.000Z') + `; + } + yield* importer.reconcileShells; +}); + +it.effect( + "keeps hydrating later transcripts after a defect and leaves the failed thread pending", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* seedPendingTranscripts; + yield* sql` + UPDATE projection_thread_messages SET created_at = '0000-not-a-date' + WHERE message_id = 'thread:first:old' + `; + + yield* importer.importPendingTranscripts; + + const later = yield* projections.getThreadProjection(ThreadId.make("thread:later")); + assert.deepStrictEqual( + later.messages.map((message) => message.text), + ["Old question", "Latest question"], + ); + assert.deepStrictEqual( + yield* sql` + SELECT thread_id, transcript_imported_at, last_error FROM orchestration_v2_legacy_imports + WHERE transcript_imported_at IS NULL OR last_error IS NOT NULL + `, + [ + { + thread_id: "thread:first", + transcript_imported_at: null, + last_error: "Transcript hydration failed; retry on next open.", + }, + ], + ); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("propagates interruption from transcript reads and retry-marker writes", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; + yield* seedPendingTranscripts; + const interrupted = []; + for (const phase of ["transcript", "retry-marker"]) { + const exit = yield* importer.importPendingTranscripts.pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + const [query] = statement.compile(); + const readingTranscript = query.includes("FROM projection_thread_messages"); + const writingRetryMarker = query.includes("SET last_error ="); + if (phase === "retry-marker" && readingTranscript) { + return Effect.die("transcript read failed"); + } + if (phase === "transcript" ? readingTranscript : writingRetryMarker) { + return Effect.interrupt; + } + return Effect.succeed(statement); + }), + Effect.exit, + ); + interrupted.push(Exit.hasInterrupts(exit)); + } + assert.deepStrictEqual(interrupted, [true, true]); + assert.deepStrictEqual( + yield* sql` + SELECT thread_id FROM orchestration_v2_legacy_imports + WHERE transcript_imported_at IS NOT NULL OR last_error IS NOT NULL + `, + [], + ); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { it.effect("uses the created-thread index for startup migration checks", () => Effect.gen(function* () { @@ -214,6 +328,31 @@ it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { ) `; + const validContext = { + version: 1, + records: [ + { + version: 1, + contextId: ComposerContextId.make("ctx_1"), + kind: "skill", + label: "$test", + name: "test", + }, + ], + } satisfies OrchestrationMessageContext; + yield* sql` + UPDATE projection_thread_messages + SET context_json = CASE message_id + WHEN 'message:legacy:2' THEN '{broken-json' + WHEN 'message:legacy:3' THEN '{"version":2,"records":[]}' + ELSE ${JSON.stringify(validContext)} + END + WHERE thread_id = ${threadId} + `; + const sourceMessages = yield* sql` + SELECT * FROM projection_thread_messages WHERE thread_id = ${threadId} ORDER BY message_id + `; + yield* sql` INSERT INTO projection_thread_pull_requests ( thread_id, host, repository, number, url, source, linked_at, snapshot_json @@ -284,6 +423,13 @@ it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { shellProjection.messages.map((message) => message.id), ["message:legacy:3", "message:legacy:4"], ); + assert.deepStrictEqual( + shellProjection.messages.map((message) => [message.text, message.context]), + [ + ["Follow-up question", undefined], + ["Partial answer", validContext], + ], + ); const renamedAt = DateTime.makeUnsafe("2026-01-05T00:00:00.000Z"); yield* eventSink.write({ @@ -308,7 +454,7 @@ it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { ], }); - const transcriptImport = yield* importer.ensureTranscript(threadId); + const transcriptImport = yield* importer.importPendingTranscripts; assert.equal(yield* importer.pendingThreadCount, 0); assert.deepStrictEqual(transcriptImport, { importedThreadCount: 1, @@ -326,6 +472,22 @@ it.layer(layerTest)("LegacyV1ThreadImporter", (it) => { projection.messages.map((message) => message.id), ["message:legacy:1", "message:legacy:2", "message:legacy:3", "message:legacy:4"], ); + const expectedMessages = [ + ["First question", validContext], + ["First answer", undefined], + ["Follow-up question", undefined], + ["Partial answer", validContext], + ]; + assert.deepStrictEqual( + projection.messages.map((message) => [message.text, message.context]), + expectedMessages, + ); + assert.deepStrictEqual( + yield* sql` + SELECT * FROM projection_thread_messages WHERE thread_id = ${threadId} ORDER BY message_id + `, + sourceMessages, + ); assert.deepStrictEqual( projection.turnItems .filter( diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index ced91b12fcc6..c07eca4048bb 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -22,6 +22,7 @@ import { TurnItemId, } from "@t3tools/contracts"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -126,6 +127,7 @@ const decodeLinkedPullRequest = Schema.decodeUnknownOption(ThreadLinkedPullReque const decodeStoredThread = Schema.decodeUnknownOption( Schema.fromJsonString(OrchestrationV2AppThreadJson), ); +const decodeMessageContext = Schema.decodeUnknownOption(OrchestrationMessageContext); function parseJson(json: string): unknown { try { @@ -151,6 +153,11 @@ function attachmentsFor(row: LegacyMessageRow) { return Option.getOrElse(decodeAttachments(parseJson(row.attachments_json)), () => []); } +function contextFor(row: LegacyMessageRow) { + if (!row.context_json) return undefined; + return Option.getOrUndefined(decodeMessageContext(parseJson(row.context_json))); +} + function linkedPullRequestFor(row: LegacyThreadRow) { if (row.linked_pull_request_json === null) return null; return Option.getOrNull(decodeLinkedPullRequest(parseJson(row.linked_pull_request_json))); @@ -248,6 +255,7 @@ function messageEvents(row: LegacyMessageRow): ReadonlyArray - Effect.logWarning("Failed to hydrate migrated v1 thread transcript", { - threadId: row.thread_id, - cause: error, - }), - ), - Effect.catch(() => - sql` - UPDATE orchestration_v2_legacy_imports - SET last_error = 'Transcript hydration failed; retry on next open.' - WHERE thread_id = ${row.thread_id} - `.pipe( - Effect.as({ importedThreadCount: 0, importedMessageCount: 0 }), - Effect.orElseSucceed(() => ({ - importedThreadCount: 0, - importedMessageCount: 0, - })), - ), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("Failed to hydrate migrated v1 thread transcript", { + threadId: row.thread_id, + cause, + }).pipe( + Effect.andThen(() => + sql` + UPDATE orchestration_v2_legacy_imports + SET last_error = 'Transcript hydration failed; retry on next open.' + WHERE thread_id = ${row.thread_id} + `.pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + () => Effect.void, + ), + ), + ), + Effect.as({ importedThreadCount: 0, importedMessageCount: 0 }), + ), ), ); importedThreadCount += result.importedThreadCount; @@ -813,9 +806,11 @@ const make = Effect.gen(function* () { return { importedThreadCount, importedMessageCount }; }).pipe( Effect.catchCause((cause) => - Effect.logWarning("Legacy v1 transcript background import stopped", { cause }).pipe( - Effect.as({ importedThreadCount: 0, importedMessageCount: 0 }), - ), + Cause.hasInterrupts(cause) + ? Effect.failCause(Cause.fromReasons(cause.reasons.filter(Cause.isInterruptReason))) + : Effect.logWarning("Legacy v1 transcript background import stopped", { cause }).pipe( + Effect.as({ importedThreadCount: 0, importedMessageCount: 0 }), + ), ), ); From 166e4e7187cea448c5499a626c581597b81cb177 Mon Sep 17 00:00:00 2001 From: maco Date: Mon, 19 Oct 2026 20:44:24 +0200 Subject: [PATCH 064/898] fix(web): record the layout character in the keybinding editor (#15972) Co-authored-by: macodev00 Co-authored-by: Julius Marminge --- .../KeybindingsSettings.logic.test.ts | 72 ++++++++++++++++++- .../settings/KeybindingsSettings.logic.ts | 20 +++++- .../settings/KeybindingsSettings.tsx | 8 ++- apps/web/src/terminal/ghostty/keyCodes.ts | 28 +------- packages/shared/src/keybindings.ts | 24 +++++++ 5 files changed, 123 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 99e8ad7d20d8..9abea577d5a4 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -1,10 +1,13 @@ -import { describe, expect, it } from "vite-plus/test"; +import { assert, describe, expect, it } from "vite-plus/test"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { compileResolvedKeybindingsConfig, DEFAULT_RESOLVED_KEYBINDINGS, + parseKeybindingShortcut, } from "@t3tools/shared/keybindings"; +import { resolveShortcutCommand } from "../../keybindings"; + import { buildKeybindingRows, buildKeybindingCommandOptions, @@ -254,6 +257,73 @@ describe("KeybindingsSettings.logic", () => { ).toBe(expected); }); + it("records layout punctuation while preserving physical Alt shortcuts; saved bindings still trigger", () => { + for (const { key, code, altKey = false, shiftKey, expected, defaultInput } of [ + { key: "#", code: "Backslash", shiftKey: false, expected: "#", defaultInput: "\\" }, + { + key: "'", + code: "Backslash", + shiftKey: true, + expected: "shift+'", + defaultInput: "shift+\\", + }, + { + key: "¡", + code: "Digit1", + altKey: true, + shiftKey: false, + expected: "alt+1", + defaultInput: "alt+1", + }, + ]) { + const pressed = { + key, + code, + metaKey: false, + ctrlKey: false, + altKey, + shiftKey, + }; + expect(keybindingFromKeyboardEvent(pressed, "MacIntel")).toBe(defaultInput); + const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); + expect(input).toBe(expected); + assert(input); + const shortcut = parseKeybindingShortcut(input); + assert(shortcut); + expect( + resolveShortcutCommand(pressed, [{ command: "chat.new", shortcut }], { + platform: "MacIntel", + }), + ).toBe("chat.new"); + } + }); + + it("records an unshifted brace literally and a US shifted brace as Shift+[; both trigger the saved binding", () => { + for (const { shiftKey, expected } of [ + { shiftKey: false, expected: "mod+{" }, + { shiftKey: true, expected: "mod+shift+[" }, + ]) { + const pressed = { + key: "{", + code: "BracketLeft", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey, + }; + const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); + expect(input).toBe(expected); + assert(input); + const shortcut = parseKeybindingShortcut(input); + assert(shortcut); + expect( + resolveShortcutCommand(pressed, [{ command: "thread.previous", shortcut }], { + platform: "MacIntel", + }), + ).toBe("thread.previous"); + } + }); + it("captures Latin layout keys instead of their punctuation position", () => { expect( keybindingFromKeyboardEvent( diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 28409786007a..0c9a5f32a8c4 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -9,6 +9,7 @@ import { import { DEFAULT_RESOLVED_KEYBINDINGS, parseKeybindingWhenExpression, + US_SHIFTED_TO_UNSHIFTED_KEYS, } from "@t3tools/shared/keybindings"; import { shortcutKeyFromEvent } from "../../keybindings"; @@ -389,6 +390,18 @@ function titleCaseCommandSegment(segment: string): string { return words.join(" "); } +function recordedLayoutShortcutKey( + event: Pick, +): string { + const physicalKey = shortcutKeyFromEvent(event); + const layoutKey = event.key.toLowerCase(); + if (event.altKey || layoutKey.length !== 1) return physicalKey; + // Normalize only US shifted glyphs at their US position. Other layouts can + // produce the same glyph without Shift or at a different position. + const unshiftedKey = event.shiftKey ? US_SHIFTED_TO_UNSHIFTED_KEYS.get(layoutKey) : undefined; + return unshiftedKey === physicalKey ? physicalKey : layoutKey; +} + function normalizeShortcutKeyToken(key: string): string | null { const normalized = key.toLowerCase(); if ( @@ -422,8 +435,13 @@ function normalizeShortcutKeyToken(key: string): string | null { export function keybindingFromKeyboardEvent( event: Pick, platform: string, + options?: { + readonly layoutCharacter?: boolean; + }, ): string | null { - const keyToken = normalizeShortcutKeyToken(shortcutKeyFromEvent(event)); + const keyToken = normalizeShortcutKeyToken( + options?.layoutCharacter ? recordedLayoutShortcutKey(event) : shortcutKeyFromEvent(event), + ); if (!keyToken) return null; const parts: string[] = []; diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 995ca8cab379..3740cf91c951 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -742,7 +742,9 @@ function useKeybindingRowEditor({ setDraft({ keyDraft: row.key, isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform, { + layoutCharacter: true, + }); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); }; @@ -1052,7 +1054,9 @@ function useNewKeybindingDraft({ setDraft({ keyDraft: "", isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform, { + layoutCharacter: true, + }); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); }; diff --git a/apps/web/src/terminal/ghostty/keyCodes.ts b/apps/web/src/terminal/ghostty/keyCodes.ts index 7751f9b14b50..cab2c671e744 100644 --- a/apps/web/src/terminal/ghostty/keyCodes.ts +++ b/apps/web/src/terminal/ghostty/keyCodes.ts @@ -1,3 +1,5 @@ +import { US_SHIFTED_TO_UNSHIFTED_KEYS } from "@t3tools/shared/keybindings"; + // This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are // intentionally derived from the official W3C-aligned enum instead of // maintaining a second keyboard protocol. @@ -197,30 +199,6 @@ export interface GhosttyKeyboardLayoutMap { get(code: string): string | undefined; } -const shiftedToUnshiftedCharacter = new Map([ - ["!", "1"], - ["@", "2"], - ["#", "3"], - ["$", "4"], - ["%", "5"], - ["^", "6"], - ["&", "7"], - ["*", "8"], - ["(", "9"], - [")", "0"], - ["~", "`"], - ["_", "-"], - ["+", "="], - ["{", "["], - ["}", "]"], - ["|", "\\"], - [":", ";"], - ['"', "'"], - ["<", ","], - [">", "."], - ["?", "/"], -]); - let keyboardLayoutMapPromise: Promise | undefined; export function loadGhosttyKeyboardLayoutMap(): Promise { @@ -258,7 +236,7 @@ export function ghosttyUnshiftedCodepoint( } if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32; if (event.shiftKey) { - const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key); + const unshiftedCharacter = US_SHIFTED_TO_UNSHIFTED_KEYS.get(event.key); if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0; const lowercase = event.key.toLowerCase(); if (lowercase !== event.key && [...lowercase].length === 1) { diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index afadd35b6a1a..b511f830f69b 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -53,6 +53,30 @@ export const EVENT_CODE_SHORTCUT_KEYS: Readonly> = { Slash: "/", }; +export const US_SHIFTED_TO_UNSHIFTED_KEYS: ReadonlyMap = new Map([ + ["!", "1"], + ["@", "2"], + ["#", "3"], + ["$", "4"], + ["%", "5"], + ["^", "6"], + ["&", "7"], + ["*", "8"], + ["(", "9"], + [")", "0"], + ["~", "`"], + ["_", "-"], + ["+", "="], + ["{", "["], + ["}", "]"], + ["|", "\\"], + [":", ";"], + ['"', "'"], + ["<", ","], + [">", "."], + ["?", "/"], +]); + export function isMacPlatform(platform: string): boolean { return /mac|iphone|ipad|ipod/i.test(platform); } From b459f2ad4e07bcedb88bd8e38038a642555a838f Mon Sep 17 00:00:00 2001 From: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:44:43 -0300 Subject: [PATCH 065/898] fix(web): new thread shortcut works while a sent draft sets up its worktree (#14538) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 68 +++++++++++---------- apps/web/src/components/ThreadRouteView.tsx | 3 +- apps/web/src/composerDraftStore.test.ts | 8 ++- apps/web/src/composerDraftStore.ts | 5 +- 4 files changed, 46 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 694e9cf32907..0d9808977dba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -325,7 +325,7 @@ import { clearBackgroundDraftSubmissionByRef, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, - restoreFailedBackgroundDraftThread, + restoreFailedDraftThread, composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, @@ -6845,6 +6845,11 @@ export default function ChatView(props: ChatViewProps) { }), ); } + const sentDraftThreadRef = isLocalDraftThread + ? scopeThreadRef(environmentId, threadIdForSend) + : null; + // Keep new-thread flows from reusing this draft while attachments and worktree setup settle. + if (sentDraftThreadRef) markPromotedDraftThreadByRef(sentDraftThreadRef); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -6998,7 +7003,6 @@ export default function ChatView(props: ChatViewProps) { }, }); if (backgroundThreadRef) { - markPromotedDraftThreadByRef(backgroundThreadRef); try { backgroundDraftOpened = Boolean( await handleNewThread( @@ -7032,12 +7036,15 @@ export default function ChatView(props: ChatViewProps) { releaseDraftAttachments(composerAttachmentsSnapshot); } acknowledgeActiveThreadWoke(); - if (backgroundThreadRef) { + // A draft still on screen finalizes once its thread route takes over. + if (sentDraftThreadRef) { if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) { - finalizePromotedDraftThreadByRef(backgroundThreadRef); + finalizePromotedDraftThreadByRef(sentDraftThreadRef); } else { - clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + clearBackgroundDraftSubmissionByRef(sentDraftThreadRef); } + } + if (backgroundThreadRef) { if (backgroundDraftOpened) { toastManager.add( stackedThreadToast({ @@ -7066,8 +7073,8 @@ export default function ChatView(props: ChatViewProps) { } if (failure !== null) { - if (submissionIntent === "background" && draftId && draftThread) { - restoreFailedBackgroundDraftThread( + if (isLocalDraftThread && draftId && draftThread) { + restoreFailedDraftThread( draftId, draftThread, wasBootstrapThreadDeleted(squashAtomCommandFailure(failure)) @@ -7077,20 +7084,9 @@ export default function ChatView(props: ChatViewProps) { clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); } if ( - backgroundDraftOpened - ? !composerDraftHasUserContent( - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), - ) - : promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerFilesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) - ?.previewAnnotations.length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.threadContexts - .length ?? 0) === 0 + !composerDraftHasUserContent( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ) ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -7100,11 +7096,7 @@ export default function ChatView(props: ChatViewProps) { const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); - promptRef.current = messageTextForSend; const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - composerImagesRef.current = retryComposerImages; - composerFilesRef.current = composerFilesSnapshot; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, messageTextForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); @@ -7112,11 +7104,17 @@ export default function ChatView(props: ChatViewProps) { setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); setComposerDraftThreadContexts(composerDraftTarget, composerThreadContextsSnapshot); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length), - prompt: messageTextForSend, - detectTrigger: true, - }); + if (currentRouteThreadKeyRef.current === routeThreadKey) { + promptRef.current = messageTextForSend; + composerImagesRef.current = retryComposerImages; + composerFilesRef.current = composerFilesSnapshot; + composerTerminalContextsRef.current = composerTerminalContextsSnapshot; + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length), + prompt: messageTextForSend, + detectTrigger: true, + }); + } } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); @@ -7124,11 +7122,17 @@ export default function ChatView(props: ChatViewProps) { threadIdForSend, error instanceof Error ? error.message : "Failed to send message.", ); - if (backgroundDraftOpened && draftId) { + if ( + draftId && + (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) + ) { toastManager.add( stackedThreadToast({ type: "error", - title: "Background task failed", + title: + submissionIntent === "background" + ? "Background task failed" + : "Could not start thread", description: error instanceof Error ? error.message : "Failed to send message.", actionProps: { children: "Open draft", diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 12fbd0d55036..6bbe65761fbd 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -35,6 +35,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { draftId === null ? null : store.getDraftSession(draftId), ); const threadRefs = useThreadRefs(); + // Promotion can begin before the server creates the reserved thread. const inferredThreadRef = draftSession ? (threadRefs.find( (ref) => @@ -43,7 +44,7 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { ) ?? null) : null; const serverThreadRef: ScopedThreadRef | null = - target.kind === "server" ? target.threadRef : (draftSession?.promotedTo ?? inferredThreadRef); + target.kind === "server" ? target.threadRef : inferredThreadRef; const serverThread = useQuietThreadShell(serverThreadRef); const backgroundSubmissionPending = useBackgroundDraftSubmissionPending( target.kind === "draft" ? serverThreadRef : null, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 629adc76fe80..410aba2b39bf 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -69,7 +69,7 @@ import { clearBackgroundDraftSubmissionByRef, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, - restoreFailedBackgroundDraftThread, + restoreFailedDraftThread, type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, @@ -1643,7 +1643,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull(); }); - it("restores a failed background draft without changing the fresh draft", () => { + it("restores a failed draft without changing the fresh draft", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); const sentDraft = useComposerDraftStore.getState().getDraftSession(draftId)!; @@ -1651,12 +1651,14 @@ describe("composerDraftStore project draft thread mapping", () => { const freshId = DraftId.make("fresh-background-draft"); store.setProjectDraftThreadId(projectRef, freshId, { threadId: ThreadId.make("fresh-thread") }); store.setPrompt(freshId, "new work"); - restoreFailedBackgroundDraftThread(draftId, sentDraft, ThreadId.make("retry-thread")); + store.setDraftThreadContext(draftId, { branch: "retry-base" }); + restoreFailedDraftThread(draftId, sentDraft, ThreadId.make("retry-thread")); store.setPrompt(draftId, "retry work"); expect(useComposerDraftStore.getState().getDraftSession(draftId)?.promotedTo).toBeNull(); expect(useComposerDraftStore.getState().getDraftSession(draftId)?.threadId).toBe( "retry-thread", ); + expect(useComposerDraftStore.getState().getDraftSession(draftId)?.branch).toBe("retry-base"); expect(draftByKey(freshId)?.prompt).toBe("new work"); expect(draftByKey(draftId)?.prompt).toBe("retry work"); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 76b86ae4cd46..b93af955f32d 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4270,7 +4270,7 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { } } -export function restoreFailedBackgroundDraftThread( +export function restoreFailedDraftThread( draftId: DraftId, draftThread: DraftThreadState, threadId: ThreadId, @@ -4279,8 +4279,9 @@ export function restoreFailedBackgroundDraftThread( draftThreadsByThreadKey: { ...state.draftThreadsByThreadKey, [draftId]: { - ...draftThread, + ...(state.draftThreadsByThreadKey[draftId] ?? draftThread), threadId, + ...(threadId !== draftThread.threadId ? { createdAt: new Date().toISOString() } : {}), promotedTo: null, }, }, From 954d3bdd894ff6f24c4affe37189ea00df7d9f52 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:47:01 +0200 Subject: [PATCH 066/898] fix(web): preserve images copied from unsent drafts (#17821) Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ChatComposer.tsx | 133 +++++++++++------- apps/web/src/composerDraftStore.test.ts | 60 ++++++++ apps/web/src/lib/composerContextRecords.ts | 2 +- apps/web/src/lib/copiedComposerAttachments.ts | 21 +++ 4 files changed, 168 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/lib/copiedComposerAttachments.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 146393fcc8cc..41a96d009732 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -239,6 +239,7 @@ import { } from "~/lib/composerContextReferences"; import { asKnownContextRecord, + attachmentContextRecord, composerContextImportLookupIds, isSameComposerContextPayload, uploadedAttachmentContextRecord, @@ -257,6 +258,10 @@ import { threadContextRecord, threadContextReference, } from "~/lib/composerContextRecords"; +import { + copiedComposerAttachmentFile, + rememberCopiedComposerAttachments, +} from "~/lib/copiedComposerAttachments"; import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; import { readThreadShell, useThreadShells } from "~/state/entities"; @@ -2975,6 +2980,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); const buildContextClipboardFragment = (contextIds: ReadonlyArray): string | null => { const wanted = new Set(contextIds); + const copiedFiles = new Map(); for (const annotation of composerPreviewAnnotations) { if ( wanted.has(previewAnnotationContextId(annotation.id)) && @@ -3005,19 +3011,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) wanted.has(toKindScopedComposerContextId(attachment.type, attachment.id)), ) .flatMap((attachment) => { - const record = uploadedAttachmentContextRecord( - attachment, - uploadsByImageId[attachment.id], - ); - return record ? [record] : []; + const record = + uploadedAttachmentContextRecord(attachment, uploadsByImageId[attachment.id]) ?? + (attachment.file + ? attachmentContextRecord({ attachment, attachmentId: attachment.id }) + : null); + if (!record) return []; + if (attachment.file) copiedFiles.set(record.attachmentId, attachment.file); + return [record]; }), ]; if (records.length === 0) return null; - return encodeComposerContextFragment({ + const fragment = encodeComposerContextFragment({ version: 1, source: { environmentId, ...(activeThread ? { threadId: activeThread.id } : {}) }, records, }); + if (fragment) rememberCopiedComposerAttachments(environmentId, copiedFiles); + return fragment; }; const buildContextClipboardFragmentStable = useStableCallback(buildContextClipboardFragment); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); @@ -3049,11 +3060,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); }; const countReservedAttachmentsStable = useStableCallback(countReservedAttachments); - /** - * Bytes for a pasted image or file come back through the source environment's asset URL - * (the client is the only party that can reach both) and re-enter this draft as a normal - * attachment under a fresh id. - */ const runAttachmentImport = async ( record: Extract, localId: string, @@ -3067,29 +3073,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) description: `${reason} Remove the chip or attach the file again.`, }); }; - const sourceConnection = readPreparedConnection(sourceEnvironmentId); - if (!sourceConnection) { - fail("The environment it came from is not connected."); - return; - } - const result = await createAssetUrl({ - environmentId: sourceEnvironmentId, - input: { resource: { _tag: "attachment", attachmentId: record.attachmentId } }, - }); - const url = - result._tag === "Success" - ? resolveAssetUrl(sourceConnection.httpBaseUrl, result.value.relativeUrl) - : null; - if (!url) { - fail("The original attachment is no longer available."); - return; - } - const blob = await downloadAttachmentBlob(url); - if (!blob) { - fail("Downloading it from the source failed."); - return; + let file = copiedComposerAttachmentFile(sourceEnvironmentId, record.attachmentId); + if (!file) { + const sourceConnection = readPreparedConnection(sourceEnvironmentId); + if (!sourceConnection) { + fail("The environment it came from is not connected."); + return; + } + const result = await createAssetUrl({ + environmentId: sourceEnvironmentId, + input: { resource: { _tag: "attachment", attachmentId: record.attachmentId } }, + }); + const url = + result._tag === "Success" + ? resolveAssetUrl(sourceConnection.httpBaseUrl, result.value.relativeUrl) + : null; + if (!url) { + fail("The original attachment is no longer available."); + return; + } + const blob = await downloadAttachmentBlob(url); + if (!blob) { + fail("Downloading it from the source failed."); + return; + } + file = new File([blob], record.name, { type: record.mimeType || blob.type }); } - const file = new File([blob], record.name, { type: record.mimeType || blob.type }); // The draft these bytes belong to may have been sent or switched away from while they // downloaded. Dropping them here keeps them out of whatever draft is open now. if (attachmentTargetKeyRef.current !== importTargetKey) return; @@ -3113,15 +3122,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (record.kind === "image") { - const accepted = addComposerImage({ - type: "image", - id: localId, - name: record.name, - mimeType: file.type, - sizeBytes: file.size, - previewUrl: URL.createObjectURL(file), - file, - }); + const accepted = addComposerDraftImages( + attachmentDraftTarget, + [ + { + type: "image", + id: localId, + name: record.name, + mimeType: file.type, + sizeBytes: file.size, + previewUrl: URL.createObjectURL(file), + file, + }, + ], + { + allowDuplicates: + getComposerDraft(composerDraftTarget)?.previewAnnotations.some( + (annotation) => annotation.id === localId, + ) ?? false, + }, + ); if (!accepted.includes(localId)) fail("The draft rejected this attachment (duplicate or attachment limit reached)."); } else { @@ -3175,17 +3195,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // re-minted under a fresh id so both survive the paste. const record = asKnownContextRecord(candidate); if (!record) continue; - const existing = composerContextImportLookupIds(record).flatMap((contextId) => { - const found = composerContextRecords.get(contextId); - return found ? [found] : []; - })[0]; + // A reminted annotation needs its screenshot under the new annotation id. + const existing = dependentAttachmentLocalIds.has(record.contextId) + ? undefined + : composerContextImportLookupIds(record).flatMap((contextId) => { + const found = composerContextRecords.get(contextId); + return found ? [found] : []; + })[0]; + // Upload completion can change the server id since copy; the local bytes still match. + if ( + sourceEnvironmentId !== null && + (record.kind === "image" || record.kind === "file") && + (existing?.kind === "image" || existing?.kind === "file") && + existing.record.file !== null && + existing.record.file === + copiedComposerAttachmentFile(sourceEnvironmentId, record.attachmentId) + ) + continue; const existingRecord = existing?.kind === "terminal" ? terminalContextRecord(existing.record) : existing?.kind === "review-comment" ? reviewCommentContextRecord(existing.record) : existing?.kind === "preview-annotation" - ? previewAnnotationContextRecord(existing.record) + ? previewAnnotationContextRecord(existing.record, { + screenshotContextId: composerImages.some( + (image) => image.id === existing.record.id, + ) + ? existing.record.id + : undefined, + }) : existing?.kind === "thread" ? existing.record : existing diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 410aba2b39bf..5a03c81a8f68 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -80,6 +80,10 @@ import { import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; import { insertInlineContextReference } from "./lib/composerContextReferences"; import { terminalContextReference, threadContextRecord } from "./lib/composerContextRecords"; +import { + copiedComposerAttachmentFile, + rememberCopiedComposerAttachments, +} from "./lib/copiedComposerAttachments"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, formatTerminalContextReference, @@ -361,6 +365,62 @@ describe("composerDraftStore addImages", () => { }); }); +describe("composerDraftStore copied attachments", () => { + beforeEach(resetComposerDraftStore); + afterEach(() => { + rememberCopiedComposerAttachments(TEST_ENVIRONMENT_ID, new Map()); + resetComposerDraftStore(); + vi.restoreAllMocks(); + }); + + it("keeps unsent image and file bytes usable in another draft after source removal", async () => { + const source = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("copy-source")); + const target = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("copy-target")); + const image = makeImage({ id: "uploading-image", previewUrl: "blob:source" }); + const file = makeFile("uploading-file"); + const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + const store = useComposerDraftStore.getState(); + store.addImages(source, [image]); + store.addFiles(source, [file]); + rememberCopiedComposerAttachments( + TEST_ENVIRONMENT_ID, + new Map([ + [image.id, image.file], + [file.id, file.file!], + ]), + ); + + store.removeImage(source, image.id); + store.clearComposerContent(source); + expect(store.getComposerDraft(source)).toBeNull(); + expect(revoke).toHaveBeenCalledWith(image.previewUrl); + + const copiedImage = copiedComposerAttachmentFile(TEST_ENVIRONMENT_ID, image.id); + const copiedFile = copiedComposerAttachmentFile(TEST_ENVIRONMENT_ID, file.id); + expect(copiedImage).toBe(image.file); + expect(copiedFile).toBe(file.file); + store.addImages(target, [{ ...image, file: copiedImage!, previewUrl: "blob:target" }]); + store.addFiles(target, [{ ...file, file: copiedFile! }]); + const draft = store.getComposerDraft(target)!; + expect([...new Uint8Array(await draft.images[0]!.file.arrayBuffer())]).toEqual([1, 1, 1, 1]); + expect(await draft.files[0]!.file!.text()).toBe("report"); + }); + + it("does not reuse bytes from another environment or a superseded clipboard copy", () => { + const first = makeImage({ id: "first", previewUrl: "blob:first" }); + const second = makeFile("second"); + rememberCopiedComposerAttachments(TEST_ENVIRONMENT_ID, new Map([[first.id, first.file]])); + expect(copiedComposerAttachmentFile(OTHER_TEST_ENVIRONMENT_ID, first.id)).toBeUndefined(); + + rememberCopiedComposerAttachments(TEST_ENVIRONMENT_ID, new Map([[second.id, second.file!]])); + expect(copiedComposerAttachmentFile(TEST_ENVIRONMENT_ID, first.id)).toBeUndefined(); + expect(copiedComposerAttachmentFile(TEST_ENVIRONMENT_ID, second.id)).toBe(second.file); + + rememberCopiedComposerAttachments(TEST_ENVIRONMENT_ID, new Map()); + expect(copiedComposerAttachmentFile(TEST_ENVIRONMENT_ID, second.id)).toBeUndefined(); + }); +}); + describe("composerDraftStore clearComposerContent", () => { const threadId = ThreadId.make("thread-clear"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts index b10ed8a02278..a1f7aae27254 100644 --- a/apps/web/src/lib/composerContextRecords.ts +++ b/apps/web/src/lib/composerContextRecords.ts @@ -276,7 +276,7 @@ export interface BoundComposerAttachment { attachmentId: string; } -/** Clipboard payloads may only point at attachments that already exist on the server. */ +/** Returns null until the attachment has a server id. */ export function uploadedAttachmentContextRecord( attachment: ComposerImageAttachment | ComposerFileAttachment, upload: AttachmentUploadState | undefined, diff --git a/apps/web/src/lib/copiedComposerAttachments.ts b/apps/web/src/lib/copiedComposerAttachments.ts new file mode 100644 index 000000000000..7f0b51613973 --- /dev/null +++ b/apps/web/src/lib/copiedComposerAttachments.ts @@ -0,0 +1,21 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +// Keep the latest copy's files alive after source removal, including preview URL revocation. +// Replacing the copy releases the previous files instead of accumulating draft attachments. +let latestCopy: { environmentId: EnvironmentId; files: ReadonlyMap } | null = null; + +export function rememberCopiedComposerAttachments( + environmentId: EnvironmentId, + files: ReadonlyMap, +): void { + latestCopy = files.size > 0 ? { environmentId, files } : null; +} + +export function copiedComposerAttachmentFile( + environmentId: EnvironmentId, + attachmentId: string, +): File | undefined { + return latestCopy?.environmentId === environmentId + ? latestCopy.files.get(attachmentId) + : undefined; +} From 502ce3cf65d962ce36ef9b5f928337a54f97b109 Mon Sep 17 00:00:00 2001 From: Ignacio Rasetti <53914192+ignaciorasetti@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:47:33 -0300 Subject: [PATCH 067/898] fix(server): settle OpenCode model catalog before caching it (#16448) Co-authored-by: Ignacio Rasetti Co-authored-by: Julius Marminge --- .../src/server/status.test.ts | 133 +++++++++++++++++- .../provider-opencode/src/server/status.ts | 56 +++++--- 2 files changed, 171 insertions(+), 18 deletions(-) diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 7f944d251e1d..342b6c26db9c 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -842,7 +842,9 @@ it.effect("keeps the last OpenCode 2 model list while a fresh server's stays emp Effect.gen(function* () { let listed: ReadonlyArray = [bigPickle]; const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); - NodeAssert.deepEqual(yield* load, [bigPickle]); + const first = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + NodeAssert.deepEqual(yield* Fiber.join(first), [bigPickle]); listed = []; const fiber = yield* load.pipe(Effect.forkChild); yield* TestClock.adjust("6 seconds"); @@ -850,6 +852,135 @@ it.effect("keeps the last OpenCode 2 model list while a fresh server's stays emp }).pipe(Effect.provide(TestClock.layer())), ); +it.effect("waits for a growing OpenCode 2 catalog to settle before caching it", () => + Effect.gen(function* () { + const first: OpenCode2Model = { + providerID: "opencode", + id: "first", + name: "First", + variants: [], + }; + const second: OpenCode2Model = { + providerID: "opencode", + id: "second", + name: "Second", + variants: [], + }; + const replies: Array> = [ + [first], + [first, second], + [first, second], + ]; + const load = yield* makeOpenCode2ModelLoader( + Effect.sync(() => replies.shift() ?? [first, second]), + ); + const fiber = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + NodeAssert.ok(fiber.pollUnsafe(), "A settled catalog should finish before the load deadline"); + NodeAssert.deepEqual(yield* Fiber.join(fiber), [first, second]); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("waits for OpenCode 2 model names, provider labels, and variants to settle", () => + Effect.gen(function* () { + const renamed = { ...bigPickle, name: "Bigger Pickle" }; + const labeled = { ...renamed, providerName: "OpenCode Zen" }; + const withVariants = { ...labeled, variants: [{ id: "low" }] }; + const complete = { ...labeled, variants: [{ id: "high" }, { id: "medium" }] }; + const reordered = { ...complete, variants: [{ id: "medium" }, { id: "high" }] }; + const replies = [[bigPickle], [renamed], [labeled], [withVariants], [complete], [reordered]]; + const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [complete])); + const fiber = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("1500 millis"); + NodeAssert.ok( + fiber.pollUnsafe(), + "Equivalent variant orders should settle before the deadline", + ); + NodeAssert.deepEqual(yield* Fiber.join(fiber), [reordered]); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("bounds an unsettled OpenCode 2 catalog and keeps its latest nonempty observation", () => + Effect.gen(function* () { + let reads = 0; + let latest: ReadonlyArray = []; + const changing = () => { + latest = [{ ...bigPickle, name: `Pickle ${++reads}` }]; + return latest; + }; + let list = changing; + const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => list())); + const cold = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("5 seconds"); + NodeAssert.ok(cold.pollUnsafe()); + NodeAssert.ok(reads > 1); + NodeAssert.deepEqual(yield* Fiber.join(cold), latest); + + list = () => []; + const empty = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("5 seconds"); + NodeAssert.ok(empty.pollUnsafe()); + NodeAssert.deepEqual(yield* Fiber.join(empty), latest); + + list = () => [bigPickle]; + const settled = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + NodeAssert.deepEqual(yield* Fiber.join(settled), [bigPickle]); + + reads = 0; + list = () => (reads++ === 0 ? [bigPickle] : changing()); + const fresh = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("5 seconds"); + NodeAssert.ok(fresh.pollUnsafe()); + NodeAssert.ok(reads > 1); + NodeAssert.deepEqual(yield* Fiber.join(fresh), latest); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect( + "refreshes a warm OpenCode 2 catalog when slow reads cannot settle before the deadline", + () => + Effect.gen(function* () { + let listed: ReadonlyArray = [bigPickle]; + let slow = false; + const load = yield* makeOpenCode2ModelLoader( + Effect.gen(function* () { + if (slow) yield* Effect.sleep("3 seconds"); + return listed; + }), + ); + const warm = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + NodeAssert.deepEqual(yield* Fiber.join(warm), [bigPickle]); + + listed = [{ ...bigPickle, name: "Fresh Pickle" }]; + slow = true; + const fresh = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("5 seconds"); + NodeAssert.ok(fresh.pollUnsafe()); + NodeAssert.deepEqual(yield* Fiber.join(fresh), listed); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect( + "keeps a fresh OpenCode 2 observation if confirmation fails but propagates an initial failure", + () => + Effect.gen(function* () { + const error = new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: "Model list failed", + }); + let reads = 0; + const load = yield* makeOpenCode2ModelLoader( + Effect.suspend(() => (reads++ === 0 ? Effect.succeed([bigPickle]) : Effect.fail(error))), + ); + const fresh = yield* load.pipe(Effect.forkChild); + yield* TestClock.adjust("250 millis"); + NodeAssert.deepEqual(yield* Fiber.join(fresh), [bigPickle]); + NodeAssert.equal(yield* load.pipe(Effect.flip), error); + }).pipe(Effect.provide(TestClock.layer())), +); + const builtinCommands = [ { name: "init", description: "guided AGENTS.md setup" }, { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 33c9052eece3..4557ce3f9728 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -396,28 +396,50 @@ function subProviderLabelFor(input: { : label; } -/** - * A server loads its catalog lazily and lists nothing for its first few - * hundred milliseconds, and a local instance starts a fresh server for each - * status check. So an empty list is read again for a while, and the last - * non-empty list stands in if the catalog still has not loaded. - */ +const catalogFingerprint = (models: ReadonlyArray) => + models + .map((model) => + JSON.stringify([ + model.providerID, + model.id, + model.name, + model.providerName, + model.variants.map((variant) => variant.id).toSorted(), + ]), + ) + .toSorted() + .join("\n"); + +/** A cold server can list a partial catalog; two agreeing reads reduce that startup race. */ export const makeOpenCode2ModelLoader = ( list: Effect.Effect, E>, ) => Effect.sync(() => { let lastLoaded: ReadonlyArray = []; - return list.pipe( - Effect.repeat({ - until: (models) => models.length > 0, - schedule: Schedule.spaced("250 millis"), - }), - Effect.timeoutOption("5 seconds"), - Effect.map((loaded) => { - if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; - return lastLoaded; - }), - ); + return Effect.suspend(() => { + let previous: string | undefined; + let latest: ReadonlyArray = []; + return list.pipe( + Effect.repeat({ + until: (models) => { + if (models.length > 0) latest = models; + const fingerprint = models.length > 0 ? catalogFingerprint(models) : undefined; + const settled = fingerprint !== undefined && fingerprint === previous; + previous = fingerprint; + return settled; + }, + schedule: Schedule.spaced("250 millis"), + }), + Effect.catch((error) => (latest.length > 0 ? Effect.succeed(latest) : Effect.fail(error))), + Effect.timeoutOption("5 seconds"), + Effect.map((loaded) => { + // Fresh progress may still be partial, but a warm cache must not freeze slow catalogs. + if (loaded._tag === "Some") lastLoaded = loaded.value; + else if (latest.length > 0) lastLoaded = latest; + return lastLoaded; + }), + ); + }); }); export interface OpenCode2Workspace { From 7ee6e924cb8c826785a452801466e0b886fd095c Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 05:48:30 +1100 Subject: [PATCH 068/898] fix(web): reject file suggestions from an older composer query (#18845) Co-authored-by: SAPHID Co-authored-by: GPT-6.1 Sol Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ChatComposer.tsx | 6 ++-- .../components/chat/ComposerCommandMenu.tsx | 2 ++ apps/web/src/composer-logic.test.ts | 36 +++++++++++++++++++ apps/web/src/composer-logic.ts | 21 ++++++++++- apps/web/src/lib/composerPathSearchState.ts | 16 --------- apps/web/src/state/queries.ts | 1 + 6 files changed, 63 insertions(+), 19 deletions(-) delete mode 100644 apps/web/src/lib/composerPathSearchState.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 41a96d009732..c55dfb2ca988 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -90,6 +90,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, formatAssistantCitationForComposer, + isPathSuggestionCurrent, replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; @@ -203,7 +204,6 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../../lib/terminalContext"; -import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { replaceComposerContextReferences, toKindScopedComposerContextId, @@ -284,7 +284,7 @@ import { type EnvironmentQueryTarget, } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; -import { useDebouncedValue } from "~/state/queries"; +import { useComposerPathSearch, useDebouncedValue } from "~/state/queries"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { @@ -2636,6 +2636,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) type: "path" as const, path: entry.path, pathKind: entry.kind, + searchTarget: workspaceEntries.searchedTarget, label: basenameOfPath(entry.path), description: entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))), })), @@ -3809,6 +3810,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; if (item.type === "path") { + if (!isPathSuggestionCurrent(item.searchTarget, trigger, environmentId, gitCwd)) return; const replacement = `${serializeComposerFileLink(item.path)} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index e89f4375ef85..4d556d48be70 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,4 +1,5 @@ import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; +import type { ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; import { resolveProviderSkillSourceKind, type ProviderSkillSourceKind, @@ -36,6 +37,7 @@ export type ComposerCommandItem = type: "path"; path: string; pathKind: ProjectEntry["kind"]; + searchTarget: ComposerPathSearchTarget; label: string; description: string; } diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 49424306d64a..7050dc6251db 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -22,6 +22,7 @@ import { expandCollapsedComposerCursor, formatAssistantCitationForComposer, isCollapsedCursorAdjacentToInlineToken, + isPathSuggestionCurrent, parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; @@ -48,6 +49,41 @@ const citation: AssistantCitation = { }; const citationSource = serializeAssistantCitation(citation).replaceAll("+", "%20"); +describe("isPathSuggestionCurrent", () => { + const searchedTarget = { + environmentId: EnvironmentId.make("local"), + cwd: "/repo", + query: "alpha", + }; + + it("allows a file suggestion matching the live search", () => { + expect( + isPathSuggestionCurrent( + searchedTarget, + detectComposerTrigger("@alpha", 6), + searchedTarget.environmentId, + searchedTarget.cwd, + ), + ).toBe(true); + }); + + it.each([ + ["query", { ...searchedTarget, query: "beta" }], + ["cwd", { ...searchedTarget, cwd: "/other-repo" }], + ["environment", { ...searchedTarget, environmentId: EnvironmentId.make("remote") }], + ] as const)("rejects a visible file suggestion after the live %s changes", (_change, current) => { + const prompt = `@${current.query}`; + expect( + isPathSuggestionCurrent( + searchedTarget, + detectComposerTrigger(prompt, prompt.length), + current.environmentId, + current.cwd, + ), + ).toBe(false); + }); +}); + describe("formatAssistantCitationForComposer", () => { it.each([undefined, "", " \n\t "])( "keeps citation-only insertion for a blank comment %j", diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c82bf38d154d..fa21194ba7af 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,5 +1,10 @@ import type { ClientSettings } from "@t3tools/contracts/settings"; -import type { AssistantCitation, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import type { + AssistantCitation, + EnvironmentId, + ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import type { ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; import { serializeAssistantCitation, withAssistantCitationComment, @@ -23,6 +28,20 @@ export interface ComposerTrigger { rangeEnd: number; } +export function isPathSuggestionCurrent( + searchTarget: ComposerPathSearchTarget, + trigger: ComposerTrigger | null, + environmentId: EnvironmentId | null, + cwd: string | null, +) { + return ( + trigger?.kind === "path" && + searchTarget.environmentId === environmentId && + searchTarget.cwd === cwd && + searchTarget.query === trigger.query.trim() + ); +} + export function formatAssistantCitationForComposer(citation: AssistantCitation, comment = "") { return `${serializeAssistantCitation(withAssistantCitationComment(citation, comment))} `; } diff --git a/apps/web/src/lib/composerPathSearchState.ts b/apps/web/src/lib/composerPathSearchState.ts deleted file mode 100644 index 9ae590bcd7f3..000000000000 --- a/apps/web/src/lib/composerPathSearchState.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - type ComposerPathSearchState, - type ComposerPathSearchTarget, -} from "@t3tools/client-runtime/state/threads"; - -import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; - -export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { - const state = useComposerPathSearchQuery(target); - const entries = state.entries.map((entry) => ({ path: entry.path, kind: entry.kind })); - return { - entries, - error: state.error, - isPending: state.isPending, - }; -} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 3e2b00d269d3..b27e270aa756 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -272,6 +272,7 @@ export function useProjectPathSearch( isPending: !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || (hasTarget && (fileAccess.isPending || result.isPending)), + searchedTarget: debouncedTarget, searchedQuery: debouncedTarget.query ?? "", truncated: result.data?.truncated ?? false, refresh: result.refresh, From 69ba80dfae93707d0d09091bea3266a5a0c8eec0 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:23:27 +0530 Subject: [PATCH 069/898] fix(threads): snoozed threads no longer wake early after background updates (#16517) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../orchestration-v2/ProjectionStore.test.ts | 118 ++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 17 ++- .../client-runtime/src/state/threadSettled.ts | 28 ++++- .../src/state/threadSnoozed.test.ts | 40 ++++++ 4 files changed, 195 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 34fc75a64d25..990ec2a6e862 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -664,6 +664,124 @@ it.effect("memory projection keeps restart-cancelled work through a stale run.up restartCancelledWorkSurvivesStaleRunUpdate.pipe(Effect.provide(ProjectionStore.layerMemory)), ); +const backgroundThreadUpdatesKeepTimestamp = Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const createdAt = DateTime.makeUnsafe("2026-10-06T14:00:00.000Z"); + const threadId = ThreadId.make("thread:background-updates"); + const thread = { + createdBy: "user" as const, + creationSource: "web" as const, + id: threadId, + projectId: ProjectId.make("project:background-updates"), + title: "Background updates", + providerInstanceId, + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt, + updatedAt: createdAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + yield* store.apply({ + id: EventId.make("event:background-updates:created"), + type: "thread.created", + threadId, + occurredAt: createdAt, + payload: thread, + }); + const readActivity = Effect.gen(function* () { + const projection = yield* store.getThreadProjection(threadId); + const thread = yield* store.getThread(threadId); + const shell = yield* store.getThreadShell(threadId); + const listed = (yield* store.getShellSnapshot()).threads.find((entry) => entry.id === threadId); + assert.deepEqual(projection.thread.updatedAt, createdAt); + assert.deepEqual(thread.updatedAt, createdAt); + assert.deepEqual(shell?.updatedAt, createdAt); + assert.deepEqual(listed?.updatedAt, createdAt); + return projection; + }); + const link = { + host: "github.com", + repository: "owner/repository", + number: 42, + url: "https://github.com/owner/repository/pull/42", + source: "manual" as const, + linkedAt: DateTime.formatIso(createdAt), + snapshot: null, + stack: null, + }; + yield* store.apply({ + id: EventId.make("event:background-updates:pull-request-synced"), + type: "thread.pull-request-synced", + threadId, + occurredAt: DateTime.add(createdAt, { minutes: 45 }), + payload: { ...thread, pullRequests: [link] }, + }); + const synced = yield* readActivity; + assert.deepEqual(synced.updatedAt, createdAt); + assert.equal(synced.thread.pullRequests?.[0]?.number, 42); + + const snapshot = { + state: "open" as const, + title: "Ship it", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: DateTime.formatIso(DateTime.add(createdAt, { minutes: 46 })), + syncedAt: DateTime.formatIso(DateTime.add(createdAt, { minutes: 47 })), + closedAt: null, + mergedAt: null, + }; + yield* store.apply({ + id: EventId.make("event:background-updates:pull-request-link-updated"), + type: "thread.pull-request-link-updated", + threadId, + occurredAt: DateTime.add(createdAt, { minutes: 48 }), + payload: { ...link, snapshot }, + }); + const refreshed = yield* readActivity; + assert.deepEqual(refreshed.updatedAt, createdAt); + assert.deepEqual(refreshed.thread.pullRequests?.[0]?.snapshot, snapshot); + + yield* store.apply({ + id: EventId.make("event:background-updates:active-reordered"), + type: "thread.active-reordered", + threadId, + occurredAt: DateTime.add(createdAt, { minutes: 50 }), + payload: { ...refreshed.thread, activeOrderKey: "a" }, + }); + const reordered = yield* readActivity; + assert.deepEqual(reordered.updatedAt, createdAt); + assert.equal(reordered.thread.activeOrderKey, "a"); + + // An already-unpinned thread keeps its activity time even when replay's envelope advances. + yield* store.apply({ + id: EventId.make("event:background-updates:already-unpinned"), + type: "thread.unpinned", + threadId, + occurredAt: DateTime.add(createdAt, { minutes: 55 }), + payload: { ...reordered.thread, pinnedAt: null, pinOrderKey: null }, + }); + yield* readActivity; +}); + +it.effect("memory projection keeps background thread updates out of updatedAt", () => + backgroundThreadUpdatesKeepTimestamp.pipe(Effect.provide(ProjectionStore.layerMemory)), +); + +it.effect("SQL projection keeps background thread updates out of updatedAt", () => + backgroundThreadUpdatesKeepTimestamp.pipe(Effect.provide(layerTest)), +); + it.effect("memory recovery selection ignores unfinished items from rolled-back runs", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index fedd86a48414..d04149f911c9 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -734,7 +734,7 @@ export function applyToProjection( switch (event.type) { case "thread.pull-request-link-updated": return { - ...base, + ...projection, thread: applyThreadPullRequestLinkUpdate(projection.thread, event.payload), }; case "thread.created": @@ -749,9 +749,7 @@ export function applyToProjection( case "thread.pinned": case "thread.unpinned": case "thread.pin-reordered": - case "thread.active-reordered": case "thread.metadata-updated": - case "thread.pull-request-synced": case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": case "thread.model-selection-updated": @@ -760,6 +758,14 @@ export function applyToProjection( ...base, thread: event.payload, }; + // Reordering and PR refreshes preserve the orchestrator's activity timestamp. + case "thread.active-reordered": + case "thread.pull-request-synced": + return { + ...base, + thread: event.payload, + updatedAt: event.payload.updatedAt, + }; case "thread.visited": case "thread.marked-unread": return { @@ -1568,7 +1574,7 @@ export function threadShellFromProjection( itemCount: activeLocalTurnItems(projection).length, visibleItemCount: projection.visibleTurnItems.length, createdAt: projection.thread.createdAt, - updatedAt: projection.updatedAt, + updatedAt: projection.thread.updatedAt, archivedAt: projection.thread.archivedAt, settledOverride: projection.thread.settledOverride, settledAt: projection.thread.settledAt, @@ -2705,9 +2711,12 @@ export const layer: Layer.Layer = event.type !== "thread.pinned" && event.type !== "thread.unpinned" && event.type !== "thread.pin-reordered" && + event.type !== "thread.active-reordered" && event.type !== "thread.visited" && event.type !== "thread.marked-unread" && event.type !== "thread.metadata-updated" && + event.type !== "thread.pull-request-synced" && + event.type !== "thread.pull-request-link-updated" && event.type !== "thread.runtime-mode-updated" && event.type !== "thread.interaction-mode-updated" && event.type !== "thread.model-selection-updated" && diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index af89742160d9..c7172c330008 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -81,12 +81,12 @@ export interface ThreadSnoozeShell extends QueuedThreadShell { export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; if (shell.pendingSubagentRequest != null) return true; - const runtime = shell.runtime ?? shell.session ?? null; const latestRun = shell.latestRun ?? shell.latestTurn ?? null; + const failedAt = failureEdgeAt(shell); if ( - (runtime?.status === "error" || runtime?.status === "failed") && + failedAt !== undefined && (shell.snoozedAt == null || - (runtime.updatedAt != null && Date.parse(runtime.updatedAt) > Date.parse(shell.snoozedAt))) + (failedAt != null && Date.parse(failedAt) > Date.parse(shell.snoozedAt))) ) { return true; } @@ -101,6 +101,18 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean return false; } +// V2 runtime.updatedAt includes metadata writes; prefer the failed run's completion. +// Legacy sessions only expose the runtime timestamp. +function failureEdgeAt(shell: ThreadSnoozeShell): string | null | undefined { + const runtime = shell.runtime ?? shell.session ?? null; + if (runtime?.status !== "error" && runtime?.status !== "failed") return undefined; + const latestRun = shell.latestRun ?? shell.latestTurn ?? null; + if (latestRun?.status === "failed" && latestRun.completedAt != null) { + return latestRun.completedAt; + } + return runtime.updatedAt ?? null; +} + export function canSnooze( shell: Pick< ThreadSnoozeShell, @@ -145,7 +157,6 @@ export function threadWokeAt( // than that visit's lastVisitedAt). if (threadRaisedHandWhileSnoozed(shell)) { const latestRun = shell.latestRun ?? shell.latestTurn ?? null; - const runtime = shell.runtime ?? shell.session ?? null; if ( shell.snoozedAt != null && (latestRun?.state === "completed" || latestRun?.status === "completed") && @@ -154,6 +165,15 @@ export function threadWokeAt( ) { return latestRun.completedAt; } + // An old failure must not replace the activity time of an approval or input wake. + const failedAt = failureEdgeAt(shell); + if ( + failedAt != null && + (shell.snoozedAt == null || Date.parse(failedAt) > Date.parse(shell.snoozedAt)) + ) { + return failedAt; + } + const runtime = shell.runtime ?? shell.session ?? null; return runtime?.updatedAt ?? shell.snoozedAt ?? null; } return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index ff3899ddb9f0..d3b11c82a3ff 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -119,6 +119,46 @@ describe("effectiveSnoozed", () => { ).toBe(true); }); + it.each([ + { + reason: "stale failure", + completedAt: "2026-04-10T08:00:00.000Z", + pending: undefined, + wokeAt: null, + }, + { + reason: "fresh failure", + completedAt: "2026-04-10T10:00:00.000Z", + pending: undefined, + wokeAt: "2026-04-10T10:00:00.000Z", + }, + { + reason: "approval after a stale failure", + completedAt: "2026-04-10T08:00:00.000Z", + pending: "approval", + wokeAt: "2026-04-10T11:00:00.000Z", + }, + { + reason: "input after a stale failure", + completedAt: "2026-04-10T08:00:00.000Z", + pending: "user-input", + wokeAt: "2026-04-10T11:00:00.000Z", + }, + ] as const)( + "uses the wake event time for $reason despite newer metadata", + ({ completedAt, pending, wokeAt }) => { + const shell: ThreadSnoozeShell = { + ...makeShell({ snoozedUntil: FUTURE_WAKE }), + hasPendingApprovals: pending === "approval", + hasPendingUserInput: pending === "user-input", + runtime: { status: "failed", updatedAt: "2026-04-10T11:00:00.000Z" }, + latestRun: { status: "failed", requestedAt: "2026-04-10T07:00:00.000Z", completedAt }, + }; + expect(effectiveSnoozed(shell, { now: NOW })).toBe(wokeAt === null); + expect(threadWokeAt(shell, { now: NOW })).toBe(wokeAt); + }, + ); + it("stays snoozed while the session keeps working — snooze never pauses the agent", () => { expect( effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "running" }), { From 3f7c3aa57c504ba15b8cf89f0d69964477dc6dc2 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Mon, 19 Oct 2026 19:54:52 +0100 Subject: [PATCH 070/898] fix(server): pnpm provider updates only offer versions pnpm will install (#19580) Co-authored-by: Julius Marminge --- apps/server/src/ws.ts | 8 +- .../provider-core/src/server/maintenance.ts | 5 ++ .../src/server/maintenanceResolver.test.ts | 80 ++++++++++++++++++- .../src/server/maintenanceResolver.ts | 80 ++++++++++++++++++- 4 files changed, 168 insertions(+), 5 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ade826e53f38..47ff1314f2c6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -168,6 +168,7 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import { latestProviderVersionCacheKeys } from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; @@ -2177,8 +2178,11 @@ const layerWsRpc = ( const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true, }); - if (maintenance.packageName) - yield* providerLatestVersions.invalidate(maintenance.packageName); + yield* Effect.forEach( + latestProviderVersionCacheKeys(maintenance), + providerLatestVersions.invalidate, + { discard: true }, + ); }), { concurrency: "unbounded", discard: true }, ); diff --git a/packages/provider-core/src/server/maintenance.ts b/packages/provider-core/src/server/maintenance.ts index e9eb9953c5c0..6180ba8f371f 100644 --- a/packages/provider-core/src/server/maintenance.ts +++ b/packages/provider-core/src/server/maintenance.ts @@ -1,4 +1,5 @@ import type { ProviderDriverKind } from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; export interface ProviderMaintenanceCapabilities { readonly provider: ProviderDriverKind; @@ -10,6 +11,10 @@ export interface ProviderMaintenanceCapabilities { * installer was asked and did not know. */ readonly latestVersion?: string | null; + /** + * Deferred until update checks are enabled; `null` falls back to npm. + */ + readonly lookupLatestVersion?: Effect.Effect; readonly compareVersions?: (current: string, latest: string) => number; /** * The executable T3 Code runs, symlinks followed, when no update command is diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 4b4b55c6c66a..3f4db2dbd600 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -99,7 +99,10 @@ const noSpawn = ChildProcessSpawner.make(() => Effect.die("maintenance resolution should not spawn a process here"), ); -function stdoutSpawner(onSpawn: (command: string, args: ReadonlyArray) => string) { +function stdoutSpawner( + onSpawn: (command: string, args: ReadonlyArray) => string, + exitCodeOf: (args: ReadonlyArray) => number = () => 0, +) { return ChildProcessSpawner.make((command) => { const { command: executable, args } = command as unknown as { readonly command: string; @@ -108,7 +111,7 @@ function stdoutSpawner(onSpawn: (command: string, args: ReadonlyArray) = return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCodeOf(args))), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), @@ -624,6 +627,79 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); + it.effect.each([ + { + case: "the eligible release", + outdated: { "@example/package-tool": { current: "1.0.0", latest: "1.1.0" } }, + exitCode: 1, + expected: "1.1.0", + }, + { case: "no update during cooldown", outdated: {}, exitCode: 0, expected: "1.0.0" }, + { + case: "the npm fallback when the pnpm probe fails", + outdated: { "@example/package-tool": { current: "1.0.0", latest: "1.1.0" } }, + exitCode: 2, + expected: "9.9.9", + }, + ])( + "advertises $case for pnpm-global installs", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-pnpm-latest"); + const pnpmBinDir = NodePath.join(tempDir, "pnpm-bin"); + writeExecutable(NodePath.join(pnpmBinDir, "pnpm")); + const link = linkIntoPackage(tempDir, "package-tool", [ + ".local", + "share", + "pnpm", + "global", + "5", + "node_modules", + "@example", + "package-tool", + ]); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + packageToolUpdate, + { binaryPath: link, env: { PATH: "" } }, + ).pipe( + Effect.provideService(HostProcess.Environment, { PATH: pnpmBinDir }), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner( + (_command, args) => + args[0] === "outdated" + ? JSON.stringify(fixture.outdated) + : JSON.stringify([ + { dependencies: { "@example/package-tool": { version: "1.0.0" } } }, + ]), + (args) => (args[0] === "outdated" ? fixture.exitCode : 0), + ), + ), + ); + + const provider = yield* enrichProviderSnapshotWithVersionAdvisory( + installedPackageToolProvider, + capabilities, + ).pipe( + Effect.provideServiceEffect( + ProviderLatestVersions.ProviderLatestVersions, + ProviderLatestVersions.make([["@example/package-tool", "9.9.9"]]), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("the npm version is already cached")), + ), + ); + expect(provider.versionAdvisory).toMatchObject({ + status: fixture.expected === "1.0.0" ? "current" : "behind_latest", + currentVersion: "1.0.0", + latestVersion: fixture.expected, + }); + }), + { skip: !symlinksSupported || windowsHost }, + ); + it.effect.skipIf(windowsHost)( "switches to bun updates when the resolved binary lives in bun's global bin", () => diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index bf512d81f00c..b4ea15c5bd84 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -121,6 +121,7 @@ export function makeProviderMaintenanceCapabilities(input: { readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv; readonly latestVersion?: string | null; + readonly lookupLatestVersion?: Effect.Effect; }): ProviderMaintenanceCapabilities { const platform = input.platform ?? HostProcess.Platform.defaultValue(); const update = @@ -143,6 +144,7 @@ export function makeProviderMaintenanceCapabilities(input: { packageName: input.packageName, update, ...("latestVersion" in input ? { latestVersion: input.latestVersion } : {}), + ...(input.lookupLatestVersion ? { lookupLatestVersion: input.lookupLatestVersion } : {}), }; } @@ -326,6 +328,7 @@ const runProbe = Effect.fn("runProbe")(function* ( command: string, args: ReadonlyArray, env: NodeJS.ProcessEnv, + okExitCodes: ReadonlyArray = [0], ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const collect = Effect.gen(function* () { @@ -348,7 +351,7 @@ const runProbe = Effect.fn("runProbe")(function* ( ], { concurrency: "unbounded" }, ); - return Number(exitCode) !== 0 || stdout.truncated ? null : stdout.text; + return !okExitCodes.includes(Number(exitCode)) || stdout.truncated ? null : stdout.text; }); return yield* collect.pipe( Effect.scoped, @@ -364,6 +367,54 @@ const runProbe = Effect.fn("runProbe")(function* ( ); }); +const decodePnpmOutdated = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Record(Schema.String, Schema.Struct({ latest: Schema.optional(Schema.String) })), + ), +); +const decodePnpmList = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + dependencies: Schema.optional( + Schema.Record(Schema.String, Schema.Struct({ version: Schema.optional(Schema.String) })), + ), + }), + ), + ), +); + +// Unlike npm's latest tag, pnpm outdated respects minimumReleaseAge and its excludes. +const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion")(function* ( + packageName: string, +) { + // Match the updater's pnpm and server environment, rather than the instance's. + const pnpmPath = yield* resolveCommandPath("pnpm"); + if (!pnpmPath) return null; + // Silent reporting keeps project package-manager warnings out of the JSON. + const outdated = yield* runProbe( + pnpmPath, + ["outdated", "-g", "--reporter=silent", "--format", "json", packageName], + {}, + // pnpm outdated exits 1 when updates are available. + [0, 1], + ); + const outdatedEntries = outdated + ? Option.getOrUndefined(decodePnpmOutdated(outdated)) + : undefined; + if (!outdatedEntries) return null; + const latest = nonEmptyString(outdatedEntries[packageName]?.latest); + if (latest) return latest; + // An empty result means the installed version is still the eligible release. + const listed = yield* runProbe( + pnpmPath, + ["ls", "-g", "--reporter=silent", "--depth", "0", "--json", packageName], + {}, + ); + const listedEntries = listed ? Option.getOrUndefined(decodePnpmList(listed)) : undefined; + return nonEmptyString(listedEntries?.[0]?.dependencies?.[packageName]?.version); +}); + export const resolvePackageManagedProviderMaintenance = Effect.fn( "resolvePackageManagedProviderMaintenance", )(function* ( @@ -443,12 +494,16 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( pnpmVersion !== null && satisfiesSemverRange(pnpmVersion, ">=10.4.0") ? [`--allow-build=${packageName}`] : []; + const services = yield* Effect.context(); return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, updateExecutable: "pnpm", updateArgs: ["add", "-g", ...allowBuild, `${packageName}@latest`], updateLockKey: "pnpm-global", + lookupLatestVersion: resolvePnpmGlobalLatestVersion(packageName).pipe( + Effect.provideContext(services), + ), }); } @@ -795,6 +850,21 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack return payload ? nonEmptyString(payload.version) : null; }); +/** The installer's own answer is cached apart from the npm registry entry. */ +function installerLatestVersionKey(capabilities: ProviderMaintenanceCapabilities) { + return capabilities.lookupLatestVersion && capabilities.update && capabilities.packageName + ? `${capabilities.update.lockKey}:${capabilities.packageName}` + : null; +} + +export function latestProviderVersionCacheKeys( + capabilities: ProviderMaintenanceCapabilities, +): ReadonlyArray { + return [capabilities.packageName, installerLatestVersionKey(capabilities)].filter( + (key) => key !== null, + ); +} + export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* ( maintenanceCapabilities: ProviderMaintenanceCapabilities, ) { @@ -807,6 +877,14 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers } const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + const installerKey = installerLatestVersionKey(maintenanceCapabilities); + if (installerKey && maintenanceCapabilities.lookupLatestVersion) { + const installable = yield* latestVersions.cached( + installerKey, + maintenanceCapabilities.lookupLatestVersion, + ); + if (installable !== null) return installable; + } return yield* latestVersions.cached(packageName, fetchNpmLatestVersion(packageName)); }); From 99c692bb1f7dc31a6b1a28eddcff48aee6fd8186 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Mon, 19 Oct 2026 19:55:58 +0100 Subject: [PATCH 071/898] fix(ssh): concurrent archive installs no longer race on uutils mkdir (#20059) Co-authored-by: Julius Marminge --- packages/ssh/src/tunnel.test.ts | 72 ++++++++++++++++++++++++++------- packages/ssh/src/tunnel.ts | 17 ++------ 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 692f105e4a57..fb80b1f37176 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -124,8 +124,6 @@ describe("ssh tunnel scripts", () => { script, 'T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"', ); - assert.include(script, 'while ! mkdir "$T3_LOCK" 2>/dev/null; do'); - assert.include(script, 'mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"'); assert.include(script, 'if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then'); assert.include(script, 'if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then'); assert.include(script, 'if [ "$T3_LOCK_WAITED" -ge 360 ]; then'); @@ -233,7 +231,6 @@ describe("ssh tunnel scripts", () => { it("embeds the runner in the launch script byte for byte", () => { const runner = SshTunnel.buildRemoteT3RunnerScript(ARCHIVE); - assert.include(runner, 'printf \'%s\\n\' "$$" > "$T3_LOCK/pid.tmp"'); assert.include(SshTunnel.buildRemoteLaunchScript(ARCHIVE), runner); }); @@ -1053,13 +1050,22 @@ describe("remote port picker", () => { ); }); -const runScript = (home: string, args: ReadonlyArray) => +const runScript = ( + home: string, + args: ReadonlyArray, + options?: { + path?: string; + stdin?: Stream.Stream; + onStderrLine?: (line: string) => Effect.Effect; + }, +) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn( ChildProcess.make("sh", args, { - env: { PATH: process.env.PATH ?? "", HOME: home }, + env: { PATH: options?.path ?? process.env.PATH ?? "", HOME: home }, extendEnv: false, + ...(options?.stdin ? { stdin: { stream: options.stdin } } : {}), }), ); const [stdout, stderr, exitCode] = yield* Effect.all( @@ -1073,9 +1079,11 @@ const runScript = (home: string, args: ReadonlyArray) => ), child.stderr.pipe( Stream.decodeText(), + Stream.splitLines, + Stream.tap((line) => options?.onStderrLine?.(line) ?? Effect.void), Stream.runFold( () => "", - (acc, chunk) => acc + chunk, + (acc, line) => `${acc}${line}\n`, ), ), child.exitCode.pipe(Effect.map(Number)), @@ -1091,7 +1099,8 @@ describe("archive runner script", () => { const windowsHost = hostPlatform === "win32"; const archiveVersion = "1.2.3-preview.20260911.4"; - const runRunner = (home: string, runner: string) => runScript(home, [runner, "--version"]); + const runRunner = (home: string, runner: string, options?: Parameters[2]) => + runScript(home, [runner, "--version"], options); const makeMirror = Effect.fn("makeMirror")(function* (root: string) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -1127,11 +1136,45 @@ describe("archive runner script", () => { ); const home = `${root}/home`; yield* fs.makeDirectory(home, { recursive: true }); - - const results = yield* Effect.all( - [runRunner(home, runner), runRunner(home, runner), runRunner(home, runner)], - { concurrency: "unbounded" }, - ); + // Reproduce mkdir succeeding on EEXIST, even on hosts without the uutils bug. + const bin = `${root}/bin`; + yield* fs.makeDirectory(bin); + for (const [name, script] of Object.entries({ + mkdir: 'PATH=${PATH#*:}\nexec mkdir -p "$@"', + tar: 'echo >> "${0%/*}/installs"\necho installing >&2\nread -r release\nPATH=${PATH#*:}\nexec tar "$@"', + sleep: 'echo waiting >&2\nPATH=${PATH#*:}\nexec sleep "$@"', + })) { + yield* fs.writeFileString(`${bin}/${name}`, `#!/bin/sh\n${script}\n`); + yield* fs.chmod(`${bin}/${name}`, 0o755); + } + const path = `${bin}:${process.env.PATH ?? ""}`; + const installing = yield* Deferred.make(); + const contended = yield* Deferred.make(); + // Hold the first extraction until another runner either waits for the lock + // or incorrectly begins installing. This forces contention without timing assumptions. + const first = yield* runRunner(home, runner, { + path, + stdin: Stream.fromEffect( + Deferred.await(contended).pipe(Effect.as(new TextEncoder().encode("release\n"))), + ), + onStderrLine: (line) => + line === "installing" + ? Deferred.succeed(installing, undefined).pipe(Effect.asVoid) + : Effect.void, + }).pipe(Effect.forkChild); + yield* Deferred.await(installing); + const contender = runRunner(home, runner, { + path, + stdin: Stream.make(new TextEncoder().encode("release\n")), + onStderrLine: (line) => + line === "installing" || line === "waiting" + ? Deferred.succeed(contended, undefined).pipe(Effect.asVoid) + : Effect.void, + }); + const results = yield* Effect.all([Fiber.join(first), contender, contender], { + concurrency: "unbounded", + }); + assert.equal(yield* fs.readFileString(`${bin}/installs`), "\n"); for (const result of results) { assert.equal(result.exitCode, 0, result.stderr); assert.include(result.stdout, `t3 v${archiveVersion}`); @@ -1145,13 +1188,12 @@ describe("archive runner script", () => { const lock = `${versionsDir}/.${archiveVersion}.install.lock`; yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true }); - yield* fs.makeDirectory(lock); - yield* fs.writeFileString(`${lock}/pid`, "999999\n"); + yield* fs.writeFileString(lock, "999999\n"); const afterDead = yield* runRunner(home, runner); assert.equal(afterDead.exitCode, 0, afterDead.stderr); yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true }); - yield* fs.makeDirectory(lock); + yield* fs.writeFileString(lock, ""); const afterUnowned = yield* runRunner(home, runner); assert.equal(afterUnowned.exitCode, 0, afterUnowned.stderr); assert.isFalse(yield* fs.exists(lock)); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 11371a38f26b..c7be29a9dedb 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -461,21 +461,13 @@ t3_runtime_ready() { } if ! t3_runtime_ready; then mkdir -p "$HOME/.t3/runtime/versions" - # Concurrent launches (two clients, a retry racing a slow first run) must - # not both install: mkdir is the atomic lock and the ready check repeats - # under it. T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" - # mkdir is the only portable atomic exclusive create (mv would silently - # nest a candidate inside an existing lock). The owner publishes its pid - # right after, so a lock with a live owner is never reclaimed however - # slow its download is, and a lock whose owner is dead is reclaimed at - # once. A lock with no pid at all is a crash between mkdir and the pid - # write; it is reclaimed after a short grace so a live owner has time to - # publish. + # uutils mkdir can report success on EEXIST, so use shell noclobber. + # An empty lock gets a short grace period for its owner to publish a PID. T3_LOCK_WAITED=0 T3_LOCK_UNOWNED=0 - while ! mkdir "$T3_LOCK" 2>/dev/null; do - T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" + while ! (set -C; printf '%s\\n' "$$" > "$T3_LOCK") 2>/dev/null; do + T3_LOCK_OWNER="$(cat "$T3_LOCK" 2>/dev/null || true)" if [ -n "$T3_LOCK_OWNER" ]; then T3_LOCK_UNOWNED=0 if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then @@ -496,7 +488,6 @@ if ! t3_runtime_ready; then sleep 1 T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) done - printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" trap 'rm -rf "$T3_LOCK"' EXIT fi if ! t3_runtime_ready; then From eb88541ac29374fb2925910b55ff749e631bd851 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 12:05:09 -0700 Subject: [PATCH 072/898] fix(provider-core): refresh installer metadata before explicit probes (#21451) --- apps/server/src/ws.ts | 5 +- .../src/server/managedProvider.test.ts | 55 +++++++++++ .../src/server/managedProvider.ts | 4 +- packages/provider-muse/src/server/driver.ts | 13 ++- .../src/server/maintenance.test.ts | 97 ++++++++++++++++++- 5 files changed, 169 insertions(+), 5 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 47ff1314f2c6..ea489a886d41 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2176,7 +2176,10 @@ const layerWsRpc = ( Effect.gen(function* () { yield* instance.invalidateCaches ?? Effect.void; const maintenance = yield* instance.snapshot.resolveMaintenance({ - fresh: true, + fresh: + input.instanceId !== undefined && + input.cwd !== undefined && + input.fresh !== true, }); yield* Effect.forEach( latestProviderVersionCacheKeys(maintenance), diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 0cbb238cf1f8..a5556ba2664b 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -12,12 +12,17 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ProviderHost from "./ProviderHost.ts"; import { makeManagedServerProvider } from "./managedProvider.ts"; +import { + createProviderVersionAdvisory, + makeCachedProviderMaintenanceResolution, +} from "./maintenanceResolver.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); const fastModeCapabilities = createModelCapabilities({ @@ -140,6 +145,56 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect( + "rereads installer metadata before explicit refresh while background checks stay cached", + () => + Effect.scoped( + Effect.gen(function* () { + const latestVersion = yield* Ref.make("1.0.0"); + const published = yield* Queue.unbounded(); + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + Ref.get(latestVersion).pipe( + Effect.map((version) => ({ ...maintenanceCapabilities, latestVersion: version })), + ), + ); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const capabilities = yield* resolveMaintenance(); + return { + ...refreshedSnapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: refreshedSnapshot.driver, + currentVersion: refreshedSnapshot.version, + latestVersion: capabilities.latestVersion ?? null, + maintenanceCapabilities: capabilities, + }), + }; + }), + enrichSnapshot: ({ snapshot }) => Queue.offer(published, snapshot).pipe(Effect.asVoid), + refreshInterval: "1 minute", + }); + assert.strictEqual((yield* Queue.take(published)).versionAdvisory?.status, "current"); + + yield* Ref.set(latestVersion, "2.0.0"); + yield* TestClock.adjust("1 minute"); + assert.strictEqual( + (yield* Queue.take(published)).versionAdvisory?.latestVersion, + "1.0.0", + ); + + const refreshed = yield* provider.refresh; + assert.strictEqual(refreshed.versionAdvisory?.latestVersion, "2.0.0"); + assert.strictEqual(refreshed.versionAdvisory?.status, "behind_latest"); + assert.deepStrictEqual(yield* provider.getSnapshot, refreshed); + }), + ).pipe(Effect.provide(Layer.mergeAll(layerAlwaysRunTest, TestClock.layer()))), + ); + it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index e90d5eefa04b..c63534c3a89f 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -293,7 +293,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return { resolveMaintenance: input.resolveMaintenance, getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), - refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), + refresh: input + .resolveMaintenance({ fresh: true }) + .pipe(Effect.andThen(refreshSnapshot), Effect.tapError(Effect.logError), Effect.orDie), applyUsageLimits, get streamChanges() { return Stream.fromPubSub(changesPubSub); diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 9a70f74eed83..bf0024a00979 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -119,7 +119,16 @@ export const MuseDriver: ProviderDriver = { return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities; }); const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance, + // Status refresh respects update checks; explicit updates use the resolver below. + resolveMaintenance: (options) => + host.settings.get.pipe( + Effect.orDie, + Effect.flatMap((settings) => + settings.enableProviderUpdateChecks + ? resolveMaintenance(options) + : resolveInstallation(options), + ), + ), getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, @@ -176,7 +185,7 @@ export const MuseDriver: ProviderDriver = { displayName, accentColor, enabled, - snapshot, + snapshot: { ...snapshot, resolveMaintenance }, orchestrationAdapter, textGeneration, } satisfies ProviderInstance; diff --git a/packages/provider-muse/src/server/maintenance.test.ts b/packages/provider-muse/src/server/maintenance.test.ts index 51550fe9f143..8271e70946b1 100644 --- a/packages/provider-muse/src/server/maintenance.test.ts +++ b/packages/provider-muse/src/server/maintenance.test.ts @@ -1,8 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -15,6 +24,11 @@ import { } from "./maintenance.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; +import { MuseDriver } from "./driver.ts"; const maintenanceCapabilities = { ...makeManualOnlyProviderMaintenanceCapabilities({ @@ -38,6 +52,87 @@ const snapshot: ServerProvider = { }; it.layer(NodeServices.layer)("Muse maintenance", (it) => { + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "refreshes installation metadata without release requests when update checks are disabled", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-refresh-" }); + const binaryPath = `${directory}/muse`; + // A failing version probe keeps the installed version without starting an SDK host. + const source = "#!/bin/sh\nprintf 'muse 1.0.3-R2198.1\\n'\nexit 1\n"; + yield* fs.writeFileString(binaryPath, source); + yield* fs.chmod(binaryPath, 0o755); + const requests: string[] = []; + yield* Effect.gen(function* () { + const { snapshot: provider } = yield* MuseDriver.create({ + instanceId: ProviderInstanceId.make("muse-refresh"), + displayName: "Muse refresh", + enabled: true, + environment: [{ name: "MUSE_CHANNEL", value: "muse-stable", sensitive: false }], + config: { ...MuseDriver.defaultConfig(), binaryPath }, + }); + const enriched = provider.streamChanges.pipe( + Stream.filter((current) => current.versionAdvisory !== undefined), + Stream.take(1), + Stream.runCollect, + Effect.map((snapshots) => Array.from(snapshots)[0]!), + ); + const current = yield* provider.getSnapshot; + const initial = current.versionAdvisory ? current : yield* enriched; + expect(initial.version).toBe("1.0.3-R2198.1"); + expect(initial.versionAdvisory?.canUpdate).toBe(false); + expect(requests).toEqual([]); + + yield* fs.writeFileString( + binaryPath, + `${source}# muse-code/launcher-2 MUSE_SYNC_UPDATE\n`, + ); + const refreshed = yield* enriched.pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* provider.refresh; + const latest = yield* Fiber.join(refreshed); + expect(requests).toEqual([]); + expect(latest.versionAdvisory).toMatchObject({ + status: "unknown", + latestVersion: null, + canUpdate: true, + }); + expect((yield* provider.resolveMaintenance()).update?.executable).toBe(binaryPath); + + // Explicit update verification must still fetch its target despite the preference. + const verification = yield* provider.resolveMaintenance({ fresh: true }); + expect(verification.latestVersion).toBe("1.1.1-R10.1"); + expect(requests).toEqual(["https://api.meta.ai/muse-code/channels/muse-stable"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + TestProviderHost.layer({ + runBackgroundWork: false, + settings: { ...DEFAULT_SERVER_SETTINGS, enableProviderUpdateChecks: false }, + }), + IdAllocator.layer, + McpProviderSessions.layer, + ProviderLatestVersions.layer, + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ version: "1.1.1-R10.1" })), + ); + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.effect( "refreshes a cached channel failure when an explicit update requests verification", () => From 23d75ef43dab1ddd6142921a693d8a2abe6fdbd9 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:35:45 +0530 Subject: [PATCH 073/898] fix(opencode): stop cancelled text generation sessions (#10606) Co-authored-by: Julius Marminge --- .../src/server/textGeneration.test.ts | 164 +++++++++++++++++- .../src/server/textGeneration.ts | 51 ++++-- 2 files changed, 199 insertions(+), 16 deletions(-) diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 0cc03755a5ce..daa5de57bac3 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -2,9 +2,14 @@ import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as NetService from "@t3tools/shared/Net"; @@ -24,6 +29,12 @@ const runtimeMock = { authHeaders: [] as Array, closeCalls: [] as string[], sessionCreateCalls: 0, + abortCalls: [] as string[], + onSessionCreate: undefined as ((signal?: AbortSignal) => Promise) | undefined, + onPrompt: undefined as + | ((signal?: AbortSignal) => Promise<{ data: { parts: ReadonlyArray } }>) + | undefined, + onAbort: undefined as ((signal?: AbortSignal) => Promise) | undefined, connectionError: undefined as Error | undefined, sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, @@ -39,6 +50,10 @@ const runtimeMock = { this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; this.state.sessionCreateCalls = 0; + this.state.abortCalls.length = 0; + this.state.onSessionCreate = undefined; + this.state.onPrompt = undefined; + this.state.onAbort = undefined; this.state.connectionError = undefined; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; @@ -93,19 +108,34 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => { + create: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => { runtimeMock.state.sessionCreateCalls += 1; + if (runtimeMock.state.onSessionCreate) { + return runtimeMock.state.onSessionCreate(options?.signal); + } if (runtimeMock.state.sessionCreateError !== undefined) { throw runtimeMock.state.sessionCreateError; } return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; }, - prompt: async (input: { readonly parts: ReadonlyArray }) => { + abort: async ( + input: { readonly sessionID: string }, + options?: { readonly signal?: AbortSignal }, + ) => { + runtimeMock.state.abortCalls.push(input.sessionID); + if (runtimeMock.state.onAbort) return runtimeMock.state.onAbort(options?.signal); + return { data: true }; + }, + prompt: async ( + input: { readonly parts: ReadonlyArray }, + options?: { readonly signal?: AbortSignal }, + ) => { runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.promptParts.push(input.parts); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); + if (runtimeMock.state.onPrompt) return runtimeMock.state.onPrompt(options?.signal); if (runtimeMock.state.promptRequestError !== undefined) { throw runtimeMock.state.promptRequestError; } @@ -228,6 +258,136 @@ const advanceIdleClock = Effect.gen(function* () { }); it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { + it.effect("cancels an in-flight session creation before prompting", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + runtimeMock.state.onSessionCreate = (signal) => { + Deferred.doneUnsafe(started, Effect.succeed(signal)); + return new Promise(() => {}); + }; + const fiber = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + const signal = yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true); + expect(signal?.aborted).toBe(true); + expect(runtimeMock.state.promptUrls).toEqual([]); + expect(runtimeMock.state.abortCalls).toEqual([]); + }), + ), + ); + + it.effect("cancels only its prompt session while another generation uses the shared server", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + runtimeMock.state.sessionResult = { data: { id: "cancelled-session" } }; + runtimeMock.state.onPrompt = (signal) => { + Deferred.doneUnsafe(started, Effect.succeed(signal)); + return new Promise(() => {}); + }; + const fiber = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + const signal = yield* Deferred.await(started); + + const otherStarted = yield* Deferred.make(); + const otherReply = Promise.withResolvers<{ data: { parts: ReadonlyArray } }>(); + runtimeMock.state.sessionResult = { data: { id: "other-session" } }; + runtimeMock.state.onPrompt = (otherSignal) => { + Deferred.doneUnsafe(otherStarted, Effect.succeed(otherSignal)); + return otherReply.promise; + }; + const otherFiber = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + const otherSignal = yield* Deferred.await(otherStarted); + yield* Fiber.interrupt(fiber); + + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true); + expect(signal?.aborted).toBe(true); + expect(otherSignal?.aborted).toBe(false); + expect(runtimeMock.state.abortCalls).toEqual(["cancelled-session"]); + expect(runtimeMock.state.closeCalls).toEqual([]); + + otherReply.resolve({ + data: { + parts: [{ type: "text", text: '{"subject":"Other generation completed","body":""}' }], + }, + }); + expect((yield* Fiber.join(otherFiber)).subject).toBe("Other generation completed"); + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4301", + ]); + expect(runtimeMock.state.abortCalls).toEqual(["cancelled-session"]); + expect(runtimeMock.state.closeCalls).toEqual([]); + }), + ), + ); + + it.effect.each(["failure", "timeout"] as const)( + "preserves cancellation when the session abort ends in %s", + (outcome) => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + return withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const promptStarted = yield* Deferred.make(); + const abortStarted = yield* Deferred.make(); + runtimeMock.state.onPrompt = (signal) => { + Deferred.doneUnsafe(promptStarted, Effect.succeed(signal)); + return new Promise(() => {}); + }; + runtimeMock.state.onAbort = (signal) => { + Deferred.doneUnsafe(abortStarted, Effect.succeed(signal)); + return outcome === "failure" + ? Promise.reject(new Error("Synthetic SDK response: confidential-value")) + : new Promise(() => {}); + }; + const fiber = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.forkChild); + const promptSignal = yield* Deferred.await(promptStarted); + const cancellation = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild); + const abortSignal = yield* Deferred.await(abortStarted); + if (outcome === "timeout") { + yield* TestClock.adjust("10 seconds"); + } + yield* Fiber.join(cancellation); + + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true); + expect(promptSignal?.aborted).toBe(true); + expect(runtimeMock.state.abortCalls).toEqual(["http://127.0.0.1:9999/session"]); + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.closeCalls).toEqual([]); + if (outcome === "timeout") expect(abortSignal?.aborted).toBe(true); + expect(messages).toEqual([ + [ + "Failed to stop cancelled OpenCode text generation.", + { + operation: "generateCommitMessage", + cwd: DEFAULT_COMMIT_MESSAGE_INPUT.cwd, + sessionId: "http://127.0.0.1:9999/session", + providerId: "openai", + modelId: "gpt-5", + }, + ], + ]); + }), + ).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }, + ); + it.effect("excludes generic files from thread title generation", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index 4c1f13362b78..11d6c19fba2c 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -186,11 +186,14 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), }); const session = yield* Effect.tryPromise({ - try: () => - client.session.create({ - title: `T3 Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], - }), + try: (signal) => + client.session.create( + { + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }, + { signal }, + ), catch: (cause) => new OpenCodeTextGenerationSessionRequestError({ operation: input.operation, @@ -215,20 +218,40 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }; const result = yield* Effect.tryPromise({ - try: () => - client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], - }), + try: (signal) => + client.session.prompt( + { + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }, + { signal }, + ), catch: (cause) => new OpenCodeTextGenerationPromptRequestError({ ...promptContext, cause, }), - }); + }).pipe( + // Disconnecting the HTTP request does not stop work in a shared or external server. + Effect.onInterrupt(() => + OpenCodeRuntime.runOpenCodeSdk("session.abort", (signal) => + client.session.abort({ sessionID: session.data.id }, { signal }), + ).pipe( + Effect.interruptible, + Effect.timeout("10 seconds"), + Effect.catch(() => + Effect.logWarning( + "Failed to stop cancelled OpenCode text generation.", + promptContext, + ), + ), + Effect.asVoid, + ), + ), + ); const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); if (promptFailure) { return yield* new OpenCodeTextGenerationPromptResponseError({ From 92a6b4307fc6a29096a2e6a83a7a526caed4fa61 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:36:01 +0530 Subject: [PATCH 074/898] fix(web): usage limits show one account popover at a time (#18076) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge --- .../usage/UsageLimitsPooled.browser.test.tsx | 120 ++++++++++ .../components/usage/UsageLimitsPooled.tsx | 223 +++++++++++------- 2 files changed, 254 insertions(+), 89 deletions(-) diff --git a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx index 2454e5cc8dff..296588761ea8 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.browser.test.tsx @@ -2,6 +2,7 @@ import { EnvironmentId, ProviderDriverKind, ProviderInstanceId, + UsageLimitSourceId, type ServerProvider, } from "@t3tools/contracts"; import type { LimitPresentations } from "@t3tools/shared/usageLimits"; @@ -164,3 +165,122 @@ it("omits an empty reset plate but keeps countdowns and banked credits visible", await expect.element(plate()).toBeVisible(); expect(plate()?.textContent).toBe("1"); }); + +it("moves the one limit popover between windows on hover", async () => { + const [environmentId, presentation] = [...presentations("2026-09-03T14:00:00Z", 1)][0]!; + const serverConfig = presentation.serverConfig!; + const provider = serverConfig.providers[0]!; + const twoWindows: LimitPresentations = new Map([ + [ + environmentId, + { + ...presentation, + serverConfig: { + ...serverConfig, + providers: [ + { + ...provider, + usageLimits: { + ...provider.usageLimits!, + windows: [ + ...provider.usageLimits!.windows, + { id: "weekly", kind: "weekly", label: "Weekly", usedPercent: 32 }, + ], + }, + }, + ], + }, + }, + ], + ]); + // Room above the bars so the popover opens there instead of flipping over the next card. + container.style.paddingTop = "20rem"; + await act(() => root.render()); + const segment = (label: string) => + Array.from(document.querySelectorAll("button")).find((node) => + node.getAttribute("aria-label")?.startsWith(label), + )!; + // Popup contents, once the previous segment's details have animated away. + const popups = () => + document.querySelector("[data-previous]") + ? null + : Array.from( + document.querySelectorAll('[data-slot="popover-popup"]'), + (node) => node.textContent, + ); + const session = segment("Alpha: 60%"); + const weekly = segment("Alpha: 68%"); + + await act(() => userEvent.click(session)); + await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); + + await act(() => userEvent.hover(weekly)); + await expect.poll(popups).toEqual([expect.stringContaining("Left68%")]); + + // Weekly's popover now sits over the middle of the Session bar; come back in at its edge. + await act(() => userEvent.hover(session, { position: { x: 4, y: 4 } })); + await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); + + await act(() => root.render()); + await expect.poll(popups).toEqual([]); + await act(() => userEvent.hover(container, { position: { x: 4, y: 4 } })); + + await act(() => root.render()); + await expect.poll(popups).toEqual([]); + await act(() => userEvent.click(segment("Alpha: 60%"))); + await expect.poll(popups).toEqual([expect.stringContaining("Left60%")]); +}); + +it("closes an open source-account popover when reset availability replaces its trigger", async () => { + const [environmentId, presentation] = [...presentations("2026-09-03T14:00:00Z", 1)][0]!; + const provider = presentation.serverConfig!.providers[0]!; + const source = (nextCreditId?: string): LimitPresentations => + new Map([ + [ + environmentId, + { + ...presentation, + serverConfig: { + providers: [], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "Hub", + checkedAt: "2026-09-03T11:00:00Z", + accounts: [ + { + id: "Alpha", + driver: provider.driver, + usageLimits: { + ...provider.usageLimits!, + resetCredits: { + availableCount: 1, + ...(nextCreditId ? { nextCreditId } : {}), + }, + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const popup = () => document.querySelector('[data-slot="popover-popup"]'); + await act(() => root.render()); + await click("Alpha: 60%"); + await expect.poll(() => popup()?.textContent).toContain("Use reset"); + + await act(() => root.render()); + await expect.poll(popup).toBeNull(); + await click("Alpha: 60%"); + await expect.poll(() => popup()?.textContent).toContain("Left60%"); + + await act(() => root.render()); + await expect.poll(popup).toBeNull(); + await click("Alpha: 60%"); + await expect.poll(() => popup()?.textContent).toContain("Use reset"); + await click("Use reset"); + await expect.poll(() => document.querySelector('[role="alertdialog"]')).not.toBeNull(); +}); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 52d938cdf5cc..c8fd3f239698 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -15,7 +15,7 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react"; -import { Fragment, type ReactNode, useState } from "react"; +import { createContext, Fragment, type ReactNode, use, useEffect, useState } from "react"; import { ensureLocalApi } from "../../localApi"; import { usePrimarySettings } from "../../hooks/useSettings"; @@ -27,7 +27,7 @@ import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { Button } from "../ui/button"; import { OpenAI } from "../Icons"; import { Alert, AlertTitle } from "../ui/alert"; -import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { PaceIcon, ResetCreditDialog, @@ -204,37 +204,134 @@ function SegmentPopover({ ); } +/** + * Every segment on the page shares one popover, so hovering or clicking another + * segment moves it instead of opening a second one. The payload is the segment + * it currently describes. + */ +type SegmentPayload = Omit[0], "now" | "onRedeem">; +const SegmentPopoverContext = createContext +> | null>(null); + +function ActiveSegmentPopover({ + handle, + payload, + pools, + now, +}: { + readonly handle: ReturnType>; + readonly payload: SegmentPayload; + readonly pools: readonly LimitPool[]; + readonly now: number; +}) { + const available = pools.some((pool) => + displayLimitWindows(pool).some((window) => + window.members.some( + (member) => + member.account.key === payload.account.key && + member.window.id === payload.window.id && + member.window.kind === payload.window.kind && + Boolean(member.account.redeem) === Boolean(payload.redeem), + ), + ), + ); + useEffect(() => { + if (!available) handle.close(); + }, [available, handle]); + return ( + + { + handle.close(); + payload.redeem?.setConfirming(true); + }} + /> + + ); +} + /** * The confirm is a sibling of the popover, not a child: dialogs stack under * popovers, and the popover closes as the confirm opens. */ function PoolSegment({ + account, + ...props +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly refillPercent: number; + readonly color: string; + readonly now: number; + readonly index: number; + readonly showAccountName: boolean; +}) { + return account.redeem ? ( + + ) : ( + + ); +} + +function RedeemableSegment({ + redeemAt, + ...props +}: Omit[0], "redeem"> & { + readonly redeemAt: NonNullable; +}) { + const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input); + return ( + <> + + void redeem.redeem()} + disabled={!redeem.canManageProviders} + /> + {/* The popover closed before the confirm, so progress and the outcome need a home outside it. */} + {redeem.busy || redeem.status ? ( + + {" "} + {redeem.busy ? "Using reset credit…" : redeem.status} + + ) : null} + + ); +} + +function SegmentTriggers({ account, window, reset, + redeem, refillPercent, color, now, index, showAccountName, -}: { - readonly account: LimitAccount; - readonly window: LimitPoolMember["window"]; - readonly reset: LimitPoolWindow["resets"][number] | undefined; +}: SegmentPayload & { readonly refillPercent: number; readonly color: string; readonly now: number; readonly index: number; readonly showAccountName: boolean; }) { - const [open, setOpen] = useState(false); + const popover = use(SegmentPopoverContext) ?? undefined; + const payload: SegmentPayload = { account, window, reset, redeem }; const remaining = remainingPercent(window); const timeLeft = paceRemainingPercent(window, now); const resetsIn = formatResetsIn(window, now); const credits = account.limits.resetCredits?.availableCount ?? 0; return ( - + <> - - {account.redeem ? ( - setOpen(false)} - /> - ) : ( - - {}} - /> - - )} - + + ); } function LegendRow({ - account, - window, + payload, color, now, index, }: { - readonly account: LimitAccount; - readonly window: LimitPoolMember["window"]; + readonly payload: SegmentPayload; readonly color: string; readonly now: number; readonly index: number; }) { + const popover = use(SegmentPopoverContext) ?? undefined; + const { account, window } = payload; const remaining = remainingPercent(window); const resetsIn = formatResetsIn(window, now); const credits = account.limits.resetCredits?.availableCount ?? 0; return ( } className="min-w-0 @2xl/pool:hidden" @@ -384,54 +462,6 @@ function LegendRow({ ); } -function RedeemableSegmentPopup({ - account, - window, - reset, - now, - redeemAt, - closePopover, -}: { - readonly account: LimitAccount; - readonly window: LimitPoolMember["window"]; - readonly reset: LimitPoolWindow["resets"][number] | undefined; - readonly now: number; - readonly redeemAt: NonNullable; - readonly closePopover: () => void; -}) { - const redeem = useResetCredit(redeemAt.environmentId, redeemAt.input); - return ( - <> - - { - closePopover(); - redeem.setConfirming(true); - }} - /> - - void redeem.redeem()} - disabled={!redeem.canManageProviders} - /> - {/* The popover closed before the confirm, so progress and the outcome need a home outside it. */} - {redeem.busy || redeem.status ? ( - - {" "} - {redeem.busy ? "Using reset credit…" : redeem.status} - - ) : null} - - ); -} - function PoolBar({ pool, color, @@ -551,6 +581,7 @@ export function UsageLimitsPooled({ readonly now: number; readonly cursorPrompt?: ReactNode; }) { + const [segmentPopover] = useState(() => PopoverCreateHandle()); const pools = collectLimitPools(collectLimitAccounts(presentations), now); const notices = collectLimitNotices(presentations); const externalLinks = collectExternalUsageLinks(presentations); @@ -566,12 +597,26 @@ export function UsageLimitsPooled({ No provider on the selected environments reports subscription limits.

) : null} - {pools.map((pool, index) => ( - - {index === cursorPromptAt ? cursorPrompt : null} - - - ))} + + {pools.map((pool, index) => ( + + {index === cursorPromptAt ? cursorPrompt : null} + + + ))} + + + {({ payload }) => + payload ? ( + + ) : null + } + {cursorPromptAt === pools.length ? cursorPrompt : null} {externalLinks.map((link) => (
Date: Mon, 19 Oct 2026 21:23:49 +0200 Subject: [PATCH 075/898] fix(web): preview viewports keep their size when the main window is zoomed (#14039) Co-authored-by: Claude Fable 5.1 Co-authored-by: Julius Marminge --- apps/desktop/src/preload.ts | 1 + .../HostedBrowserWebview.browser.test.tsx | 94 +++++++++++++++++++ apps/web/src/browser/HostedBrowserWebview.tsx | 21 ++--- apps/web/src/browser/browserViewportLayout.ts | 6 ++ .../src/browser/useBrowserLayoutZoomFactor.ts | 15 +++ .../preview/PreviewView.browser.test.tsx | 75 +++++++++++++++ .../src/components/preview/PreviewView.tsx | 6 +- .../preview/ThreadPreviewMiniPlayer.tsx | 4 +- packages/contracts/src/ipc.ts | 2 + 9 files changed, 209 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/browser/useBrowserLayoutZoomFactor.ts diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2e046b7f238c..a32724a649f2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -250,6 +250,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, + getWindowZoomFactor: () => webFrame.getZoomFactor(), onWindowFullscreenStateChange: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => { if (typeof fullscreen !== "boolean") return; diff --git a/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx b/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx index b534fae0215e..a6d2404e3bbf 100644 --- a/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx @@ -5,8 +5,11 @@ import { ThreadId, type ClientSettings, type DesktopPreviewBridge, + type PreviewViewportSetting, } from "@t3tools/contracts"; import { act } from "react"; + +import "~/index.css"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -201,3 +204,94 @@ describe("HostedBrowserWebview settings hydration", () => { expect(mocks.setClientSettings).not.toHaveBeenCalled(); }); }); + +describe("HostedBrowserWebview viewport layout", () => { + const runtimeTabId = "zoom-layout-tab"; + const owner = Symbol("panel"); + const panel = { x: 0, y: 0, width: 2000, height: 1600 }; + + async function renderViewport(viewport: PreviewViewportSetting) { + mocks.getClientSettings.mockResolvedValue({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.5, + }); + root ??= createRoot(container); + await act(async () => { + await ensureClientSettingsHydrated(); + root!.render( + , + ); + }); + const guest = container.querySelector("webview"); + expect(guest).not.toBeNull(); + return guest!; + } + + it("keeps fixed viewports sized in guest pixels across host zoom and fitted surfaces", async () => { + let windowZoom = 1.25; + vi.stubGlobal("desktopBridge", { getWindowZoomFactor: () => windowZoom }); + useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false); + useBrowserSurfaceStore.getState().present(runtimeTabId, owner, panel, true, 0, 30); + const guest = await renderViewport({ _tag: "freeform", width: 800, height: 600 }); + expect(guest.getBoundingClientRect()).toMatchObject({ width: 960, height: 720 }); + + windowZoom = 0.75; + await act(() => window.dispatchEvent(new Event("resize"))); + expect(guest.getBoundingClientRect()).toMatchObject({ width: 1600, height: 1200 }); + + windowZoom = 1; + await act(() => window.dispatchEvent(new Event("resize"))); + expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 }); + + windowZoom = 1.25; + await act(() => window.dispatchEvent(new Event("resize"))); + await renderViewport({ _tag: "preset", presetId: "iphone-se", width: 375, height: 667 }); + expect(guest.getBoundingClientRect().width).toBe(450); + expect(guest.getBoundingClientRect().height).toBeCloseTo(800.4, 1); + + const miniPlayer = Symbol("mini-player"); + await act(() => { + useBrowserSurfaceStore.getState().claim(runtimeTabId, miniPlayer, true); + useBrowserSurfaceStore + .getState() + .present(runtimeTabId, miniPlayer, { x: 0, y: 0, width: 225, height: 401 }, true, 0, 48); + }); + expect(guest.offsetWidth).toBe(450); + expect(guest.offsetHeight).toBe(800); + expect(guest.getBoundingClientRect().width).toBe(225); + expect(guest.getBoundingClientRect().height).toBeCloseTo(400.2, 1); + + await act(() => { + useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false); + useBrowserSurfaceStore.getState().present(runtimeTabId, owner, panel, true, 0, 30); + }); + await renderViewport(FILL_PREVIEW_VIEWPORT); + expect(guest.getBoundingClientRect()).toMatchObject({ width: 2000, height: 1600 }); + expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { + zoomFactor: 1.5, + colorScheme: DEFAULT_CLIENT_SETTINGS.browserDefaultAppearance, + }); + }); + + it("preserves preview scaling with an older bridge or no desktop bridge", async () => { + vi.stubGlobal("desktopBridge", {}); + const guest = await renderViewport({ _tag: "freeform", width: 800, height: 600 }); + expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 }); + + vi.stubGlobal("desktopBridge", undefined); + await act(() => window.dispatchEvent(new Event("resize"))); + expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 }); + }); +}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 616ed7969f11..7d6c86d77409 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -25,6 +25,7 @@ import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab, withDesktopTab } from "./desktopTabLifetime"; import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; +import { useBrowserLayoutZoomFactor } from "./useBrowserLayoutZoomFactor"; import { useBrowserViewportResize } from "./useBrowserViewportResize"; import { INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, @@ -209,7 +210,7 @@ export function HostedBrowserWebview(props: { const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; - const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const layoutZoomFactor = useBrowserLayoutZoomFactor(zoomFactor); const viewportWidth = viewport._tag === "fill" ? null : viewport.width; const viewportHeight = viewport._tag === "fill" ? null : viewport.height; const viewportAspectRatio = @@ -222,13 +223,13 @@ export function HostedBrowserWebview(props: { const content = presentation.content; const hiddenWidth = viewport._tag !== "fill" - ? viewport.width * normalizedZoomFactor + ? viewport.width * layoutZoomFactor : content ? content.width / content.scale : (lastRect?.width ?? 1280); const hiddenHeight = viewport._tag !== "fill" - ? viewport.height * normalizedZoomFactor + ? viewport.height * layoutZoomFactor : content ? content.height / content.scale : (lastRect?.height ?? 800); @@ -247,22 +248,18 @@ export function HostedBrowserWebview(props: { } = useBrowserViewportResize({ tabId: runtimeTabId, viewport, - zoomFactor, + zoomFactor: layoutZoomFactor, containerSize, deviceToolbarVisible, aspectRatio: lockedAspectRatio, }); const fittedSourceViewport = presentation.fitSourceContent && lastRect - ? resolveFittedBrowserViewport( - viewport, - presentation.fittedSourceContent, - normalizedZoomFactor, - ) + ? resolveFittedBrowserViewport(viewport, presentation.fittedSourceContent, layoutZoomFactor) : null; const layout = fittedSourceViewport && lastRect - ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, normalizedZoomFactor) + ? resolveBrowserViewportLayout(lastRect, fittedSourceViewport, layoutZoomFactor) : viewportLayout; const syncContentPresentation = () => { @@ -350,14 +347,14 @@ export function HostedBrowserWebview(props: { fittedSourceViewport ? fittedSourceViewport.width : effectiveViewport._tag === "fill" - ? Math.max(1, Math.round(layout.viewportWidth / normalizedZoomFactor)) + ? Math.max(1, Math.round(layout.viewportWidth / layoutZoomFactor)) : effectiveViewport.width } data-preview-css-height={ fittedSourceViewport ? fittedSourceViewport.height : effectiveViewport._tag === "fill" - ? Math.max(1, Math.round(layout.viewportHeight / normalizedZoomFactor)) + ? Math.max(1, Math.round(layout.viewportHeight / layoutZoomFactor)) : effectiveViewport.height } aria-hidden={active ? undefined : true} diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts index 9634cd8d270e..2fe4e035100a 100644 --- a/apps/web/src/browser/browserViewportLayout.ts +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -38,6 +38,12 @@ export const browserViewportSettingKey = (setting: PreviewViewportSetting): stri const normalizeZoomFactor = (zoomFactor: number): number => Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; +// Convert guest CSS pixels to host CSS pixels without changing the guest's page zoom. +export const resolveBrowserLayoutZoomFactor = ( + previewZoomFactor: number, + windowZoomFactor: number, +): number => normalizeZoomFactor(previewZoomFactor) / normalizeZoomFactor(windowZoomFactor); + export function resolveFittedBrowserViewport( setting: PreviewViewportSetting, sourceContent: { diff --git a/apps/web/src/browser/useBrowserLayoutZoomFactor.ts b/apps/web/src/browser/useBrowserLayoutZoomFactor.ts new file mode 100644 index 000000000000..a51be1685685 --- /dev/null +++ b/apps/web/src/browser/useBrowserLayoutZoomFactor.ts @@ -0,0 +1,15 @@ +import { useSyncExternalStore } from "react"; + +import { resolveBrowserLayoutZoomFactor } from "./browserViewportLayout"; + +// App zoom changes the CSS viewport, so resize also signals zoom changes. +const subscribeToWindowZoom = (onChange: () => void) => { + window.addEventListener("resize", onChange); + return () => window.removeEventListener("resize", onChange); +}; +const readWindowZoomFactor = () => window.desktopBridge?.getWindowZoomFactor?.() ?? 1; + +export function useBrowserLayoutZoomFactor(previewZoomFactor: number) { + const windowZoomFactor = useSyncExternalStore(subscribeToWindowZoom, readWindowZoomFactor); + return resolveBrowserLayoutZoomFactor(previewZoomFactor, windowZoomFactor); +} diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 0147addd0914..3855354dc81e 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => ({ pictureInPicture: false, showEmptyState: false, loading: false, + nativeServerTab: false, serverEpoch: null as string | null, servers: [] as PreviewableServer[], recordingTabIds: new Set(), @@ -115,6 +116,7 @@ vi.mock(import("~/previewStateStore"), async (original) => ({ "tab-1": { threadId: "thread-1", tabId: "tab-1", + runtime: mocks.nativeServerTab ? "server" : "desktop", navStatus: { _tag: "Success", url: "http://example.com/", title: "Example" }, canGoBack: false, canGoForward: false, @@ -147,6 +149,15 @@ vi.mock(import("./usePreviewSession"), async (original) => ({ usePreviewSession: () => {}, })); +vi.mock(import("~/browser/previewRuntime"), async (original) => ({ + ...(await original()), + useRendersServerTabNatively: () => mocks.nativeServerTab, +})); + +vi.mock(import("~/browser/browserViewportActions"), { spy: true }); + +import { commitBrowserViewportChange } from "~/browser/browserViewportActions"; +import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -205,6 +216,7 @@ function hasCursor() { describe("PreviewView", () => { beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.clearAllMocks(); mocks.readEnvironmentScope.mockReset().mockReturnValue(true); mocks.setAnnotationSendEnabled.mockReset().mockResolvedValue(undefined); @@ -212,6 +224,7 @@ describe("PreviewView", () => { mocks.pictureInPicture = false; mocks.showEmptyState = false; mocks.loading = false; + mocks.nativeServerTab = false; mocks.serverEpoch = null; mocks.servers = []; mocks.recordingTabIds = new Set(); @@ -232,6 +245,7 @@ describe("PreviewView", () => { for (const each of roots.splice(0)) await act(async () => each.unmount()); document.body.replaceChildren(); vi.useRealTimers(); + vi.unstubAllGlobals(); }); it("shows the cursor in a replacement browser while the old instance still records", async () => { @@ -254,6 +268,67 @@ describe("PreviewView", () => { expect(mocks.recordingTabIds.has(TEST_RUNTIME_TAB_ID)).toBe(true); }); + it.each([false, true])( + "aligns the cursor and responsive viewport after host zoom changes (native server tab: %s)", + async (nativeServerTab) => { + mocks.nativeServerTab = nativeServerTab; + let windowZoom = 1; + vi.stubGlobal("desktopBridge", { + getWindowZoomFactor: () => windowZoom, + getLocalEnvironmentBootstraps: () => [], + }); + const commit = vi.mocked(commitBrowserViewportChange).mockResolvedValueOnce(undefined); + try { + await render(view()); + await act(() => { + const surfaces = useBrowserSurfaceStore.getState(); + const slot = document.querySelector("[data-browser-surface-slot]")!; + slot.style.width = "1020px"; + slot.style.height = "798px"; + surfaces.presentContent(TEST_RUNTIME_TAB_ID, { + x: 20, + y: 40, + width: 500, + height: 375, + scale: 0.5, + scrollLeft: 7, + scrollTop: 11, + }); + useBrowserPointerStore.setState({ + byTabId: { + [TEST_RUNTIME_TAB_ID]: { + tabId: TEST_RUNTIME_TAB_ID, + phase: "move", + x: 100, + y: 200, + sequence: 1, + createdAt: "2026-07-27T00:00:00Z", + }, + }, + }); + windowZoom = 1.25; + window.dispatchEvent(new Event("resize")); + }); + const cursor = document.querySelector("[data-agent-browser-cursor]"); + expect(cursor).not.toBeNull(); + const position = new DOMMatrixReadOnly(getComputedStyle(cursor!).transform); + expect(position.e).toBe(53); + expect(position.f).toBe(109); + + await userEvent.click(page.getByRole("button", { name: "Preview menu" })); + await userEvent.click(page.getByRole("menuitem", { name: "Show device toolbar" })); + expect(commit).toHaveBeenCalledExactlyOnceWith(TEST_RUNTIME_TAB_ID, { + _tag: "freeform", + width: 1250, + height: 938, + }); + } finally { + commit.mockReset(); + await act(() => useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} })); + } + }, + ); + it("does not rerender while loading time passes", async () => { vi.useFakeTimers(); mocks.loading = true; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index e740c7b903f7..9423fed3835b 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -75,6 +75,7 @@ import { import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar"; import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout"; +import { useBrowserLayoutZoomFactor } from "~/browser/useBrowserLayoutZoomFactor"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { PreviewFileNotShown, PreviewUnreachable } from "./PreviewUnreachable"; @@ -221,6 +222,7 @@ export function PreviewView({ const serverInputDisabled = isServerTab && serverControlledTabId !== runtimeTabId; const [serverFrameTabId, setServerFrameTabId] = useState(null); const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; + const layoutZoomFactor = useBrowserLayoutZoomFactor(desktopOverlay?.zoomFactor ?? 1); const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; const url = navStatus._tag === "Idle" ? "" : navStatus.url; const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading"; @@ -391,7 +393,7 @@ export function PreviewView({ browserResponsiveViewportForToggle({ defaults: browserDefaults, panelRect, - zoomFactor: serverOwnsRendering ? serverZoomFactor : desktopOverlay?.zoomFactor, + zoomFactor: isServerTab ? serverZoomFactor : layoutZoomFactor, }), ).catch(() => undefined); }; @@ -1058,7 +1060,7 @@ export function PreviewView({ !activeRecordingTabIds.has(runtimeTabId) ? ( ) : null} diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 2155d6f0d197..73ffdbd769f3 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -31,6 +31,7 @@ import { supportsServerPictureInPicture, useServerPictureInPictureKey, } from "~/browser/serverPictureInPicture"; +import { useBrowserLayoutZoomFactor } from "~/browser/useBrowserLayoutZoomFactor"; import { Button } from "~/components/ui/button"; import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; @@ -125,6 +126,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly recordingTabIds.has(runtimeTabId) || findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) !== null; const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; + const layoutZoomFactor = useBrowserLayoutZoomFactor(desktopOverlay?.zoomFactor ?? 1); const fittedSourceContent = useBrowserSurfaceStore( (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, ); @@ -140,7 +142,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly : resolvePreviewMiniPlayerSourceSize( snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, fittedSourceContent, - desktopOverlay?.zoomFactor ?? 1, + layoutZoomFactor, ); const openInPanel = () => { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dcce2af0b0ea..d45c61e8666a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1120,6 +1120,8 @@ export interface DesktopBridge { onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; + /** App window zoom; older desktop shells omit this method. */ + getWindowZoomFactor?: () => number; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise; From 4ac8fa41625c5276bcd36dc18c60f68391c8fd3e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 19 Oct 2026 12:24:25 -0700 Subject: [PATCH 076/898] fix(mobile): Android thread pane no longer ends short after switching threads (#18963) Co-authored-by: Julius Marminge --- apps/mobile/src/Stack.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index c35d9911ca41..5763f8f2d2fd 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -169,7 +169,9 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; -const PULL_REQUEST_HEADER_OPTIONS: AppScreenOptions = { +// Hiding Android's native header after mount can leave detail panes measured short. +// These screens render their own Material toolbar, so hide it from the first layout. +const DETAIL_HEADER_OPTIONS: AppScreenOptions = { ...GLASS_HEADER_OPTIONS, headerShown: Platform.OS !== "android", }; @@ -704,15 +706,15 @@ const RootStackConfig = createWorkspaceStackNavigator({ path: "pull-requests/:environmentId/:projectId/:repository/:number", parse: { number: Number }, }, - options: PULL_REQUEST_HEADER_OPTIONS, + options: DETAIL_HEADER_OPTIONS, }), PullRequestGuideStop: createNativeStackScreen({ screen: PullRequestGuideStopScreen, - options: PULL_REQUEST_HEADER_OPTIONS, + options: DETAIL_HEADER_OPTIONS, }), PullRequestFile: createNativeStackScreen({ screen: PullRequestFileRouteScreen, - options: PULL_REQUEST_HEADER_OPTIONS, + options: DETAIL_HEADER_OPTIONS, }), PullRequestFiles: createNativeStackScreen({ screen: PullRequestFilesRouteScreen, @@ -726,7 +728,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ PullRequestReviewers: createNativeStackScreen({ screen: PullRequestReviewersScreen, options: { - ...PULL_REQUEST_HEADER_OPTIONS, + ...DETAIL_HEADER_OPTIONS, ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.75, 0.92], sheetGrabberVisible: true, @@ -735,7 +737,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ }), PullRequestDiscussion: createNativeStackScreen({ screen: PullRequestDiscussionScreen, - options: PULL_REQUEST_HEADER_OPTIONS, + options: DETAIL_HEADER_OPTIONS, }), PullRequestInlineComment: createNativeStackScreen({ screen: PullRequestInlineCommentScreen, @@ -760,7 +762,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: THREAD_LINKING_PREFIX, - options: GLASS_HEADER_OPTIONS, + options: DETAIL_HEADER_OPTIONS, }), ThreadTerminal: createNativeStackScreen({ screen: ThreadTerminalRouteScreen, From caa8c601acdb3646360031a2504fcacb680bace5 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:57:39 +0530 Subject: [PATCH 077/898] fix(mobile): Android review diff scrolls to the end of long lines (#19431) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../t3reviewdiff/ReviewDiffCanvasDrawing.kt | 63 +++++++++++++++++++ .../t3reviewdiff/T3ReviewDiffModule.kt | 5 +- .../modules/t3reviewdiff/T3ReviewDiffView.kt | 21 +++---- .../t3reviewdiff/ReviewDiffCodeLayoutTest.kt | 52 +++++++++++++++ .../features/diffs/nativeReviewDiffSurface.ts | 1 + 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt index 80d0410643ff..67579a1cad88 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -10,6 +10,7 @@ import android.graphics.RectF import android.graphics.Shader import android.graphics.Typeface import android.text.TextPaint +import kotlin.math.ceil import kotlin.math.max import kotlin.math.min @@ -211,6 +212,68 @@ internal class ReviewDiffCanvasDrawing(context: Context) { return codeLayouts.layout(rows, tokens, textPaint, style, theme, availableWidth.toInt()) } + private data class WidthEntry(val row: DiffRow, val tokens: List?, val width: Float) + private var widthEntries = emptyMap() + private var widthRows: List? = null + private var widthTokens: Map>? = null + private var widthStyle: DiffStyle? = null + private var measuredContentWidth = 0 + + fun contentWidth( + rows: List, + tokens: Map>, + style: DiffStyle + ): Int { + if (style.wordWrap) { + widthEntries = emptyMap() + widthRows = null + widthTokens = null + widthStyle = null + return 0 + } + // Comment heights and viewport resizing do not change unwrapped text widths. + if (widthRows !== rows || widthTokens !== tokens || widthStyle != style) { + if (widthStyle != style) widthEntries = emptyMap() + val next = HashMap() + var widest = 0f + for (row in rows) { + if (row.kind != "line" && row.kind != "hunk") continue + val rowTokens = if (row.kind == "line") tokens[row.id] else null + val cached = widthEntries[row.id] + val entry = if (cached?.row == row && cached.tokens == rowTokens) { + cached + } else { + WidthEntry(row, rowTokens, measureRowWidth(row, rowTokens, style)) + } + next[row.id] = entry + widest = max(widest, entry.width) + } + widthEntries = next + widthRows = rows + widthTokens = tokens + widthStyle = style + measuredContentWidth = ceil( + style.changeBarWidthPx + style.gutterWidthPx + style.codePaddingPx * 2f + widest, + ).toInt() + } + return measuredContentWidth + } + + private fun measureRowWidth(row: DiffRow, tokens: List?, style: DiffStyle): Float { + if (row.kind == "hunk") { + configureMonospacePaint(theme.hunkText, style.hunkFontSizePx, style.hunkFontWeight) + return textPaint.measureText(row.text.ifEmpty { row.content }) + } + val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(row.content, null, 0)) else tokens + var width = 0f + // Match drawCode's run boundaries and fonts, including fallback glyphs and tabs. + for (run in runs) { + configureCodePaint(run.color ?: theme.text, run.fontStyle, style) + width += textPaint.measureText(run.content) + } + return width + } + fun lineNumberColor(change: String): Int = when (change) { "add" -> theme.addText "delete" -> theme.deleteText diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt index 58422149016c..ee6740cf34c3 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -38,9 +38,8 @@ class T3ReviewDiffModule : Module() { Prop("rowHeight") { view: T3ReviewDiffView, rowHeight: Double -> view.setRowHeight(rowHeight.toFloat()) } - Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> - view.setContentWidth(contentWidth.toFloat()) - } + // Accept the shared prop while deriving Android's scroll range from the drawn text. + Prop("contentWidth") { _: T3ReviewDiffView, _: Double -> } Prop("contentInsetBottom") { view: T3ReviewDiffView, value: Double -> view.setContentInsetBottom(value.toFloat()) } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 495a63719a74..e39fdeb96340 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -139,10 +139,6 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont canvasView.style = canvasView.style.copy(rowHeightPx = dp(value)) } - fun setContentWidth(value: Float) { - canvasView.contentWidthPx = max(width, dp(value).toInt()) - } - fun setContentInsetBottom(value: Float) { canvasView.setPadding(0, 0, 0, max(0, dp(value).toInt())) canvasView.setVerticalOffset(canvasView.verticalOffset()) @@ -718,6 +714,8 @@ private class DiffCanvasView(context: Context) : View(context) { private var codeWrap = CodeWrapLayout.NONE private var verticalOffset = 0 private var horizontalOffset = 0 + + private var contentWidthPx = 0 private val headerPathOffsetsByFileId = mutableMapOf() private var lastVisibleRange: Pair? = null var rows: List = emptyList() @@ -731,7 +729,12 @@ private class DiffCanvasView(context: Context) : View(context) { var tokensByRowId: Map> = emptyMap() set(value) { field = value - if (style.wordWrap) rebuildOffsets() + if (style.wordWrap) { + rebuildOffsets() + } else { + contentWidthPx = drawing.contentWidth(rows, value, style) + setHorizontalOffset(horizontalOffset) + } invalidate() } var viewedFileIds: Set = emptySet() @@ -766,13 +769,6 @@ private class DiffCanvasView(context: Context) : View(context) { field = value rebuildOffsets() } - var contentWidthPx: Int = (1200 * density).toInt() - set(value) { - field = max(value, suggestedMinimumWidth) - setHorizontalOffset(horizontalOffset) - clampHeaderPathOffsets() - invalidate() - } var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null var onContentHeightChanged: ((Int) -> Unit)? = null @@ -894,6 +890,7 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun layoutRows() { + contentWidthPx = drawing.contentWidth(rows, tokensByRowId, style) codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width) rowOffsets = IntArray(rows.size + 1) rows.forEachIndexed { index, row -> diff --git a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt index 1f4234b7175f..7c4f531fbecb 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt @@ -14,6 +14,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode @@ -141,6 +142,57 @@ class ReviewDiffCodeLayoutTest { assertEquals(21, text.getSpanEnd(span)) } + @Test + fun cachedContentWidthMatchesFreshMeasurementAfterUpdatesAndRemoval() { + val context = RuntimeEnvironment.getApplication() + val drawing = ReviewDiffCanvasDrawing(context) + val style = DiffStyle.defaults(1f) + fun assertFresh( + rows: List, + tokens: Map> = emptyMap(), + currentStyle: DiffStyle = style + ) { + assertEquals( + ReviewDiffCanvasDrawing(context).contentWidth(rows, tokens, currentStyle), + drawing.contentWidth(rows, tokens, currentStyle), + ) + } + + val line = row("مرحبا\t漢字 e\u0301 ".repeat(20)) + val rows = listOf(line) + val tokens = mapOf( + line.id to listOf( + DiffToken("مرحبا", Color.RED, 2), + DiffToken(line.content.drop(5), Color.BLUE, 1), + ), + ) + assertFresh(rows) + assertFresh(rows, tokens) + assertFresh(rows, tokens) + assertFresh( + rows, + tokens, + style.copy( + codeFontSizePx = 24f, + codeFontWeight = "bold", + gutterWidthPx = 90f, + codePaddingPx = 16f, + changeBarWidthPx = 5f, + ), + ) + + val hunk = line.copy(kind = "hunk", id = "hunk", text = "@@ context @@ ".repeat(100)) + val hunkRows = listOf(line, hunk) + assertFresh(hunkRows, tokens) + assertFresh(hunkRows, tokens, style.copy(hunkFontSizePx = 22f, hunkFontWeight = "bold")) + val shortHunk = hunk.copy(text = "@@ short @@") + assertFresh(listOf(line, shortHunk), tokens) + assertFresh(listOf(shortHunk), tokens) + assertFresh(rows, tokens, style.copy(wordWrap = true)) + assertFresh(rows, tokens) + assertFresh(rows) + } + private fun row(content: String) = DiffRow( kind = "line", id = "line", fileId = "file", filePath = "test.ts", previousPath = null, changeType = "modified", additions = 1, deletions = 0, text = "", content = content, diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 21d7d372740c..9d51c72272b6 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -122,6 +122,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly themeJson: string; readonly styleJson?: string; readonly rowHeight: number; + /** Android ignores this and measures the widest row. */ readonly contentWidth: number; readonly initialRowIndex?: number; readonly scrollEnabled?: boolean; From 53196435dd6d3cb5ff97e29e6a06be73304b1e95 Mon Sep 17 00:00:00 2001 From: Jacob <589761+jvzijp@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:29:06 +0200 Subject: [PATCH 078/898] fix(web): open Azure DevOps PR links in the panel (#10987) Co-authored-by: Julius Marminge --- .../pullRequest/pullRequestList.logic.test.ts | 109 ++++++++++++++++++ .../pullRequest/pullRequestList.logic.ts | 44 +++++++ apps/web/src/routes/_chat.pull-requests.tsx | 37 +++--- 3 files changed, 169 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 172e64062f89..f6788ee094c9 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -12,6 +12,7 @@ import { largestPullRequestReadCount, pullRequestQuickActions, filterPullRequestsByInvolvement, + findProjectForRepository, findScopedProject, mergePullRequestLists, pullRequestEntryKey, @@ -1615,3 +1616,111 @@ describe("refresh page size", () => { expect(largestPullRequestReadCount(twoPagesAcrossFourRepositories("github"))).toBe(792); }); }); + +describe("findProjectForRepository", () => { + it("resolves Azure bare names using matching project and environment scope", () => { + const azure = (id: string, environmentId: string, path: string) => ({ + id, + environmentId, + repositoryIdentity: { + canonicalKey: `dev.azure.com/${path}`, + locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, + provider: "azure-devops", + displayName: path, + owner: "contoso", + name: path.split("/").at(-1)!, + }, + }); + const payments = azure("p1", ENV_1, "contoso/payments/_git/api"); + const billing = azure("p2", ENV_1, "contoso/billing/_git/api"); + const remoteBilling = azure("p2", ENV_2, "contoso/billing/_git/api"); + const unrelated = azure("p3", ENV_2, "contoso/billing/_git/web"); + const projects = [payments, billing, remoteBilling, unrelated]; + + expect(findProjectForRepository(projects, { repository: "api" })).toBe(payments); + expect( + findProjectForRepository(projects, { + repository: "api", + scopedProjectId: "p2", + scopedEnvironmentId: ENV_2, + }), + ).toBe(remoteBilling); + expect( + findProjectForRepository(projects, { + repository: "api", + scopedProjectId: "p3", + scopedEnvironmentId: ENV_2, + }), + ).toBe(payments); + }); + + it("keeps repository matches on the selected environment when project IDs overlap", () => { + const project = (id: string, environmentId: string, repository: string) => ({ + id, + environmentId, + repositoryIdentity: { + canonicalKey: `github.com/${repository}`, + locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, + provider: "github", + displayName: repository, + }, + }); + const unrelated = project("p1", ENV_1, "acme/web"); + const local = project("p2", ENV_1, "acme/api"); + const remote = project("p1", ENV_2, "acme/api"); + const projects = [unrelated, local, remote]; + + expect(findProjectForRepository(projects, { repository: "acme/api" })).toBe(local); + expect( + findProjectForRepository(projects, { + repository: "acme/api", + selectedEnvironmentId: ENV_2, + }), + ).toBe(remote); + expect( + findProjectForRepository(projects, { + repository: "acme/web", + selectedEnvironmentId: ENV_2, + }), + ).toBeUndefined(); + expect( + findProjectForRepository(projects, { + repository: "acme/api", + selectedEnvironmentId: "env-connecting", + }), + ).toBeUndefined(); + }); + + it.each([ + ["gitlab", "t3tools/platform/t3code", "T3Tools/Platform/T3Code"], + ["github", "t3tools/t3code", "T3Tools/T3Code"], + ] as const)( + "selects the %s repository on the requested host despite a scope on another host", + (provider, repository, displayName) => { + const project = (host: string) => ({ + id: host, + environmentId: ENV_1, + repositoryIdentity: { + canonicalKey: `${host}/${repository}`, + locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, + provider, + displayName, + owner: "t3tools", + name: "t3code", + }, + }); + const publicProject = project(`${provider}.com`); + const enterpriseProject = project(`${provider}.acme.test`); + const projects = [publicProject, enterpriseProject]; + + expect( + findProjectForRepository(projects, { + repository: repository.toUpperCase(), + host: `${provider}.ACME.TEST`, + scopedProjectId: publicProject.id, + scopedEnvironmentId: ENV_1, + }), + ).toBe(enterpriseProject); + }, + ); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index c64254d58f05..7ea97cfd05f1 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -5,10 +5,13 @@ import { PullRequestListEntry, PullRequestListProjectError, PullRequestListResult, + pullRequestHostOf, resolvePullRequestAuthorFilter, } from "@t3tools/contracts"; import type { ProjectId, + RepositoryIdentity, + SourceControlProviderKind, PullRequestAction, PullRequestActor, PullRequestDiffStat, @@ -20,6 +23,8 @@ import type { PullRequestState, } from "@t3tools/contracts"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; + import { toSortableTimestamp } from "../../lib/threadSort"; import type { PullRequestListSort } from "./pullRequestListPreferences"; @@ -968,3 +973,42 @@ export function largestPullRequestReadCount( } return Math.max(0, ...counts.values()); } + +export function findProjectForRepository< + Project extends { + readonly id: string; + readonly environmentId: string; + readonly repositoryIdentity?: RepositoryIdentity | null | undefined; + }, +>( + projects: ReadonlyArray, + input: { + readonly repository: string; + readonly host?: string | undefined; + readonly selectedEnvironmentId?: string | null | undefined; + readonly scopedProjectId?: string | undefined; + readonly scopedEnvironmentId?: string | null | undefined; + }, +): Project | undefined { + const repository = input.repository.toLowerCase(); + const host = input.host?.toLowerCase(); + const candidates = projects.filter((project) => { + if ( + input.selectedEnvironmentId != null && + project.environmentId !== input.selectedEnvironmentId + ) { + return false; + } + const identity = project.repositoryIdentity; + if (!identity || sourceControlRepositorySelector(identity)?.toLowerCase() !== repository) { + return false; + } + return ( + host === undefined || + pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) === host + ); + }); + // Azure repository names can collide across projects on the same host. + const scoped = findScopedProject(candidates, input.scopedEnvironmentId, input.scopedProjectId); + return scoped ?? candidates[0]; +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 8b9bef98d0e9..f327646a25b3 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -55,6 +55,7 @@ import { import { filterPullRequestsByInvolvement, + findProjectForRepository, findScopedProject, collectPullRequestListFacets, groupPullRequestsByInvolvement, @@ -1799,26 +1800,6 @@ function usePullRequestScope(search: PullRequestsSearch) { const scopedProjects = pullRequestFilterProjects(projects, environmentLabels, scopedProject); const selectedHost = search.selectedHost ?? search.host; - const projectIdForRepository = (() => { - const repository = search.repository?.toLowerCase(); - if (repository === undefined) return undefined; - const identity = projects.find( - (project) => - project.repositoryIdentity?.owner && - project.repositoryIdentity.name && - `${project.repositoryIdentity.owner}/${project.repositoryIdentity.name}`.toLowerCase() === - repository && - (selectedHost === undefined || - pullRequestHostOf( - project.repositoryIdentity, - project.repositoryIdentity.provider as SourceControlProviderKind, - ) === selectedHost.toLowerCase()), - ); - return identity?.id; - })(); - - const linkedProjectId = resolveProjectScope(search.selectedProjectId, projects, projectsKnown); - const selectedProjectId = linkedProjectId ?? projectIdForRepository ?? scopedProjectId; // A named server still connecting is kept named rather than falling back: falling back while it // is merely not ready yet would resolve the project against whatever other server has answered, // which can be the wrong one where two servers share a project id. @@ -1827,7 +1808,21 @@ function usePullRequestScope(search: PullRequestsSearch) { knownEnvironmentIds, scopedEnvironmentId, ); - const selectedProject = findScopedProject(projects, selectedEnvironmentId, selectedProjectId); + const projectForRepository = + search.repository === undefined + ? undefined + : findProjectForRepository(projects, { + repository: search.repository, + host: selectedHost, + selectedEnvironmentId, + scopedProjectId, + scopedEnvironmentId, + }); + const linkedProjectId = resolveProjectScope(search.selectedProjectId, projects, projectsKnown); + const selectedProject = + linkedProjectId === undefined && search.repository !== undefined + ? projectForRepository + : findScopedProject(projects, selectedEnvironmentId, linkedProjectId ?? scopedProjectId); const queryEnvironmentIds = resolveQueryEnvironmentIds( environmentIds, projects, From b8d96370034e89d584f8d20160921644b31e1427 Mon Sep 17 00:00:00 2001 From: abderrahmen bejaoui <115119179+abderbejaoui@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:29:16 +0100 Subject: [PATCH 079/898] fix(server): Changes totals compare the default branch with its tracking upstream (#15393) Co-authored-by: abderbj <115119179+abderbj@users.noreply.github.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 Co-authored-by: Julius Marminge --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 44 +++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 46 ++++++++++++++------ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f797cc7f1451..7819dec2f363 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2593,6 +2593,50 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("Changes compares the default branch with its tracking upstream, not origin", () => + Effect.gen(function* () { + // `origin` is a fork whose main is behind; the checkout tracks the + // canonical repository through a second remote (#15314). + const canonical = yield* makeTmpDir(); + yield* initRepoWithCommit(canonical); + yield* git(canonical, ["branch", "-M", "main"]); + const fork = yield* makeTmpDir(); + yield* git(fork, ["clone", "--quiet", canonical, "."]); + yield* writeTextFile(canonical, "canonical.txt", "one\ntwo\nthree\n"); + yield* git(canonical, ["add", "canonical.txt"]); + yield* git(canonical, ["commit", "-m", "canonical moves ahead"]); + + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["init", "-b", "main"]); + yield* git(cwd, ["remote", "add", "origin", fork]); + yield* git(cwd, ["remote", "add", "upstream", canonical]); + yield* git(cwd, ["fetch", "--quiet", "--all"]); + yield* git(cwd, ["reset", "--hard", "--quiet", "upstream/main"]); + yield* git(cwd, ["branch", "--set-upstream-to=upstream/main", "main"]); + + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: "upstream/main", + insertions: 0, + deletions: 0, + }); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const changes = preview.sources.find((source) => source.kind === "branch-range")!; + assert.strictEqual(changes.baseRef, "upstream/main"); + assert.deepStrictEqual(changes.files, []); + + // Without an upstream the primary remote's copy stays the base. + yield* git(cwd, ["branch", "--unset-upstream", "main"]); + const untracked = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(untracked.branchChanges, { + baseRef: "origin/main", + insertions: 3, + deletions: 0, + }); + }), + ); + it.effect("reports non-repository directories without failing", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f3120f5ebefd..880226675cee 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1658,6 +1658,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return remoteBase.exitCode === 0 ? { baseRef, comparisonRef } : null; }); + // Tracking may point to a different remote than the primary one. Resolve its + // full ref so a similarly named local branch or tag cannot shadow it. + const resolveTrackedRemoteBaseRef = Effect.fn("resolveTrackedRemoteBaseRef")(function* ( + cwd: string, + refName: string, + ) { + const upstream = yield* executeGit( + "GitVcsDriver.resolveTrackedRemoteBaseRef", + cwd, + ["rev-parse", "--symbolic-full-name", `${refName}@{upstream}`], + { allowNonZeroExit: true }, + ); + const comparisonRef = upstream.stdout.trim(); + if (upstream.exitCode !== 0 || !comparisonRef.startsWith("refs/remotes/")) return null; + return { baseRef: comparisonRef.slice("refs/remotes/".length), comparisonRef }; + }); + const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( cwd: string, refName: string, @@ -1701,19 +1718,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* continue; } if (normalizedCandidate === refName) { - if ( - options?.allowRemoteOfCurrent && - primaryRemoteName && - (yield* remoteBranchExists({ - cwd, - remoteName: primaryRemoteName, - refName: normalizedCandidate, - })) - ) { - return { - baseRef: `${primaryRemoteName}/${normalizedCandidate}`, - comparisonRef: `refs/remotes/${primaryRemoteName}/${normalizedCandidate}`, - }; + if (options?.allowRemoteOfCurrent) { + const trackedRemoteBase = yield* resolveTrackedRemoteBaseRef(cwd, refName); + if (trackedRemoteBase !== null) return trackedRemoteBase; + if ( + primaryRemoteName && + (yield* remoteBranchExists({ + cwd, + remoteName: primaryRemoteName, + refName: normalizedCandidate, + })) + ) { + return { + baseRef: `${primaryRemoteName}/${normalizedCandidate}`, + comparisonRef: `refs/remotes/${primaryRemoteName}/${normalizedCandidate}`, + }; + } } continue; } From 65ba252b7008266510835b7b85c4b15d3babb460 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 06:29:33 +1100 Subject: [PATCH 080/898] fix(desktop): explain why the update button is disabled (#18404) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: SAPHID Co-authored-by: Julius Marminge --- .../src/updates/DesktopUpdates.test.ts | 23 +++++++++++++ apps/desktop/src/updates/DesktopUpdates.ts | 22 ++++++------- .../components/desktopUpdate.logic.test.ts | 33 +++++++++++++++++++ .../web/src/components/desktopUpdate.logic.ts | 16 +++++++++ .../components/settings/SettingsPanels.tsx | 3 +- .../components/sidebar/SidebarUpdatePill.tsx | 16 +++++++-- 6 files changed, 98 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 2a0d1d453092..94bfbe44c368 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -107,6 +107,29 @@ describe("DesktopUpdates", () => { }), ); + it.effect("keeps the disabled reason available after configuration and channel changes", () => { + const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } }); + const reason = "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const configured = yield* updates.getState; + assert.equal(configured.message, reason); + + const changed = yield* updates.setChannel("nightly"); + assert.equal(changed.channel, "nightly"); + assert.equal(changed.enabled, false); + assert.equal(changed.status, "disabled"); + assert.equal(changed.message, reason); + assert.equal(harness.sentStates.at(-1)?.message, reason); + assert.equal(harness.checkCount(), 0); + }), + ).pipe(Effect.provide(harness.layer)); + }); + it.effect("subscribe delivers the latest state plus subsequent changes", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index b2d93f407ac7..ae3c2588fab2 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -273,13 +273,15 @@ function parseAppUpdateYml(raw: string): Effect.Effect, environment: DesktopEnvironment.DesktopEnvironment["Service"], ): DesktopUpdateState { + const enabled = Option.isNone(disabledReason); return { ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), enabled, status: enabled ? "idle" : "disabled", + message: Option.getOrNull(disabledReason), }; } @@ -468,8 +470,6 @@ export const make = Effect.gen(function* () { }); }); - const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); - const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* ( reason: string, actionReservation: "acquire" | "held" = "acquire", @@ -631,10 +631,10 @@ export const make = Effect.gen(function* () { ), ); - const baseUpdateState = (channel: DesktopUpdateChannel, enabled: boolean) => + const baseUpdateState = (channel: DesktopUpdateChannel, disabledReason: Option.Option) => Ref.get(notAppliedVersionRef).pipe( Effect.map((notAppliedVersion) => ({ - ...createBaseUpdateState(channel, enabled, environment), + ...createBaseUpdateState(channel, disabledReason, environment), ...Option.match(notAppliedVersion, { onNone: () => ({}), onSome: (version) => ({ notAppliedVersion: version }), @@ -1056,9 +1056,9 @@ export const make = Effect.gen(function* () { } const settings = yield* desktopSettings.get; - const enabled = yield* shouldEnableAutoUpdates; - yield* setState(yield* baseUpdateState(settings.updateChannel, enabled)); - if (!enabled) { + const disabledReason = yield* resolveDisabledReason; + yield* setState(yield* baseUpdateState(settings.updateChannel, disabledReason)); + if (Option.isSome(disabledReason)) { return; } yield* Ref.set(updaterConfiguredRef, true); @@ -1127,10 +1127,10 @@ export const make = Effect.gen(function* () { ), ); - const enabled = yield* shouldEnableAutoUpdates; - yield* setState(yield* baseUpdateState(nextChannel, enabled)); + const disabledReason = yield* resolveDisabledReason; + yield* setState(yield* baseUpdateState(nextChannel, disabledReason)); - if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) { + if (Option.isSome(disabledReason) || !(yield* Ref.get(updaterConfiguredRef))) { return yield* Ref.get(updateStateRef); } diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 3a42348381de..6d9ce611c909 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -6,6 +6,8 @@ import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, + getDesktopUpdateDisabledReason, + getDesktopUpdateIdleTooltip, getDesktopUpdateInstallConfirmationMessage, getDesktopUpdateReleaseHistoryUrl, getDesktopUpdateReleaseUrl, @@ -391,3 +393,34 @@ describe("getDesktopUpdateButtonTooltip", () => { ); }); }); + +describe("disabled update reason", () => { + const disabledState: DesktopUpdateState = { + ...baseState, + enabled: false, + status: "disabled", + message: "Automatic updates are only available in packaged production builds.", + }; + + it("explains why checking is unavailable", () => { + expect(getDesktopUpdateDisabledReason(disabledState)).toBe( + "Automatic updates are only available in packaged production builds.", + ); + expect(getDesktopUpdateIdleTooltip(disabledState)).toEqual({ + title: "Updates unavailable", + detail: "Automatic updates are only available in packaged production builds.", + }); + expect(getDesktopUpdateDisabledReason({ ...disabledState, message: null })).toBe( + "Automatic updates are not available in this build.", + ); + }); + + it("keeps the check prompt when updates are available", () => { + expect(getDesktopUpdateDisabledReason(baseState)).toBeNull(); + expect(getDesktopUpdateDisabledReason(null)).toBeNull(); + expect(getDesktopUpdateIdleTooltip(baseState)).toEqual({ + title: "Check for updates", + detail: null, + }); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index bbef4bf5a4e3..f6d5bc90d60b 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -148,3 +148,19 @@ export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { state.status !== "checking" && state.status !== "downloading" && state.status !== "disabled" ); } + +export function getDesktopUpdateDisabledReason( + state: Pick | null, +): string | null { + if (!state || (state.enabled && state.status !== "disabled")) return null; + return state.message?.trim() || "Automatic updates are not available in this build."; +} + +export function getDesktopUpdateIdleTooltip( + state: Pick | null, +) { + const disabledReason = getDesktopUpdateDisabledReason(state); + return disabledReason + ? { title: "Updates unavailable", detail: disabledReason } + : { title: "Check for updates", detail: null }; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 949ad3ea1338..5c4cdcaffd73 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -60,6 +60,7 @@ import { MobileAppSettingsRow } from "./MobileAppSettingsRow"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, + getDesktopUpdateDisabledReason, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, @@ -419,7 +420,7 @@ function AboutVersionSection() { const description = action === "download" || action === "install" ? "Update available." - : "Current version of the application."; + : (getDesktopUpdateDisabledReason(updateState) ?? "Current version of the application."); return ( <> diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index cfe0ba456829..b7b329de37c9 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -15,6 +15,7 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateDownloadedVersion, + getDesktopUpdateIdleTooltip, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, @@ -236,6 +237,8 @@ function SidebarUpdateControl() { isDownloading, showCheckIcon, }); + const idleTooltip = + !showUpdateDetails && !showCheckIcon ? getDesktopUpdateIdleTooltip(state) : null; const tooltip = runsEverything ? everything.summary : showUpdateDetails @@ -244,7 +247,7 @@ function SidebarUpdateControl() { : "Update available" : showCheckIcon ? "Checking for updates…" - : "Check for updates"; + : (idleTooltip?.title ?? "Check for updates"); const disabled = showCheckIcon ? true : runsEverything @@ -441,7 +444,7 @@ function SidebarUpdateControl() { const updateButton = ( : null} -

- ), -})); +// List-only cases isolate query-backed rows; the density case uses their real layout. +vi.mock(import("./ThreadDetailsPrRow"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ThreadDetailsPrRow: (props: ComponentProps) => + fixture.realRows ? ( + + ) : ( +
+ {props.onStopWatching ? ( + + ) : null} +
+ ), + }; +}); vi.mock(import("~/state/entities"), async (importOriginal) => ({ ...(await importOriginal()), useProjects: () => [], + useQuietThreadShell: (() => ({ + environmentId: "environment", + projectId: "project", + worktreePath: null, + })) as never, + useProject: (() => ({ workspaceRoot: "/tmp/project" })) as never, + useThreadShellsForProjectRefs: () => [], })); vi.mock(import("~/state/use-atom-command"), async (importOriginal) => ({ ...(await importOriginal()), useAtomCommand: (() => watchCommand) as never, })); +vi.mock(import("./ChatCanvasContext"), async (original) => ({ + ...(await original()), + useChatCanvas: (() => ({ + container: { width: 1800, height: 500 }, + lane: { padding: 48, minChatWidth: 640 }, + layout: { frame: null }, + })) as never, +})); +vi.mock(import("../../hooks/useT3ProjectFileScripts"), async (original) => ({ + ...(await original()), + useT3ProjectFileScripts: () => [], +})); +vi.mock(import("./ThreadAutomationsPanel"), () => ({ ThreadAutomationsPanel: () => null })); +vi.mock(import("./ThreadRelationshipsControl"), () => ({ ThreadRelationshipsPanel: () => null })); +vi.mock(import("../pullRequest/usePullRequestActions"), async (original) => ({ + ...(await original()), + usePullRequestHandoffs: () => ({ handoff: null, startAsk: vi.fn(), startHandoff: vi.fn() }), +})); +vi.mock(import("../BranchToolbarBranchSelector"), () => ({ + BranchToolbarBranchSelector: () => ( + + ), +})); + +import { PopoverCreateHandle } from "../ui/popover"; +import { ThreadDetailsPanel } from "./ThreadDetailsPanel"; import { ThreadDetailsPrRows } from "./ThreadDetailsPrRows"; +const threadRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), +}; +const manyLinks = Array.from({ length: 70 }, (_, i) => + link(i + 1, `branch-${i + 1}`, "main", "2026-01-01T00:00:00.000Z"), +); + function link( number: number, headBranch: string, @@ -73,6 +133,7 @@ afterEach(() => { act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); + fixture.realRows = false; }); function render(links: ReadonlyArray, current: ThreadPullRequestLink) { @@ -166,3 +227,58 @@ it("lets only watched pull requests stop their watch", () => { }, }); }); + +it("keeps workspace controls after expanding and collapsing 70 linked pull requests", async () => { + fixture.realRows = true; + container.style.cssText = "position:relative;width:1800px;height:500px"; + await act(async () => { + root.render( + , + ); + }); + const runContext = page.getByRole("button", { name: "Run context" }); + const editor = page.getByRole("group", { name: "Open in editor" }); + await expect.element(runContext).toBeVisible(); + await expect.element(editor).toBeVisible(); + + await userEvent.click(page.getByRole("button", { name: "Show 69 more" })); + await expect.element(page.getByRole("button", { name: "Show less" })).toBeInTheDocument(); + const extraRows = () => + container.querySelectorAll('[aria-label^="https://github.com/pingdotgg/t3code/pull/"]'); + expect(extraRows()).toHaveLength(69); + // Let native ResizeObserver deliver the expanded content size before collapsing it. + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + await userEvent.click(page.getByRole("button", { name: "Show less" })); + await expect.element(page.getByRole("button", { name: "Show 69 more" })).toBeVisible(); + expect(extraRows()).toHaveLength(0); + await expect.element(page.getByRole("button", { name: "Open pull request #1" })).toBeVisible(); + await expect.element(runContext).toBeVisible(); + await expect.element(editor).toBeVisible(); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index c653411ce659..8dfb4623801f 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -101,8 +101,9 @@ export function ThreadDetailsPrRows({ return ( <> {currentRow} - {expanded - ? rest.map((link) => ( + {expanded ? ( +
+ {rest.map((link) => ( - )) - : null} + ))} +
+ ) : null} Date: Mon, 19 Oct 2026 23:25:08 +0300 Subject: [PATCH 112/898] fix(server): show Codex Enterprise monthly credit limits (#14657) Co-authored-by: Julius Marminge --- .../src/provider/codexUsageLimits.test.ts | 67 +++++++++++++++++++ apps/server/src/provider/codexUsageLimits.ts | 16 +++++ 2 files changed, 83 insertions(+) diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index 8d310a9fff73..0a1906685981 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -13,6 +13,34 @@ import { const checkedAt = "2026-07-18T10:00:00.000Z"; describe("codexRateLimitsToLimits", () => { + it("shows an Enterprise monthly credit allowance in reads and notifications without a duration", () => { + const snapshot = { + limitId: "codex", + planType: "enterprise_cbp_usage_based", + primary: null, + secondary: null, + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, + }; + const windows = [ + { + id: "individual", + kind: "monthly", + label: "Monthly credit limit", + usedPercent: 1, + resetsAt: "2026-07-19T22:26:40.000Z", + }, + ]; + + expect( + codexRateLimitsToLimits({ + checkedAt, + snapshot: { limitId: "spark" }, + rateLimitsByLimitId: { codex: snapshot }, + }), + ).toEqual({ checkedAt, windows }); + expect(codexRateLimitsToUpdate(snapshot)).toEqual({ windows }); + }); + it("maps primary and secondary onto the session and weekly windows", () => { expect( codexRateLimitsToLimits({ @@ -66,6 +94,7 @@ describe("codexRateLimitsToLimits", () => { it("selects the main Codex allowance and leaves Spark out", () => { const spark = { limitId: "codex_bengalfox", + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, primary: { usedPercent: 0, windowDurationMins: 300 }, secondary: { usedPercent: 90, windowDurationMins: 10080 }, }; @@ -105,6 +134,7 @@ describe("codexRateLimitsToLimits", () => { checkedAt, snapshot: { limitId: "codex_bengalfox", + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, secondary: { usedPercent: 90 }, }, }).windows, @@ -136,6 +166,7 @@ describe("codexRateLimitsToUpdate", () => { expect( codexRateLimitsToUpdate({ limitId: "codex_bengalfox", + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, primary: { usedPercent: 0, windowDurationMins: 300 }, secondary: { usedPercent: 90, windowDurationMins: 10080 }, }), @@ -266,6 +297,41 @@ describe("codexUsageLimitMessage", () => { }); describe("mergeCodexRateLimits", () => { + it("updates monthly credit usage, retains it when omitted, and clears an explicit null", () => { + const snapshot = { + limitId: "codex", + primary: { usedPercent: 12 }, + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, + }; + const individualLimit = { remainingPercent: 75, resetsAt: 1_787_000_000 }; + const changed = mergeCodexRateLimits(snapshot, { individualLimit }); + expect(changed).toEqual({ ...snapshot, individualLimit }); + expect(codexRateLimitsToUpdate(changed!)).toEqual({ + windows: [ + { + id: "primary", + kind: "session", + label: "Session", + usedPercent: 12, + windowDurationMins: 300, + }, + { + id: "individual", + kind: "monthly", + label: "Monthly credit limit", + usedPercent: 25, + resetsAt: "2026-08-17T20:53:20.000Z", + }, + ], + }); + + const retained = mergeCodexRateLimits(changed, { primary: null }); + expect(retained).toEqual({ ...snapshot, primary: null, individualLimit }); + const cleared = mergeCodexRateLimits(retained, { individualLimit: null }); + expect(cleared).toEqual({ ...snapshot, primary: null, individualLimit: null }); + expect(codexRateLimitsToLimits({ checkedAt, snapshot: cleared! }).windows).toEqual([]); + }); + it("keeps windows an update does not carry", () => { const merged = mergeCodexRateLimits( { @@ -292,6 +358,7 @@ describe("mergeCodexRateLimits", () => { expect( mergeCodexRateLimits(main, { limitId: "spark", + individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 }, }), ).toBe(main); diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index 31d48535cf94..f3ec514aae31 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -22,6 +22,10 @@ export interface CodexRateLimitSnapshot { readonly rateLimitReachedType?: string | null; readonly primary?: CodexRateLimitWindow | null; readonly secondary?: CodexRateLimitWindow | null; + readonly individualLimit?: { + readonly remainingPercent: number; + readonly resetsAt: number; + } | null; } export interface CodexResetCreditsSummary { @@ -84,6 +88,17 @@ function codexRateLimitsToWindows( ...(resetsAt ? { resetsAt } : {}), }); } + const individualLimit = snapshot.individualLimit; + if (individualLimit && Number.isFinite(individualLimit.remainingPercent)) { + const resetsAt = isoFromEpochSeconds(individualLimit.resetsAt); + windows.push({ + id: "individual", + kind: "monthly", + label: "Monthly credit limit", + usedPercent: clampPercent(100 - individualLimit.remainingPercent), + ...(resetsAt ? { resetsAt } : {}), + }); + } return windows; } @@ -166,6 +181,7 @@ export function mergeCodexRateLimits( : {}), ...(update.primary !== undefined ? { primary: update.primary } : {}), ...(update.secondary !== undefined ? { secondary: update.secondary } : {}), + ...(update.individualLimit !== undefined ? { individualLimit: update.individualLimit } : {}), }; } From 8f72a73b6e4136a5288ed036642ffb6465fd3a78 Mon Sep 17 00:00:00 2001 From: Ibodullayev Ruslan <82746962+Ieruss@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:25:11 +0500 Subject: [PATCH 113/898] fix(server): the turn after a stopped turn shows its diff again (#17150) Co-authored-by: Julius Marminge --- .../checkpointing/CheckpointDiffQuery.test.ts | 85 +++++++++++++++++++ .../src/checkpointing/CheckpointDiffQuery.ts | 20 ++++- .../orchestration-v2/ProjectionStore.test.ts | 35 +++++--- .../src/orchestration-v2/ProjectionStore.ts | 7 +- 4 files changed, 130 insertions(+), 17 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index bdb3e02785d8..1a4d1092972a 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -37,6 +37,7 @@ function makeProjection(): ProjectionCheckpointContext { { scopeId: secondScopeId, runId: secondRunId, + ordinalWithinScope: 1, appRunOrdinal: 2, status: "ready", ref: secondRef, @@ -200,6 +201,90 @@ it.effect("excludes ready checkpoints from rolled-back runs", () => { }).pipe(Effect.provide(layer)); }); +function makeProjectionAfterStoppedRun(stoppedRunCheckpoint: boolean) { + const thirdRunId = RunId.make("run:checkpoint-diff-v2:3"); + const checkpoint = ( + ordinalWithinScope: number, + runId: RunId | null, + ): ProjectionCheckpointContext["checkpoints"][number] => ({ + scopeId: firstScopeId, + runId, + ordinalWithinScope, + appRunOrdinal: runId === null ? null : ordinalWithinScope, + status: "ready", + ref: CheckpointRef.make(`refs/t3/test/ordinal/${ordinalWithinScope}`), + }); + return { + runs: [ + { id: firstRunId, ordinal: 1, status: "completed" }, + { id: secondRunId, ordinal: 2, status: "interrupted" }, + { id: thirdRunId, ordinal: 3, status: "completed" }, + ], + checkpointScopes: [ + { id: firstScopeId, runId: thirdRunId, kind: "root_run", cwd: "/repo" }, + { id: secondScopeId, runId: secondRunId, kind: "tool", cwd: "/repo" }, + ], + checkpoints: [ + { + ...checkpoint(2, null), + scopeId: secondScopeId, + ref: CheckpointRef.make("refs/t3/test/tool/2"), + }, + checkpoint(0, null), + checkpoint(1, firstRunId), + checkpoint(2, stoppedRunCheckpoint ? secondRunId : null), + checkpoint(3, thirdRunId), + ], + } satisfies ProjectionCheckpointContext; +} + +it.effect.each([false, true])( + "diffs the turn after a stopped run from its parent checkpoint (stopped run captured: %s)", + (stoppedRunCheckpoint) => { + const diffCheckpoints = vi.fn((_input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed("diff --git a/file b/file"), + ); + const layer = layerFor({ + projection: Effect.succeed(makeProjectionAfterStoppedRun(stoppedRunCheckpoint)), + diffCheckpoints, + }); + + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const result = yield* query.getTurnDiff({ threadId, fromTurnCount: 2, toTurnCount: 3 }); + + assert.strictEqual(result.diff, "diff --git a/file b/file"); + yield* query.getTurnDiff({ threadId, fromTurnCount: 1, toTurnCount: 3 }); + yield* query.getFullThreadDiff({ threadId, toTurnCount: 3 }); + assert.deepEqual( + diffCheckpoints.mock.calls.map(([input]) => ({ + from: input.fromCheckpointRef, + to: input.toCheckpointRef, + })), + [ + { from: "refs/t3/test/ordinal/2", to: "refs/t3/test/ordinal/3" }, + { from: "refs/t3/test/ordinal/1", to: "refs/t3/test/ordinal/3" }, + { + from: yield* checkpointRefForScopeOrdinal({ + scopeId: firstScopeId, + ordinalWithinScope: 0, + }), + to: "refs/t3/test/ordinal/3", + }, + ], + ); + const stoppedTurnError = yield* query + .getTurnDiff({ threadId, fromTurnCount: 1, toTurnCount: 2 }) + .pipe(Effect.flip); + assert.instanceOf(stoppedTurnError, CheckpointRefUnavailableError); + assert.deepEqual( + { checkpoint: stoppedTurnError.checkpoint, turnCount: stoppedTurnError.turnCount }, + { checkpoint: "to", turnCount: 2 }, + ); + }).pipe(Effect.provide(layer)); + }, +); + it.effect("preserves the typed missing-baseline-ref error contract", () => { const projection = makeProjection(); const layer = layerFor({ diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 08ed4abca0da..3cf37fa38343 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -161,6 +161,18 @@ export const make = Effect.gen(function* () { }); } + // Capture compares against the preceding scope ordinal, even when that + // checkpoint belongs to a stopped run or has no run. + const parentCheckpoint = + input.fromTurnCount === input.toTurnCount - 1 + ? projection.checkpoints.find( + (checkpoint) => + checkpoint.status === "ready" && + checkpoint.scopeId === toCheckpoint.scopeId && + checkpoint.ordinalWithinScope === toCheckpoint.ordinalWithinScope - 1, + ) + : undefined; + const firstScope = input.fromTurnCount === 0 ? projection.checkpointScopes.find((scope) => scope.kind === "root_run") @@ -173,8 +185,12 @@ export const make = Effect.gen(function* () { scopeId: firstScope.id, ordinalWithinScope: 0, }).pipe(Effect.provideService(Crypto.Crypto, crypto)) - : readyCheckpoints.find((checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount) - ?.ref; + : ( + parentCheckpoint ?? + readyCheckpoints.find( + (checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount, + ) + )?.ref; if (fromCheckpointRef === undefined) { return yield* new CheckpointRefUnavailableError({ operation, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 990ec2a6e862..a225030b8b48 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -4184,6 +4184,11 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const memoryStore = yield* ProjectionStore.ProjectionStoreV2.pipe( + Effect.provide(ProjectionStore.layerMemory), + ); + const apply = (event: Parameters[0]) => + Effect.all([projectionStore.apply(event), memoryStore.apply(event)]); const sql = yield* SqlClient.SqlClient; const now = yield* DateTime.now; const threadId = ThreadId.make("thread:checkpoint-context"); @@ -4192,7 +4197,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const scopeId = CheckpointScopeId.make("scope:checkpoint-context"); const checkpointId = CheckpointId.make("checkpoint:checkpoint-context"); const ref = CheckpointRef.make("refs/t3/checkpoint-context/1"); - yield* projectionStore.apply({ + yield* apply({ id: EventId.make("event:checkpoint-context:thread"), type: "thread.created", threadId, @@ -4221,7 +4226,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { deletedAt: null, }, }); - yield* projectionStore.apply({ + yield* apply({ id: EventId.make("event:checkpoint-context:run"), type: "run.created", threadId, @@ -4244,7 +4249,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { contextHandoffId: null, }, }); - yield* projectionStore.apply({ + yield* apply({ id: EventId.make("event:checkpoint-context:scope"), type: "checkpoint-scope.created", threadId, @@ -4263,7 +4268,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { createdAt: now, }, }); - yield* projectionStore.apply({ + yield* apply({ id: EventId.make("event:checkpoint-context:checkpoint"), type: "checkpoint.captured", threadId, @@ -4302,15 +4307,19 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { SET payload_json = json_set(payload_json, '$.files', 'obsolete file summary') WHERE checkpoint_id = ${checkpointId} `; - assert.deepEqual(yield* projectionStore.getCheckpointContext(threadId), { - runs: [{ id: runId, ordinal: 1, status: "completed" }], - checkpointScopes: [{ id: scopeId, runId, kind: "root_run", cwd: "/repo/worktree" }], - checkpoints: [{ scopeId, runId, appRunOrdinal: 1, status: "ready", ref }], - }); - const missing = yield* projectionStore - .getCheckpointContext(ThreadId.make("thread:checkpoint-context:missing")) - .pipe(Effect.flip); - assert.instanceOf(missing, ProjectionStore.ProjectionStoreThreadNotFoundError); + for (const store of [projectionStore, memoryStore]) { + assert.deepEqual(yield* store.getCheckpointContext(threadId), { + runs: [{ id: runId, ordinal: 1, status: "completed" }], + checkpointScopes: [{ id: scopeId, runId, kind: "root_run", cwd: "/repo/worktree" }], + checkpoints: [ + { scopeId, runId, ordinalWithinScope: 1, appRunOrdinal: 1, status: "ready", ref }, + ], + }); + const missing = yield* store + .getCheckpointContext(ThreadId.make("thread:checkpoint-context:missing")) + .pipe(Effect.flip); + assert.instanceOf(missing, ProjectionStore.ProjectionStoreThreadNotFoundError); + } }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index d04149f911c9..9deee8f3850a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -220,9 +220,10 @@ const ProjectionCheckpointContext = Schema.Struct({ ), checkpoints: Schema.Array( OrchestrationV2CheckpointJsonSchema.mapFields( - ({ scopeId, runId, appRunOrdinal, status, ref }) => ({ + ({ scopeId, runId, ordinalWithinScope, appRunOrdinal, status, ref }) => ({ scopeId, runId, + ordinalWithinScope, appRunOrdinal, status, ref, @@ -4732,6 +4733,7 @@ export const layer: Layer.Layer = `, sql` SELECT scope_id AS "scopeId", run_id AS "runId", + ordinal_within_scope AS "ordinalWithinScope", app_run_ordinal AS "appRunOrdinal", status, json_extract(payload_json, '$.ref') AS ref FROM orchestration_v2_projection_checkpoints @@ -6594,9 +6596,10 @@ export const layerMemory: Layer.Layer = Layer.effect( cwd, })), checkpoints: projection.checkpoints.map( - ({ scopeId, runId, appRunOrdinal, status, ref }) => ({ + ({ scopeId, runId, ordinalWithinScope, appRunOrdinal, status, ref }) => ({ scopeId, runId, + ordinalWithinScope, appRunOrdinal, status, ref, From 09b464cf83df88c7d104be3932b0e1e5822bc655 Mon Sep 17 00:00:00 2001 From: Madhav Gautam <63944499+mxdhavgautam@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:25:17 +0800 Subject: [PATCH 114/898] fix(web): urgent and warning notices no longer hide behind activity banners (#18919) Co-authored-by: Julius Marminge --- .../chat/ComposerBannerStack.browser.test.tsx | 51 +++++++++++++++++++ .../components/chat/ComposerBannerStack.tsx | 4 +- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx index 887f61abcbe1..9e03a177ad3c 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -3,6 +3,7 @@ import "../../index.css"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; import { ComposerBannerStack } from "./ComposerBannerStack"; @@ -58,3 +59,53 @@ it("only offers notice details when the description cannot fit", async () => { await nextFrame(); expect(hasDetails()).toBe(true); }); + +it("keeps only the usage limit attached while an active goal waits in the collapsible stack", async () => { + await act(async () => + root.render( + , + ), + ); + + const visibleBanners = () => + Array.from(container.querySelectorAll('[data-slot="composer-banner"]')).filter((banner) => + banner.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }), + ); + const limit = page.getByText("Usage limit reached", { exact: true }); + const goal = page.getByText("Goal active", { exact: true }); + + expect(visibleBanners()).toHaveLength(1); + await expect.element(limit).toBeVisible(); + await expect.element(goal).not.toBeVisible(); + expect(container.querySelector('[data-slot="composer-banner-attachment"]')?.textContent).toBe( + "Usage limit reached", + ); + + await act(async () => page.getByRole("button", { name: "Show other notices" }).click()); + await expect.element(goal).toBeVisible(); + await expect.element(limit).toBeVisible(); + await expect.poll(visibleBanners).toHaveLength(2); + + await act(async () => userEvent.keyboard("{Escape}")); + await expect.element(goal).not.toBeVisible(); + await expect.element(limit).toBeVisible(); + expect(visibleBanners()).toHaveLength(1); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 6f710d912313..dd9d7c570db3 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -31,10 +31,10 @@ export type ComposerBannerStackContent = Pick< type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent; function bannerPriority(item: ComposerBannerStackEntry) { - if (item.priority === "activity") { + if (item.priority === "urgent" || item.variant === "error" || item.variant === "warning") { return 0; } - if (item.priority === "urgent" || item.variant === "error" || item.variant === "warning") { + if (item.priority === "activity") { return 1; } return 2; From e1931b56896a56484028c5ff2828687fc1c2d3cc Mon Sep 17 00:00:00 2001 From: Vitaly Iegorov Date: Mon, 19 Oct 2026 22:25:21 +0200 Subject: [PATCH 115/898] fix(server): keep a failed service start out of the current state (#12199) Co-authored-by: Claude Fable 5.1 Co-authored-by: Julius Marminge --- apps/server/src/cloud/bootService.test.ts | 26 +++++++++++++++++++++++ apps/server/src/cloud/bootService.ts | 8 ++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 6a79d22ce90f..4fb6d93babd2 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -858,6 +858,32 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("keeps failed launch agent installs pending until activation succeeds", () => + Effect.gen(function* () { + const { service, fs, statePath, commands, control } = yield* makeHarness("darwin"); + const path = yield* Path.Path; + const plan = yield* service.install(); + const markerPath = path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE); + const bootstrap = `launchctl bootstrap gui/501 ${plan.unitPath}`; + control.failCommand = bootstrap; + + expect((yield* service.install().pipe(Effect.flip))._tag).toBe("BootServiceCommandError"); + expect(yield* service.status).toMatchObject({ + current: false, + problems: ["restart-pending"], + }); + expect(yield* fs.readFileString(markerPath)).toBe("1.2.3\n"); + + control.failCommand = undefined; + commands.length = 0; + yield* service.install(); + + expect(commands).toContain(bootstrap); + expect(yield* fs.exists(markerPath)).toBe(false); + expect((yield* service.status).current).toBe(true); + }), + ); + it.effect("restarts without overwriting a pending remote update on macOS", () => Effect.gen(function* () { const { service, fs, statePath, commands } = yield* makeHarness("darwin"); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 8da2ca2db83a..8820fc9d442a 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -810,12 +810,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - if (!start && installed) { - // Written first: once the files below name the new version, the running - // service is behind them, and a failure between the two writes must not - // leave it looking current. - yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); - } + // Files can match the new version even when activation fails; keep that install retryable. + yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); yield* writeDurably( statePath, // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. From 29d46101f3e5fc488097fd6c0a5e32a4aad1a8c3 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:25:26 +0200 Subject: [PATCH 116/898] fix(desktop): Linux URL handler quotes the Exec path only when it must (#16670) Co-authored-by: Julius Marminge --- .../src/app/DesktopLinuxUrlHandler.test.ts | 28 +++++++++++++++++-- .../desktop/src/app/DesktopLinuxUrlHandler.ts | 8 +++++- .../src/app/DesktopPreReadyPlatform.test.ts | 2 +- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index e6900a03b7d2..295f63eafe48 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -179,6 +179,25 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png"); }); + it.each([ + { + execTarget: "/opt/T3(beta)/t3code.AppImage", + execLine: 'Exec="/opt/T3(beta)/t3code.AppImage" %U', + }, + { + execTarget: "/opt/t3%20code/t3code.AppImage", + execLine: "Exec=/opt/t3%%20code/t3code.AppImage %U", + }, + ])("renders the handler Exec line for $execTarget", ({ execTarget, execLine }) => { + const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code", + execTarget, + scheme: "t3code", + }); + + assert.include(entry.split("\n"), execLine); + }); + it("carries structured context on registration errors", () => { const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ step: "write-desktop-entry", @@ -222,7 +241,7 @@ describe("DesktopLinuxUrlHandler", () => { ); assert.include( recorded.files[0]?.content, - 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', + "Exec=/home/alice/Applications/T3-Code.AppImage %U", ); assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); assert.deepEqual(recorded.commands, [ @@ -247,7 +266,12 @@ describe("DesktopLinuxUrlHandler", () => { assert.include( recorded.files[0]?.content, - `Exec=${DesktopLinuxUrlHandler.escapeDesktopEntryExecArgument(process.execPath)} %U`, + DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Alpha)", + execTarget: process.execPath, + scheme: "t3code", + iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png", + }), ); }); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 08cd1d47c08d..e03418964e33 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -75,6 +75,12 @@ export function escapeDesktopEntryExecArgument(value: string): string { return escapeDesktopEntryString(`"${quoted}"`); } +// Legacy xdg-open looks up the first Exec word without unquoting it. +const escapeUrlHandlerExecTarget = (value: string): string => + /[ \t\n"'\\><~|&;$*?#()`]/.test(value) + ? escapeDesktopEntryExecArgument(value) + : escapeDesktopEntryString(value.replaceAll("%", "%%")); + // The AppImage integration entry owns the window identity. This // hidden URL-only entry must not compete with it for StartupWMClass matching. export function renderUrlHandlerDesktopEntry(input: { @@ -87,7 +93,7 @@ export function renderUrlHandlerDesktopEntry(input: { "[Desktop Entry]", "Type=Application", `Name=${escapeDesktopEntryString(input.displayName)}`, - `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, + `Exec=${escapeUrlHandlerExecTarget(input.execTarget)} %U`, ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]), "Terminal=false", "NoDisplay=true", diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index dd15f879fd8e..845e89331f96 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -174,7 +174,7 @@ describe("DesktopPreReadyPlatform", () => { ); const identity = yield* Effect.promise(() => portalIdentity); assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); - assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); + assert.include(identity.desktopEntry ?? "", "Exec=/Applications/current.AppImage %U"); assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Beta)"); assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); assert.include( From 3b744af49a88f1be5cc4694ac392f0dfc9117e30 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:25:30 -0400 Subject: [PATCH 117/898] fix(desktop): preserve signals in backend exit logs (#15406) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- .../src/backend/DesktopBackendManager.test.ts | 24 +++++++++++++++++ .../src/backend/DesktopBackendManager.ts | 27 +++++++++++++------ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 8cc1a9fc6433..eaf7fc308456 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -431,6 +431,30 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("reports a SIGKILL exit without an exit code", () => + Effect.gen(function* () { + const exitCause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "exitCode", + cause: new Error("Process interrupted due to receipt of signal: 'SIGKILL'"), + }); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed(makeProcess({ exitCode: Effect.fail(exitCause) })), + ), + ); + + const exit = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient))); + + assert.deepEqual(exit, { code: Option.none(), reason: "killed by SIGKILL" }); + }), + ); + it.effect("reports output stream failures with process and stream context", () => Effect.gen(function* () { const outputCause = PlatformError.systemError({ diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 8d2a3f1c4912..5ad864f945d7 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -568,8 +568,22 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); const exit = yield* handle.exitCode.pipe( - Effect.mapError( - (cause) => + Effect.map((exitCode) => ({ + code: Option.some(exitCode), + reason: `code=${exitCode}`, + })), + Effect.catch((cause) => { + // Effect's Node spawner exposes signal exits only through this cause message. + const signal = + cause.cause instanceof Error + ? /^Process interrupted due to receipt of signal: '(SIG[A-Z0-9]+)'$/.exec( + cause.cause.message, + )?.[1] + : undefined; + if (signal !== undefined) { + return Effect.succeed({ code: Option.none(), reason: `killed by ${signal}` }); + } + return Effect.fail( new BackendProcessExitStatusError({ executablePath: options.executablePath, entryPath: options.entryPath, @@ -578,7 +592,8 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( pid: Number(handle.pid), cause, }), - ), + ); + }), Effect.exit, ); yield* options.onExitObserved?.() ?? Effect.void; @@ -592,11 +607,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( if (Exit.isFailure(exit)) { return yield* Effect.failCause(exit.cause); } - const exitCode = exit.value; - return { - code: Option.some(exitCode), - reason: `code=${exitCode}`, - } satisfies BackendProcessExit; + return exit.value; }); export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( From 833b11fe0f380291cd60af8573529e9a4a185c69 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Mon, 19 Oct 2026 21:25:35 +0100 Subject: [PATCH 118/898] fix(contracts): older clients still load PRs from newer servers (#19899) Co-authored-by: Julius Marminge --- packages/contracts/src/pullRequest.test.ts | 77 +++++++++++++++++++++- packages/contracts/src/pullRequest.ts | 17 ++--- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 08c1307fa248..b20f78dd9571 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -5,19 +5,23 @@ import { describe, expect, it } from "vite-plus/test"; import { PullRequestActionInput, PullRequestCapabilities, + PullRequestDetail, PullRequestFilesViewedResult, PullRequestListInput, PullRequestListResult, PullRequestReviewerRequestInput, PullRequestSetFilesViewedInput, + PullRequestSubmitReviewInput, pullRequestHostOf, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; -const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult); +const decodeListResult = Schema.decodeUnknownSync(Schema.toCodecJson(PullRequestListResult)); +const decodeDetail = Schema.decodeUnknownSync(Schema.toCodecJson(PullRequestDetail)); const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput); const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput); -const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput); +const decodeAction = Schema.decodeUnknownSync(Schema.toCodecJson(PullRequestActionInput)); +const decodeReview = Schema.decodeUnknownSync(Schema.toCodecJson(PullRequestSubmitReviewInput)); const decodeSetFilesViewed = Schema.decodeUnknownSync(PullRequestSetFilesViewedInput); const decodeFilesViewed = Schema.decodeUnknownSync(PullRequestFilesViewedResult); @@ -102,6 +106,18 @@ describe("PullRequestListResult", () => { expect(decoded).toStrictEqual(LIST_RESULT); }); + it("loads a newer server's list response, dropping unknown provider actions in order", () => { + const decoded = decodeListResult({ + ...LIST_RESULT, + providers: [{ ...LIST_RESULT.providers[0], actions: ["close", "archive", "merge"] }], + }); + + expect(decoded).toEqual({ + ...LIST_RESULT, + providers: [{ ...LIST_RESULT.providers[0], actions: ["close", "merge"] }], + }); + }); + it("keys a viewer by host, so two hosts of one kind stay separate accounts", () => { const decoded = decodeListResult({ ...LIST_RESULT, @@ -274,6 +290,63 @@ describe("PullRequestCapabilities", () => { expect(decodeCapabilities(base).deleteBranch).toBeUndefined(); expect(decodeCapabilities({ ...base, deleteBranch: true }).deleteBranch).toBe(true); }); + + it("loads a newer server's detail response, dropping unknown options in order", () => { + const options = { + actions: ["close", "archive", "merge"], + verdicts: ["request-changes", "block", "approve"], + updateMethods: ["rebase", "fast-forward", "merge"], + }; + const detail = { + ...LIST_RESULT.entries[0], + workspaceRoot: "/workspace/t3code", + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + capabilities: { + ...base, + actions: options.actions, + mergeMethods: ["squash", "fast-forward", "merge"], + updateMethods: options.updateMethods, + review: { ...base.review, verdicts: options.verdicts }, + }, + viewerPermissions: { ...options, comment: true, resolve: true, requestReviewers: true }, + }; + const decoded = decodeDetail(detail); + + expect(decoded.capabilities).toEqual({ + ...base, + actions: ["close", "merge"], + mergeMethods: ["squash", "merge"], + updateMethods: ["rebase", "merge"], + review: { ...base.review, verdicts: ["request-changes", "approve"] }, + }); + expect(decoded.viewerPermissions).toEqual({ + actions: ["close", "merge"], + verdicts: ["request-changes", "approve"], + updateMethods: ["rebase", "merge"], + comment: true, + resolve: true, + requestReviewers: true, + }); + expect(decoded.title).toBe(detail.title); + }); +}); + +describe("pull request mutation requests", () => { + it("still rejects unknown actions, merge methods, and review verdicts on the wire", () => { + const ref = { projectId: "project-1", repository: "acme/web", number: 7 }; + + expect(() => decodeAction({ ...ref, action: "archive" })).toThrow(); + expect(() => decodeAction({ ...ref, action: "merge", mergeMethod: "fast-forward" })).toThrow(); + const review = { ...ref, body: "", comments: [], verdict: "approve" }; + expect(decodeReview(review).verdict).toBe("approve"); + expect(() => decodeReview({ ...review, verdict: "block" })).toThrow(); + }); }); describe("naming the reader as the author to narrow by", () => { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 2415c59f056e..1777dc6762f6 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -3,6 +3,7 @@ import * as HttpServerRespondable from "effect/http/HttpServerRespondable"; import * as HttpServerResponse from "effect/http/HttpServerResponse"; import { + ForwardCompatibleArray, IsoDateTime, NonNegativeInt, PositiveInt, @@ -240,7 +241,7 @@ export const PullRequestReviewCapabilities = Schema.Struct({ inlineComment: Schema.Boolean, reply: Schema.Boolean, resolve: Schema.Boolean, - verdicts: Schema.Array(PullRequestReviewVerdict), + verdicts: ForwardCompatibleArray(PullRequestReviewVerdict), }); export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities.Type; @@ -262,14 +263,14 @@ export type PullRequestViewedFilesStore = typeof PullRequestViewedFilesStore.Typ export const PullRequestCapabilities = Schema.Struct({ diff: Schema.Boolean, comment: Schema.Boolean, - actions: Schema.Array(PullRequestAction), - mergeMethods: Schema.Array(PullRequestMergeMethod), + actions: ForwardCompatibleArray(PullRequestAction), + mergeMethods: ForwardCompatibleArray(PullRequestMergeMethod), deleteBranch: Schema.optional(Schema.Boolean), /** * Absent where it cannot at all, which is every host that has not said otherwise — so a * provider that says nothing offers nothing. */ - updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)), + updateMethods: Schema.optional(ForwardCompatibleArray(PullRequestUpdateMethod)), search: Schema.Boolean, reactions: Schema.optional(Schema.Boolean), viewedFiles: Schema.optional(PullRequestViewedFilesStore), @@ -287,12 +288,12 @@ export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; */ export const PullRequestViewerPermissions = Schema.Struct({ stackRebase: Schema.optional(Schema.Boolean), - actions: Schema.Array(PullRequestAction), + actions: ForwardCompatibleArray(PullRequestAction), comment: Schema.Boolean, resolve: Schema.Boolean, - verdicts: Schema.Array(PullRequestReviewVerdict), + verdicts: ForwardCompatibleArray(PullRequestReviewVerdict), requestReviewers: Schema.Boolean, - updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)), + updateMethods: Schema.optional(ForwardCompatibleArray(PullRequestUpdateMethod)), labels: Schema.optional(Schema.Boolean), editChangeRequest: Schema.optional(Schema.Boolean), }); @@ -368,7 +369,7 @@ export const PullRequestProviderSummary = Schema.Struct({ host: TrimmedNonEmptyString, kind: SourceControlProviderKind, searchesOnHost: Schema.Boolean, - actions: Schema.optional(Schema.Array(PullRequestAction)), + actions: Schema.optional(ForwardCompatibleArray(PullRequestAction)), projectCount: PositiveInt, configured: Schema.Boolean, detail: Schema.NullOr(TrimmedNonEmptyString), From ad50f62eefd3ab5e923a3eda7cd0de30f4be271b Mon Sep 17 00:00:00 2001 From: nekohasekai Date: Tue, 20 Oct 2026 04:25:40 +0800 Subject: [PATCH 119/898] fix(mobile): picking a model shows its remembered options before saving (#15719) Co-authored-by: Julius Marminge --- .../src/features/threads/ThreadComposer.tsx | 1 + .../features/threads/ThreadSettingsSheet.tsx | 22 +++- .../thread-settings-sheet-state.test.ts | 107 +++++++++++++++--- .../threads/thread-settings-sheet-state.ts | 10 +- 4 files changed, 121 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 8aecb5b07e66..ee34ce242c03 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -605,6 +605,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps reportedModelSelection: props.reportedModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(withRememberedModelOptions(option.selection)), + restoreSelection: withRememberedModelOptions, optionDescriptors: providerOptionDescriptors, onUpdateOptionSelections: (options) => { rememberModelOptions( diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 0df2b64e1e6c..3d3c471cb9e9 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -28,7 +28,15 @@ import { useNavigation, useRoute, type RouteProp } from "@react-navigation/nativ import { type NativeStackNavigationProp } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; import { AsyncResult } from "effect/reactivity"; -import { createContext, use, useEffect, useRef, useState, type ReactNode } from "react"; +import { + createContext, + use, + useEffect, + useEffectEvent, + useRef, + useState, + type ReactNode, +} from "react"; import { Platform, Pressable, RefreshControl, ScrollView, TextInput, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -51,7 +59,10 @@ import { import { applyProviderOptionSelection } from "../../lib/providerOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { rememberModelOptions } from "../../state/use-model-option-memory"; +import { + rememberModelOptions, + withRememberedModelOptions, +} from "../../state/use-model-option-memory"; import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { ChatGptSharingStatus } from "./ChatGptSharingStatus"; @@ -234,6 +245,7 @@ type ThreadSettingsSessionProps = { readonly selectedModel: ModelSelection | null; readonly reportedModelSelection?: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; + readonly restoreSelection?: (selection: ModelSelection) => ModelSelection; readonly optionDescriptors: ReadonlyArray; readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void; readonly runtimeMode: RuntimeMode; @@ -533,6 +545,7 @@ function ThreadSettingsSessionProvider( current, pressed: option, pressedIsApplied: isApplied(option), + restoreSelection: props.restoreSelection, }), ); }; @@ -1122,10 +1135,10 @@ function ThreadSettingsModelsScreen() { const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const theme = useUniwindTheme(); const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; - const commitAndClose = () => { + const commitAndClose = useEffectEvent(() => { if (!session.commitPendingModel()) return; presentation.onClose(); - }; + }); const providerFilters = [ { id: "all-providers", title: "All providers", value: null }, { id: "favorites", title: "Favorites", value: FAVORITES_PROVIDER_FILTER }, @@ -1455,6 +1468,7 @@ export function NewTaskThreadSettingsRouteScreen() { providerGroups={flow.providerGroups} selectedModel={flow.selectedModel} onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)} + restoreSelection={withRememberedModelOptions} optionDescriptors={optionDescriptors} onUpdateOptionSelections={flow.setSelectedModelOptions} runtimeMode={flow.runtimeMode} diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 61e606aa6dd6..8c65f3e6e22f 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; +import { getProviderOptionDescriptors } from "@t3tools/shared/model"; import type { ModelOption } from "../../lib/modelOptions"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { modelOptionMemoryAtom } from "../../state/use-composer-drafts"; +import { + rememberModelOptions, + withRememberedModelOptions, +} from "../../state/use-model-option-memory"; import { canCommitPendingModel, favoritesFirst, @@ -23,7 +31,30 @@ function modelOption( providerDriver: "codex", isDefault: false, isLegacy: false, - capabilities: null, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + currentValue: "default", + }, + ], + }, selection: { instanceId: ProviderInstanceId.make("codex"), model, @@ -32,6 +63,16 @@ function modelOption( }; } +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + appAtomRegistry.set(modelOptionMemoryAtom, {}); +}); + describe("thread settings sheet state", () => { it("keeps favorites in catalog order ahead of other models", () => { const models = [ @@ -101,7 +142,8 @@ describe("thread settings sheet state", () => { ).toBeNull(); }); - it("preserves staged options when the highlighted model is pressed again", () => { + it("preserves in-progress edits when the staged model is pressed again", () => { + rememberModelOptions("codex", "gpt-next", [{ id: "effort", value: "medium" }]); const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]); expect( @@ -109,20 +151,59 @@ describe("thread settings sheet state", () => { current: pending, pressed: modelOption("gpt-next"), pressedIsApplied: false, - }), - ).toBe(pending); + restoreSelection: withRememberedModelOptions, + })?.selection.options, + ).toEqual([{ id: "effort", value: "high" }]); + }); + + it("previews remembered options before Save when switching models", () => { + const remembered = [ + { id: "effort", value: "high" }, + { id: "serviceTier", value: "priority" }, + ]; + rememberModelOptions("codex", "gpt-other", remembered); + const pressed = modelOption("gpt-other"); + + const pending = pendingModelAfterPress({ + current: modelOption("gpt-next"), + pressed, + pressedIsApplied: false, + restoreSelection: withRememberedModelOptions, + }); + + expect(pending?.selection).toEqual({ ...pressed.selection, options: remembered }); + const preview = getProviderOptionDescriptors({ + caps: pressed.capabilities!, + selections: pending?.selection.options, + }); + expect(preview.map(({ id, currentValue }) => ({ id, value: currentValue }))).toEqual( + remembered, + ); + expect(pending?.selection).toEqual(withRememberedModelOptions(pressed.selection)); }); - it("stages a different model", () => { + it("keeps catalog defaults for scheduled-task picks even when options are remembered", () => { + rememberModelOptions("codex", "gpt-other", [ + { id: "effort", value: "high" }, + { id: "serviceTier", value: "priority" }, + ]); const pressed = modelOption("gpt-other"); - expect( - pendingModelAfterPress({ - current: modelOption("gpt-next"), - pressed, - pressedIsApplied: false, - }), - ).toBe(pressed); + const pending = pendingModelAfterPress({ + current: modelOption("gpt-next"), + pressed, + pressedIsApplied: false, + }); + + expect(pending?.selection).toEqual(pressed.selection); + const preview = getProviderOptionDescriptors({ + caps: pressed.capabilities!, + selections: pending?.selection.options, + }); + expect(preview.map(({ id, currentValue }) => ({ id, value: currentValue }))).toEqual([ + { id: "effort", value: "medium" }, + { id: "serviceTier", value: "default" }, + ]); }); it("cannot select a hidden model exposed only for visibility management", () => { diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index 98519b7349e7..4fff09253ab4 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,5 +1,5 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; -import type { ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string { return `${provider}:${model}`; @@ -39,11 +39,17 @@ export function pendingModelAfterPress(input: { readonly current: ModelOption | null; readonly pressed: ModelOption; readonly pressedIsApplied: boolean; + readonly restoreSelection?: (selection: ModelSelection) => ModelSelection; }): ModelOption | null { if (input.pressedIsApplied) { return null; } - return input.current?.key === input.pressed.key ? input.current : input.pressed; + if (input.current?.key === input.pressed.key) { + return input.current; + } + return input.restoreSelection + ? { ...input.pressed, selection: input.restoreSelection(input.pressed.selection) } + : input.pressed; } export function canCommitPendingModel( From b3a13349505e4797860dda86cc1cc21413dc9f08 Mon Sep 17 00:00:00 2001 From: maco Date: Mon, 19 Oct 2026 22:25:44 +0200 Subject: [PATCH 120/898] fix(web): right-panel launcher letters work while a closed menu stays mounted (#18132) Co-authored-by: macodev00 Co-authored-by: Julius Marminge --- .../RightPanelTabs.keyboard.browser.test.tsx | 32 +++++++++++++++++-- apps/web/src/components/RightPanelTabs.tsx | 21 ++++++------ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx index 38c147764080..adbe0d586b23 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.browser.test.tsx @@ -137,6 +137,28 @@ describe("right panel new-tab shortcut", () => { }, ); + it("opens Files while a closed popup remains mounted", async () => { + await renderPanel(); + const popup = document.createElement("div"); + popup.dataset.slot = "menu-popup"; + popup.dataset.closed = ""; + container.append(popup); + + expect((await press("f")).defaultPrevented).toBe(true); + expect(addFiles).toHaveBeenCalledOnce(); + }); + + it("opens the add-surface menu while a closed popup remains mounted", async () => { + await renderPanel(); + const popup = document.createElement("div"); + popup.dataset.slot = "menu-popup"; + popup.dataset.closed = ""; + container.append(popup); + + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(true); + expect(document.querySelector('[role="menu"]:not([data-closed])')).not.toBeNull(); + }); + it("leaves the shortcut alone while the mounted panel is closed", async () => { await renderPanel({ open: false }); expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); @@ -197,8 +219,14 @@ describe("right panel new-tab shortcut", () => { const dialog = document.createElement("div"); dialog.dataset.slot = "dialog-popup"; container.append(dialog); - expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); - expect(document.querySelector('[role="menu"]')).toBeNull(); + for (const state of ["data-open", "data-ending-style"]) { + dialog.setAttribute(state, ""); + expect((await press("f")).defaultPrevented, state).toBe(false); + expect(addFiles, state).not.toHaveBeenCalled(); + expect((await press("t", { metaKey: true })).defaultPrevented, state).toBe(false); + expect(document.querySelector('[role="menu"]'), state).toBeNull(); + dialog.removeAttribute(state); + } }); it("supports arrow keys with an existing tab in sheet mode and clears the menu on close", async () => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index c44de7b9bcbd..5a705cbc6fcf 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -174,16 +174,19 @@ const SURFACE_DISABLED_REASONS = { device: "Devices are only available from a thread.", } as const; +// Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ - '[data-slot="dialog-popup"]', - '[data-slot="alert-dialog-popup"]', - '[data-slot="command-dialog-popup"]', - '[data-slot="menu-popup"]', - '[data-slot="select-popup"]', - '[data-slot="popover-popup"]', - '[data-slot="combobox-popup"]', - '[data-slot="autocomplete-popup"]', -].join(","); + "dialog-popup", + "alert-dialog-popup", + "command-dialog-popup", + "menu-popup", + "select-popup", + "popover-popup", + "combobox-popup", + "autocomplete-popup", +] + .map((slot) => `[data-slot="${slot}"]:is([data-open],[data-ending-style])`) + .join(","); const SURFACE_UNAVAILABLE_HINTS = { browser: "Only available in the desktop app.", From d5e6f69810c08dd2e741a0fc7d8e33e535b73c57 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu Date: Mon, 19 Oct 2026 13:25:49 -0700 Subject: [PATCH 121/898] fix(server): cloning into a new folder directly under a drive root no longer fails (#18265) Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Co-authored-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Co-authored-by: Julius Marminge --- .../SourceControlRepositoryService.test.ts | 47 +++++++++++++++++-- .../SourceControlRepositoryService.ts | 6 ++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index cbce130dcc8f..fca680111c83 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -270,13 +270,14 @@ it.effect("keeps arbitrary Bitbucket request failures out of the repository mess }), ); -it.effect("clones a looked-up repository into the requested destination", () => +it.effect("clones a looked-up repository after creating missing parent directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const parent = yield* fs.makeTempDirectoryScoped({ + const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-clone-parent-", }); + const parent = path.join(temporaryDirectory, "missing", "nested"); const destinationPath = path.join(parent, "t3code"); const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; @@ -305,7 +306,8 @@ it.effect("clones a looked-up repository into the requested destination", () => layer({ git: { execute: (input) => - Effect.sync(() => { + Effect.gen(function* () { + assert.isTrue(yield* fs.exists(parent).pipe(Effect.orDie)); cloneCalls.push({ cwd: input.cwd, args: input.args }); return processOutput(); }), @@ -528,6 +530,45 @@ it.effect("preserves destination probe failures instead of treating them as miss ); }); +it.effect("clones directly under an existing filesystem root without recreating it", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const root = path.parse(path.resolve(".")).root; + const cloneCalls: Array = []; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + yield* service.cloneRepository({ + provider: SourceControlProviderKind.make("github"), + repository: "octocat/t3code", + destinationPath: path.join(root, "t3code"), + protocol: "https", + }); + + assert.deepStrictEqual(cloneCalls, [root]); + }).pipe( + Effect.provide( + layer({ + fileSystem: FileSystem.makeNoop({ + exists: (target) => Effect.succeed(target === root), + makeDirectory: (target) => + Effect.sync(() => { + assert.notStrictEqual(target, root, "must not try to create the existing root"); + }), + }), + git: { + execute: (input) => + Effect.sync(() => { + cloneCalls.push(input.cwd); + return processOutput(); + }), + }, + }), + ), + ); + }).pipe(Effect.provide(NodePath.layer)), +); + it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index fb59349749a3..bca8279ef2e1 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -262,7 +262,11 @@ export const make = Effect.gen(function* () { }); } } else { - yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); + // Skip existing parents: recursive mkdir on a Windows drive root fails with EPERM. + const parentPath = path.dirname(normalizedDestination); + if (!(yield* fileSystem.exists(parentPath))) { + yield* fileSystem.makeDirectory(parentPath, { recursive: true }); + } } return { From 04013723ef112ce052f97aa97b759644e6e7c0d1 Mon Sep 17 00:00:00 2001 From: singul-oss Date: Mon, 19 Oct 2026 22:25:54 +0200 Subject: [PATCH 122/898] fix(server): forking a Claude thread works on instances with their own home (#18662) Co-authored-by: SIngularity <56167379+Singularity777x@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Julius Marminge --- .../Adapters/ClaudeAdapterV2.test.ts | 10 +- .../Adapters/ClaudeAdapterV2.ts | 82 +++++++++++++++- .../ClaudeAgentSdkQueryRunner.test.ts | 94 ++++++++++++++++++- 3 files changed, 173 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index c0f60c3411cf..dbcd849e92e0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1886,16 +1886,11 @@ describe("ClaudeAdapterV2 native fork", () => { prefix: "t3-claude-v2-fork-attachments-", }); const openedQueries: Array = []; - const forkCalls: Array<{ - readonly sessionId: string; - readonly options: unknown; - readonly threadId: ThreadId; - readonly providerSessionId: ProviderSessionId; - }> = []; + const forkCalls: Array = []; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, + environment: { CLAUDE_CONFIG_DIR: "/custom/claude-home" }, attachmentsDir, fileSystem, path: yield* Path.Path, @@ -1987,6 +1982,7 @@ describe("ClaudeAdapterV2 native fork", () => { dir: "/workspace", upToMessageId: "assistant-message-cursor", }, + environment: { CLAUDE_CONFIG_DIR: "/custom/claude-home" }, threadId: targetThreadId, providerSessionId, }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a5e19ed1ca51..3a00dd04e5ba 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -110,6 +110,8 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts"; @@ -121,6 +123,7 @@ import { } from "../../claudeModelOptions.ts"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { claudeSignedOutMessage, @@ -507,6 +510,7 @@ export class ClaudeAgentSdkQueryRunner extends Context.Service< export interface ClaudeAgentSdkSessionForkInput { readonly sessionId: string; readonly options: ForkSessionOptions; + readonly environment: NodeJS.ProcessEnv; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; } @@ -740,14 +744,72 @@ export function makeClaudeAgentSdkProtocolLogger(input: { }; } +const encodeClaudeHistoryOptions = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeClaudeHistoryFork = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ sessionId: Schema.String })), +); + +const claudeConfigDirOf = ( + path: Path.Path, + environment: NodeJS.ProcessEnv, + homeDirectory: string, +) => path.resolve(environment.CLAUDE_CONFIG_DIR ?? path.join(homeDirectory, ".claude")); + +// The SDK reads CLAUDE_CONFIG_DIR from process.env. Isolate custom homes in a +// worker so concurrent providers keep the server's environment. +const forkClaudeSessionInWorker = Effect.fn("forkClaudeSessionInWorker")(function* ( + input: ClaudeAgentSdkSessionForkInput, +) { + const path = yield* Path.Path; + const fromSource = import.meta.url.endsWith(".ts"); + // A single executable hosts the worker itself; it has no sibling script. + const workerArguments = (yield* HostProcess.IsExecutable) + ? ["__claude-history"] + : [ + ...(fromSource ? ["--experimental-strip-types"] : []), + yield* path.fromFileUrl( + new URL( + fromSource ? "../../claude-history-worker.ts" : "./claude-history-worker.mjs", + import.meta.url, + ), + ), + ]; + const executablePath = yield* HostProcess.ExecutablePath; + const result = yield* spawnAndCollect( + executablePath, + ChildProcess.make( + executablePath, + [ + ...workerArguments, + "forkSession", + input.sessionId, + encodeClaudeHistoryOptions(input.options), + ], + { env: { ...input.environment, ELECTRON_RUN_AS_NODE: "1" } }, + ), + ).pipe(Effect.timeout("30 seconds")); + if (result.code !== 0) { + return yield* new ClaudeAgentSdkQueryRunnerError({ + method: "forkSession", + cause: result.stderr.trim() || `Claude history worker exited with code ${result.code}.`, + }); + } + return yield* decodeClaudeHistoryFork(result.stdout); +}); + export const layerQueryRunner: Layer.Layer< ClaudeAgentSdkQueryRunner, never, - Crypto.Crypto | ProviderEventLoggers.ProviderEventLoggers + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | Path.Path + | ProviderEventLoggers.ProviderEventLoggers > = Layer.effect( ClaudeAgentSdkQueryRunner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; const agentScope = yield* AgentScope; const platform = yield* HostProcess.Platform; @@ -1014,10 +1076,19 @@ export const layerQueryRunner: Layer.Layer< options: input.options, }, }); - const result = yield* Effect.tryPromise({ - try: () => forkClaudeSession(input.sessionId, input.options), - catch: (cause) => queryRunnerError(cause, "forkSession"), - }); + const hostEnvironment = yield* HostProcess.Environment; + const homeDirectory = yield* HostProcess.HomeDirectory; + const fork: Effect.Effect = + claudeConfigDirOf(path, input.environment, homeDirectory) === + claudeConfigDirOf(path, hostEnvironment, homeDirectory) + ? Effect.tryPromise(() => forkClaudeSession(input.sessionId, input.options)) + : forkClaudeSessionInWorker(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + ); + const result = yield* fork.pipe( + Effect.mapError((cause) => queryRunnerError(cause, "forkSession")), + ); yield* logProtocolEvent({ direction: "incoming", stage: "decoded", @@ -9646,6 +9717,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const forked = yield* queryRunner.forkSession({ sessionId: sourceNativeThreadId, options: forkOptions, + environment: adapterOptions.environment, threadId: forkInput.targetThreadId, providerSessionId: input.providerSessionId, }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index a691d7e8459a..c9c091d0e75c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -9,11 +9,13 @@ import { assert, describe, it } from "@effect/vitest"; import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { vi } from "vite-plus/test"; -import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; import { CLAUDE_AGENT_SDK_QUERY_PROTOCOL, ClaudeAgentSdkQueryRunner, @@ -85,6 +87,96 @@ it.effect("logs successful task stops for replay and omits failed stops", () => }).pipe(Effect.provide(NodeServices.layer)), ); +const FORK_SESSION_ID = "11111111-1111-4111-8111-111111111111"; + +const writeClaudeSession = Effect.fnUntraced(function* (configDir: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectDir = path.join(configDir, "projects", "-repo"); + const entry = (uuid: string, parentUuid: string | null, message: unknown) => ({ + type: parentUuid === null ? "user" : "assistant", + uuid, + parentUuid, + sessionId: FORK_SESSION_ID, + cwd: "/repo", + isSidechain: false, + timestamp: "2026-10-01T00:00:00.000Z", + message, + }); + yield* fileSystem.makeDirectory(projectDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(projectDir, `${FORK_SESSION_ID}.jsonl`), + [ + entry("aaaaaaaa-0000-4000-8000-000000000001", null, { role: "user", content: "hello" }), + entry("aaaaaaaa-0000-4000-8000-000000000002", "aaaaaaaa-0000-4000-8000-000000000001", { + role: "assistant", + content: [{ type: "text", text: "hi" }], + }), + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + return projectDir; +}); + +const forkRunnerLayer = layerQueryRunner.pipe( + Layer.provide(Layer.succeed(ProviderEventLoggers, { native: undefined, canonical: undefined })), +); + +it.effect("forks a session stored under the instance's own CLAUDE_CONFIG_DIR", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-fork-config-", + }); + const projectDir = yield* writeClaudeSession(configDir); + const hostConfigDir = process.env.CLAUDE_CONFIG_DIR; + const inProcessFork = vi.spyOn(ClaudeSdk, "forkSession"); + yield* Effect.addFinalizer(() => Effect.sync(() => inProcessFork.mockRestore())); + + const runner = yield* ClaudeAgentSdkQueryRunner; + const forked = yield* runner.forkSession({ + sessionId: FORK_SESSION_ID, + options: { dir: "/repo" }, + environment: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, + threadId: ThreadId.make("thread-fork-config-dir"), + providerSessionId: ProviderSessionId.make("session-fork-config-dir"), + }); + + assert.notEqual(forked.sessionId, FORK_SESSION_ID); + const transcript = yield* fileSystem.readFileString( + path.join(projectDir, `${forked.sessionId}.jsonl`), + ); + assert.include(transcript, '"content":"hello"'); + assert.include(transcript, '"text":"hi"'); + assert.equal(process.env.CLAUDE_CONFIG_DIR, hostConfigDir); + assert.equal(inProcessFork.mock.calls.length, 0); + }).pipe(Effect.provide(forkRunnerLayer)), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("forks in-process when the instance shares the server's config dir", () => + Effect.scoped( + Effect.gen(function* () { + const inProcessFork = vi + .spyOn(ClaudeSdk, "forkSession") + .mockResolvedValue({ sessionId: "forked-in-process" }); + yield* Effect.addFinalizer(() => Effect.sync(() => inProcessFork.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const forked = yield* runner.forkSession({ + sessionId: FORK_SESSION_ID, + options: { dir: "/repo" }, + environment: { ...process.env }, + threadId: ThreadId.make("thread-fork-shared-config-dir"), + providerSessionId: ProviderSessionId.make("session-fork-shared-config-dir"), + }); + + assert.equal(forked.sessionId, "forked-in-process"); + }).pipe(Effect.provide(forkRunnerLayer)), + ).pipe(Effect.provide(NodeServices.layer)), +); const realQueryRunner = layerQueryRunner.pipe( Layer.provide( Layer.succeed(ProviderEventLoggers, { From 21cbec39d2a66ea078e7276ece7b5c1baf09ccc9 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 19 Oct 2026 15:25:59 -0500 Subject: [PATCH 123/898] fix(server): Codex file-change approvals list the files they change (#18248) Co-authored-by: only21mil <263261067+only21mil@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../Adapters/CodexAdapterV2.test.ts | 213 ++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 27 ++- .../codex_output.ts | 7 + 3 files changed, 245 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 51d072951b4e..c0dbc441fdc5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -17,6 +17,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, type OrchestrationV2TurnItem, ProjectId, type ProviderApprovalDecision, @@ -35,6 +36,7 @@ import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexError from "effect-codex-app-server/errors"; import * as CodexReplay from "effect-codex-app-server/replay"; +import type * as CodexSchema from "effect-codex-app-server/schema"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Crypto from "effect/Crypto"; @@ -47,6 +49,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -9533,4 +9536,214 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); }); + + describe("Codex file-change approval paths", () => { + const nativeThreadId = "file-approval-thread"; + const nativeTurnId = "file-approval-turn"; + const prompt = "Edit the files."; + type FileUpdateChange = CodexSchema.V2ItemStartedNotification__FileUpdateChange; + const addA: FileUpdateChange = { path: "/w/a.ts", kind: { type: "add" }, diff: "export {};" }; + const updateB: FileUpdateChange = { + path: "/w/b.ts", + kind: { type: "update", move_path: null }, + diff: "@@", + }; + const renameC: FileUpdateChange = { + path: "/w/c.ts", + kind: { type: "update", move_path: "/w/d.ts" }, + diff: "@@", + }; + + const deleteE: FileUpdateChange = { path: "/w/e.ts", kind: { type: "delete" }, diff: "gone" }; + + const fileChange = ( + phase: "started" | "completed", + turnId: string, + itemId: string, + changes: ReadonlyArray, + ): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label: `${phase} ${itemId}`, + frame: { + method: `item/${phase}`, + params: { + threadId: nativeThreadId, + turnId, + ...(phase === "started" ? { startedAtMs: 1 } : { completedAtMs: 3 }), + item: { + type: "fileChange", + id: itemId, + status: phase === "started" ? "inProgress" : "completed", + changes, + }, + }, + }, + }); + + const approval = (input: { + readonly id: number; + readonly turnId: string; + readonly itemId: string; + }): Array => [ + { + type: "emit_inbound", + label: `approval ${input.itemId}`, + frame: { + id: input.id, + method: "item/fileChange/requestApproval", + params: { + threadId: nativeThreadId, + turnId: input.turnId, + itemId: input.itemId, + startedAtMs: 2, + reason: null, + grantRoot: null, + }, + }, + }, + { + type: "expect_outbound", + label: `approval ${input.itemId}`, + frame: { id: input.id, result: { decision: "accept" } }, + }, + ]; + + const turnCompleted = (turnId: string): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label: `complete ${turnId}`, + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: turnId, status: "completed" }), + }, + }, + }); + + const startApprovalTurn = ( + scenario: string, + entries: ReadonlyArray, + ) => + Effect.gen(function* () { + const pending = yield* Queue.unbounded(); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario, + entries: [...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), ...entries], + }), + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending" + ? Queue.offer(pending, event.runtimeRequest) + : Effect.void, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`${scenario}-attempt`), + text: prompt, + }), + ); + return { + harness, + answerNext: Queue.take(pending).pipe( + Effect.tap((request) => + harness.runtime.respondToRuntimeRequest({ + requestId: request.id, + decision: "accept", + }), + ), + ), + }; + }); + + const approvalItem = ( + events: ReadonlyArray, + request: OrchestrationV2RuntimeRequest, + ) => + events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "approval_request" && + event.turnItem.requestId === request.id + ? [event.turnItem] + : [], + )[0]; + + it.effect("answers without waiting for late paths or borrowing another item's paths", () => + Effect.gen(function* () { + const { harness, answerNext } = yield* startApprovalTurn("file-approval-after", [ + fileChange("started", nativeTurnId, "other-patch", [updateB]), + // Late details cannot arrive until the approval response has been sent. + ...approval({ id: 0, turnId: nativeTurnId, itemId: "patch-a" }), + fileChange("started", nativeTurnId, "patch-a", [addA]), + fileChange("completed", nativeTurnId, "patch-a", [addA]), + fileChange("completed", nativeTurnId, "other-patch", [updateB]), + turnCompleted(nativeTurnId), + ]); + const request = yield* answerNext; + yield* harness.firstTerminal; + + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "approval_request" && + event.turnItem.requestId === request.id + ? [event.turnItem] + : [], + ); + assert.equal(request.nativeRequestRef?.nativeId, "patch-a"); + assert.isNotEmpty(items); + for (const item of items) { + assert.equal(item.requestKind, "file-change"); + assert.isUndefined(item.prompt); + } + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["completed"], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("matches file paths to items approved out of order", () => + Effect.gen(function* () { + const { harness, answerNext } = yield* startApprovalTurn("file-approval-concurrent", [ + fileChange("started", nativeTurnId, "patch-a", [renameC, deleteE, addA]), + fileChange("started", nativeTurnId, "patch-b", [updateB]), + ...approval({ id: 0, turnId: nativeTurnId, itemId: "patch-b" }), + fileChange("completed", nativeTurnId, "patch-b", [updateB]), + ...approval({ id: 1, turnId: nativeTurnId, itemId: "patch-a" }), + fileChange("completed", nativeTurnId, "patch-a", [renameC, deleteE, addA]), + turnCompleted(nativeTurnId), + ]); + const first = yield* answerNext; + const second = yield* answerNext; + yield* harness.firstTerminal; + + assert.deepEqual( + [first, second].map((request) => [ + request.nativeRequestRef?.nativeId, + approvalItem(harness.events, request)?.prompt, + ]), + [ + ["patch-b", "update /w/b.ts"], + ["patch-a", "add /w/a.ts\nupdate /w/c.ts -> /w/d.ts\ndelete /w/e.ts"], + ], + ); + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["completed"], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index d9316c7a6915..994ec3342462 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -153,10 +153,14 @@ const CODEX_PROVIDER = ProviderDriverKind.make("codex"); export const CODEX_DRIVER_KIND = CODEX_PROVIDER; export const CODEX_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CODEX_DRIVER_KIND); +type CodexFileChangeKinds = Readonly< + Record +>; + export function codexFileChangeApprovalPrompt(input: { readonly reason?: string | null; readonly grantRoot?: string | null; - readonly fileChanges?: CodexSchema.ServerRequest__ApplyPatchApprovalParams["fileChanges"]; + readonly fileChanges?: CodexFileChangeKinds; }): string | undefined { const reason = input.reason?.trim(); if (reason) return reason; @@ -1126,6 +1130,8 @@ interface ActiveCodexTurnContext { readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; readonly subagent: CodexSubagentThreadContext | null; readonly startedAt: DateTime.Utc; + // Modern approval requests omit paths; keep the kinds without retaining patch bodies. + readonly fileChangeKindsByItem: Map; readonly itemPositions: Map< string, { readonly ordinal: number; readonly startedAt: DateTime.Utc } @@ -2010,6 +2016,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( subagent: null, startedAt: input.startedAt, itemPositions: new Map(), + fileChangeKindsByItem: new Map(), }; yield* Ref.update(limitedTurnItems, (current) => { const next = new Map(current); @@ -2581,6 +2588,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( subagent, startedAt: turn.startedAt, itemPositions: new Map(), + fileChangeKindsByItem: new Map(), }; beginTurnTokenUsage(activeContext); endSessionApprovalsOnModeChange(activeContext); @@ -4659,6 +4667,14 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ); } + if (payload.item.type === "fileChange") { + context.fileChangeKindsByItem.set( + payload.item.id, + Object.fromEntries(payload.item.changes.map(({ path, kind }) => [path, kind])), + ); + return; + } + if (payload.item.type === "agentMessage") { if (payload.item.phase !== "commentary") { yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { @@ -4875,6 +4891,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } if (payload.item.type === "fileChange") { + context.fileChangeKindsByItem.delete(payload.item.id); const artifacts = yield* buildFileChangeArtifacts(context, payload.item); if (artifacts === null) { return; @@ -5127,7 +5144,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( nativeItemId: payload.itemId, nativeRequestId: payload.itemId, requestKind: "file-change", - prompt: codexFileChangeApprovalPrompt(payload) ?? null, + prompt: + codexFileChangeApprovalPrompt({ + ...payload, + fileChanges: context.fileChangeKindsByItem.get(payload.itemId) ?? {}, + }) ?? null, }); const decision = yield* Deferred.make(); yield* Ref.update(pendingRuntimeRequests, (current) => { @@ -5752,6 +5773,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (current !== input.context) { return false; } + input.context.fileChangeKindsByItem.clear(); const providerRetry = yield* Ref.modify(providerRetries, (current) => { const retry = current.get(input.context.providerTurnId); if (retry === undefined) { @@ -6050,6 +6072,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( subagent: null, startedAt: now, itemPositions: new Map(), + fileChangeKindsByItem: new Map(), }; const providerTurn = { id: context.providerTurnId, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_output.ts index ec6981cccd2b..0ef9af47e3a8 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_output.ts @@ -1,3 +1,4 @@ +import { assert } from "@effect/vitest"; import type { ProviderReplayTranscript } from "@t3tools/contracts"; import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; @@ -33,4 +34,10 @@ export function assertToolCallRestrictedGranularOutput( assertRuntimeRequestKinds(projection, ["file-change"]); assertAllRuntimeRequestsResolved(projection); assertUserMessagesInclude(projection, [TOOL_CALL_WRITE_PROMPT]); + + const approval = projection.turnItems.find((item) => item.type === "approval_request"); + assert.equal( + approval?.prompt, + "add /home/replay-user/t3code/packages/effect-codex-app-server/.codex-probe-write-action.txt", + ); } From 221831ff8ff419ed6618b25abbf7adeb2ab4987e Mon Sep 17 00:00:00 2001 From: Antony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:26:07 +0200 Subject: [PATCH 124/898] fix(server): self-hosted GitLab projects resolve bare pull request numbers (#15395) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge --- packages/shared/src/git.test.ts | 8 ++ packages/shared/src/git.ts | 7 +- .../GitLabSourceControlProvider.test.ts | 80 +++++++++++++++++++ .../src/server/GitLabSourceControlProvider.ts | 44 +++++++++- 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 7ff050fbb847..e5ec7b623b51 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -54,6 +54,14 @@ describe("normalizeGitRemoteUrl", () => { expect(normalizeGitRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe( "bitbucket.org/workspace/repo", ); + expect(normalizeGitRemoteUrl("code.example.test:team/repo.git")).toBe( + "code.example.test/team/repo", + ); + expect(normalizeGitRemoteUrl("git@code.example.test:/team/repo.git")).toBe( + "code.example.test/team/repo", + ); + expect(normalizeGitRemoteUrl("C:/repos/project")).toBe("c:/repos/project"); + expect(normalizeGitRemoteUrl("git@x:team/project.git")).toBe("x/team/project"); }); it("gives an Azure DevOps repository the same key over SSH as over HTTPS", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 14af818f4c21..9ee615bf2ef1 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -163,9 +163,10 @@ export function normalizeGitRemoteUrl(value: string): string { } } - const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec( - normalized, - ); + // A userless one-letter prefix is a Windows drive, not an SCP host. + const scpStyleHostAndPath = /^[a-z]:/i.test(normalized) + ? null + : /^(?:[a-zA-Z0-9._-]+@)?([^@:/\\\s]+):\/?([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); const scpHost = scpStyleHostAndPath?.[1]; const scpPath = scpStyleHostAndPath?.[2]; if (scpHost && scpPath) { diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index 1c4268ba4004..3227a13d3f89 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -1,4 +1,11 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; +import { changeRequestUrlFor } from "@t3tools/shared/changeRequestUrl"; +import { + detectSourceControlProviderFromGitRemoteUrl, + normalizeGitRemoteUrl, +} from "@t3tools/shared/git"; +import { refineUnknownRemoteProvider } from "@t3tools/source-control-core/server/discovery"; +import { processOutput } from "@t3tools/source-control-testing/TestSourceControlHost"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -241,6 +248,79 @@ it("tells a host glab has no login for apart from output that cannot say", () => assert.strictEqual(refine("git.example.test\n x API call failed: 503"), undefined); }); +it.effect.each([ + [ + "https://code.example.test:8443@Code.Example.Test:8443/Team/Repo.git", + true, + "gitlab", + "Code.Example.Test:8443", + ], + ["git@code.example.test:team/repo.git", true, "gitlab"], + ["code.example.test:team/repo.git", true, "gitlab"], + ["git@code.example.test:/team/repo.git", true, "gitlab"], + ["https://code.example.test/team/repo.git", false, "unknown"], + ["https://github.com/team/repo.git", true, "github"], +] as const)( + "resolves MR numbers for %s with authenticated=%s as %s", + ([remoteUrl, authenticated, expectedProvider, expectedAuthHost = "code.example.test"]) => + Effect.gen(function* () { + const provider = yield* makeProvider({}); + const identity = { + canonicalKey: normalizeGitRemoteUrl(remoteUrl), + locator: { source: "git-remote" as const, remoteName: "upstream", remoteUrl }, + rootPath: "/repo", + provider: detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind ?? "unknown", + }; + let authCalls = 0; + const refined = yield* ( + provider.refineRepositoryIdentity?.({ + identity, + resolveContext: (input) => + refineUnknownRemoteProvider({ + ...input, + specs: [GitLabSourceControlProvider.discovery], + process: { + run: ({ command, args, cwd }) => { + authCalls++; + assert.deepStrictEqual( + { command, args, cwd }, + { + command: "glab", + args: ["auth", "status", "--hostname", expectedAuthHost], + cwd: "/repo", + }, + ); + return Effect.succeed( + processOutput( + authenticated + ? `${expectedAuthHost}\n ✓ Logged in to ${expectedAuthHost} as gitlab-user\n` + : "x code.example.test has not been authenticated with glab.", + { exitCode: ChildProcessSpawner.ExitCode(authenticated ? 0 : 1) }, + ), + ); + }, + }, + }).pipe( + Effect.map(({ provider }) => + provider ? { ...input.context, provider } : input.context, + ), + ), + }) ?? Effect.succeed(identity) + ); + const [host, ...repository] = refined.canonicalKey.split("/"); + assert.strictEqual( + changeRequestUrlFor(refined.provider, host!, repository.join("/"), 42), + expectedProvider === "gitlab" + ? "https://code.example.test/team/repo/-/merge_requests/42" + : expectedProvider === "github" + ? "https://github.com/team/repo/pull/42" + : null, + ); + assert.deepStrictEqual(refined, { ...identity, provider: expectedProvider }); + if (expectedProvider === "github") assert.strictEqual(authCalls, 0); + }), +); + it("parses authenticated GitLab auth status hosts with ports and single-label names", () => { assert.deepStrictEqual( parseGitLabAuthStatusHosts(`localhost:8080 diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index c8cd955b3f79..8d84beb7a838 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as GitLabCli from "./GitLabCli.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; @@ -101,6 +102,39 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn } as const; } +function unknownRemoteProvider(remoteUrl: string) { + // The shared detector does not recognize userless SCP remotes. + const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+$/u.exec(remoteUrl.trim())?.[1]; + const provider = detectSourceControlProviderFromRemoteUrl( + scpHost ? `ssh://${scpHost}` : remoteUrl, + ); + return provider?.kind === "unknown" ? provider : null; +} + +// A glab login confirms a self-hosted GitLab whose hostname does not identify its provider. +const refineRepositoryIdentity: NonNullable< + SourceControlProvider.SourceControlProvider["Service"]["refineRepositoryIdentity"] +> = Effect.fn("GitLabSourceControlProvider.refineRepositoryIdentity")(function* ({ + identity, + resolveContext, +}) { + if (!identity.rootPath || (identity.provider !== undefined && identity.provider !== "unknown")) + return identity; + const provider = unknownRemoteProvider(identity.locator.remoteUrl); + if (!provider) return identity; + const context = yield* resolveContext({ + cwd: identity.rootPath, + context: { + provider, + remoteName: identity.locator.remoteName, + remoteUrl: identity.locator.remoteUrl, + }, + }); + return context?.provider.kind === "gitlab" + ? { ...identity, provider: SourceControlProviderKind.make("gitlab") } + : identity; +}); + export const discovery = { type: "cli", kind: SourceControlProviderKind.make("gitlab"), @@ -110,8 +144,13 @@ export const discovery = { authArgs: ["auth", "status"], // glab matches its configured hosts by exact name, so ask with the remote's own spelling. remoteRefinementArgs: ({ provider, remoteUrl }) => { - const at = remoteUrl.toLowerCase().indexOf(provider.name.toLowerCase()); - const host = at < 0 ? provider.name : remoteUrl.slice(at, at + provider.name.length); + const authority = /^[a-z][a-z0-9+.-]*:\/\/([^/]+)/iu.exec(remoteUrl.trim())?.[1]; + const remoteHost = authority + ? authority.slice(authority.lastIndexOf("@") + 1) + : remoteUrl.trim().replace(/^[^@:/]+@/u, ""); + const host = remoteHost.toLowerCase().startsWith(provider.name.toLowerCase()) + ? remoteHost.slice(0, provider.name.length) + : provider.name; return ["auth", "status", "--hostname", host]; }, parseAuth: parseGitLabAuth, @@ -163,6 +202,7 @@ export const make = Effect.gen(function* () { return SourceControlProvider.SourceControlProvider.of({ kind: SourceControlProviderKind.make("gitlab"), + refineRepositoryIdentity, resolveLink: (input) => { // Automatic enrichment must not send ambient CLI credentials to a host from message text. if (input.url.host !== "gitlab.com") return undefined; From 8f6c3b209db2a38094fcf367b26cb364d92b85f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0lkay=20Din=C3=A7?= Date: Mon, 19 Oct 2026 22:26:10 +0200 Subject: [PATCH 125/898] fix(web): loading no longer shifts the Files tree (#13904) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- .../files/FileBrowserPanel.browser.test.tsx | 66 ++++++++++++++++++- .../src/components/files/FileBrowserPanel.tsx | 9 ++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 4f8f9a5d44b2..a09803e9f2fc 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -1,3 +1,5 @@ +import "../../index.css"; + import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -7,6 +9,8 @@ import FileBrowserPanel from "./FileBrowserPanel"; const files = vi.hoisted(() => ({ directoryEntries: [] as ProjectEntry[], + directoryReady: true, + directoryPending: false, searchEntries: [] as ProjectEntry[], searchPending: false, searchError: null as string | null, @@ -21,9 +25,9 @@ vi.mock("./useDirectoryEntries", () => ({ loadingDirectories: files.loadingDirectories, load: files.load, refresh: files.refresh, - ready: true, + ready: files.directoryReady, error: null, - isPending: false, + isPending: files.directoryPending, }), })); vi.mock("~/state/queries", () => ({ @@ -101,6 +105,25 @@ describe("Files panel search", () => { ); } + function expectVisibleLoadingStatus() { + const status = container!.querySelector('[role="status"]'); + expect(status?.textContent).toBe("Loading files…"); + expect(status!.checkVisibility()).toBe(true); + const bounds = status!.getBoundingClientRect(); + expect(bounds.width).toBeGreaterThan(1); + expect(bounds.height).toBeGreaterThan(1); + } + + function rowTop(path: string) { + const row = container! + .querySelector("file-tree-container") + ?.shadowRoot!.querySelector(`[data-item-path="${CSS.escape(path)}"]`); + expect(row, `visible row ${path}`).not.toBeNull(); + const bounds = row!.getBoundingClientRect(); + expect(bounds.height).toBeGreaterThan(0); + return bounds.top; + } + beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); files.directoryEntries = [ @@ -108,6 +131,8 @@ describe("Files panel search", () => { { path: "src/app.ts", kind: "file" }, { path: "package.json", kind: "file" }, ]; + files.directoryReady = true; + files.directoryPending = false; files.searchEntries = []; files.searchPending = false; files.searchError = null; @@ -121,6 +146,41 @@ describe("Files panel search", () => { vi.unstubAllGlobals(); }); + it("keeps visible rows stationary during loading while showing initial loading status", async () => { + const entries = files.directoryEntries; + files.directoryEntries = []; + files.directoryReady = false; + files.directoryPending = true; + await render(); + expect(visiblePaths()).toEqual([]); + expectVisibleLoadingStatus(); + + files.directoryEntries = entries; + files.directoryReady = true; + files.directoryPending = false; + await render(); + expect(visiblePaths()).toEqual(["src/", "package.json"]); + const initialTop = rowTop("package.json"); + + files.directoryPending = true; + await render(); + expect(rowTop("package.json")).toBe(initialTop); + + files.directoryPending = false; + await search("pack"); + expect(visiblePaths()).toEqual(["package.json"]); + const filteredTop = rowTop("package.json"); + + files.searchPending = true; + await search("package"); + expect(visiblePaths()).toEqual(["package.json"]); + expect(rowTop("package.json")).toBe(filteredTop); + + files.searchPending = false; + await render(); + expect(rowTop("package.json")).toBe(filteredTop); + }); + it("shows no matching files instead of the full tree when a filename is absent", async () => { await render(); expect(visiblePaths()).toEqual(["src/", "package.json"]); @@ -150,7 +210,7 @@ describe("Files panel search", () => { files.searchPending = true; await search("settings.ts"); expect(visiblePaths()).toEqual([]); - expect(hasMessage("Loading files…")).toBe(true); + expectVisibleLoadingStatus(); expect(hasMessage("No matching files.")).toBe(false); files.searchEntries = [{ path: "config/settings.ts", kind: "file" }]; diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 30764b2a64a1..7b8825d8e082 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -561,7 +561,14 @@ export default function FileBrowserPanel({ ) : null} {(!ready || pathSearch.isPending) && ( -
+
0 && !hasNoSearchMatches + ? "sr-only" + : "px-3 py-1 text-xs text-muted-foreground" + } + > Loading files…
)} From ac871e6f1b1383183b33e43868596b1cc980decd Mon Sep 17 00:00:00 2001 From: Keirara <121832625+K3irara@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:26:16 -0400 Subject: [PATCH 126/898] fix(server): checkpoints work in Windows workspaces with paths over 260 characters (#13538) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- apps/server/src/vcs/GitVcsDriver.test.ts | 48 ++++++++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 31 +++++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 3c2b62259bff..83587e65134b 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/process"; import { assert, it } from "@effect/vitest"; import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -1212,3 +1213,50 @@ it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publi ), ); }); + +it.effect.each(["win32", "linux"] as const)( + "checkpoint working-tree commands enable Git long paths only on Windows (%s)", + (platform) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-long-paths-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + // An unborn nested repository fails the first `add`, so recovery's listing runs too. + yield* git(["init", "empty"]); + const observedArgs: ReadonlyArray[] = []; + const platformDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + observedArgs.push(input.args); + return liveProcess.run(input); + }, + }), + ); + + yield* platformDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + yield* fs.writeFileString(path.join(cwd, "file.txt"), "changed\n"); + assert.isTrue(yield* platformDriver.checkpoints.restoreCheckpoint({ cwd, checkpointRef })); + + assert.strictEqual(yield* fs.readFileString(path.join(cwd, "file.txt")), "unstaged\n"); + assert.strictEqual((yield* git(["ls-tree", checkpointRef, "--", "empty"])).stdout, ""); + for (const command of ["add", "ls-files", "restore", "clean", "reset"]) { + const calls = observedArgs.filter( + (args) => args.includes(command) && (command !== "ls-files" || args.includes("--others")), + ); + assert.isNotEmpty(calls, `Expected checkpoint command: ${command}`); + for (const args of calls) { + const setting = args.indexOf("core.longpaths=true"); + if (platform === "win32") { + assert.strictEqual(args[setting - 1], "-c", args.join(" ")); + assert.isBelow(setting, args.indexOf(command), args.join(" ")); + } else { + assert.strictEqual(setting, -1, args.join(" ")); + } + } + } + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 5d382f16d2ec..f08e250d58d6 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -32,6 +32,7 @@ import { type VcsStatusResult, type WorktreeSubmodules, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { makeGitVcsDriverCore, PATCH_RENDER_PREFIX_ARGS, @@ -792,6 +793,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "core.fsyncMethod=fsync", ] as const; + // Checkpoints must handle existing paths beyond Windows MAX_PATH, even when + // core.longpaths is disabled in the user's Git configuration. + const longPaths = (yield* HostProcess.Platform) === "win32" ? ["-c", "core.longpaths=true"] : []; + const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; @@ -954,6 +959,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( args: [ ...indexConfig, ...durableWrite, + ...longPaths, "add", ...(sparseCheckout ? ["--sparse"] : []), "-A", @@ -972,7 +978,15 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const untracked = yield* execute({ operation, cwd: input.cwd, - args: ["ls-files", "--others", "--exclude-standard", "-z", "--", "."], + args: [ + ...longPaths, + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--", + ".", + ], env: commitEnv, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, }); @@ -1092,7 +1106,16 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* execute({ operation, cwd: input.cwd, - args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], + args: [ + ...longPaths, + "restore", + "--source", + commitOid, + "--worktree", + "--staged", + "--", + ".", + ], }); } // Restoring away the last tracked file can remove a nested workspace directory. @@ -1112,7 +1135,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const cleaned = yield* execute({ operation, cwd: input.cwd, - args: ["clean", "-fd", "--", "."], + args: [...longPaths, "clean", "-fd", "--", "."], allowNonZeroExit: true, }); if (cleaned.exitCode !== 0) { @@ -1141,7 +1164,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* execute({ operation, cwd: input.cwd, - args: ["reset", "--quiet", "--", "."], + args: [...longPaths, "reset", "--quiet", "--", "."], }); } From 207e7e89d18298e54cf56f2de44879e7de61fc51 Mon Sep 17 00:00:00 2001 From: maco Date: Mon, 19 Oct 2026 22:26:26 +0200 Subject: [PATCH 127/898] fix(web): keep colliding diff paths readable and selectable (#18133) Co-authored-by: macodev00 Co-authored-by: Julius Marminge --- .../diffs/DiffFileTree.browser.test.tsx | 148 ++++++++++++++++-- .../web/src/components/diffs/DiffFileTree.tsx | 96 ++++++++---- .../components/diffs/diffFileTree.logic.ts | 39 ++++- 3 files changed, 238 insertions(+), 45 deletions(-) diff --git a/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx b/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx index 1e5814cef2ee..e3ab74f2d51c 100644 --- a/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx @@ -42,9 +42,11 @@ describe("diff tree file activation", () => { function Panel({ files = entries, selectedPath = null, + revealRequestId = 0, }: { files?: DiffFileTreeEntry[]; selectedPath?: string | null; + revealRequestId?: number; }) { const reveal = useCodeViewFileReveal(viewer, "working-tree"); return ( @@ -52,6 +54,7 @@ describe("diff tree file activation", () => { entries={files} ariaLabel="Working tree files" selectedPath={selectedPath} + revealRequestId={revealRequestId} onSelectFile={(path) => reveal(`${path}\0${path}`)} /> ); @@ -73,9 +76,18 @@ describe("diff tree file activation", () => { return element!; } - async function activate(path: string, modifiers: MouseEventInit = {}) { + function fileRows(): HTMLElement[] { + const host = container.querySelector("file-tree-container"); + return [ + ...(host?.shadowRoot ?? container).querySelectorAll( + '[role="treeitem"][data-item-type="file"]', + ), + ]; + } + + async function activate(target: string | HTMLElement, modifiers: MouseEventInit = {}) { await act(async () => { - row(path).dispatchEvent( + (typeof target === "string" ? row(target) : target).dispatchEvent( new MouseEvent("click", { bubbles: true, composed: true, @@ -99,6 +111,7 @@ describe("diff tree file activation", () => { await act(async () => root?.unmount()); root = undefined; container.remove(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -200,16 +213,131 @@ describe("diff tree file activation", () => { expect(folder.isExpanded()).toBe(false); }); - it("does not echo controlled selection, but lets the reader activate it", async () => { - await mount({ selectedPath: "02-short.ts" }); - expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + it("reveals real diff paths and statuses when a directory is replaced by a file", async () => { + const files: DiffFileTreeEntry[] = [ + { path: "office/config.ts", status: "deleted" }, + { path: "office", status: "added" }, + ]; + await mount({ files, selectedPath: "office" }); + expect(fileRows().map((row) => row.getAttribute("data-item-git-status"))).toEqual([ + "deleted", + "added", + ]); + expect(fileRows()[1]!.getAttribute("aria-selected")).toBe("true"); expect(targets).toEqual([]); - await activate("02-short.ts"); - expect(targets).toHaveLength(1); - await act(async () => { - root!.render(); + await activate(fileRows()[1]!); + await activate(fileRows()[0]!); + await activate("office/"); + expect(targets).toEqual([ + { type: "item", id: "office\0office", align: "start" }, + { type: "item", id: "office/config.ts\0office/config.ts", align: "start" }, + ]); + expect(row("office/").getAttribute("aria-expanded")).toBe("false"); + await mount({ files, selectedPath: "office/config.ts" }); + expect(row("office/").getAttribute("aria-expanded")).toBe("true"); + expect(fileRows()[0]!.getAttribute("aria-selected")).toBe("true"); + expect(targets).toHaveLength(2); + }); + + it("keeps selection, focus, and collapsed folders through a colliding slice and refresh", async () => { + const files: DiffFileTreeEntry[] = [ + { path: "office", status: "deleted" }, + { path: "src/route.ts", status: "modified" }, + ]; + await mount({ files }); + await activate("src/"); + await activate("office"); + await act(async () => model().focusPath("office")); + expect(model().getFocusedPath()).toBe("office"); + expect(row("office").getAttribute("aria-selected")).toBe("true"); + const appended: DiffFileTreeEntry[] = [...files, { path: "office/config.ts", status: "added" }]; + await mount({ files: appended }); + expect(row("src/").getAttribute("aria-expanded")).toBe("false"); + expect(fileRows()[0]!.getAttribute("aria-selected")).toBe("true"); + expect(row("office/").getAttribute("aria-selected")).toBe("false"); + expect.soft(model().getFocusedPath()).toBe("office\u200b"); + expect(targets).toEqual([{ type: "item", id: "office\0office", align: "start" }]); + await activate(fileRows()[0]!); + await activate(fileRows()[1]!); + expect(targets).toEqual([ + { type: "item", id: "office\0office", align: "start" }, + { type: "item", id: "office\0office", align: "start" }, + { type: "item", id: "office/config.ts\0office/config.ts", align: "start" }, + ]); + await activate("office/"); + await mount({ files: appended.map((file) => ({ ...file })) }); + expect(row("src/").getAttribute("aria-expanded")).toBe("false"); + expect(row("office/").getAttribute("aria-expanded")).toBe("false"); + await activate(fileRows()[0]!); + expect(targets.at(-1)).toEqual({ type: "item", id: "office\0office", align: "start" }); + expect(targets).toHaveLength(4); + }); + + it("preserves filename and extension icons when files become directory prefixes", async () => { + const files: DiffFileTreeEntry[] = [ + { path: "AGENTS.md", status: "deleted" }, + { path: "config.ts", status: "deleted" }, + ]; + const rootFileIcons = () => + fileRows() + .filter((row) => row.getAttribute("aria-level") === "1") + .map((row) => row.querySelector('[data-item-section="icon"] use')?.getAttribute("href")); + await mount({ files }); + const icons = rootFileIcons(); + expect(icons).toEqual(["#t3-file-icon-agents", "#file-tree-builtin-typescript"]); + await mount({ + files: [ + ...files, + { path: "AGENTS.md/note.txt", status: "added" }, + { path: "config.ts/note.txt", status: "added" }, + ], }); - expect(model().getSelectedPaths()).toEqual(["03-medium.ts"]); + expect(rootFileIcons()).toEqual(icons); + }); + + it("preserves a collapsed controlled selection through paging until explicitly revealed", async () => { + const files: DiffFileTreeEntry[] = [ + { path: "src/office", status: "deleted" }, + { path: "src/route.ts", status: "modified" }, + ]; + await mount({ files, selectedPath: "src/office" }); + expect(model().getSelectedPaths()).toEqual(["src/office"]); + expect(targets).toEqual([]); + await activate("src/office"); + expect(targets).toEqual([{ type: "item", id: "src/office\0src/office", align: "start" }]); + + const folder = model().getItem("src/")!; + if (!("collapse" in folder)) throw new Error("Expected the directory handle"); + await act(async () => folder.collapse()); + expect(row("src/").getAttribute("aria-expanded")).toBe("false"); + const scroll = vi.spyOn(model(), "scrollToPath"); + const appended: DiffFileTreeEntry[] = [...files, { path: "src/office/x.ts", status: "added" }]; + await mount({ files: appended, selectedPath: "src/office" }); + expect.soft(row("src/").getAttribute("aria-expanded")).toBe("false"); + expect.soft(scroll).not.toHaveBeenCalled(); + const selected = model().getSelectedPaths(); + expect(selected).toHaveLength(1); + expect(model().getItem(selected[0]!)?.isDirectory()).toBe(false); expect(targets).toHaveLength(1); + + await act(async () => folder.collapse()); + scroll.mockClear(); + await mount({ files: appended, selectedPath: "src/office", revealRequestId: 1 }); + expect(row("src/").getAttribute("aria-expanded")).toBe("true"); + expect(model().getSelectedPaths()).toEqual(selected); + expect(row(selected[0]!).getAttribute("aria-selected")).toBe("true"); + expect(scroll).toHaveBeenCalledOnce(); + expect(targets).toHaveLength(1); + await activate(selected[0]!); + expect(targets).toEqual([ + { type: "item", id: "src/office\0src/office", align: "start" }, + { type: "item", id: "src/office\0src/office", align: "start" }, + ]); + + await act(async () => folder.collapse()); + await mount({ files: appended, selectedPath: "src/route.ts", revealRequestId: 1 }); + expect(row("src/").getAttribute("aria-expanded")).toBe("true"); + expect(model().getSelectedPaths()).toEqual(["src/route.ts"]); + expect(targets).toHaveLength(2); }); }); diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 59b49647dbe8..cda48198c79f 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -6,7 +6,6 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useStableCallback } from "~/hooks/useStableCallback"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; -import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion"; @@ -17,6 +16,7 @@ import { buildDiffFileTreeUpdates, compareDiffFileTreeEntries, collectDirectoryPaths, + diffFileTreeModel, diffFileTreePositions, type DiffFileTreeEntry, } from "./diffFileTree.logic"; @@ -46,8 +46,11 @@ export function DiffFileTree({ }: DiffFileTreeProps) { const { resolvedTheme } = useTheme(); const paths = entries.map((entry) => entry.path); - const directoryPaths = collectDirectoryPaths(paths); - const positions = diffFileTreePositions(paths); + const presented = diffFileTreeModel(paths); + const modelPaths = presented.paths; + const icons = presented.icons; + const directoryPaths = collectDirectoryPaths(modelPaths); + const positions = diffFileTreePositions(modelPaths); const [ordering] = useState(() => { let currentPositions: ReadonlyMap = new Map(); return { @@ -58,25 +61,30 @@ export function DiffFileTree({ }; }); const gitStatus: ReadonlyArray = entries.map((entry) => ({ - path: entry.path, + path: presented.modelPath(entry.path), status: entry.status, })); const syncingSelectionRef = useRef(false); - const handledRevealRef = useRef<{ path: string; revealRequestId: number } | null>(null); - const mountedPathsRef = useRef | null>(null); + const handledRevealRef = useRef<{ + path: string; + revealRequestId: number; + } | null>(null); + const mountedPresentationRef = useRef | null>(null); // The tree model keeps the first selection handler it was given. const handleSelectionChange = useStableCallback((selectedPaths: ReadonlyArray) => { if (syncingSelectionRef.current) return; - const path = selectedPaths.at(-1)?.replace(/\/$/, ""); - if (path && paths.includes(path)) onSelectFile(path); + const modelPath = selectedPaths.at(-1); + if (modelPath && modelPaths.includes(modelPath)) { + onSelectFile(presented.selectionPath(modelPath)); + } }); const { model } = useFileTree({ density: "compact", flattenEmptyDirectories: true, initialExpansion: "open", - icons: T3_PIERRE_ICONS, + icons, onSelectionChange: handleSelectionChange, paths: [], search: false, @@ -87,67 +95,87 @@ export function DiffFileTree({ areAllDirectoriesExpanded(currentModel, directoryPaths), ); + useEffect(() => { + model.setIcons(icons); + }, [icons, model]); + useEffect(() => { ordering.update(positions); - const mountedPaths = mountedPathsRef.current; - if (mountedPaths === paths) return; - mountedPathsRef.current = paths; - if (mountedPaths === null) { - model.resetPaths(paths); - } else if (mountedPaths.every((path, index) => paths[index] === path)) { - const updates = buildDiffFileTreeUpdates(mountedPaths, paths); + const mounted = mountedPresentationRef.current; + if (mounted?.paths === modelPaths) return; + mountedPresentationRef.current = presented; + if (mounted === null) { + model.resetPaths(modelPaths); + } else if (mounted.paths.every((path, index) => modelPaths[index] === path)) { + const updates = buildDiffFileTreeUpdates(mounted.paths, modelPaths); if (updates.length > 0) model.batch(updates); } else { - // A refreshed diff can change the rank of existing siblings. Mutations do not reorder - // those rows, so rebuild while carrying the reader's folder expansion forward. + // A reset can turn a file path into a directory. Carry selection and focus + // through real paths so paging keeps the same file without revealing it again. + const focusedPath = model.getFocusedPath(); + const nextFocusedPath = + focusedPath === null ? null : presented.modelPath(mounted.selectionPath(focusedPath)); + const selectedPaths = model + .getSelectedPaths() + .map((path) => presented.modelPath(mounted.selectionPath(path))) + .filter((path) => path.endsWith("/") || modelPaths.includes(path)); const collapsedDirectories = directoryPaths.filter((path) => { const directory = model.getItem(path); return directory !== null && "isExpanded" in directory && !directory.isExpanded(); }); - model.resetPaths(paths); + syncingSelectionRef.current = true; + model.resetPaths(modelPaths); + for (const path of model.getSelectedPaths()) model.getItem(path)?.deselect(); + for (const path of selectedPaths) model.getItem(path)?.select(); + queueMicrotask(() => { + syncingSelectionRef.current = false; + }); for (const path of collapsedDirectories) { const directory = model.getItem(path); if (directory !== null && "collapse" in directory) directory.collapse(); } + if ( + nextFocusedPath !== null && + (modelPaths.includes(nextFocusedPath) || directoryPaths.includes(nextFocusedPath)) + ) { + model.focusPath(nextFocusedPath); + } } model.setGitStatus(gitStatus); - }, [directoryPaths, gitStatus, model, ordering, paths, positions]); + }, [directoryPaths, gitStatus, model, modelPaths, ordering, positions, presented]); useEffect(() => { if (selectedPath === null) { handledRevealRef.current = null; return; } - const item = model.getItem(selectedPath); + const modelPath = presented.modelPath(selectedPath); + const item = model.getItem(modelPath); if (item === null || item.isDirectory()) { handledRevealRef.current = null; return; } const handled = handledRevealRef.current; - if (handled?.path === selectedPath && handled.revealRequestId === revealRequestId) return; + if (handled?.path === selectedPath && handled.revealRequestId === revealRequestId) { + return; + } handledRevealRef.current = { path: selectedPath, revealRequestId }; syncingSelectionRef.current = true; for (const path of model.getSelectedPaths()) { - if (path !== selectedPath) model.getItem(path)?.deselect(); + if (path !== modelPath) model.getItem(path)?.deselect(); } let ancestor = ""; - for (const segment of selectedPath.split("/").slice(0, -1)) { + for (const segment of modelPath.split("/").slice(0, -1)) { ancestor += `${segment}/`; const directory = model.getItem(ancestor); if (directory !== null && "expand" in directory) directory.expand(); } item.select(); - model.scrollToPath(selectedPath, { offset: "nearest" }); + model.scrollToPath(modelPath, { offset: "nearest" }); queueMicrotask(() => { syncingSelectionRef.current = false; }); - }, [ - model, - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A file that arrives after it was asked for is still revealed. - paths, - revealRequestId, - selectedPath, - ]); + }, [model, presented, revealRequestId, selectedPath]); return (
@@ -204,13 +232,13 @@ export function DiffFileTree({ // Read selection before the row handles the click so new selections reveal only once. const selected = model.getSelectedPaths(); const path = selected.length === 1 ? selected[0] : undefined; - if (!path || !paths.includes(path)) return; + if (!path || !modelPaths.includes(path)) return; const clickedSelectedRow = event.nativeEvent .composedPath() .some( (node) => node instanceof HTMLElement && node.getAttribute("data-item-path") === path, ); - if (clickedSelectedRow) onSelectFile(path); + if (clickedSelectedRow) onSelectFile(presented.selectionPath(path)); }} className="min-h-0 flex-1 overflow-hidden" style={pierreTreeStyle(resolvedTheme)} diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts index 61509a7bb3d1..6d67f203d0f8 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -1,7 +1,13 @@ import type { FileDiffMetadata } from "@pierre/diffs"; -import type { FileTreeBatchOperation, FileTreeSortComparator, GitStatus } from "@pierre/trees"; +import type { + FileTreeBatchOperation, + FileTreeIconConfig, + FileTreeSortComparator, + GitStatus, +} from "@pierre/trees"; import { resolveFileDiffPath } from "~/lib/diffRendering"; +import { basenameOfPath, resolvePierreIconForEntry, T3_PIERRE_ICONS } from "~/pierre-icons"; export interface DiffFileTreeEntry { readonly path: string; @@ -35,6 +41,37 @@ export function diffFileTreeEntries( return [...statusByPath].map(([path, status]) => ({ path, status })); } +// Pierre cannot store a file and a directory at the same path. Give only those +// files an invisible suffix, keeping real paths at the diff/viewer boundary. +export function diffFileTreeModel(paths: ReadonlyArray) { + const directories = new Set(collectDirectoryPaths(paths)); + // Icon overrides match basenames case-insensitively, so aliases must also avoid + // real names in other directories, including names that already contain the suffix. + const occupiedNames = new Set(paths.flatMap((path) => path.toLowerCase().split("/"))); + const selectionByModelPath = new Map(); + const modelBySelectionPath = new Map(); + const byFileName: FileTreeIconConfig["byFileName"] = { ...T3_PIERRE_ICONS.byFileName }; + const modelPaths = paths.map((path) => { + if (!directories.has(`${path}/`)) return path; + let modelPath = path; + do { + modelPath += "\u200b"; + } while (occupiedNames.has(basenameOfPath(modelPath).toLowerCase())); + occupiedNames.add(basenameOfPath(modelPath).toLowerCase()); + selectionByModelPath.set(modelPath, path); + modelBySelectionPath.set(path, modelPath); + const icon = resolvePierreIconForEntry(path, "file"); + if (icon !== null) byFileName[basenameOfPath(modelPath)] = icon; + return modelPath; + }); + return { + paths: modelBySelectionPath.size === 0 ? paths : modelPaths, + selectionPath: (modelPath: string) => selectionByModelPath.get(modelPath) ?? modelPath, + modelPath: (path: string) => modelBySelectionPath.get(path) ?? path, + icons: modelBySelectionPath.size === 0 ? T3_PIERRE_ICONS : { ...T3_PIERRE_ICONS, byFileName }, + }; +} + export function collectDirectoryPaths(paths: ReadonlyArray): ReadonlyArray { const directories = new Set(); for (const path of paths) { From 10b6779a2d4b4696cff0e9ac32f563c0d342a6a6 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:26:38 -0300 Subject: [PATCH 128/898] fix(web): repo-root-relative file chips open in subfolder projects (#14510) Co-authored-by: Julius Marminge --- .../components/ChatMarkdown.browser.test.tsx | 176 +++++++++++++++++- apps/web/src/components/ChatMarkdown.tsx | 60 ++++-- apps/web/src/markdown-links.ts | 18 +- apps/web/src/workspaceBasenameLookup.ts | 33 ++++ .../client-runtime/src/state/filesystem.ts | 4 + 5 files changed, 270 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index eac41c7eb0be..6e9b8bdf161d 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -1,15 +1,28 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; +import { AsyncResult } from "effect/reactivity"; import { act, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { filesystemEnvironment } from "../state/filesystem"; +import { useRightPanelStore } from "../rightPanelStore"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { MarkdownFindContext } from "./chat/markdownFindContext"; import { useThreadFindHighlights } from "./chat/threadFindHighlights"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; +const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ + workspaceSearch: vi.fn(), + filesystemMetadata: vi.fn(), +})); +vi.mock(import("../state/use-atom-query-runner"), async (original) => ({ + ...(await original()), + useAtomQueryRunner: ((family: unknown) => + family === filesystemEnvironment.getMetadata ? filesystemMetadata : workspaceSearch) as never, +})); + // Atoms derived from the server config run in the real registry over a missing config. vi.mock(import("../state/server"), async (original) => { const actual = await original(); @@ -47,6 +60,8 @@ import ChatMarkdown from "./ChatMarkdown"; const roots: Root[] = []; beforeEach(() => { + workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); + filesystemMetadata.mockReset().mockResolvedValue(AsyncResult.success({ entries: [null] })); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); }); @@ -400,6 +415,165 @@ describe("ChatMarkdown skill chips", () => { }); }); +describe("ChatMarkdown workspace file opens", () => { + const threadRef = { + environmentId: EnvironmentId.make("workspace"), + threadId: ThreadId.make("thread"), + }; + + it.each([ + { + cwd: "/school/courses/physics", + path: "courses/physics/notes/a.md", + indexedPath: "notes/a.md", + }, + { + cwd: "C:/school/courses/Physics", + path: "courses/physics/notes/a.md", + indexedPath: "Notes/A.md", + }, + ])( + "opens a repo-relative chip at its indexed path in $cwd", + async ({ cwd, path, indexedPath }) => { + workspaceSearch.mockResolvedValue( + AsyncResult.success({ + entries: [ + { path: indexedPath, kind: "file" }, + { path, kind: "file" }, + ], + truncated: false, + }), + ); + const openFile = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(() => {}); + const container = await renderText(`[Notes](${path}:7)`, { cwd, threadRef }); + + await act(async () => container.querySelector("a")!.click()); + + expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, indexedPath, 7); + }, + ); + + it("prefers an existing literal nested file over the stripped Windows match", async () => { + filesystemMetadata.mockResolvedValue( + AsyncResult.success({ entries: [{ kind: "file", byteLength: 10 }] }), + ); + workspaceSearch.mockResolvedValue( + AsyncResult.success({ + entries: [ + { path: "Notes/A.md", kind: "file" }, + { path: "Physics/Notes/A.md", kind: "file" }, + ], + truncated: false, + }), + ); + const openFile = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(() => {}); + const container = await renderText("[Notes](physics/notes/a.md)", { + cwd: "C:/school/Physics", + threadRef, + }); + + await act(async () => container.querySelector("a")!.click()); + + expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "physics/notes/a.md", undefined); + }); + + it("keeps a literal file omitted by a truncated search", async () => { + workspaceSearch.mockResolvedValue( + AsyncResult.success({ + entries: [{ path: "notes/a.md", kind: "file" }], + truncated: true, + }), + ); + filesystemMetadata.mockResolvedValue( + AsyncResult.success({ entries: [{ kind: "file", byteLength: 10 }] }), + ); + const openFile = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(() => {}); + const container = await renderText("[Notes](physics/notes/a.md:7)", { + cwd: "/school/physics", + threadRef, + }); + + await act(async () => container.querySelector("a")!.click()); + + expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "physics/notes/a.md", 7); + expect(filesystemMetadata).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { paths: ["/school/physics/physics/notes/a.md"] }, + }); + }); + + it.each([ + { + kind: "absolute link", + href: "/school/physics/physics/notes/a.md:7", + imageBaseDir: undefined, + }, + { kind: "nested preview", href: "notes/a.md:7", imageBaseDir: "/school/physics/physics" }, + ])( + "keeps the literal target for a $kind when only its stripped twin exists", + async ({ href, imageBaseDir }) => { + workspaceSearch.mockResolvedValue( + AsyncResult.success({ + entries: [{ path: "notes/a.md", kind: "file" }], + truncated: false, + }), + ); + const openFile = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(() => {}); + const container = await renderText(`[Notes](${href})`, { + cwd: "/school/physics", + imageBaseDir, + threadRef, + }); + + await act(async () => container.querySelector("a")!.click()); + + expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "physics/notes/a.md", 7); + expect(workspaceSearch).not.toHaveBeenCalled(); + expect(filesystemMetadata).not.toHaveBeenCalled(); + }, + ); + + it("keeps a newer absolute file open when an earlier repo-relative lookup finishes", async () => { + const result = AsyncResult.success({ + entries: [{ path: "notes/a.md", kind: "file" }], + truncated: false, + }); + let resolvePending!: (value: typeof result) => void; + const pending = new Promise((resolve) => { + resolvePending = resolve; + }); + workspaceSearch.mockReturnValueOnce(pending); + const openFile = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(() => {}); + const container = await renderText( + "[Notes](physics/notes/a.md) and [Other](/elsewhere/b.md:9)", + { + cwd: "/school/physics", + threadRef, + }, + ); + const [notes, other] = container.querySelectorAll("a"); + await act(async () => notes!.click()); + expect(workspaceSearch).toHaveBeenCalledOnce(); + expect(openFile).not.toHaveBeenCalled(); + + await act(async () => other!.click()); + await act(async () => resolvePending(result)); + + expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "/elsewhere/b.md", 9); + expect(workspaceSearch).toHaveBeenCalledOnce(); + }); +}); + describe("ChatMarkdown file citations", () => { it.each([true, false])( "renders Codex file citations as file chips with parseRawHtml=%s", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9ec6ec2db051..d637b25e1e51 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -11,7 +11,7 @@ import { extractMarkdownLinkHrefs, inlineCodeFilePathCandidate, } from "@t3tools/shared/markdownLinks"; -import { isAbsolutePath } from "@t3tools/shared/path"; +import { isAbsolutePath, resolveWorkspaceFilePath } from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { @@ -195,10 +195,13 @@ 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 { filesystemEnvironment } from "../state/filesystem"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, + pickStrippedWorkspaceMatch, pickWorkspaceBasenameMatch, + stripRepeatedWorkspacePrefix, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { @@ -2386,6 +2389,10 @@ function useChatMarkdownState({ const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); + const getFilesystemMetadata = useAtomQueryRunner(filesystemEnvironment.getMetadata, { + reportFailure: false, + refresh: true, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -2609,49 +2616,68 @@ function useChatMarkdownState({ openPreview, }); }; - const findWorkspaceBasenameMatch = async (workspaceRelativePath: string) => { + const findWorkspaceFileMatch = async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceLookupPath; if ( + !workspaceRelativePath || !cwd || environmentId === null || - !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || - !needsWorkspaceBasenameLookup(workspaceRelativePath) + !readEnvironmentScope(environmentId, AuthFilesystemReadScope) ) { return null; } + const isBasename = needsWorkspaceBasenameLookup(workspaceRelativePath); + const strippedPath = isBasename + ? null + : stripRepeatedWorkspacePrefix(workspaceRelativePath, cwd); + if (!isBasename && strippedPath === null) return null; const result = await searchProjectEntries({ environmentId, input: { cwd, - query: workspaceRelativePath, + query: strippedPath ?? workspaceRelativePath, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file", }, }); - return result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; + if (result._tag !== "Success") return null; + if (strippedPath === null) { + return pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries); + } + const match = pickStrippedWorkspaceMatch(strippedPath, cwd, result.value.entries); + if (match === null) return null; + // Search can be truncated or still scanning, so it cannot rule out the + // literal path. Check the host before redirecting to the stripped match. + const metadata = await getFilesystemMetadata({ + environmentId, + input: { paths: [resolveWorkspaceFilePath(workspaceRelativePath, cwd)] }, + }); + return metadata._tag === "Success" && metadata.value.entries[0] === null ? match : null; }; - const openFileInPanel = (panelPath: string, line: number | undefined) => { + const openFileInPanel = (fileLinkMeta: MarkdownFileLinkMeta, panelPath: string) => { 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(panelPath)) { + const openAt = (path: string) => + useRightPanelStore.getState().openFile(threadRef, path, fileLinkMeta.line); + if ( + fileLinkMeta.workspaceLookupPath === null || + !cwd || + (!needsWorkspaceBasenameLookup(panelPath) && + stripRepeatedWorkspacePrefix(panelPath, cwd) === null) + ) { openAt(panelPath); return; } void (async () => { - const match = await findWorkspaceBasenameMatch(panelPath); + const match = await findWorkspaceFileMatch(fileLinkMeta); if (!isLatestLookup()) return; openAt(match ?? panelPath); })(); }; const revealMarkdownFileInFileManager = async (fileLinkMeta: MarkdownFileLinkMeta) => { - const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; - const match = workspaceRelativePath - ? await findWorkspaceBasenameMatch(workspaceRelativePath) - : null; + const match = await findWorkspaceFileMatch(fileLinkMeta); const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; return revealFileInFileManager(filePath); }; @@ -2690,7 +2716,7 @@ function useChatMarkdownState({ theme={resolvedTheme} threadRef={threadRef} {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} - onOpenInPanel={openFileInPanel} + onOpenInPanel={(path) => openFileInPanel(fileLinkMeta, path)} onOpenMedia={ threadRef && canPreviewMedia ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath) diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 72120997fd80..de5b7d3efad2 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,8 +1,9 @@ -import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path"; +import { fileBasename, isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path"; import { inlineCodeFilePathCandidate, isRelativeFilePath, normalizeMarkdownLinkDestination, + parseMarkdownFileLink, resolveMarkdownFileLinkTarget, } from "@t3tools/shared/markdownLinks"; import { parseFileUrlHref, splitFilePathPosition } from "@t3tools/shared/fileLinks"; @@ -12,6 +13,7 @@ import { isTerminalLinkActivation } from "./terminal-links"; export interface MarkdownFileLinkMeta { filePath: string; + workspaceLookupPath: string | null; targetPath: string; displayPath: string; workspaceRelativePath: string | null; @@ -70,10 +72,20 @@ export function resolveMarkdownFileLinkMeta( ): MarkdownFileLinkMeta | null { const targetPath = resolveMarkdownFileLinkTarget(href, cwd, baseDir); if (!targetPath) return null; - return buildFileLinkMetaFromTarget(targetPath, cwd); + const source = href ? parseMarkdownFileLink(href) : null; + return { + ...buildFileLinkMetaFromTarget(targetPath, cwd), + workspaceLookupPath: + source !== null && + !isAbsolutePath(source.path) && + baseDir !== undefined && + workspaceRelativeFilePath(baseDir, cwd) === "." + ? source.path + : null, + }; } -function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { +function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string) { const { path, line, column } = splitFilePathPosition(targetPath); return { filePath: path, diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index 2d007992319a..710998dfd08a 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -1,3 +1,5 @@ +import { isAbsolutePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; + export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; // One counter for every caller: they all open the same panel, so the newest @@ -46,3 +48,34 @@ export function pickWorkspaceBasenameMatch( ); return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; } + +// Repository-relative paths can repeat the project's suffix in subfolder projects. +export function stripRepeatedWorkspacePrefix(relativePath: string, cwd: string): string | null { + if (isAbsolutePath(relativePath)) return null; + const pathSegments = relativePath.split(/[\\/]+/); + const cwdSegments = cwd.split(/[\\/]+/).filter(Boolean); + const fold = isWindowsAbsolutePath(cwd) + ? (segment: string | undefined) => segment?.toLowerCase() + : (segment: string | undefined) => segment; + for (let count = Math.min(cwdSegments.length, pathSegments.length - 1); count > 0; count--) { + const prefix = cwdSegments.slice(-count); + if (prefix.every((segment, index) => fold(segment) === fold(pathSegments[index]))) { + return pathSegments.slice(count).join("/") || null; + } + } + return null; +} + +export function pickStrippedWorkspaceMatch( + strippedPath: string, + cwd: string, + entries: ReadonlyArray, +): string | null { + const caseInsensitive = isWindowsAbsolutePath(cwd); + const key = (path: string) => { + const normalized = path.replaceAll("\\", "/"); + return caseInsensitive ? normalized.toLowerCase() : normalized; + }; + const files = entries.filter((entry) => entry.kind === "file"); + return files.find((entry) => key(entry.path) === key(strippedPath))?.path ?? null; +} diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index b9ceb665d9a4..619fd20b9b79 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -115,6 +115,10 @@ export function createFilesystemEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { return { + getMetadata: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:filesystem:get-metadata", + tag: WS_METHODS.filesystemGetMetadata, + }), browse: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:filesystem:browse", tag: WS_METHODS.filesystemBrowse, From 178e2abce998bfb608ee12a804501868c3d716f8 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:56:44 +0530 Subject: [PATCH 129/898] fix(web): keep large collapsed file trees virtualized (#21831) --- .../chat/ChangedFilesTree.browser.test.tsx | 46 ++++++++++++++++--- .../src/components/chat/ChangedFilesTree.tsx | 6 +-- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/ChangedFilesTree.browser.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.browser.test.tsx index 800e9e9cc782..6e40307bb524 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.browser.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.browser.test.tsx @@ -107,12 +107,12 @@ describe("ChangedFilesTree", () => { file(`src/file-${index.toString().padStart(5, "0")}.ts`), ); - it("virtualizes above 250 expanded files and disables it when folders collapse below that count", async () => { + it("virtualizes above 250 visible rows and disables it when folders collapse below that count", async () => { await render(
{}} @@ -122,20 +122,52 @@ describe("ChangedFilesTree", () => { expect(container.querySelectorAll("button").length).toBe(2); await act(async () => buttonWithText("src").click()); - expect(container.querySelectorAll("button").length).toBe(252); - expect(text()).toContain("file-00249.ts"); + expect(container.querySelectorAll("button").length).toBe(250); + expect(text()).toContain("file-00247.ts"); expect(text()).not.toContain("overflow.ts"); await act(async () => buttonWithText("extra").click()); expect(container.querySelectorAll("button").length).toBeLessThan(60); - expect(text()).not.toContain("file-00249.ts"); + expect(text()).not.toContain("file-00247.ts"); await act(async () => buttonWithText("extra").click()); - expect(container.querySelectorAll("button").length).toBe(252); - expect(text()).toContain("file-00249.ts"); + expect(container.querySelectorAll("button").length).toBe(250); + expect(text()).toContain("file-00247.ts"); expect(text()).not.toContain("overflow.ts"); }); + it("keeps 1,000 collapsed folders virtualized and reachable after scrolling", async () => { + const files = Array.from({ length: 1_000 }, (_, index) => + file(`folder-${index.toString().padStart(4, "0")}/index.ts`), + ); + const content = (expanded: boolean) => ( +
+ {}} + /> +
+ ); + await render(content(true)); + expect(container.querySelectorAll("button").length).toBeLessThan(60); + + await render(content(false)); + expect(container.querySelectorAll("button").length).toBeLessThan(60); + expect(text()).toContain("folder-0000"); + expect(text()).not.toContain("index.ts"); + expect(text()).not.toContain("folder-0999"); + + const viewport = container.firstElementChild as HTMLElement; + await scrollTo(viewport, viewport.scrollHeight); + expect(text()).toContain("folder-0999"); + expect(container.querySelectorAll("button").length).toBeLessThan(60); + await act(async () => buttonWithText("folder-0999").click()); + expect(text()).toContain("index.ts"); + }); + it("keeps expand-all bounded and opens files after scrolling the surrounding viewport", async () => { const onOpenTurnDiff = vi.fn(); const onFileContextMenu = vi.fn(); diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 4550f9bf8e67..f73c3d0dff79 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -18,7 +18,7 @@ import { MorphIcon } from "~/components/MorphIcon"; import { observeResize } from "~/lib/observeResize"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; -const VIRTUALIZE_AFTER_FILES = 250; +const VIRTUALIZE_AFTER_ROWS = 250; const ROW_OVERSCAN = 8; interface ChangedFileRow { @@ -170,9 +170,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { }; const rows = collectExpandedRows(treeNodes, expandedDirectories, allDirectoriesExpanded); - const virtualized = - rows.reduce((count, row) => count + (row.node.kind === "file" ? 1 : 0), 0) > - VIRTUALIZE_AFTER_FILES; + const virtualized = rows.length > VIRTUALIZE_AFTER_ROWS; const renderRow = ({ node, depth }: ChangedFileRow) => { const leftPadding = 8 + depth * 14; From 1945d38a81a7eaea182f2887906181c6d863e99f Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:57:05 +0530 Subject: [PATCH 130/898] fix(web): place worktree details below their disclosure (#21853) --- apps/web/src/components/chat/WorktreeSetupCard.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 28c942f676db..be40305f4c5a 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -359,8 +359,6 @@ export function WorktreeSetupCard({

{snapshot.error}

) : null} - {detailsOpen ? : null} -
+ {detailsOpen ? : null} ); } From 3bd670918127b3af15dcbb0102ad87fbca28ccaf Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 07:27:39 +1100 Subject: [PATCH 131/898] fix(bitbucket): advance pull request pages through tied updates (#18813) Co-authored-by: SAPHID Co-authored-by: GPT-6.1 Sol Co-authored-by: Julius Marminge --- .../pullRequest/PullRequestService.test.ts | 85 ++++++++++++++++++- .../src/pullRequest/PullRequestService.ts | 12 ++- .../src/server/BitbucketPullRequestApi.ts | 5 +- .../src/server/PullRequestProvider.ts | 5 ++ 4 files changed, 98 insertions(+), 9 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 5eb0ea9660f7..7698f28be462 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -36,6 +36,9 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; +import * as BitbucketPullRequestProvider from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; @@ -921,6 +924,82 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" }), ); +it.effect("continues Bitbucket listings through tied timestamps and terminates", () => + Effect.gen(function* () { + const boundary = "2026-10-12T00:00:00.000Z"; + const older = "2026-10-11T00:00:00.000Z"; + const rows = [1, 2, 3, 4, 5].map((id) => ({ + id, + title: `Pull request ${id}`, + state: "OPEN", + created_on: older, + updated_on: id <= 3 ? boundary : older, + source: { branch: { name: `feat/${id}` } }, + destination: { branch: { name: "main" } }, + links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${id}` } }, + })); + const provider = yield* BitbucketPullRequestProvider.make.pipe( + Effect.provide( + BitbucketPullRequestApi.layer.pipe( + Layer.provide( + Layer.mock(BitbucketApi.BitbucketApi)({ + request: ({ url }) => { + if (url === "/user") { + return Effect.succeed({ + body: JSON.stringify({ nickname: "bilal" }), + truncated: false, + }); + } + const filter = + new URL(url, "https://api.bitbucket.org").searchParams.get("q") ?? ""; + const updatedBefore = /updated_on <= (\S+)/.exec(filter)?.[1]; + const excluded = new Set( + Array.from(filter.matchAll(/id != (\d+)/g), (match) => Number(match[1])), + ); + const values = rows.filter( + (row) => + (updatedBefore === undefined || row.updated_on <= updatedBefore) && + !excluded.has(row.id), + ); + return Effect.succeed({ body: JSON.stringify({ values }), truncated: false }); + }, + }), + ), + ), + ), + ); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/w", + repository: "acme/web", + provider: "bitbucket", + host: "bitbucket.org", + }), + ], + providers: [provider], + }); + let page = yield* service.list({ state: "open", limit: 2 }); + assert.deepStrictEqual( + page.entries.map((entry) => entry.number), + [1, 2], + ); + for (const expected of [[3, 4], [5]]) { + assert.isTrue(page.truncated); + assert.isNotEmpty(page.nextCursors); + page = yield* service.list({ state: "open", limit: 2, cursors: page.nextCursors }); + assert.deepStrictEqual( + page.entries.map((entry) => entry.number), + expected, + ); + } + assert.isFalse(page.truncated); + assert.deepStrictEqual(page.nextCursors, {}); + }), +); + it.effect("reads only the repositories it was asked to carry on with", () => Effect.gen(function* () { const listed: string[] = []; @@ -947,7 +1026,9 @@ it.effect("reads only the repositories it was asked to carry on with", () => }); assert.deepStrictEqual(listed, ["acme/web"]); - assert.deepStrictEqual(cursors, [{ updatedBefore: "2026-07-02T00:00:00Z", delivered: 99 }]); + assert.deepStrictEqual(cursors, [ + { updatedBefore: "2026-07-02T00:00:00Z", delivered: 99, seenAt: [7] }, + ]); assert.strictEqual(result.providers.length, 1); }), ); @@ -4554,7 +4635,7 @@ it.effect("carries a slice on without sending the rows it already sent", () => [4], ); assert.deepStrictEqual(result.nextCursors, { - "github.com acme/web": "2026-07-02T00:00:00Z|2|3,3,4", + "github.com acme/web": "2026-07-02T00:00:00Z|2|3,4", }); }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index fc2a3a143b80..ff77f99ebe45 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -376,11 +376,11 @@ function listCursorAt( fetched: ReadonlyArray, deliveredCount: number, ): string { - const seenAt = [ + const seenAt = new Set([ ...(previous?.updatedBefore === boundary ? previous.seenAt : []), ...fetched.filter((item) => item.updatedAt === boundary).map((item) => item.number), - ]; - return `${boundary}|${(previous?.delivered ?? 0) + deliveredCount}|${seenAt.join(",")}`; + ]); + return `${boundary}|${(previous?.delivered ?? 0) + deliveredCount}|${[...seenAt].join(",")}`; } function isProviderUnusable(error: PullRequestProviderError): boolean { @@ -1176,7 +1176,11 @@ export const make = Effect.gen(function* () { ...(cursor === undefined ? {} : { - cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered }, + cursor: { + updatedBefore: cursor.updatedBefore, + delivered: cursor.delivered, + seenAt: cursor.seenAt, + }, }), }) .pipe( diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts index 4cb963ff30bb..aefcbd015019 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts @@ -561,12 +561,11 @@ export const make = Effect.gen(function* () { listPullRequests: (input) => withRepository(input.repository, (path) => { const search = input.query?.trim() ?? ""; - // The boundary instant is read inclusively — the rows already sent at it come back and - // the caller drops them, which is what keeps their neighbours at the same instant from - // being skipped. + // Exclude already-sent IDs at the inclusive timestamp before applying the page limit. const predicates = [ ...(search.length === 0 ? [] : [searchFilter(search)]), ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]), + ...(input.cursor?.seenAt ?? []).map((number) => `id != ${number}`), ]; return listPage({ // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them. diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 882bc73c1153..0aadb0bbc929 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -155,6 +155,11 @@ export interface ProviderListCursor { */ readonly updatedBefore: string; readonly delivered: number; + /** + * Rows already handed over at `updatedBefore`. Timestamp-paged hosts can exclude these before + * applying their limit so a full page of tied updates does not repeat the same prefix. + */ + readonly seenAt?: ReadonlyArray | undefined; } export interface ProviderBatchedChangeRequest extends ProviderChangeRequest { From eb0652067cd143592fcaffbdcf43c3c5c97c6e2c Mon Sep 17 00:00:00 2001 From: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:46 +0200 Subject: [PATCH 132/898] fix(server): Stop no longer leaves a killed background command showing as running (#18163) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../BackgroundWorkStop.integration.test.ts | 134 ++++++++++++++---- .../src/orchestration-v2/Orchestrator.ts | 28 +++- 2 files changed, 131 insertions(+), 31 deletions(-) diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts index 24521561f14b..666531762d70 100644 --- a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -38,10 +38,12 @@ const stopEarlierBackgroundWork = ({ failedStart = false, stopWithQueue, olderStart = false, + backgroundWorkAfterStop, stalledRun, workflowMember = false, }: { readonly failedStart?: boolean; + readonly backgroundWorkAfterStop?: "running" | "unknown"; readonly stopWithQueue?: "thread.stop" | "run.interrupt"; readonly olderStart?: boolean; readonly workflowMember?: boolean; @@ -83,6 +85,22 @@ const stopEarlierBackgroundWork = ({ lastError: null, }, events: Stream.fromQueue(events), + ...(failedStart + ? { + hasPendingBackgroundWorkForThread: (providerThread) => + Effect.gen(function* () { + if ( + !interrupts.some((input) => input.providerThread.id === providerThread.id) + ) { + return false; + } + if (backgroundWorkAfterStop === "unknown") { + return yield* Effect.die("Background work probe failed"); + } + return backgroundWorkAfterStop === "running"; + }), + } + : {}), ensureThread: ({ threadId }) => Effect.succeed({ id: ProviderThreadId.make(`provider-thread:codex:${threadId}`), @@ -412,6 +430,10 @@ const stopEarlierBackgroundWork = ({ const codexProviderThread = (yield* orchestrator.getThreadProjection(threadId)) .providerThreads[0]!; + const backgroundTasks = [ + { taskId: "sleep", description: "Wait before the recheck", kind: "command" as const }, + ]; + const otherBackgroundTasks = [{ ...backgroundTasks[0]!, taskId: "other-sleep" }]; const subagentThread = (name: string) => Effect.gen(function* () { const subagentThreadId = ThreadId.make(`thread:${name}`); @@ -735,40 +757,76 @@ const stopEarlierBackgroundWork = ({ id: otherProviderThreadId, firstRunOrdinal: 4, lastRunOrdinal: 4, + ...(failedStart ? { pendingBackgroundTasks: otherBackgroundTasks } : {}), }, }, ...latestRun.events, ], }); - const failedRun = settledRun({ ordinal: 5, providerThreadId: otherProviderThreadId }); + const failedRun = settledRun({ ordinal: 5, providerThreadId: codexProviderThread.id }); if (failedStart) { yield* sink.write({ - events: failedRun.events.flatMap((event): Array => { - switch (event.type) { - case "provider-turn.updated": - return []; - case "run.created": - return [{ ...event, payload: { ...event.payload, status: "failed" } }]; - case "node.updated": - return [ - { - ...event, - payload: { ...event.payload, status: "failed", providerTurnId: null }, - }, - ]; - case "run-attempt.created": - return [ - { - ...event, - payload: { ...event.payload, status: "failed", providerTurnId: null }, - }, - ]; - default: - return [event]; - } - }), + events: [ + { + id: EventId.make("failed-start-roster"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { + ...codexProviderThread, + lastRunOrdinal: 5, + pendingBackgroundTasks: backgroundTasks, + }, + }, + ...failedRun.events.flatMap((event): Array => { + switch (event.type) { + case "provider-turn.updated": + return []; + case "run.created": + return [{ ...event, payload: { ...event.payload, status: "failed" } }]; + case "node.updated": + return [ + { + ...event, + payload: { ...event.payload, status: "failed", providerTurnId: null }, + }, + ]; + case "run-attempt.created": + return [ + { + ...event, + payload: { ...event.payload, status: "failed", providerTurnId: null }, + }, + ]; + default: + return [event]; + } + }), + ], }); + // The older turn reports an empty roster, but the refused run owns the row now. + const clear = yield* sink.writeIfProviderThreadOwner({ + providerThreadId: codexProviderThread.id, + runId: reviewerRun.runId, + activeAttemptId: RunAttemptId.make("attempt:3"), + expectedLastRunOrdinal: 3, + events: [ + { + id: EventId.make("dropped-roster-clear"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { ...codexProviderThread, pendingBackgroundTasks: [] }, + }, + ], + }); + assert.isFalse(clear.committed); + const beforeStop = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.find((thread) => thread.id === codexProviderThread.id)!; + assert.equal(beforeStop.lastRunOrdinal, 5); + assert.deepEqual(beforeStop.pendingBackgroundTasks, backgroundTasks); } if (stopWithQueue !== undefined) { @@ -864,7 +922,9 @@ const stopEarlierBackgroundWork = ({ assert.sameDeepMembers( interrupts.map((interrupt) => [interrupt.providerThread.id, interrupt.providerTurnId]), [ - ...(olderStart ? [] : [[otherProviderThreadId, latestRun.providerTurnId]]), + ...(olderStart || failedStart + ? [] + : [[otherProviderThreadId, latestRun.providerTurnId]]), [codexProviderThread.id, reviewerRun.providerTurnId], ], ); @@ -906,6 +966,18 @@ const stopEarlierBackgroundWork = ({ } const testerThread = yield* orchestrator.getThreadProjection(tester.threadId); assert.equal(testerThread.nodes[0]?.status, "interrupted"); + if (failedStart) { + assert.deepEqual( + after.providerThreads.find((candidate) => candidate.id === codexProviderThread.id) + ?.pendingBackgroundTasks, + backgroundWorkAfterStop === undefined ? [] : backgroundTasks, + ); + assert.deepEqual( + after.providerThreads.find((candidate) => candidate.id === otherProviderThreadId) + ?.pendingBackgroundTasks, + otherBackgroundTasks, + ); + } }).pipe( Effect.provide( ProviderReplayHarness.layerWithRegistry( @@ -927,10 +999,18 @@ it.effect("Stop settles workflow members without subagent turn items", () => ); it.effect( - "Stop reaches earlier background work after the newest run fails before provider start", + "Stop clears a stale roster after a refused run takes ownership of the provider thread", () => stopEarlierBackgroundWork({ failedStart: true }), ); +it.effect("Stop preserves the roster when background work survives the interrupt", () => + stopEarlierBackgroundWork({ failedStart: true, backgroundWorkAfterStop: "running" }), +); + +it.effect("Stop preserves the roster when the background work probe fails", () => + stopEarlierBackgroundWork({ failedStart: true, backgroundWorkAfterStop: "unknown" }), +); + it.effect.each(["thread.stop", "run.interrupt"] as const)( "%s stops background work when a later message is queued", (stopType) => stopEarlierBackgroundWork({ stopWithQueue: stopType }), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 86f591c16bb9..fffba7935982 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8547,6 +8547,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio liveness.set(providerThreadId, live); return live; }); + const stillReportsBackgroundWork = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + ) { + if (providerThread.providerSessionId == null) return false; + const session = yield* providerSessions + .get(providerThread.providerSessionId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if ( + Option.isNone(session) || + session.value.hasPendingBackgroundWorkForThread === undefined + ) { + return false; + } + return yield* session.value + .hasPendingBackgroundWorkForThread(providerThread) + // A failed probe cannot confirm that the work ended. + .pipe(Effect.catchCause(() => Effect.succeed(true))); + }); for (const item of pendingBackgroundTurnItems({ turnItems: input.projection.turnItems, runs: input.projection.runs, @@ -8649,10 +8667,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } } for (const providerThread of input.projection.providerThreads) { - if ( - (providerThread.pendingBackgroundTasks?.length ?? 0) === 0 || - (yield* hasLiveSession(providerThread.id)) - ) { + if ((providerThread.pendingBackgroundTasks?.length ?? 0) === 0) continue; + if (providerThread.id === input.stoppedProviderThreadId) { + // A refused newer start can take ownership and reject the stopped + // turn's roster clear. Reconcile it without hiding surviving work. + if (yield* stillReportsBackgroundWork(providerThread)) continue; + } else if (yield* hasLiveSession(providerThread.id)) { continue; } yield* emitEvent({ From 284ce8fe2ff99076657707fd754e538403d88d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Far=C3=A8s=20SIONI?= <82713835+fsioni@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:28:07 +0200 Subject: [PATCH 133/898] fix(desktop): keep PDF preview reloads inside the guest (#21733) --- apps/desktop/src/preview/GuestInput.test.ts | 15 +++++++++++++++ apps/desktop/src/preview/GuestInput.ts | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index d607b217262f..7c7f499bc293 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -19,6 +19,8 @@ const makeGuest = ({ focusedFrame: focused ? {} : null, insertText: vi.fn(async () => {}), sendInputEvent: vi.fn(), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), }; return { webContents, @@ -34,6 +36,19 @@ const makeGuest = ({ }; describe("preview guest input", () => { + it.each([undefined, false, true])( + "reloads only the guest with ignoreCache=%s", + async (ignoreCache) => { + const guest = makeGuest({ focused: false }); + await expect( + guest.send("Page.reload", ignoreCache === undefined ? {} : { ignoreCache }), + ).resolves.toEqual({}); + expect(guest.webContents.reload).toHaveBeenCalledTimes(ignoreCache ? 0 : 1); + expect(guest.webContents.reloadIgnoringCache).toHaveBeenCalledTimes(ignoreCache ? 1 : 0); + expect(guest.sendCommand).not.toHaveBeenCalled(); + }, + ); + it("types into an unfocused guest's own page, not the app's focused field", async () => { const guest = makeGuest({ focused: false }); await guest.send("Input.insertText", { text: "pairing-code" }); diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 773d6b79873b..35006dfcec08 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -115,13 +115,19 @@ const evaluate = async (debuggee: Electron.Debugger, expression: string) => { return reply.result?.value; }; -/** Sends one command to the guest's page, keeping key and text input inside it. */ +/** Sends one command to the guest's page, keeping reloads and input inside it. */ export const sendPageCommand = async ( webContents: Electron.WebContents, debuggee: Electron.Debugger, method: string, params: Record, ): Promise => { + // Electron's debugger routes Page.reload on a webview to its embedder window. + if (method === "Page.reload") { + if (params["ignoreCache"] === true) webContents.reloadIgnoringCache(); + else webContents.reload(); + return {}; + } if (!FOCUS_ROUTED_INPUT.has(method) || webContents.focusedFrame) { return debuggee.sendCommand(method, params); } From 780b62dd26c4b897ec43b095911ff6659c48da3c Mon Sep 17 00:00:00 2001 From: Stephen Jason Wang Date: Tue, 20 Oct 2026 04:29:30 +0800 Subject: [PATCH 134/898] fix(usage): deduplicate native subscription accounts (#10700) Co-authored-by: Julius Marminge --- packages/shared/src/usageLimits.test.ts | 89 ++++++++++++++++++++++++- packages/shared/src/usageLimits.ts | 62 ++++++++++++----- 2 files changed, 133 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 7cc2962f664a..5c32792cde48 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1258,7 +1258,7 @@ describe("/usage-limits", () => { expect(report?.accounts[0]?.limits.resetCredits?.availableCount).toBe(3); }); - it("keeps accounts and custom instances separate, filtering by driver", () => { + it("keeps unidentified custom instances separate, filtering by driver", () => { const report = collectProviderUsageLimits( selected.instanceId, [ @@ -1299,6 +1299,93 @@ describe("/usage-limits", () => { expect(report?.notices).toEqual([]); }); + it("keeps the freshest usable native duplicate and the freshest native credits in either order", () => { + const failed = provider({ + auth: selected.auth, + usageLimits: { + ...limits, + checkedAt: "2026-09-03T11:40:00.000Z", + unavailable: { reason: "probeFailed" }, + }, + }); + const credited = provider({ + instanceId: ProviderInstanceId.make("codex-credit"), + auth: selected.auth, + usageLimits: { + checkedAt: "2026-09-03T11:20:00.000Z", + windows: [{ ...window, usedPercent: 50 }], + resetCredits: { availableCount: 2 }, + }, + }); + const staleCredit = provider({ + ...credited, + instanceId: ProviderInstanceId.make("codex-stale-credit"), + usageLimits: { ...limits, resetCredits: { availableCount: 3 } }, + }); + const freshest = provider({ + instanceId: ProviderInstanceId.make("codex-fresh"), + auth: selected.auth, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }); + const duplicates = [failed, staleCredit, credited, freshest]; + for (const providers of [duplicates, duplicates.toReversed()]) { + const report = collectProviderUsageLimits(failed.instanceId, providers, sources, now); + expect(report?.accounts.map((account) => account.id)).toEqual(["codex-fresh", "hub:oss"]); + expect(report?.accounts[0]).toMatchObject({ + limits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ usedPercent: 55 }], + resetCredits: { availableCount: 2 }, + }, + resetCreditInput: { instanceId: "codex-credit" }, + }); + expect(report?.accounts[0]?.limits.unavailable).toBeUndefined(); + } + }); + + it("merges native workspace duplicates only while the workspace evidence is unambiguous", () => { + const known = provider({ + ...selected, + instanceId: ProviderInstanceId.make("workspace-a"), + auth: { ...selected.auth, workspaceId: "ws-a" }, + }); + const fresh = provider({ + ...known, + instanceId: ProviderInstanceId.make("workspace-a-fresh"), + usageLimits: { ...limits, checkedAt: "2026-09-03T11:30:00.000Z" }, + }); + const natives = [known, fresh, selected]; + expect( + collectProviderUsageLimits(selected.instanceId, natives, [], now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["workspace-a-fresh"]); + + const unreadable = provider({ + instanceId: ProviderInstanceId.make("workspace-b"), + auth: { ...selected.auth, workspaceId: "ws-b" }, + }); + expect( + collectProviderUsageLimits( + selected.instanceId, + [...natives, unreadable], + [], + now, + )?.accounts.map((account) => account.id), + ).toEqual(["workspace-a-fresh", "codex"]); + expect( + collectProviderUsageLimits( + selected.instanceId, + [...natives, { ...unreadable, usageLimits: limits }], + [], + now, + )?.accounts.map((account) => account.id), + ).toEqual(["workspace-a-fresh", "codex", "workspace-b"]); + }); + it("supports a source-only provider and keeps duplicates when the native probe failed", () => { expect( collectProviderUsageLimits(selected.instanceId, [provider({})], sources, now)?.accounts.map( diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 2ced4094548d..9073fb5dfd58 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -662,25 +662,52 @@ export function collectProviderUsageLimits( ): UsageLimitsReport | null { const selected = providers.find((provider) => provider.instanceId === instanceId); if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; - const native = providersWithLimits(providers).filter( - (provider) => provider.driver === selected.driver, - ); const resolveKey = accountKeyResolver([ ...providers.filter((provider) => provider.enabled).map(nativeReport), ...sources.flatMap((source) => source.accounts), ]); - const nativeAccounts = new Set( - native.flatMap((provider) => { + type NativeSnapshot = { provider: ServerProvider; limits: ServerProviderUsageLimits }; + const nativeByAccount = new Map< + string, + { usage: NativeSnapshot; credits: NativeSnapshot | null } + >(); + for (const provider of providersWithLimits(providers)) { + if (provider.driver !== selected.driver || !provider.usageLimits) continue; + const snapshot = { provider, limits: provider.usageLimits }; + const key = resolveKey(nativeReport(provider)) ?? `instance:${provider.instanceId}`; + const previous = nativeByAccount.get(key); + const previousUsable = previous ? limitsNotice(previous.usage.limits) === null : false; + const usable = limitsNotice(snapshot.limits) === null; + const providerWins = + !previous || + (usable && !previousUsable) || + (usable === previousUsable && + Date.parse(snapshot.limits.checkedAt) > Date.parse(previous.usage.limits.checkedAt)); + const previousCredit = previous?.credits; + const credits = + snapshot.limits.resetCredits && + (!previousCredit || + Date.parse(snapshot.limits.checkedAt) > Date.parse(previousCredit.limits.checkedAt)) + ? snapshot + : (previousCredit ?? null); + nativeByAccount.set(key, { + usage: providerWins ? snapshot : previous.usage, + credits, + }); + } + const native = [...nativeByAccount.values()]; + const nativeAccountKeys = new Set( + native.flatMap(({ usage: { provider, limits } }) => { const key = resolveKey(nativeReport(provider)); - return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable - ? [key] - : []; + return key && limits.windows.length && !limits.unavailable ? [key] : []; }), ); const accounts: Array = []; const notices: string[] = []; - for (const provider of native) { - if (!provider.usageLimits) continue; + for (const { + usage: { provider, limits }, + credits, + } of native) { const key = resolveKey(nativeReport(provider)); const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) @@ -698,9 +725,12 @@ export function collectProviderUsageLimits( const hubCreditId = hubCredits?.account.usageLimits.resetCredits?.nextCreditId; const showHubCredits = hubCredits && - (!provider.usageLimits.resetCredits || + (!credits || Date.parse(hubCredits.account.usageLimits.checkedAt) > - Date.parse(provider.usageLimits.checkedAt)); + Date.parse(credits.limits.checkedAt)); + const shownCredits = showHubCredits + ? hubCredits.account.usageLimits.resetCredits + : credits?.limits.resetCredits; accounts.push({ id: provider.instanceId, driver: provider.driver, @@ -714,20 +744,18 @@ export function collectProviderUsageLimits( accountId: hubCredits.account.id, creditId: hubCreditId, } - : { instanceId: provider.instanceId }, + : { instanceId: credits?.provider.instanceId ?? provider.instanceId }, ...(provider.displayName ? { displayName: provider.displayName } : {}), ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), ...(provider.auth.email ? { email: provider.auth.email } : {}), - limits: showHubCredits - ? { ...provider.usageLimits, resetCredits: hubCredits.account.usageLimits.resetCredits } - : provider.usageLimits, + limits: shownCredits ? { ...limits, resetCredits: shownCredits } : limits, }); } for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { const key = resolveKey(account); - if (key && nativeAccounts.has(key)) continue; + if (key && nativeAccountKeys.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`, driver: account.driver, From 6eff4a9647f0f08d9e97f89e0546be3320cb056a Mon Sep 17 00:00:00 2001 From: ash Date: Mon, 19 Oct 2026 22:30:31 +0200 Subject: [PATCH 135/898] fix(web): show native coordinator status in member lineage (#18149) Co-authored-by: ashx-j <281260375+ashx-j@users.noreply.github.com> Co-authored-by: Julius Marminge --- ...lationshipsControl.agents.browser.test.tsx | 79 ++++++++++++++++++- .../chat/ThreadRelationshipsControl.tsx | 34 ++++++-- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx index ae09b4f639af..4efcb87b7b25 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx @@ -1,9 +1,11 @@ import { act, cloneElement, type ReactElement, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId, type ModelSelection, + type ScopedThreadRef, type ProviderOptionDescriptor, type OrchestrationV2ContextTransfer, } from "@t3tools/contracts"; @@ -15,6 +17,7 @@ import "~/index.css"; const state = vi.hoisted(() => ({ stopWorkflowLabel: "environment-data:orchestration:stop-workflow", projection: null as unknown, + projections: null as Map | null, navigate: vi.fn(), shells: [] as unknown[], projects: [] as unknown[], @@ -31,7 +34,14 @@ vi.mock(import("@tanstack/react-router"), async (original) => ({ })); vi.mock(import("../../state/entities"), async (original) => ({ ...(await original()), - useThreadProjection: (() => ({ projection: state.projection })) as never, + useThreadProjection: ((ref: ScopedThreadRef | null) => + ref === null + ? undefined + : { + projection: state.projections + ? state.projections.get(scopedThreadKey(ref)) + : state.projection, + }) as never, useThreadRelationshipShells: ((ref: { readonly environmentId: string }) => (state.shells as ReadonlyArray<{ readonly environmentId: string; readonly source: unknown }>) .filter((shell) => shell.environmentId === ref.environmentId) @@ -153,6 +163,7 @@ afterEach(async () => { state.workflowCommand.mockReset().mockResolvedValue({ _tag: "Success" }); state.toast.mockClear(); state.projection = null; + state.projections = null; }); it("stops the entire native workflow through its parent without changing state before acknowledgement", async () => { @@ -1188,6 +1199,72 @@ it("shows the parent's own visible status as parent and child activity change", expect(visibleText()).not.toContain("Running"); }); +it("shows the native coordinator status from its member and gives follow-up activity precedence", async () => { + const environmentId = EnvironmentId.make("remote"); + const coordinator = { + id: "coordinator", + title: "Workflow coordinator", + status: "idle", + creationSource: "provider", + activeProviderThreadId: null, + lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, + }; + const member = { + ...coordinator, + id: "member", + title: "Reviewer", + lineage: { parentThreadId: "coordinator", relationshipToParent: "subagent" }, + }; + const emptyProjection = { + runs: [], + nodes: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [], + }; + const rootTurn = { + kind: "root_turn", + runId: null, + status: "failed", + startedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"), + completedAt: DateTime.makeUnsafe("2026-09-16T12:00:10Z"), + }; + state.projections = new Map([ + ["remote:member", { ...emptyProjection, thread: member, nodes: [rootTurn] }], + ["local:coordinator", { ...emptyProjection, thread: coordinator, nodes: [rootTurn] }], + ]); + state.shells = [coordinator, member].map((source) => ({ environmentId, source })); + await render( + , + ); + const coordinatorText = () => + buttons().find((button) => button.textContent?.includes("Workflow coordinator"))?.textContent; + expect(coordinatorText()).toMatch(/Workflow coordinator\s*Idle$/); + + for (const [status, label] of [ + ["running", "Running"], + ["completed", "Done"], + ]) { + state.projections.set("remote:coordinator", { + ...emptyProjection, + thread: coordinator, + nodes: [ + { ...rootTurn, status, completedAt: status === "running" ? null : rootTurn.completedAt }, + ], + }); + await update(); + expect(coordinatorText()).toMatch(new RegExp(`Workflow coordinator\\s*${label}$`)); + } + + state.shells = [ + { environmentId, source: { ...coordinator, activityRunStatus: "running" } }, + { environmentId, source: member }, + ]; + await update(); + expect(coordinatorText()).toMatch(/Workflow coordinator\s*Running$/); +}); + it.each(["source", "target"])( "shows transfer lifecycle states when viewing the %s thread", async (currentThreadId) => { diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index b4688c45f571..d9755bf523d0 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -12,6 +12,7 @@ import { type RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; +import { deriveProviderSubagentStatus } from "@t3tools/client-runtime/state/thread-execution"; import { deriveThreadRelationshipGraph, immediateThreadRelationships, @@ -27,6 +28,7 @@ import { resolveLatestMergeBackRun, } from "@t3tools/client-runtime/state/thread-workflows"; import { + isProviderNativeSubagentThread, NodeId, type EnvironmentId, type OrchestrationV2Subagent, @@ -214,6 +216,26 @@ export function ThreadRelationshipsPanel(props: { const relationshipShells = useThreadRelationshipShells(ref); const allProjects = useProjects(); const archived = useArchivedThreadSnapshots([props.environmentId]); + const archivedShells = archived.snapshots.find( + (entry) => entry.environmentId === props.environmentId, + )?.snapshot.threads; + const currentThread = + projection?.thread ?? + relationshipShells.find((thread) => thread.id === props.threadId) ?? + archivedShells?.find((thread) => thread.id === props.threadId); + const parentThreadId = + currentThread?.forkedFrom?.type === "run" + ? currentThread.forkedFrom.threadId + : currentThread?.lineage.parentThreadId; + const parentShell = parentThreadId + ? (relationshipShells.find((thread) => thread.id === parentThreadId) ?? + archivedShells?.find((thread) => thread.id === parentThreadId)) + : undefined; + const parentRef = + parentShell && isProviderNativeSubagentThread(parentShell) + ? scopeThreadRef(props.environmentId, parentShell.id) + : null; + const parentProjection = useThreadProjection(parentRef)?.projection; const navigate = useNavigate(); const mergeBack = useAtomCommand(threadEnvironment.mergeBack); const stopSession = useAtomCommand(threadEnvironment.stopSession); @@ -228,9 +250,9 @@ export function ThreadRelationshipsPanel(props: { // the panel's hooks where the compiler can cache them. const providers = providersByEnvironment.get(props.environmentId); const projects = allProjects.filter((project) => project.environmentId === props.environmentId); - const archivedShells = archived.snapshots.find( - (entry) => entry.environmentId === props.environmentId, - )?.snapshot.threads; + const parentNativeStatus = parentProjection + ? deriveProviderSubagentStatus(parentProjection)?.status + : undefined; const projected = projection?.subagents ?? []; const runtimeSubagents = projectedSubagentsToRuntime(projected); const byId = new Map(runtimeSubagents.map((agent) => [agent.id, agent])); @@ -261,7 +283,6 @@ export function ThreadRelationshipsPanel(props: { ...(archivedShells ?? []), ]; const graph = deriveThreadRelationshipGraph({ threads: shells, projection }); - const currentThread = projection?.thread ?? graph.nodes.get(props.threadId)?.thread; const currentProject = projects.find((project) => project.id === currentThread?.projectId); const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); @@ -418,7 +439,10 @@ export function ThreadRelationshipsPanel(props: { const isSubagent = edge.kind === "subagent"; const isMergeTarget = threadId === mergeTargetThreadId; const isParent = isParentThreadRelationship(edge, props.threadId); - const rowStatus = threadRelationshipRowStatus(graph, { threadId, edge }); + const rowStatus = + (isParent && threadId === parentRef?.threadId + ? (node?.thread?.activityRunStatus ?? parentNativeStatus) + : undefined) ?? threadRelationshipRowStatus(graph, { threadId, edge }); const RelationshipIcon = isParent ? CornerLeftUpIcon : isSubagent From 325a7e2ae4bf504cdaeb7b842e88b47f60b4bf59 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 19 Oct 2026 13:32:06 -0700 Subject: [PATCH 136/898] fix(mobile): the split-view sidebar no longer crashes in an update loop (#21867) --- .../layout/AdaptiveWorkspaceLayout.tsx | 70 ++++++++++++++----- .../threads/ThreadNavigationSidebar.tsx | 45 +++++++----- 2 files changed, 80 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 82bd286cbdbd..40b0e6e69696 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -17,6 +17,7 @@ import { use, useEffect, useEffectEvent, + useMemo, useRef, useState, type ReactNode, @@ -247,7 +248,14 @@ function AdaptiveWorkspaceLayoutContent( const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = useState(null); - const baseLayout = deriveLayout({ width: viewportWidth, height, nativeMetrics }); + // The compiler can't memoize these derivations: they stay live across later hook calls. Fresh + // objects here change the workspace context on every render, so every workspace screen + // re-renders on any layout state change, and the inspector registration then loops: each + // re-register re-renders its screen, which hands over a new inspector renderer. + const baseLayout = useMemo( + () => deriveLayout({ width: viewportWidth, height, nativeMetrics }), + [height, viewportWidth, nativeMetrics], + ); const primarySidebarPinned = isPrimarySidebarPinned({ pathname, nativeWorkspace, @@ -260,17 +268,30 @@ function AdaptiveWorkspaceLayoutContent( setHinge(next); setWindowHinge(nextWindow); }; - const layout = alignListPaneToHinge({ layout: baseLayout, hinge, viewportWidth }); + const layout = useMemo( + () => alignListPaneToHinge({ layout: baseLayout, hinge, viewportWidth }), + [baseLayout, hinge, viewportWidth], + ); const shouldRenderPrimarySidebar = layout.usesSplitView; - const fileInspector = deriveFileInspectorPaneLayout({ - layout, - viewportWidth, - preferredWidth: fileInspectorPreferredWidth ?? undefined, - reservedLeadingWidth: - shouldRenderPrimarySidebar && showPrimarySidebar - ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) - : 0, - }); + const fileInspector = useMemo( + () => + deriveFileInspectorPaneLayout({ + layout, + viewportWidth, + preferredWidth: fileInspectorPreferredWidth ?? undefined, + reservedLeadingWidth: + shouldRenderPrimarySidebar && showPrimarySidebar + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0, + }), + [ + fileInspectorPreferredWidth, + layout, + showPrimarySidebar, + shouldRenderPrimarySidebar, + viewportWidth, + ], + ); const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); const auxiliaryPanePreferredVisible = @@ -281,14 +302,25 @@ function AdaptiveWorkspaceLayoutContent( auxiliaryPaneRole === "inspector" ? fileInspectorPreferredWidth : supplementaryPanePreferredWidth; - const panes = deriveWorkspacePaneLayout({ - layout, - viewportWidth, - primarySidebarPreferredVisible: showPrimarySidebar, - auxiliaryPanePreferredVisible, - auxiliaryPaneRole, - auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, - }); + const panes = useMemo( + () => + deriveWorkspacePaneLayout({ + layout, + viewportWidth, + primarySidebarPreferredVisible: showPrimarySidebar, + auxiliaryPanePreferredVisible, + auxiliaryPaneRole, + auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, + }), + [ + auxiliaryPanePreferredVisible, + auxiliaryPaneRole, + auxiliaryPanePreferredWidth, + layout, + showPrimarySidebar, + viewportWidth, + ], + ); const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d9cf141d2f98..a588120b0dc4 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -13,7 +13,7 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent, TextInputInstance } from "react-native"; import { Platform, StyleSheet, TextInput, View } from "react-native"; import { GestureDetector, useNativeGesture } from "react-native-gesture-handler"; @@ -180,7 +180,9 @@ function ThreadNavigationSidebarPane( ? [options.selectedEnvironmentId] : []; const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); - const threadSearchMatchByKey = (() => { + // These maps reach LegendList's extraData, which re-renders every row whenever it changes + // identity. The compiler leaves them unmemoized because they stay live across later hook calls. + const threadSearchMatchByKey = useMemo(() => { const matches = new Map(); for (const match of threadSearch.matches) { if (match.source === "user" || match.source === "assistant") { @@ -188,7 +190,7 @@ function ThreadNavigationSidebarPane( } } return matches; - })(); + }, [threadSearch.matches]); const matchedThreadKeys = new Set(threadSearch.matches.map(threadSearchMatchKey)); const projectScopeKey = options.selectedProjectKey; const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { @@ -200,22 +202,33 @@ function ThreadNavigationSidebarPane( setSelectedProjectKey(resolvedProjectKey); } }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]); - const projectScopes = buildHomeProjectScopes({ - projects, - environmentId: options.selectedEnvironmentId, - projectGroupingMode: options.projectGroupingMode, - }); + const projectScopes = useMemo( + () => + buildHomeProjectScopes({ + projects, + environmentId: options.selectedEnvironmentId, + projectGroupingMode: options.projectGroupingMode, + }), + [projects, options.selectedEnvironmentId, options.projectGroupingMode], + ); const projectFilterOptions = projectScopes.map((scope) => ({ key: scope.key, label: scope.title, })); - const projectTitleByProjectKey = new Map( - projectScopes.flatMap((scope) => - scope.projectRefs.map( - (projectRef) => - [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.title] as const, + const projectTitleByProjectKey = useMemo( + () => + new Map( + projectScopes.flatMap((scope) => + scope.projectRefs.map( + (projectRef) => + [ + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + scope.title, + ] as const, + ), + ), ), - ), + [projectScopes], ); const selectedProjectScope = projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null; @@ -228,13 +241,13 @@ function ThreadNavigationSidebarPane( scopedProjectKey(projectRef.environmentId, projectRef.projectId), ), ); - const projectByKey = (() => { + const projectByKey = useMemo(() => { const map = new Map(); for (const project of projects) { map.set(scopedProjectKey(project.environmentId, project.id), project); } return map; - })(); + }, [projects]); const [settledVisibleCount, setSettledVisibleCount] = useState( THREAD_LIST_V2_SETTLED_INITIAL_COUNT, From f0fb0e8525fca72f0259c7b44a2b15aafde52a31 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 13:50:46 -0700 Subject: [PATCH 137/898] test(web): pass openInShortcutEnabled in ThreadDetailsPanel browser test (#21912) Co-authored-by: Claude Fable 5.1 --- .../web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx index 60bc23b71e42..b36dc779935c 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx @@ -244,6 +244,7 @@ it("keeps workspace controls after expanding and collapsing 70 linked pull reque keybindings={[]} availableEditors={[]} showOpenInPicker + openInShortcutEnabled={false} gitCwd="/tmp/project" isGitRepo envLocked={false} From 0d9668c6274a9842c9fa463369e99e4138816e4b Mon Sep 17 00:00:00 2001 From: Abhijeet Singh Date: Tue, 20 Oct 2026 02:39:51 +0530 Subject: [PATCH 138/898] fix(web): empty diff panel no longer flashes a loading skeleton on every refresh (#17886) Co-authored-by: Julius Marminge --- apps/web/src/components/DiffPanel.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index b4083eac66b1..ed05d56deea9 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -352,9 +352,8 @@ export default function DiffPanel({ const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff; const isSelectedPatchTruncated = !selectedTurn && selectedGitSource?.truncated === true; - const isLoadingSelectedPatch = selectedTurn - ? activeCheckpointDiff.isPending - : branchDiffPreview.isPending; + const selectedPatchQuery = selectedTurn ? activeCheckpointDiff : branchDiffPreview; + const isLoadingSelectedPatch = selectedPatchQuery.isPending && selectedPatchQuery.data === null; const selectedPatchError = selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; From 4131e6d77685169e57ef7ff9f6437e482e578a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 19 Oct 2026 23:09:56 +0200 Subject: [PATCH 139/898] fix(web): composer context strip no longer counts its padding as free space (#13642) Co-authored-by: Julius Marminge --- apps/web/src/components/BranchToolbar.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 53d320192f3a..f546658a377a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -426,8 +426,13 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { const current = element; const compact = overflows; if (!current) return; - const available = current.clientWidth; - if (available === 0) return; + const stripWidth = current.clientWidth; + if (stripWidth === 0) return; + const stripStyle = getComputedStyle(current); + const available = + stripWidth - + (Number.parseFloat(stripStyle.paddingInlineStart) || 0) - + (Number.parseFloat(stripStyle.paddingInlineEnd) || 0); const contentWidth = (parent: Element): number => { const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; let width = 0; @@ -446,7 +451,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { } return width + gap * Math.max(0, counted - 1); }; - const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; + const stripGap = Number.parseFloat(stripStyle.columnGap) || 0; let needed = 0; let groups = 0; for (const child of current.children) { From 844a9812a0015ecc998b6fbb2815847f088215d3 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Mon, 19 Oct 2026 18:10:01 -0300 Subject: [PATCH 140/898] fix(web): default-branch confirmation keeps its copy while closing (#16907) Co-authored-by: Julius Marminge --- apps/web/src/components/GitActionsControl.tsx | 41 ++++++++----------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1b60f7179d3f..23fe91ce6b0f 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1008,6 +1008,8 @@ export default function GitActionsControl({ const [inlineSuccess, setInlineSuccess] = useState(null); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); + // Keep the payload until the dialog finishes closing. + const [isDefaultBranchDialogOpen, setIsDefaultBranchDialogOpen] = useState(false); const sourceControlScope = { environmentId: activeEnvironmentId, cwd: gitCwd }; const vcsActionState = useAtomValue(vcsActionManager.stateAtom(sourceControlScope)); const visibleInlineSuccess = inlineSuccess?.scopeKey === successScopeKey ? inlineSuccess : null; @@ -1250,6 +1252,7 @@ export default function GitActionsControl({ ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), }); + setIsDefaultBranchDialogOpen(true); return; } onConfirmed?.(); @@ -1359,29 +1362,18 @@ export default function GitActionsControl({ } } - const continuePendingDefaultBranchAction = () => { - if (!pendingDefaultBranchAction) return; - const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; - setPendingDefaultBranchAction(null); - void runGitActionWithToast({ - action, - ...(commitMessage ? { commitMessage } : {}), - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - skipDefaultBranchPrompt: true, - }); - }; - - const checkoutFeatureBranchAndContinuePendingAction = () => { - if (!pendingDefaultBranchAction) return; + const continuePendingDefaultBranchAction = (featureBranch: boolean) => { + if (!isDefaultBranchDialogOpen || !pendingDefaultBranchAction || !canWriteSourceControl) return; + if (featureBranch && (!canChangeThreadBranch || featureBranchUnavailableReason !== null)) + return; const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; - setPendingDefaultBranchAction(null); + setIsDefaultBranchDialogOpen(false); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), - featureBranch: true, + featureBranch, skipDefaultBranchPrompt: true, }); }; @@ -2041,11 +2033,10 @@ export default function GitActionsControl({ /> { - if (!open) { - setPendingDefaultBranchAction(null); - } + open={isDefaultBranchDialogOpen} + onOpenChange={setIsDefaultBranchDialogOpen} + onOpenChangeComplete={(open) => { + if (!open) setPendingDefaultBranchAction(null); }} > @@ -2063,7 +2054,7 @@ export default function GitActionsControl({ className="w-full sm:mr-auto sm:w-auto" variant="outline" size="sm" - onClick={() => setPendingDefaultBranchAction(null)} + onClick={() => setIsDefaultBranchDialogOpen(false)} > Abort @@ -2071,7 +2062,7 @@ export default function GitActionsControl({ className="w-full max-w-full sm:w-auto" variant="outline" size="sm-multiline" - onClick={continuePendingDefaultBranchAction} + onClick={() => continuePendingDefaultBranchAction(false)} disabled={!canWriteSourceControl} > {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} @@ -2079,7 +2070,7 @@ export default function GitActionsControl({ + + + + , + ); + }); +} + +function element(selector: string) { + const node = container.querySelector(selector); + if (!node) throw new Error(`Missing element: ${selector}`); + return node; +} + describe("responsive sidebar state", () => { it("uses mobile sheet visibility for the shared responsive state", () => { expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe( @@ -13,3 +61,35 @@ describe("responsive sidebar state", () => { ); }); }); + +describe("desktop sidebar focus", () => { + it("moves focus to the external toggle on collapse and restores sidebar access on expansion", async () => { + await renderSidebar(true); + const control = element('[data-testid="sidebar-control"]'); + const sidebar = element('[data-slot="sidebar"]'); + control.focus(); + expect(document.activeElement).toBe(control); + + await renderSidebar(false); + + expect(document.activeElement).toBe(element('[data-testid="external-trigger"]')); + expect(sidebar.hasAttribute("inert")).toBe(true); + + await renderSidebar(true); + + expect(sidebar.hasAttribute("inert")).toBe(false); + control.focus(); + expect(document.activeElement).toBe(control); + }); + + it("keeps focus in the content when the sidebar collapses", async () => { + await renderSidebar(true); + const control = element('[data-testid="content-control"]'); + control.focus(); + expect(document.activeElement).toBe(control); + + await renderSidebar(false); + + expect(document.activeElement).toBe(control); + }); +}); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 01b52062714d..570e8299694f 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -215,7 +215,24 @@ function Sidebar({ resizable?: boolean | SidebarResizableOptions; }) { const { isMobile, state, openMobile, setOpenMobile, setMobileSheetElement } = useSidebar(); - // The rail's width restore and drag refresh key on this value. + const desktopSidebarRef = React.useRef(null); + const offcanvasCollapsed = !isMobile && collapsible === "offcanvas" && state === "collapsed"; + + React.useLayoutEffect(() => { + const sidebar = desktopSidebarRef.current; + if (!sidebar) return; + + // Transfer focus before inert causes the browser to clear the focused descendant. + if (offcanvasCollapsed && sidebar.contains(document.activeElement)) { + const wrapper = sidebar.closest('[data-slot="sidebar-wrapper"]'); + const trigger = Array.from( + wrapper?.querySelectorAll('[data-sidebar="trigger"]') ?? [], + ).find((element) => !sidebar.contains(element)); + trigger?.focus({ preventScroll: true }); + } + sidebar.toggleAttribute("inert", offcanvasCollapsed); + }, [offcanvasCollapsed]); + const resolvedResizable = resolveSidebarResizable({ collapsible, isMobile, resizable }); const instanceContextValue: SidebarInstanceContextProps = { side, resizable: resolvedResizable }; @@ -280,6 +297,7 @@ function Sidebar({ return (
Date: Mon, 19 Oct 2026 22:10:24 +0100 Subject: [PATCH 143/898] fix(server): storage cleanup finds the default branch when the remote has no HEAD ref (#17909) Co-authored-by: Julius Marminge --- apps/server/src/storageCleanup.test.ts | 77 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 26 ++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c99a0fa29eda..fe2c8b86fb20 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1567,6 +1567,83 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live( + "discovers trunk without a remote HEAD or tracking ref and removes an integrated worktree", + () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeUnchanged: true, + }); + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); + yield* command(remote, ["init", "--bare", "-b", "trunk"]); + yield* command(repo, ["remote", "add", "origin", remote]); + yield* command(repo, ["push", "origin", "main:trunk"]); + yield* command(repo, ["update-ref", "-d", "refs/remotes/origin/trunk"]); + expect((yield* command(repo, ["for-each-ref", "refs/remotes/origin"])).stdout).toBe(""); + + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ + outcome: "removed", + reason: "No commits beyond the default branch", + }); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); + it.live( + "keeps worktree commits present on nondefault main but absent from advertised trunk", + () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeUnchanged: true, + }); + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); + yield* command(remote, ["init", "--bare", "-b", "trunk"]); + yield* command(repo, ["remote", "add", "origin", remote]); + yield* command(repo, ["push", "origin", "main:trunk"]); + yield* fs.writeFileString(`${worktree}/tracked.txt`, "unmerged work\n"); + yield* command(worktree, ["commit", "-am", "unmerged work"]); + yield* command(repo, ["push", "origin", "feature:main"]); + expect( + (yield* command(repo, ["symbolic-ref", "refs/remotes/origin/HEAD"]).pipe(Effect.exit)) + ._tag, + ).toBe("Failure"); + + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Has commits beyond the default branch", + }); + expect(yield* fs.readFileString(`${worktree}/tracked.txt`)).toBe("unmerged work\n"); + }), + ), + ); + it.live("keeps the worktree when the remote default is unavailable even if main exists", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeUnchanged: true, + }); + const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); + yield* command(remote, ["init", "--bare", "-b", "trunk"]); + yield* command(repo, ["remote", "add", "origin", remote]); + // The remote HEAD names an unborn trunk; main exists but is not its default. + yield* command(repo, ["push", "origin", "main"]); + + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Default branch is unavailable", + }); + expect(yield* fs.exists(worktree)).toBe(true); + }), + ), + ); it.live("preserves tracked edits written after the final status check", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 16d2905ba04c..74d24ffa2117 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -592,6 +592,7 @@ const make = Effect.gen(function* () { )).filter((thread) => thread.worktreePath !== null && thread.branch !== null); const snapshot = yield* readThreads(); const refreshedDefaultRefs = new Map>(); + const defaultBranches = new Map(); const groups = Map.groupBy( snapshot.threads.filter((thread) => thread.worktreePath !== null), (thread) => path.resolve(thread.worktreePath!), @@ -684,7 +685,30 @@ const make = Effect.gen(function* () { if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); - const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); + const defaultBranchKey = `${repositoryCwd}\0${remote}`; + let branch = defaultBranches.get(defaultBranchKey); + if (branch === undefined) { + branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); + // Without a local HEAD ref, only the remote can identify its default; + // a conventional name may be a feature branch. + if (branch === null) { + branch = yield* git + .execute({ + operation: "StorageCleanup.resolveDefaultBranch", + cwd: repositoryCwd, + args: ["ls-remote", "--symref", "--", remote, "HEAD"], + timeoutMs: 20_000, + }) + .pipe( + Effect.map( + (result) => + /^ref: refs\/heads\/(.+)\tHEAD\r?$/m.exec(result.stdout)?.[1] ?? null, + ), + Effect.orElseSucceed(() => null), + ); + } + defaultBranches.set(defaultBranchKey, branch); + } if (branch === null) return keep("default branch is unavailable"); const defaultRef = `refs/remotes/${remote}/${branch}`; const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set(); From 5cf38542aff1d92b5ffc97b8345ef99ac55906d6 Mon Sep 17 00:00:00 2001 From: Aras <72440556+itsmeares@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:10:28 +0100 Subject: [PATCH 144/898] fix(desktop): restore missing Start Menu shortcut (#7829) Co-authored-by: Julius Marminge --- apps/desktop/resources/installer.nsh | 10 ++++++++++ scripts/build-desktop-artifact.test.ts | 5 ++++- scripts/build-desktop-artifact.ts | 5 ++++- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/resources/installer.nsh diff --git a/apps/desktop/resources/installer.nsh b/apps/desktop/resources/installer.nsh new file mode 100644 index 000000000000..092f641c3359 --- /dev/null +++ b/apps/desktop/resources/installer.nsh @@ -0,0 +1,10 @@ +!macro customInstall + !ifndef DO_NOT_CREATE_START_MENU_SHORTCUT + ${IfNot} ${FileExists} "$newStartMenuLink" + !insertmacro createMenuDirectory + CreateShortCut "$newStartMenuLink" "$appExe" "" "$appExe" 0 "" "" "${APP_DESCRIPTION}" + ClearErrors + WinShell::SetLnkAUMI "$newStartMenuLink" "${APP_ID}" + ${EndIf} + !endif +!macroend diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 8a0dbc4faa8b..c8faf49221af 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -687,7 +687,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ...DESKTOP_EXTRA_RESOURCES, ...WINDOWS_SERVER_EXTRA_RESOURCES, ]); - assert.deepStrictEqual(win.nsis, { differentialPackage: true }); + assert.deepStrictEqual(win.nsis, { + differentialPackage: true, + include: "apps/desktop/resources/installer.nsh", + }); assert.deepStrictEqual(WINDOWS_SERVER_ASAR_IGNORE_GLOBS, [ "**/node_modules/@cursor/sdk-*", "**/node_modules/@cursor/sdk-*/**", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index e58e15aeb3ee..5688143ebc5f 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2860,7 +2860,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( if (platform === "win") { buildConfig.npmRebuild = false; - buildConfig.nsis = { differentialPackage: true }; + buildConfig.nsis = { + differentialPackage: true, + include: "apps/desktop/resources/installer.nsh", + }; const winConfig: Record = { target: [target], icon: "icon.ico", From fcab788163e7ad859bfb93ba584f6f8c77178164 Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 19 Oct 2026 21:10:35 +0000 Subject: [PATCH 145/898] fix(desktop): handle macos command-plus zoom input (#18439) Co-authored-by: Julius Marminge --- .../src/window/DesktopApplicationMenu.test.ts | 16 ++++++++++++---- .../desktop/src/window/DesktopApplicationMenu.ts | 10 ++++++++++ apps/desktop/src/window/DesktopWindow.test.ts | 5 ++++- apps/desktop/src/window/DesktopWindow.ts | 2 +- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index fa3960a34ee5..a07a24a66c21 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -247,13 +247,13 @@ describe("DesktopApplicationMenu", () => { }), ); - it.effect("routes View menu zoom to the main window instead of zoom roles", () => + it.effect("registers macOS command-plus once and routes it through main-window zoom", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); const applicationMenuTemplate = yield* Deferred.make(); - yield* configureMenu(selectedAction, applicationMenuTemplate); + yield* configureMenu(selectedAction, applicationMenuTemplate, { platform: "darwin" }); const template = yield* Deferred.await(applicationMenuTemplate); const viewMenu = template.find((item) => item.label === "View"); @@ -268,9 +268,17 @@ describe("DesktopApplicationMenu", () => { ), ); - const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In"); + const shiftedEqual = viewMenu.submenu.filter( + (item) => item.accelerator === "CmdOrCtrl+Shift+=", + ); + assert.lengthOf(shiftedEqual, 1); + assert.lengthOf( + viewMenu.submenu.filter((item) => item.accelerator === "CmdOrCtrl+numadd"), + 1, + ); + const zoomIn = shiftedEqual[0]; assert.isDefined(zoomIn); - assert.equal(zoomIn.accelerator, "CmdOrCtrl+="); + assert.isFalse(zoomIn.visible); if (typeof zoomIn.click !== "function") { throw new Error("Expected Zoom In menu item to have a click handler."); } diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index c51e13e58673..aeaca8350709 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -263,6 +263,16 @@ export const make = Effect.gen(function* () { visible: false, click: zoomClick("in"), }, + ...(environment.platform === "darwin" + ? [ + { + label: "Zoom In", + accelerator: "CmdOrCtrl+Shift+=", + visible: false, + click: zoomClick("in"), + }, + ] + : []), { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, // The numeric keypad sends its own key codes, so the number-row // accelerators above never match it. diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ab59f63e1ad7..56206a227963 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -774,9 +774,10 @@ 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", () => + it.effect("zooms main with a focused popup and reapplies preview zoom", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); + const popup = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const previewZoomReapplies: number[] = []; @@ -785,6 +786,7 @@ describe("DesktopWindow", () => { createCount, mainWindow, previewZoomReapplies, + focusedWindow: popup.window, }); yield* Effect.gen(function* () { @@ -801,6 +803,7 @@ describe("DesktopWindow", () => { [-0.5, -1, -0.5, 0], ); assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); + assert.equal(popup.setZoomLevel.mock.calls.length, 0); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3926cda14ece..5d14fdfdc53f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1026,7 +1026,7 @@ export const make = Effect.gen(function* () { }), zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) { yield* Effect.annotateCurrentSpan({ direction }); - const window = yield* focusedMainWindow; + const window = yield* electronWindow.main; if (Option.isNone(window) || window.value.isDestroyed()) { return; } From 9a648d44db22f6c7e87fc4b7bea5d57a09a80a82 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 19 Oct 2026 14:10:41 -0700 Subject: [PATCH 146/898] fix(android): space the New thread FAB 16dp from the edge and tab bar (#18578) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5.1 --- .../src/features/home/AndroidHomeFab.android.tsx | 13 ++++++------- .../src/features/home/AndroidHomeFab.shared.tsx | 12 ++++++++++++ apps/mobile/src/features/home/ThreadListToolbar.tsx | 11 ++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index d8f4a218d6b8..e8a29ce516c6 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -7,7 +7,10 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { NewThreadButton } from "../../components/NewThreadButton"; -import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared"; +import { + androidHomeFabPosition, + type AndroidHomeFabLayout as SharedAndroidHomeFabLayout, +} from "./AndroidHomeFab.shared"; import { useWorkspaceState } from "../../state/workspace"; import { FabScrollContext } from "./FabScrollContext"; import { updateFabScroll } from "./fab-scroll"; @@ -42,12 +45,8 @@ export function AndroidHomeFabLayout(props: ComponentProps ) : null} diff --git a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx index 682bbd459147..5226fce97c2f 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx @@ -8,3 +8,15 @@ export function AndroidHomeFabLayout(props: { }) { return <>{props.children}; } + +/** + * The filter button stacks on the New thread FAB from this same origin so the two + * cannot drift apart. In dp rather than a rem class so Material's 16dp margin does + * not grow with the app's text size. + */ +export function androidHomeFabPosition(safeAreaBottom: number, sidebar: boolean | undefined) { + return { + right: 16, + bottom: sidebar ? Math.max(safeAreaBottom, 12) + 6 : safeAreaBottom + 16, + }; +} diff --git a/apps/mobile/src/features/home/ThreadListToolbar.tsx b/apps/mobile/src/features/home/ThreadListToolbar.tsx index 14b50e036b8c..974f29b83b6b 100644 --- a/apps/mobile/src/features/home/ThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/ThreadListToolbar.tsx @@ -24,6 +24,7 @@ import { useWorkspaceState } from "../../state/workspace"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; +import { androidHomeFabPosition } from "./AndroidHomeFab.shared"; /** One toolbar height for the compact list and expanded sidebar, including search. */ export function ThreadListToolbar(props: { @@ -46,6 +47,7 @@ export function ThreadListToolbar(props: { const listFocused = homeFocused && (workspace === null || workspace.state.selectedTab === "threads"); const { fabSize } = useAndroidControlSizing(); + const fab = androidHomeFabPosition(insets.bottom, props.sidebar); const { height: toolbarHeight, ...headerPadding } = useToolbarLayout(); const { state } = useWorkspaceState(); const { onRequestVisibility, onSearchQueryChange } = props; @@ -150,13 +152,8 @@ export function ThreadListToolbar(props: { {state.hasConnections ? ( {(open) => ( From 8de7e3502c0d76a478119aa03a1914c94e014751 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:35 -0300 Subject: [PATCH 147/898] fix(server): keep worktree creation working when git config is locked (#11773) Co-authored-by: Julius Marminge --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 38 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 9 ++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7819dec2f363..75d5335f1b7e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3816,6 +3816,44 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("still creates the worktree when the base-ref config is locked", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "locked-config"); + yield* writeTextFile(cwd, ".git/config.lock", ""); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/locked-config", + baseRefName: initialBranch, + }); + + assert.deepEqual(created.worktree, { + path: worktreePath, + refName: "feature/locked-config", + }); + assert.equal( + yield* git(worktreePath, ["branch", "--show-current"]), + "feature/locked-config", + ); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "README.md")), + "# test\n", + ); + assert.equal( + yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.gh-merge-base"), + null, + ); + assert.equal(yield* fileSystem.exists(pathService.join(cwd, ".git/config.lock")), true); + }), + ); + it.effect("reports checkout progress during parallel worktree creation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 880226675cee..754dd535a825 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3601,7 +3601,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "config", `branch.${input.newRefName}.gh-merge-base`, baseBranch, - ]); + ]).pipe( + Effect.catchTags({ + GitCommandError: () => + Effect.logWarning( + "worktree base-ref configuration failed; using base-branch fallback", + ), + }), + ); } } From a195ba0809b5c1f16fad8119f3b961119924d1b2 Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Tue, 20 Oct 2026 10:13:26 +1300 Subject: [PATCH 148/898] fix(server): recognize partial-clone remote annotations (#16316) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge --- .../RepositoryIdentityResolver.test.ts | 24 ++++++++++++++ .../src/project/RepositoryIdentityResolver.ts | 3 +- apps/server/src/vcs/GitVcsDriver.test.ts | 32 +++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 3 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 24 ++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 3 +- .../src/server/ForgejoCli.ts | 5 ++- .../ForgejoSourceControlProvider.test.ts | 4 ++- .../server/gitHubRepositoryResolution.test.ts | 5 +++ .../src/server/gitHubRepositoryResolution.ts | 3 +- 10 files changed, 100 insertions(+), 6 deletions(-) diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 5f4e60f01302..6893b360c9f3 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -203,6 +203,30 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); + it.effect("resolves the fetch remote of a partial clone", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-partial-clone-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + yield* git(cwd, ["remote", "set-url", "--push", "origin", "git@github.com:T3Tools/push.git"]); + yield* git(cwd, ["config", "remote.origin.promisor", "true"]); + yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + + const remotes = yield* git(cwd, ["remote", "-v"]); + expect(remotes.stdout).toContain("(fetch) [blob:none]"); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.locator.remoteUrl).toBe("git@github.com:T3Tools/t3code.git"); + expect(identity?.locator.remoteName).toBe("origin"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + it.effect("returns the git top-level root path when resolving from a nested workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 80d5b39823ab..8447509b954f 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -58,7 +58,8 @@ function parseRemoteFetchUrls(stdout: string): Map { for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if ( diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 83587e65134b..9ac83d1dd9df 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -9,6 +9,7 @@ import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/process"; @@ -97,6 +98,37 @@ runVcsDriverContractSuite({ }, }); +it.effect("lists the fetch and push URLs of a partial clone remote", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-partial-clone-remotes-", + }); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + yield* runGit(cwd, ["init"]); + yield* runGit(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); + yield* runGit(cwd, [ + "remote", + "set-url", + "--push", + "origin", + "git@github.com:octocat/t3code.git", + ]); + yield* runGit(cwd, ["config", "remote.origin.promisor", "true"]); + yield* runGit(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + + const result = yield* driver.listRemotes(cwd); + assert.deepEqual(result.remotes, [ + { + name: "origin", + url: "https://github.com/pingdotgg/t3code.git", + pushUrl: Option.some("git@github.com:octocat/t3code.git"), + isPrimary: true, + }, + ]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( driver: Effect.Success>, cwd: string, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f08e250d58d6..8a780150f63d 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -466,7 +466,8 @@ function parseGitRemoteVerboseOutput( continue; } - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); if (!match) { continue; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 75d5335f1b7e..85670aa7ffb5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4054,6 +4054,30 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + it.effect("ensureRemote reuses a partial clone remote and avoids its name", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); + yield* git(cwd, ["config", "remote.origin.promisor", "true"]); + yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const reused = yield* driver.ensureRemote({ + cwd, + preferredName: "origin", + url: "git@github.com:pingdotgg/t3code.git", + }); + assert.equal(reused, "origin"); + const added = yield* driver.ensureRemote({ + cwd, + preferredName: "origin", + url: "https://github.com/another-owner/t3code.git", + }); + assert.equal(added, "origin-1"); + }), + ); + it.effect("explains a real fetch failure for a missing local remote", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 754dd535a825..219467c29537 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -334,7 +334,8 @@ function parseRemoteFetchUrls(stdout: string): Map { for (const line of stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { diff --git a/packages/source-control-forgejo/src/server/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts index 81e96f61fa9f..2df2a8ab5b44 100644 --- a/packages/source-control-forgejo/src/server/ForgejoCli.ts +++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts @@ -515,7 +515,10 @@ export const make = Effect.gen(function* () { const matchingUrls = [ ...new Set( result.stdout.split("\n").flatMap((line) => { - const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1]; + // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. + const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)(?:\s+\[[^\]]+\])?$/.exec( + line.trim(), + )?.[1]; return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase() ? [url] : []; diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index 354edbb981e4..a6a453c3f327 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -1046,7 +1046,9 @@ it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { input.cwd === "/no-remotes" ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }) : processOutput( - `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`, + input.cwd === "/upstream-only" + ? "upstream\thttp://forgejo.local:3000/maria/project.git (fetch) [blob:none]\nother\thttp://forgejo.local:3000/maria/other.git (fetch) [blob:none]\nunrelated\thttp://other.local:3000/maria/project.git (fetch) [blob:none]" + : "origin\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)", ), ); assert.strictEqual(input.command, "fj"); diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index 7c6f754192bb..c78c1fd7612f 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -31,6 +31,11 @@ describe("selectGitHubBaseRepository", () => { select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), { owner: "acme", name: "other" }, ); + const partialClone = fork.replaceAll("(fetch)", "(fetch) [blob:none]"); + assert.deepStrictEqual( + select({ remotes: partialClone, resolved: "remote.origin.gh-resolved base" }), + { owner: "me", name: "web" }, + ); assert.deepStrictEqual( select({ remotes: remotes( diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index bd36758605e1..d9e2e9743e16 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -13,9 +13,10 @@ export interface GitHubRepositoryLocator { export function parseFetchRemotes( remotes: string, ): ReadonlyArray<{ readonly name: string; readonly url: string }> { + // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. return remotes .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) + .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)(?:\s+\[[^\]]+\])?$/u.exec(line.trim())) .filter((match): match is RegExpExecArray => match !== null) .map((match) => ({ name: match[1]!, url: match[2]! })); } From 27b708542ccb3e8346e8a84790ddcf48cc4055bd Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 19 Oct 2026 17:13:40 -0400 Subject: [PATCH 149/898] fix(server): a retried thread creation replays after the project turns its provider off (#18360) Signed-off-by: Yordis Prieto Co-authored-by: Julius Marminge --- .../src/mcp/OrchestratorMcpService.test.ts | 170 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 64 ++++--- 2 files changed, 210 insertions(+), 24 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index b524921f02a6..1ea5e1ce414c 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import { CommandId, EnvironmentId, + MessageId, NodeId, type OrchestrationV2ThreadShell, type ScheduledTask, @@ -15,11 +16,18 @@ import { type OrchestrationV2ThreadProjection, type ServerProvider, } from "@t3tools/contracts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; +import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { DispatchModeLimit, type DispatchModeRefusal, @@ -1677,6 +1685,168 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.live.each([ + { name: "parent instance", parentEnabled: true, model: "gpt-5.4" }, + { name: "alternate instance", parentEnabled: false, model: "gpt-5.5" }, + ])( + "replays accepted creation on the $name without authorizing new disabled work", + ({ parentEnabled, model }) => + Effect.scoped( + Effect.gen(function* () { + const altInstanceId = ProviderInstanceId.make("codex-alt"); + const driver = ProviderDriverKind.make("codex"); + const cwd = yield* checkpointWorkspace("mcp-create-retry"); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters( + [codexInstanceId, altInstanceId].map((instanceId) => ({ + instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => + Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => + Effect.die("These tests exercise committed commands before provider execution."), + })), + ); + const layerDatabase = SqlitePersistence.layerMemory; + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "mcp-create-retry", runtimePolicyOverride: { cwd } }, + layerRegistry, + { databaseLayer: layerDatabase, runEffectWorker: false }, + ); + const layerSettings = ServerSettings.layerTest({ + providerInstances: { + [codexInstanceId]: { driver: "codex", enabled: true }, + [altInstanceId]: { driver: "codex", enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [codexInstanceId]: { enabled: parentEnabled } } }, + }, + }); + const layerThreads = ThreadManagementService.layer.pipe( + Layer.provideMerge(layerOrchestrator), + Layer.provide( + LegacyV1ThreadImporter.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerDatabase), + ), + ), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + Layer.provideMerge(layerSettings), + ); + const layerTest = layerOrchestratorService.pipe( + Layer.provideMerge(layerThreads), + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + layerRegistry, + layerSettings, + providerRegistryLayer([ + providerSnapshot({ instanceId: codexInstanceId, driver, model: "gpt-5.4" }), + providerSnapshot({ instanceId: altInstanceId, driver, model: "gpt-5.5" }), + ]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), + ), + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const settings = yield* ServerSettings.ServerSettingsService; + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("parent:create"), + threadId: parentThreadId, + projectId, + title: "MCP parent", + modelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("parent:start"), + threadId: parentThreadId, + messageId: MessageId.make("parent:message"), + text: "Create a thread.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const request = { + threads: [{ target: { driverKind: driver, model } }], + clientRequestId: "create-retry", + }; + const first = yield* service.createThreads(scope, request); + const child = first.threads[0]!; + const selection = parentEnabled + ? { instanceId: codexInstanceId, model: "gpt-5.4" } + : { instanceId: altInstanceId, model: "gpt-5.5" }; + assert.equal(first.threads.length, 1); + assert.equal(child.providerInstanceId, selection.instanceId); + assert.equal(child.model, selection.model); + assert.deepEqual(yield* service.createThreads(scope, request), first); + + // The creation receipt exists, but this prompt has not been accepted yet. + const withPrompt = { + ...request, + threads: [{ ...request.threads[0]!, prompt: "Start work." }], + }; + const started = yield* service.createThreads(scope, withPrompt); + assert.equal(started.threads[0]?.threadId, child.threadId); + const before = yield* orchestrator.getThreadProjection(child.threadId); + assert.deepEqual(before.runs[0]?.modelSelection, selection); + assert.equal(before.messages.length, 1); + const empty = yield* service.createThreads(scope, { + ...request, + clientRequestId: "empty", + }); + + yield* settings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { + providerInstances: { + [codexInstanceId]: { enabled: false }, + [altInstanceId]: { enabled: false }, + }, + }, + }, + }); + assert.deepEqual(yield* service.createThreads(scope, withPrompt), started); + const after = yield* orchestrator.getThreadProjection(child.threadId); + assert.deepEqual(after.thread.modelSelection, selection); + assert.deepEqual(after.runs, before.runs); + assert.deepEqual(after.messages, before.messages); + + const freshError = yield* service + .createThreads(scope, { + ...request, + clientRequestId: "fresh", + }) + .pipe(Effect.flip); + assert.equal(freshError.code, "provider_unavailable"); + const promptError = yield* service + .createThreads(scope, { + ...withPrompt, + clientRequestId: "empty", + }) + .pipe(Effect.flip); + assert.equal(promptError.code, "invalid_request"); + assert.include(promptError.message, "disabled"); + const untouched = yield* orchestrator.getThreadProjection(empty.threads[0]!.threadId); + assert.equal(untouched.messages.length, 0); + assert.equal(untouched.runs.length, 0); + }).pipe(Effect.provide(layerTest)); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + it.effect("re-probes an unavailable target once before refusing it", () => Effect.gen(function* () { const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 86b54b238a16..b31c12d9ca27 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2336,10 +2336,34 @@ const make = Effect.gen(function* () { input.threads, (request, index) => Effect.gen(function* () { + const commandId = stableCommandId({ + scope, + requestKey: key, + operation: "create-thread", + index, + }); + const threadId = stableThreadId({ + scope, + requestKey: key, + index, + }); + const isRetryOfAcceptedCreation = yield* threadManagement.hasReceipt(commandId); + const committedThread = isRetryOfAcceptedCreation + ? yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)) + : null; const target = yield* resolveTargetRechecking({ parent, - target: request.target, + target: + committedThread === null + ? request.target + : { + ...request.target, + providerInstanceId: committedThread.modelSelection.instanceId, + }, providers, + skipProjectEnablementFilter: isRetryOfAcceptedCreation, }); const runtimeMode = yield* resolveRuntimeMode( parent.thread.runtimeMode, @@ -2350,16 +2374,7 @@ const make = Effect.gen(function* () { parent.thread.interactionMode, request.interactionMode, ); - const threadId = stableThreadId({ - scope, - requestKey: key, - index, - }); - yield* assertTargetEnabledForProject( - parent, - stableCommandId({ scope, requestKey: key, operation: "create-thread", index }), - target, - ); + yield* assertTargetEnabledForProject(parent, commandId, target); const title = threadTitle({ parentTitle: parent.thread.title, prompt: request.prompt, @@ -2371,12 +2386,7 @@ const make = Effect.gen(function* () { type: "thread.create", createdBy: "agent", creationSource: "mcp", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "create-thread", - index, - }), + commandId, threadId, projectId: parent.thread.projectId, title, @@ -2394,6 +2404,10 @@ const make = Effect.gen(function* () { ), ), ); + // A receipt can appear after target resolution; use the committed thread's selection. + const resolvedModelSelection = (yield* threadManagement + .getThreadRecords(threadId, []) + .pipe(Effect.mapError(threadManagementFailure))).thread.modelSelection; if (request.prompt !== undefined) { yield* threadManagement .dispatch({ @@ -2415,15 +2429,17 @@ const make = Effect.gen(function* () { }), text: request.prompt, attachments: [], - modelSelection: target.modelSelection, + modelSelection: resolvedModelSelection, dispatchMode: { type: "start_immediately" }, }) .pipe( Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to start thread ${index + 1}: ${errorMessage(error)}`, - ), + error._tag === "ThreadManagementProviderInstanceDisabledError" + ? threadManagementFailure(error) + : failure( + "orchestration_error", + `Unable to start thread ${index + 1}: ${errorMessage(error)}`, + ), ), ); } @@ -2459,8 +2475,8 @@ const make = Effect.gen(function* () { title: projection.thread.title, createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, - providerInstanceId: target.modelSelection.instanceId, - model: target.modelSelection.model, + providerInstanceId: resolvedModelSelection.instanceId, + model: resolvedModelSelection.model, } satisfies OrchestratorMcpCreatedThread; }), { concurrency: 1 }, From 7f847ae4840ed5c8029103225c76e5b0f3885982 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Mon, 19 Oct 2026 22:13:51 +0100 Subject: [PATCH 150/898] fix(server): report ffmpeg errors when opening Android emulators (#18784) Co-authored-by: Julius Marminge --- .../server/src/device/DeviceMultiHost.test.ts | 9 +- apps/server/src/device/DeviceService.test.ts | 129 +++++++++++++++--- apps/server/src/device/DeviceService.ts | 27 ++++ .../server/src/device/LocalDeviceHost.test.ts | 50 +++++++ apps/server/src/device/LocalDeviceHost.ts | 5 +- apps/server/src/device/SshDeviceHost.ts | 5 +- .../server/src/device/sshDeviceScript.test.ts | 7 +- docs/user/devices.md | 14 +- 8 files changed, 223 insertions(+), 23 deletions(-) diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index 63502353238c..cb843d8f3761 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -12,11 +12,16 @@ import * as DeviceService from "./DeviceService.ts"; it.effect("keeps hosts independent when serials collide and another host fails", () => Effect.gen(function* () { const host = (id: string, failed = false): DeviceHost.DeviceHost["Service"] => { - const ready = { + const ready: DeviceHost.DeviceHostAgentReady = { nodePath: process.execPath, hub: { origin: `http://${id}` }, agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, - run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), + run: (command) => + Effect.succeed({ + stdout: command === "emulator" ? "" : " V....D libx264", + stderr: "", + code: 0, + }), helpers: { serveSimAxSettings: null, serveSimCli: null }, }; return { diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 54c6f814f951..01c424924b78 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -5,6 +5,7 @@ import { DeviceOperationError, LOCAL_DEVICE_HOST_ID, ThreadId, + type DeviceSummary, type DeviceServiceState, type ServerSettings as Settings, type SshDeviceHostConfig, @@ -90,13 +91,18 @@ const fixture = Effect.fn("fixture")(function* ( const agentStarts: string[] = []; const agentStops: string[] = []; const requests: string[] = []; + const devices: Array> = []; + const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" }); let booted = false; let shutDown = false; const ready: DeviceHost.DeviceHostReady = { nodePath: process.execPath, hub: { origin: "http://device.test" }, helpers: { serveSimAxSettings: null, serveSimCli: null }, - run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), + run: (command) => + command === "emulator" + ? Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }) + : Ref.get(encoderResult), }; const host: DeviceHost.DeviceHost["Service"] = { ...(inspectError @@ -115,7 +121,10 @@ const fixture = Effect.fn("fixture")(function* ( id: LOCAL_DEVICE_HOST_ID, kind: "local", label: "Test server", - platforms: [{ platform: "android", available: true }], + platforms: [ + { platform: "android", available: true }, + { platform: "ios", available: true }, + ], hubInstalled: true, agentDeviceInstalled: false, }), @@ -207,29 +216,35 @@ const fixture = Effect.fn("fixture")(function* ( ), ); } + if (request.url.endsWith("/grid/api/start")) { + return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); + } return HttpClientResponse.fromWeb( request, Response.json({ - simulators: [], - emulators: booted - ? [ - { - id: "emulator-5554", - name: "Pixel_API_35", - platform: "android", - version: "Android 15", - booted: true, - physical: false, - }, - ] - : [], + simulators: devices.filter((device) => device.platform === "ios"), + emulators: [ + ...devices.filter((device) => device.platform === "android"), + ...(booted + ? [ + { + id: "emulator-5554", + name: "Pixel_API_35", + platform: "android", + version: "Android 15", + booted: true, + physical: false, + }, + ] + : []), + ], }), ); }), ), ), ); - return { service, starts, agentStarts, agentStops, requests, settings }; + return { service, starts, agentStarts, agentStops, requests, settings, devices, encoderResult }; }); describe("device setup consent", () => { @@ -349,6 +364,88 @@ describe("device setup consent", () => { }).pipe(Effect.scoped), ); + it.effect("blocks only emulator video until ffmpeg is usable, then allows retry", () => + Effect.gen(function* () { + const { service, requests, devices, encoderResult } = yield* fixture(); + devices.push( + { + id: DeviceId.make("physical-android"), + name: "Pixel", + platform: "android", + version: "Android 15", + booted: true, + physical: true, + }, + { + id: DeviceId.make("ios-simulator"), + name: "iPhone", + platform: "ios", + version: "iOS 18", + booted: true, + physical: false, + }, + ); + yield* Ref.set(encoderResult, { code: 127, stdout: "", stderr: "spawn ffmpeg ENOENT" }); + const state = yield* service.configure({ enabled: true }); + expect(state.devices.map((device) => device.id)).toEqual([ + "ios-simulator", + "physical-android", + "Pixel_API_35", + ]); + const threadId = ThreadId.make("emulator-video-readiness"); + const physical = yield* service.open({ + threadId, + deviceId: "physical-android", + platform: "android", + }); + const ios = yield* service.open({ threadId, deviceId: "ios-simulator", platform: "ios" }); + expect(physical.deviceId).toBe("physical-android"); + expect(ios.deviceId).toBe("ios-simulator"); + + const openEmulator = service.open({ + threadId, + deviceId: "Pixel_API_35", + platform: "android", + }); + const missing = yield* openEmulator.pipe(Effect.flip); + expect(missing.message).toMatch(/ffmpeg/i); + expect(missing.message).toMatch(/emulator/i); + expect(requests.filter((url) => url.endsWith("/boot"))).toEqual([]); + expect((yield* service.state).sessions).toEqual([physical, ios]); + expect((yield* service.state).bootingDevices ?? []).toEqual([]); + + yield* Ref.set(encoderResult, { code: 1, stdout: "", stderr: "cannot execute ffmpeg" }); + expect((yield* openEmulator.pipe(Effect.flip)).message).toMatch(/ffmpeg/i); + yield* Ref.set(encoderResult, { + code: 0, + stdout: " V....D libx264rgb libx264 H.264 / AVC RGB (codec h264)", + stderr: "", + }); + expect((yield* openEmulator.pipe(Effect.flip)).message).toMatch(/libx264/i); + expect(requests.filter((url) => url.endsWith("/boot"))).toEqual([]); + expect((yield* service.state).sessions).toEqual([physical, ios]); + + devices.push({ + id: DeviceId.make("emulator-5556"), + name: "Already_booted", + platform: "android", + version: "Android 15", + booted: true, + physical: false, + }); + expect( + (yield* service + .open({ threadId, deviceId: "emulator-5556", platform: "android" }) + .pipe(Effect.flip)).message, + ).toMatch(/libx264/i); + + yield* Ref.set(encoderResult, { code: 0, stdout: " V....D libx264", stderr: "" }); + const emulator = yield* openEmulator; + expect(emulator.deviceId).toBe("emulator-5554"); + expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1); + }).pipe(Effect.scoped), + ); + it.effect("installs agent support only after the separate agent permission", () => Effect.gen(function* () { const { service, agentStarts, agentStops, settings } = yield* fixture(); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 76099d23550c..5fba72079fe8 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -671,6 +671,33 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (!device) { return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: input.deviceId }); } + if (device.platform === "android" && !device.physical) { + // Resolve the override on the device host the way its hub does. + const encoders = yield* ready.run(ready.nodePath, [ + "-e", + `const { spawnSync } = require("node:child_process"); +const result = spawnSync(process.env.SERVE_EMU_FFMPEG?.trim() || "ffmpeg", ["-hide_banner", "-encoders"], { + stdio: "inherit", timeout: 10000, killSignal: "SIGKILL", +}); +process.exitCode = result.error ? 1 : (result.status ?? 1);`, + ]); + if (encoders.code !== 0) { + return yield* new DevicePlatformUnavailableError({ + hostId: host.id, + platform: "android", + reason: + "Android emulator video needs working ffmpeg with the libx264 encoder. Install it on the device host's PATH or set SERVE_EMU_FFMPEG there, then retry opening the emulator. After changing PATH or SERVE_EMU_FFMPEG, turn device support off and on for SSH hosts, or restart the local environment server.", + }); + } + if (!/^\s*V\S*[ \t]+libx264(?:[ \t]|$)/m.test(`${encoders.stdout}\n${encoders.stderr}`)) { + return yield* new DevicePlatformUnavailableError({ + hostId: host.id, + platform: "android", + reason: + "Android emulator video needs the libx264 encoder. Install an ffmpeg build that includes libx264 on the device host, then retry opening the emulator. After changing PATH or SERVE_EMU_FFMPEG, turn device support off and on for SSH hosts, or restart the local environment server.", + }); + } + } if (!device.booted && input.boot !== false) { const booting = { ...device, threadId: input.threadId }; yield* publish((current) => ({ diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 6b921aaad979..68bf759e414b 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -502,6 +502,56 @@ http.createServer((req,res)=>res.end('ok')).listen(Number(args[args.indexOf('--p }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("keeps local Android and iOS available without ffmpeg", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-ffmpeg-" }); + yield* fs.makeDirectory(`${baseDir}/bin`); + yield* fs.writeFileString(`${baseDir}/bin/xcrun`, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(`${baseDir}/bin/xcrun`, 0o755); + for (const [directory, tool] of [ + ["platform-tools", "adb"], + ["emulator", "emulator"], + ["cmdline-tools/latest/bin", "avdmanager"], + ]) { + yield* fs.makeDirectory(`${baseDir}/sdk/${directory}`, { recursive: true }); + yield* fs.writeFileString(`${baseDir}/sdk/${directory}/${tool}`, ""); + } + const onTestHost = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcess.Environment, { + HOME: baseDir, + PATH: `${baseDir}/bin`, + ANDROID_HOME: `${baseDir}/sdk`, + }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const host = yield* LocalDeviceHost.make().pipe( + Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)), + onTestHost, + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die(new Error("Readiness must not make network requests"))), + ), + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => Effect.die("Platform availability must not probe emulator video"), + }), + ); + expect((yield* onTestHost(host.summary)).platforms).toEqual([ + { platform: "ios", available: true }, + { platform: "android", available: true }, + ]); + expect(yield* onTestHost(host.platformAvailability("android"))).toEqual({ + platform: "android", + available: true, + }); + expect(yield* onTestHost(host.platformAvailability("ios"))).toEqual({ + platform: "ios", + available: true, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect( "constructs and inspects an unconfigured host without installing or starting helpers", () => diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index f5aa43e13a73..bb8384df8276 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -237,6 +237,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const runner = yield* ProcessRunner.ProcessRunner; const httpClient = yield* HttpClient.HttpClient; const environment = yield* HostProcess.Environment; + const workingDirectory = yield* HostProcess.WorkingDirectory; const hostPlatform = yield* HostProcess.Platform; const sdk = yield* androidSdk; const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path); @@ -401,6 +402,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ...hubPlatformArguments(hostPlatform), ], { + cwd: workingDirectory, detached: false, shell: false, stdout: "pipe", @@ -766,6 +768,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ) : command, args, + cwd: workingDirectory, env: hostEnvironment, timeout: Duration.millis(options?.timeoutMs ?? 20_000), timeoutBehavior: "timedOutResult", @@ -775,7 +778,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Effect.map((result) => ({ stdout: result.stdout, stderr: result.stderr, - code: Number(result.code), + code: result.timedOut ? 124 : Number(result.code ?? 127), })), Effect.catch((cause) => Effect.succeed({ stdout: "", stderr: String(cause), code: 127 })), ); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 217cf84392a6..2fc8c57e05d7 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -172,7 +172,10 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( provide( runSshCommand(targetFor(config), { preHostArgs: identityArgs(config), - remoteCommandArgs: commandArgs(`exec ${[command, ...args].map(quoteRemoteArg).join(" ")}`), + // Match the hub cwd so relative executables and PATH entries resolve identically. + remoteCommandArgs: commandArgs( + `cd "$HOME/.t3/device/hosts/${owner}" && exec ${[command, ...args].map(quoteRemoteArg).join(" ")}`, + ), ...(options?.stdin === undefined ? {} : { stdin: options.stdin }), ...(options?.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), }), diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index ffff2c9cfe64..91e88e123761 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -111,7 +111,12 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1 .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION), ); const result = await exec(process.execPath, [file], { - env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + SERVE_EMU_FFMPEG: NodePath.join(home, "missing-ffmpeg"), + }, }); return result.stdout ? JSON.parse(result.stdout) : null; }; diff --git a/docs/user/devices.md b/docs/user/devices.md index a87770853a2d..bf372009d580 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -31,7 +31,15 @@ macOS with Xcode. Android needs the SDK Platform-Tools, Android Emulator, and Command-line Tools (latest), plus a virtual device created in Android Studio's Device Manager. T3 Code detects standard SDK locations; set `ANDROID_HOME` for a custom location. The panel explains missing dependencies. -After installing them, restart the environment server and refresh devices. +After installing SDK tools, restart the environment server and refresh devices. + +Android emulator video also needs `ffmpeg` with the libx264 encoder on the device +host's PATH, or an executable selected by `SERVE_EMU_FFMPEG` on that host. If +opening an emulator reports an ffmpeg problem, install or fix the executable +and retry opening it. After changing PATH or `SERVE_EMU_FFMPEG`, restart the +environment server for local devices, or turn device support off and on for SSH +devices, so the running tools inherit the new environment. Physical Android +devices and iOS do not need ffmpeg. The screen is interactive: click and drag to touch, type while the screen is focused, and use the toolbar for Home, Back, and Recents on Android, rotate on @@ -100,7 +108,9 @@ the environment’s own machine are skipped, since its devices are already local The first device listing installs pinned device tools on the host. Node 22 or newer and npm must be available to non-interactive SSH commands. T3 checks common Homebrew and Android SDK locations; custom installations need -the appropriate PATH and ANDROID_HOME on the host. +the appropriate PATH and ANDROID_HOME on the host. Android emulator video needs +`ffmpeg` with libx264 on that host; physical Android devices use scrcpy and do +not need ffmpeg. The picker identifies devices by host when several hosts are configured. Connections recover after interruptions. Removing a host closes its device From 1da4a1b0f2aabbda932375c80e9bd974d151caa5 Mon Sep 17 00:00:00 2001 From: noah cuadrado <119018346+noahcuadrado@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:13:57 +0200 Subject: [PATCH 151/898] fix(provider-cursor): use the selected context window for handoff budgets (#17462) Co-authored-by: Cursor Co-authored-by: Julius Marminge --- .../provider-cursor/src/server/adapter.ts | 8 +- .../provider-cursor/src/server/driver.test.ts | 236 +++++++++++++++++- packages/provider-cursor/src/server/driver.ts | 27 +- .../provider-cursor/src/server/sdkModel.ts | 22 +- 4 files changed, 282 insertions(+), 11 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index c9898a507fb5..b69ba4449b82 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -27,6 +27,7 @@ import { type OrchestrationV2Subagent, type OrchestrationV2TurnItem, type ProviderInstanceId, + type ServerProviderModel, type ThreadId, } from "@t3tools/contracts"; import { CursorSettings } from "../settings.ts"; @@ -46,7 +47,7 @@ import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresen import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { CursorTransportFailure } from "./transportFailure.ts"; -import { cursorSdkModelSelection } from "./sdkModel.ts"; +import { cursorContextWindowTokens, cursorSdkModelSelection } from "./sdkModel.ts"; import { discoverCursorSkills, hasCursorSkillMention, @@ -876,6 +877,7 @@ export interface CursorAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly settings: CursorSettings; readonly environment: NodeJS.ProcessEnv; + readonly getModels?: () => ReadonlyArray; } export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( @@ -2428,6 +2430,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( driver: CursorAgentSdk.CURSOR_PROVIDER, providerSessionId: input.providerSessionId, providerSession: session, + getModelContextWindow: (selection) => + selection.instanceId === adapterOptions.instanceId + ? cursorContextWindowTokens(selection, adapterOptions.getModels?.() ?? []) + : undefined, events: Stream.fromEffectRepeat(Queue.take(events)), ensureThread: Effect.fn("CursorAdapterV2.ensureThread")( function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index 8215f6563d48..912b0ec992f7 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -1,7 +1,20 @@ // @effect-diagnostics nodeBuiltinImport:off +import type { AgentOptions, SDKModel } from "@cursor/sdk"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import { + MessageId, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + RunAttemptId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -10,6 +23,10 @@ import { vi } from "vite-plus/test"; import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { + DEFAULT_HANDOFF_TOKEN_CAP, + handoffBudget, +} from "@t3tools/provider-core/server/handoffBudget"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { CursorDriver } from "./driver.ts"; @@ -157,6 +174,223 @@ it.layer(layerTest)("CursorDriver", (it) => { }).pipe(Effect.scoped), ); + it.effect("budgets handoffs for the SDK model across catalog refreshes and option changes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-window-" }); + const catalog = Promise.withResolvers>(); + const catalogRequested = Promise.withResolvers(); + const me = vi.spyOn(Cursor, "me").mockResolvedValue({ + apiKeyName: "T3 Code", + createdAt: "2026-01-01T00:00:00.000Z", + }); + const models = vi.spyOn(Cursor.models, "list").mockImplementation(() => { + catalogRequested.resolve(); + return catalog.promise; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + me.mockRestore(); + models.mockRestore(); + }), + ); + const instanceId = ProviderInstanceId.make("cursor-catalog-window"); + const openedModels: Array = []; + const sentModels: Array = []; + const instance = yield* CursorDriver.create({ + instanceId, + displayName: "Cursor", + enabled: true, + environment: [{ name: "CURSOR_API_KEY", value: "catalog-window-key", sensitive: true }], + config: CursorDriver.defaultConfig(), + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + assertComplete: Effect.void, + open: (request) => + Effect.sync(() => { + openedModels.push(request.options.model); + return { + agentId: "native-cursor-window", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.sync(() => { + sentModels.push(input.options?.model); + const runId = `native-cursor-window-run-${sentModels.length}`; + return { + agentId: "native-cursor-window", + runId, + cancel: Effect.void, + wait: Effect.succeed({ + id: runId, + requestId: runId, + status: "finished" as const, + model: { id: "claude-opus-5-5" }, + durationMs: 1, + }), + }; + }), + }; + }), + }), + ); + const selection = (contextWindow?: string) => ({ + instanceId, + model: "claude-opus-5-5", + ...(contextWindow === undefined + ? {} + : { options: [{ id: "contextWindow", value: contextWindow }] }), + }); + const threadId = ThreadId.make("cursor-catalog-window-thread"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const runtime = yield* instance.orchestrationAdapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-catalog-window-session"), + modelSelection: selection("1m"), + runtimePolicy, + }); + const refresh = yield* instance.snapshot.refresh.pipe(Effect.forkScoped); + yield* Effect.promise(() => catalogRequested.promise); + expect(runtime.getModelContextWindow?.(selection())).toBeUndefined(); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: selection("1m"), + runtimePolicy, + }); + const now = yield* DateTime.now; + const startTurn = Effect.fnUntraced(function* (contextWindow?: string) { + const modelSelection = selection(contextWindow); + const ordinal = sentModels.length + 1; + yield* runtime.startTurn({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make(`cursor-window-run-${ordinal}`), + runOrdinal: ordinal, + providerTurnOrdinal: ordinal, + attemptId: RunAttemptId.make(`cursor-window-attempt-${ordinal}`), + rootNodeId: NodeId.make(`cursor-window-root-${ordinal}`), + appThread: { + id: threadId, + projectId: ProjectId.make("cursor-window-project"), + createdBy: "user", + creationSource: "web", + title: "Cursor context window", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + message: { + messageId: MessageId.make(`cursor-window-message-${ordinal}`), + createdBy: "user", + creationSource: "web", + text: "Continue.", + attachments: [], + }, + }); + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runDrain, + ); + const window = runtime.getModelContextWindow?.(modelSelection); + return { + model: sentModels.at(-1), + window, + allowance: handoffBudget({ + tokenCap: DEFAULT_HANDOFF_TOKEN_CAP, + userText: "Continue.", + attachments: [], + providerThread, + nativeContextEstimate: 400_000, + modelContextWindow: window, + }), + }; + }); + const beforeCatalog = yield* startTurn("1m"); + catalog.resolve([ + { + id: "claude-opus-5-5", + displayName: "Claude Opus 5.5", + parameters: [ + { + id: "context", + displayName: "Context", + values: [{ value: "300k" }, { value: "1m" }], + }, + ], + variants: [ + { + displayName: "Claude Opus 5.5", + isDefault: true, + params: [{ id: "context", value: "1m" }], + }, + ], + }, + ]); + expect((yield* Fiber.join(refresh)).status).toBe("ready"); + const catalogDefault = yield* startTurn(); + const explicitWindow = yield* startTurn("500k"); + const unknownWindow = yield* startTurn("max"); + me.mockRejectedValue(new Error("Cursor is unreachable")); + expect((yield* instance.snapshot.refresh).status).toBe("error"); + const retainedDefault = yield* startTurn(); + + expect(openedModels).toEqual([ + { + id: "claude-opus-5-5", + params: [{ id: "context", value: "1m" }], + }, + ]); + expect({ + beforeCatalog, + catalogDefault, + explicitWindow, + unknownWindow, + retainedDefault, + }).toEqual({ + beforeCatalog: { + model: { id: "claude-opus-5-5", params: [{ id: "context", value: "1m" }] }, + window: 1_000_000, + allowance: DEFAULT_HANDOFF_TOKEN_CAP, + }, + catalogDefault: { + model: { id: "claude-opus-5-5" }, + window: 1_000_000, + allowance: DEFAULT_HANDOFF_TOKEN_CAP, + }, + explicitWindow: { + model: { id: "claude-opus-5-5", params: [{ id: "context", value: "500k" }] }, + window: 500_000, + allowance: 0, + }, + unknownWindow: { + model: { id: "claude-opus-5-5", params: [{ id: "context", value: "max" }] }, + window: undefined, + allowance: 0, + }, + retainedDefault: catalogDefault, + }); + }).pipe(Effect.scoped), + ); + it.effect("keeps the bundled SDK manual-only without probing or updating cursor-agent", () => Effect.gen(function* () { const instance = yield* CursorDriver.create({ diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index e938024ffc58..5a3ac63166fc 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -1,4 +1,8 @@ -import { ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderSetupError, + type ServerProviderModel, +} from "@t3tools/contracts"; import { CursorSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; import * as Crypto from "effect/Crypto"; @@ -10,7 +14,7 @@ import { readCursorUsageLimits } from "./usageLimits.ts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { makeCursorTextGeneration } from "./textGeneration.ts"; -import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./adapter.ts"; +import { makeCursorAdapterV2, type CursorAdapterV2DriverEnv } from "./adapter.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus } from "./status.ts"; import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; @@ -154,13 +158,12 @@ export const CursorDriver: ProviderDriver< }, }); - const orchestrationAdapter = yield* CursorAdapterV2Driver.create({ + let catalogModels: ReadonlyArray = []; + const orchestrationAdapter = yield* makeCursorAdapterV2({ instanceId, - displayName, - accentColor, - environment, - enabled, - config, + settings: effectiveConfig, + environment: processEnv, + getModels: () => catalogModels, }).pipe( Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { ...sdkRunner, @@ -239,6 +242,14 @@ export const CursorDriver: ProviderDriver< Effect.provideService(Path.Path, path), Effect.provideService(CursorKeychain.CursorKeychain, keychain), Effect.map(stampSnapshot), + Effect.tap((provider) => + Effect.sync(() => { + // A failed check lists only custom models, so keep the last loaded catalog. + if (provider.models.some((model) => !model.isCustom)) { + catalogModels = provider.models; + } + }), + ), Effect.provide(CursorSdkCatalog.layer), ); diff --git a/packages/provider-cursor/src/server/sdkModel.ts b/packages/provider-cursor/src/server/sdkModel.ts index 3332cdbf8671..fec0bafc45ee 100644 --- a/packages/provider-cursor/src/server/sdkModel.ts +++ b/packages/provider-cursor/src/server/sdkModel.ts @@ -1,5 +1,6 @@ import type { ModelSelection as CursorSdkModelSelection, ModelParameterValue } from "@cursor/sdk"; -import type { ModelSelection } from "@t3tools/contracts"; +import type { ModelSelection, ServerProviderModel } from "@t3tools/contracts"; +import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; const CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION: Readonly> = { context: "contextWindow", @@ -35,6 +36,25 @@ export function cursorSdkParameterPriority(parameterId: string): number { } } +export function cursorContextWindowTokens( + modelSelection: ModelSelection, + models: ReadonlyArray, +): number | undefined { + // The SDK forwards explicit values even when the catalog does not list them. + const explicit = modelSelection.options?.find((option) => option.id === "contextWindow"); + let value = explicit?.value; + if (explicit === undefined) { + const descriptor = models + .find((model) => model.slug === modelSelection.model) + ?.capabilities?.optionDescriptors?.find((option) => option.id === "contextWindow"); + value = getProviderOptionCurrentValue(descriptor); + } + const match = /^(\d+)([km])$/i.exec(typeof value === "string" ? value.trim() : ""); + if (match === null) return undefined; + const tokens = Number(match[1]) * (match[2]?.toLowerCase() === "m" ? 1_000_000 : 1_000); + return Number.isSafeInteger(tokens) && tokens > 0 ? tokens : undefined; +} + export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorSdkModelSelection { return { id: modelSelection.model === "auto" ? "default" : modelSelection.model, From 1535b120ec6af190ccf7953d8bc4b44fe3fee470 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:14:07 -0300 Subject: [PATCH 152/898] fix(clients): async questions keep a running thread Working (#15430) Co-authored-by: Julius Marminge --- .../orchestration-v2/ProjectionStore.test.ts | 96 +++++++++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 99 ++++++++++--------- .../client-runtime/src/state/entities.test.ts | 52 ++++++++++ packages/client-runtime/src/state/models.ts | 12 ++- .../client-runtime/src/state/threadSettled.ts | 7 +- packages/contracts/src/orchestrationV2.ts | 2 + packages/shared/src/agentAwareness.test.ts | 22 +++++ packages/shared/src/agentAwareness.ts | 10 +- 8 files changed, 244 insertions(+), 56 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index a225030b8b48..8f416f156f81 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -3179,6 +3179,48 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ); assert.isNull(shell?.latestRunCompletedAt); + const requestId = RuntimeRequestId.make("request:projection-shell-question"); + for (const [responseCapability, blocking] of [ + [{ type: "message" }, false], + [{ type: "live", providerSessionId: ProviderSessionId.make("session:question") }, true], + ] as const) { + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-question:${responseCapability.type}`), + type: "runtime-request.updated", + threadId, + nodeId: rootNodeId, + driver, + occurredAt: now, + payload: { + id: requestId, + nodeId: rootNodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability, + createdAt: now, + resolvedAt: null, + }, + }); + const projection = yield* projectionStore.getThreadProjection(threadId); + const sqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( + (row) => row.id === threadId, + )!; + for (const questionShell of [ + sqlShell, + ProjectionStore.threadShellFromProjection(projection), + ]) { + assert.equal(questionShell.activeRunId, runId); + assert.deepEqual(questionShell.pendingRuntimeRequest, { + id: requestId, + kind: "user_input", + createdAt: now, + blocking, + }); + } + } + yield* projectionStore.apply({ id: EventId.make("event:projection-shell-interruptible:waiting"), type: "run.updated", @@ -4355,7 +4397,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { title: null, startedAt: now, completedAt: null, - updatedAt: now, + updatedAt: DateTime.add(now, { seconds: ordinal }), type: "secret_request", label: "Test credential", reason: "Test pending input", @@ -4366,6 +4408,45 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { yield* addSecret("secret:b", 3, "waiting"); yield* addSecret("secret:c", 4, "completed"); + yield* sql` + UPDATE orchestration_v2_projection_runs + SET status = 'running', payload_json = json_set(payload_json, '$.status', 'running') + WHERE run_id = ${runId} + `; + const secretOnlyShell = (yield* projectionStore.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + ); + const secretOnlyProjection = yield* projectionStore.getThreadProjection(threadId); + for (const shell of [ + secretOnlyShell, + ProjectionStore.threadShellFromProjection(secretOnlyProjection), + ]) { + assert.equal(shell?.pendingRuntimeRequest?.id, "secret:b"); + assert.isTrue(shell?.pendingRuntimeRequest?.blocking); + } + + const requestId = RuntimeRequestId.make(`request:${suffix}:async`); + yield* projectionStore.apply({ + id: EventId.make(`event:${suffix}:async`), + type: "runtime-request.updated", + threadId, + runId, + nodeId, + driver, + occurredAt: now, + payload: { + id: requestId, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: DateTime.subtract(now, { seconds: 1 }), + resolvedAt: null, + }, + }); + for (const status of [ "preparing", "starting", @@ -4382,9 +4463,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const shell = yield* projectionStore.getShellSnapshot(); const thread = shell.threads.find((candidate) => candidate.id === threadId); assert.isDefined(thread); - assert.equal( - thread?.pendingRuntimeRequest?.id ?? null, - status === "completed" || status === "rolled_back" ? null : "secret:b", + const projection = yield* projectionStore.getThreadProjection(threadId); + const memoryShell = ProjectionStore.threadShellFromProjection(projection); + const terminal = status === "completed" || status === "rolled_back"; + assert.deepEqual( + [thread?.pendingRuntimeRequest?.id, memoryShell.pendingRuntimeRequest?.id], + terminal ? [requestId, requestId] : ["secret:b", "secret:b"], + ); + assert.deepEqual( + [thread?.pendingRuntimeRequest?.blocking, memoryShell.pendingRuntimeRequest?.blocking], + terminal ? [false, false] : [true, true], ); } }), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 9deee8f3850a..508365bd789a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -19,6 +19,7 @@ import type { OrchestrationV2CheckpointScope, OrchestrationV2ExecutionNode, OrchestrationV2PlanArtifact, + OrchestrationV2PendingRuntimeRequestSummary, PlanId, OrchestrationV2ConversationMessage, OrchestrationV2DomainEvent, @@ -1427,20 +1428,27 @@ function buildVisibleTurnItems(input: { ]); } +function pendingRuntimeRequestSummary( + request: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null, +): OrchestrationV2PendingRuntimeRequestSummary | null { + if (request === null) return null; + return { + id: request.id, + kind: request.kind, + createdAt: request.createdAt, + blocking: request.responseCapability.type !== "message", + }; +} + function secretRequestAsPendingInput( item: OrchestrationV2TurnItem | null, -): OrchestrationV2ThreadProjection["runtimeRequests"][number] | null { +): OrchestrationV2PendingRuntimeRequestSummary | null { if (item?.type !== "secret_request" || item.nodeId === null) return null; return { id: RuntimeRequestId.make(item.id), - nodeId: item.nodeId, - providerTurnId: item.providerTurnId, - nativeRequestRef: null, kind: "user_input", - status: "pending", - responseCapability: { type: "message" }, createdAt: item.startedAt ?? item.updatedAt, - resolvedAt: null, + blocking: true, }; } @@ -1469,27 +1477,31 @@ export function threadShellFromProjection( .filter(isActivityRunForShell) .toSorted((left, right) => right.ordinal - left.ordinal)[0] ?? null; const liveRunIds = new Set(projection.runs.filter(isActivityRunForShell).map((run) => run.id)); - const pendingRuntimeRequest = + let pendingRuntimeRequest = pendingRuntimeRequestSummary( projection.runtimeRequests .filter((request) => request.status === "pending") .toSorted( (left, right) => DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt), - )[0] ?? - secretRequestAsPendingInput( - projection.turnItems - .filter( - (item) => - item.type === "secret_request" && - item.status === "waiting" && - item.runId !== null && - liveRunIds.has(item.runId), - ) - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), - )[0] ?? null, - ); + )[0] ?? null, + ); + if (pendingRuntimeRequest === null || pendingRuntimeRequest.blocking === false) { + pendingRuntimeRequest = + secretRequestAsPendingInput( + projection.turnItems + .filter( + (item) => + item.type === "secret_request" && + item.status === "waiting" && + item.runId !== null && + liveRunIds.has(item.runId), + ) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), + )[0] ?? null, + ) ?? pendingRuntimeRequest; + } const userMessages = projection.messages .filter((message) => message.role === "user") .toSorted( @@ -1546,14 +1558,7 @@ export function threadShellFromProjection( latestRootProviderFailure(latestRun, projection.turnItems), providerSession?.lastError ?? null, ), - pendingRuntimeRequest: - pendingRuntimeRequest === null - ? null - : { - id: pendingRuntimeRequest.id, - kind: pendingRuntimeRequest.kind, - createdAt: pendingRuntimeRequest.createdAt, - }, + pendingRuntimeRequest, // Thread detail owns message bodies. Keeping them out of shell rows makes // initial hydration and streaming updates independent of transcript size. latestVisibleMessage: null, @@ -1648,7 +1653,7 @@ type ShellThreadState = { readonly lastError: string | null; readonly lastErrorClass: OrchestrationV2ThreadShell["lastErrorClass"]; readonly usageLimitResetAt: OrchestrationV2ThreadShell["usageLimitResetAt"]; - readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null; + readonly pendingRuntimeRequest: OrchestrationV2PendingRuntimeRequestSummary | null; readonly latestUserMessageAt: DateTime.Utc | null; readonly latestUserAuthoredMessageAt: DateTime.Utc | null; readonly hasActionableProposedPlan: boolean; @@ -1801,14 +1806,7 @@ function shellFromState(input: { lastError: input.state.lastError, lastErrorClass: input.state.lastErrorClass, usageLimitResetAt: input.state.usageLimitResetAt, - pendingRuntimeRequest: - input.state.pendingRuntimeRequest === null - ? null - : { - id: input.state.pendingRuntimeRequest.id, - kind: input.state.pendingRuntimeRequest.kind, - createdAt: input.state.pendingRuntimeRequest.createdAt, - }, + pendingRuntimeRequest: input.state.pendingRuntimeRequest, latestVisibleMessage: null, latestUserMessageAt: input.state.latestUserMessageAt, latestUserAuthoredMessageAt: input.state.latestUserAuthoredMessageAt, @@ -5791,14 +5789,21 @@ export const layer: Layer.Layer = pendingTurnItemsByThreadId, } = input; const thread = yield* decodeThreadPayload(row.payload_json); - const pendingRuntimeRequest = + let pendingRuntimeRequest = row.pending_request_payload_json !== null - ? yield* decodeRuntimeRequestPayload(row.pending_request_payload_json) - : row.pending_secret_request_payload_json !== null - ? secretRequestAsPendingInput( - yield* decodeTurnItemPayload(row.pending_secret_request_payload_json), - ) - : null; + ? pendingRuntimeRequestSummary( + yield* decodeRuntimeRequestPayload(row.pending_request_payload_json), + ) + : null; + if ( + (pendingRuntimeRequest === null || pendingRuntimeRequest.blocking === false) && + row.pending_secret_request_payload_json !== null + ) { + pendingRuntimeRequest = + secretRequestAsPendingInput( + yield* decodeTurnItemPayload(row.pending_secret_request_payload_json), + ) ?? pendingRuntimeRequest; + } let terminalFailureItem = row.terminal_failure_payload_json === null ? null diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 02d9fa11696f..63a1ac57f2bb 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -26,7 +26,9 @@ import { } from "./models.ts"; import { v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { deriveLatestThreadRun, deriveThreadRuntime } from "./threadExecution.ts"; +import { isThreadWorking } from "./threadInbox.ts"; import { derivePendingThreadRequests } from "./threadRequests.ts"; +import { canSnooze, effectiveSnoozed } from "./threadSettled.ts"; const environmentId = EnvironmentId.make("environment-v2"); @@ -111,6 +113,56 @@ describe("V2 client presentation", () => { }); }); + it("keeps async questions in Working until the run completes", () => { + const runId = RunId.make("run-async-question"); + const running = { + ...v2ThreadShell, + snoozedAt: DateTime.makeUnsafe("2026-06-19T23:59:00.000Z"), + snoozedUntil: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"), + latestRunId: runId, + activeRunId: runId, + status: "running" as const, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-async-question"), + kind: "user_input" as const, + createdAt: v2ThreadShell.updatedAt, + blocking: false, + }, + }; + const active = presentThreadShell(environmentId, running); + expect(active.hasPendingUserInput).toBe(false); + expect(isThreadWorking(active)).toBe(true); + expect(canSnooze(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); + + const completed = presentThreadShell(environmentId, { + ...running, + activeRunId: null, + status: "completed", + }); + expect(completed.hasPendingUserInput).toBe(true); + expect(isThreadWorking(completed)).toBe(false); + expect(effectiveSnoozed(active, { now: "2026-06-20T00:05:00.000Z" })).toBe(false); + }); + + it("keeps blocking and legacy questions out of Working", () => { + for (const capability of [{ blocking: true }, {}]) { + const shell = presentThreadShell(environmentId, { + ...v2ThreadShell, + latestRunId: RunId.make("run-blocking-question"), + activeRunId: RunId.make("run-blocking-question"), + status: "running", + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-blocking-question"), + kind: "user_input", + createdAt: v2ThreadShell.updatedAt, + ...capability, + }, + }); + expect(shell.hasPendingUserInput).toBe(true); + expect(isThreadWorking(shell)).toBe(false); + } + }); + it("preserves shell run timestamps for sidebar activity clocks", () => { const runId = RunId.make("run-working-clock"); const requestedAt = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"); diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 36535a273cc0..91fe95b8585b 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -220,6 +220,12 @@ export function presentThreadShell( : nullableIso(thread.latestRunCompletedAt), assistantMessageId: null, } satisfies ThreadRunSummary); + const runtime = shellRuntime(thread); + // Waiting runs have ended their provider turn and are only draining effects. + const runtimeIsWorking = + runtime?.status === "preparing" || + runtime?.status === "starting" || + runtime?.status === "running"; return { environmentId, id: thread.id, @@ -241,7 +247,7 @@ export function presentThreadShell( forkedFrom: thread.forkedFrom, activeProviderThreadId: thread.activeProviderThreadId, latestRun, - runtime: shellRuntime(thread), + runtime, latestUserMessageAt: nullableIso(thread.latestUserMessageAt), ...(thread.latestUserAuthoredMessageAt === undefined ? {} @@ -250,7 +256,9 @@ export function presentThreadShell( thread.pendingRuntimeRequest !== null && thread.pendingRuntimeRequest.kind !== "user_input" && thread.pendingRuntimeRequest.kind !== "auth_refresh", - hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input", + hasPendingUserInput: + thread.pendingRuntimeRequest?.kind === "user_input" && + (thread.pendingRuntimeRequest.blocking !== false || !runtimeIsWorking), pendingSubagentRequest, hasActionableProposedPlan: thread.hasActionableProposedPlan, pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [], diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index c7172c330008..a48f22b28e33 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,7 +1,7 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import * as DateTime from "effect/DateTime"; -import type { PendingSubagentRequest } from "./models.ts"; +import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; interface SettlementRunLike { readonly turnId?: unknown; @@ -71,6 +71,7 @@ export function hasQueuedTurnStart( } export interface ThreadSnoozeShell extends QueuedThreadShell { + readonly source?: Pick; readonly snoozedUntil?: string | null; readonly snoozedAt?: string | null; readonly hasPendingApprovals: boolean; @@ -80,6 +81,7 @@ export interface ThreadSnoozeShell extends QueuedThreadShell { export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; + if (shell.source?.pendingRuntimeRequest != null) return true; if (shell.pendingSubagentRequest != null) return true; const latestRun = shell.latestRun ?? shell.latestTurn ?? null; const failedAt = failureEdgeAt(shell); @@ -124,10 +126,13 @@ export function canSnooze( | "latestRun" | "session" | "runtime" + | "source" >, options: { readonly now: string }, ): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + // The server rejects snoozing even for nonblocking questions. + if (shell.source?.pendingRuntimeRequest != null) return false; if (shell.pendingSubagentRequest != null) return false; if (hasQueuedTurnStart(shell, options)) return false; return true; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 94a9eca331d1..2dc4296bb963 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1782,6 +1782,8 @@ export const OrchestrationV2PendingRuntimeRequestSummary = Schema.Struct({ id: RuntimeRequestId, kind: OrchestrationV2RuntimeRequest.fields.kind, createdAt: Schema.DateTimeUtc, + /** False for async questions the run keeps working past. Absent from older servers. */ + blocking: Schema.optional(Schema.Boolean), }); export type OrchestrationV2PendingRuntimeRequestSummary = typeof OrchestrationV2PendingRuntimeRequestSummary.Type; diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index c31312646c02..85b8d614150b 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -122,6 +122,28 @@ describe("projectThreadAwarenessV2", () => { ).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" }); }); + it("reports an async question as input only after the working run completes", () => { + const input = { + environmentId: "env-1" as EnvironmentId, + project, + thread: v2Thread({ + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-async"), + kind: "user_input", + createdAt: updatedAt, + blocking: false, + }, + }), + }; + expect(projectThreadAwarenessV2(input)).toMatchObject({ + phase: "running", + headline: "Agent is working", + }); + expect( + projectThreadAwarenessV2({ ...input, thread: { ...input.thread, status: "completed" } }), + ).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" }); + }); + it("does not present authentication refreshes as user approvals", () => { expect( projectThreadAwarenessV2({ diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index b8bd43766de5..b8f6a194a9d0 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -86,16 +86,22 @@ export function projectThreadAwarenessV2( function resolveThreadAwarenessPhaseV2( thread: ProjectThreadAwarenessV2Input["thread"], ): AgentAwarenessPhase | null { - if (thread.pendingRuntimeRequest?.kind === "user_input") { + const runStatus = thread.activityRunStatus ?? thread.status; + if ( + thread.pendingRuntimeRequest?.kind === "user_input" && + (thread.pendingRuntimeRequest.blocking !== false || + !(runStatus === "preparing" || runStatus === "starting" || runStatus === "running")) + ) { return "waiting_for_input"; } if ( thread.pendingRuntimeRequest !== null && + thread.pendingRuntimeRequest.kind !== "user_input" && thread.pendingRuntimeRequest.kind !== "auth_refresh" ) { return "waiting_for_approval"; } - switch (thread.activityRunStatus ?? thread.status) { + switch (runStatus) { case "preparing": case "starting": return "starting"; From 7cca7d17efefcc8be7901a947f0b10b68892c2c1 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 20 Oct 2026 08:15:46 +1100 Subject: [PATCH 153/898] fix(web): keep action shortcuts when editing other fields (#18412) Co-authored-by: SAPHID Co-authored-by: GPT-6.1 Sol Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge --- .../useProjectScriptSettings.browser.test.tsx | 112 ++++++++++++++---- .../settings/useProjectScriptSettings.ts | 3 + 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx b/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx index 46b07ef317d8..cc354b1d2119 100644 --- a/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx +++ b/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx @@ -66,52 +66,65 @@ const lintBinding: ResolvedKeybindingsConfig = [ }, }, ]; -const input = (keybinding: string | null): NewProjectScriptInput => ({ +const input: NewProjectScriptInput = { name: lint.name, command: lint.command, icon: lint.icon, runOnWorktreeCreate: false, waitForSetup: false, runOnSettle: false, - keybinding, previewUrl: null, autoOpenPreview: false, -}); +}; type ProjectScriptSettings = ReturnType; function Probe(props: { - keybindings: ResolvedKeybindingsConfig; + targets: Parameters[0]; onResult: (settings: ProjectScriptSettings) => void; }) { - props.onResult( - useProjectScriptSettings([ - { - environmentId, - settings: DEFAULT_SERVER_SETTINGS, - keybindings: props.keybindings, - project: { id: ProjectId.make("project"), scripts: [lint] }, - }, - ]), - ); + props.onResult(useProjectScriptSettings(props.targets)); return null; } let root: Root; let container: HTMLDivElement; -async function renderSettings(keybindings: ResolvedKeybindingsConfig) { +async function renderSettings( + keybindings: ResolvedKeybindingsConfig, + scope: "project" | "environment" = "project", +) { let settings: ProjectScriptSettings | undefined; await act(async () => - root.render( (settings = next)} />), + root.render( + (settings = next)} + />, + ), ); return settings!; } -async function submitInAct(submit: ProjectScriptSettings["submit"], keybinding: string | null) { +async function submitInAct( + submit: ProjectScriptSettings["submit"], + changes: Partial, +) { let result: Awaited> | undefined; await act(async () => { - result = await submit(lint.id, input(keybinding)); + result = await submit(lint.id, { ...input, ...changes }); }); return result!; } @@ -134,10 +147,34 @@ afterEach(async () => { }); describe("useProjectScriptSettings", () => { + it.each(["project", "environment"] as const)( + "preserves the shortcut for %s action name and command edits", + async (scope) => { + const { submit } = await renderSettings(lintBinding, scope); + const changes = { name: "Lint and fix", command: "vp lint --fix" }; + + const result = await submitInAct(submit, changes); + + expect(result._tag).toBe("Success"); + const scripts = [expect.objectContaining({ id: lint.id, ...changes })]; + expect(commands.updateSettings).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { + patch: + scope === "project" + ? { projectSettingsOverrides: { project: { defaultProjectScripts: scripts } } } + : { defaultProjectScripts: scripts }, + }, + }); + expect(commands.removeKeybinding).not.toHaveBeenCalled(); + expect(commands.upsertKeybinding).not.toHaveBeenCalled(); + }, + ); + it("saves a recorded action shortcut", async () => { const { submit } = await renderSettings([]); - const result = await submitInAct(submit, "mod+shift+k"); + const result = await submitInAct(submit, { keybinding: "mod+shift+k" }); expect(result._tag).toBe("Success"); expect(commands.upsertKeybinding).toHaveBeenCalledWith({ @@ -149,7 +186,7 @@ describe("useProjectScriptSettings", () => { it("removes a cleared action shortcut", async () => { const { submit } = await renderSettings(lintBinding); - const result = await submitInAct(submit, null); + const result = await submitInAct(submit, { keybinding: null }); expect(result._tag).toBe("Success"); expect(commands.removeKeybinding).toHaveBeenCalledWith({ @@ -158,4 +195,39 @@ describe("useProjectScriptSettings", () => { }); expect(commands.upsertKeybinding).not.toHaveBeenCalled(); }); + + it("replaces an explicitly changed action shortcut", async () => { + const { submit } = await renderSettings(lintBinding); + + const result = await submitInAct(submit, { keybinding: "mod+shift+l" }); + + expect(result._tag).toBe("Success"); + expect(commands.upsertKeybinding).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { + command: "script.lint.run", + key: "mod+shift+l", + replace: { command: "script.lint.run", key: "mod+shift+k" }, + }, + }); + expect(commands.removeKeybinding).not.toHaveBeenCalled(); + }); + + it("removes the shortcut when its action is deleted", async () => { + const { persist } = await renderSettings(lintBinding); + + await act(async () => { + expect((await persist(() => []))._tag).toBe("Success"); + }); + + expect(commands.updateSettings).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { patch: { projectSettingsOverrides: { project: { defaultProjectScripts: [] } } } }, + }); + expect(commands.removeKeybinding).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { key: "mod+shift+k", command: "script.lint.run" }, + }); + expect(commands.upsertKeybinding).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/settings/useProjectScriptSettings.ts b/apps/web/src/components/settings/useProjectScriptSettings.ts index c9e64467e347..d75749203d0d 100644 --- a/apps/web/src/components/settings/useProjectScriptSettings.ts +++ b/apps/web/src/components/settings/useProjectScriptSettings.ts @@ -140,6 +140,9 @@ export function useProjectScriptSettings( .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) .map((script) => script.id); for (const id of changedIds) { + if (keybinding === undefined && effectiveScripts.some((script) => script.id === id)) { + continue; + } const command = commandForProjectScript(id); const previousValue = keybindingValueForCommand(keybindings, command); const previous = previousValue From c1377176b1107dead2b4afe40cf423f0054449ca Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Mon, 19 Oct 2026 23:16:14 +0200 Subject: [PATCH 154/898] fix(composer): don't send attached threads to a machine that can't read them (#18310) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 43 +++++++++++++++- apps/web/src/components/ThreadContextChip.tsx | 10 +++- .../composerContextPresentation.tsx | 12 ++++- .../src/composerContextReferences.test.ts | 50 ++++++++++++++++++- .../shared/src/composerContextReferences.ts | 20 ++++++++ 5 files changed, 130 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 425007855bac..0e1b1d30a7ec 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -339,6 +339,7 @@ import { type TerminalContextSelection, } from "../lib/terminalContext"; import { + collectInlineContextIds, ensureInlineContextReferences, removeInlineContextReference, } from "../lib/composerContextReferences"; @@ -559,7 +560,10 @@ import { supportsServerUpdateThreadContinuation, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; -import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; +import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + threadContextsOutsideEnvironment, +} from "@t3tools/shared/composerContextReferences"; import { recallableComposerPrompt } from "./chat/composerPromptHistory"; import { observeResize } from "~/lib/observeResize"; @@ -1226,6 +1230,15 @@ export default function ChatView(props: ChatViewProps) { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; }); + const referencedThreadContexts = useComposerDraftStore( + useShallow((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + if (!draft?.threadContexts.length) return []; + // Linked prompts can replace text without pruning stored context records. + const referenced = new Set(collectInlineContextIds(draft.prompt)); + return draft.threadContexts.filter((record) => referenced.has(record.contextId)); + }), + ); const composerHasNonPromptContent = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; @@ -3128,6 +3141,9 @@ export default function ChatView(props: ChatViewProps) { return ( environment?.connection.phase === "connected" && (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 && + referencedThreadContexts.every( + (record) => record.environmentId === candidate.environmentId, + ) && providersByEnvironment .get(candidate.environmentId) ?.some( @@ -6037,7 +6053,14 @@ export default function ChatView(props: ChatViewProps) { : "Choose a machine to continue", description: loadBalancing.pending ? "Resource checks are still running. You can choose a machine in the composer." - : "No eligible machine has available resources. Choose a machine in the composer to override.", + : referencedThreadContexts.length > 0 && + !logicalProjectEnvironments.some((candidate) => + referencedThreadContexts.every( + (record) => record.environmentId === candidate.environmentId, + ), + ) + ? "No machine can read every attached thread. Choose a machine in the composer or remove the threads." + : "No eligible machine has available resources. Choose a machine in the composer to override.", }); return; } @@ -6122,6 +6145,22 @@ export default function ChatView(props: ChatViewProps) { previewAnnotationContextReference(directAnnotation.annotation), ]) : promptRef.current; + const strandedThread = threadContextsOutsideEnvironment({ + text: promptForSend, + records: composerThreadContexts, + environmentId, + })[0]; + if (strandedThread) { + const owner = environmentById.get(strandedThread.environmentId)?.label ?? "its machine"; + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Attached thread is on another machine", + description: `This machine's agent can't read "${strandedThread.title}". Remove it, or switch back to ${owner} to send.`, + }), + ); + return; + } if (queuedRunEdit.isEditing) { // Edit mode repurposes the composer: sending saves the queued message // in place instead of dispatching a new turn. diff --git a/apps/web/src/components/ThreadContextChip.tsx b/apps/web/src/components/ThreadContextChip.tsx index fb9d020a570e..f120cbde298e 100644 --- a/apps/web/src/components/ThreadContextChip.tsx +++ b/apps/web/src/components/ThreadContextChip.tsx @@ -9,6 +9,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function ThreadContextChip(props: { record: Pick; copyMarkdown?: string; + unreadable?: boolean; }) { const { environmentId, threadId } = props.record; const shell = useThreadShell({ environmentId, threadId }); @@ -19,6 +20,7 @@ export function ThreadContextChip(props: { render={ } aria-label={`Thread, ${title}`} data-markdown-copy={props.copyMarkdown} @@ -29,7 +31,13 @@ export function ThreadContextChip(props: { } /> - {shell ? "Open thread" : "Thread no longer available"} + + {props.unreadable + ? "On another machine, so this machine's agent can't read it. Remove it or switch back to send." + : shell + ? "Open thread" + : "Thread no longer available"} + ); } diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx index 81d8fb4f0e17..c642a29c89a0 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -323,6 +323,16 @@ function ComposerPreviewAnnotationDetails({ ); } +function ComposerThreadContextChip(props: { record: ThreadContextRecord }) { + const { environmentId } = use(ComposerContextActionsContext); + return ( + + ); +} + function UnresolvedContextChip(props: { label: string }) { return ( entry.kind === "thread", render: (entry, context) => entry.kind === "thread" ? ( - + ) : ( ), diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index b1a41b2eac3f..aceb63698ea8 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -1,4 +1,9 @@ -import type { ComposerContextId, ComposerContextRecord } from "@t3tools/contracts"; +import { + type ComposerContextId, + type ComposerContextRecord, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -11,6 +16,7 @@ import { projectComposerContextForProvider, replaceComposerContextReferences, sanitizeComposerContextLabel, + threadContextsOutsideEnvironment, toComposerContextId, toKindScopedComposerContextId, } from "./composerContextReferences.ts"; @@ -301,6 +307,48 @@ describe("provider projection", () => { }); }); +describe("threadContextsOutsideEnvironment", () => { + it("returns only referenced foreign threads, ignoring local and removed references", () => { + const local = { + version: 1 as const, + kind: "thread" as const, + contextId: ctx("thread_local"), + label: "Local thread", + environmentId: EnvironmentId.make("env-local"), + threadId: ThreadId.make("local"), + title: "Local thread", + }; + const foreign = { + ...local, + contextId: ctx("thread_foreign"), + environmentId: EnvironmentId.make("env-foreign"), + threadId: ThreadId.make("foreign"), + }; + const removed = { + ...foreign, + contextId: ctx("thread_removed"), + threadId: ThreadId.make("removed"), + }; + const records = [local, foreign, removed]; + const localReference = "[Local](t3-context://v1/thread/thread_local)"; + + expect( + threadContextsOutsideEnvironment({ + text: `${localReference} [Foreign](t3-context://v1/thread/thread_foreign)`, + records, + environmentId: local.environmentId, + }), + ).toEqual([foreign]); + expect( + threadContextsOutsideEnvironment({ + text: localReference, + records, + environmentId: local.environmentId, + }), + ).toEqual([]); + }); +}); + describe("toComposerContextId", () => { it("keeps same-kind raw IDs distinct when one contains the namespace prefix", () => { expect(toKindScopedComposerContextId("image", "x")).toBe("image_x"); diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index 8bad6dcd3f73..a9815ba57cbc 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -4,7 +4,9 @@ import { type ComposerContextKind, type ComposerContextRecord, type ElementContextDetails, + type EnvironmentId, type KnownComposerContextRecord, + type ThreadContextRecord, } from "@t3tools/contracts"; const CONTEXT_PROTOCOL = "t3-context:"; @@ -320,6 +322,24 @@ export function projectComposerContextForProvider(input: { return `${body}\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n${entries.join("\n")}\n`; } +// Thread reads are environment-local even when a draft moves between machines. +export function threadContextsOutsideEnvironment(input: { + text: string; + records: ReadonlyArray; + environmentId: EnvironmentId; +}): ThreadContextRecord[] { + const referenced = new Set( + collectComposerContextReferences(input.text).map((occurrence) => occurrence.contextId), + ); + return input.records.filter( + (record): record is ThreadContextRecord => + record.kind === "thread" && + "threadId" in record && + referenced.has(record.contextId) && + record.environmentId !== input.environmentId, + ); +} + export function remapComposerContextAttachments( context: import("@t3tools/contracts").OrchestrationMessageContext | undefined, before: ReadonlyArray<{ readonly id?: string | undefined }>, From 28059f6ed31b4358c5eedeb9115ceb68d6acc347 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Mon, 19 Oct 2026 22:16:54 +0100 Subject: [PATCH 155/898] fix(mobile): lift thread status above composer suggestions (#17964) Co-authored-by: Julius Marminge --- .../threads/ComposerPopoverAnchor.android.tsx | 17 +++++++++++++-- .../threads/ComposerPopoverAnchor.tsx | 21 ++++++++++++++++++- .../features/threads/ComposerPopoverHost.tsx | 8 ++++++- .../features/threads/ThreadDetailScreen.tsx | 14 +++++++++++-- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx b/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx index 172d3266bc7d..6fe265f99b55 100644 --- a/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx +++ b/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx @@ -16,6 +16,7 @@ export function ComposerPopoverAnchor(props: { readonly children: ReactNode }) { const host = useComposerPopoverHost(); const anchorRef = useRef(null); const [frame, setFrame] = useState(null); + const popoverHeight = host?.popoverHeight; const measure = () => { const hostView = host?.hostRef.current; @@ -41,14 +42,26 @@ export function ComposerPopoverAnchor(props: { readonly children: ReactNode }) { className="absolute top-0 justify-end" style={{ left: frame.x, width: frame.width, height: frame.y - COMPOSER_GAP }} > - {props.children} + { + popoverHeight?.set(event.nativeEvent.layout.height); + }} + > + {props.children} + ), ); }); const setHostContent = host?.setContent; - useEffect(() => () => setHostContent?.(null), [setHostContent]); + useEffect( + () => () => { + setHostContent?.(null); + popoverHeight?.set(0); + }, + [setHostContent, popoverHeight], + ); if (host === null) { return {props.children}; diff --git a/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx b/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx index c1292873d859..ecf9314791ab 100644 --- a/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx +++ b/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx @@ -1,6 +1,25 @@ import type { ReactNode } from "react"; +import { useEffect } from "react"; import { View } from "react-native"; +import { useComposerPopoverHost } from "./ComposerPopoverHost"; + export function ComposerPopoverAnchor(props: { readonly children: ReactNode }) { - return {props.children}; + const popoverHeight = useComposerPopoverHost()?.popoverHeight; + useEffect( + () => () => { + popoverHeight?.set(0); + }, + [popoverHeight], + ); + return ( + { + popoverHeight?.set(event.nativeEvent.layout.height); + }} + > + {props.children} + + ); } diff --git a/apps/mobile/src/features/threads/ComposerPopoverHost.tsx b/apps/mobile/src/features/threads/ComposerPopoverHost.tsx index e3ee8525a38c..818ef490a5b0 100644 --- a/apps/mobile/src/features/threads/ComposerPopoverHost.tsx +++ b/apps/mobile/src/features/threads/ComposerPopoverHost.tsx @@ -1,11 +1,13 @@ import type { ReactNode, RefObject } from "react"; import { createContext, useContext, useMemo, useRef, useState } from "react"; import { View, type ViewInstance } from "react-native"; +import type { SharedValue } from "react-native-reanimated"; type ComposerPopoverHostValue = { readonly hostRef: RefObject; readonly setContent: (content: ReactNode) => void; readonly layoutVersion: number; + readonly popoverHeight: SharedValue; }; const ComposerPopoverHostContext = createContext(null); @@ -21,12 +23,16 @@ export function useComposerPopoverHost() { */ export function ComposerPopoverHost(props: { readonly hidden: boolean; + readonly popoverHeight: SharedValue; readonly children: ReactNode; }) { const hostRef = useRef(null); const [content, setContent] = useState(null); const [layoutVersion, setLayoutVersion] = useState(0); - const value = useMemo(() => ({ hostRef, setContent, layoutVersion }), [layoutVersion]); + const value = useMemo( + () => ({ hostRef, setContent, layoutVersion, popoverHeight: props.popoverHeight }), + [layoutVersion, props.popoverHeight], + ); return ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index bb36fce9bcb9..500abb7575ad 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -695,6 +695,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai const userInputCardProgress = useSharedValue(1); const userInputInsetProgress = useSharedValue(1); const userInputCardCoverage = useSharedValue(0); + const composerPopoverHeight = useSharedValue(0); const floatingControlCoverage = useSharedValue( showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0, ); @@ -721,9 +722,18 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai }, [userInputCoverageApplies], ); + const composerPopoverLift = useDerivedValue(() => + withTiming( + !composerSlotHidden && composerPopoverHeight.value > 0 ? composerPopoverHeight.value + 8 : 0, + { duration: 180, easing: Easing.out(Easing.cubic), reduceMotion: ReduceMotion.System }, + ), + ); const floatingControlLift = useDerivedValue( () => - userInputCoverageApplies ? userInputCardProgress.value * userInputCardCoverage.value : 0, + Math.max( + userInputCoverageApplies ? userInputCardProgress.value * userInputCardCoverage.value : 0, + composerPopoverLift.value, + ), [userInputCoverageApplies], ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); @@ -1127,7 +1137,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai style={{ position: "absolute", bottom: 0, left: 0, right: 0, top: 0 }} offset={{ closed: 0, opened: 0 }} > -