From e57ae5a0bf38a4a38018df57d250fce323702401 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 30 Oct 2026 16:39:52 +0000 Subject: [PATCH 001/379] feat(web): pasted text, voice notes, snapshots and sending files get their own shapes (#27377) --- apps/web/src/components/ContextChip.tsx | 9 +- .../chat/MessagesTimeline.browser.test.tsx | 75 +++++++++++ .../chat/TimelineUserMessageRow.logic.ts | 6 +- .../chat/TimelineUserMessageRow.tsx | 121 +++++++++++++----- .../chat/UserMessageContextChips.tsx | 11 +- .../web/src/components/files/AudioPreview.tsx | 4 +- .../components/files/InlineAudioPreview.tsx | 2 + 7 files changed, 189 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index fcd4c9e4fed30..a536f28ccfb12 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -35,6 +35,10 @@ const contextChipVariants = cva( "doc-slides": "[--context-chip-accent:var(--context-chip-doc-slides)]", "doc-epub": "[--context-chip-accent:var(--context-chip-doc-epub)]", }, + shape: { + inline: "", + bubble: "w-64 gap-2 rounded-2xl bg-message px-3 py-2 text-sm text-message-foreground", + }, state: { unresolved: "data-[state=unresolved]:[--context-chip-accent:currentColor]", invalid: @@ -49,11 +53,12 @@ type ContextChipKind = NonNullable["kin interface ContextChipProps extends useRender.ComponentProps<"span"> { kind?: ContextChipKind; state?: "unresolved" | "invalid"; + shape?: "inline" | "bubble"; } -function ContextChip({ className, kind, state, render, ...props }: ContextChipProps) { +function ContextChip({ className, kind, state, shape, render, ...props }: ContextChipProps) { const defaultProps = { - className: cn(contextChipVariants({ kind, state }), className), + className: cn(contextChipVariants({ kind, state, shape }), className), "data-slot": "context-chip", "data-state": state, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 2c7aa332756cb..24e15957192b1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -3,6 +3,7 @@ import { onTestFinished } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; import { stubGlobal } from "../../../test/render"; import { + ComposerContextId, EnvironmentId, MessageId, RunAttemptId, @@ -32,6 +33,11 @@ import { toastManager } from "../ui/toast"; import type { CitedHistory } from "./citationProcedure"; import type { TimelineNavigation } from "./timelineViewport"; import { rememberTimelinePosition } from "./timelineScrollAnchoring"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + decodeComposerContextFragment, +} from "@t3tools/shared/composerContextClipboard"; +import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; const environmentQuery = vi.hoisted(() => ({ error: "Pull request not found", @@ -642,6 +648,75 @@ describe("MessagesTimeline agent messages", () => { }); describe("MessagesTimeline attachments", () => { + it.each(["image", "file"] as const)( + "copies only matching context records for a wordless %s attachment", + async (kind) => { + const attachment = { + type: kind, + id: "media", + name: kind === "image" ? "photo.png" : "clip.mp4", + mimeType: kind === "image" ? "image/png" : "video/mp4", + sizeBytes: 100, + downloadable: false, + }; + const record = { + version: 1 as const, + ...(kind === "image" ? { kind: "image" as const } : { kind: "file" as const }), + contextId: ComposerContextId.make("media-context"), + label: attachment.name, + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + await using screen = await renderTimeline([message("", { attachments: [attachment] })]); + expect(screen.buttons("Copy message")).toHaveLength(0); + await screen.rerender( + , + ); + expect(screen.buttons("Copy message")).toHaveLength(0); + await screen.rerender( + , + ); + using write = vi.spyOn(navigator.clipboard, "write").mockResolvedValue(); + await click(screen.button("Copy message")); + await vi.waitFor(() => expect(write).toHaveBeenCalledOnce()); + const [item] = write.mock.calls[0]![0]; + const text = await (await item!.getType("text/plain")).text(); + const fragment = await (await item!.getType(COMPOSER_CONTEXT_CLIPBOARD_MIME)).text(); + expect( + collectComposerContextReferences(text).map((reference) => reference.contextId), + ).toEqual([record.contextId]); + expect(decodeComposerContextFragment(fragment)?.records).toEqual([record]); + }, + ); + it.each(["loadedmetadata", "loadeddata", "canplay"])( "plays audio with unknown duration after %s and updates the total later", async (readyEvent) => { diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts b/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts index 9738e29d7ccaa..2bb6d0470fa18 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts +++ b/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts @@ -1,7 +1,11 @@ -import { type ChatImageAttachment } from "../../types"; +import { type ChatFileAttachment, type ChatImageAttachment } from "../../types"; import type { ComposerContextRecord, KnownComposerContextRecord } from "@t3tools/contracts"; import { asKnownContextRecord } from "~/lib/composerContextRecords"; +export function userMessageFileLabel(file: Pick) { + return file.source?._tag === "pasted-text" ? "Pasted text" : file.name; +} + export function resolvePreviewAnnotationImage(input: { record: Extract; recordsById: ReadonlyMap; diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index aee30e27d51ec..48086d40b4bf3 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -62,7 +62,10 @@ import { resolveUserMessageContext, selectedMessageContextFragment, } from "~/lib/composerContextRecords"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + collectComposerContextReferences, + formatComposerContextReference, +} from "@t3tools/shared/composerContextReferences"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, encodeComposerContextClipboardHtml, @@ -80,7 +83,10 @@ import { TimelineRowTimestamp, } from "./TimelineRowParts"; import { UserMessageContextReferenceChip } from "./UserMessageContextChips"; -import { resolvePreviewAnnotationImage } from "./TimelineUserMessageRow.logic"; +import { + resolvePreviewAnnotationImage, + userMessageFileLabel, +} from "./TimelineUserMessageRow.logic"; // Drawn inside the media so dark screenshots keep an edge on the dark background. const MEDIA_EDGE_CLASS_NAME = @@ -97,6 +103,8 @@ function UserFileCard({ readonly copyMarkdown: string | undefined; }) { const ctx = use(TimelineRowCtx); + const pastedText = file.source?._tag === "pasted-text"; + const label = userMessageFileLabel(file); const extension = /\.([a-z0-9]{1,8})$/i.exec(file.name)?.[1]?.toUpperCase() ?? "File"; const identity = ( <> @@ -105,10 +113,17 @@ function UserFileCard({ - {file.name} + {label} - {extension} · {formatBytes(file.sizeBytes)} + {file.downloadable === false ? ( + "Sending…" + ) : ( + <> + {!pastedText && `${extension} · `} + {formatBytes(file.sizeBytes)} + + )} @@ -119,12 +134,17 @@ function UserFileCard({ className="flex w-64 max-w-full items-center gap-1 rounded-2xl bg-message py-2 ps-2 pe-1 text-message-foreground" > {file.downloadable === false ? ( -
{identity}
+ <> +
{identity}
+ + ) : ( <> @@ -724,8 +773,8 @@ function UserTimelineRow({ row }: { row: Extract @@ -760,11 +809,12 @@ function UserTimelineRow({ row }: { row: Extract {!mediaAboveText && mediaBlock} {userAudio.length > 0 ? ( -
+
{userAudio.map((file) => ( 0 || unknownAttachments.length > 0) ? (
{unchippedFiles.map((file) => { + const pastedText = file.source?._tag === "pasted-text"; + const label = userMessageFileLabel(file); const fileIdentity = ( <> - {file.name} + + {label} + {file.downloadable === false || pastedText ? ( + + {file.downloadable === false ? "Sending…" : formatBytes(file.sizeBytes)} + + ) : null} + ); if (file.downloadable !== false) { @@ -810,7 +869,7 @@ function UserTimelineRow({ row }: { row: Extract
); @@ -854,7 +913,7 @@ function UserTimelineRow({ row }: { row: Extract ) : null} - {fileCardsOnly ? null : ( + {fileCardsOnly || audioOnly ? null : (
)} - {resolvedContext.text && ( + {copyText && ( ; @@ -372,12 +373,14 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry } const attachment = context.attachment; const isVideo = isVideoAttachment(attachment); + const label = userMessageFileLabel(attachment); const disabled = attachment.downloadable === false && (!isVideo || attachment.previewUrl === undefined); - const size = formatAttachmentSize(record.sizeBytes); + const size = + attachment.downloadable === false ? "Sending…" : formatAttachmentSize(record.sizeBytes); return ( isVideo ? context.onExpandVideo(attachment) : context.onOpenFile(attachment) } - tooltip={`${record.name}\n${size}`} + tooltip={`${label}\n${size}`} /> ); }, diff --git a/apps/web/src/components/files/AudioPreview.tsx b/apps/web/src/components/files/AudioPreview.tsx index 0a8f4bcfe16c9..abd05015a9414 100644 --- a/apps/web/src/components/files/AudioPreview.tsx +++ b/apps/web/src/components/files/AudioPreview.tsx @@ -15,6 +15,7 @@ type AudioPreviewProps = { readonly revision?: string | null; readonly onError?: () => void; readonly compact?: boolean; + readonly variant?: "chip" | "bubble"; readonly failed?: boolean; readonly onOpen?: (() => void) | undefined; readonly onRetry?: () => void; @@ -111,6 +112,7 @@ function AudioChip(props: AudioPreviewProps) { @@ -140,7 +142,7 @@ function AudioChip(props: AudioPreviewProps) { > - + {open ? ( + + ); +} + +it.each([ + { width: 1920, height: 1080, axis: "width", typed: "2560", expected: "2560 × 1440" }, + { width: 390, height: 844, axis: "height", typed: "2532", expected: "1170 × 2532" }, +])("keeps each typed $axis digit until the locked size is committed", async (testCase) => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: `Viewport ${testCase.axis}` }); + await userEvent.clear(input); + await userEvent.type(input, testCase.typed); + await expect.element(input).toHaveValue(Number(testCase.typed)); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent(testCase.expected); +}); + +it("clamps a completed width below the locked minimum when leaving the toolbar", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "256"); + await expect.element(input).toHaveValue(256); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("427 × 240"); +}); + +it("still limits a completed locked size to the viewport area cap", async () => { + await using screen = await render(); + const input = screen.getByRole("spinbutton", { name: "Viewport width" }); + await userEvent.clear(input); + await userEvent.type(input, "3840"); + await expect.element(input).toHaveValue(3840); + await screen.getByRole("button", { name: "Leave toolbar" }).click(); + await expect + .element(screen.getByRole("status", { name: "Saved viewport" })) + .toHaveTextContent("2880 × 2880"); +}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index dd5b707cfebdb..977431aa6ff4d 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -51,6 +51,7 @@ export function BrowserDeviceToolbar({ const [customSize, setCustomSize] = useState<{ readonly width: string; readonly height: string; + readonly axis?: "width" | "height"; } | null>(null); const presentedSize = customSize ?? { width: String(setting.width), @@ -61,15 +62,25 @@ export function BrowserDeviceToolbar({ PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId) ? setting.presetId : RESPONSIVE_VALUE; - const customWidth = Number(presentedSize.width); - const customHeight = Number(presentedSize.height); + const typedWidth = Number(presentedSize.width); + const typedHeight = Number(presentedSize.height); + const { width: customWidth, height: customHeight } = + aspectRatio === null || customSize?.axis === undefined + ? { width: typedWidth, height: typedHeight } + : resizeFreeformViewport( + { width: typedWidth, height: typedHeight }, + { x: 0, y: 0 }, + 1, + customSize?.axis === "height" ? "south" : "east", + aspectRatio, + ); const customValid = - Number.isInteger(customWidth) && - Number.isInteger(customHeight) && - customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && - customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && - customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && + Number.isInteger(typedWidth) && + Number.isInteger(typedHeight) && + typedWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && + typedHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && + typedHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA; const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => { @@ -96,6 +107,7 @@ export function BrowserDeviceToolbar({ const next = { width: axis === "width" ? value : (current?.width ?? String(setting.width)), height: axis === "height" ? value : (current?.height ?? String(setting.height)), + axis, }; const numeric = Number(value); if ( @@ -115,7 +127,11 @@ export function BrowserDeviceToolbar({ axis === "width" ? "east" : "south", aspectRatio, ); - return { width: String(resized.width), height: String(resized.height) }; + return { + ...next, + width: axis === "width" ? value : String(resized.width), + height: axis === "height" ? value : String(resized.height), + }; }); }; From 87b36c77fb5cdb26911d2419f62a23dbab6becc5 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 30 Oct 2026 20:45:50 +0000 Subject: [PATCH 066/379] fix(codex): preserve partial answer messages and thread histories (#23388) Co-authored-by: SAPHID Co-authored-by: maria-rcks --- .../Adapters/CodexAdapterV2.test.ts | 35 ++++- .../Adapters/CodexAdapterV2.ts | 4 +- .../scripts/generate.ts | 23 +++ .../src/_generated/schema.gen.ts | 135 +++++++++++++++--- .../src/schema.test.ts | 46 ++++++ 5 files changed, 219 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 6413ed0bfe9ac..8efdc1b468b3a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -3201,7 +3201,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { answers: ReadonlyArray<{ readonly id: string; readonly text: string; - readonly phase?: "commentary" | "final_answer" | null; + readonly phase?: "commentary" | "partial_answer" | "final_answer" | null; readonly omitPhase?: boolean; readonly streamed?: boolean; readonly completionDelayMs?: number; @@ -3296,6 +3296,39 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); }; + it.effect.each([false, true])( + "preserves a final answer that repeats a preceding partial answer (streamed: %s)", + (streamed) => + Effect.scoped( + Effect.gen(function* () { + const transcript = finalAnswerTranscript("codex-partial-then-final", [ + { id: "partial", text: "CODEX_RECOVERY_OK", phase: "partial_answer", streamed }, + { id: "final", text: "CODEX_RECOVERY_OK", phase: "final_answer", streamed }, + ]); + const harness = yield* makeCodexReplayHarness(transcript); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-partial-then-final"), + text: "Reply with the requested recovery marker.", + }), + ); + yield* harness.awaitTerminal(1); + assert.deepEqual( + assistantMessages(harness.events).map((event) => event.message.text), + ["CODEX_RECOVERY_OK", "CODEX_RECOVERY_OK"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect.each([ { name: "a text reply gains each saved image once", diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 1ebb5b5fb12d4..37426780466f8 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -4769,7 +4769,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } if (payload.item.type === "agentMessage") { - if (payload.item.phase !== "commentary") { + if (payload.item.phase == null || payload.item.phase === "final_answer") { yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { const updated = new Map(current); const itemIds = new Set(updated.get(payload.turnId) ?? []); @@ -5132,7 +5132,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return; } - const finalAnswer = payload.item.phase !== "commentary"; + const finalAnswer = payload.item.phase == null || payload.item.phase === "final_answer"; if (finalAnswer) { yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { const updated = new Map(current); diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 23ce2e1edd360..8f9a45bb9293c 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -145,6 +145,29 @@ const ManualSchemas: Record = { // `account/read` decode and take the provider down with it. const DefinitionOverrides: Record = { PlanType: { type: "string" }, + // Newer custom Codex binaries emit partial answers before additional tools or text. + MessagePhase: { + description: + 'Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as "phase unknown" and keep compatibility behavior for legacy models.', + oneOf: [ + { + type: "string", + enum: ["commentary"], + description: + "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + }, + { + type: "string", + enum: ["partial_answer"], + description: "Stable answer text that may be followed by more assistant output or tools.", + }, + { + type: "string", + enum: ["final_answer"], + description: "The assistant's terminal answer text for the current turn.", + }, + ], + }, }; const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index 90006c016978b..90869b56aa89d 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -1532,13 +1532,16 @@ export const ClientRequest__InternalChatMessageMetadataPassthrough = Schema.Stru identifier: "ClientRequest__InternalChatMessageMetadataPassthrough", }); -export type ClientRequest__MessagePhase = "commentary" | "final_answer"; +export type ClientRequest__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const ClientRequest__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -2219,13 +2222,16 @@ export const ServerNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "ServerNotification__MemoryCitationEntry" }); -export type ServerNotification__MessagePhase = "commentary" | "final_answer"; +export type ServerNotification__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const ServerNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -5991,13 +5997,19 @@ export const V2ItemCompletedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ItemCompletedNotification__MemoryCitationEntry" }); -export type V2ItemCompletedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2ItemCompletedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ItemCompletedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -6538,13 +6550,19 @@ export const V2ItemStartedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ItemStartedNotification__MemoryCitationEntry" }); -export type V2ItemStartedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2ItemStartedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ItemStartedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -7915,13 +7933,19 @@ export const V2RawResponseItemCompletedNotification__InternalChatMessageMetadata identifier: "V2RawResponseItemCompletedNotification__InternalChatMessageMetadataPassthrough", }); -export type V2RawResponseItemCompletedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2RawResponseItemCompletedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2RawResponseItemCompletedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -8215,13 +8239,16 @@ export const V2ReviewStartResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ReviewStartResponse__MemoryCitationEntry" }); -export type V2ReviewStartResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ReviewStartResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ReviewStartResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -8927,13 +8954,16 @@ export const V2ThreadForkResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadForkResponse__MemoryCitationEntry" }); -export type V2ThreadForkResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadForkResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadForkResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -9382,13 +9412,19 @@ export const V2ThreadItemsListResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadItemsListResponse__MemoryCitationEntry" }); -export type V2ThreadItemsListResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadItemsListResponse__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadItemsListResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -9872,13 +9908,16 @@ export const V2ThreadListResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadListResponse__MemoryCitationEntry" }); -export type V2ThreadListResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadListResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadListResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -10364,13 +10403,19 @@ export const V2ThreadMetadataUpdateResponse__MemoryCitationEntry = Schema.Struct path: Schema.String, }).annotate({ identifier: "V2ThreadMetadataUpdateResponse__MemoryCitationEntry" }); -export type V2ThreadMetadataUpdateResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadMetadataUpdateResponse__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadMetadataUpdateResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -10822,13 +10867,16 @@ export const V2ThreadReadResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadReadResponse__MemoryCitationEntry" }); -export type V2ThreadReadResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadReadResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadReadResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -11382,13 +11430,16 @@ export const V2ThreadResumeParams__InternalChatMessageMetadataPassthrough = Sche identifier: "V2ThreadResumeParams__InternalChatMessageMetadataPassthrough", }); -export type V2ThreadResumeParams__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadResumeParams__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadResumeParams__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -11755,13 +11806,16 @@ export const V2ThreadResumeResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadResumeResponse__MemoryCitationEntry" }); -export type V2ThreadResumeResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadResumeResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadResumeResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -12202,13 +12256,16 @@ export const V2ThreadRevertResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadRevertResponse__MemoryCitationEntry" }); -export type V2ThreadRevertResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadRevertResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadRevertResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -12841,13 +12898,19 @@ export const V2ThreadStartedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadStartedNotification__MemoryCitationEntry" }); -export type V2ThreadStartedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadStartedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadStartedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -13469,13 +13532,16 @@ export const V2ThreadStartResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadStartResponse__MemoryCitationEntry" }); -export type V2ThreadStartResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadStartResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2ThreadStartResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -13910,13 +13976,19 @@ export const V2ThreadTurnsListResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadTurnsListResponse__MemoryCitationEntry" }); -export type V2ThreadTurnsListResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadTurnsListResponse__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadTurnsListResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -14384,13 +14456,19 @@ export const V2ThreadUnarchiveResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2ThreadUnarchiveResponse__MemoryCitationEntry" }); -export type V2ThreadUnarchiveResponse__MessagePhase = "commentary" | "final_answer"; +export type V2ThreadUnarchiveResponse__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2ThreadUnarchiveResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -14785,13 +14863,19 @@ export const V2TurnCompletedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2TurnCompletedNotification__MemoryCitationEntry" }); -export type V2TurnCompletedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2TurnCompletedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2TurnCompletedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -15200,13 +15284,19 @@ export const V2TurnStartedNotification__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2TurnStartedNotification__MemoryCitationEntry" }); -export type V2TurnStartedNotification__MessagePhase = "commentary" | "final_answer"; +export type V2TurnStartedNotification__MessagePhase = + | "commentary" + | "partial_answer" + | "final_answer"; export const V2TurnStartedNotification__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), @@ -15729,13 +15819,16 @@ export const V2TurnStartResponse__MemoryCitationEntry = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2TurnStartResponse__MemoryCitationEntry" }); -export type V2TurnStartResponse__MessagePhase = "commentary" | "final_answer"; +export type V2TurnStartResponse__MessagePhase = "commentary" | "partial_answer" | "final_answer"; export const V2TurnStartResponse__MessagePhase = Schema.Union( [ Schema.Literal("commentary").annotate({ description: "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", }), + Schema.Literal("partial_answer").annotate({ + description: "Stable answer text that may be followed by more assistant output or tools.", + }), Schema.Literal("final_answer").annotate({ description: "The assistant's terminal answer text for the current turn.", }), diff --git a/packages/effect-codex-app-server/src/schema.test.ts b/packages/effect-codex-app-server/src/schema.test.ts index 2f6fefde0d606..dfbedf86af978 100644 --- a/packages/effect-codex-app-server/src/schema.test.ts +++ b/packages/effect-codex-app-server/src/schema.test.ts @@ -33,6 +33,52 @@ it("keeps async questions in live notifications and thread history", () => { } }); +it("keeps partial answers in notifications and persisted thread histories", () => { + const item = { + type: "agentMessage", + id: "partial-1", + text: "A stable answer before more tool work.", + phase: "partial_answer", + } as const; + for (const schema of [ + CodexSchema.ServerNotification__ThreadItem, + CodexSchema.V2ItemStartedNotification__ThreadItem, + CodexSchema.V2ItemCompletedNotification__ThreadItem, + CodexSchema.V2ThreadReadResponse__ThreadItem, + CodexSchema.V2ThreadResumeResponse__ThreadItem, + CodexSchema.V2ThreadForkResponse__ThreadItem, + CodexSchema.V2TurnCompletedNotification__ThreadItem, + ]) { + assert.deepEqual(Schema.decodeSync(schema)(item), item); + } + const response = { + approvalPolicy: "never", + approvalsReviewer: "user", + cwd: "/tmp/project", + model: "gpt-5.6-sol", + modelProvider: "openai", + sandbox: { type: "dangerFullAccess" }, + thread: { + cliVersion: "0.162.0", + createdAt: 0, + updatedAt: 0, + cwd: "/tmp/project", + ephemeral: false, + id: "thread-1", + modelProvider: "openai", + preview: "", + projectId: null, + sessionId: "session-1", + source: "cli", + status: { type: "idle" }, + turns: [{ id: "turn-1", status: "completed", items: [item] }], + }, + }; + assert.equal(isThreadForkResponse(response), true); + assert.equal(isThreadResumeResponse(response), true); + assert.equal(isThreadReadResponse({ thread: response.thread }), true); +}); + it("accepts Codex 0.150 multi-agent values", () => { const schemas = [ CodexSchema.ServerNotification__SubAgentActivityKind, From e92981dcf3f79c13aff01f975d68ea80bf4b2770 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:46:05 -0500 Subject: [PATCH 067/379] fix(server): list folders named with a backslash on Linux and macOS (#23398) Co-authored-by: Claude Opus 5.5 --- .../src/workspace/WorkspacePaths.test.ts | 22 +++++++++++++++++++ apps/server/src/workspace/WorkspacePaths.ts | 9 ++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index 574904b343563..b178c621453b8 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -5,6 +5,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as WorkspacePaths from "./WorkspacePaths.ts"; const layerTest = Layer.empty.pipe( @@ -58,5 +60,25 @@ it.layer(layerTest)("WorkspacePathsLive", (it) => { ); }), ); + + // On Windows `\` is a separator, so these names cannot exist there. + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "keeps a backslash in a name as part of the name", + () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + + for (const relativePath of ["weird\\name.txt", "..\\..\\etc"]) { + expect( + yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: cwd, + relativePath, + }), + ).toEqual({ absolutePath: path.join(cwd, relativePath), relativePath }); + } + }), + ); }); }); diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 6b748e19396ec..ea183ed43e1ab 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -103,10 +103,6 @@ export class WorkspacePaths extends Context.Service< } >()("t3/workspace/WorkspacePaths") {} -function toPosixRelativePath(input: string): string { - return input.replaceAll("\\", "/"); -} - const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -188,7 +184,10 @@ const make = Effect.gen(function* () { } const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); + // Only the host separator becomes `/`: on POSIX `\` is a file name character. + const relativeToRoot = path + .relative(input.workspaceRoot, absolutePath) + .replaceAll(path.sep, "/"); if ( relativeToRoot.length === 0 || relativeToRoot === "." || From 14d56e18f94b73fc5a1cecf2078d8530ab1304ad Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:16:16 +0530 Subject: [PATCH 068/379] fix(server): preserve byte-order marks in Git diff contents (#23421) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 21 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 3566a48fc4caf..c0f0967725e58 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2536,6 +2536,27 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each([ + { scenario: "kept", before: "\uFEFFold\n", after: "\uFEFFnew\n" }, + { scenario: "added", before: "same\n", after: "\uFEFFsame\n" }, + { scenario: "removed", before: "\uFEFFsame\n", after: "same\n" }, + ])("preserves byte-order marks in diff expansion when $scenario", ({ before, after }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "README.md", before); + yield* git(cwd, ["commit", "-am", "write original contents"]); + yield* writeTextFile(cwd, "README.md", after); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd), + ); + + assert.deepStrictEqual(contents, { oldContents: before, newContents: after }); + }), + ); + it.effect("reads a base ref that looks like an option as a revision", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 79086ab9e9fa1..421c240514e42 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -844,7 +844,7 @@ const collectOutput = Effect.fnUntraced(function* ( onLine: ((line: string) => Effect.Effect) | undefined, keepLineCallbacksAfterTruncation = false, ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { - const decoder = new TextDecoder(); + const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); const lineDecoder = keepLineCallbacksAfterTruncation && onLine ? new TextDecoder() : null; let bytes = 0; let text = ""; @@ -3562,7 +3562,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (bytes.includes(0)) { return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`); } - return new TextDecoder("utf-8").decode(bytes); + return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); }); const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( From c65e4883596e6ca06c71176006b1b7fb61995a2e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:46:32 -0500 Subject: [PATCH 069/379] fix(web): save settings fields only after an edit (#23427) Co-authored-by: GPT-6.1 Sol --- .../hooks/useCommitOnBlur.browser.test.tsx | 41 +++++++++++++++++++ apps/web/src/hooks/useCommitOnBlur.ts | 6 ++- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/hooks/useCommitOnBlur.browser.test.tsx diff --git a/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx b/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx new file mode 100644 index 0000000000000..cec4f8cb8ea5b --- /dev/null +++ b/apps/web/src/hooks/useCommitOnBlur.browser.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { DraftInput } from "../components/ui/draft-input"; + +describe("settings draft commits", () => { + it("preserves an external update when an untouched field loses focus", async () => { + let saved = "~/"; + const commit = (next: string) => { + saved = next; + }; + await using screen = await render(); + const input = screen.getByRole("textbox"); + await input.click(); + + saved = "/projects"; + await screen.rerender(); + await userEvent.tab(); + + expect(saved).toBe("/projects"); + }); + + it("preserves a saved edit when refocused before its server snapshot arrives", async () => { + let saved = "~/"; + const commit = (next: string) => { + saved = next; + }; + await using screen = await render(); + const input = screen.getByRole("textbox"); + await input.fill("/projects"); + await userEvent.tab(); + expect(saved).toBe("/projects"); + + await input.click(); + await screen.rerender(); + await userEvent.tab(); + + expect(saved).toBe("/projects"); + }); +}); diff --git a/apps/web/src/hooks/useCommitOnBlur.ts b/apps/web/src/hooks/useCommitOnBlur.ts index 6906402d718ab..022f79a2a58bb 100644 --- a/apps/web/src/hooks/useCommitOnBlur.ts +++ b/apps/web/src/hooks/useCommitOnBlur.ts @@ -1,7 +1,8 @@ -import { type ChangeEvent, type KeyboardEvent, useState } from "react"; +import { type ChangeEvent, type KeyboardEvent, useRef, useState } from "react"; export function useCommitOnBlur(value: string, onCommit: (next: string) => void) { const [draft, setDraft] = useState(null); + const valueOnFocus = useRef(value); return { value: draft ?? value, @@ -9,12 +10,13 @@ export function useCommitOnBlur(value: string, onCommit: (next: string) => void) setDraft(event.target.value); }, onFocus: () => { + valueOnFocus.current = value; setDraft(value); }, onBlur: () => { const next = draft ?? value; setDraft(null); - if (next !== value) { + if (next !== valueOnFocus.current && next !== value) { onCommit(next); } }, From 2b1e15c999aed3b8a06238b467d74f9cd9a1aeae Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:18:09 +0530 Subject: [PATCH 070/379] fix(shared): preserve hyphens in prerelease versions (#23430) Co-authored-by: maria-rcks --- packages/shared/src/semver.test.ts | 18 ++++++++++++++++++ packages/shared/src/semver.ts | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/semver.test.ts b/packages/shared/src/semver.test.ts index f77926313dd72..a66e5b07d8bcc 100644 --- a/packages/shared/src/semver.test.ts +++ b/packages/shared/src/semver.test.ts @@ -35,6 +35,24 @@ describe("semver helpers", () => { expect(compareSemverVersions("2.1.111-beta.1", "2.1.111")).toBeLessThan(0); }); + it("preserves hyphens within prerelease identifiers", () => { + expect(normalizeSemverVersion("20-alpha-1")).toBe("20.0.0-alpha-1"); + expect(parseSemver("1.2.3-alpha-1.2")).toEqual({ + major: 1, + minor: 2, + patch: 3, + prerelease: ["alpha-1", "2"], + }); + expect(parseSemver("1.2.3-x-y-z.--")?.prerelease).toEqual(["x-y-z", "--"]); + }); + + it("distinguishes releases whose identifiers differ after a hyphen", () => { + expect(compareSemverVersions("1.2.3-alpha-1", "1.2.3-alpha-2")).toBeLessThan(0); + expect(compareSemverVersions("1.2.3-alpha-2", "1.2.3-alpha-1")).toBeGreaterThan(0); + expect(compareSemverVersions("1.2.3-alpha-1.2", "1.2.3-alpha-1.10")).toBeLessThan(0); + expect(compareSemverVersions("1.2.3-alpha.1+build-2", "1.2.3-alpha.1+build-3")).toBe(0); + }); + it("supports comparison comparators", () => { expect(satisfiesSemverRange("24.9.0", ">=24.0 <24.10")).toBe(true); expect(satisfiesSemverRange("24.10.0", ">=24.0 <24.10")).toBe(false); diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts index 88ae4754f1809..8d1a354f008bc 100644 --- a/packages/shared/src/semver.ts +++ b/packages/shared/src/semver.ts @@ -8,7 +8,9 @@ interface ParsedSemver { const SEMVER_NUMBER_SEGMENT = /^\d+$/; export function normalizeSemverVersion(version: string): string { - const [main, prerelease] = version.trim().split("-", 2); + const [versionWithoutBuildMetadata = ""] = version.trim().split("+", 1); + const [main, ...prereleaseParts] = versionWithoutBuildMetadata.split("-"); + const prerelease = prereleaseParts.join("-"); const segments: string[] = []; for (const segment of (main ?? "").split(".")) { const trimmed = segment.trim(); @@ -26,7 +28,8 @@ export function normalizeSemverVersion(version: string): string { export function parseSemver(value: string): ParsedSemver | null { const normalized = normalizeSemverVersion(value).replace(/^v/, ""); - const [main = "", prerelease] = normalized.split("-", 2); + const [main = "", ...prereleaseParts] = normalized.split("-"); + const prerelease = prereleaseParts.join("-"); const segments = main.split("."); if (segments.length !== 3) { return null; From b0410a445397a695782e5ca27da8515e5f845d70 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:18:25 +0530 Subject: [PATCH 071/379] fix(browser): serialize concurrent profile clears (#23434) --- .../src/preview/ServerBrowserContexts.test.ts | 23 +++++++++++++++++++ .../src/preview/ServerBrowserContexts.ts | 2 ++ 2 files changed, 25 insertions(+) diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 3cab864de54f2..522df30fb66c8 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -183,6 +183,29 @@ describe("ServerBrowserContexts", () => { await pool.close(); }); + it("waits for the context to close when the same profile is cleared concurrently", async () => { + const human = makeContext(); + const closed = Promise.withResolvers(); + human.close.mockImplementation(async () => closed.promise); + launches.persistent.mockResolvedValue(human as unknown as BrowserContext); + const pool = new ServerBrowserContexts(options()); + await pool.contextFor("work/team"); + + const first = pool.clearProfile("work/team"); + const second = pool.clearProfile("work/team"); + const deletionsBeforeClose = launches.rm.mock.calls.length; + closed.resolve(); + await Promise.all([first, second]); + + expect(deletionsBeforeClose).toBe(0); + expect(human.close).toHaveBeenCalledTimes(1); + expect(launches.rm).toHaveBeenCalledExactlyOnceWith("/test/profiles/work%2Fteam", { + recursive: true, + force: true, + }); + await pool.close(); + }); + it("opens a profile requested during its clear only after the storage is deleted", async () => { launches.persistent.mockImplementation(async () => makeContext() as unknown as BrowserContext); const configuration = options(); diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 9f3fd48430752..2a7c342d30d81 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -212,6 +212,8 @@ export class ServerBrowserContexts { async clearProfile(profileId: string) { if (profileId === INCOGNITO_BROWSER_PROFILE_ID) return; const key = JSON.stringify([profileId, null]); + const existing = this.clearing.get(key); + if (existing) return existing; const clearing = (async () => { const pending = this.contexts.get(key); if (pending) { From 3a537d2c916e61493e91fd104e3fc525116d980d Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:18:41 +0530 Subject: [PATCH 072/379] fix(diagnostics): include browser trace failures (#23438) --- .../src/diagnostics/TraceDiagnostics.test.ts | 29 +++++++++++++++++++ .../src/diagnostics/TraceDiagnostics.ts | 27 ++++++++--------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index 97156800e3db9..35909007c395c 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -61,6 +61,35 @@ function readTraces(fileSystem: Partial) { } describe("TraceDiagnostics", () => { + it("includes browser failures and interruptions in diagnostics", () => { + const diagnostics = aggregateLines( + [ + { status: { code: "2", message: "Browser request failed" }, attributes: {} }, + { status: { code: "0" }, attributes: { "effect.fiber.interrupted": true } }, + { status: { code: "1", message: "Interrupted" }, attributes: {} }, + { status: { code: "1" }, attributes: {} }, + ].map((outcome, index) => + JSON.stringify({ + type: "otlp-span", + name: "browser.request", + traceId: `trace-${index}`, + spanId: `span-${index}`, + startTimeUnixNano: ns(index * 100), + endTimeUnixNano: ns(index * 100 + 10), + durationMs: 10, + ...outcome, + }), + ), + ); + + assert.equal(diagnostics.recordCount, 4); + assert.equal(diagnostics.failureCount, 1); + assert.equal(diagnostics.interruptionCount, 2); + assert.equal(diagnostics.topSpansByCount[0]?.failureCount, 1); + assert.equal(diagnostics.commonFailures[0]?.cause, "Browser request failed"); + assert.equal(diagnostics.latestFailures[0]?.cause, "Browser request failed"); + }); + it.effect("aggregates failures, slow spans, log levels, and parse errors", () => Effect.sync(() => { const diagnostics = aggregateLines([ diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index 0cd5ae9b3a750..6f25c424a7b83 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -18,17 +18,6 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -interface TraceRecordLike { - readonly name?: unknown; - readonly traceId?: unknown; - readonly spanId?: unknown; - readonly startTimeUnixNano?: unknown; - readonly endTimeUnixNano?: unknown; - readonly durationMs?: unknown; - readonly exit?: unknown; - readonly events?: unknown; -} - interface TraceEventLike { readonly name?: unknown; readonly timeUnixNano?: unknown; @@ -85,7 +74,7 @@ export function toRotatedTracePaths( return [...backups, traceFilePath]; } -function isRecordObject(value: unknown): value is TraceRecordLike { +function isRecordObject(value: unknown): value is Readonly> { return typeof value === "object" && value !== null; } @@ -253,8 +242,13 @@ export function makeTraceDiagnosticsAggregator( lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; const exitTag = readExitTag(parsed.exit); - const isFailure = exitTag === "Failure"; - const isInterrupted = exitTag === "Interrupted"; + const status = isRecordObject(parsed.status) ? parsed.status : {}; + const attributes = isRecordObject(parsed.attributes) ? parsed.attributes : {}; + const isFailure = exitTag === "Failure" || status.code === "2"; + const isInterrupted = + exitTag === "Interrupted" || + attributes["effect.fiber.interrupted"] === true || + status.message === "Interrupted"; if (isFailure) failureCount += 1; if (isInterrupted) interruptionCount += 1; @@ -277,7 +271,10 @@ export function makeTraceDiagnosticsAggregator( insertBounded(slowestSpans, spanItem, TOP_LIMIT, slowestFirst); if (isFailure) { - const cause = readExitCause(parsed.exit); + const cause = + exitTag === "Failure" + ? readExitCause(parsed.exit) + : (toStringValue(status.message)?.trim() ?? "Failure"); insertBounded(latestFailures, { ...spanItem, cause }, RECENT_LIMIT, latestEndedFirst); const failureKey = `${name}\0${cause}`; From 9522c7dd8f7050db198ee758e66bc62a146db1e5 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:18:57 +0530 Subject: [PATCH 073/379] fix(acp): forward empty plan replacements (#23440) --- .../src/server/runtimeModel.test.ts | 29 +++++++++++++++++++ .../provider-acp/src/server/runtimeModel.ts | 20 ++++++------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index e60671c775d51..45063ffc325dd 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -329,6 +329,35 @@ describe("AcpRuntimeModel", () => { ).toBe(true); }); + it("forwards an empty ACP v1 plan to clear previous entries", () => { + const entries = [ + [{ content: "Inspect state", priority: "high", status: "pending" }], + [], + ] as const; + const updates = entries.flatMap( + (plan) => + parseSessionUpdateEvent({ + sessionId: "session-1", + update: { sessionUpdate: "plan", entries: plan }, + }).events, + ); + + expect(updates).toMatchObject([ + { + _tag: "PlanUpdated", + payload: { + nativePlanId: "legacy", + kind: "items", + plan: [{ step: "Inspect state", status: "pending" }], + }, + }, + { + _tag: "PlanUpdated", + payload: { nativePlanId: "legacy", kind: "items", plan: [] }, + }, + ]); + }); + it("projects typed ACP plan and content updates", () => { const planResult = parseSessionUpdateEvent({ sessionId: "session-1", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 43fa2bf758e2f..ca6ed15db80d1 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1392,17 +1392,15 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, status: normalizePlanStepStatus(entry.status), })); - if (plan.length > 0) { - events.push({ - _tag: "PlanUpdated", - payload: { - nativePlanId: "legacy", - kind: "items", - plan, - }, - rawPayload: params, - }); - } + events.push({ + _tag: "PlanUpdated", + payload: { + nativePlanId: "legacy", + kind: "items", + plan, + }, + rawPayload: params, + }); break; } case "plan_update": { From b665f0b11c351935bea5aa633416f9a32c9c6b08 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:49:13 -0500 Subject: [PATCH 074/379] fix(web): recognize plain text aliases in code fences (#23446) Co-authored-by: GPT-6.1 Sol --- apps/web/src/composer-code-languages.test.ts | 11 +++++++++++ apps/web/src/composer-code-languages.ts | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/composer-code-languages.test.ts diff --git a/apps/web/src/composer-code-languages.test.ts b/apps/web/src/composer-code-languages.test.ts new file mode 100644 index 0000000000000..0e8c3f0188f91 --- /dev/null +++ b/apps/web/src/composer-code-languages.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from "vite-plus/test"; + +import { codeLanguageEntry, languageOfInfoString } from "./composer-code-languages"; + +it("recognizes plain-text fence info strings without treating metadata as the language", () => { + const entries = ["Text", "TEXT", "plaintext", "txt"].map((language) => + codeLanguageEntry(languageOfInfoString(` ${language} title=notes.txt`)), + ); + + expect(entries).toEqual(Array.from({ length: 4 }, () => ({ id: "", label: "Plain text" }))); +}); diff --git a/apps/web/src/composer-code-languages.ts b/apps/web/src/composer-code-languages.ts index 42558162d9fde..641b75b6926df 100644 --- a/apps/web/src/composer-code-languages.ts +++ b/apps/web/src/composer-code-languages.ts @@ -47,12 +47,15 @@ const LANGUAGE_ALIASES: Record = { "c++": "cpp", js: "javascript", md: "markdown", + plaintext: "", py: "python", rb: "ruby", rs: "rust", sh: "bash", shell: "bash", + text: "", ts: "typescript", + txt: "", yml: "yaml", }; @@ -69,7 +72,7 @@ export function withInfoStringLanguage(info: string, language: string): string { } export function codeLanguageEntry(language: string): CodeBlockLanguage | null { - const id = language === "text" ? "" : (LANGUAGE_ALIASES[language.toLowerCase()] ?? language); + const id = LANGUAGE_ALIASES[language.toLowerCase()] ?? language; return CODE_BLOCK_LANGUAGES.find((entry) => entry.id === id.toLowerCase()) ?? null; } From 42ca5d560d053831e5c26ea826291d56ab94052c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:49:30 -0500 Subject: [PATCH 075/379] fix(web): keep deleted clipboard attachments out of the draft (#23447) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/chat/ChatComposer.tsx | 1 + .../useComposerContextImport.browser.test.tsx | 188 ++++++++++++++++++ .../chat/useComposerContextImport.ts | 17 ++ 3 files changed, 206 insertions(+) create mode 100644 apps/web/src/components/chat/useComposerContextImport.browser.test.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ae0bb19336e0e..e08e74a49063b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1356,6 +1356,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerContextRecords, composerImages, composerFilesRef, + promptRef, pendingThreadImportsRef, addComposerFilesToDraft, }); diff --git a/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx new file mode 100644 index 0000000000000..a050b0f51edf0 --- /dev/null +++ b/apps/web/src/components/chat/useComposerContextImport.browser.test.tsx @@ -0,0 +1,188 @@ +import { useEffect } from "react"; +import { type ComposerContextRecord, ComposerContextId, EnvironmentId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render, stubGlobal } from "../../../test/render"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { formatInlineContextReference } from "../../lib/composerContextReferences"; +import { rememberCopiedComposerAttachments } from "../../lib/copiedComposerAttachments"; +import { pendingDraftWork } from "./pendingDraftWork"; +import { useComposerContextImport } from "./useComposerContextImport"; + +vi.mock("~/state/use-atom-query-runner", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomQueryRunner: () => async () => ({ _tag: "Success", value: { relativeUrl: "/file" } }), +})); +vi.mock("../../state/use-atom-command", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomCommand: () => () => {}, +})); +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + readPreparedConnection: () => ({ httpBaseUrl: "http://localhost" }), +})); + +type ImportInput = Parameters[0]; +type Importer = ReturnType; + +function Harness({ input, ready }: { input: ImportInput; ready: (value: Importer) => void }) { + const importer = useComposerContextImport(input); + useEffect(() => ready(importer)); + return null; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} + +it.each([ + { kind: "file", edit: "delete" }, + { kind: "image", edit: "delete" }, + { kind: "file", edit: "undo" }, + { kind: "file", edit: "retain" }, + { kind: "file", edit: "switch" }, + { kind: "file", edit: "cached" }, + { kind: "file", edit: "question" }, + { kind: "image", edit: "question" }, + { kind: "image", edit: "annotation" }, + { kind: "image", edit: "delete-annotation" }, +] as const)("settles a delayed $kind import after $edit", async ({ kind, edit }) => { + const target = DraftId.make(`clipboard-${kind}-${edit}`); + const store = useComposerDraftStore.getState(); + using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) }; + const sourceEnvironmentId = EnvironmentId.make("clipboard-source"); + rememberCopiedComposerAttachments( + sourceEnvironmentId, + edit === "cached" + ? new Map([["source-attachment", new File(["notes"], "notes.txt")]]) + : new Map(), + ); + using _copy = { + [Symbol.dispose]: () => rememberCopiedComposerAttachments(sourceEnvironmentId, new Map()), + }; + const promptRef = { current: "" }; + const downloaded = deferred(); + const requested = deferred(); + const settled = deferred(); + const input = { + environmentId: EnvironmentId.make("clipboard-target"), + linkedEnvironments: new Map(), + activeThread: undefined, + activeThreadId: null, + composerDraftTarget: target, + attachmentDraftTarget: target, + attachmentTargetKey: target, + attachmentTargetKeyRef: { current: target }, + questionAttachmentTarget: edit === "question" ? target : null, + pendingUserInputs: [], + composerContextRecords: new Map(), + composerImages: [], + composerFilesRef: { current: [] }, + pendingThreadImportsRef: { current: new Map() }, + promptRef, + addComposerFilesToDraft: (files: Parameters[1]) => + store.addFiles(target, files, { appendReference: edit !== "question" }), + } satisfies ImportInput & { promptRef: { current: string } }; + let importer!: Importer; + await using _screen = await render( + { + importer = value; + }} + />, + ); + const end = pendingDraftWork.end.bind(pendingDraftWork); + using _settled = vi.spyOn(pendingDraftWork, "end").mockImplementation((key) => { + end(key); + settled.resolve(); + }); + using _fetch = stubGlobal("fetch", () => { + requested.resolve(); + return downloaded.promise; + }); + const contextId = ComposerContextId.make(`${kind}_source`); + const attachmentFields = { + version: 1 as const, + contextId, + label: "notes.txt", + attachmentId: "source-attachment", + name: "notes.txt", + mimeType: kind === "file" ? "text/plain" : "image/png", + sizeBytes: 5, + }; + const attachment = ( + kind === "file" + ? { ...attachmentFields, kind: "file" as const } + : { ...attachmentFields, kind: "image" as const } + ) satisfies ComposerContextRecord; + const annotation = { + version: 1, + kind: "preview-annotation", + contextId: ComposerContextId.make("preview-annotation_source"), + label: "Page annotation", + annotationId: "source", + pageUrl: "http://localhost/page", + pageTitle: "Page", + comment: "Check this", + targetSummary: "Page", + styleChanges: [], + screenshotContextId: contextId, + } satisfies ComposerContextRecord; + const annotated = edit === "annotation" || edit === "delete-annotation"; + let pastedPrompt = ""; + await act(async () => { + const remapped = importer.importContextRecords( + annotated ? [attachment, annotation] : [attachment], + sourceEnvironmentId, + ); + pastedPrompt = formatInlineContextReference({ + kind: annotated ? "preview-annotation" : kind, + contextId: remapped.get(annotated ? annotation.contextId : contextId)!, + label: annotated ? "Page annotation" : "notes.txt", + }); + if (edit === "question") pastedPrompt = "Question answer"; + promptRef.current = pastedPrompt; + store.setPrompt(target, pastedPrompt); + if (edit !== "cached") await requested.promise; + }); + if (edit !== "cached") expect(pendingDraftWork.has(target)).toBe(true); + await act(() => { + if ( + edit !== "annotation" && + edit !== "retain" && + edit !== "cached" && + edit !== "question" && + edit !== "switch" + ) { + promptRef.current = "Keep this edit"; + store.setPrompt(target, promptRef.current); + } + if (edit === "undo") { + promptRef.current = pastedPrompt; + store.setPrompt(target, pastedPrompt); + } + if (edit === "switch") input.attachmentTargetKeyRef.current = DraftId.make("other-draft"); + }); + await act(async () => { + downloaded.resolve(new Response("notes", { headers: { "Content-Type": attachment.mimeType } })); + await settled.promise; + }); + expect(pendingDraftWork.has(target)).toBe(false); + const draft = store.getComposerDraft(target)!; + expect(draft.prompt).toBe(promptRef.current); + const attachments = kind === "file" ? draft.files : draft.images; + expect(attachments.map((file) => file.name)).toEqual( + edit === "undo" || + edit === "retain" || + edit === "annotation" || + edit === "cached" || + edit === "question" + ? ["notes.txt"] + : [], + ); +}); diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index e859e816074e6..74e97a5076d7d 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -39,6 +39,7 @@ import { import { assetEnvironment } from "~/state/assets"; import { copiedComposerAttachmentFile } from "~/lib/copiedComposerAttachments"; import { countQuestionAttachments, questionAttachmentDraftId } from "../../questionAttachments"; +import { collectInlineContextIds } from "../../lib/composerContextReferences"; import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; import { orchestrationEnvironment } from "~/state/orchestration"; import { pendingDraftWork } from "./pendingDraftWork"; @@ -87,6 +88,7 @@ export function useComposerContextImport(input: { readonly composerContextRecords: ComposerDraftContextRecords; readonly composerImages: ReadonlyArray; readonly composerFilesRef: ChatComposerProps["composerFilesRef"]; + readonly promptRef: RefObject; readonly pendingThreadImportsRef: RefObject>; readonly addComposerFilesToDraft: (files: ComposerFileAttachment[]) => string[]; }) { @@ -104,6 +106,7 @@ export function useComposerContextImport(input: { composerContextRecords, composerImages, composerFilesRef, + promptRef, pendingThreadImportsRef, addComposerFilesToDraft, } = input; @@ -191,6 +194,20 @@ export function useComposerContextImport(input: { fail("Downloading it from the source failed."); return; } + // Question attachments have no inline reference; annotations can own their screenshots. + const references = new Set(collectInlineContextIds(promptRef.current)); + if ( + !questionAttachmentTarget && + !references.has(toKindScopedComposerContextId(record.kind, localId)) && + !( + record.kind === "image" && + references.has(previewAnnotationContextId(localId)) && + getComposerDraft(composerDraftTarget)?.previewAnnotations.some( + (annotation) => annotation.id === localId, + ) + ) + ) + return; 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 From c40b32a729dcf21eb14aa9dcb140bf978303f9b1 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:49:45 -0500 Subject: [PATCH 076/379] fix(web): keep ACP agent logos in multi-model selections (#23448) Co-authored-by: GPT-6.1 Sol --- .../chat/ProviderModelPicker.browser.test.tsx | 90 +++++++++++++++++++ .../components/chat/ProviderModelPicker.tsx | 2 + 2 files changed, 92 insertions(+) create mode 100644 apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx new file mode 100644 index 0000000000000..72c9cdf39f23d --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx @@ -0,0 +1,90 @@ +import { ProviderDriverKind, ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; +import { useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; + +import { render } from "../../../test/render"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { ProviderModelPicker } from "./ProviderModelPicker"; + +it("keeps each registry image when adding a model from another ACP agent", async () => { + const icons = [ + "https://cdn.agentclientprotocol.com/registry/icons/picker-explicit.svg", + "https://cdn.agentclientprotocol.com/registry/v1/latest/picker-by-id.svg", + ]; + const cache = await caches.open("t3-acp-registry-icons-v1"); + await using _cachedIcons = { + async [Symbol.asyncDispose]() { + await Promise.all(icons.map((url) => cache.delete(url))); + }, + }; + for (const [index, url] of icons.entries()) { + await cache.put( + url, + new Response( + ``, + { headers: { "content-type": "image/svg+xml" } }, + ), + ); + } + const entries = deriveProviderInstanceEntries( + ["First", "Second"].map((name) => ({ + instanceId: ProviderInstanceId.make(name.toLowerCase()), + driver: ProviderDriverKind.make("acpRegistry"), + displayName: `${name} agent`, + enabled: true, + installed: true, + version: null, + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { slug: name.toLowerCase(), name: `${name} model`, isCustom: false, capabilities: {} }, + ], + slashCommands: [], + skills: [], + })), + ).map((entry, index) => ({ + ...entry, + ...(index === 0 ? { acpRegistryIconUrl: icons[0] } : { acpRegistryAgentId: "picker-by-id" }), + })); + const first = { instanceId: entries[0]!.instanceId, model: "first" }; + const options = new Map(entries.map((entry) => [entry.instanceId, entry.models])); + + function Picker() { + const [selection, setSelection] = useState>([first]); + return ( + 1 ? { selectedModels: selection } : {})} + lockedProvider={null} + instanceEntries={entries} + modelOptionsByInstance={options} + onToggleModel={(instanceId, model) => setSelection([...selection, { instanceId, model }])} + onInstanceModelChange={(instanceId, model) => setSelection([{ instanceId, model }])} + /> + ); + } + + await using screen = await render(); + const imageWidths = () => + Array.from( + screen.container.querySelectorAll("[data-chat-provider-model-picker] img"), + (image) => image.naturalWidth, + ); + await vi.waitFor(() => expect(imageWidths()).toEqual([11])); + await screen.getByRole("button", { name: "First model", exact: true }).click(); + await page.getByRole("button", { name: "Second agent", exact: true }).click(); + await userEvent.keyboard("{Shift>}"); + try { + await page.getByRole("option", { name: /Second model/ }).click(); + } finally { + await userEvent.keyboard("{/Shift}"); + } + await userEvent.keyboard("{Escape}"); + await vi.waitFor(() => expect(imageWidths()).toEqual([11, 17])); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 83e028a9bbcd2..1551ac881e73f 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -221,6 +221,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { driverKind={selection.entry.driverKind} displayName={selection.entry.displayName} accentColor={selection.entry.accentColor} + acpRegistryAgentId={selection.entry.acpRegistryAgentId} + acpRegistryIconUrl={selection.entry.acpRegistryIconUrl} className="size-4 rounded-full bg-(--chat-composer-glass-surface,var(--background)) ring-2 ring-(--chat-composer-glass-surface,var(--background))" iconClassName="size-4" /> From 6ad082745a5d19e8e5363e537b09d591e2cd0cbe Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:50:00 -0500 Subject: [PATCH 077/379] fix(web): name inherited background activity profiles (#23450) Co-authored-by: GPT-6.1 Sol --- .../settings/SettingInheritance.test.ts | 25 +++++++++++++++++++ .../settings/SettingInheritance.tsx | 10 ++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/web/src/components/settings/SettingInheritance.test.ts b/apps/web/src/components/settings/SettingInheritance.test.ts index e02bb6b3d35ec..527a1cdbbf58f 100644 --- a/apps/web/src/components/settings/SettingInheritance.test.ts +++ b/apps/web/src/components/settings/SettingInheritance.test.ts @@ -8,6 +8,31 @@ const environmentId = EnvironmentId.make("laptop"); const projectId = ProjectId.make("project"); describe("settingInheritanceLayers", () => { + it("identifies the background activity profile supplied by each layer", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + backgroundActivity: { + schemaVersion: 1 as const, + profile: "performance" as const, + overrides: {}, + }, + }; + const layers = settingInheritanceLayers( + { + environmentId, + label: "Laptop", + projectId: null, + ...resolveProjectSettings(settings, projectId), + }, + settings, + "backgroundActivity", + ); + expect(layers.map((layer) => [layer.key, layer.value, layer.effective])).toEqual([ + ["environment", "Performance", true], + ["built-in", "Balanced", false], + ]); + }); + it("shows the checkout's t3.json as a layer for file-backed keys", () => { const file = { defaultThreadEnvMode: "worktree" as const }; const fromFile = settingInheritanceLayers( diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index 692f254df1fad..051017a95894e 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -37,6 +37,13 @@ const WRITING_STYLE_LABELS: Record = { custom: "Custom instructions", }; +const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { + balanced: "Balanced", + performance: "Performance", + "battery-saver": "Battery saver", + custom: "Advanced", +}; + function formatValue(key: keyof ServerSettings, value: unknown): string { if (value === null || value === undefined) { return key === "pullRequestMergeMethod" @@ -75,6 +82,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { } if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`; if (typeof value === "object") { + if (key === "backgroundActivity" && "profile" in value && typeof value.profile === "string") { + return BACKGROUND_ACTIVITY_PROFILE_LABELS[value.profile] ?? value.profile; + } if ("model" in value && typeof value.model === "string") return value.model; if ("mode" in value && typeof value.mode === "string") { return WRITING_STYLE_LABELS[value.mode] ?? value.mode; From f8768d68b6e27b05b9a8cf4582002db2e4d12a8f Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 30 Oct 2026 13:50:17 -0700 Subject: [PATCH 078/379] fix(mobile): use the Android sheet header for Add environment (#23452) Co-authored-by: maria-rcks --- apps/mobile/src/Stack.tsx | 1 + .../settings/components/SettingsScreen.tsx | 31 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7a0042903a3c3..872ca142c8aaa 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -1006,6 +1006,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...HORIZONTAL_FORM_SHEET_OPTIONS, sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, + sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, }), NotFound: createNativeStackScreen({ diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index 05b267242d41c..b61a57c8b7536 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -2,6 +2,7 @@ import { useNavigation, useRoute } from "@react-navigation/native"; import type { ReactNode } from "react"; import { Platform, View } from "react-native"; +import { AndroidSheetHeader } from "../../../components/ui/AndroidScreenHeader"; import type { AppBarProps } from "../../../components/ui/AppBar.types"; import { AppBar } from "../../../components/ui/AppBar"; import { ScreenContent as SettingsScreenContent } from "../../../components/ui/ScreenContent"; @@ -43,18 +44,28 @@ export function SettingsScreen( return ( - navigation.goBack()} - hideBottomBorder={!props.formSheet} - /> + {Platform.OS === "android" && props.formSheet ? ( + navigation.goBack()} + hideBottomBorder + /> + ) : ( + navigation.goBack()} + hideBottomBorder={!props.formSheet} + /> + )} - {props.formSheet ? ( + {props.formSheet && Platform.OS !== "android" ? ( props.children ) : ( Date: Fri, 30 Oct 2026 15:50:33 -0500 Subject: [PATCH 079/379] fix(desktop): oversized preview events no longer take down the app (#23461) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- apps/desktop/src/preview/CdpRelay.test.ts | 82 +++++++++++++++---- apps/desktop/src/preview/CdpRelay.ts | 48 ++++++++++- .../src/preview/DesktopBrowserHost.test.ts | 71 +++++++++++++++- .../desktop/src/preview/DesktopBrowserHost.ts | 20 ++++- 4 files changed, 201 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 1c1f9dc078ca0..a2589edf8f7b2 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -14,6 +14,9 @@ const makeTarget = ( setDownloadDirectory, }); +const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => + createCdpRelayConnection(target, write, () => {}); + // Each relay reply resolves after a few microtasks; one macrotask lets them all land. // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -21,9 +24,7 @@ const settle = () => new Promise((resolve) => setImmediate(resolve)); describe("CDP relay", () => { it("announces only the tab's page, under its real target id", async () => { const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw))); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.createTarget", params: {} })); await settle(); @@ -51,9 +52,7 @@ describe("CDP relay", () => { new Promise((resolve) => pending.push(() => resolve({ method }))), ); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive( JSON.stringify({ id: 5, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), ); @@ -76,7 +75,7 @@ describe("CDP relay", () => { it("resumes paused child frames, so cross-site iframes run", async () => { const send = vi.fn(async () => ({})); - const relay = createCdpRelayConnection(makeTarget(send), () => {}); + const relay = makeRelay(makeTarget(send), () => {}); relay.receive( JSON.stringify({ id: 1, @@ -102,9 +101,7 @@ describe("CDP relay", () => { it("acknowledges Playwright's focus emulation without forcing it onto the guest", async () => { const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive( JSON.stringify({ id: 1, @@ -120,9 +117,7 @@ describe("CDP relay", () => { it("routes tab events to the page session once the page is announced", async () => { const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(vi.fn()), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw))); relay.event("Page.loadEventFired", {}, undefined); expect(written).toEqual([]); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); @@ -140,9 +135,7 @@ describe("CDP relay extra sessions", () => { it("gives a second page session the page's commands and events", async () => { const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = createCdpRelayConnection(makeTarget(send), (raw) => - written.push(JSON.parse(raw)), - ); + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.attachToBrowserTarget" })); await settle(); @@ -182,7 +175,7 @@ describe("CDP relay downloads", () => { const send = vi.fn(async () => ({})); const directories: Array = []; const written: Array> = []; - const relay = createCdpRelayConnection( + const relay = makeRelay( makeTarget(send, (directory) => directories.push(directory)), (raw) => written.push(JSON.parse(raw)), ); @@ -213,3 +206,58 @@ describe("CDP relay downloads", () => { ]); }); }); + +describe("CDP relay payload budget", () => { + // 2,048 references to one 40 KiB string: about 80 MiB once serialized, little memory here. + const oversized = Array(2048).fill("x".repeat(40 * 1024)); + + it("fails only an oversized reply and still returns a large screenshot", async () => { + const screenshot = "A".repeat(16 * 1024 * 1024); + const send = vi.fn(async (method: string) => + method === "DOM.getDocument" ? { nodes: oversized } : { data: screenshot }, + ); + const written: Array> = []; + const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); + relay.receive( + JSON.stringify({ id: 1, method: "DOM.getDocument", sessionId: "t3-preview-page" }), + ); + relay.receive( + JSON.stringify({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), + ); + await settle(); + + const replies = written.map(({ id, error, result }) => ({ + id, + error, + size: (result as { data?: string } | undefined)?.data?.length, + })); + expect(replies).toEqual([ + { id: 1, error: { code: -32000, message: "CDP payload too large" }, size: undefined }, + { id: 2, error: undefined, size: screenshot.length }, + ]); + }); + + it.each([ + { args: oversized }, + { args: "\u0001".repeat(12 * 1024 * 1024) }, + { args: Array(2_700_000).fill(-0.0000010000000000000002) }, + ])("closes instead of dropping an oversized event (%#)", async ({ args }) => { + const written: Array> = []; + const close = vi.fn(); + const relay = createCdpRelayConnection( + makeTarget(vi.fn()), + (raw) => written.push(JSON.parse(raw)), + close, + ); + relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); + await settle(); + written.length = 0; + + relay.event("Runtime.consoleAPICalled", { args }, ""); + relay.event("Page.loadEventFired", { timestamp: 1 }, ""); + + relay.receive(JSON.stringify({ id: 2, method: "Target.setAutoAttach" })); + expect(close).toHaveBeenCalledOnce(); + expect(written.map((message) => message["method"])).toEqual([]); + }); +}); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 948bc361f5d92..d52b0a3b2ab55 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -36,6 +36,29 @@ const isCommand = (value: unknown): value is CdpCommand => typeof (value as { id?: unknown }).id === "number" && typeof (value as { method?: unknown }).method === "string"; +// CDP values are plain JSON data, so their serialized size can be bounded before +// JSON.stringify allocates it. A string counts at its length unless it holds a +// character JSON may escape, which can take six code units. +const MAX_CDP_PAYLOAD_SIZE = 64 * 1024 * 1024; +const MAY_ESCAPE = /[^\x20\x21\x23-\x5b\x5d-\ud7ff\ue000-\uffff]/; +const serializedSize = (text: string) => + (MAY_ESCAPE.test(text) ? text.length * 6 : text.length) + 3; + +const fitsPayloadBudget = (message: unknown) => { + let remaining = MAX_CDP_PAYLOAD_SIZE; + const visit = (value: unknown): boolean => { + remaining -= typeof value === "string" ? serializedSize(value) : 26; + if (remaining < 0) return false; + if (typeof value !== "object" || value === null) return true; + if (Array.isArray(value)) return value.every(visit); + return Object.entries(value).every(([key, item]) => { + remaining -= serializedSize(key); + return visit(item); + }); + }; + return visit(message); +}; + const BROWSER_CONTEXT_ID = "t3-preview"; const ANNOUNCE = Symbol("announce"); const PAGE_SESSION_ID = "t3-preview-page"; @@ -50,8 +73,30 @@ const ACKNOWLEDGED = new Set([ export function createCdpRelayConnection( target: CdpRelayTarget, write: (message: string) => void, + close: () => void, ): CdpRelayConnection { - const send = (message: Record) => write(JSON.stringify(message)); + let closed = false; + const send = (message: Record) => { + if (closed) return; + if (fitsPayloadBudget(message)) { + write(JSON.stringify(message)); + return; + } + // An error reply fails only that command. A dropped event would leave + // Playwright out of step with the page, so the relay closes instead. + if (typeof message["id"] === "number") { + write( + JSON.stringify({ + id: message["id"], + error: { code: -32000, message: "CDP payload too large" }, + ...(message["sessionId"] === undefined ? {} : { sessionId: message["sessionId"] }), + }), + ); + return; + } + closed = true; + close(); + }; let attached = false; /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); @@ -151,6 +196,7 @@ export function createCdpRelayConnection( return { receive: (raw) => { + if (closed) return; let command: unknown; try { command = JSON.parse(raw); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 08fefd8d733f5..3168788bebe9b 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron debugger. -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; import { DesktopBrowserEvent } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -968,4 +968,73 @@ describe("DesktopBrowserHost", () => { expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(false); }), ); + + it.effect("detaches only the tab whose CDP event is too large to relay", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const tabs = [makeDebuggee({ deferFocus: true }), makeDebuggee()]; + const send = vi.spyOn(tabs[0]!.tab.debugger, "sendCommand"); + const keys = [key, { threadId: "thread-2", tabId: "tab-2" }]; + const events = yield* Queue.unbounded(); + yield* host.events.pipe( + Stream.runForEach((line) => + Queue.offer(events, decodeEvent(new TextDecoder().decode(line))), + ), + Effect.forkScoped, + ); + for (const [index, tabKey] of keys.entries()) { + host.attach(tabKey, tabs[index]!.tab); + expect(yield* Queue.take(events)).toEqual({ type: "attached", ...tabKey }); + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...tabKey, + message: encodeJson({ id: 1, method: "Target.setAutoAttach" }), + }), + ); + // The command's reply and the page announcement. + yield* Queue.takeN(events, 2); + } + + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ + id: 2, + method: "Target.setAutoAttach", + params: { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, + sessionId: "t3-preview-page", + }), + }), + ); + yield* command(host, 3, "Page.startScreencast"); + tabs[0]!.emit("Runtime.consoleAPICalled", { + args: Array(2048).fill("x".repeat(40 * 1024)), + }); + tabs[1]!.emit("Page.loadEventFired", { timestamp: 1 }); + const [detached, next] = yield* Queue.takeN(events, 2); + + const { type, threadId, tabId } = detached!; + expect({ type, threadId, tabId }).toEqual({ type: "detached", ...key }); + expect(send).toHaveBeenCalledWith("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }); + tabs[0]!.settleFocus(); + yield* settle; + expect(send).not.toHaveBeenCalledWith("Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + }); + expect(send).toHaveBeenCalledWith("Page.stopScreencast"); + expect(tabs[0]!.focusEmulation).toEqual([true, false]); + expect(next).toMatchObject({ type: "cdp", ...keys[1] }); + expect(JSON.parse((next as { message: string }).message)).toMatchObject({ + method: "Page.loadEventFired", + }); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 30f252e3a677e..5a2b5463fc674 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -329,7 +329,7 @@ export const make = Effect.gen(function* () { const forward = (current?: () => boolean) => { const change = tab.focusChange; const run = () => - current === undefined || current() + tab.relay === relay && (current === undefined || current()) ? send(method, params, sessionId) : Promise.reject(new Error("The page this screenshot was for has moved on.")); return change === null ? run() : change.then(run); @@ -372,6 +372,24 @@ export const make = Effect.gen(function* () { emit({ type: "cdp", ...tab.key, message }); } }, + // Announcing the tab again would reconnect Playwright, and the page could + // replay the same oversized event, so the tab stays detached. + () => { + if (tab.relay !== relay || tabs.get(keyOf(tab.key)) !== tab) return; + // Disabling auto-attach detaches child sessions and releases their debugger pauses. + void debuggee + .sendCommand("Target.setAutoAttach", { + autoAttach: false, + waitForDebuggerOnStart: false, + flatten: true, + }) + .catch(() => {}); + if (tab.screencasts.size > 0) { + void debuggee.sendCommand("Page.stopScreencast").catch(() => {}); + } + setFocusEmulation(tab, false); + detach(tab.key); + }, ); tab.relay = relay; return relay; From 8bbe5aa384ed40f1582c06b010ff649e7bec2107 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:50:49 -0500 Subject: [PATCH 080/379] fix(mobile): share pull request refresh subscriptions (#23464) Co-authored-by: GPT-6.1 Sol --- .../src/state/pullRequests.test.ts | 53 +++++++++++++++++++ .../client-runtime/src/state/pullRequests.ts | 17 +++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index a8627e2d9977e..18e33cbbcf559 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1177,6 +1177,59 @@ it.effect("keeps hover previews fresh after edits and turns", () => ), ); +it.effect("shares one refresh stream between independently created pull request readers", () => + Effect.scoped( + Effect.gen(function* () { + const revision = yield* SubscriptionRef.make(0); + let subscriptions = 0; + let title = "Original title"; + const client = { + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => + Stream.unwrap( + Effect.sync(() => { + subscriptions++; + return SubscriptionRef.changes(revision); + }), + ), + [WS_METHODS.pullRequestsSummary]: () => Effect.sync(() => ({ title, state: "open" })), + [WS_METHODS.pullRequestsDetail]: () => Effect.sync(() => ({ title, state: "open" })), + } as unknown as WsRpcProtocolClient; + const { runtime, atoms, registry } = yield* makeTestRuntime(client); + const target = { + environmentId: TARGET.environmentId, + input: { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 1 }, + }; + const summary = createLinkedPullRequestSummaryAtomFamily(runtime)(target); + const detail = atoms.detail(target); + for (const atom of [summary, detail]) { + const unmount = registry.mount(atom); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + expect((yield* AtomRegistry.getResult(registry, atom)).title).toBe(title); + } + yield* AtomRegistry.getResult( + registry, + atoms.refreshes({ environmentId: target.environmentId, input: {} }), + ); + expect(subscriptions).toBe(1); + + const changed = [summary, detail].map((atom) => { + const ready = Latch.makeUnsafe(); + const stop = registry.subscribe(atom, (result) => { + if (AsyncResult.isSuccess(result) && result.value.title === "Updated title") { + ready.openUnsafe(); + } + }); + return { ready, stop }; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => changed.forEach(({ stop }) => stop()))); + title = "Updated title"; + yield* SubscriptionRef.update(revision, (value) => value + 1); + for (const { ready } of changed) yield* ready.await; + expect(subscriptions).toBe(1); + }), + ), +); + it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index ca5fb6d7a77d5..1785af6fd384f 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -115,7 +115,7 @@ function updateCached( registry.update(atom, AsyncResult.map(update)); } -function createPullRequestRefreshAtomFamily( +function makePullRequestRefreshAtomFamily( runtime: Atom.AtomRuntime, ) { return createEnvironmentRpcSubscriptionAtomFamily(runtime, { @@ -124,6 +124,21 @@ function createPullRequestRefreshAtomFamily( }); } +const refreshFamiliesByRuntime = new WeakMap< + object, + ReturnType +>(); + +function createPullRequestRefreshAtomFamily( + runtime: Atom.AtomRuntime, +): ReturnType> { + const existing = refreshFamiliesByRuntime.get(runtime); + if (existing) return existing as ReturnType>; + const refreshes = makePullRequestRefreshAtomFamily(runtime); + refreshFamiliesByRuntime.set(runtime, refreshes); + return refreshes; +} + export function createLinkedPullRequestSummaryAtomFamily( runtime: Atom.AtomRuntime, refreshes = createPullRequestRefreshAtomFamily(runtime), From 4ce9205d1fe86c7b977a9e12960614bfe797777c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:51:05 -0500 Subject: [PATCH 081/379] fix(web): support Home and End on hue sliders (#23465) Co-authored-by: GPT-6.1 Sol --- .../settings/colorPickers.browser.test.tsx | 24 ++++++++++++++++++- apps/web/src/components/ui/color-picker.tsx | 5 ++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/colorPickers.browser.test.tsx b/apps/web/src/components/settings/colorPickers.browser.test.tsx index e468830ff3e64..f93659f112f05 100644 --- a/apps/web/src/components/settings/colorPickers.browser.test.tsx +++ b/apps/web/src/components/settings/colorPickers.browser.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { act, render } from "../../../test/render"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; @@ -69,7 +69,29 @@ async function renderThemeField(value: string) { }); } +function ControlledThemeField() { + const [value, setValue] = useState("#408080"); + return setValue(next)} />; +} + describe("theme color field", () => { + it("jumps hue to both endpoints without changing saturation or brightness", async () => { + await using _screen = await render(); + await open("Choose Accent color color"); + + await press(HUE, "Home"); + expect(slider(HUE).getAttribute("aria-valuenow")).toBe("0"); + expect(byLabel("Accent color picker hex value").value).toBe("#804040"); + + await press(HUE, "End"); + expect(slider(HUE).getAttribute("aria-valuenow")).toBe("360"); + expect(byLabel("Accent color picker hex value").value).toBe("#804040"); + + await press(HUE, "ArrowRight", true); + expect(slider(HUE).getAttribute("aria-valuenow")).toBe("10"); + expect(byLabel("Accent color picker hex value").value).toBe("#804b40"); + }); + it("keeps the selected hue when a grey color is echoed back from theme settings", async () => { await using screen = await renderThemeField("#ff0000"); const { onChange, echo } = screen; diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index 588428c62536c..b640942ef528c 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -182,6 +182,11 @@ export function ColorHueSlider({ }, onInteractionEnd); const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + onChange(event.key === "Home" ? 0 : 360); + return; + } if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return; event.preventDefault(); const step = event.shiftKey ? 10 : 1; From 4524eeeaa3fbcfde64b207c553b42357e3542433 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:51:21 -0500 Subject: [PATCH 082/379] fix(claude): no failed AskUserQuestion row after the resume dialog (#23481) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 63 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 24 +++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e1ee3bbe36f0a..07124b0240648 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4953,6 +4953,69 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("completes a turn after the resume dialog without a failed tool row", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-resume-dialog"), + text: "continue", + attachments: [], + }), + ); + const dialog = yield* Effect.promise(() => + harness.getOpenedOptions()!.onUserDialog!( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, + }, + { signal: new AbortController().signal, requestId: "dialog-resume" }, + ), + ).pipe(Effect.forkScoped); + const request = yield* Queue.take(harness.runtimeRequestReceipts); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "node.updated" && event.node.id === request.runtimeRequest.nodeId + ? [event.node.parentNodeId] + : [], + ), + [NodeId.make("node-attempt-resume-dialog")], + ); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + answers: { + [formatClaudeResumeCompactionQuestion({ ageMinutes: 90, estimatedTokens: 120000 })]: + "Keep full history", + }, + }); + assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "continue" }); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "result-resume-dialog", result: "Done" }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === "dialog-resume" + ? [event.turnItem.status] + : [], + ), + [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect.each( (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) => [true, false].map((steered) => ({ terminalReason, steered })), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1fb3b9ff120e1..b1a94c2e1c0f5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7081,6 +7081,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly context: ActiveClaudeTurnContext; readonly nativeItemId: string; readonly nativeRequestId: string; + readonly parentNodeId?: OrchestrationV2ExecutionNode["id"]; readonly requestKind: OrchestrationV2RuntimeRequest["kind"]; readonly prompt?: string; readonly appName?: string; @@ -7116,10 +7117,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( id: nodeId, threadId: input.context.input.threadId, runId: input.context.input.runId, - parentNodeId: idAllocator.derive.nodeFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: input.nativeItemId, - }), + parentNodeId: + input.parentNodeId ?? + idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), rootNodeId: input.context.input.rootNodeId, kind: input.questions === undefined ? "approval_request" : "user_input_request", status: "waiting", @@ -9556,6 +9559,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolName: Parameters[0], toolInput: Parameters[1], callbackOptions: Parameters[2], + options?: { readonly startToolCall?: boolean }, ) { const context = yield* Ref.get(activeTurn); if (context === null) { @@ -9569,6 +9573,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const nativeRequestId = callbackOptions.toolUseID; const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); const heldForEcho = context.heldRootFrames.length > 0; + const startToolCall = toolName !== "Agent" && options?.startToolCall !== false; if (toolName === "Agent") { rememberClaudeSubagentLaunch( subagentModelCatalog, @@ -9578,7 +9583,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeToolInput, null, ); - } else if (!heldForEcho) { + } else if (startToolCall && !heldForEcho) { yield* ensureToolCallStarted({ context, nativeItemId: nativeRequestId, @@ -9595,7 +9600,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { // The SDK blocks prompt echoes on permission answers; release held output except for ExitPlanMode. yield* releaseHeldRootFrames(context); - if (toolName !== "Agent") { + if (startToolCall) { yield* ensureToolCallStarted({ context, nativeItemId: nativeRequestId, @@ -9612,6 +9617,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context, nativeItemId: nativeRequestId, nativeRequestId, + // No tool node exists for a control request to hang under. + ...(options?.startToolCall === false + ? { parentNodeId: context.input.rootNodeId } + : {}), requestKind: "user_input", questions, }); @@ -9973,6 +9982,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( requestId: callbackOptions.requestId, toolUseID: request.toolUseID ?? callbackOptions.requestId, }, + // The dialog is a control request, not a tool call, so no + // tool_result would ever settle a tool row started for it. + { startToolCall: false }, ); if (result.behavior !== "allow") return { behavior: "cancelled" as const }; const answers = From 3fa79a5c83817c15b04b07f5db6c639fca33abda Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:51:37 -0500 Subject: [PATCH 083/379] fix(scripts): copy web brand icons to an absolute target directory (#23491) Co-authored-by: Claude Opus 5.5 --- scripts/apply-web-brand-assets.test.ts | 63 ++++++++++++++++++++++++++ scripts/apply-web-brand-assets.ts | 7 +-- 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 scripts/apply-web-brand-assets.test.ts diff --git a/scripts/apply-web-brand-assets.test.ts b/scripts/apply-web-brand-assets.test.ts new file mode 100644 index 0000000000000..c3073879b207c --- /dev/null +++ b/scripts/apply-web-brand-assets.test.ts @@ -0,0 +1,63 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { applyWebBrandAssets } from "./apply-web-brand-assets.ts"; + +const WEB_ICON_FILENAMES = [ + "apple-touch-icon.png", + "favicon-16x16.png", + "favicon-32x32.png", + "favicon.ico", +]; + +it.layer(NodeServices.layer)("apply-web-brand-assets", (it) => { + it.effect("copies the icons into an absolute target directory as given", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const targetDirectory = yield* fs.makeTempDirectoryScoped({ + prefix: "apply-web-brand-assets-", + }); + + yield* applyWebBrandAssets("production", targetDirectory); + + assert.deepStrictEqual( + (yield* fs.readDirectory(targetDirectory)).toSorted(), + WEB_ICON_FILENAMES, + ); + }), + ); + + it.effect("resolves an empty target directory to the repo root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* path.fromFileUrl(new URL("..", import.meta.url)); + const destinations = WEB_ICON_FILENAMES.map((filename) => path.join(repoRoot, filename)); + // readDirectory does not follow symlinks, so a dangling link counts as an existing entry. + // Names are lowercased so a case-insensitive filesystem cannot hide a differently cased file. + const existingEntries = new Set( + (yield* fs.readDirectory(repoRoot)).map((entry) => entry.toLowerCase()), + ); + for (const destination of destinations) { + assert.isFalse( + existingEntries.has(path.basename(destination)), + `${destination} already exists`, + ); + } + yield* Effect.addFinalizer(() => + Effect.forEach(destinations, (destination) => fs.remove(destination, { force: true })).pipe( + Effect.orDie, + ), + ); + + yield* applyWebBrandAssets("production", ""); + + for (const destination of destinations) { + assert.isTrue(yield* fs.exists(destination)); + } + }), + ); +}); diff --git a/scripts/apply-web-brand-assets.ts b/scripts/apply-web-brand-assets.ts index 90b4e39c104ac..a6941de8b4307 100644 --- a/scripts/apply-web-brand-assets.ts +++ b/scripts/apply-web-brand-assets.ts @@ -29,12 +29,9 @@ export const applyWebBrandAssets = Effect.fn("applyWebBrandAssets")(function* ( const repoRoot = yield* path.fromFileUrl(new URL("..", import.meta.url)); yield* Effect.forEach( - resolveWebIconOverrides(brand, targetDirectory), + resolveWebIconOverrides(brand, path.resolve(repoRoot, targetDirectory)), (override) => - fs.copyFile( - path.join(repoRoot, override.sourceRelativePath), - path.join(repoRoot, override.targetRelativePath), - ), + fs.copyFile(path.join(repoRoot, override.sourceRelativePath), override.targetRelativePath), { concurrency: "unbounded" }, ); }); From 54c1f1007cd59dbc899befc568ba643bf594cb62 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:51:54 -0500 Subject: [PATCH 084/379] fix(web): keep model picker open during IME composition (#23493) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- .../components/chat/ModelPickerContent.tsx | 13 +- ...erModelPicker.composition.browser.test.tsx | 120 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c5a8cdf64c18f..6e35ebb5f7e28 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -773,7 +773,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { + if ( + event.defaultPrevented || + event.repeat || + event.isComposing || + event.keyCode === 229 || + isCommandPaletteOpen() + ) { return; } @@ -912,6 +918,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { setSearchQuery(e.target.value); }} onKeyDown={(e) => { + if (e.nativeEvent.isComposing || e.keyCode === 229) { + (e as typeof e & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + e.stopPropagation(); + return; + } if ( showSidebar && !e.altKey && diff --git a/apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx new file mode 100644 index 0000000000000..a4dbe8bd31398 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.composition.browser.test.tsx @@ -0,0 +1,120 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; +import { useState } from "react"; +import { expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../../test/render"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { ProviderModelPicker } from "./ProviderModelPicker"; + +const instanceId = ProviderInstanceId.make("codex"); + +function Picker({ keybindings = [] }: { keybindings?: ResolvedKeybindingsConfig }) { + const [model, setModel] = useState("original"); + return ( + <> + {model} + setModel(selected)} + /> + + ); +} + +it("keeps composition keys out of model selection and dismissal", async () => { + await using screen = await render(); + await page.getByRole("button", { name: "Choose model" }).click(); + const search = page.getByPlaceholder("Search models..."); + await search.fill("Target"); + await expect.element(page.getByRole("option", { name: /Target model/ })).toBeVisible(); + + for (const nativeEvent of [{ isComposing: true }, { keyCode: 229 }]) { + for (const key of ["Enter", "Escape"]) { + const event = new KeyboardEvent("keydown", { + key, + ...nativeEvent, + bubbles: true, + cancelable: true, + }); + await act(() => search.element().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + await expect.element(search).toBeVisible(); + await expect + .element(screen.getByRole("status", { name: "Selected model" })) + .toHaveTextContent("original"); + } + } + + await userEvent.keyboard("{Enter}"); + await expect.element(search).not.toBeInTheDocument(); + await expect + .element(screen.getByRole("status", { name: "Selected model" })) + .toHaveTextContent("target"); + await page.getByRole("button", { name: "Choose model" }).click(); + await expect.element(search).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await expect.element(search).not.toBeInTheDocument(); +}); + +it("keeps composing keys from triggering model jump shortcuts", async () => { + const keybindings = compileResolvedKeybindingsConfig([ + { key: "enter", command: "modelPicker.jump.1", when: "modelPickerOpen" }, + ]); + await using screen = await render(); + await page.getByRole("button", { name: "Choose model" }).click(); + const search = page.getByPlaceholder("Search models..."); + await search.fill("Target"); + await expect.element(page.getByRole("option", { name: /Target model/ })).toBeVisible(); + + for (const nativeEvent of [{ isComposing: true }, { keyCode: 229 }]) { + const event = new KeyboardEvent("keydown", { + key: "Enter", + ...nativeEvent, + bubbles: true, + cancelable: true, + }); + await act(() => search.element().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + await expect.element(search).toBeVisible(); + await expect + .element(screen.getByRole("status", { name: "Selected model" })) + .toHaveTextContent("original"); + } +}); From 269357ccd9d3e38367248707e3931e0e7dfe76aa Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:52:09 -0500 Subject: [PATCH 085/379] fix(mobile): fit large thread titles in Arrange threads (#23499) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../threads/ThreadArrangementSheet.tsx | 36 +++++++++++-------- apps/mobile/src/lib/layout.test.ts | 13 +++++++ apps/mobile/src/lib/layout.ts | 15 ++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index 603fac3d00f5c..839506c84bc19 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -5,7 +5,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; -import { Animated, FlatList, Modal, Pressable, View } from "react-native"; +import { Animated, FlatList, Modal, Pressable, useWindowDimensions, View } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { threadDragGapOffset } from "./threadDragGap"; @@ -14,6 +14,7 @@ import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { deriveThreadArrangementSizing } from "../../lib/layout"; import { environmentServerConfigsAtom } from "../../state/server"; import { threadListEnvironmentsAtom } from "../../state/presentation"; import { environmentThreadShells } from "../../state/threads"; @@ -39,9 +40,8 @@ import { } from "./threadListV2"; import { useThreadListNow } from "./use-thread-list-now"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -const ROW_HEIGHT = 56; -const HEADER_HEIGHT = 48; const keyOf = (thread: EnvironmentThreadShell) => scopedThreadKey(thread.environmentId, thread.id); type Section = "pinned" | "active" | "snoozed" | "settled"; type Destination = Exclude; @@ -163,6 +163,12 @@ function DragHandle(props: { } function ThreadArrangementSheet(props: { onClose: () => void }) { + const { appearance } = useAppearancePreferences(); + const { fontScale } = useWindowDimensions(); + const { rowHeight, headerHeight } = deriveThreadArrangementSizing({ + baseFontSize: appearance.baseFontSize, + fontScale, + }); const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom); const configs = useAtomValue(environmentServerConfigsAtom); const listEnvironments = useAtomValue(threadListEnvironmentsAtom); @@ -230,20 +236,20 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { let offset = 0; for (const section of ["pinned", "active", "snoozed", "settled"] as const) { if (section === "snoozed" && sections[section].length === 0) continue; - result.push({ key: section, section, offset, height: HEADER_HEIGHT }); - offset += HEADER_HEIGHT; + result.push({ key: section, section, offset, height: headerHeight }); + offset += headerHeight; if ((section === "snoozed" || section === "settled") && !expanded[section]) continue; for (const thread of section === "settled" ? visibleSettled : sections[section]) { - result.push({ key: keyOf(thread), section, thread, offset, height: ROW_HEIGHT }); - offset += ROW_HEIGHT; + result.push({ key: keyOf(thread), section, thread, offset, height: rowHeight }); + offset += rowHeight; } if (section === "settled" && settledCount > visibleSettled.length) { - result.push({ key: "settled-more", section, more: true, offset, height: ROW_HEIGHT }); - offset += ROW_HEIGHT; + result.push({ key: "settled-more", section, more: true, offset, height: rowHeight }); + offset += rowHeight; } } return result; - }, [sections, expanded, visibleSettled, settledCount]); + }, [sections, expanded, visibleSettled, settledCount, rowHeight, headerHeight]); const list = useRef>(null); const geometry = useRef({ height: 0, offset: 0 }); const drag = useRef(null); @@ -261,7 +267,9 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { setPreview(null); } const orderVersion = rows - .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`) + .map( + (row) => `${row.key}:${row.height}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`, + ) .join("|"); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the in-progress drag gesture when the thread order changes underneath it useEffect(() => { @@ -282,7 +290,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { current.translation = translation; const { height, offset } = geometry.current; const y = current.startY + translation; - translateY.setValue(Math.max(0, Math.min(height - ROW_HEIGHT, y - ROW_HEIGHT / 2))); + translateY.setValue(Math.max(0, Math.min(height - rowHeight, y - rowHeight / 2))); const contentY = Math.max(0, y + offset); const target = latest.current.rows.find((row) => contentY < row.offset + row.height) ?? @@ -321,7 +329,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { orderVersion, sourceSection: row.section, thread: row.thread, - startY: row.offset + ROW_HEIGHT / 2 - geometry.current.offset, + startY: row.offset + row.height / 2 - geometry.current.offset, translation: 0, destination: null, }; @@ -554,7 +562,7 @@ function ThreadArrangementSheet(props: { onClose: () => void }) { { + it("fits two title lines and section labels with combined app and system text scaling", () => { + const sizing = deriveThreadArrangementSizing({ baseFontSize: 22, fontScale: 1.3 }); + expect(sizing.rowHeight).toBeGreaterThanOrEqual(2 * 32 * 1.3 + 10); + expect(sizing.headerHeight).toBeGreaterThanOrEqual(26 * 1.3 + 24); + expect(deriveThreadArrangementSizing({ baseFontSize: 16, fontScale: 1 })).toEqual({ + rowHeight: 56, + headerHeight: 48, + }); + }); +}); + describe("thread work-log text sizing", () => { it.each([ { baseFontSize: 16, fontScale: 1.25, estimatedRowHeight: 28 }, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index ccff7fdf6c890..4155d834ba24d 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -19,6 +19,21 @@ export const ANDROID_READING_MAX_WIDTH = 640; // min-h-8 uses the 14px rem configured in metro.config.js. export const THREAD_WORK_ROW_MIN_HEIGHT = 28; +export function deriveThreadArrangementSizing(input: { + readonly baseFontSize: number; + readonly fontScale: number; +}) { + const bodyLineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.body, input.baseFontSize); + const headerLineHeight = scaledTypographyLineHeight( + MOBILE_TYPOGRAPHY.footnote, + input.baseFontSize, + ); + return { + rowHeight: Math.max(56, Math.ceil(2 * bodyLineHeight * input.fontScale) + 10), + headerHeight: Math.max(48, Math.ceil(headerLineHeight * input.fontScale) + 24), + }; +} + export function deriveThreadWorkLogSizing(input: { readonly baseFontSize: number; readonly fontScale: number; From b1b3c10e249c68d6be86708f453c28b5c226f9b9 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:52:27 -0500 Subject: [PATCH 086/379] fix(clients): keep accepted server updates through disconnects (#23532) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../src/connection/outdatedHostUpdate.test.ts | 125 +++++++++++++- .../src/connection/outdatedHostUpdate.ts | 13 +- .../client-runtime/src/state/server.test.ts | 163 ++++++++++++++++++ packages/client-runtime/src/state/server.ts | 16 +- 4 files changed, 313 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts b/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts index f241f7b464cbd..33aa01ee71b06 100644 --- a/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts +++ b/packages/client-runtime/src/connection/outdatedHostUpdate.test.ts @@ -124,6 +124,20 @@ class OutdatedHostSocket { const message = decodeJson(data); if (!isRpcRequest(message)) return; this.requests.push(message); + if (message.tag === WS_METHODS.serverUpdateServerWithProgress) { + this.onUpdate(); + queueMicrotask(() => + this.emit({ + type: "message", + data: encodeJson({ + _tag: "Chunk", + requestId: message.id, + values: [{ type: "progress", stage: "downloading" }], + }), + }), + ); + return; + } if (message.tag !== WS_METHODS.serverUpdateServer) return; this.onUpdate(); queueMicrotask(() => @@ -143,10 +157,10 @@ class OutdatedHostSocket { close() { this.readyState = 3; - this.emit({ type: "close" }); + this.emit({ type: "close", code: 1006 }); } - private emit(event: { readonly type: string; readonly data?: unknown }) { + private emit(event: { readonly type: string; readonly data?: unknown; readonly code?: number }) { for (const listener of this.listeners.get(event.type) ?? []) listener(event); } } @@ -314,4 +328,111 @@ describe("updateOutdatedHost", () => { expect(opened).toBe(false); }), ); + + it.effect("resumes an outdated host update after progress transport loss", () => + Effect.gen(function* () { + const blocked = orchestrationProtocolCompatibilityError(descriptor(undefined, "0.0.45")); + expect(blocked).toMatchObject({ serverUpdateRequired: true }); + + const sockets: Array = []; + let served: ExecutionEnvironmentDescriptor = { + ...descriptor(undefined, "0.0.45"), + capabilities: { + repositoryIdentity: true, + serverSelfUpdate: "boot-service", + serverSelfUpdateProgress: true, + }, + }; + const entries = yield* SubscriptionRef.make< + ReadonlyMap + >( + new Map([ + [ + TARGET.environmentId, + { + target: TARGET, + profile: Option.none(), + enabled: false, + unsupportedReason: blocked?.message ?? "", + serverUpdateRequired: true, + }, + ], + ]), + ); + const calls: Array = []; + const registry = EnvironmentRegistry.EnvironmentRegistry.of({ + entries, + setCompatibility: (_environmentId: EnvironmentId, error: unknown) => + Effect.sync(() => calls.push(`compatibility:${error === null ? "clear" : "block"}`)), + setEnabled: (_environmentId: EnvironmentId, enabled: boolean) => + Effect.sync(() => calls.push(`enabled:${enabled}`)), + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const resolver = ConnectionResolver.ConnectionResolver.of({ + prepare: () => Effect.die(new Error("The update must bypass the protocol gate.")), + prepareForUpdate: () => + Effect.sync(() => served).pipe( + Effect.map((current) => ({ + descriptor: current, + prepared: { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://build.example.test/ws?wsTicket=ticket", + httpAuthorization: null, + target: TARGET, + }, + })), + ), + }); + const httpClient = HttpClient.make((request) => + Effect.sync(() => + HttpClientResponse.fromWeb(request, new Response(encodeDescriptor(served))), + ), + ); + + const stages: Array = []; + const result = yield* updateOutdatedHost( + TARGET.environmentId, + { targetVersion: "0.0.46" }, + (stage) => + Effect.sync(() => { + stages.push(stage); + // Lose the transport only after the consumer has observed progress. + if (stage === "downloading") sockets[0]?.close(); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registry), + Layer.succeed(ConnectionResolver.ConnectionResolver, resolver), + Layer.succeed( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ + state: yield* SubscriptionRef.make( + RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + ), + refresh: Effect.void, + }), + ), + Layer.succeed(HttpClient.HttpClient, httpClient), + Layer.succeed(Socket.WebSocketConstructor, (url) => { + const socket = new OutdatedHostSocket(url, () => { + served = descriptor(ORCHESTRATION_PROTOCOL_VERSION, "0.0.46"); + }); + sockets.push(socket); + return socket as unknown as globalThis.WebSocket; + }), + ), + ), + ); + + expect(sockets[0]?.url).not.toContain("orchestrationProtocol"); + expect(sockets[0]?.requests.map((request) => request.tag)).toEqual([ + WS_METHODS.serverUpdateServerWithProgress, + ]); + expect(stages).toEqual(["downloading", "resuming"]); + expect(result.targetVersion).toBe("0.0.46"); + expect(calls).toEqual(["compatibility:clear", "enabled:true"]); + }), + ); }); diff --git a/packages/client-runtime/src/connection/outdatedHostUpdate.ts b/packages/client-runtime/src/connection/outdatedHostUpdate.ts index 444a33a1ac54e..d702e1f6b057f 100644 --- a/packages/client-runtime/src/connection/outdatedHostUpdate.ts +++ b/packages/client-runtime/src/connection/outdatedHostUpdate.ts @@ -141,20 +141,31 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd capabilities.serverSelfUpdateProgress === true ? yield* Effect.gen(function* () { const terminal = yield* Ref.make(Option.none()); + let receivedProgress = false; const streamExit = yield* client[WS_METHODS.serverUpdateServerWithProgress]( input, ).pipe( Stream.runForEach((event) => event.type === "complete" ? Ref.set(terminal, Option.some(event.result)) - : onStage(event.stage), + : Effect.sync(() => { + receivedProgress = true; + }).pipe(Effect.andThen(onStage(event.stage))), ), Effect.exit, ); + const selfUpdateMethod = capabilities.serverSelfUpdate; + // Desktop preparation needs its terminal commit token. + const accepted = + receivedProgress && + (selfUpdateMethod === "boot-service" || selfUpdateMethod === "respawn") + ? { targetVersion: input.targetVersion, method: selfUpdateMethod } + : undefined; return yield* resolveServerUpdateProgressResult( input.targetVersion, yield* Ref.get(terminal), streamExit, + accepted, ); }) : yield* client[WS_METHODS.serverUpdateServer](input).pipe( diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index b07148a9c4bc5..c852afdcae32e 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + ServerSelfUpdateError, ProviderInstanceId, ServerObservability, EnvironmentThemeId, @@ -16,12 +17,14 @@ 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 Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import * as TestClock from "effect/testing/TestClock"; import { RpcClientError } from "effect/rpc"; import * as Socket from "effect/socket/Socket"; @@ -32,11 +35,15 @@ import { type PreparedConnection, } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import { RpcPermissionGuard } from "../rpc/client.ts"; import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { applyServerWelcomeEvent, + createServerEnvironmentAtoms, + serverUpdateStateAtom, makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, @@ -216,6 +223,162 @@ describe("update restart reconnect nudges", () => { ); }); +describe("accepted server update recovery", () => { + it.effect.each([ + { loss: "interrupt", resumes: true }, + { loss: "socket", resumes: true }, + { loss: "server_error", resumes: false }, + { loss: "no_progress", resumes: false }, + { loss: "cancel", resumes: false }, + { loss: "socket", method: "respawn", resumes: true }, + { loss: "socket", method: "desktop-managed", resumes: false }, + ] as const)("handles $loss with $method and recovery=$resumes", (scenario) => + Effect.gen(function* () { + const { loss, resumes } = scenario; + const method = "method" in scenario ? scenario.method : "boot-service"; + const observing = yield* Deferred.make(); + const cancelFiber = yield* Deferred.make>(); + const oldReadyObserved = yield* Deferred.make(); + const readyEvents = + yield* Queue.unbounded[1]>(); + const config = { + ...CONFIG, + environment: { + ...CONFIG.environment, + serverVersion: "0.0.30", + capabilities: { serverSelfUpdate: method, serverSelfUpdateProgress: true }, + }, + } as ServerConfig; + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.never, + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(observing, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(readyEvents)), + Stream.tap((event) => + event.payload.environment.serverVersion === "0.0.30" + ? Deferred.succeed(oldReadyObserved, undefined) + : Effect.void, + ), + ), + [WS_METHODS.serverUpdateServerWithProgress]: () => + (loss === "no_progress" + ? Stream.empty + : Stream.make({ type: "progress" as const, stage: "downloading" as const }) + ).pipe( + Stream.concat( + Stream.fromEffect( + loss === "cancel" + ? Effect.fiber.pipe( + Effect.flatMap((fiber) => Deferred.succeed(cancelFiber, fiber)), + Effect.andThen(Effect.never), + ) + : loss === "interrupt" + ? Effect.interrupt + : Effect.fail( + loss === "server_error" + ? new ServerSelfUpdateError({ + reason: "Archive verification failed.", + }) + : new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006 }), + }), + ), + ), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (_id, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["runStream"] = ( + _id, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + runStream, + followStream: runStream, + stateChanges: () => Stream.never, + retryNow: () => Effect.void, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(RpcPermissionGuard, { authorize: () => Effect.void }), + Layer.succeed(Persistence.EnvironmentCacheStore, { + loadServerConfig: () => Effect.succeedNone, + } as unknown as Persistence.EnvironmentCacheStore["Service"]), + ), + ); + const atoms = createServerEnvironmentAtoms(runtime, { + initialConfigValueAtom: () => Atom.make(config), + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (value) => + Effect.sync(() => value.dispose()), + ); + const pending = atoms.updateServer.run(registry, { + environmentId: TARGET.environmentId, + input: { targetVersion: "0.0.31" }, + }); + if (loss === "cancel") { + yield* Fiber.interrupt(yield* Deferred.await(cancelFiber)); + const cancelled = yield* Effect.promise(() => pending); + expect(cancelled._tag).toBe("Failure"); + if (cancelled._tag === "Failure") + expect(Cause.hasInterruptsOnly(cancelled.cause)).toBe(true); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toEqual({ + status: "idle", + }); + expect(yield* Deferred.isDone(observing)).toBe(false); + return; + } + const first = yield* Effect.raceFirst( + Effect.promise(() => pending).pipe(Effect.as("settled")), + Deferred.await(observing).pipe(Effect.as("observing")), + ); + if (!resumes) { + expect(first).toBe("settled"); + expect(AsyncResult.isFailure(yield* Effect.promise(() => pending))).toBe(true); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toMatchObject({ + status: "failed", + }); + return; + } + expect(first).toBe("observing"); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toMatchObject({ + status: "running", + stage: "resuming", + }); + const ready = (serverVersion: string) => + ({ + version: 1, + sequence: 1, + type: "ready", + payload: { at: "2026-09-01T00:00:00.000Z", environment: { serverVersion } }, + }) as Parameters[1]; + yield* Queue.offer(readyEvents, ready("0.0.30")); + yield* Deferred.await(oldReadyObserved); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toMatchObject({ + status: "running", + }); + yield* Queue.offer(readyEvents, ready("0.0.31")); + expect(AsyncResult.isSuccess(yield* Effect.promise(() => pending))).toBe(true); + expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toEqual({ + status: "idle", + }); + }).pipe(Effect.scoped), + ); +}); + const makeConfigPersistenceHarness = Effect.fn("test.makeConfigPersistenceHarness")(function* ( saveServerConfig: Persistence.EnvironmentCacheStore["Service"]["saveServerConfig"], ) { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index dadaf2dafb7a5..4c44c7df9dec9 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -103,7 +103,7 @@ export class ServerUpdateResumeTimeoutError extends Schema.TaggedError( targetVersion: string, terminal: Option.Option, streamExit: Exit.Exit, + accepted?: ServerSelfUpdateResult, ): Effect.Effect { if ( Option.isSome(terminal) && @@ -346,6 +347,9 @@ export function resolveServerUpdateProgressResult( return Effect.succeed(terminal.value); } if (Exit.isFailure(streamExit)) { + if (accepted !== undefined && isLegacyUpdateHandoffLoss(streamExit.cause)) { + return Effect.succeed(accepted); + } return Effect.failCause(streamExit.cause); } return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); @@ -882,6 +886,7 @@ export function createServerEnvironmentAtoms( currentConfig?.environment.capabilities.serverSelfUpdateProgress === true; const updateResult: ServerSelfUpdateResult = supportsProgress ? yield* Effect.gen(function* () { + let receivedProgress = false; const terminal = yield* Ref.make>( Option.none(), ); @@ -893,6 +898,7 @@ export function createServerEnvironmentAtoms( .pipe( Stream.runForEach((event) => Effect.sync(() => { + receivedProgress ||= event.type === "progress"; const state = serverUpdateStateForProgressEvent( fromVersion, targetVersion, @@ -910,10 +916,18 @@ export function createServerEnvironmentAtoms( ), Effect.exit, ); + const selfUpdateMethod = currentConfig?.environment.capabilities.serverSelfUpdate; + // Desktop preparation needs its terminal commit token. + const accepted = + receivedProgress && + (selfUpdateMethod === "boot-service" || selfUpdateMethod === "respawn") + ? { targetVersion, method: selfUpdateMethod } + : undefined; return yield* resolveServerUpdateProgressResult( targetVersion, yield* Ref.get(terminal), streamExit, + accepted, ); }) : yield* Effect.gen(function* () { From 0804c60a9cd0a120092a565b5c422a19fc6c5612 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira <99990310+Aforno@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:52:44 +0000 Subject: [PATCH 087/379] fix(server): reject busy merge-back targets before blocking their queue (#23554) --- .../src/orchestration-v2/Orchestrator.ts | 13 ++- .../src/orchestration-v2/runtimeLayer.test.ts | 90 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ce43fef58a48a..ada7bba8234aa 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4340,7 +4340,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // The source is unlocked; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const targetProjection = yield* projectionStore - .getThreadRecords(command.targetThreadId, ["contextTransfers"]) + .getThreadRecords(command.targetThreadId, ["runs", "contextTransfers"]) .pipe( Effect.mapError( (cause) => @@ -4392,6 +4392,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } + // Queued runs cannot consume merge-back context. Refuse under the target's + // command lock before a pending transfer can block its ordinary messages. + if (targetProjection.runs.some((run) => isBlockingRun(run) || run.status === "queued")) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "Finish or stop the target thread's active work and clear its queue before merging back.", + }); + } + const sourceProviderThread = providerThreadForRun(sourceProjection, sourceRun); const now = command.createdAt ?? (yield* DateTime.now); const emitEvent = emit(events, command); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 20158a80d3ff6..a04fe4c7c1374 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1726,7 +1726,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { }), ); - it.effect("merges an explicit provider-finished run while checkpoint capture is pending", () => + it.effect("refuses busy merge-back targets without blocking the queue", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const eventSink = yield* EventSink.EventSinkV2; @@ -1741,6 +1741,13 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { ); const forkTransferId = ContextTransferId.make("runtime-layer-waiting-merge-fork-transfer"); + yield* seedProject({ + projectId, + title: "Merge-back project", + workspaceRoot: process.cwd(), + defaultModelSelection: modelSelection, + createdAt: DateTime.formatIso(now), + }); yield* orchestrator.dispatch({ type: "thread.create", createdBy: "user", @@ -1868,6 +1875,87 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { ], }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-merge-target-start"), + threadId: targetThreadId, + messageId: MessageId.make("runtime-layer-merge-target-message"), + text: "Keep working in the parent.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const activeTarget = yield* orchestrator.getThreadProjection(targetThreadId); + const targetRun = activeTarget.runs[0]!; + for (const status of ["preparing", "starting", "waiting", "queued", "running"] as const) { + yield* eventSink.write({ + commandId: CommandId.make(`runtime-layer-merge-target-${status}`), + events: [ + { + id: EventId.make(`runtime-layer-merge-target-${status}-event`), + type: "run.updated", + threadId: targetThreadId, + runId: targetRun.id, + occurredAt: now, + payload: { ...targetRun, status }, + }, + ], + }); + const error = yield* orchestrator + .dispatch({ + type: "thread.merge_back", + createdBy: "user", + creationSource: "mobile", + commandId: CommandId.make(`runtime-layer-merge-refused-${status}`), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "run", runId: sourceRunId }, + }) + .pipe(Effect.flip); + assert.instanceOf(error, Orchestrator.OrchestratorDispatchError); + assert.equal( + error.cause, + "Finish or stop the target thread's active work and clear its queue before merging back.", + ); + const refusedTarget = yield* orchestrator.getThreadProjection(targetThreadId); + assert.deepEqual(refusedTarget.contextTransfers, activeTarget.contextTransfers); + assert.equal(refusedTarget.runs[0]?.status, status); + } + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-merge-target-queue"), + threadId: targetThreadId, + messageId: MessageId.make("runtime-layer-merge-target-queued-message"), + text: "Run this after the current turn.", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + }); + const queuedTarget = yield* orchestrator.getThreadProjection(targetThreadId); + const queuedRun = queuedTarget.runs.find((run) => run.status === "queued"); + assert.isDefined(queuedRun); + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("runtime-layer-merge-target-cancel-queue"), + threadId: targetThreadId, + runId: queuedRun.id, + }); + yield* eventSink.write({ + commandId: CommandId.make("runtime-layer-merge-target-complete"), + events: [ + { + id: EventId.make("runtime-layer-merge-target-completed-event"), + type: "run.updated", + threadId: targetThreadId, + runId: targetRun.id, + occurredAt: now, + payload: { ...targetRun, status: "completed", completedAt: now }, + }, + ], + }); + yield* orchestrator.dispatch({ type: "thread.merge_back", createdBy: "user", From 37022730bacb03f26c1cba9b31ec1d8da3c6e730 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:53:05 -0500 Subject: [PATCH 088/379] perf(web): skip favicon loading for custom project icons (#23569) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- .../ProjectFavicon.browser.test.tsx | 57 +++++++++++++++++++ apps/web/src/components/ProjectFavicon.tsx | 19 ++++--- 2 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/ProjectFavicon.browser.test.tsx diff --git a/apps/web/src/components/ProjectFavicon.browser.test.tsx b/apps/web/src/components/ProjectFavicon.browser.test.tsx new file mode 100644 index 0000000000000..746302ef5b242 --- /dev/null +++ b/apps/web/src/components/ProjectFavicon.browser.test.tsx @@ -0,0 +1,57 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId } from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; + +const state = vi.hoisted(() => ({ favicon: undefined as Atom.Atom | undefined })); +vi.mock(import("../state/assets"), () => ({ + projectFaviconUrlAtom: () => { + if (!state.favicon) throw new Error("Favicon resource not configured"); + return state.favicon; + }, +})); + +function setup() { + const activeResources = new Set(); + state.favicon = Atom.make((get) => { + activeResources.add("favicon"); + get.addFinalizer(() => activeResources.delete("favicon")); + return null; + }); + return { activeResources }; +} + +it("only activates the favicon resource after removing a custom icon", async () => { + const { activeResources } = setup(); + const registry = AtomRegistry.make(); + const project: ProjectFaviconProject = { + environmentId: EnvironmentId.make("test"), + workspaceRoot: "/project", + title: "Project", + faviconPath: "icon.png", + projectIcon: { kind: "monogram", text: "PR", color: "blue" }, + }; + const view = (projectIcon: ProjectFaviconProject["projectIcon"]) => ( + + + + ); + try { + await using screen = await render(view(project.projectIcon)); + expect(activeResources.size).toBe(0); + await screen.rerender(view({ kind: "emoji", emoji: "🌱" })); + expect(activeResources.size).toBe(0); + await screen.rerender(view({ kind: "lucide", name: "leaf", color: "green" })); + expect(activeResources.size).toBe(0); + await screen.rerender(view(null)); + expect(activeResources.size).toBe(1); + await screen.rerender(view(project.projectIcon)); + await screen.rerender(view(null)); + expect(activeResources.size).toBe(1); + } finally { + registry.dispose(); + } +}); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 14a00ae86583b..3545a0aa5db87 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -38,13 +38,6 @@ export function ProjectFavicon(input: { fallbackIcon?: ComponentType<{ className?: string }>; }) { const { project } = input; - const src = useAtomValue( - projectFaviconUrlAtom({ - environmentId: project.environmentId, - cwd: project.workspaceRoot, - faviconPath: project.faviconPath, - }), - ); if (project.projectIcon?.kind === "monogram") { return ( ); } + return ; +} + +function AutomaticProjectFavicon(input: Parameters[0]) { + const { project } = input; + const src = useAtomValue( + projectFaviconUrlAtom({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }), + ); const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon; if (!src || isProjectFaviconFallbackUrl(src)) { From f46f838bf22460be5061d378723ff1c15f6d9417 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira <99990310+Aforno@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:53:26 +0000 Subject: [PATCH 089/379] fix(server): setup scripts follow terminal shell fallbacks (#23576) Co-authored-by: maria-rcks --- .../project/ProjectSetupScriptRunner.test.ts | 54 +++++-- .../src/project/ProjectSetupScriptRunner.ts | 138 ++++++++++-------- apps/server/src/terminal/Manager.test.ts | 115 +++++++++++++++ apps/server/src/terminal/Manager.ts | 18 ++- 4 files changed, 252 insertions(+), 73 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 29a15a6f0715b..f383436cdb439 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,7 +1,11 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; -import { ProjectId, type TerminalEvent, type TerminalSessionSnapshot } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + ProjectId, + TerminalNotRunningError, + type TerminalEvent, + type TerminalSessionSnapshot, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -36,6 +40,7 @@ const openSnapshot = (history: string): TerminalSessionSnapshot => ({ const makeHarness = Effect.fn(function* (options: { readonly history?: string; readonly opening?: Deferred.Deferred; + readonly shell?: string | null; }) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const writes = yield* Queue.unbounded(); @@ -46,6 +51,10 @@ const makeHarness = Effect.fn(function* (options: { ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void ).pipe(Effect.as(openSnapshot(options.history ?? ""))), + getShell: () => + options.shell === null + ? Effect.fail(new TerminalNotRunningError({ threadId, terminalId })) + : Effect.succeed(options.shell ?? "/bin/zsh"), subscribe: (listener) => Effect.sync(() => { listeners.add(listener); @@ -66,8 +75,6 @@ const makeHarness = Effect.fn(function* (options: { NodeCrypto.layer, ), ), - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), ); const start = runner.runForThread({ threadId, @@ -92,7 +99,12 @@ const makeHarness = Effect.fn(function* (options: { return { start, listeners, writes, outputLines, emit }; }); -const setup = Effect.fn(function* (options: { readonly history?: string } = {}) { +const setup = Effect.fn(function* ( + options: { + readonly history?: string; + readonly shell?: string | null; + } = {}, +) { const harness = yield* makeHarness(options); const result = yield* harness.start; const completion = result.status === "started" ? result.completion : undefined; @@ -101,6 +113,15 @@ const setup = Effect.fn(function* (options: { readonly history?: string } = {}) }); describe("ProjectSetupScriptRunner", () => { + it.effect("writes immediately when the spawned fallback is PowerShell", () => + Effect.gen(function* () { + const { writes } = yield* setup({ + shell: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }); + expect(yield* Queue.take(writes)).toContain("$global:LASTEXITCODE"); + }), + ); + it.effect("shows a startup prompt and types only once the line editor starts", () => Effect.gen(function* () { const { result, writes, outputLines, emit } = yield* setup(); @@ -160,13 +181,20 @@ describe("ProjectSetupScriptRunner", () => { }), ); - it.effect("does not type into a shell that exits during startup", () => - Effect.gen(function* () { - const { completion, writes, emit } = yield* setup(); - yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); - expect(yield* completion).toMatchObject({ exitCode: null }); - yield* TestClock.adjust("1 minute"); - expect(yield* Queue.size(writes)).toBe(0); - }), + it.effect.each([false, true])( + "does not type into a shell that exits during startup (before shell lookup: %s)", + (beforeShellLookup) => + Effect.gen(function* () { + const { completion, writes, emit, listeners } = yield* setup( + beforeShellLookup ? { shell: null } : {}, + ); + if (!beforeShellLookup) { + yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); + } + expect(yield* completion).toMatchObject({ exitCode: null }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + expect(listeners.size).toBe(0); + }), ); }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 095f20938c8b0..ec11ce9840448 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,4 @@ import { linkOriginIsFenced, ProjectId, type ProjectScript, ThreadId } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { projectScriptRuntimeEnv, projectScriptTerminalInput, @@ -107,9 +106,19 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedError

()( + "ProjectSetupScriptUnsupportedShellError", + { shell: Schema.String }, +) { + override get message(): string { + return "Project script completion is not supported in cmd.exe. Install PowerShell and retry."; + } +} + export const ProjectSetupScriptRunnerError = Schema.Union([ ProjectSetupScriptOperationError, ProjectSetupScriptProjectNotFoundError, + ProjectSetupScriptUnsupportedShellError, ]); export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type; @@ -159,13 +168,9 @@ function lineEditorReading(text: string, previous: boolean): boolean { type CompletionShell = "posix" | "fish" | "powershell"; -function resolveCompletionShell( - platform: NodeJS.Platform, - env: NodeJS.ProcessEnv, -): CompletionShell { - if (platform === "win32") return "powershell"; - const shell = env.SHELL ?? ""; - const name = shell.split("/").at(-1) ?? shell; +function resolveCompletionShell(shell: string): CompletionShell | null { + const name = (shell.split(/[/\\]/).at(-1) ?? shell).toLowerCase().replace(/\.exe$/, ""); + if (name === "cmd") return null; if (name === "fish") return "fish"; if (name === "pwsh" || name === "powershell") return "powershell"; return "posix"; @@ -196,10 +201,6 @@ export const make = Effect.gen(function* () { const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; const serviceScope = yield* Effect.scope; - const completionShell = resolveCompletionShell( - yield* HostProcess.Platform, - yield* HostProcess.Environment, - ); const observeTerminalCompletion = Effect.fn("ProjectSetupScriptRunner.observeTerminalCompletion")( function* (input: { @@ -444,15 +445,7 @@ export const make = Effect.gen(function* () { const completionToken = observe ? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)).replaceAll("-", "") : null; - const commandLine = - observe && completionToken - ? wrapCommandForCompletion( - script.command, - completionShell, - completionSentinel(completionToken), - ) - : script.command; - + const echoedWrapperLines: string[] = []; // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. const observed = observe && completionToken @@ -461,49 +454,76 @@ export const make = Effect.gen(function* () { terminalId, sentinel: completionSentinel(completionToken), sentinelPattern: completionSentinelPattern(completionToken), - echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0), + echoedWrapperLines, onOutputLine: observe.onOutputLine, }) : undefined; - // PowerShell has no line-editor signal; keep its immediate write. - const readiness = - completionShell === "powershell" - ? undefined - : yield* watchShellReadiness({ - threadId: input.threadId, - terminalId, - onStartupPrompt: observed?.flushPartialLine ?? Effect.void, - }); - - yield* terminalManager - .open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "openTerminal", - cause, - }), - ), - Effect.onError(() => - Effect.sync(() => { - observed?.unsubscribe(); - readiness?.unsubscribe(); - }), - ), - ); + const readiness = yield* watchShellReadiness({ + threadId: input.threadId, + terminalId, + onStartupPrompt: observed?.flushPartialLine ?? Effect.void, + }); + const { completionShell, commandLine } = yield* Effect.gen(function* () { + const shell = yield* terminalManager + .open({ + threadId: input.threadId, + terminalId, + cwd, + worktreePath: input.worktreePath, + env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, + }) + .pipe( + Effect.andThen(() => + terminalManager + .getShell({ threadId: input.threadId, terminalId }) + .pipe(Effect.catchTags({ TerminalNotRunningError: () => Effect.succeed(null) })), + ), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); + if (shell === null) { + yield* observed?.abandon ?? Effect.void; + return { completionShell: null, commandLine: null }; + } + const completionShell = resolveCompletionShell(shell); + let commandLine = script.command; + if (observe && completionToken) { + if (completionShell === null) { + yield* terminalManager.closeIdle({ threadId: input.threadId, terminalId }); + return yield* new ProjectSetupScriptUnsupportedShellError({ shell }); + } + commandLine = wrapCommandForCompletion( + script.command, + completionShell, + completionSentinel(completionToken), + ); + } + echoedWrapperLines.push(...commandLine.split("\r").filter((line) => line.length > 0)); + return { completionShell, commandLine }; + }).pipe( + Effect.onError(() => + Effect.sync(() => { + observed?.unsubscribe(); + readiness.unsubscribe(); + }), + ), + ); // Return once the terminal exists so callers can show it while a startup prompt waits. - yield* (readiness?.wait ?? Effect.succeed("ready" as const)).pipe( + // PowerShell and cmd have no line-editor signal. + yield* ( + completionShell === "posix" || completionShell === "fish" + ? readiness.wait + : Effect.succeed("ready" as const) + ).pipe( Effect.flatMap((shell) => - shell === "exited" + shell === "exited" || commandLine === null ? Effect.void : terminalManager.write({ threadId: input.threadId, @@ -518,7 +538,7 @@ export const make = Effect.gen(function* () { cause, }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)), ), - Effect.ensuring(Effect.sync(() => readiness?.unsubscribe())), + Effect.ensuring(Effect.sync(() => readiness.unsubscribe())), Effect.forkIn(serviceScope), ); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 61fe149cf8335..4afae89eb5a50 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -7,6 +7,7 @@ import { type TerminalOpenInput, ProviderDriverKind, ProviderInstanceId, + ProjectId, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -31,8 +32,11 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as TestClock from "effect/testing/TestClock"; import { expect, vi } from "vite-plus/test"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as TerminalManager from "./Manager.ts"; import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; @@ -1441,6 +1445,61 @@ it.layer( }), ); + it.effect( + "writes completion syntax for the fallback shell instead of the configured fish shell", + () => + Effect.gen(function* () { + const env = { SHELL: "/missing/fish" }; + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { env }).pipe( + Effect.provide(layerWithHostPlatform("linux")), + ); + ptyAdapter.spawnFailures.push(new Error("spawn fish ENOENT")); + const written = yield* Deferred.make(); + const terminals = TerminalManager.TerminalManager.of({ + ...manager, + write: (input) => + manager.write(input).pipe(Effect.tap(() => Deferred.succeed(written, undefined))), + }); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, terminals), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), + ServerSettings.layerTest(), + Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(HostProcess.Environment, env), + ), + ), + ); + yield* runner.runForThread({ + threadId: "thread-setup-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-setup-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }); + const process = ptyAdapter.processes[0]!; + process.emitData("\x1b[?2004h"); + yield* Deferred.await(written); + const command = process.writes[0]!; + expect(command).toContain('"$?"'); + }), + ); + it.effect("preserves Windows Path casing when appending managed ACP binaries", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1534,6 +1593,62 @@ it.layer( }), ); + it.effect("closes an idle cmd fallback before rejecting its completion wrapper", () => + Effect.gen(function* () { + const { manager, ptyAdapter, baseDir } = yield* createManager(5, { + env: { SystemRoot: "C:\\Windows" }, + subprocessInspector: () => + Effect.succeed({ + hasRunningSubprocess: false, + childCommand: null, + processIds: [], + }), + }).pipe(Effect.provide(layerWithHostPlatform("win32"))); + ptyAdapter.spawnFailures.push( + new Error("spawn pwsh ENOENT"), + new Error("spawn WindowsPowerShell ENOENT"), + new Error("spawn powershell ENOENT"), + ); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TerminalManager.TerminalManager, manager), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), + ServerSettings.layerTest(), + ), + ), + ); + const error = yield* runner + .runForThread({ + threadId: "thread-cmd-fallback", + worktreePath: baseDir, + project: { + id: ProjectId.make("project-cmd-fallback"), + workspaceRoot: baseDir, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup-ready", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }, + observeCompletion: {}, + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("ProjectSetupScriptUnsupportedShellError"); + expect(ptyAdapter.spawnInputs.at(-1)?.shell).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(ptyAdapter.processes[0]?.writes).toEqual([]); + expect(ptyAdapter.processes[0]?.killed).toBe(true); + }), + ); + it.effect("turns off fish terminal queries unless the user chose query-term", () => Effect.gen(function* () { for (const [platform, env, runtimeEnv, expected] of [ diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index ca93558d623ec..9e0eabc88da8a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -172,6 +172,8 @@ export class TerminalManager extends Context.Service< input: TerminalOpenInput, ) => Effect.Effect; + readonly getShell: (input: TerminalObserveInput) => Effect.Effect; + /** * Attached viewers share one PTY sized to the smallest of them, so a narrow client * still sees the whole TUI. Streams and resizes with the same `viewerId` (one client @@ -296,6 +298,7 @@ interface TerminalSessionState { cols: number; rows: number; process: PtyAdapter.PtyProcess | null; + shell: string | null; unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; @@ -2370,7 +2373,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func index = 0, lastError: PtyAdapter.PtySpawnError | null = null, ): Effect.fn.Return< - { process: PtyAdapter.PtyProcess; shellLabel: string }, + { process: PtyAdapter.PtyProcess; shell: string; shellLabel: string }, PtyAdapter.PtySpawnError > { if (index >= shellCandidates.length) { @@ -2413,6 +2416,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (attempt._tag === "Success") { return { process: attempt.success, + shell: candidate.shell, shellLabel: formatShellCandidate(candidate), }; } @@ -2441,6 +2445,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const startingAt = yield* nowIso; yield* modifyManagerState((state) => { session.status = "starting"; + session.shell = null; session.cwd = input.cwd; session.worktreePath = input.worktreePath ?? null; session.cols = input.cols; @@ -2515,6 +2520,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }; yield* modifyManagerState((state) => { session.process = ptyProcess; + session.shell = spawnResult.shell; session.pid = processPid; session.status = "running"; // onExit may replay an exit immediately; accept it before subscribing. @@ -2891,6 +2897,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func cols, rows, process: null, + shell: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, @@ -2988,6 +2995,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); + const getShell = Effect.fn("terminal.getShell")(function* (input: TerminalObserveInput) { + const session = yield* requireSession(input.threadId, input.terminalId); + if (session.status !== "running" || session.shell === null) { + return yield* new TerminalNotRunningError(input); + } + return session.shell; + }); + const openOrAttachForStream = Effect.fnUntraced(function* (requestedInput: TerminalAttachInput) { const terminalId = requestedInput.terminalId; const input = withSharedGrid(toSessionKey(requestedInput.threadId, terminalId), requestedInput); @@ -3472,6 +3487,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, + getShell, attachStream, attachOutputStream, observeStream, From 1c99a55444b6a03ec6a056a99a44131f3e1d13fb Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:53:47 -0500 Subject: [PATCH 090/379] fix(web): report invalid preview addresses (#23577) Co-authored-by: GPT-6.1 Sol --- .../preview/PreviewView.browser.test.tsx | 23 +++++++++++++---- .../src/components/preview/PreviewView.tsx | 25 +++++++++++++++++-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index c17655db964bc..43f7845d10dbb 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -28,7 +28,6 @@ const mocks = vi.hoisted(() => ({ pickElement: vi.fn(), addPreviewAnnotation: vi.fn(), addImage: vi.fn(), - toast: vi.fn(), pictureInPicture: false, showEmptyState: false, loading: false, @@ -136,10 +135,6 @@ vi.mock(import("~/browser/browserRecording"), async (original) => ({ findActiveBrowserRecordingRuntimeTabId: () => mocks.recordingRuntimeTabId, useActiveBrowserRecordingTabIds: () => mocks.recordingTabIds, })); -vi.mock(import("~/components/ui/toast"), async (original) => { - const actual = await original(); - return { ...actual, toastManager: { ...actual.toastManager, add: mocks.toast } }; -}); vi.mock(import("./useDiscoveredLocalServers"), async (original) => ({ ...(await original()), useDiscoveredLocalServers: () => mocks.servers, @@ -162,6 +157,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { dispatchPreviewAction } from "./previewActionBus"; import { PreviewView } from "./PreviewView"; +import { ToastProvider } from "~/components/ui/toast"; const TEST_THREAD_REF = { environmentId: EnvironmentId.make("environment-1"), @@ -219,6 +215,23 @@ function setup() { } describe("PreviewView", () => { + it.each(["mailto:alice@example.com", "http://"])( + "reports rejected address %s without navigating or recording it", + async (address) => { + setup(); + await using _screen = await render({view()}); + const input = page.getByPlaceholder("Search or enter URL"); + await input.fill(address); + await userEvent.keyboard("{Enter}"); + + await expect.element(page.getByText("Could not open this address")).toBeVisible(); + await expect.element(page.getByText("Enter an http or https URL.")).toBeVisible(); + expect(mocks.navigate).not.toHaveBeenCalled(); + expect(mocks.recordVisitForThread).not.toHaveBeenCalled(); + await userEvent.click(page.getByRole("button", { name: "Dismiss notification" })); + }, + ); + it("retains the annotation locally when its own environment loses access during capture", async () => { setup(); const picked = annotation("annotation-revoked"); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 4eed7d21bc04a..5126b18ec7a91 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -17,6 +17,7 @@ import { } from "@t3tools/contracts"; import { normalizePreviewUrl, + isPreviewUrlNormalizationError, resolveAddressBarInput, previewPdfAssetUrl, resolveSearchUrlTemplate, @@ -259,7 +260,17 @@ export function PreviewView({ if (await navigateToResolvedUrl(resolved)) { recordVisitForThread(threadRef, resolved); } - } catch {} + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not open this address", + description: isPreviewUrlNormalizationError(error) + ? "Enter an http or https URL." + : error instanceof Error + ? error.message + : "An error occurred.", + }); + } }; const handleOpenServerUrl = async (next: string) => { @@ -271,7 +282,17 @@ export function PreviewView({ if (await navigateToResolvedUrl(resolved)) { recordVisitForThread(threadRef, next); } - } catch {} + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not open this address", + description: isPreviewUrlNormalizationError(error) + ? "Enter an http or https URL." + : error instanceof Error + ? error.message + : "An error occurred.", + }); + } }; const handleRefresh = () => { From 200a373fbde4a41dd70c4d075a993fb079df14f4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:54:03 -0500 Subject: [PATCH 091/379] fix(opencode): keep streamed reply text after an event stream reconnect (#23591) Co-authored-by: Claude Opus 5.5 --- .../Adapters/OpenCode2AdapterV2.test.ts | 43 +++++++++++++++++++ .../src/server/v2/adapter.ts | 15 ++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 37350b4925cf9..bb26a5dd53a30 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2265,6 +2265,49 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect("continues a reply still streaming at reconnect from the text already shown", () => + Effect.gen(function* () { + const delta = (text: string) => + event("session.text.delta", { + sessionID: SESSION, + assistantMessageID: "msg_assistant_gap", + ordinal: 0, + delta: text, + }); + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, id: PROMPT_ID, text: "" }), + promptAccepted, + delta("Sent before the stream dropped"), + // OpenCode saves a block's text only when the block ends. + ...reconnected({ [SESSION]: { type: "running" } }, undefined, { + content: [{ type: "text", text: "" }], + finish: undefined, + }), + ...openRequests([]), + delta(" and after it came back."), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn(turnInput(thread)); + const collected = yield* Fiber.join(events); + const texts = collected.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem.text] + : [], + ); + assert.deepEqual(texts, [ + "Sent before the stream dropped", + "Sent before the stream dropped", + "Sent before the stream dropped and after it came back.", + "Sent before the stream dropped and after it came back.", + ]); + }).pipe(Effect.scoped), + ); + it.effect("shows a permission asked while the stream was down, and answers it", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed( diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 1f2dd94ca2112..91b3d62fffd37 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -2493,10 +2493,21 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr for (const message of recent.toReversed()) { if (message.type !== "assistant" || state.active !== turn) continue; const ordinals = { text: 0, reasoning: 0 }; - for (const part of message.content) { + for (const [index, part] of message.content.entries()) { if (part.type === "text" || part.type === "reasoning") { const block = { assistantMessageID: message.id, ordinal: ordinals[part.type]++ }; - yield* emitText(state, turn, block, part.type, () => part.text, true); + // OpenCode saves a block's text only when the block ends, so the last block of a + // response still streaming stays open and keeps the text that already arrived. + const open = + message.time.streamed === undefined && index === message.content.length - 1; + yield* emitText( + state, + turn, + block, + part.type, + (text) => (open && !part.text.startsWith(text) ? text : part.text), + !open, + ); continue; } if (part.type !== "tool") continue; From 9facfc59230a68be71c427d708d3ee6c2ccaa2d3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:54:20 -0500 Subject: [PATCH 092/379] fix(opencode): apply the 3-minute limit to the whole v2 text generation (#23598) Co-authored-by: Claude Opus 5.5 --- .../OpenCode2TextGeneration.test.ts | 69 +++++++++++++++++++ .../src/server/v2/textGeneration.ts | 65 +++++++++-------- 2 files changed, 106 insertions(+), 28 deletions(-) diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index 775d2542d67ec..2cfe556ebf58f 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -1,8 +1,11 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; +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 { TestClock } from "effect/testing"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts"; @@ -119,4 +122,70 @@ it.layer(layer)("OpenCode2TextGeneration", (it) => { assert.deepEqual(failure.cause, refusal); }).pipe(Effect.scoped), ); + + it.effect("times out when the prompt request and its interrupt never answer", () => + Effect.gen(function* () { + const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; + const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; + const prompted = yield* Deferred.make(); + const server = yield* OpenCode2AdapterV2Testkit.replayServer( + { + provider: "opencode", + protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, + version: "2.0.18", + scenario: "opencode2_title_generation_stalled", + entries: [ + subscribe!, + connected!, + create!, + created!, + { + type: "expect_outbound", + frame: { type: "session.prompt", input: { sessionID: sessionId, text: "" } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.prompt", data: "" }, + }, + { + type: "expect_outbound", + frame: { type: "session.interrupt", input: { sessionID: sessionId } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.interrupt", data: "" }, + }, + { + type: "expect_outbound", + frame: { type: "session.remove", input: { sessionID: sessionId } }, + }, + { + type: "emit_inbound", + frame: { type: "sdk.response", operation: "session.remove", data: null }, + }, + { type: "runtime_exit", status: "success" }, + ], + }, + { onPrompt: () => Deferred.doneUnsafe(prompted, Effect.void) }, + ); + const textGeneration = yield* OpenCode2TextGeneration.make().pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, server), + ); + const generation = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "fix the login redirect loop after oauth", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/big-pickle", + }, + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Deferred.await(prompted); + yield* TestClock.adjust("3 minutes"); + yield* TestClock.adjust("5 seconds"); + const failure = yield* Fiber.join(generation); + assert.equal(failure.detail, "OpenCode did not finish generating in time."); + }).pipe(Effect.scoped), + ); }); diff --git a/packages/provider-opencode/src/server/v2/textGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts index ff47491e14fe1..4897b58afdc17 100644 --- a/packages/provider-opencode/src/server/v2/textGeneration.ts +++ b/packages/provider-opencode/src/server/v2/textGeneration.ts @@ -112,38 +112,47 @@ const runOnServer = Effect.fn("Opencode2TextGeneration.runOnServer")(function* ( ), Effect.forkScoped, ); - const session = yield* client.session.create({ - title: `T3 Code ${input.operation}`, - location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), - model: Model.Ref.make({ - providerID: Provider.ID.make(parsed.providerID), - id: Model.ID.make(parsed.modelID), - ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), - }), - permissions: GENERATION_PERMISSIONS, - }); - sessionId = session.id; - yield* Effect.addFinalizer(() => - client.session - .remove({ sessionID: session.id }) - .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), - ); - const images = (input.attachments ?? []).flatMap((attachment) => { - if (attachment.type !== "image") return []; - const path = resolveAttachmentPath(attachment); - return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; - }); - yield* client.session.prompt({ - sessionID: session.id, - text: input.prompt, - ...(images.length === 0 ? {} : { files: images }), + // The limit covers creating the session and sending the prompt as well, since + // either request can stall while the event stream stays connected. + const generate = Effect.gen(function* () { + const session = yield* client.session.create({ + title: `T3 Code ${input.operation}`, + location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }), + model: Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }), + permissions: GENERATION_PERMISSIONS, + }); + sessionId = session.id; + yield* Effect.addFinalizer(() => + client.session + .remove({ sessionID: session.id }) + .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })), + ); + const images = (input.attachments ?? []).flatMap((attachment) => { + if (attachment.type !== "image") return []; + const path = resolveAttachmentPath(attachment); + return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; + }); + yield* client.session.prompt({ + sessionID: session.id, + text: input.prompt, + ...(images.length === 0 ? {} : { files: images }), + }); + return yield* Deferred.await(outcome); }); - const result = yield* Deferred.await(outcome).pipe( + const result = yield* generate.pipe( Effect.timeoutOrElse({ duration: GENERATION_TIMEOUT, orElse: () => - client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe( - Effect.ignore, + (sessionId === undefined + ? Effect.void + : client.session + .interrupt({ sessionID: Session.ID.make(sessionId) }) + .pipe(Effect.timeout("5 seconds"), Effect.ignore) + ).pipe( Effect.as({ _tag: "failed", detail: "OpenCode did not finish generating in time.", From 72986b483ad5913cdbc1147017cd5fb7d541a9b4 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:54:36 -0500 Subject: [PATCH 093/379] fix(mobile): show draft review comments inline and in the count (#23602) Co-authored-by: GPT-6.1 Sol Co-authored-by: maria-rcks --- .../src/features/review/ReviewSheet.tsx | 3 +- .../review/reviewCommentSelection.test.ts | 72 +++++++++++++++++++ .../features/review/reviewCommentSelection.ts | 42 +++++++++-- .../src/features/review/useReviewDiffData.ts | 10 +-- .../src/state/use-thread-composer-state.ts | 1 + 5 files changed, 117 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 18908302d577e..359ea229cb8cc 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -435,7 +435,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const environment = useEnvironmentPresentation(environmentId); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; - const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); + const { draftMessage, draftContext } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const { selectedThreadCwd } = useSelectedThreadWorktree(); const columnMetrics = useNativeColumnLayoutMetrics(); @@ -475,6 +475,7 @@ export function ReviewSheet(props: ReviewSheetProps) { cwd: selectedThreadCwd, selectedSection, draftMessage, + draftContext, }); // Expo may return null before registration or in older binaries; use the fallback. const NativeReviewDiffView = resolveNativeReviewDiffView(); diff --git a/apps/mobile/src/features/review/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts index 59a0bb6bcb683..81eb0bba4083a 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.test.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; +import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; + +import { pullRequestComposerContext } from "../../lib/composerContext"; import { clearReviewCommentTarget, @@ -50,6 +54,74 @@ function makeTarget(): ReviewCommentTarget { } describe("review comment serialization", () => { + it("excludes a linked pull request chip from draft review comments", () => { + const record = pullRequestComposerContext( + { + number: 42, + title: "Fix checkout", + url: "https://github.com/example/repo/pull/42", + headBranch: "fix-checkout", + baseBranch: "main", + state: "open", + isDraft: false, + }, + "pr-42", + ); + + expect( + parseReviewInlineComments(formatComposerContextReference(record), { + version: 1, + records: [record], + }), + ).toEqual([]); + }); + + it("reads an added review comment after the composer upgrades it to a context chip", () => { + const upgraded = upgradeLegacyContextMessage( + formatReviewCommentContext(makeTarget(), "Please keep this configurable."), + ); + const comments = parseReviewInlineComments(upgraded.text, { + version: 1, + records: upgraded.records, + }); + + expect(comments).toEqual([ + { + id: upgraded.records[0]!.contextId, + sectionId: "section-1", + sectionTitle: "Working tree", + filePath: "apps/demo/src/main.ts", + startIndex: 0, + endIndex: 1, + rangeLabel: "7", + text: "Please keep this configurable.", + diff: "@@ -7,1 +7,1 @@\n-const retryLimit = 2;\n+const retryLimit = 4;", + fenceLanguage: "diff", + }, + ]); + }); + + it("keeps legacy comments and counts a typed comment only while its chip is present", () => { + const upgraded = upgradeLegacyContextMessage( + formatReviewCommentContext(makeTarget(), "Typed comment"), + ); + const context = { version: 1, records: upgraded.records } as const; + const legacy = formatReviewCommentContext(makeTarget(), "Legacy comment"); + const comments = parseReviewInlineComments( + `${legacy}\n${upgraded.text}\n${upgraded.text}`, + context, + ); + + expect(comments.map((comment) => comment.text)).toEqual(["Legacy comment", "Typed comment"]); + expect(parseReviewInlineComments(legacy, context).map((comment) => comment.text)).toEqual([ + "Legacy comment", + ]); + expect(parseReviewInlineComments("", context)).toEqual([]); + expect( + parseReviewInlineComments(upgraded.text.replace("/review-comment/", "/mention/"), context), + ).toEqual([]); + }); + it("keeps closing-tag text inside a chip label within a real review body", () => { const body = "Before [](t3-context://v1/mention/context-1) after"; const serialized = `${body}`; diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 4e3b22407f5a0..70f226d604e04 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -1,5 +1,9 @@ import { useSyncExternalStore } from "react"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import type { OrchestrationMessageContext } from "@t3tools/contracts"; +import { + collectComposerContextReferences, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import type { ReviewRenderableLineRow } from "./reviewModel"; @@ -357,11 +361,10 @@ export function formatReviewCommentContext(target: ReviewCommentTarget, comment: ].join("\n"); } -export function countReviewCommentContexts(value: string): number { - return Array.from(value.matchAll(/ { +export function parseReviewInlineComments( + value: string, + context?: OrchestrationMessageContext, +): ReadonlyArray { const comments: ReviewInlineComment[] = []; // Mask chip delimiters: labels containing would otherwise truncate the block. const masked = replaceComposerContextReferences(value, (reference) => @@ -385,6 +388,33 @@ export function parseReviewInlineComments(value: string): ReadonlyArray reference.kind === "review-comment") + .map((reference) => reference.contextId), + ); + for (const record of context?.records ?? []) { + if ( + record.kind !== "review-comment" || + "payload" in record || + record.sectionId.startsWith("pull-request:") || + !referencedIds.has(record.contextId) + ) { + continue; + } + comments.push({ + id: record.contextId, + sectionId: record.sectionId, + sectionTitle: record.sectionTitle, + filePath: record.filePath, + startIndex: record.startIndex, + endIndex: record.endIndex, + rangeLabel: record.rangeLabel, + text: record.text, + diff: record.diff, + ...(record.fenceLanguage !== undefined ? { fenceLanguage: record.fenceLanguage } : {}), + }); + } return comments; } diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index 6ccc5ae3f495c..d9ec656cbc773 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -1,7 +1,7 @@ import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; +import { parseReviewInlineComments } from "./reviewCommentSelection"; import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; import { getCachedReviewParsedDiff } from "./reviewState"; import { @@ -14,6 +14,7 @@ import { import type { EnvironmentId, + OrchestrationMessageContext, ReviewDiffFileStat, ReviewDiffPreviewSource, } from "@t3tools/contracts"; @@ -126,8 +127,9 @@ export function useReviewDiffData(input: { readonly cwd: string | null; readonly selectedSection: ReviewSectionItem | null; readonly draftMessage: string; + readonly draftContext: OrchestrationMessageContext | undefined; }) { - const { draftMessage, selectedSection, threadKey } = input; + const { draftMessage, draftContext, selectedSection, threadKey } = input; const selectedSectionId = selectedSection?.id ?? null; const source = selectedSection?.source; const lazySource = source?.truncated && source.files ? source : null; @@ -230,7 +232,7 @@ export function useReviewDiffData(input: { () => formatHeaderDiffSummary(parsedDiff, selectedSection?.files), [parsedDiff, selectedSection?.files], ); - const inlineReviewComments = parseReviewInlineComments(draftMessage); + const inlineReviewComments = parseReviewInlineComments(draftMessage, draftContext); const selectedSectionInlineComments = useMemo(() => { if (!selectedSectionId || inlineReviewComments.length === 0) { return EMPTY_INLINE_REVIEW_COMMENTS; @@ -241,7 +243,7 @@ export function useReviewDiffData(input: { parsedDiff, comments: selectedSectionInlineComments, }); - const pendingReviewCommentCount = countReviewCommentContexts(draftMessage); + const pendingReviewCommentCount = inlineReviewComments.length; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records review perf marks and diagnostic logs once a parsed diff has committed useEffect(() => { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 9056bc20937fa..3926b2f781f5d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -164,6 +164,7 @@ export function useThreadDraftForThread(input: { return { draftMessage: draft.text, + draftContext: draft.context, draftAttachments: draft.attachments, }; } From 2b98f54fc05256b7158bb23fd8f1ab4c4112059e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:55:37 -0500 Subject: [PATCH 094/379] fix(github): expand pull request context from the merge base (#23638) Co-authored-by: Claude Opus 5.5 --- .../src/server/GitHubPullRequestApi.test.ts | 31 ++++++++++++++++++ .../src/server/GitHubPullRequestApi.ts | 32 ++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 78deffdfaeb10..5a5c46f28d99a 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -574,6 +574,7 @@ const checkContextsPage = ( }); const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); +const pullRequestMergeBase = encodeJson({ merge_base_commit: { sha: "c1d2e3f" } }); const crossRepositoryDetail = (headRefOid = "abc123") => coreResponse({ @@ -2856,6 +2857,34 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("expands a pull request file from its merge base, not the moved base tip", () => + Effect.gen(function* () { + const answers: Record = { + "repos/acme/web/pulls/7": pullRequestRefs, + "repos/acme/web/compare/a1b2c3d...b1c2d3e?per_page=1&page=2": pullRequestMergeBase, + "repos/acme/web/contents/src/a.ts?ref=a1b2c3d": "base tip\n", + "repos/acme/web/contents/src/a.ts?ref=c1d2e3f": "merge base\n", + "repos/acme/web/contents/src/a.ts?ref=b1c2d3e": "head\n", + }; + mockedExecute.mockImplementation((call) => + Effect.succeed(output(call.kind === "rest" ? (answers[call.path] ?? "") : "")), + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const contents = yield* cli.getPullRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + changeType: "change", + oldPath: "src/a.ts", + newPath: "src/a.ts", + }); + + expect(contents).toEqual({ oldContents: "merge base\n", newContents: "head\n" }); + }), + ); + it.effect("reports unusable diff revisions as a structured error", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); @@ -2885,6 +2914,7 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("reports undecodable diff file contents as binary", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestMergeBase))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; @@ -2911,6 +2941,7 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("returns valid text containing a literal replacement character", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestMergeBase))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index b812fd7682eaf..2defbb6684919 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -863,6 +863,13 @@ const decodeRevisionRefs = Schema.decodeUnknownOption( ), ); +/** Where a pull request's head forked off its base, the commit its patch is computed from. */ +const decodeMergeBase = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ merge_base_commit: Schema.Struct({ sha: Schema.String }) }), + ), +); + const GRAPHQL_MERGE_METHODS = { merge: "MERGE", squash: "SQUASH", @@ -1299,6 +1306,29 @@ export const make = Effect.gen(function* () { ...(input.commit === undefined ? {} : { commit: input.commit }), }); } + // A pull request's patch runs from the merge base, and the base branch may have moved on + // since, so its old side is read there. Page 2 of a compare leaves out the file list; a + // failed read falls back to the base branch tip. + const oldRef = + input.commit === undefined && input.changeType !== "new" + ? yield* api + .rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + path: `repos/${owner}/${name}/compare/${baseRef}...${headRef}?per_page=1&page=2`, + timeout: DIFF_TIMEOUT, + }) + .pipe( + Effect.map((response) => + decodeMergeBase(response.body).pipe( + Option.map((compare) => compare.merge_base_commit.sha), + Option.filter(isCommitSha), + Option.getOrElse(() => baseRef ?? ""), + ), + ), + Effect.orElseSucceed(() => baseRef ?? ""), + ) + : (baseRef ?? ""); const readFile = (revision: string, filePath: string) => api @@ -1329,7 +1359,7 @@ export const make = Effect.gen(function* () { const [oldContents, newContents] = yield* Effect.all( [ - input.changeType === "new" ? Effect.succeed("") : readFile(baseRef ?? "", input.oldPath), + input.changeType === "new" ? Effect.succeed("") : readFile(oldRef, input.oldPath), input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), ], { concurrency: 2 }, From 353cf7b519251bb073515e337d23be9eea93a05a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 15:55:55 -0500 Subject: [PATCH 095/379] fix(web): show project action edit buttons on touch screens (#23640) Co-authored-by: GPT-6.1 Sol --- apps/web/src/components/settings/ProjectActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx index cc19de17746be..f30b90c184ba2 100644 --- a/apps/web/src/components/settings/ProjectActionsList.tsx +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -56,7 +56,7 @@ export function ProjectActionsList({ {shortcutLabel ? ( {shortcutLabel} ) : null} - +