From 28107e89c25711a835369c2b52c552f82e8de02a Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Mon, 22 Jun 2026 05:31:26 +0200 Subject: [PATCH 01/28] chore: remove `AnnotatableFileDiff` leftovers, rename file (#3488) --- apps/web/src/components/DiffPanel.tsx | 2 +- ...leFileDiff.tsx => AnnotatableCodeView.tsx} | 180 +----------------- 2 files changed, 2 insertions(+), 180 deletions(-) rename apps/web/src/components/diffs/{AnnotatableFileDiff.tsx => AnnotatableCodeView.tsx} (60%) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f39af581d5a..e41ba9c2440 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -38,7 +38,7 @@ import { resolveThreadRouteRef } from "../threadRoutes"; import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff"; +import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx similarity index 60% rename from apps/web/src/components/diffs/AnnotatableFileDiff.tsx rename to apps/web/src/components/diffs/AnnotatableCodeView.tsx index f74b1e59aa3..6cea64fb570 100644 --- a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -6,13 +6,7 @@ import type { FileDiffMetadata, SelectedLineRange, } from "@pierre/diffs"; -import { - CodeView, - type CodeViewHandle, - type CodeViewProps, - FileDiff, - type FileDiffProps, -} from "@pierre/diffs/react"; +import { CodeView, type CodeViewHandle, type CodeViewProps } from "@pierre/diffs/react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react"; @@ -76,178 +70,6 @@ function appendAnnotationEntry( ); } -interface AnnotatableFileDiffProps { - fileDiff: FileDiffMetadata; - filePath: string; - sectionId: string; - sectionTitle: string; - composerDraftTarget: ScopedThreadRef | DraftId; - options: FileDiffProps["options"]; - renderHeaderPrefix: (fileDiff: FileDiffMetadata) => ReactNode; -} - -export function AnnotatableFileDiff({ - fileDiff, - filePath, - sectionId, - sectionTitle, - composerDraftTarget, - options, - renderHeaderPrefix, -}: AnnotatableFileDiffProps) { - const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); - const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); - const reviewComments = useComposerDraftStore( - (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, - ); - const [selectedRange, setSelectedRange] = useState(null); - const [draftAnnotation, setDraftAnnotation] = useState(null); - const persistedAnnotations = useMemo( - () => - reviewComments - .filter( - (comment) => - comment.sectionId === sectionId && - comment.filePath === filePath && - (comment.fenceLanguage ?? "diff") === "diff", - ) - .reduce((annotations, comment) => { - const range = restoreDiffReviewCommentRange(fileDiff, comment); - if (!range) return annotations; - return appendAnnotationEntry(annotations, range, { - id: comment.id, - kind: "comment", - range, - rangeLabel: comment.rangeLabel, - text: comment.text, - }); - }, []), - [fileDiff, filePath, reviewComments, sectionId], - ); - const lineAnnotations = useMemo( - () => (draftAnnotation ? [...persistedAnnotations, draftAnnotation] : persistedAnnotations), - [draftAnnotation, persistedAnnotations], - ); - - const removeAnnotationEntry = useCallback( - (entryId: string) => { - setSelectedRange(null); - if ( - draftAnnotation?.metadata.entries.some( - (entry) => entry.id === entryId && entry.kind === "draft", - ) - ) { - setDraftAnnotation(null); - return; - } - removeReviewComment(composerDraftTarget, entryId); - }, - [composerDraftTarget, draftAnnotation, removeReviewComment], - ); - - const submitAnnotationEntry = useCallback( - (entryId: string, text: string) => { - const entry = draftAnnotation?.metadata.entries.find((candidate) => candidate.id === entryId); - if (!entry) return; - - const comment = buildDiffReviewComment({ - id: entry.id, - sectionId, - sectionTitle, - filePath, - fileDiff, - range: entry.range, - text, - }); - if (comment) { - addReviewComment(composerDraftTarget, comment); - } - setSelectedRange(null); - setDraftAnnotation(null); - }, - [ - addReviewComment, - composerDraftTarget, - fileDiff, - filePath, - draftAnnotation, - sectionId, - sectionTitle, - ], - ); - - const beginComment = useCallback( - (range: SelectedLineRange) => { - const id = nextFileCommentId(); - const comment = buildDiffReviewComment({ - id, - sectionId, - sectionTitle, - filePath, - fileDiff, - range, - text: "", - }); - if (!comment) return; - - const draftEntry: DiffCommentAnnotationEntry = { - id, - kind: "draft", - range, - rangeLabel: comment.rangeLabel, - text: "", - }; - setDraftAnnotation({ - side: annotationSide(range), - lineNumber: range.end, - metadata: { entries: [draftEntry] }, - }); - }, - [fileDiff, filePath, sectionId, sectionTitle], - ); - - const hasOpenCommentForm = draftAnnotation !== null; - const handleLineSelectionEnd = useCallback( - (range: SelectedLineRange | null) => { - setSelectedRange(range); - if (range) beginComment(range); - }, - [beginComment], - ); - - return ( - - fileDiff={fileDiff} - renderHeaderPrefix={renderHeaderPrefix} - options={{ - ...options, - enableGutterUtility: !hasOpenCommentForm, - enableLineSelection: !hasOpenCommentForm, - onGutterUtilityClick: setSelectedRange, - onLineSelectionChange: setSelectedRange, - onLineSelectionEnd: handleLineSelectionEnd, - }} - selectedLines={selectedRange} - lineAnnotations={lineAnnotations} - renderAnnotation={(annotation) => ( -
- {annotation.metadata.entries.map((entry) => ( - removeAnnotationEntry(entry.id)} - onComment={(text) => submitAnnotationEntry(entry.id, text)} - onDelete={() => removeAnnotationEntry(entry.id)} - /> - ))} -
- )} - /> - ); -} - interface AnnotatableCodeViewProps { files: ReadonlyArray<{ fileDiff: FileDiffMetadata; From ea52bb1dbda115f9824415f7589505c9e57268c6 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:46:15 +0200 Subject: [PATCH 02/28] [codex] fix: guard trace ID clipboard copy (#3505) Co-authored-by: Codex --- .../settings/ConnectionsSettings.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 96d9dd4510f..e693c7b15b0 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1423,6 +1423,31 @@ function SavedBackendListRow({ : "bg-muted-foreground/40"; const statusTooltip = connectionStatusText(environment.connection); const errorTraceId = environment.connection.traceId; + const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ + target: "trace ID", + onCopy: ({ traceId }) => { + toastManager.add({ + type: "success", + title: "Trace ID copied", + description: traceId, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy trace ID", + description: error.message, + }), + ); + }, + }); + const copyTraceId = useCallback( + (traceId: string) => { + copyTraceIdToClipboard(traceId, { traceId }); + }, + [copyTraceIdToClipboard], + ); const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); const sshTarget = environment.entry.target._tag === "SshConnectionTarget" && @@ -1468,7 +1493,7 @@ function SavedBackendListRow({ From 8b993bc9ff79c52be691f56f077abdac26f0e69f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:10:47 +0200 Subject: [PATCH 03/28] [codex] fix: restore pending input keyboard activation (#3501) Co-authored-by: Codex --- .../components/chat/ComposerPendingUserInputPanel.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index bf869d25c66..d826eca0063 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -208,19 +208,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ); return ( -
{ - if (isResponding) return; handleOptionSelection(activeQuestion.id, option.label); }} className={className} > {content} -
+ ); })} From c1e2408e1f317bba2fbb48a27498b4ae4bef47d8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:14:16 +0200 Subject: [PATCH 04/28] [codex] fix: preserve localhost preview hosts (#3499) Co-authored-by: Codex --- apps/web/src/browser/browserTargetResolver.test.ts | 8 ++++++++ apps/web/src/browser/browserTargetResolver.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 2305812784f..d3c7f6a8dab 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -47,6 +47,14 @@ describe("browser target resolver", () => { ).toBe("http://localhost:3000/app"); }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173/app?x=1#top"), + ).toBe("http://localhost:5173/app?x=1#top"); + }); + it("normalizes public URLs without treating them as environment ports", async () => { const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 0a6dc3aa7c2..9142cce1e72 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -24,6 +24,11 @@ const isPrivateNetworkHost = (host: string): boolean => { ); }; +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +}; + export function resolveBrowserNavigationTarget( environmentId: EnvironmentId, target: BrowserNavigationTarget, @@ -68,6 +73,12 @@ export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: const normalizedUrl = normalizePreviewUrl(rawUrl); const parsed = new URL(normalizedUrl); if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const environmentUrl = new URL(connection.httpBaseUrl); + if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { + return normalizedUrl; + } const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); return resolveBrowserNavigationTarget(environmentId, { kind: "environment-port", From 8919ae7c53973048827481ecc3a96d4d778955f7 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:15:43 +0200 Subject: [PATCH 05/28] [codex] Reject unsupported remote pairing protocols (#3498) Co-authored-by: Codex --- packages/shared/src/remote.test.ts | 47 ++++++++++++++++++++++++++++++ packages/shared/src/remote.ts | 23 +++++++++++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 54c78907421..24e78757009 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -72,6 +72,53 @@ describe("remote", () => { }); }); + it("rejects unsupported direct pairing URL protocols", () => { + let pairingUrlError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: "ftp://remote.example.com/pair#token=pairing-token", + }); + } catch (cause) { + pairingUrlError = cause; + } + + expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError); + expect(pairingUrlError).toMatchObject({ protocol: "ftp:" }); + expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported hosted pairing backend protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: + "https://app.t3.codes/pair?host=ftp%3A%2F%2Fremote.example.com#token=pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "hosted-pairing-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported direct host protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + host: "ftp://remote.example.com", + pairingCode: "pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "direct-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + it("uses distinct structural errors for missing pairing inputs", () => { expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError); expect(() => diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 703811609b8..7347dbc74a1 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; +const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); @@ -18,7 +19,10 @@ export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( "RemotePairingUrlInvalidError", - { cause: Schema.Defect() }, + { + cause: Schema.optional(Schema.Defect()), + protocol: Schema.optional(Schema.String), + }, ) { override get message(): string { return "Pairing URL is invalid."; @@ -29,7 +33,8 @@ export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass + SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol); + const normalizeRemoteBaseUrl = ( rawValue: string, source: RemoteBackendUrlInvalidError["source"], @@ -83,6 +91,12 @@ const normalizeRemoteBaseUrl = ( } catch (cause) { throw new RemoteBackendUrlInvalidError({ source, cause }); } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemoteBackendUrlInvalidError({ + source, + protocol: url.protocol, + }); + } url.pathname = "/"; url.search = ""; url.hash = ""; @@ -184,6 +198,11 @@ export const resolveRemotePairingTarget = (input: { } catch (cause) { throw new RemotePairingUrlInvalidError({ cause }); } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemotePairingUrlInvalidError({ + protocol: url.protocol, + }); + } const hostedPairingRequest = readHostedPairingRequest(url); if (hostedPairingRequest) { const hostedBackendUrl = normalizeRemoteBaseUrl( From 37ac970e289f6a79940c00e17c5a58114d94d4cf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 12:37:31 -0700 Subject: [PATCH 06/28] Persist mobile composer selectors across drafts (#3496) Co-authored-by: codex --- .../features/threads/NewTaskDraftScreen.tsx | 64 ++++-- .../features/threads/ThreadRouteScreen.tsx | 85 ++------ .../threads/new-task-flow-provider.tsx | 185 ++++++++++-------- apps/mobile/src/lib/composer-image-schema.ts | 11 ++ apps/mobile/src/state/thread-outbox-model.ts | 78 ++++++-- apps/mobile/src/state/thread-outbox.test.ts | 79 +++++++- .../src/state/use-composer-drafts.test.ts | 131 ++++++++++++- apps/mobile/src/state/use-composer-drafts.ts | 146 +++++++++++--- .../src/state/use-thread-composer-state.ts | 69 ++++++- .../src/state/use-thread-outbox-drain.ts | 149 ++++++++++---- 10 files changed, 751 insertions(+), 246 deletions(-) create mode 100644 apps/mobile/src/lib/composer-image-schema.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 92c13c20070..ce24198f5e2 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -30,7 +30,9 @@ import { resolveProviderOptionDescriptors, } from "../../lib/providerOptions"; import { buildThreadRoutePath } from "../../lib/routes"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { useProjects } from "../../state/entities"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; @@ -63,6 +65,7 @@ export function NewTaskDraftScreen(props: { const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; const promptInputRef = useRef(null); + const loadedBranchesProjectKeyRef = useRef(null); const borderColor = useThemeColor("--color-border"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; @@ -78,6 +81,12 @@ export function NewTaskDraftScreen(props: { ) ?? null; if (directProject) { + if ( + selectedProject?.environmentId === directProject.environmentId && + selectedProject.id === directProject.id + ) { + return; + } setProject(directProject); return; } @@ -105,10 +114,16 @@ export function NewTaskDraftScreen(props: { useEffect(() => { if (!selectedProject) { + loadedBranchesProjectKeyRef.current = null; + return; + } + const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`; + if (loadedBranchesProjectKeyRef.current === projectKey) { return; } + loadedBranchesProjectKeyRef.current = projectKey; void flow.loadBranches(); - }, [flow, selectedProject]); + }, [flow.loadBranches, selectedProject]); useEffect(() => { if (!selectedProject) { @@ -292,11 +307,7 @@ export function NewTaskDraftScreen(props: { if (!event.startsWith("model:")) { return; } - // Defer state update so the native menu dismiss animation completes - // before re-rendering the menu actions (prevents submenu jump). - setTimeout(() => { - flow.setSelectedModelKey(event.slice("model:".length)); - }, 150); + flow.setSelectedModelKey(event.slice("model:".length)); } function handleEnvironmentMenuAction(event: string) { @@ -366,27 +377,42 @@ export function NewTaskDraftScreen(props: { ); async function handleStart(): Promise { + const selectedProject = flow.selectedProject; + if (!selectedProject) { + return; + } + const draft = getComposerDraftSnapshot( + `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`, + ); + const modelSelection = draft.modelSelection ?? flow.selectedModel; + const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; + const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; + const selectedWorktreePath = + draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; + const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; + const interactionMode = draft.interactionMode ?? flow.interactionMode; + const initialMessageText = draft.text.trim(); + if ( - !flow.selectedProject || - !flow.selectedModel || - flow.prompt.trim().length === 0 || + !modelSelection || + initialMessageText.length === 0 || flow.submitting || - (flow.workspaceMode === "worktree" && !flow.selectedBranchName) + (workspaceMode === "worktree" && !selectedBranchName) ) { return; } flow.setSubmitting(true); const result = await createProjectThread({ - project: flow.selectedProject, - modelSelection: flow.selectedModel, - envMode: flow.workspaceMode, - branch: flow.selectedBranchName, - worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath, - runtimeMode: flow.runtimeMode, - interactionMode: flow.interactionMode, - initialMessageText: flow.prompt.trim(), - initialAttachments: flow.attachments, + project: selectedProject, + modelSelection, + envMode: workspaceMode, + branch: selectedBranchName, + worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, + runtimeMode, + interactionMode, + initialMessageText, + initialAttachments: draft.attachments, }); flow.setSubmitting(false); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7bb74ae88ff..f8c916974e5 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,13 +1,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useMemo, useState } from "react"; import * as Option from "effect/Option"; -import { - EnvironmentId, - type ModelSelection, - type ProjectScript, - type ProviderInteractionMode, - type RuntimeMode, -} from "@t3tools/contracts"; +import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Pressable, ScrollView, Text as RNText, View } from "react-native"; import { useWorkspaceState } from "../../state/workspace"; @@ -79,18 +73,6 @@ export function ThreadRouteScreen() { const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); - const updateThreadMetadata = useAtomCommand( - threadEnvironment.updateMetadata, - "thread metadata update", - ); - const setThreadRuntimeMode = useAtomCommand( - threadEnvironment.setRuntimeMode, - "thread runtime mode", - ); - const setThreadInteractionMode = useAtomCommand( - threadEnvironment.setInteractionMode, - "thread interaction mode", - ); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const router = useRouter(); const params = useLocalSearchParams<{ @@ -105,6 +87,18 @@ export function ThreadRouteScreen() { const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; + const selectedThreadWithDraftSettings = useMemo( + () => + selectedThread + ? { + ...selectedThread, + modelSelection: composer.modelSelection ?? selectedThread.modelSelection, + runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode, + interactionMode: composer.interactionMode ?? selectedThread.interactionMode, + } + : null, + [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread], + ); /* ─── Native header theming ──────────────────────────────────────── */ const iconColor = String(useThemeColor("--color-icon")); @@ -157,51 +151,6 @@ export function ThreadRouteScreen() { const handleOpenConnectionEditor = useCallback(() => { void router.push("/connections"); }, [router]); - const handleUpdateThreadModelSelection = useCallback( - (modelSelection: ModelSelection) => { - if (!selectedThread) { - return; - } - return updateThreadMetadata({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - modelSelection, - }, - }); - }, - [selectedThread, updateThreadMetadata], - ); - const handleUpdateThreadRuntimeMode = useCallback( - (runtimeMode: RuntimeMode) => { - if (!selectedThread) { - return; - } - return setThreadRuntimeMode({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - runtimeMode, - }, - }); - }, - [selectedThread, setThreadRuntimeMode], - ); - const handleUpdateThreadInteractionMode = useCallback( - (interactionMode: ProviderInteractionMode) => { - if (!selectedThread) { - return; - } - return setThreadInteractionMode({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - interactionMode, - }, - }); - }, - [selectedThread, setThreadInteractionMode], - ); const handleStopThread = useCallback(() => { if ( !selectedThread || @@ -435,7 +384,7 @@ export function ThreadRouteScreen() { (null); - const [selectedModelKey, setSelectedModelKey] = useState(null); - const [workspaceMode, setWorkspaceMode] = useState("local"); - const [selectedBranchName, setSelectedBranchName] = useState(null); - const [selectedWorktreePath, setSelectedWorktreePath] = useState(null); - const branchLoadVersionRef = useRef(0); const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const [runtimeMode, setRuntimeMode] = useState(DEFAULT_RUNTIME_MODE); - const [interactionMode, setInteractionMode] = useState( - DEFAULT_PROVIDER_INTERACTION_MODE, - ); - const [modelSelectionOverrides, setModelSelectionOverrides] = useState< - Record - >({}); const [expandedProvider, setExpandedProvider] = useState(null); const reset = useCallback(() => { setSelectedEnvironmentId(null); setSelectedProjectKey(null); - setSelectedModelKey(null); - setWorkspaceMode("local"); - setSelectedBranchName(null); - setSelectedWorktreePath(null); setSubmitting(false); setBranchQuery(""); - setRuntimeMode(DEFAULT_RUNTIME_MODE); - setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); - setModelSelectionOverrides({}); setExpandedProvider(null); }, []); @@ -247,33 +229,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; + const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; + const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; + const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; + const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null, ), - [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection], + [ + selectedEnvironmentServerConfig, + selectedProject?.defaultModelSelection, + selectedProjectDraft.modelSelection, + ], ); - const defaultModelKey = selectedProject?.defaultModelSelection - ? `${selectedProject.defaultModelSelection.instanceId}:${selectedProject.defaultModelSelection.model}` - : null; - const baseSelectedModel = - modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? - (defaultModelKey - ? modelOptions.find((option) => option.key === defaultModelKey)?.selection - : null) ?? + const selectedModel = + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? modelOptions[0]?.selection ?? null; - const selectedModelIdentity = baseSelectedModel - ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}` + const selectedModelKey = selectedModel + ? `${selectedModel.instanceId}:${selectedModel.model}` : null; - const selectedModel = - (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ?? - baseSelectedModel; const selectedModelOption = modelOptions.find( @@ -282,13 +264,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = - selectedEnvironmentServerConfig?.providers.find( - (provider) => provider.instanceId === selectedModel?.instanceId, - )?.skills ?? []; + const selectedProviderSkills = useMemo( + () => + selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === selectedModel?.instanceId, + )?.skills ?? [], + [selectedEnvironmentServerConfig, selectedModel?.instanceId], + ); + const setSelectedModelKey = useCallback( + (key: string | null) => { + if (!key || !selectedProjectDraftKey) { + return; + } + const option = modelOptions.find((candidate) => candidate.key === key); + if (!option) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: option.selection, + }); + }, + [modelOptions, selectedProjectDraftKey], + ); const setSelectedModelOptions = useCallback( (options: ReadonlyArray | undefined) => { - if (!selectedModel || !selectedModelIdentity) { + if (!selectedModel || !selectedProjectDraftKey) { return; } const nextSelection: ModelSelection = options @@ -297,12 +297,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { instanceId: selectedModel.instanceId, model: selectedModel.model, }; - setModelSelectionOverrides((current) => ({ - ...current, - [selectedModelIdentity]: nextSelection, - })); + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: nextSelection, + }); }, - [selectedModel, selectedModelIdentity], + [selectedModel, selectedProjectDraftKey], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); @@ -381,62 +380,85 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const setProject = useCallback((project: EnvironmentProject) => { const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - branchLoadVersionRef.current += 1; setSelectedEnvironmentId(project.environmentId); setSelectedProjectKey(nextProjectKey); - setSelectedBranchName(null); - setSelectedWorktreePath(null); - setModelSelectionOverrides({}); }, []); const selectEnvironment = useCallback((environmentId: EnvironmentId) => { - branchLoadVersionRef.current += 1; setSelectedEnvironmentId(environmentId); setSelectedProjectKey(null); - setSelectedBranchName(null); - setSelectedWorktreePath(null); - setModelSelectionOverrides({}); }, []); + const setWorkspaceMode = useCallback( + (mode: WorkspaceMode) => { + if (!selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode, + branch: selectedBranchName, + worktreePath: selectedWorktreePath, + }, + }); + }, + [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], + ); + const selectBranch = useCallback( (branch: VcsRef) => { - setSelectedBranchName(branch.name); - setSelectedWorktreePath( - selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null, - ); + if (!selectedProject || !selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode: workspaceMode, + branch: branch.name, + worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), + }, + }); }, - [selectedProject], + [selectedProject, selectedProjectDraftKey, workspaceMode], ); + const refreshBranches = branchState.refresh; const loadBranches = useCallback(async () => { if (!selectedProject) { return; } + setPendingConnectionError(null); + refreshBranches(); + }, [refreshBranches, selectedProject]); - const loadVersion = ++branchLoadVersionRef.current; - const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); - branchState.refresh(); - if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { + useEffect(() => { + if (workspaceMode !== "worktree" || selectedBranchName !== null) { return; } - setPendingConnectionError(null); - if (workspaceMode === "worktree" && !selectedBranchName) { - const preferredBranch = - availableBranches.find((branch) => branch.current)?.name ?? - availableBranches.find((branch) => branch.isDefault)?.name ?? - null; - if (preferredBranch) { - setSelectedBranchName(preferredBranch); - } + const preferredBranch = + availableBranches.find((branch) => branch.current) ?? + availableBranches.find((branch) => branch.isDefault) ?? + null; + if (preferredBranch) { + selectBranch(preferredBranch); } - }, [ - availableBranches, - branchState, - selectedBranchName, - selectedProject, - selectedProjectKey, - workspaceMode, - ]); + }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]); + + const setRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); + } + }, + [selectedProjectDraftKey], + ); + const setInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value }); + } + }, + [selectedProjectDraftKey], + ); const value = useMemo( () => ({ @@ -513,6 +535,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setProject, selectBranch, selectEnvironment, + setInteractionMode, + setPrompt, + setRuntimeMode, + setSelectedModelKey, + setWorkspaceMode, submitting, workspaceMode, appendAttachments, diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts new file mode 100644 index 00000000000..a121b70ddb5 --- /dev/null +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -0,0 +1,11 @@ +import * as Schema from "effect/Schema"; + +export const DraftComposerImageAttachmentSchema = Schema.Struct({ + id: Schema.String, + previewUri: Schema.String, + type: Schema.Literal("image"), + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + dataUrl: Schema.String, +}); diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index aa7a1055136..ed0c06ba38b 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,32 +1,38 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; -import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + IsoDateTime, + MessageId, + ModelSelection, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + type ModelSelection as ModelSelectionType, + type ProviderInteractionMode as ProviderInteractionModeType, + type RuntimeMode as RuntimeModeType, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -const THREAD_OUTBOX_SCHEMA_VERSION = 1; +const THREAD_OUTBOX_SCHEMA_VERSION = 2; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; -const DraftComposerImageAttachmentSchema = Schema.Struct({ - id: Schema.String, - previewUri: Schema.String, - type: Schema.Literal("image"), - name: Schema.String, - mimeType: Schema.String, - sizeBytes: Schema.Number, - dataUrl: Schema.String, -}); - export const QueuedThreadMessageSchema = Schema.Struct({ - schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION), + schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, commandId: CommandId, text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelection), + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), createdAt: IsoDateTime, }); @@ -40,9 +46,37 @@ export interface QueuedThreadMessage { readonly commandId: CommandId; readonly text: string; readonly attachments: ReadonlyArray; + readonly modelSelection?: ModelSelectionType; + readonly runtimeMode?: RuntimeModeType; + readonly interactionMode?: ProviderInteractionModeType; readonly createdAt: string; } +export interface ThreadSettingsSnapshot { + readonly modelSelection: ModelSelectionType; + readonly runtimeMode: RuntimeModeType; + readonly interactionMode: ProviderInteractionModeType; +} + +export function resolveQueuedThreadSettings( + message: QueuedThreadMessage, + thread: ThreadSettingsSnapshot, +): ThreadSettingsSnapshot { + return { + modelSelection: message.modelSelection ?? thread.modelSelection, + runtimeMode: message.runtimeMode ?? thread.runtimeMode, + interactionMode: message.interactionMode ?? thread.interactionMode, + }; +} + +export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean { + return ( + left.instanceId === right.instanceId && + left.model === right.model && + JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null) + ); +} + export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown { return encodeStoredQueuedThreadMessage({ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, @@ -119,3 +153,21 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { } return isTransportConnectionErrorMessage(errorMessage(error)); } + +export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; +export type ThreadOutboxFailureAction = "retry" | "discard"; + +export function resolveThreadOutboxFailureAction(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; +}): ThreadOutboxFailureAction { + if ( + input.stage === "settings-sync" || + input.interrupted || + shouldRetryThreadOutboxDelivery(input.error) + ) { + return "retry"; + } + return "discard"; +} diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index d6b91c1c4f6..68d06d2e424 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it } from "@effect/vitest"; -import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; import { decodeQueuedThreadMessage, + encodeQueuedThreadMessage, groupQueuedThreadMessages, + modelSelectionsEqual, resolveThreadOutboxDeliveryAction, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadMessage, @@ -66,6 +76,54 @@ describe("thread outbox", () => { ).toThrow(); }); + it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { + const legacyMessage = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const selectedMessage = { + ...legacyMessage, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + } satisfies QueuedThreadMessage; + + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual( + selectedMessage, + ); + expect( + resolveQueuedThreadSettings(legacyMessage, { + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }), + ).toEqual({ + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }); + }); + + it("compares model options as part of the queued settings change", () => { + const base = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + } as const; + + expect(modelSelectionsEqual(base, base)).toBe(true); + expect( + modelSelectionsEqual(base, { + ...base, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }), + ).toBe(false); + }); + it("backs off queued delivery retries and caps them at sixteen seconds", () => { expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, @@ -271,4 +329,23 @@ describe("thread outbox", () => { ).toBe(true); expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); }); + + it("retains queued messages when settings synchronization fails before startTurn", () => { + const deterministicFailure = new Error("Thread no longer exists"); + + expect( + resolveThreadOutboxFailureAction({ + stage: "settings-sync", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("retry"); + expect( + resolveThreadOutboxFailureAction({ + stage: "start-turn", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("discard"); + }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 48e4e8703f0..d02abb6a265 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1,14 +1,136 @@ -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts"; +import { appAtomRegistry } from "./atom-registry"; +import { + clearComposerDraftContentState, + composerDraftsAtom, + decodePersistedComposerDrafts, + type ComposerDraft, + getComposerDraftSnapshot, + removeComposerDraftsForEnvironment, +} from "./use-composer-drafts"; const DRAFT: ComposerDraft = { text: "hello", attachments: [], }; +afterEach(() => { + appAtomRegistry.set(composerDraftsAtom, {}); +}); + describe("mobile composer drafts", () => { + it("hydrates selector state even when the message content is empty", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }, + }), + ).toEqual({ + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }); + }); + + it("keeps legacy content-only drafts and rejects invalid selector state", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + }), + ).toEqual({ + "environment-1:thread-1": DRAFT, + }); + + expect(() => + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { + ...DRAFT, + runtimeMode: "sometimes-safe", + }, + }, + }), + ).toThrow(); + }); + + it("clears sent content without clearing the selected model or workspace", () => { + const draftKey = "environment-1:thread-1"; + const draft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }; + + expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({ + [draftKey]: { + ...draft, + text: "", + attachments: [], + }, + }); + }); + + it("reads the latest selector state synchronously for send", () => { + const draftKey = "environment-1:thread-1"; + const selectedDraft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }; + appAtomRegistry.set(composerDraftsAtom, { [draftKey]: selectedDraft }); + + expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); + }); + it("removes only drafts owned by the selected environment", () => { const environmentId = EnvironmentId.make("environment-cloud"); const retainedEnvironmentId = EnvironmentId.make("environment-local"); @@ -17,12 +139,15 @@ describe("mobile composer drafts", () => { removeComposerDraftsForEnvironment( { [`${environmentId}:thread-cloud`]: DRAFT, + [`new-task:${environmentId}:project-cloud`]: DRAFT, [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }, environmentId, ), ).toEqual({ [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }); }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index d0329ad2598..9e2c1566190 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,9 +1,18 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + ModelSelection as ModelSelectionSchema, + ProviderInteractionMode as ProviderInteractionModeSchema, + RuntimeMode as RuntimeModeSchema, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; @@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } -interface PersistedComposerDrafts { - readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION; - readonly drafts: Record; +export interface ComposerDraftWorkspaceSelection { + readonly mode: "local" | "worktree"; + readonly branch: string | null; + readonly worktreePath: string | null; } +export type ComposerDraftSettingsUpdate = Pick< + ComposerDraft, + "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" +>; + +const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ + mode: Schema.Literals(["local", "worktree"]), + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), +}); + +const ComposerDraftSchema = Schema.Struct({ + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelectionSchema), + runtimeMode: Schema.optional(RuntimeModeSchema), + interactionMode: Schema.optional(ProviderInteractionModeSchema), + workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), +}); + +const PersistedComposerDraftsSchema = Schema.Struct({ + schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), + drafts: Schema.Record(Schema.String, ComposerDraftSchema), +}); + +const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( + PersistedComposerDraftsSchema, +); + const EMPTY_DRAFT: ComposerDraft = { text: "", attachments: [], @@ -54,13 +97,32 @@ function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { return EMPTY_DRAFT; } return { + ...draft, text: draft.text, attachments: draft.attachments, }; } +export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { + return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]); +} + function isEmptyDraft(draft: ComposerDraft): boolean { - return draft.text.length === 0 && draft.attachments.length === 0; + return ( + draft.text.length === 0 && + draft.attachments.length === 0 && + draft.modelSelection === undefined && + draft.runtimeMode === undefined && + draft.interactionMode === undefined && + draft.workspaceSelection === undefined + ); +} + +export function decodePersistedComposerDrafts(value: unknown): Record { + const parsed = decodePersistedComposerDraftsDocument(value); + return Object.fromEntries( + Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), + ); } async function getComposerDraftsFile() { @@ -80,20 +142,7 @@ async function loadPersistedComposerDrafts(): Promise; - if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) { - return {}; - } - return Object.fromEntries( - Object.entries(parsed.drafts).filter((entry): entry is [string, ComposerDraft] => { - const draft = entry[1]; - return ( - typeof draft?.text === "string" && - Array.isArray(draft.attachments) && - !isEmptyDraft(draft) - ); - }), - ); + return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); } catch (cause) { console.warn( "[composer-drafts] ignored persisted draft failure", @@ -116,10 +165,10 @@ async function writePersistedComposerDrafts(drafts: Record !isEmptyDraft(draft)), ); - const document: PersistedComposerDrafts = { + const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, - }; + } as const; const encoded = JSON.stringify(document); operation = "write"; if (!file.exists) { @@ -282,6 +331,55 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) }); } +export function updateComposerDraftSettings( + draftKey: string, + settings: Partial, +): void { + updateComposerDrafts((current) => { + const draft = { + ...normalizeDraft(current[draftKey]), + ...settings, + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; + }); +} + +export function clearComposerDraftContentState( + current: Record, + draftKey: string, +): Record { + const existing = current[draftKey]; + if (!existing) { + return current; + } + const draft = { + ...existing, + text: "", + attachments: [], + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; +} + +export function clearComposerDraftContent(draftKey: string): void { + updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); +} + export function clearComposerDraft(draftKey: string): void { updateComposerDrafts((current) => { if (!current[draftKey]) { @@ -298,8 +396,12 @@ export function removeComposerDraftsForEnvironment( environmentId: EnvironmentId, ): Record { const environmentPrefix = `${environmentId}:`; + const newTaskPrefix = `new-task:${environmentId}:`; return Object.fromEntries( - Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)), + Object.entries(drafts).filter( + ([draftKey]) => + !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix), + ), ); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 60970b32a4d..0b8cba16e16 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,7 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; -import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; @@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry"; import { appendComposerDraftAttachments, appendComposerDraftText, - clearComposerDraft, + clearComposerDraftContent, composerDraftsAtom, ensureComposerDraftsLoaded, + getComposerDraftSnapshot, removeComposerDraftAttachment, setComposerDraftText, + updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; @@ -98,6 +108,10 @@ export function useThreadComposerState() { const draftMessage = selectedDraft?.text ?? ""; const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; + const selectedThread = selectedThreadDetail ?? selectedThreadShell; + const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; + const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; + const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; const selectedThreadSessionActivity = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; @@ -130,7 +144,6 @@ export function useThreadComposerState() { selectedThreadShell, ]); - const selectedThread = selectedThreadDetail ?? selectedThreadShell; const activeThreadBusy = !!selectedThread && (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); @@ -141,9 +154,10 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const draft = composerDrafts[threadKey]; - const text = (draft?.text ?? "").trim(); - const attachments = draft?.attachments ?? []; + const draft = getComposerDraftSnapshot(threadKey); + const thread = selectedThreadDetail ?? selectedThreadShell; + const text = draft.text.trim(); + const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { return; } @@ -157,15 +171,18 @@ export function useThreadComposerState() { commandId: CommandId.make(metadata.commandId), text, attachments, + modelSelection: draft.modelSelection ?? thread.modelSelection, + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + interactionMode: draft.interactionMode ?? thread.interactionMode, createdAt: metadata.createdAt, }); - clearComposerDraft(threadKey); + clearComposerDraftContent(threadKey); } catch (error) { setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); } - }, [composerDrafts, selectedThreadShell]); + }, [selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { @@ -255,12 +272,45 @@ export function useThreadComposerState() { [selectedThreadShell], ); + const onUpdateModelSelection = useCallback( + (value: ModelSelection) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { modelSelection: value }); + }, + [selectedThreadKey], + ); + + const onUpdateRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); + }, + [selectedThreadKey], + ); + + const onUpdateInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { interactionMode: value }); + }, + [selectedThreadKey], + ); + return { selectedThreadFeed, selectedThreadQueueCount, activeWorkStartedAt, draftMessage, draftAttachments, + modelSelection, + runtimeMode, + interactionMode, activeThreadBusy, onChangeDraftMessage, onPickDraftImages, @@ -268,5 +318,8 @@ export function useThreadComposerState() { onNativePasteImages, onRemoveDraftImage, onSendMessage, + onUpdateModelSelection, + onUpdateRuntimeMode, + onUpdateInteractionMode, }; } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 840456e2d1c..e912d6366b4 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { type MessageId } from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { CommandId, type MessageId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry"; import { useThreadShells } from "./entities"; import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; import { + modelSelectionsEqual, resolveThreadOutboxDeliveryAction, - shouldRetryThreadOutboxDelivery, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, threadOutboxRetryDelayMs, type QueuedThreadMessage, + type ThreadOutboxCommandStage, } from "./thread-outbox-model"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; @@ -44,8 +48,21 @@ function findThread( ); } +function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { + return CommandId.make(`${message.commandId}:${setting}`); +} + export function useThreadOutboxDrain(): void { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { + reportFailure: false, + }); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const shellStatuses = useThreadOutboxShellStatuses(); @@ -68,6 +85,98 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { + const settings = resolveQueuedThreadSettings(queuedMessage, thread); + const reportFailure = ( + commandResult: AtomCommandResult, + stage: ThreadOutboxCommandStage, + ): boolean => { + if (!AsyncResult.isFailure(commandResult)) { + return false; + } + const action = resolveThreadOutboxFailureAction({ + stage, + error: Cause.squash(commandResult.cause), + interrupted: Cause.hasInterruptsOnly(commandResult.cause), + }); + const retry = action === "retry"; + console.warn("[thread-outbox] queued message delivery failed", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + stage, + cause: commandResult.cause, + retry, + }); + return retry; + }; + const completeDelivery = async ( + deliveryResult: AtomCommandResult, + ): Promise => { + if (reportFailure(deliveryResult, "start-turn")) { + return false; + } + + try { + await removeThreadOutboxMessage(queuedMessage); + return true; + } catch (error) { + console.warn("[thread-outbox] failed to remove delivered queued message", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + error, + }); + return false; + } + }; + + if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { + const updateResult = await updateThreadMetadata({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "model-selection"), + threadId: queuedMessage.threadId, + modelSelection: settings.modelSelection, + }, + }); + if (AsyncResult.isFailure(updateResult)) { + reportFailure(updateResult, "settings-sync"); + return false; + } + } + + if (settings.runtimeMode !== thread.runtimeMode) { + const runtimeResult = await setThreadRuntimeMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "runtime-mode"), + threadId: queuedMessage.threadId, + runtimeMode: settings.runtimeMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(runtimeResult)) { + reportFailure(runtimeResult, "settings-sync"); + return false; + } + } + + if (settings.interactionMode !== thread.interactionMode) { + const interactionResult = await setThreadInteractionMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "interaction-mode"), + threadId: queuedMessage.threadId, + interactionMode: settings.interactionMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(interactionResult)) { + reportFailure(interactionResult, "settings-sync"); + return false; + } + } + const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -79,41 +188,15 @@ export function useThreadOutboxDrain(): void { text: queuedMessage.text, attachments: queuedMessage.attachments, }, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, + modelSelection: settings.modelSelection, + runtimeMode: settings.runtimeMode, + interactionMode: settings.interactionMode, createdAt: queuedMessage.createdAt, }, }); - if (AsyncResult.isFailure(deliveryResult)) { - const error = Cause.squash(deliveryResult.cause); - const retry = - Cause.hasInterruptsOnly(deliveryResult.cause) || shouldRetryThreadOutboxDelivery(error); - console.warn("[thread-outbox] queued message delivery failed", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - cause: deliveryResult.cause, - retry, - }); - if (retry) { - return false; - } - } - - try { - await removeThreadOutboxMessage(queuedMessage); - return true; - } catch (error) { - console.warn("[thread-outbox] failed to remove delivered queued message", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - error, - }); - return false; - } + return completeDelivery(deliveryResult); }, - [startTurn], + [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata], ); useEffect(() => { From f5f98cf0ae48a14b524480a0942f9ce37190656c Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:52:13 -0700 Subject: [PATCH 07/28] Stabilize composer provider state while typing (#3507) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ChatComposer.tsx | 9 ++++++-- .../chat/composerProviderState.test.tsx | 22 ++++++++++++------- .../components/chat/composerProviderState.tsx | 12 +++++++--- apps/web/src/modelSelection.ts | 2 -- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3a5e06bce06..11feae3e501 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -78,6 +78,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { + getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -791,18 +792,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [selectedProviderEntry], ); + const composerPromptInjectionState = useMemo( + () => getComposerPromptInjectionState(prompt), + [prompt], + ); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, models: selectedProviderModels, - prompt, + promptInjectionState: composerPromptInjectionState, modelOptions: composerModelOptions?.[selectedInstanceId], }), [ composerModelOptions, - prompt, + composerPromptInjectionState, selectedInstanceId, selectedModel, selectedProvider, diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 07eec55de2c..067e71ef1bf 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -6,6 +6,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { + getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -61,6 +62,13 @@ const ULTRATHINK_FRAME_CLASSES = { } as const; describe("getComposerProviderState", () => { + it("derives a stable prompt injection state for ordinary prompt edits", () => { + expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); + expect(getComposerPromptInjectionState("Ultrathink:\nInvestigate this failure")).toBe( + "ultrathink", + ); + }); + it("returns descriptor defaults when no selections are provided", () => { const state = getComposerProviderState({ provider: PROVIDER, @@ -71,7 +79,6 @@ describe("getComposerProviderState", () => { { id: "high", label: "High", isDefault: true }, ]), ]), - prompt: "", modelOptions: undefined, }); @@ -93,7 +100,6 @@ describe("getComposerProviderState", () => { ]), booleanDescriptor("fastMode"), ]), - prompt: "", modelOptions: selections(["effort", "low"], ["fastMode", true]), }); @@ -112,7 +118,6 @@ describe("getComposerProviderState", () => { selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), booleanDescriptor("fastMode"), ]), - prompt: "", modelOptions: selections(["effort", "high"], ["fastMode", false]), }); @@ -126,7 +131,6 @@ describe("getComposerProviderState", () => { provider: PROVIDER, model: MODEL, models: modelWith([booleanDescriptor("thinking")]), - prompt: "", modelOptions: selections(["effort", "max"], ["thinking", false]), }); @@ -152,7 +156,6 @@ describe("getComposerProviderState", () => { { id: "plan", label: "Plan" }, ]), ]), - prompt: "", modelOptions: selections(["agent", "plan"]), }); @@ -167,7 +170,6 @@ describe("getComposerProviderState", () => { provider: PROVIDER, model: MODEL, models: modelWith([]), - prompt: "", modelOptions: selections(["anything", "value"]), }); @@ -193,7 +195,9 @@ describe("getComposerProviderState", () => { ["ultrathink"], ), ]), - prompt: "Ultrathink:\nInvestigate this failure", + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), modelOptions: selections(["effort", "medium"]), }); @@ -212,7 +216,9 @@ describe("getComposerProviderState", () => { models: modelWith([ selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), ]), - prompt: "Ultrathink:\nInvestigate this failure", + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), modelOptions: undefined, }); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index b5cc790538d..1349e2509b7 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -21,10 +21,12 @@ export type ComposerProviderStateInput = { provider: ProviderDriverKind; model: string; models: ReadonlyArray; - prompt: string; + promptInjectionState?: ComposerPromptInjectionState; modelOptions: ReadonlyArray | null | undefined; }; +export type ComposerPromptInjectionState = "none" | "ultrathink"; + export type ComposerProviderState = { provider: ProviderDriverKind; promptEffort: string | null; @@ -46,8 +48,12 @@ type TraitsRenderInput = { onPromptChange: (prompt: string) => void; }; +export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { + return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; +} + export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - const { provider, model, models, prompt, modelOptions } = input; + const { provider, model, models, modelOptions, promptInjectionState = "none" } = input; const caps = getProviderModelCapabilities(models, model, provider); const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); const primarySelectDescriptor = descriptors.find( @@ -58,7 +64,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com const promptEffort = typeof primaryValue === "string" ? primaryValue : null; const ultrathinkActive = (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && - isClaudeUltrathinkPrompt(prompt); + promptInjectionState === "ultrathink"; return { provider, diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 0fcf680b732..7ede9665ac9 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -303,7 +303,6 @@ export function resolveAppModelSelectionState( provider, model, models: entry.models, - prompt: "", modelOptions: selectedEntry ? selection.options : undefined, }); @@ -321,7 +320,6 @@ export function resolveAppModelSelectionState( provider, model, models: getProviderModels(providers, provider), - prompt: "", modelOptions: keptSelectedProvider ? selection.options : undefined, }); From fb1034546f989c11568ef1c4b5801e6a5372a07d Mon Sep 17 00:00:00 2001 From: Abdul Azeez Date: Tue, 23 Jun 2026 01:44:24 +0530 Subject: [PATCH 08/28] feat: add persistent word-wrap setting for chat code blocks and tables (#3480) Co-authored-by: Julius Marminge --- .../settings/DesktopClientSettings.test.ts | 2 +- apps/web/src/clientPersistenceStorage.test.ts | 21 +++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 6 +++-- apps/web/src/components/DiffPanel.tsx | 17 ++++++------ .../src/components/files/FilePreviewPanel.tsx | 16 ++++++++--- .../components/settings/SettingsPanels.tsx | 24 ++++++++--------- packages/contracts/src/settings.test.ts | 27 +++++++++++++++++-- packages/contracts/src/settings.ts | 4 +-- 8 files changed, 85 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 3584d6a21e4..ea7ec6e1512 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,7 +18,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, - diffWordWrap: true, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", @@ -29,6 +28,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", + wordWrap: true, }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index ec335892bea..8f849a6e7b3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -69,4 +69,25 @@ describe("clientPersistenceStorage", () => { }), ); }); + + it("defaults word wrap on and discards obsolete wrapping preferences", async () => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + chatWordWrap: false, + diffWordWrap: false, + }), + ); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + const settings = readBrowserClientSettings(); + + expect(settings).toEqual( + expect.objectContaining({ + wordWrap: true, + }), + ); + expect(settings).not.toHaveProperty("chatWordWrap"); + expect(settings).not.toHaveProperty("diffWordWrap"); + }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 711a545d90a..47604d23ca2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -54,6 +54,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; +import { useClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, @@ -295,7 +296,7 @@ function getHighlighterPromise(language: string): Promise { function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(useClientSettings((settings) => settings.wordWrap)); const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; @@ -525,10 +526,11 @@ function MarkdownCodeBlock({ children: ReactNode; }) { const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(false); + const [wrapped, setWrapped] = useState(useClientSettings((settings) => settings.wordWrap)); const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; + const handleCopy = useCallback(() => { if (typeof navigator === "undefined" || navigator.clipboard == null) { return; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e41ba9c2440..cbcd36ce05e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -186,7 +186,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); - const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); + const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ @@ -194,6 +194,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); const codeViewRef = useRef(null); + const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), @@ -695,14 +696,12 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff { - setDiffWordWrap(Boolean(pressed)); + setWordWrap(Boolean(pressed)); }} /> } @@ -710,7 +709,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff - {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"} + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} @@ -844,7 +843,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff options={{ diffStyle: diffRenderMode === "split" ? "split" : "unified", lineDiffType: "none", - overflow: diffWordWrap ? "wrap" : "scroll", + overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme as DiffThemeType, unsafeCSS: DIFF_PANEL_UNSAFE_CSS, @@ -860,7 +859,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
 void;
 }
@@ -296,6 +298,7 @@ function EditableFileSurface({
   contents,
   resolvedTheme,
   revealRequestId,
+  wordWrap,
   onPostRender,
   onPendingChange,
 }: EditableFileSurfaceProps) {
@@ -516,7 +519,7 @@ function EditableFileSurface({
               onGutterUtilityClick: setSelectedRange,
               onLineSelectionChange: setSelectedRange,
               onLineSelectionEnd: handleLineSelectionEnd,
-              overflow: "scroll",
+              overflow: wordWrap ? "wrap" : "scroll",
               theme: resolveDiffThemeName(resolvedTheme),
               themeType: resolvedTheme,
               unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
@@ -557,7 +560,12 @@ function RenderedMarkdownSurface({
   onPendingChange,
 }: Omit<
   EditableFileSurfaceProps,
-  "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender"
+  | "resolvedTheme"
+  | "composerDraftTarget"
+  | "revealLine"
+  | "revealRequestId"
+  | "wordWrap"
+  | "onPostRender"
 > & {
   threadRef: ScopedThreadRef;
 }) {
@@ -613,6 +621,7 @@ export default function FilePreviewPanel({
   onPendingChange,
 }: FilePreviewPanelProps) {
   const { resolvedTheme } = useTheme();
+  const wordWrap = useClientSettings((settings) => settings.wordWrap);
   const primaryEnvironmentId = usePrimaryEnvironmentId();
   const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
   const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
@@ -844,7 +853,7 @@ export default function FilePreviewPanel({
                   }}
                   options={{
                     disableFileHeader: true,
-                    overflow: "scroll",
+                    overflow: wordWrap ? "wrap" : "scroll",
                     theme: resolveDiffThemeName(resolvedTheme),
                     themeType: resolvedTheme,
                     unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
@@ -863,6 +872,7 @@ export default function FilePreviewPanel({
                 contents={file.data.contents}
                 resolvedTheme={resolvedTheme}
                 revealRequestId={revealRequestId}
+                wordWrap={wordWrap}
                 onPostRender={onFilePostRender}
                 onPendingChange={onPendingChange}
               />
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 994cbb08f23..40017d56314 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -391,9 +391,7 @@ export function useSettingsRestore(onRestored?: () => void) {
       ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
         ? ["Visible threads"]
         : []),
-      ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
-        ? ["Diff line wrapping"]
-        : []),
+      ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
       ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
         ? ["Diff whitespace changes"]
         : []),
@@ -434,11 +432,11 @@ export function useSettingsRestore(onRestored?: () => void) {
       settings.defaultThreadEnvMode,
       settings.newWorktreesStartFromOrigin,
       settings.diffIgnoreWhitespace,
-      settings.diffWordWrap,
       settings.automaticGitFetchInterval,
       settings.enableAssistantStreaming,
       settings.sidebarThreadPreviewCount,
       settings.timestampFormat,
+      settings.wordWrap,
       theme,
     ],
   );
@@ -456,7 +454,7 @@ export function useSettingsRestore(onRestored?: () => void) {
     setTheme("system");
     updateSettings({
       timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
-      diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
+      wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
       diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
       sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
       autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
@@ -594,15 +592,15 @@ export function GeneralSettingsPanel() {
         />
 
         
                   updateSettings({
-                    diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap,
+                    wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
                   })
                 }
               />
@@ -610,9 +608,9 @@ export function GeneralSettingsPanel() {
           }
           control={
              updateSettings({ diffWordWrap: Boolean(checked) })}
-              aria-label="Wrap diff lines by default"
+              checked={settings.wordWrap}
+              onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })}
+              aria-label="Wrap code, tables, diffs, and file previews by default"
             />
           }
         />
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index aba97cbe205..ac2d47ca336 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -2,12 +2,35 @@ import { describe, expect, it } from "vite-plus/test";
 import * as Schema from "effect/Schema";
 
 import { ProviderInstanceId } from "./providerInstance.ts";
-import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts";
-
+import {
+  ClientSettingsSchema,
+  DEFAULT_SERVER_SETTINGS,
+  ServerSettings,
+  ServerSettingsPatch,
+} from "./settings.ts";
+
+const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
 const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
 const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
 const encodeServerSettings = Schema.encodeSync(ServerSettings);
 
+describe("ClientSettings word wrap", () => {
+  it("defaults word wrap on", () => {
+    expect(decodeClientSettings({}).wordWrap).toBe(true);
+  });
+
+  it("ignores obsolete wrapping preferences", () => {
+    const decoded = decodeClientSettings({
+      chatWordWrap: false,
+      diffWordWrap: false,
+    });
+
+    expect(decoded.wordWrap).toBe(true);
+    expect(decoded).not.toHaveProperty("chatWordWrap");
+    expect(decoded).not.toHaveProperty("diffWordWrap");
+  });
+});
+
 describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
   it("defaults to an empty record so legacy configs without the key still decode", () => {
     expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 7ba267b1e72..6ccd65533dd 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -47,7 +47,6 @@ export const ClientSettingsSchema = Schema.Struct({
     Schema.withDecodingDefault(Effect.succeed([])),
   ),
   diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
-  diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
   // Model favorites. Historically keyed by provider kind, now
   // widened to `ProviderInstanceId` so users can favorite a specific model
   // on a custom provider instance (e.g. "Codex Personal · gpt-5") without
@@ -92,6 +91,7 @@ export const ClientSettingsSchema = Schema.Struct({
   timestampFormat: TimestampFormat.pipe(
     Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
   ),
+  wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
 });
 export type ClientSettings = typeof ClientSettingsSchema.Type;
 
@@ -538,7 +538,6 @@ export const ClientSettingsPatch = Schema.Struct({
   confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
   confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
   diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
-  diffWordWrap: Schema.optionalKey(Schema.Boolean),
   favorites: Schema.optionalKey(
     Schema.Array(
       Schema.Struct({
@@ -568,5 +567,6 @@ export const ClientSettingsPatch = Schema.Struct({
   sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
   sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
   timestampFormat: Schema.optionalKey(TimestampFormat),
+  wordWrap: Schema.optionalKey(Schema.Boolean),
 });
 export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;

From b2d17b7108766a989374d670ff12de39b6c4d033 Mon Sep 17 00:00:00 2001
From: Julius Marminge 
Date: Mon, 22 Jun 2026 14:00:30 -0700
Subject: [PATCH 09/28] Add main sidebar toggle (#3497)

Co-authored-by: Julius Marminge 
Co-authored-by: codex 
---
 apps/server/src/keybindings.test.ts           |   1 +
 apps/web/src/components/AppSidebarLayout.tsx  |  57 +++++++++-
 apps/web/src/components/ChatView.tsx          |   4 +-
 .../src/components/NoActiveThreadState.tsx    |   7 +-
 apps/web/src/components/Sidebar.tsx           | 106 +++++++++---------
 apps/web/src/components/chat/ChatHeader.tsx   |   2 -
 apps/web/src/components/ui/sidebar.test.tsx   |  33 ++++++
 apps/web/src/components/ui/sidebar.tsx        |  23 +++-
 apps/web/src/components/ui/sidebarState.ts    |   9 ++
 apps/web/src/index.css                        |  33 ++++++
 apps/web/src/keybindings.test.ts              |   5 +
 apps/web/src/routes/_chat.index.tsx           |  12 +-
 apps/web/src/routes/settings.tsx              |  19 +++-
 apps/web/src/workspaceTitlebar.ts             |   2 +
 packages/contracts/src/keybindings.test.ts    |   6 +
 packages/contracts/src/keybindings.ts         |   1 +
 packages/shared/src/keybindings.ts            |   1 +
 17 files changed, 247 insertions(+), 74 deletions(-)
 create mode 100644 apps/web/src/components/ui/sidebarState.ts
 create mode 100644 apps/web/src/workspaceTitlebar.ts

diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts
index ba95422735c..a51ad20afbe 100644
--- a/apps/server/src/keybindings.test.ts
+++ b/apps/server/src/keybindings.test.ts
@@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
       assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
       assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
       assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
+      assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
       assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
       assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
       assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index cbfce7b43d0..0f1a8f9d429 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -1,14 +1,64 @@
-import { useEffect, type ReactNode } from "react";
+import { useAtomValue } from "@effect/atom-react";
+import { useEffect, type CSSProperties, type ReactNode } from "react";
 import { useNavigate } from "@tanstack/react-router";
 
+import { isElectron } from "../env";
+import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
+import { isMacPlatform } from "../lib/utils";
+import { primaryServerKeybindingsAtom } from "../state/server";
 import ThreadSidebar from "./Sidebar";
-import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
+import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
 
 const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
 const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
 const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
+const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";
+
+function SidebarControl() {
+  const keybindings = useAtomValue(primaryServerKeybindingsAtom);
+  const { toggleSidebar } = useSidebar();
+  const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle");
+
+  useEffect(() => {
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.defaultPrevented) return;
+      if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return;
+
+      event.preventDefault();
+      event.stopPropagation();
+      toggleSidebar();
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+    return () => window.removeEventListener("keydown", onKeyDown);
+  }, [keybindings, toggleSidebar]);
+
+  return (
+    
+ + + } + /> + + Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + + +
+ ); +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const macosWindowControlsStyle = + isElectron && isMacPlatform(navigator.platform) + ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) + : undefined; useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; @@ -28,7 +78,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + {children} + ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf5bb9de5e9..44429614b44 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -128,6 +128,7 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -4677,7 +4678,7 @@ function ChatViewContent(props: ChatViewProps) {
{!rightPanelOpen ? panelLayoutControls : null} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index c874ee58a98..68a5855c1a2 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,7 +1,8 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; -import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; +import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export function NoActiveThreadState() { return ( @@ -9,8 +10,9 @@ export function NoActiveThreadState() {
{isElectron ? ( @@ -19,7 +21,6 @@ export function NoActiveThreadState() { ) : (
- No active thread diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f3ed88bd3b9..ce925618caa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -70,7 +70,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; +import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; @@ -187,12 +187,12 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - resolveSidebarStageBadgeLabel, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -2452,22 +2452,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar ); }); -function T3Wordmark() { - return ( - - - - ); -} - type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" @@ -2664,48 +2648,64 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { + return isElectron ? ( + + + + + ) : ( + + + + + ); +}); + +function SidebarBrand() { + const stageLabel = useSidebarStageLabel(); + + return ( + + + + Code + + + {stageLabel} + + + ); +} + +function useSidebarStageLabel() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - const stageBadgeLabel = resolveSidebarStageBadgeLabel({ + + return resolveSidebarStageBadgeLabel({ primaryServerVersion, fallbackStageLabel: APP_STAGE_LABEL, }); - const wordmark = ( -
- - - - - - Code - - - {stageBadgeLabel} - - - } - /> - - Version {APP_VERSION} - - -
- ); +} - return isElectron ? ( - - {wordmark} - - ) : ( - {wordmark} +function T3Wordmark() { + return ( + + + ); -}); +} const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index efc160b0bd1..ef3ec863d0b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -14,7 +14,6 @@ import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { cn } from "~/lib/utils"; @@ -80,7 +79,6 @@ export const ChatHeader = memo(function ChatHeader({ return (
- { + it("uses mobile sheet visibility for the shared responsive state", () => { + expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe( + "collapsed", + ); + expect(resolveSidebarState({ isMobile: true, open: false, openMobile: true })).toBe("expanded"); + expect(resolveSidebarState({ isMobile: false, open: true, openMobile: false })).toBe( + "expanded", + ); + }); + + it("exposes collapsed state for shared titlebar inset styling", () => { + const html = renderToStaticMarkup( + +
+ , + ); + + expect(html).toContain('data-sidebar-state="collapsed"'); + }); + + it("keeps the sidebar trigger interactive inside Electron drag regions", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("[-webkit-app-region:no-drag]"); + expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!"); + }); + it("uses a pointer cursor for menu buttons by default", () => { const html = renderSidebarButton(); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 718fc22b3fe..097568f77f0 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -19,6 +19,7 @@ import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; import * as Schema from "effect/Schema"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; @@ -29,7 +30,7 @@ const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; type SidebarContextProps = { - state: "expanded" | "collapsed"; + state: ResponsiveSidebarState; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; @@ -85,6 +86,11 @@ function useSidebar() { return context; } +function useSidebarVisibility() { + const { isMobile, open, openMobile } = useSidebar(); + return isMobile ? openMobile : open; +} + function SidebarProvider({ defaultOpen = true, open: openProp, @@ -132,7 +138,7 @@ function SidebarProvider({ // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; + const state = resolveSidebarState({ isMobile, open, openMobile }); const contextValue = React.useMemo( () => ({ @@ -154,6 +160,7 @@ function SidebarProvider({ "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className, )} + data-sidebar-state={state} data-slot="sidebar-wrapper" style={ { @@ -310,13 +317,18 @@ function Sidebar({ } function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { - const { toggleSidebar, openMobile } = useSidebar(); + const { toggleSidebar } = useSidebar(); + const isOpen = useSidebarVisibility(); return ( ); @@ -1004,4 +1016,5 @@ export { SidebarSeparator, SidebarTrigger, useSidebar, + useSidebarVisibility, }; diff --git a/apps/web/src/components/ui/sidebarState.ts b/apps/web/src/components/ui/sidebarState.ts new file mode 100644 index 00000000000..fcdfed10521 --- /dev/null +++ b/apps/web/src/components/ui/sidebarState.ts @@ -0,0 +1,9 @@ +export type ResponsiveSidebarState = "expanded" | "collapsed"; + +export function resolveSidebarState(input: { + isMobile: boolean; + open: boolean; + openMobile: boolean; +}): ResponsiveSidebarState { + return (input.isMobile ? input.openMobile : input.open) ? "expanded" : "collapsed"; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 09ef006a638..148e8783b4a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -7,13 +7,24 @@ :root { --workspace-topbar-height: 52px; --workspace-controls-top: 0px; + --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem); --workspace-native-controls-inset: 0px; + --workspace-titlebar-control-size: 1.75rem; + --workspace-titlebar-control-gap: 0.75rem; +} + +[data-slot="sidebar-wrapper"] { + --workspace-titlebar-content-left: calc( + var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + + var(--workspace-titlebar-control-gap) + ); } .wco { --workspace-topbar-height: env(titlebar-area-height, 52px); --workspace-controls-top: env(titlebar-area-y, 0px); + --workspace-controls-left: calc(env(titlebar-area-x, 0px) + 0.75rem); --workspace-controls-right: calc( 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem ); @@ -90,6 +101,28 @@ } @layer components { + .sidebar-brand { + display: none; + } + + .sidebar-brand-stage { + display: none; + } + + @media (min-width: 48rem) { + @container sidebar-header (min-width: 13.5rem) { + .sidebar-brand { + display: flex; + } + } + + @container sidebar-header (min-width: 15.75rem) { + .sidebar-brand-stage { + display: inline-flex; + } + } + } + .workspace-topbar { display: flex; height: var(--workspace-topbar-height); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d4fc945cc04..c0d326edd55 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -85,6 +85,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { } const DEFAULT_BINDINGS = compile([ + { shortcut: modShortcut("b"), command: "sidebar.toggle" }, { shortcut: modShortcut("j"), command: "terminal.toggle" }, { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, { @@ -312,6 +313,10 @@ describe("shortcutLabelForCommand", () => { }); it("returns effective labels for non-terminal commands", () => { + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), + "⌘B", + ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 7be0f50414e..94d49d00afe 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -4,10 +4,12 @@ import { LinkIcon, PlusIcon } from "lucide-react"; import { NoActiveThreadState } from "../components/NoActiveThreadState"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { SidebarInset } from "../components/ui/sidebar"; import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -30,9 +32,13 @@ function HostedStaticOnboardingState() { return (
-
+
- {APP_DISPLAY_NAME} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index fa5c6a4201d..40507321066 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -11,8 +11,10 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -64,9 +66,13 @@ function SettingsContentLayout() {
{!isElectron && ( -
+
- Settings {showRestoreDefaults ? (
@@ -78,7 +84,12 @@ function SettingsContentLayout() { )} {isElectron && ( -
+
Settings diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts new file mode 100644 index 00000000000..b481221e63a --- /dev/null +++ b/apps/web/src/workspaceTitlebar.ts @@ -0,0 +1,2 @@ +export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS = + "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 19c98c390c3..33ecd38039f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsed.command, "terminal.toggle"); + const parsedSidebarToggle = yield* decode(KeybindingRule, { + key: "mod+b", + command: "sidebar.toggle", + }); + assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); + const parsedRightPanelToggle = yield* decode(KeybindingRule, { key: "mod+alt+b", command: "rightPanel.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 4a5ffd0c3dd..c7cff9943cd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -48,6 +48,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; const STATIC_KEYBINDING_COMMANDS = [ + "sidebar.toggle", "terminal.toggle", "terminal.split", "terminal.splitVertical", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 4abe53f2053..b6bdd7b4783 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -19,6 +19,7 @@ type WhenToken = | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+b", command: "sidebar.toggle" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, From 8cc9a6fa6b0e3b6d6528793e942982d7de040833 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 15:58:54 -0700 Subject: [PATCH 10/28] [codex] Restore T3 Connect account controls (#3492) Co-authored-by: Julius Marminge --- apps/web/src/cloud/linkEnvironment.test.ts | 33 ++++ apps/web/src/cloud/linkEnvironmentAtoms.ts | 9 + ...MobileClientsUserProfilePage.logic.test.ts | 65 +++++++ .../MobileClientsUserProfilePage.logic.ts | 39 ++++ .../clerk/MobileClientsUserProfilePage.tsx | 166 ++++++++++++++++++ .../clerk/T3ConnectSidebarSignIn.tsx | 13 +- .../settings/ConnectionsSettings.tsx | 127 ++++++++++---- 7 files changed, 417 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts create mode 100644 apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts create mode 100644 apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index f823016ddf0..7e6f2365e50 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -33,6 +33,7 @@ import { readPrimaryCloudLinkState, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "./linkEnvironment"; const TARGET: CloudLinkTarget = { @@ -252,6 +253,38 @@ describe("web cloud link environment client", () => { }), ); + it.effect("updates agent activity publishing for the explicit primary target", () => + Effect.gen(function* () { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: true, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const state = yield* withServices( + updatePrimaryCloudPreferences({ + target: TARGET, + publishAgentActivity: true, + }), + ); + + expect(state.publishAgentActivity).toBe(true); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/preferences", + ); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + publishAgentActivity: true, + }); + }), + ); + it.effect("links an available primary environment without invoking installation", () => Effect.gen(function* () { const fetchMock = vi diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts index ea924cae234..4cb62271a48 100644 --- a/apps/web/src/cloud/linkEnvironmentAtoms.ts +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -8,6 +8,7 @@ import { linkPrimaryEnvironmentToCloud, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "./linkEnvironment"; const cloudLinkScheduler = createAtomCommandScheduler(); @@ -31,3 +32,11 @@ export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRunti execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => unlinkPrimaryEnvironmentFromCloud(input), }); + +export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connectionAtomRuntime, { + label: "web:cloud:update-primary-environment-preferences", + scheduler: cloudLinkScheduler, + concurrency: cloudLinkConcurrency, + execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) => + updatePrimaryCloudPreferences(input), +}); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts new file mode 100644 index 00000000000..fcc660e8305 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -0,0 +1,65 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; + +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +function device(overrides: Partial = {}): RelayClientDeviceRecord { + return { + deviceId: "device-1", + label: "Julius’s iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.2.3", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: false, + notifyOnCompletion: true, + notifyOnFailure: false, + }, + liveActivities: { enabled: true }, + updatedAt: "2026-06-21T12:00:00.000Z", + ...overrides, + }; +} + +describe("mobile client presentation", () => { + it("describes the client platform and enabled notification events", () => { + const client = device(); + + expect(mobileClientPlatformLabel(client)).toBe("iOS 18 · T3 Code 1.2.3"); + expect(mobileClientNotificationDetail(client)).toBe( + "Alerts enabled for approvals, completions.", + ); + }); + + it("distinguishes disabled notifications from an empty event selection", () => { + expect( + mobileClientNotificationDetail( + device({ notifications: { ...device().notifications, enabled: false } }), + ), + ).toBe("Push notifications are disabled on this device."); + expect( + mobileClientNotificationDetail( + device({ + notifications: { + enabled: true, + notifyOnApproval: false, + notifyOnInput: false, + notifyOnCompletion: false, + notifyOnFailure: false, + }, + }), + ), + ).toBe("Push notifications are enabled, but no alert types are selected."); + }); + + it("handles missing app versions and invalid update timestamps", () => { + expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18"); + expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable"); + }); +}); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts new file mode 100644 index 00000000000..5ca9595bef4 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -0,0 +1,39 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; + +const mobileClientUpdatedAtFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +const NOTIFICATION_PREFERENCES = [ + ["notifyOnApproval", "approvals"], + ["notifyOnInput", "input requests"], + ["notifyOnCompletion", "completions"], + ["notifyOnFailure", "failures"], +] as const satisfies ReadonlyArray< + readonly [keyof RelayClientDeviceRecord["notifications"], string] +>; + +export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { + return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; +} + +export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { + if (!device.notifications.enabled) { + return "Push notifications are disabled on this device."; + } + + const enabledPreferences = NOTIFICATION_PREFERENCES.flatMap(([preference, label]) => + device.notifications[preference] ? [label] : [], + ); + return enabledPreferences.length > 0 + ? `Alerts enabled for ${enabledPreferences.join(", ")}.` + : "Push notifications are enabled, but no alert types are selected."; +} + +export function mobileClientUpdatedAtLabel(updatedAt: string): string { + const date = new Date(updatedAt); + return Number.isNaN(date.getTime()) + ? "Update time unavailable" + : `Updated ${mobileClientUpdatedAtFormatter.format(date)}`; +} diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx new file mode 100644 index 00000000000..26af10ba5b8 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -0,0 +1,166 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; + +import { useManagedRelayDevices } from "../../cloud/managedRelayState"; +import { cn } from "../../lib/utils"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Skeleton } from "../ui/skeleton"; +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; + +function MobileClientStatusBadge({ + enabled, + label, +}: { + readonly enabled: boolean; + readonly label: string; +}) { + return ( + + {label}: {enabled ? "On" : "Off"} + + ); +} + +function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { + return ( +
  • +
    +
    + +
    +
    +
    +
    +

    {device.label}

    +

    {mobileClientPlatformLabel(device)}

    +
    +

    + {mobileClientUpdatedAtLabel(device.updatedAt)} +

    +
    +
    + + +
    +

    + {mobileClientNotificationDetail(device)} +

    +
    +
    +
  • + ); +} + +function MobileClientsSkeleton() { + return ( +
    + {MOBILE_CLIENT_SKELETON_ROWS.map((row) => ( +
    +
    + +
    + + +
    + + +
    +
    +
    +
    + ))} +
    + ); +} + +function EmptyMobileClients() { + return ( + + + + + + No mobile clients + + Sign in to T3 Code on your iPhone to register it for push notifications and Live + Activities. + + + + ); +} + +export function MobileClientsUserProfilePage() { + const devicesState = useManagedRelayDevices(); + const devices = devicesState.data ?? []; + const isInitialLoad = + !devicesState.accountId || (devicesState.data === null && !devicesState.error); + const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; + + return ( +
    +
    +
    +

    Mobile clients

    +

    + Devices registered to receive T3 Connect activity from your environments. +

    +
    + +
    + +
    + {devicesState.error ? ( +
    +
    +

    + Could not load mobile clients +

    +

    {devicesState.error}

    +
    + +
    + ) : null} + + {isInitialLoad ? ( + + ) : hasErrorWithoutData ? null : devices.length > 0 ? ( +
      + {devices.map((device) => ( + + ))} +
    + ) : ( + + )} +
    +
    + ); +} diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index d3f906ef414..45477ee1b7e 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,8 +1,9 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon } from "lucide-react"; +import { LogInIcon, SmartphoneIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; export function T3ConnectSidebarSignIn() { @@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() { userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent", }, }} - /> + > + } + url="mobile-clients" + > + + + ); } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e693c7b15b0..5012986ff45 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -118,6 +118,7 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, + updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, } from "~/cloud/linkEnvironmentAtoms"; import { authEnvironment } from "~/state/auth"; import { environmentCatalog } from "~/connection/catalog"; @@ -1457,7 +1458,7 @@ function SavedBackendListRow({ : null; const metadataBits = [ sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, - environment.relayManaged ? "T3 Cloud" : null, + environment.relayManaged ? "T3 Connect" : null, ].filter((value): value is string => value !== null); return ( @@ -1575,7 +1576,7 @@ function CloudLinkSwitch({ }) { const control = ( (null); const [isUpdating, setIsUpdating] = useState(false); + const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); const reportUpdateFailure = (cause: unknown) => { - const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; + const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; const traceId = findErrorTraceId(cause); - console.error("[t3-cloud] Could not update T3 Cloud", { message, traceId, cause }); + console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); toastManager.add({ type: "error", - title: "Could not update T3 Cloud", + title: "Could not update T3 Connect", description: message, data: traceId ? { @@ -1643,9 +1649,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b return; } if (enabled && !tokenResult.value) { - reportUpdateFailure( - new Error("Sign in from T3 Cloud settings before linking this environment."), - ); + reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment.")); setIsUpdating(false); return; } @@ -1680,38 +1684,95 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b toastManager.add({ type: "success", - title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", + title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", description: enabled - ? "This environment is available through T3 Cloud." - : "This environment is no longer available through T3 Cloud.", + ? "This environment is available through T3 Connect." + : "This environment is no longer available through T3 Connect.", }); setIsUpdating(false); }; + + const updatePublishAgentActivity = async (enabled: boolean) => { + const target = primaryCloudLinkState.target; + if (!target) { + reportUpdateFailure(new Error("Local environment is not ready yet.")); + return; + } + + setIsUpdatingPreference(true); + setOperationError(null); + const updateResult = await updatePrimaryEnvironmentPreferences({ + target, + publishAgentActivity: enabled, + }); + if (updateResult._tag === "Failure") { + if (!isAtomCommandInterrupted(updateResult)) { + reportUpdateFailure(squashAtomCommandFailure(updateResult)); + } + setIsUpdatingPreference(false); + return; + } + + primaryCloudLinkState.refresh(); + toastManager.add({ + type: "success", + title: enabled ? "Agent activity enabled" : "Agent activity disabled", + description: enabled + ? "This environment can publish agent activity to your mobile clients." + : "This environment will stop publishing agent activity.", + }); + setIsUpdatingPreference(false); + }; const disabledReason = !isSignedIn - ? "Sign in from T3 Cloud settings to manage this environment." + ? "Sign in to T3 Connect to manage this environment." : !canManageRelay - ? "Your session does not have permission to manage T3 Cloud access." + ? "Your session does not have permission to manage T3 Connect access." : null; const linked = primaryCloudLinkState.data?.linked ?? false; return ( - void updateLink(enabled)} + <> + void updateLink(enabled)} + /> + } + /> + {linked ? ( + void updatePublishAgentActivity(enabled)} + /> + } /> - } - /> + ) : null} + ); } @@ -1729,7 +1790,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable No saved remote environments {cloudEnabled - ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." + ? "Click “Add environment” to pair another environment, or connect one from T3 Connect." : "Click “Add environment” to pair another environment."} @@ -1794,7 +1855,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ toastManager.add({ type: "success", title: "Environment connected", - description: `${environment.label} is available through T3 Cloud.`, + description: `${environment.label} is available through T3 Connect.`, }); return; } @@ -1803,9 +1864,9 @@ function ConfiguredCloudRemoteEnvironmentRows({ } const cause = squashAtomCommandFailure(result); const message = - cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment."; + cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; const traceId = findErrorTraceId(cause); - console.error("[t3-cloud] Could not connect environment", { message, traceId, cause }); + console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); toastManager.add({ type: "error", title: "Could not connect environment", From 92e54fb96c5e24c45fc0fa7066a3a4d38294dfc7 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:21:06 +0200 Subject: [PATCH 11/28] [codex] fix: guard DPoP fallback URL construction (#3503) Co-authored-by: Codex Co-authored-by: Julius Marminge --- apps/server/src/auth/dpop.ts | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 87dc0c263e2..f19984eb369 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -3,7 +3,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; -import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Option from "effect/Option"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { ServerAuthDpopReplayKeyCalculationError, @@ -13,22 +14,6 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -function firstHeaderValue(value: string | undefined): string | undefined { - const first = value?.split(",")[0]?.trim(); - return first && first.length > 0 ? first : undefined; -} - -export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string { - try { - return new URL(request.originalUrl).href; - } catch { - const host = firstHeaderValue(request.headers.host) ?? "127.0.0.1"; - const forwardedProto = firstHeaderValue(request.headers["x-forwarded-proto"]); - const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : "http"; - return new URL(request.originalUrl, `${proto}://${host}`).href; - } -} - export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError => @@ -48,11 +33,17 @@ export const verifyRequestDpopProof = (input: { }) => Effect.gen(function* () { const proof = input.request.headers.dpop; + const url = HttpServerRequest.toURL(input.request); + if (Option.isNone(url)) { + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: "Invalid DPoP request URL.", + }); + } const now = yield* DateTime.now; const result = verifyDpopProof({ proof, method: input.request.method, - url: requestAbsoluteUrl(input.request), + url: url.value.href, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), From 6672a1d21060c6c7b927726b6b91fbfe8c8337f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 22 Jun 2026 16:59:56 -0700 Subject: [PATCH 12/28] Bump Clerk packages and refresh lockfile (#3511) --- pnpm-lock.yaml | 148 ++++++++++++++++++++++---------------------- pnpm-workspace.yaml | 14 ++--- 2 files changed, 81 insertions(+), 81 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 192723b7663..61fa9c92146 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,13 +35,13 @@ catalogs: version: 0.1.24 overrides: - '@clerk/backend': 3.8.2-snapshot.v20260619001138 - '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138 - '@clerk/electron': 0.0.1-snapshot.v20260619001138 - '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 - '@clerk/expo': 3.4.8-snapshot.v20260619001138 - '@clerk/react': 6.10.4-snapshot.v20260619001138 - '@clerk/shared': 4.19.2-snapshot.v20260619001138 + '@clerk/backend': 3.8.3-snapshot.v20260622234151 + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151 + '@clerk/electron': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 + '@clerk/expo': 3.5.3-snapshot.v20260622234151 + '@clerk/react': 6.11.0-snapshot.v20260622234151 + '@clerk/shared': 4.21.0-snapshot.v20260622234151 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' @@ -113,11 +113,11 @@ importers: apps/desktop: dependencies: '@clerk/electron': - specifier: 0.0.1-snapshot.v20260619001138 - version: 0.0.1-snapshot.v20260619001138(@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': - specifier: 0.0.1-snapshot.v20260619001138 - version: 0.0.1-snapshot.v20260619001138 + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151 '@effect/platform-node': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -196,8 +196,8 @@ importers: specifier: ^0.7.1 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: 3.4.8-snapshot.v20260619001138 - version: 3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 3.5.3-snapshot.v20260622234151 + version: 3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -483,11 +483,11 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': - specifier: 0.0.1-snapshot.v20260619001138 - version: 0.0.1-snapshot.v20260619001138(@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: 6.10.4-snapshot.v20260619001138 - version: 6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.11.0-snapshot.v20260622234151 + version: 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -637,8 +637,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.8.2-snapshot.v20260619001138 - version: 3.8.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.8.3-snapshot.v20260622234151 + version: 3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) @@ -1532,43 +1532,43 @@ packages: resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.8.2-snapshot.v20260619001138': - resolution: {integrity: sha512-nT6M7rKTuvoDnSZwO3Th2NMjcWZy/0ZfXYyqd/o/lFpUFcQO0J4fM2e2wF9kNCl99EPvDQQUAR8APNNy/j40rg==} + '@clerk/backend@3.8.3-snapshot.v20260622234151': + resolution: {integrity: sha512-B5goX0n/5pibc4dMQOfMmn4mPq7eqtrbNV20tOqO9qMPzFA+TKAj9SnB0oJvFsZAXKO6+/n16uYPbqeM2PN6CA==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.18.2-snapshot.v20260619001138': - resolution: {integrity: sha512-BpRSi2QXdfR5nnzC7/YCCqK40m1M4A/rN5unau7QKHj6V7xChl2fOvxYjekpH+DEyw6NAe/2jdqQv35iv3T5oA==} + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151': + resolution: {integrity: sha512-mRNn6H8GbeEkcCIzZ03WZ9c1Uy8znf70okYmmeJKzK72gsdwnrxEfbu3DYE/5yRbX6lvL7ugWaNTT3DvPEbBzg==} engines: {node: '>=20.9.0'} - '@clerk/electron-passkeys-darwin-arm64@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-dbQ/0ZtfDQgYKCSzu3AMxTnGSrdZxulurZMT4Jpvin48Etc27PdcT7VvwOGIa7R+Ab8yMeaoLJbfwHTZv35F+Q==} + '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-NZjIVGitAf0yyQvs1WXIAYOle9RjGsExpfwje2U20POTnNueFy56M0g39UTFQXJ42saTZ0zauLD8sd0cHqpOjA==} cpu: [arm64] os: [darwin] - '@clerk/electron-passkeys-darwin-x64@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-g9tbni7yKIJ/Xpollm25Gf7YLyFP24VyqRHcGDnEsHC1tIffG41spjLr10NgsBoRvFLMlaQJMSrfpjVOpBhAjw==} + '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-gGAwinVoIxa9lefCJjhf5D6oA8uq1sXQ/JwqffkBPJrvczYyAv9FYuQ1/ihji3jTplC0/bpTuIrwBkBCzwj5ug==} cpu: [x64] os: [darwin] - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-mFDQ1vQ9dLIUrnjNGhIGxqyK0iiym919gYDPO3orYEcICdEiE8xZyEbCtKSVaeX6RWGJAcqFzCxbLVG6V+1k4Q==} + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-anBFktMTAF7of37nLQsqQuW489w563J75l4QolWtblKbrBjlwFsEif95uj3vlQj0qWVVRWpAcxD21oD6wdJcwQ==} cpu: [arm64] os: [win32] - '@clerk/electron-passkeys-win32-x64-msvc@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-IwkLw+d73bd7YJPRR8LAkqP42VIIpbJXCCbs5eVKbmam9M0vSUyeLWEtSiWUEypmLK13xv+7316K4GIA2tmDGQ==} + '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-Pq4FOklTJNpyMTJpxY+/gq6CukvWHFJfGYjuz985cPHup1OSsVmuO/GYil36vh7Qbe8vDivT91wNe73DbEOcVw==} cpu: [x64] os: [win32] - '@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-diU5Q9Nx+30mesrLFOmr0OCnmxE0ogUHXktZBTQx2nQvZb/n2UGOpGNLbY1p9edKMcSc5LfJEsQ8NogfLBBvPg==} + '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-UvBweTg9+FCbygxARV7RvJ4/lcQgLN+gJC6Lj++cLmbxpgRwRAgR9xXRlKV6dVg1p5k81a4DcFNMu/oR6zBdlQ==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-wrBEdMMRqhMF4a7aQpZaBXKJfONIpaYLcgBl0m1b2r+Xg4yuZ47YUoDxhI2Ksvbx2KQPd4i9HP0enL6gEcoqfA==} + '@clerk/electron@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-T0LUJeAPaAZxpk13Q14b9LSVKKio/X/zFo67hgdr35MaOChsn4T6lQ/rEL7laScQBduskcs1yyjr3e9ndy5ojg==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 electron: '>=28' electron-store: ^8.2.0 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -1581,11 +1581,11 @@ packages: react-dom: optional: true - '@clerk/expo@3.4.8-snapshot.v20260619001138': - resolution: {integrity: sha512-E6q4p5ded45aO3y/+f7APfy5pFj2Y/BEpe/1gzdr6UGxj9kJxkiZQV29hcpjYEFMEJK60f7XbOHZTQEZRB5OwQ==} + '@clerk/expo@3.5.3-snapshot.v20260622234151': + resolution: {integrity: sha512-qeKTJYA7cTe5oCmRVCT4A2QEPRp2af0ox0VdXzIhWRqRVFNF5bnsZRNKE2e4QnsC16NYLsGHLR5uQGC15qBiug==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/expo-passkeys': 1.1.8-snapshot.v20260619001138 + '@clerk/expo-passkeys': 1.1.9-snapshot.v20260622234151 expo: '>=53 <57' expo-apple-authentication: '>=7.0.0' expo-auth-session: '>=5' @@ -1596,7 +1596,7 @@ packages: expo-web-browser: '>=12.5.0' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-native: '>=0.73' + react-native: '>=0.75' peerDependenciesMeta: '@clerk/expo-passkeys': optional: true @@ -1617,15 +1617,15 @@ packages: react-dom: optional: true - '@clerk/react@6.10.4-snapshot.v20260619001138': - resolution: {integrity: sha512-Z7Otjly14SoxadMmk8d9ZdbaXU0me9B1zGdCtnNIcQ7X2GCbeyKm/lOM27IzWYXZKO6o+sXlqsq9A9tcxet5nA==} + '@clerk/react@6.11.0-snapshot.v20260622234151': + resolution: {integrity: sha512-yF4jQFJqEHAqZCpOtjQ/Kg9yqZlEG6vW2vmVR5kVwgESkpOR1KPJIEMU8o3b6W86RKQai4lt3CWtY+o7CJsDyw==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.19.2-snapshot.v20260619001138': - resolution: {integrity: sha512-NAIz0L6+CaRrYn0DoXclUP1R0g6C2ljOzHogQ3rx9/SWUVpfaYX2lxPhURU89xvPoOGBZQb2xwk6xFh/7cjJfQ==} + '@clerk/shared@4.21.0-snapshot.v20260622234151': + resolution: {integrity: sha512-OYu+hO0GHiHwYTwSFe/GCHmuQlyTHyNa7fJ8vrmtUyML09mf+dayRFeUnxgkwOYhGriyq40t1zxdVx53Td73xg==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -10871,18 +10871,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.8.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.18.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10897,9 +10897,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.18.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10914,43 +10914,43 @@ snapshots: - react - react-dom - '@clerk/electron-passkeys-darwin-arm64@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys-darwin-x64@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys-win32-x64-msvc@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': optionalDependencies: - '@clerk/electron-passkeys-darwin-arm64': 0.0.1-snapshot.v20260619001138 - '@clerk/electron-passkeys-darwin-x64': 0.0.1-snapshot.v20260619001138 - '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.1-snapshot.v20260619001138 - '@clerk/electron-passkeys-win32-x64-msvc': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys-darwin-arm64': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-darwin-x64': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-win32-x64-msvc': 0.0.2-snapshot.v20260622234151 - '@clerk/electron@0.0.1-snapshot.v20260619001138(@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/react': 6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) electron: 41.5.0 react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.10.4-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 @@ -10965,21 +10965,21 @@ snapshots: expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) - '@clerk/react@6.10.4-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -10989,7 +10989,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 03960dfbbdd..8096d3a0a3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,13 +6,13 @@ packages: - scripts catalog: - "@clerk/backend": 3.8.2-snapshot.v20260619001138 - "@clerk/clerk-js": 6.18.2-snapshot.v20260619001138 - "@clerk/electron": 0.0.1-snapshot.v20260619001138 - "@clerk/electron-passkeys": 0.0.1-snapshot.v20260619001138 - "@clerk/expo": 3.4.8-snapshot.v20260619001138 - "@clerk/react": 6.10.4-snapshot.v20260619001138 - "@clerk/shared": 4.19.2-snapshot.v20260619001138 + "@clerk/backend": 3.8.3-snapshot.v20260622234151 + "@clerk/clerk-js": 6.21.0-snapshot.v20260622234151 + "@clerk/electron": 0.0.2-snapshot.v20260622234151 + "@clerk/electron-passkeys": 0.0.2-snapshot.v20260622234151 + "@clerk/expo": 3.5.3-snapshot.v20260622234151 + "@clerk/react": 6.11.0-snapshot.v20260622234151 + "@clerk/shared": 4.21.0-snapshot.v20260622234151 "@effect/atom-react": 4.0.0-beta.78 "@effect/openapi-generator": 4.0.0-beta.78 "@effect/platform-bun": 4.0.0-beta.78 From 3083d712eefb8009f7366ba53040eb2d477bfa33 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:33:01 +0200 Subject: [PATCH 13/28] [codex] fix: clarify Cursor CLI setup error (#3519) Co-authored-by: Codex --- .../provider/Layers/CursorProvider.test.ts | 54 +++++++++++++++---- .../src/provider/Layers/CursorProvider.ts | 19 ++++++- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 60a7312eea3..78f62ac2123 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect, it } from "vite-plus/test"; import type * as EffectAcpSchema from "effect-acp/schema"; import type { CursorSettings } from "@t3tools/contracts"; @@ -24,7 +25,11 @@ import { } from "./CursorProvider.ts"; const runNode = ( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + >, ): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { @@ -293,6 +298,18 @@ const baseCursorSettings: CursorSettings = { apiEndpoint: "", customModels: [], }; +const cursorAcpDiscoveryFailedMessage = [ + "Cursor ACP model discovery failed.", + "Cursor CLI setup may be incomplete; install or enable the Cursor CLI, restart T3 Code, and try again.", + "See https://cursor.com/docs/cli/installation.", + "Check server logs for ACP details.", +].join(" "); +const missingCursorBinaryPath = "/definitely/not/installed/t3-cursor-agent"; +const cursorCliCommandMissingMessage = [ + `Cursor CLI command \`${missingCursorBinaryPath}\` was not found.`, + `Install or enable the Cursor CLI, make sure \`${missingCursorBinaryPath}\` is on PATH, then restart T3 Code.`, + "See https://cursor.com/docs/cli/installation.", +].join(" "); describe("getCursorFallbackModels", () => { it("does not publish any built-in cursor models before ACP discovery", () => { @@ -338,12 +355,11 @@ describe("buildCursorProviderSnapshot", () => { auth: { status: "unauthenticated" }, message: "Cursor Agent is not authenticated. Run `agent login` and try again.", }, - discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.", + discoveryWarning: cursorAcpDiscoveryFailedMessage, }), ).toMatchObject({ status: "error", - message: - "Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.", + message: `Cursor Agent is not authenticated. Run \`agent login\` and try again. ${cursorAcpDiscoveryFailedMessage}`, models: [ { slug: "claude-sonnet-4-6", @@ -411,10 +427,28 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { }); describe("checkCursorProviderStatus", () => { + it("reports the install docs when the Cursor CLI command is missing", async () => { + const provider = await runNode( + checkCursorProviderStatus({ + enabled: true, + binaryPath: missingCursorBinaryPath, + apiEndpoint: "", + customModels: [], + }), + ); + + expect(provider).toMatchObject({ + installed: false, + status: "error", + auth: { status: "unknown" }, + message: cursorCliCommandMissingMessage, + }); + }); + it("passes the injected environment to ACP model discovery", async () => { const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture()); - const provider = await Effect.runPromise( + const provider = await runNode( checkCursorProviderStatus( { enabled: true, @@ -426,7 +460,7 @@ describe("checkCursorProviderStatus", () => { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, }, - ).pipe(Effect.provide(NodeServices.layer)), + ), ); expect(provider.models.map((model) => model.slug)).toEqual([ @@ -443,13 +477,13 @@ describe("discoverCursorModelsViaAcp", () => { it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); - const models = await Effect.runPromise( + const models = await runNode( discoverCursorModelsViaAcp({ enabled: true, binaryPath: wrapperPath, apiEndpoint: "", customModels: [], - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.scoped), ); expect(models.map((model) => model.slug)).toEqual([ @@ -465,13 +499,13 @@ describe("discoverCursorModelsViaAcp", () => { makeExitLogFixture("cursor-provider-exit-log-"), ); - await Effect.runPromise( + await runNode( discoverCursorModelsViaAcp({ enabled: true, binaryPath: wrapperPath, apiEndpoint: "", customModels: [], - }).pipe(Effect.provide(NodeServices.layer)), + }), ); const exitLog = await runNode(waitForFileContent(exitLogPath)); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index ff96ece9349..cd9b93a4734 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -62,6 +62,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; +const CURSOR_CLI_INSTALLATION_DOCS_URL = "https://cursor.com/docs/cli/installation"; +const CURSOR_ACP_MODEL_DISCOVERY_FAILED_MESSAGE = [ + "Cursor ACP model discovery failed.", + "Cursor CLI setup may be incomplete; install or enable the Cursor CLI, restart T3 Code, and try again.", + `See ${CURSOR_CLI_INSTALLATION_DOCS_URL}.`, + "Check server logs for ACP details.", +].join(" "); export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { _meta: { parameterizedModelPicker: true, @@ -608,6 +615,14 @@ function joinProviderMessages(...messages: ReadonlyArray): s return parts.length > 0 ? parts.join(" ") : undefined; } +function buildCursorCliCommandMissingMessage(binaryPath: string): string { + return [ + `Cursor CLI command \`${binaryPath}\` was not found.`, + `Install or enable the Cursor CLI, make sure \`${binaryPath}\` is on PATH, then restart T3 Code.`, + `See ${CURSOR_CLI_INSTALLATION_DOCS_URL}.`, + ].join(" "); +} + export function buildCursorProviderSnapshot(input: { readonly checkedAt: string; readonly cursorSettings: CursorSettings; @@ -1020,7 +1035,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( status: "error", auth: { status: "unknown" }, message: isCommandMissingCause(error) - ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." + ? buildCursorCliCommandMissingMessage(cursorSettings.binaryPath) : "Failed to execute Cursor Agent CLI health check.", }, }); @@ -1079,7 +1094,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( yield* Effect.logWarning("Cursor ACP model discovery failed", { errorTag: causeErrorTag(discoveryExit.cause), }); - discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; + discoveryWarning = CURSOR_ACP_MODEL_DISCOVERY_FAILED_MESSAGE; } else if (Option.isNone(discoveryExit.value)) { discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; } else if (discoveryExit.value.value.length === 0) { From 4abf8b46c591ae2b36aa29598c388100df651411 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:33:23 +0200 Subject: [PATCH 14/28] [codex] fix: ignore stale shell reducer events (#3517) Co-authored-by: Codex --- .../src/state/shellReducer.test.ts | 20 +++++++++++++++++++ .../client-runtime/src/state/shellReducer.ts | 2 ++ 2 files changed, 22 insertions(+) diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 4689c1408f7..a069460e63c 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -44,6 +44,26 @@ const stubThread = { } as const; describe("applyShellStreamEvent", () => { + it("ignores stale project upserts without mutating the snapshot", () => { + const snapshotWithProject: OrchestrationShellSnapshot = { + ...baseSnapshot, + snapshotSequence: 4, + projects: [stubProject], + }; + + for (const sequence of [3, 4]) { + const next = applyShellStreamEvent(snapshotWithProject, { + kind: "project-upserted", + sequence, + project: { ...stubProject, title: "Stale Title" }, + }); + + expect(next).toBe(snapshotWithProject); + expect(next.snapshotSequence).toBe(4); + expect(next.projects[0]?.title).toBe("Test Project"); + } + }); + describe("project-upserted", () => { it("adds a new project", () => { const event: OrchestrationShellStreamEvent = { diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 71c8a6b0eb3..3d3b22a1289 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -13,6 +13,8 @@ export function applyShellStreamEvent( snapshot: OrchestrationShellSnapshot, event: OrchestrationShellStreamEvent, ): OrchestrationShellSnapshot { + if (event.sequence <= snapshot.snapshotSequence) return snapshot; + switch (event.kind) { case "project-upserted": { const projects = snapshot.projects.some((p) => p.id === event.project.id) From c6c64918f8c582cc8b12de155d6c9aae12037147 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:47:26 -0700 Subject: [PATCH 15/28] Reduce ChatMarkdown settings rerenders (#3536) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge --- apps/web/src/components/ChatMarkdown.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 47604d23ca2..b04e98a8a60 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -54,7 +54,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; -import { useClientSettings } from "../hooks/useSettings"; +import { getClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, @@ -293,10 +293,14 @@ function getHighlighterPromise(language: string): Promise { return promise; } +function readInitialWordWrapSetting(): boolean { + return getClientSettings().wordWrap; +} + function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); - const [expanded, setExpanded] = useState(useClientSettings((settings) => settings.wordWrap)); + const [expanded, setExpanded] = useState(readInitialWordWrapSetting); const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; @@ -526,7 +530,7 @@ function MarkdownCodeBlock({ children: ReactNode; }) { const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(useClientSettings((settings) => settings.wordWrap)); + const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; From a4964b3b363dc515fad04458e5936cd32053c659 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:04:20 +0200 Subject: [PATCH 16/28] [codex] fix: show standalone element-pick context (#3527) Co-authored-by: Codex --- .../components/chat/MessagesTimeline.test.tsx | 27 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 8 ++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c2130381af6..54ad25df7b7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -193,6 +193,33 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Show full message"); }, 20_000); + it("renders chips for standalone element-pick context messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ", + "- (Button.tsx:12):", + " url: https://example.com/dashboard", + " selector: button.submit", + " source: /repo/src/Button.tsx:12:5", + " html:", + ' ', + "", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("SubmitButton"); + expect(markup).not.toContain("<element_context"); + expect(markup).not.toContain(" { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 88cbecb9bec..69c0f2d0260 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -448,6 +448,10 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; @@ -495,9 +499,9 @@ function UserTimelineRow({ row }: { row: Extract ))} - {elementContextState.contexts.length > 0 ? ( + {elementContexts.length > 0 ? (
    - {elementContextState.contexts.map((context) => ( + {elementContexts.map((context) => ( Date: Wed, 24 Jun 2026 15:39:17 -0700 Subject: [PATCH 17/28] [codex] Upgrade Legend List chat scrolling (#3545) Co-authored-by: Julius Marminge --- apps/mobile/package.json | 6 +- .../src/features/threads/ThreadComposer.tsx | 7 +- .../features/threads/ThreadDetailScreen.tsx | 114 +++++-- .../src/features/threads/ThreadFeed.tsx | 184 ++++------- apps/mobile/src/lib/threadActivity.test.ts | 12 +- apps/mobile/src/lib/threadActivity.ts | 20 +- apps/mobile/src/lib/threadFeedLayout.test.ts | 32 -- apps/mobile/src/lib/threadFeedLayout.ts | 22 -- .../src/state/use-thread-composer-state.ts | 33 +- apps/web/package.json | 2 +- .../BranchToolbarBranchSelector.tsx | 11 +- apps/web/src/components/ChatView.tsx | 292 +++++++++++------- apps/web/src/components/chat/ChatComposer.tsx | 30 +- .../components/chat/MessagesTimeline.test.tsx | 52 +++- .../src/components/chat/MessagesTimeline.tsx | 39 +-- .../components/chat/ModelPickerContent.tsx | 2 +- apps/web/src/index.css | 55 +++- packages/shared/package.json | 4 + packages/shared/src/chatList.test.ts | 42 +++ packages/shared/src/chatList.ts | 33 ++ pnpm-lock.yaml | 28 +- 21 files changed, 586 insertions(+), 434 deletions(-) delete mode 100644 apps/mobile/src/lib/threadFeedLayout.test.ts delete mode 100644 apps/mobile/src/lib/threadFeedLayout.ts create mode 100644 packages/shared/src/chatList.test.ts create mode 100644 packages/shared/src/chatList.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ddf5b2a0250..963cefd6b7c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -44,7 +44,7 @@ "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", - "@legendapp/list": "3.0.0-beta.44", + "@legendapp/list": "3.2.0", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", @@ -93,9 +93,9 @@ "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", "react-native-image-viewing": "^0.2.2", - "react-native-keyboard-controller": "1.21.6", + "react-native-keyboard-controller": "1.21.7", "react-native-nitro-markdown": "^0.5.0", - "react-native-nitro-modules": "^0.35.4", + "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 0050eb923be..75991cae885 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,6 +1,7 @@ import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentId, + MessageId, ModelSelection, OrchestrationThreadShell, ProviderInteractionMode, @@ -91,7 +92,7 @@ export interface ThreadComposerProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: () => Promise; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -447,9 +448,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; const handleSend = useCallback(() => { - void onSendMessage().then(() => { - inputRef.current?.blur(); - }); + void onSendMessage(); }, [onSendMessage]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 624e8fe14fe..62d1bce1157 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,7 +1,10 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; +import type { LegendListRef } from "@legendapp/list/react-native"; import type { ApprovalRequestId, EnvironmentId, + MessageId, ModelSelection, OrchestrationThreadShell, ProviderApprovalDecision, @@ -13,8 +16,8 @@ import type { import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { View, type GestureResponderEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -25,7 +28,7 @@ import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { LayoutVariant } from "../../lib/layout"; -import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingApproval, PendingUserInput, @@ -73,7 +76,7 @@ export interface ThreadDetailScreenProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: () => Promise; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -206,25 +209,33 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; - const composerRef = useRef(null); + const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const composerEditorRef = useRef(null); + const composerOverlayRef = useRef(null); + const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); + const selectedThreadKeyRef = useRef(selectedThreadKey); + const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); + const [anchorMessageId, setAnchorMessageId] = useState(null); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); + const contentPresentationKind = props.contentPresentation.kind; + const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; - const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; - const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); + const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8; + const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( + listRef, + composerOverlayRef, + estimatedOverlayHeight, + ); + const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const feedBottomInset = resolveThreadFeedBottomInset({ - estimatedOverlayHeight, - measuredOverlayHeight, - gap: 8, - }); const selectedProviderSkills = useMemo( () => props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) @@ -254,15 +265,67 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread [completeDrawerGesture, isSplitLayout], ); - const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => { - const nextHeight = Math.ceil(event.nativeEvent.layout.height); - setMeasuredOverlayHeight((current) => - Math.abs(current - nextHeight) > 1 ? nextHeight : current, - ); - }, []); + useLayoutEffect(() => { + selectedThreadKeyRef.current = selectedThreadKey; + }, [selectedThreadKey]); + + useEffect(() => { + setAnchorMessageId(null); + lastScrolledAnchorMessageIdRef.current = null; + freeze.set(false); + }, [freeze, selectedThreadKey]); + + useEffect(() => { + if ( + anchorMessageId === null || + lastScrolledAnchorMessageIdRef.current === anchorMessageId || + contentPresentationKind !== "ready" || + !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) + ) { + return; + } + + const targetThreadKey = selectedThreadKey; + const frame = requestAnimationFrame(() => { + if (selectedThreadKeyRef.current !== targetThreadKey) { + return; + } + lastScrolledAnchorMessageIdRef.current = anchorMessageId; + void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { + if ( + selectedThreadKeyRef.current !== targetThreadKey || + lastScrolledAnchorMessageIdRef.current !== anchorMessageId + ) { + return; + } + lastScrolledAnchorMessageIdRef.current = null; + freeze.set(false); + }); + }); + return () => cancelAnimationFrame(frame); + }, [ + anchorMessageId, + freeze, + contentPresentationKind, + selectedThreadFeed, + scrollMessageToEnd, + selectedThreadKey, + ]); + + const handleSendMessage = useCallback(async () => { + const targetThreadKey = selectedThreadKey; + const messageId = await props.onSendMessage(); + if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { + return messageId; + } + + setAnchorMessageId(messageId); + composerEditorRef.current?.blur(); + return messageId; + }, [props.onSendMessage, selectedThreadKey]); const collapseComposer = useCallback(() => { - composerRef.current?.blur(); + composerEditorRef.current?.blur(); }, []); const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { @@ -315,10 +378,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread contentPresentation={props.contentPresentation} agentLabel={agentLabel} latestTurn={props.selectedThread.latestTurn} + listRef={listRef} + freeze={freeze} + anchorMessageId={anchorMessageId} + contentInsetEndAdjustment={contentInsetEndAdjustment} contentTopInset={headerHeight} - contentBottomInset={feedBottomInset} + contentBottomInset={estimatedOverlayHeight} layoutVariant={layoutVariant} - composerExpanded={composerExpanded} skills={selectedProviderSkills} /> @@ -332,7 +398,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} offset={{ closed: 0, opened: 0 }} > - + {props.activeWorkStartedAt ? ( ) : null} @@ -361,7 +427,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} ; + readonly freeze: SharedValue; + readonly anchorMessageId: MessageId | null; + readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly layoutVariant?: LayoutVariant; - readonly composerExpanded?: boolean; readonly skills?: ReadonlyArray; } @@ -812,30 +821,6 @@ function renderFeedEntry( ); } - if (entry.type === "queued-message") { - return ( - - - - {entry.queuedMessage.text} - - {entry.queuedMessage.attachments.length > 0 ? ( - - {entry.queuedMessage.attachments.length} image - {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached - - ) : null} - - - {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} - - - ); - } - return ( (null); const copyFeedbackTimeoutRef = useRef | null>(null); - const scrollFrameRef = useRef(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); - const suppressAutoFollowRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); - const isNearEndRef = useRef(true); - const initialScrollReadyRef = useRef(false); - const lastContentHeightRef = useRef(0); const { width: viewportWidth } = useWindowDimensions(); + const [foldToggleSettling, setFoldToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1214,6 +1194,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), [expandedTurnIds, props.feed, props.latestTurn], ); + const anchoredEndSpace = useMemo( + () => + resolveChatListAnchoredEndSpace( + presentedFeed, + props.anchorMessageId, + (entry) => (entry.type === "message" ? entry.id : null), + { anchorOffset: topContentInset + CHAT_LIST_ANCHOR_OFFSET }, + ), + [presentedFeed, props.anchorMessageId, topContentInset], + ); const terminalAssistantMessageIds = useMemo(() => { const terminalIdsByTurn = new Map(); for (const entry of props.feed) { @@ -1229,54 +1219,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? props.latestTurn.turnId : null; - const scrollToEnd = useCallback(() => { - if (scrollFrameRef.current !== null) { - return; - } - scrollFrameRef.current = requestAnimationFrame(() => { - scrollFrameRef.current = null; - listRef.current?.scrollToEnd({ animated: false }); - }); - }, []); - - const onListScroll = useCallback( - (event: NativeSyntheticEvent | NativeScrollEvent) => { - const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; - const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; - isNearEndRef.current = isThreadFeedNearEnd( - { - contentHeight: contentSize.height, - viewportHeight: layoutMeasurement.height, - offsetY: contentOffset.y, - bottomInset: contentInset.bottom, - }, - THREAD_FEED_END_THRESHOLD, - ); - }, - [], - ); - - const onListContentSizeChange = useCallback( - (_width: number, height: number) => { - const contentGrew = height > lastContentHeightRef.current + 0.5; - lastContentHeightRef.current = height; - - if ( - initialScrollReadyRef.current && - contentGrew && - isNearEndRef.current && - !suppressAutoFollowRef.current - ) { - scrollToEnd(); - } - }, - [scrollToEnd], - ); - - const onListLoad = useCallback(() => { - initialScrollReadyRef.current = true; - }, []); - useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestTurn; @@ -1308,9 +1250,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } - if (scrollFrameRef.current !== null) { - cancelAnimationFrame(scrollFrameRef.current); - } if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } @@ -1358,7 +1297,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, []); const onToggleTurnFold = useCallback((turnId: TurnId) => { - suppressAutoFollowRef.current = true; + setFoldToggleSettling(true); if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } @@ -1376,7 +1315,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); foldSettleFrameRef.current = requestAnimationFrame(() => { foldSettleSecondFrameRef.current = requestAnimationFrame(() => { - suppressAutoFollowRef.current = false; + setFoldToggleSettling(false); foldSettleFrameRef.current = null; foldSettleSecondFrameRef.current = null; }); @@ -1458,59 +1397,60 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); } - if (props.feed.length === 0) { - return ( - - ); - } - return ( <> - `${entry.type}:${entry.id}`} + keyExtractor={(entry) => entry.id} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } keyboardShouldPersistTaps="always" keyboardDismissMode="none" + keyboardLiftBehavior="whenAtEnd" estimatedItemSize={180} initialScrollAtEnd - onContentSizeChange={onListContentSizeChange} - onLoad={onListLoad} - onScroll={onListScroll} - scrollEventThrottle={16} ListHeaderComponent={} contentContainerStyle={{ paddingTop: 12, - paddingBottom: bottomContentInset, paddingHorizontal: horizontalPadding, }} /> + {props.feed.length === 0 ? ( + + + + ) : null} { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); expect(feed).toMatchObject([ { type: "activity-group", @@ -144,7 +144,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); const group = feed[0]; expect(group).toMatchObject({ @@ -209,7 +209,7 @@ describe("buildThreadFeed", () => { ], }); - const group = buildThreadFeed(thread, [], null)[0]; + const group = buildThreadFeed(thread)[0]; expect(group).toMatchObject({ type: "activity-group" }); if (!group || group.type !== "activity-group") { return; @@ -271,7 +271,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); expect(collapsed[0]).toMatchObject({ @@ -359,7 +359,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ turnId: firstTurnId, @@ -399,7 +399,7 @@ describe("buildThreadFeed", () => { ], }); - const feed = buildThreadFeed(thread, [], null); + const feed = buildThreadFeed(thread); expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); expect(feed[0]).toMatchObject({ type: "activity-group", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index bef46e46e6e..f6008057a0e 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,6 +1,5 @@ import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; import type { - MessageId, OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, @@ -10,7 +9,6 @@ import type { } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import type { QueuedThreadMessage } from "../state/thread-outbox-model"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -88,13 +86,6 @@ type RawThreadFeedEntry = readonly createdAt: string; readonly message: OrchestrationThread["messages"][number]; } - | { - readonly type: "queued-message"; - readonly id: string; - readonly createdAt: string; - readonly queuedMessage: QueuedThreadMessage; - readonly sending: boolean; - } | { readonly type: "activity"; readonly id: string; @@ -104,7 +95,7 @@ type RawThreadFeedEntry = }; export type ThreadFeedEntry = - | Extract + | Extract | { readonly type: "activity-group"; readonly id: string; @@ -1255,8 +1246,6 @@ export function buildPendingUserInputAnswers( export function buildThreadFeed( thread: OrchestrationThread, - queuedMessages: ReadonlyArray, - dispatchingQueuedMessageId: MessageId | null, options?: { readonly loadedMessages?: ReadonlyArray; }, @@ -1273,13 +1262,6 @@ export function buildThreadFeed( createdAt: message.createdAt, message, })), - ...queuedMessages.map((queuedMessage) => ({ - type: "queued-message", - id: queuedMessage.messageId, - createdAt: queuedMessage.createdAt, - queuedMessage, - sending: queuedMessage.messageId === dispatchingQueuedMessageId, - })), ...workLogEntries .filter((entry) => { if (options?.loadedMessages === undefined) { diff --git a/apps/mobile/src/lib/threadFeedLayout.test.ts b/apps/mobile/src/lib/threadFeedLayout.test.ts deleted file mode 100644 index 73f113eac38..00000000000 --- a/apps/mobile/src/lib/threadFeedLayout.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - isThreadFeedNearEnd, - resolveThreadFeedBottomInset, - threadFeedDistanceFromEnd, -} from "./threadFeedLayout"; - -describe("thread feed layout", () => { - it("accounts for the bottom inset when measuring distance from the end", () => { - const metrics = { - contentHeight: 900, - viewportHeight: 600, - offsetY: 380, - bottomInset: 100, - }; - - expect(threadFeedDistanceFromEnd(metrics)).toBe(20); - expect(isThreadFeedNearEnd(metrics, 50)).toBe(true); - expect(isThreadFeedNearEnd(metrics, 10)).toBe(false); - }); - - it("does not double count chrome already included in the measured composer overlay", () => { - expect( - resolveThreadFeedBottomInset({ - estimatedOverlayHeight: 162, - measuredOverlayHeight: 182, - gap: 8, - }), - ).toBe(190); - }); -}); diff --git a/apps/mobile/src/lib/threadFeedLayout.ts b/apps/mobile/src/lib/threadFeedLayout.ts deleted file mode 100644 index de7946f866d..00000000000 --- a/apps/mobile/src/lib/threadFeedLayout.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface ThreadFeedScrollMetrics { - readonly contentHeight: number; - readonly viewportHeight: number; - readonly offsetY: number; - readonly bottomInset: number; -} - -export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number { - return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY; -} - -export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean { - return threadFeedDistanceFromEnd(metrics) <= threshold; -} - -export function resolveThreadFeedBottomInset(input: { - readonly estimatedOverlayHeight: number; - readonly measuredOverlayHeight: number; - readonly gap: number; -}): number { - return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap; -} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 0b8cba16e16..90831f8437a 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -40,7 +40,6 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; -import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox-drain"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -77,7 +76,6 @@ export function useThreadComposerState() { const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); - const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); useEffect(() => { @@ -91,17 +89,9 @@ export function useThreadComposerState() { () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], ); - const selectedThreadFeed = useMemo( - () => - selectedThreadDetail - ? buildThreadFeed( - selectedThreadDetail, - selectedThreadQueuedMessages, - dispatchingQueuedMessageId, - ) - : [], - [dispatchingQueuedMessageId, selectedThreadDetail, selectedThreadQueuedMessages], + () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []), + [selectedThreadDetail], ); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; @@ -125,7 +115,6 @@ export function useThreadComposerState() { }; }, [selectedThreadDetail, selectedThreadShell]); - const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; const activeWorkStartedAt = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread) { @@ -135,14 +124,9 @@ export function useThreadComposerState() { return deriveActiveWorkStartedAt( selectedThread.latestTurn, selectedThreadSessionActivity, - queuedSendStartedAt, + null, ); - }, [ - queuedSendStartedAt, - selectedThreadDetail, - selectedThreadSessionActivity, - selectedThreadShell, - ]); + }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); const activeThreadBusy = !!selectedThread && @@ -150,7 +134,7 @@ export function useThreadComposerState() { const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { - return; + return null; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); @@ -159,15 +143,16 @@ export function useThreadComposerState() { const text = draft.text.trim(); const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { - return; + return null; } const metadata = makeQueuedMessageMetadata(); + const messageId = MessageId.make(metadata.messageId); try { await enqueueThreadOutboxMessage({ environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, - messageId: MessageId.make(metadata.messageId), + messageId, commandId: CommandId.make(metadata.commandId), text, attachments, @@ -177,10 +162,12 @@ export function useThreadComposerState() { createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); + return messageId; } catch (error) { setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); + return null; } }, [selectedThreadDetail, selectedThreadShell]); diff --git a/apps/web/package.json b/apps/web/package.json index 632e2d14395..484422f1a51 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,7 @@ "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.0.0-beta.44", + "@legendapp/list": "3.2.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 7798f38e43e..e2ee24c3608 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -518,7 +518,7 @@ export function BranchToolbarBranchSelector({ return; } - branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); useEffect(() => { @@ -628,7 +628,7 @@ export function BranchToolbarBranchSelector({ if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { return; } - branchListRef.current?.scrollIndexIntoView?.({ + void branchListRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false, }); @@ -696,6 +696,13 @@ export function BranchToolbarBranchSelector({ ref={branchListRef} data={filteredBranchPickerItems} keyExtractor={(item) => item} + getItemType={(item) => + item === checkoutPullRequestItemValue + ? "checkout-pull-request" + : item === createBranchItemValue + ? "create-branch" + : "branch" + } renderItem={({ item, index }) => renderPickerItem(item, index)} estimatedItemSize={28} drawDistance={336} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44429614b44..9fb8d647b4e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -41,7 +41,17 @@ import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; -import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + lazy, + memo, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { @@ -1137,12 +1147,32 @@ function ChatViewContent(props: ChatViewProps) { LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); + const [composerOverlayElement, setComposerOverlayElement] = useState(null); + const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalUiOpenByThreadRef = useRef>({}); + useLayoutEffect(() => { + if (!composerOverlayElement) return; + + const updateHeight = () => { + const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); + setComposerOverlayHeight((currentHeight) => + currentHeight === nextHeight ? currentHeight : nextHeight, + ); + }; + + updateHeight(); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(updateHeight); + observer.observe(composerOverlayElement); + return () => observer.disconnect(); + }, [composerOverlayElement]); + const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); @@ -1254,6 +1284,14 @@ function ChatViewContent(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const [timelineAnchor, setTimelineAnchor] = useState<{ + readonly threadKey: string | null; + readonly messageId: MessageId | null; + }>({ threadKey: activeThreadKey, messageId: null }); + if (timelineAnchor.threadKey !== activeThreadKey) { + setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); + } + const timelineAnchorMessageId = timelineAnchor.messageId; const activeRightPanelKind = useRightPanelStore((state) => selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); @@ -3111,7 +3149,7 @@ function ChatViewContent(props: ChatViewProps) { // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. const scrollToEnd = useCallback((animated = false) => { - legendListRef.current?.scrollToEnd?.({ animated }); + void legendListRef.current?.scrollToEnd?.({ animated }); }, []); // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during @@ -3690,14 +3728,16 @@ function ChatViewContent(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); - // Scroll to the current end *before* adding the optimistic message. - // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd - // automatically pins to the new item when the data changes. + // Sending always returns to the live edge. The new row becomes the + // anchored end-space target so it lands near the top while the response + // streams into the reserved space below it. isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - await legendListRef.current?.scrollToEnd?.({ animated: false }); - + setTimelineAnchor({ + threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageId: messageIdForSend, + }); setOptimisticUserMessages((existing) => [ ...existing, { @@ -3711,6 +3751,7 @@ function ChatViewContent(props: ChatViewProps) { streaming: false, }, ]); + void legendListRef.current?.scrollToEnd?.({ animated: false }); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { @@ -4722,7 +4763,7 @@ function ChatViewContent(props: ChatViewProps) { {/* Main content area with optional plan sidebar */}
    {/* Chat column */} -
    +
    {/* Messages Wrapper */}
    {/* Messages — LegendList handles virtualization and scrolling internally */} @@ -4747,12 +4788,17 @@ function ChatViewContent(props: ChatViewProps) { timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + anchorMessageId={timelineAnchorMessageId} + contentInsetEndAdjustment={composerOverlayHeight} onIsAtEndChange={onIsAtEndChange} /> {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} {showScrollToBottom && ( -
    +
    + + +
    + ); +} diff --git a/apps/web/src/browser/BrowserViewportResizeHandles.tsx b/apps/web/src/browser/BrowserViewportResizeHandles.tsx new file mode 100644 index 00000000000..08e69c359a1 --- /dev/null +++ b/apps/web/src/browser/BrowserViewportResizeHandles.tsx @@ -0,0 +1,169 @@ +"use client"; + +import type { + CSSProperties, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, +} from "react"; + +import { cn } from "~/lib/utils"; + +import { + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, + type BrowserViewportLayout, + type BrowserViewportResizeDirection, +} from "./browserViewportLayout"; + +interface Props { + readonly layout: BrowserViewportLayout; + readonly activeDirection: BrowserViewportResizeDirection | null; + readonly onPointerDown: ( + direction: BrowserViewportResizeDirection, + event: ReactPointerEvent, + ) => void; + readonly onKeyDown: ( + direction: BrowserViewportResizeDirection, + event: ReactKeyboardEvent, + ) => void; +} + +type HandleKind = "horizontal" | "vertical" | "corner"; + +const EDGE_BUTTON_CLASS = + "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none before:absolute before:-inset-1 before:content-[''] focus-visible:bg-foreground/[0.04]"; +const EDGE_GRIP_CLASS = + "pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-muted-foreground/55 transition-colors duration-150 group-hover:text-foreground/85 group-focus-visible:text-foreground group-active:text-foreground"; + +function ResizeHandle(props: { + readonly direction: BrowserViewportResizeDirection; + readonly label: string; + readonly kind: HandleKind; + readonly cursorClassName: string; + readonly style: CSSProperties; + readonly active: boolean; + readonly mirrorCorner?: boolean; + readonly onPointerDown: Props["onPointerDown"]; + readonly onKeyDown: Props["onKeyDown"]; +}) { + const { + direction, + label, + kind, + cursorClassName, + style, + active, + mirrorCorner = false, + onPointerDown, + onKeyDown, + } = props; + return ( + + ); +} + +export function BrowserViewportResizeHandles({ + layout, + activeDirection, + onPointerDown, + onKeyDown, +}: Props) { + const left = layout.viewportX; + const top = layout.viewportY; + const right = left + layout.viewportWidth; + const bottom = top + layout.viewportHeight; + const railSize = BROWSER_VIEWPORT_RESIZE_RAIL_SIZE; + + const shared = { activeDirection, onPointerDown, onKeyDown }; + return ( + <> + + + + + + + ); +} diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 205dce73583..51fa73a721f 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,6 +1,7 @@ "use client"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; import { useEffect, useMemo } from "react"; import { isElectron } from "~/env"; @@ -22,7 +23,7 @@ export function ElectronBrowserHost() { ? Object.values(previewState.sessions).map((snapshot) => ({ threadRef, snapshot, - active: previewState.activeTabId === snapshot.tabId, + zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, })) : []; }), @@ -73,7 +74,7 @@ export function ElectronBrowserHost() { if (!isElectron) return null; return (
    - {sessions.map(({ threadRef, snapshot }) => { + {sessions.map(({ threadRef, snapshot, zoomFactor }) => { const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; return ( ); })} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index cdd33fa150d..2907a067aad 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -1,16 +1,22 @@ "use client"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts"; import { useShallow } from "zustand/react/shallow"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; +import { cn } from "~/lib/utils"; import { useActiveBrowserRecordingTabId } from "./browserRecording"; -import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { browserViewportSettingKey } from "./browserViewportLayout"; +import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState"; +import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; +import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; +import { useBrowserViewportResize } from "./useBrowserViewportResize"; interface ElectronWebview extends HTMLElement { src: string; @@ -18,6 +24,7 @@ interface ElectronWebview extends HTMLElement { preload?: string; webpreferences?: string; getWebContentsId: () => number; + executeJavaScript: (code: string, userGesture?: boolean) => Promise; } declare global { @@ -30,13 +37,25 @@ export function HostedBrowserWebview(props: { readonly threadRef: ScopedThreadRef; readonly tabId: string; readonly initialUrl: string | null; + readonly viewport: PreviewViewportSetting; + readonly zoomFactor: number; }) { - const { threadRef, tabId, initialUrl } = props; + const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props; const config = usePreviewWebviewConfig(threadRef.environmentId); - const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); + const wrapperRef = useRef(null); const webviewRef = useRef(null); - const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); + const [lockedAspectRatio, setLockedAspectRatio] = useState(null); + const presentation = useBrowserSurfaceStore( + useShallow((state) => { + const current = state.byTabId[tabId]; + return { + rect: resolveBrowserSurfacePanelRect(state.byTabId, tabId), + visible: current?.visible ?? false, + }; + }), + ); const recording = useActiveBrowserRecordingTabId() === tabId; usePreviewBridge({ threadRef, tabId }); @@ -89,10 +108,69 @@ export function HostedBrowserWebview(props: { }; }, [config, tabId]); + const active = presentation.visible && presentation.rect !== null; + const lastRect = presentation.rect; + const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const viewportWidth = viewport._tag === "fill" ? null : viewport.width; + const viewportHeight = viewport._tag === "fill" ? null : viewport.height; + const viewportAspectRatio = + viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight; + useEffect(() => { + setLockedAspectRatio((current) => reconcileLockedAspectRatio(current, viewportAspectRatio)); + }, [viewportAspectRatio]); + const hiddenSize = + viewport._tag !== "fill" + ? { + width: viewport.width * normalizedZoomFactor, + height: viewport.height * normalizedZoomFactor, + } + : { width: lastRect?.width ?? 1280, height: lastRect?.height ?? 800 }; + const containerSize = active && lastRect ? lastRect : hiddenSize; + const deviceToolbarVisible = active && viewport._tag !== "fill"; + const { + activeDrag, + commitViewportChange, + effectiveViewport, + handleResizeKeyDown, + handleResizePointerDown, + layout, + } = useBrowserViewportResize({ + tabId, + viewport, + zoomFactor, + containerSize, + deviceToolbarVisible, + aspectRatio: lockedAspectRatio, + }); + + const syncContentPresentation = useCallback(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + useBrowserSurfaceStore.getState().presentContent(tabId, { + x: layout.viewportX, + y: layout.viewportY, + width: layout.viewportWidth, + height: layout.viewportHeight, + scale: layout.viewportScale, + scrollLeft: wrapper.scrollLeft, + scrollTop: wrapper.scrollTop, + }); + }, [layout, tabId]); + + useEffect(() => { + const frameId = window.requestAnimationFrame(syncContentPresentation); + return () => window.cancelAnimationFrame(frameId); + }, [syncContentPresentation]); + + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + wrapper.scrollTo({ left: 0, top: 0 }); + }, [tabId, viewport._tag, viewportHeight, viewportWidth]); + if (!config) return null; - const active = presentation?.visible === true && presentation.rect !== null; - const lastRect = presentation?.rect; - const style = + + const wrapperStyle = active && lastRect ? { left: lastRect.x, @@ -105,23 +183,86 @@ export function HostedBrowserWebview(props: { : { left: 0, top: 0, - width: lastRect?.width ?? 1280, - height: lastRect?.height ?? 800, + width: hiddenSize.width, + height: hiddenSize.height, zIndex: recording ? 0 : -1, pointerEvents: "none" as const, }; return ( - +
    +
    + {deviceToolbarVisible && effectiveViewport._tag !== "fill" ? ( + + ) : null} + + {active && effectiveViewport._tag !== "fill" ? ( + <> + + {activeDrag ? ( + + ) : null} + + ) : null} +
    +
    ); } diff --git a/apps/web/src/browser/browserDeviceToolbarState.ts b/apps/web/src/browser/browserDeviceToolbarState.ts new file mode 100644 index 00000000000..9986ee02282 --- /dev/null +++ b/apps/web/src/browser/browserDeviceToolbarState.ts @@ -0,0 +1,18 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +export function reconcileLockedAspectRatio( + current: number | null, + viewportAspectRatio: number | null, +): number | null { + return current === null || viewportAspectRatio === null ? null : viewportAspectRatio; +} + +export async function commitViewportAndAspectRatio( + setting: PreviewViewportSetting, + aspectRatio: number | null, + onChange: (setting: PreviewViewportSetting) => Promise, + onAspectRatioChange: (aspectRatio: number | null) => void, +): Promise { + await onChange(setting); + onAspectRatioChange(aspectRatio); +} diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 5bb3364807d..f4580628800 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -90,6 +90,10 @@ export function useActiveBrowserRecordingTabId(): string | null { let active: ActiveRecording | null = null; let unsubscribeFrames: (() => void) | null = null; +export function readActiveBrowserRecordingTabId(): string | null { + return active?.tabId ?? null; +} + const preferredMimeType = (): string => { const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; @@ -137,10 +141,11 @@ export async function startBrowserRecording(tabId: string): Promise { activeTabId: active.tabId, }); } - const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; + const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; + const recordingSize = surface?.content ?? surface?.rect; const canvas = document.createElement("canvas"); - canvas.width = Math.max(1, rect?.width ?? 1280); - canvas.height = Math.max(1, rect?.height ?? 800); + canvas.width = Math.max(1, recordingSize?.width ?? 1280); + canvas.height = Math.max(1, recordingSize?.height ?? 800); const context = canvas.getContext("2d", { alpha: false }); if (!context) { throw new BrowserRecordingCanvasUnavailableError({ diff --git a/apps/web/src/browser/browserRecordingScope.test.ts b/apps/web/src/browser/browserRecordingScope.test.ts new file mode 100644 index 00000000000..ae91f1a7fdc --- /dev/null +++ b/apps/web/src/browser/browserRecordingScope.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; + +describe("resolveBrowserRecordingStopTarget", () => { + it("stops the active recording even after the requested tab changes", () => { + expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a"); + expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b"); + expect(resolveBrowserRecordingStopTarget(null)).toBeNull(); + }); +}); diff --git a/apps/web/src/browser/browserRecordingScope.ts b/apps/web/src/browser/browserRecordingScope.ts new file mode 100644 index 00000000000..807b5d0c9a6 --- /dev/null +++ b/apps/web/src/browser/browserRecordingScope.ts @@ -0,0 +1,3 @@ +export function resolveBrowserRecordingStopTarget(activeTabId: string | null): string | null { + return activeTabId; +} diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts new file mode 100644 index 00000000000..1445303b8f7 --- /dev/null +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; + +describe("browserSurfaceStore", () => { + it("tracks content dimensions for a browser that has never been visible", () => { + const tabId = "hidden-browser-surface-content-test"; + useBrowserSurfaceStore.getState().presentContent(tabId, { + x: 0, + y: 0, + width: 393, + height: 852, + scale: 1, + scrollLeft: 0, + scrollTop: 0, + }); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + rect: null, + visible: false, + content: { width: 393, height: 852 }, + }); + }); + + it("uses the live panel rect for a hidden background tab", () => { + const staleRect = { x: 0, y: 0, width: 500, height: 700 }; + const liveRect = { x: 10, y: 20, width: 900, height: 640 }; + expect( + resolveBrowserSurfacePanelRect( + { + hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1 }, + active: { rect: liveRect, visible: true, content: null, updatedAt: 2 }, + }, + "hidden", + ), + ).toEqual(liveRect); + }); +}); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 64fd8e2df2b..79095f0bdbb 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -10,15 +10,47 @@ export interface BrowserSurfaceRect { export interface BrowserSurfacePresentation { readonly rect: BrowserSurfaceRect | null; readonly visible: boolean; + readonly content: BrowserSurfaceContentPresentation | null; readonly updatedAt: number; } +export interface BrowserSurfaceContentPresentation { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly scale: number; + readonly scrollLeft: number; + readonly scrollTop: number; +} + interface BrowserSurfaceStoreState { readonly byTabId: Record; readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; + readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; readonly hide: (tabId: string) => void; } +export function resolveBrowserSurfacePanelRect( + byTabId: Readonly>, + tabId: string, +): BrowserSurfaceRect | null { + const current = byTabId[tabId]; + if (current?.visible && current.rect) return current.rect; + + let latestVisible: BrowserSurfacePresentation | undefined; + for (const presentation of Object.values(byTabId)) { + if ( + presentation.visible && + presentation.rect && + (!latestVisible || presentation.updatedAt > latestVisible.updatedAt) + ) { + latestVisible = presentation; + } + } + return latestVisible?.rect ?? current?.rect ?? null; +} + const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => left !== null && left.x === right.x && @@ -35,7 +67,43 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { rect, visible, updatedAt: Date.now() }, + [tabId]: { rect, visible, content: current?.content ?? null, updatedAt: Date.now() }, + }, + }; + }), + presentContent: (tabId, content) => + set((state) => { + const current = state.byTabId[tabId]; + if (!current) { + return { + byTabId: { + ...state.byTabId, + [tabId]: { + rect: null, + visible: false, + content, + updatedAt: Date.now(), + }, + }, + }; + } + const previous = current.content; + if ( + previous && + previous.x === content.x && + previous.y === content.y && + previous.width === content.width && + previous.height === content.height && + previous.scale === content.scale && + previous.scrollLeft === content.scrollLeft && + previous.scrollTop === content.scrollTop + ) { + return state; + } + return { + byTabId: { + ...state.byTabId, + [tabId]: { ...current, content, updatedAt: Date.now() }, }, }; }), diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts new file mode 100644 index 00000000000..fd8f7809f30 --- /dev/null +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -0,0 +1,112 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, + commitBrowserViewportChange, + subscribeBrowserViewportChange, +} from "./browserViewportActions"; + +describe("browserViewportActions", () => { + it("routes drag commits to the visible tab handler and cleans up exactly that handler", async () => { + const first = vi.fn(async () => undefined); + const second = vi.fn(async () => undefined); + const unsubscribeFirst = subscribeBrowserViewportChange("tab-1", first); + const unsubscribeSecond = subscribeBrowserViewportChange("tab-1", second); + + unsubscribeFirst(); + await commitBrowserViewportChange("tab-1", { + _tag: "freeform", + width: 900, + height: 700, + }); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith({ _tag: "freeform", width: 900, height: 700 }); + + unsubscribeSecond(); + await expect( + commitBrowserViewportChange("tab-1", { + _tag: "freeform", + width: 800, + height: 600, + }), + ).rejects.toThrow("No visible browser viewport handler"); + }); + + it("commits viewport changes in order for each tab", async () => { + let releaseFirst: (() => void) | undefined; + let markFirstStarted: (() => void) | undefined; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + const calls: Array = []; + const unsubscribe = subscribeBrowserViewportChange("tab-serial", async (setting) => { + if (setting._tag === "fill") return; + calls.push(setting.width); + if (setting.width === 800) { + markFirstStarted?.(); + await firstPending; + } + }); + + const first = commitBrowserViewportChange("tab-serial", { + _tag: "freeform", + width: 800, + height: 600, + }); + const second = commitBrowserViewportChange("tab-serial", { + _tag: "freeform", + width: 900, + height: 700, + }); + await firstStarted; + expect(calls).toEqual([800]); + + releaseFirst?.(); + await Promise.all([first, second]); + expect(calls).toEqual([800, 900]); + unsubscribe(); + }); + + it("does not let a timed-out handler overtake a newer viewport commit", async () => { + vi.useFakeTimers(); + try { + let releaseFirst: (() => void) | undefined; + const delayed = new Promise((resolve) => { + releaseFirst = resolve; + }); + const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise => undefined); + handler.mockImplementationOnce(() => delayed).mockResolvedValueOnce(undefined); + const unsubscribe = subscribeBrowserViewportChange("tab-timeout", handler); + const first = commitBrowserViewportChange("tab-timeout", { + _tag: "freeform", + width: 800, + height: 600, + }); + const firstResult = expect(first).rejects.toThrow( + "Timed out committing the browser viewport for tab tab-timeout", + ); + const second = commitBrowserViewportChange("tab-timeout", { + _tag: "freeform", + width: 900, + height: 700, + }); + + await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); + await firstResult; + expect(handler).toHaveBeenCalledTimes(1); + + releaseFirst?.(); + await second; + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 }); + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts new file mode 100644 index 00000000000..eea176ebbff --- /dev/null +++ b/apps/web/src/browser/browserViewportActions.ts @@ -0,0 +1,66 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise; + +export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; + +export class BrowserViewportCommitTimeoutError extends Error { + override readonly name = "BrowserViewportCommitTimeoutError"; + + constructor(readonly tabId: string) { + super(`Timed out committing the browser viewport for tab ${tabId}`); + } +} + +const handlers = new Map(); +const commitTails = new Map>(); + +const runHandlerWithTimeout = (tabId: string, operation: Promise): Promise => { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new BrowserViewportCommitTimeoutError(tabId)), + BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, + ); + }); + return Promise.race([operation, timeout]).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + }); +}; + +export function subscribeBrowserViewportChange( + tabId: string, + handler: BrowserViewportHandler, +): () => void { + handlers.set(tabId, handler); + return () => { + if (handlers.get(tabId) === handler) handlers.delete(tabId); + }; +} + +export function commitBrowserViewportChange( + tabId: string, + setting: PreviewViewportSetting, +): Promise { + const previous = commitTails.get(tabId) ?? Promise.resolve(); + const started = previous + .catch(() => undefined) + .then(() => { + const handler = handlers.get(tabId); + const operation = handler + ? Promise.resolve().then(() => handler(setting)) + : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); + return { operation }; + }); + // The queue follows the real handler lifetime, not the caller-facing timeout. + // A slow commit therefore cannot time out, release the queue, and overwrite a + // newer viewport after that newer request has already completed. + const execution = started.then(({ operation }) => operation); + const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation)); + commitTails.set(tabId, execution); + const clear = () => { + if (commitTails.get(tabId) === execution) commitTails.delete(tabId); + }; + void execution.then(clear, clear); + return result; +} diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts new file mode 100644 index 00000000000..edd432a4e95 --- /dev/null +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resizeBrowserViewportFromRail, + resizeFreeformViewport, + resolveBrowserDeviceViewportLayout, + resolveBrowserViewportLayout, + resolveResponsiveBrowserViewportSize, +} from "./browserViewportLayout"; + +describe("resolveBrowserViewportLayout", () => { + it("fills the available surface in fill mode", () => { + expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({ + canvasWidth: 700, + canvasHeight: 500, + viewportX: 0, + viewportY: 0, + viewportWidth: 700, + viewportHeight: 500, + viewportScale: 1, + fillsPanel: true, + }); + }); + + it("centers a smaller fixed viewport", () => { + expect( + resolveBrowserViewportLayout( + { width: 700, height: 1000 }, + { _tag: "freeform", width: 393, height: 852 }, + ), + ).toMatchObject({ + canvasWidth: 700, + canvasHeight: 1000, + viewportX: 154, + viewportY: 74, + viewportWidth: 393, + viewportHeight: 852, + }); + }); + + it("scales a larger fixed viewport down to fit without creating overflow", () => { + const layout = resolveBrowserViewportLayout( + { width: 600, height: 700 }, + { _tag: "freeform", width: 1440, height: 900 }, + ); + expect(layout).toMatchObject({ + canvasWidth: 600, + canvasHeight: 700, + viewportX: 0, + viewportY: 163, + viewportWidth: 600, + viewportHeight: 375, + }); + expect(layout.viewportScale).toBeCloseTo(5 / 12); + }); + + it("keeps fixed dimensions in page CSS pixels when browser zoom changes", () => { + expect( + resolveBrowserViewportLayout( + { width: 800, height: 700 }, + { _tag: "freeform", width: 400, height: 300 }, + 1.5, + ), + ).toMatchObject({ + viewportX: 100, + viewportY: 125, + viewportWidth: 600, + viewportHeight: 450, + }); + expect(resizeFreeformViewport({ width: 400, height: 300 }, { x: 150, y: 75 }, 1.5)).toEqual({ + width: 500, + height: 350, + }); + }); + + it("bounds freeform drag sizes and total render area", () => { + expect(resizeFreeformViewport({ width: 1024, height: 768 }, { x: -2000, y: -2000 })).toEqual({ + width: 240, + height: 240, + }); + const large = resizeFreeformViewport({ width: 1920, height: 1080 }, { x: 2000, y: 2000 }); + expect(large.width * large.height).toBeLessThanOrEqual(3840 * 2160); + }); + + it("resizes only the axes controlled by each edge", () => { + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: 500 }, 1, "west"), + ).toEqual({ width: 900, height: 600 }); + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: 500, y: 100 }, 1, "north"), + ).toEqual({ width: 800, height: 500 }); + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: -50 }, 1, "northwest"), + ).toEqual({ width: 900, height: 650 }); + }); + + it("preserves a locked aspect ratio from either axis", () => { + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: 200, y: 0 }, 1, "east", 4 / 3), + ).toEqual({ width: 1000, height: 750 }); + expect( + resizeFreeformViewport({ width: 800, height: 600 }, { x: 0, y: 150 }, 1, "south", 4 / 3), + ).toEqual({ width: 1000, height: 750 }); + }); + + it("reserves persistent device-toolbar rails around the guest viewport", () => { + expect( + resolveBrowserDeviceViewportLayout( + { width: 1200, height: 900 }, + { _tag: "freeform", width: 1180, height: 858 }, + ), + ).toEqual({ + canvasWidth: 1200, + canvasHeight: 900, + viewportX: 10, + viewportY: 32, + viewportWidth: 1180, + viewportHeight: 858, + viewportScale: 1, + fillsPanel: false, + }); + }); + + it("captures the available framed area when responsive mode is enabled", () => { + expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ + width: 1180, + height: 858, + }); + expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ + width: 590, + height: 429, + }); + }); + + it("keeps the grabbed rail under the pointer across centered layout boundaries", () => { + const available = { width: 1120, height: 818 }; + expect( + resizeBrowserViewportFromRail( + { width: 1120, height: 818 }, + { x: -100, y: -50 }, + available, + 1, + "southeast", + ), + ).toEqual({ width: 920, height: 718 }); + expect( + resizeBrowserViewportFromRail( + { width: 800, height: 600 }, + { x: 300, y: 0 }, + { width: 1200, height: 800 }, + 1, + "east", + ), + ).toEqual({ width: 1300, height: 600 }); + expect( + resizeBrowserViewportFromRail( + { width: 560, height: 409 }, + { x: -100, y: 0 }, + available, + 2, + "east", + ), + ).toEqual({ width: 460, height: 409 }); + }); +}); diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts new file mode 100644 index 00000000000..ed5529fe138 --- /dev/null +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -0,0 +1,286 @@ +import { + PREVIEW_VIEWPORT_MAX_AREA, + PREVIEW_VIEWPORT_MAX_DIMENSION, + PREVIEW_VIEWPORT_MIN_DIMENSION, + type PreviewViewportSetting, + type PreviewViewportSize, +} from "@t3tools/contracts"; + +export interface BrowserViewportLayout { + readonly canvasWidth: number; + readonly canvasHeight: number; + readonly viewportX: number; + readonly viewportY: number; + /** Visible footprint inside the preview panel after fit-to-panel scaling. */ + readonly viewportWidth: number; + readonly viewportHeight: number; + /** Presentation-only scale; the guest keeps its requested CSS viewport. */ + readonly viewportScale: number; + readonly fillsPanel: boolean; +} + +export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 32; +export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10; + +export type BrowserViewportResizeDirection = + | "north" + | "northeast" + | "east" + | "southeast" + | "south" + | "southwest" + | "west" + | "northwest"; + +export const browserViewportSettingKey = (setting: PreviewViewportSetting): string => + setting._tag === "fill" + ? "fill" + : `${setting._tag}:${setting.width}:${setting.height}:${setting._tag === "preset" ? setting.presetId : ""}`; + +const normalizeZoomFactor = (zoomFactor: number): number => + Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + +export function resolveBrowserDeviceViewportArea(container: { + readonly width: number; + readonly height: number; +}): PreviewViewportSize { + return { + width: Math.max(1, container.width - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE * 2), + height: Math.max( + 1, + container.height - BROWSER_DEVICE_TOOLBAR_HEIGHT - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, + ), + }; +} + +export function resolveBrowserViewportLayout( + container: { readonly width: number; readonly height: number }, + setting: PreviewViewportSetting, + zoomFactor = 1, +): BrowserViewportLayout { + const containerWidth = Math.max(1, Math.round(container.width)); + const containerHeight = Math.max(1, Math.round(container.height)); + if (setting._tag === "fill") { + return { + canvasWidth: containerWidth, + canvasHeight: containerHeight, + viewportX: 0, + viewportY: 0, + viewportWidth: containerWidth, + viewportHeight: containerHeight, + viewportScale: 1, + fillsPanel: true, + }; + } + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); + const renderedWidth = setting.width * normalizedZoomFactor; + const renderedHeight = setting.height * normalizedZoomFactor; + const viewportScale = Math.min( + 1, + containerWidth / renderedWidth, + containerHeight / renderedHeight, + ); + const viewportWidth = renderedWidth * viewportScale; + const viewportHeight = renderedHeight * viewportScale; + return { + canvasWidth: containerWidth, + canvasHeight: containerHeight, + viewportX: Math.max(0, Math.round((containerWidth - viewportWidth) / 2)), + viewportY: Math.max(0, Math.round((containerHeight - viewportHeight) / 2)), + viewportWidth, + viewportHeight, + viewportScale, + fillsPanel: false, + }; +} + +export function resolveBrowserDeviceViewportLayout( + container: { readonly width: number; readonly height: number }, + setting: Exclude, + zoomFactor = 1, +): BrowserViewportLayout { + const layout = resolveBrowserViewportLayout( + resolveBrowserDeviceViewportArea(container), + setting, + zoomFactor, + ); + return { + ...layout, + canvasWidth: Math.max(1, Math.round(container.width)), + canvasHeight: Math.max(1, Math.round(container.height)), + viewportX: layout.viewportX + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, + viewportY: layout.viewportY + BROWSER_DEVICE_TOOLBAR_HEIGHT, + }; +} + +const clampViewportDimension = (value: number): number => + Math.min(PREVIEW_VIEWPORT_MAX_DIMENSION, Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, value)); + +const validAspectRatio = (aspectRatio: number | undefined): aspectRatio is number => + aspectRatio !== undefined && Number.isFinite(aspectRatio) && aspectRatio > 0; + +function resizeAtAspectRatio( + desired: number, + aspectRatio: number, + primaryAxis: "width" | "height", +): PreviewViewportSize { + if (primaryAxis === "width") { + const minimum = Math.ceil( + Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION * aspectRatio), + ); + const maximum = Math.floor( + Math.min( + PREVIEW_VIEWPORT_MAX_DIMENSION, + PREVIEW_VIEWPORT_MAX_DIMENSION * aspectRatio, + Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA * aspectRatio), + ), + ); + let width = Math.min(maximum, Math.max(minimum, Math.round(desired))); + let height = Math.round(width / aspectRatio); + while (width * height > PREVIEW_VIEWPORT_MAX_AREA && width > minimum) { + width -= 1; + height = Math.round(width / aspectRatio); + } + return { width, height }; + } + + const minimum = Math.ceil( + Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION / aspectRatio), + ); + const maximum = Math.floor( + Math.min( + PREVIEW_VIEWPORT_MAX_DIMENSION, + PREVIEW_VIEWPORT_MAX_DIMENSION / aspectRatio, + Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA / aspectRatio), + ), + ); + let height = Math.min(maximum, Math.max(minimum, Math.round(desired))); + let width = Math.round(height * aspectRatio); + while (width * height > PREVIEW_VIEWPORT_MAX_AREA && height > minimum) { + height -= 1; + width = Math.round(height * aspectRatio); + } + return { width, height }; +} + +export function resizeFreeformViewport( + start: PreviewViewportSize, + delta: { readonly x: number; readonly y: number }, + zoomFactor = 1, + direction: BrowserViewportResizeDirection = "southeast", + aspectRatio?: number, +): PreviewViewportSize { + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); + const horizontalDelta = direction.includes("east") + ? delta.x + : direction.includes("west") + ? -delta.x + : 0; + const verticalDelta = direction.includes("south") + ? delta.y + : direction.includes("north") + ? -delta.y + : 0; + const desiredWidth = start.width + horizontalDelta / normalizedZoomFactor; + const desiredHeight = start.height + verticalDelta / normalizedZoomFactor; + if (validAspectRatio(aspectRatio)) { + const controlsWidth = horizontalDelta !== 0 || direction === "east" || direction === "west"; + const controlsHeight = verticalDelta !== 0 || direction === "north" || direction === "south"; + const primaryAxis = + controlsWidth && !controlsHeight + ? "width" + : controlsHeight && !controlsWidth + ? "height" + : Math.abs(desiredWidth - start.width) / start.width >= + Math.abs(desiredHeight - start.height) / start.height + ? "width" + : "height"; + return resizeAtAspectRatio( + primaryAxis === "width" ? desiredWidth : desiredHeight, + aspectRatio, + primaryAxis, + ); + } + let width = clampViewportDimension(Math.round(desiredWidth)); + let height = clampViewportDimension(Math.round(desiredHeight)); + if (width * height <= PREVIEW_VIEWPORT_MAX_AREA) return { width, height }; + if (Math.abs(horizontalDelta) >= Math.abs(verticalDelta)) { + width = Math.max( + PREVIEW_VIEWPORT_MIN_DIMENSION, + Math.floor(PREVIEW_VIEWPORT_MAX_AREA / height), + ); + } else { + height = Math.max( + PREVIEW_VIEWPORT_MIN_DIMENSION, + Math.floor(PREVIEW_VIEWPORT_MAX_AREA / width), + ); + } + return { width, height }; +} + +const resizeFromEndRail = (start: number, pointerDelta: number, available: number): number => { + const startEdge = start < available ? (available + start) / 2 : start; + const targetEdge = startEdge + pointerDelta; + return targetEdge <= available ? targetEdge * 2 - available : targetEdge; +}; + +const resizeFromStartRail = (start: number, pointerDelta: number, available: number): number => { + if (start > available) { + const distanceToFit = start - available; + return pointerDelta <= distanceToFit + ? start - pointerDelta + : available - (pointerDelta - distanceToFit) * 2; + } + const targetEdge = (available - start) / 2 + pointerDelta; + return targetEdge >= 0 ? available - targetEdge * 2 : available - targetEdge; +}; + +export function resizeBrowserViewportFromRail( + start: PreviewViewportSize, + pointerDelta: { readonly x: number; readonly y: number }, + available: PreviewViewportSize, + zoomFactor = 1, + direction: BrowserViewportResizeDirection = "southeast", + aspectRatio?: number, +): PreviewViewportSize { + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); + const startWidth = start.width * normalizedZoomFactor; + const startHeight = start.height * normalizedZoomFactor; + const desiredWidth = direction.includes("east") + ? resizeFromEndRail(startWidth, pointerDelta.x, available.width) + : direction.includes("west") + ? resizeFromStartRail(startWidth, pointerDelta.x, available.width) + : startWidth; + const desiredHeight = direction.includes("south") + ? resizeFromEndRail(startHeight, pointerDelta.y, available.height) + : direction.includes("north") + ? resizeFromStartRail(startHeight, pointerDelta.y, available.height) + : startHeight; + const widthDelta = desiredWidth - startWidth; + const heightDelta = desiredHeight - startHeight; + return resizeFreeformViewport( + start, + { + x: direction.includes("west") ? -widthDelta : widthDelta, + y: direction.includes("north") ? -heightDelta : heightDelta, + }, + normalizedZoomFactor, + direction, + aspectRatio, + ); +} + +export function resolveResponsiveBrowserViewportSize( + container: { readonly width: number; readonly height: number }, + zoomFactor = 1, +): PreviewViewportSize { + const area = resolveBrowserDeviceViewportArea(container); + const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); + return resizeFreeformViewport( + { + width: area.width / normalizedZoomFactor, + height: area.height / normalizedZoomFactor, + }, + { x: 0, y: 0 }, + ); +} diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts new file mode 100644 index 00000000000..4f05f6dd27d --- /dev/null +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -0,0 +1,260 @@ +"use client"; + +import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contracts"; +import { + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; + +import { commitBrowserViewportChange } from "./browserViewportActions"; +import { + browserViewportSettingKey, + resizeBrowserViewportFromRail, + resizeFreeformViewport, + resolveBrowserDeviceViewportArea, + resolveBrowserDeviceViewportLayout, + resolveBrowserViewportLayout, + type BrowserViewportResizeDirection, +} from "./browserViewportLayout"; + +interface ViewportDrag extends PreviewViewportSize { + readonly sourceKey: string; + readonly direction: BrowserViewportResizeDirection; +} + +const KEYBOARD_RESIZE_COMMIT_DELAY_MS = 150; + +export function useBrowserViewportResize(options: { + readonly tabId: string; + readonly viewport: PreviewViewportSetting; + readonly zoomFactor: number; + readonly containerSize: PreviewViewportSize; + readonly deviceToolbarVisible: boolean; + readonly aspectRatio: number | null; +}) { + const { tabId, viewport, zoomFactor, containerSize, deviceToolbarVisible, aspectRatio } = options; + const dragCleanupRef = useRef<(() => void) | null>(null); + const dragVersionRef = useRef(0); + const keyboardCommitTimerRef = useRef | null>(null); + const keyboardViewportRef = useRef(null); + const [dragViewport, setDragViewport] = useState(null); + const sourceViewportKey = browserViewportSettingKey(viewport); + const sourceViewportKeyRef = useRef(sourceViewportKey); + sourceViewportKeyRef.current = sourceViewportKey; + const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null; + const effectiveViewport = activeDrag + ? ({ + _tag: "freeform", + width: activeDrag.width, + height: activeDrag.height, + } as const satisfies PreviewViewportSetting) + : viewport; + const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const viewportContainerSize = deviceToolbarVisible + ? resolveBrowserDeviceViewportArea(containerSize) + : containerSize; + const layout = + deviceToolbarVisible && effectiveViewport._tag !== "fill" + ? resolveBrowserDeviceViewportLayout(containerSize, effectiveViewport, zoomFactor) + : resolveBrowserViewportLayout(containerSize, effectiveViewport, zoomFactor); + + useEffect( + () => () => { + dragVersionRef.current += 1; + dragCleanupRef.current?.(); + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + } + keyboardCommitTimerRef.current = null; + keyboardViewportRef.current = null; + }, + [], + ); + + useEffect(() => { + const pending = keyboardViewportRef.current; + if (!pending || pending.sourceKey === sourceViewportKey) return; + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + keyboardCommitTimerRef.current = null; + } + keyboardViewportRef.current = null; + }, [sourceViewportKey]); + + const commitViewportChange = useCallback( + (next: PreviewViewportSetting) => { + dragVersionRef.current += 1; + dragCleanupRef.current?.(); + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + keyboardCommitTimerRef.current = null; + } + keyboardViewportRef.current = null; + setDragViewport(null); + return commitBrowserViewportChange(tabId, next); + }, + [tabId], + ); + + const clearDrag = () => setDragViewport(null); + const commitDrag = (next: PreviewViewportSetting) => { + const version = ++dragVersionRef.current; + const clearIfCurrent = () => { + if (dragVersionRef.current === version) clearDrag(); + }; + void commitBrowserViewportChange(tabId, next).then(clearIfCurrent, clearIfCurrent); + }; + + const handleResizeKeyDown = ( + direction: BrowserViewportResizeDirection, + event: ReactKeyboardEvent, + ) => { + if (effectiveViewport._tag === "fill") return; + const controlsWidth = direction.includes("east") || direction.includes("west"); + const controlsHeight = direction.includes("north") || direction.includes("south"); + const step = (event.shiftKey ? 50 : 10) * normalizedZoomFactor; + const delta = + event.key === "ArrowLeft" && controlsWidth + ? { x: -step, y: 0 } + : event.key === "ArrowRight" && controlsWidth + ? { x: step, y: 0 } + : event.key === "ArrowUp" && controlsHeight + ? { x: 0, y: -step } + : event.key === "ArrowDown" && controlsHeight + ? { x: 0, y: step } + : null; + if (!delta) return; + event.preventDefault(); + event.stopPropagation(); + const pending = keyboardViewportRef.current; + const base = pending?.sourceKey === sourceViewportKey ? pending : effectiveViewport; + const next = resizeFreeformViewport( + base, + delta, + zoomFactor, + direction, + aspectRatio ?? undefined, + ); + if (next.width === base.width && next.height === base.height) return; + const keyboardViewport = { sourceKey: sourceViewportKey, ...next, direction }; + keyboardViewportRef.current = keyboardViewport; + setDragViewport(keyboardViewport); + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + } + keyboardCommitTimerRef.current = setTimeout(() => { + keyboardCommitTimerRef.current = null; + const latest = keyboardViewportRef.current; + if (!latest || latest.sourceKey !== sourceViewportKeyRef.current) return; + keyboardViewportRef.current = null; + commitDrag({ _tag: "freeform", width: latest.width, height: latest.height }); + }, KEYBOARD_RESIZE_COMMIT_DELAY_MS); + }; + + const handleResizePointerDown = ( + direction: BrowserViewportResizeDirection, + event: ReactPointerEvent, + ) => { + if (effectiveViewport._tag === "fill") return; + event.preventDefault(); + event.stopPropagation(); + if (keyboardCommitTimerRef.current !== null) { + clearTimeout(keyboardCommitTimerRef.current); + keyboardCommitTimerRef.current = null; + } + keyboardViewportRef.current = null; + dragCleanupRef.current?.(); + dragVersionRef.current += 1; + const pointerId = event.pointerId; + const target = event.currentTarget; + const startX = event.clientX; + const startY = event.clientY; + const startWidth = effectiveViewport.width; + const startHeight = effectiveViewport.height; + const dragZoomFactor = normalizedZoomFactor * layout.viewportScale; + let latest = { width: startWidth, height: startHeight }; + setDragViewport({ + sourceKey: sourceViewportKey, + width: startWidth, + height: startHeight, + direction, + }); + try { + target.setPointerCapture(pointerId); + } catch { + // Window listeners below keep the drag functional when capture is unavailable. + } + + const sourceChanged = () => sourceViewportKeyRef.current !== sourceViewportKey; + const move = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + if (sourceChanged()) { + cleanup(); + dragVersionRef.current += 1; + clearDrag(); + return; + } + moveEvent.preventDefault(); + const { width, height } = resizeBrowserViewportFromRail( + { width: startWidth, height: startHeight }, + { + x: moveEvent.clientX - startX, + y: moveEvent.clientY - startY, + }, + viewportContainerSize, + dragZoomFactor, + direction, + aspectRatio ?? undefined, + ); + latest = { width, height }; + setDragViewport({ sourceKey: sourceViewportKey, width, height, direction }); + }; + function cleanup() { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", cancel); + dragCleanupRef.current = null; + try { + target.releasePointerCapture(pointerId); + } catch { + // The browser may already have released capture on pointerup. + } + } + function finish(upEvent: PointerEvent) { + if (upEvent.pointerId !== pointerId) return; + cleanup(); + if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) { + clearDrag(); + return; + } + commitDrag({ + _tag: "freeform", + width: latest.width, + height: latest.height, + }); + } + function cancel(cancelEvent: PointerEvent) { + if (cancelEvent.pointerId !== pointerId) return; + cleanup(); + dragVersionRef.current += 1; + clearDrag(); + } + dragCleanupRef.current = cleanup; + window.addEventListener("pointermove", move, { passive: false }); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", cancel); + }; + + return { + activeDrag, + commitViewportChange, + effectiveViewport, + handleResizeKeyDown, + handleResizePointerDown, + layout, + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9fb8d647b4e..5249ee0219d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -129,7 +129,6 @@ import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; -import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { RightPanelTabs } from "./RightPanelTabs"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; @@ -4704,9 +4703,6 @@ function ChatViewContent(props: ChatViewProps) { return (
    - {isElectron && activeThreadRef ? ( - - ) : null} {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
    state.byTabId[tabId] ?? null); + const content = useBrowserSurfaceStore((state) => state.byTabId[tabId]?.content ?? null); if (!event) return null; @@ -24,6 +26,7 @@ export function AgentBrowserCursor(props: { @@ -32,10 +35,17 @@ export function AgentBrowserCursor(props: { function AgentBrowserCursorEvent(props: { readonly event: DesktopPreviewPointerEvent; + readonly content: { + readonly x: number; + readonly y: number; + readonly scale: number; + readonly scrollLeft: number; + readonly scrollTop: number; + } | null; readonly zoomFactor: number; readonly controller: BrowserController; }) { - const { event, zoomFactor, controller } = props; + const { event, content, zoomFactor, controller } = props; const [active, setActive] = useState(true); useEffect(() => { @@ -48,7 +58,7 @@ function AgentBrowserCursorEvent(props: { className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none" style={{ opacity: agentBrowserCursorOpacity(active, controller), - transform: `translate3d(${event.x * zoomFactor}px, ${event.y * zoomFactor}px, 0)`, + transform: `translate3d(${event.x * zoomFactor * (content?.scale ?? 1) + (content?.x ?? 0) - (content?.scrollLeft ?? 0)}px, ${event.y * zoomFactor * (content?.scale ?? 1) + (content?.y ?? 0) - (content?.scrollTop ?? 0)}px, 0)`, }} aria-hidden="true" data-agent-browser-cursor diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx new file mode 100644 index 00000000000..2cd8494691f --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -0,0 +1,569 @@ +"use client"; + +import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + FILL_PREVIEW_VIEWPORT, + PREVIEW_AUTOMATION_OPERATIONS, + type EnvironmentId, + type PreviewAutomationNavigateInput, + type PreviewAutomationOpenInput, + type PreviewAutomationResizeInput, + type PreviewAutomationResizeResult, + type PreviewAutomationHost as PreviewAutomationHostState, + type PreviewAutomationRequest, + type PreviewAutomationStatus, + type PreviewRenderedViewportSize, + type PreviewViewportSetting, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { Atom } from "effect/unstable/reactivity"; + +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + reconcilePreviewServerSessions, + updatePreviewServerSnapshot, +} from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; +import { + readActiveBrowserRecordingTabId, + startBrowserRecording, + stopBrowserRecording, +} from "~/browser/browserRecording"; +import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; +import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; +import { isElectron } from "~/env"; +import { useEnvironments } from "~/state/environments"; +import { previewEnvironment } from "~/state/preview"; +import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { previewBridge } from "./previewBridge"; +import { + PreviewAutomationNavigationTimeoutError, + PreviewAutomationOperationError, + PreviewAutomationOverlayTimeoutError, + PreviewAutomationRecordingNotActiveError, + PreviewAutomationTargetUnavailableError, + PreviewAutomationViewportTimeoutError, +} from "./previewAutomationErrors"; +import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; +import { createPreviewAutomationClientId } from "./previewAutomationClientId"; +import { + needsPreviewAutomationSessionSync, + resolvePreviewAutomationTarget, +} from "./previewAutomationTarget"; +import { isPreviewViewportReady } from "./previewViewportReadiness"; + +const waitForDesktopOverlay = async ( + threadRef: ScopedThreadRef, + requestId: string, + tabId: string, + timeoutMs: number, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const state = readThreadPreviewState(threadRef); + if (state.desktopByTabId[tabId] && previewBridge) { + const status = await previewBridge.automation.status(tabId); + if (status.available) return; + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + throw new PreviewAutomationOverlayTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + timeoutMs, + }); +}; + +const waitForNavigationReadiness = async ( + threadRef: ScopedThreadRef, + requestId: string, + tabId: string, + readiness: PreviewAutomationNavigateInput["readiness"], + timeoutMs: number, +): Promise => { + const targetReadiness = readiness ?? "load"; + if (!previewBridge || targetReadiness === "none") return; + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (targetReadiness === "domContentLoaded") { + const readyState = await previewBridge.automation.evaluate(tabId, { + expression: "document.readyState", + }); + if (readyState === "interactive" || readyState === "complete") return; + } else { + const status = await previewBridge.automation.status(tabId); + if (!status.loading) return; + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + throw new PreviewAutomationNavigationTimeoutError({ + requestId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId, + readiness: targetReadiness, + timeoutMs, + }); +}; + +interface ExecutablePreviewWebview extends Element { + readonly executeJavaScript: (code: string, userGesture?: boolean) => Promise; +} + +const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null => + Array.from(document.querySelectorAll("webview[data-preview-tab]")).find( + (candidate) => candidate.getAttribute("data-preview-tab") === tabId, + ) ?? null; + +const readWebviewViewport = async ( + webview: ExecutablePreviewWebview, +): Promise => { + const value = await webview.executeJavaScript( + "({ width: window.innerWidth, height: window.innerHeight })", + ); + if (typeof value !== "object" || value === null) return null; + const { width, height } = value as { readonly width?: unknown; readonly height?: unknown }; + return typeof width === "number" && + Number.isInteger(width) && + width > 0 && + typeof height === "number" && + Number.isInteger(height) && + height > 0 + ? { width, height } + : null; +}; + +const readRenderedViewport = async (tabId: string): Promise => { + const webview = findPreviewWebview(tabId); + if (!webview) return null; + return await readWebviewViewport(webview); +}; + +const readDeclaredViewport = ( + webview: ExecutablePreviewWebview | null, +): PreviewRenderedViewportSize | null => { + const width = Number(webview?.getAttribute("data-preview-css-width")); + const height = Number(webview?.getAttribute("data-preview-css-height")); + return Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0 + ? { width, height } + : null; +}; + +const waitForRenderedViewport = async ( + tabId: string, + setting: PreviewViewportSetting, + timeoutMs: number, + context: { + readonly requestId: PreviewAutomationRequest["requestId"]; + readonly environmentId: EnvironmentId; + readonly threadId: PreviewAutomationRequest["threadId"]; + }, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + try { + const webview = findPreviewWebview(tabId); + const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; + const declaredViewport = readDeclaredViewport(webview); + const renderedViewport = webview ? await readWebviewViewport(webview) : null; + if ( + renderedViewport && + isPreviewViewportReady({ + setting, + appliedSettingKey, + declaredViewport, + renderedViewport, + }) + ) { + return renderedViewport; + } + } catch { + // Registration and navigation can transiently replace the guest while + // React applies the server snapshot. Retry until the operation deadline. + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + throw new PreviewAutomationViewportTimeoutError({ + ...context, + tabId, + timeoutMs, + }); +}; + +const currentStatus = async ( + threadRef: ScopedThreadRef, + requestedTabId: string | null, +): Promise => { + const state = readThreadPreviewState(threadRef); + const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId); + const visible = tabId + ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false) + : false; + const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined; + const viewport = tabId ? await readRenderedViewport(tabId).catch(() => null) : null; + const viewportStatus = { + ...(viewportSetting === undefined ? {} : { viewportSetting }), + ...(viewport === null ? {} : { viewport }), + }; + if (tabId && previewBridge && state.desktopByTabId[tabId]) { + const status = await previewBridge.automation.status(tabId); + return { ...status, visible, ...viewportStatus }; + } + const navStatus = snapshot?.navStatus; + return { + available: Boolean(previewBridge?.automation), + visible, + tabId, + url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, + title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, + loading: navStatus?._tag === "Loading", + ...viewportStatus, + }; +}; + +export function PreviewAutomationHosts() { + const { environments } = useEnvironments(); + if (!isElectron || !previewBridge?.automation) return null; + return ( + <> + {/* + * Host lifetime follows the desktop runtime's environment connections, + * not the routed thread. This keeps background threads automatable and + * lets the subscription runtime own reconnects for every saved target. + */} + {environments.map((environment) => ( + + ))} + + ); +} + +function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { + const { environmentId } = props; + const registry = useContext(RegistryContext); + const [automationClientId] = useState(createPreviewAutomationClientId); + const initialAutomationHost = useMemo( + () => ({ + clientId: automationClientId, + environmentId, + supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS], + }), + [automationClientId, environmentId], + ); + const automationRequestsAtom = previewEnvironment.automationRequests({ + environmentId, + input: initialAutomationHost, + }); + const listPreviews = useAtomQueryRunner(previewEnvironment.list, { + reportFailure: false, + }); + const open = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); + const resize = useAtomCommand(previewEnvironment.resize, { + reportFailure: false, + }); + const respondToAutomation = useAtomCommand( + previewEnvironment.respondToAutomation, + "preview automation response", + ); + const focusAutomationHost = useAtomCommand( + previewEnvironment.focusAutomationHost, + "preview automation host focus", + ); + const [automationConnectionAtom] = useState(() => Atom.make(null)); + const automationConnectionId = useAtomValue(automationConnectionAtom); + + const handleRequest = useCallback( + async (request: PreviewAutomationRequest): Promise => { + const threadRef: ScopedThreadRef = { + environmentId, + threadId: request.threadId, + }; + let tabId = request.tabId ?? null; + try { + let state = readThreadPreviewState(threadRef); + const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId); + if (needsSessionSync) { + const listTarget = { + environmentId, + input: { threadId: request.threadId }, + } as const; + registry.refresh(previewEnvironment.list(listTarget)); + const result = await listPreviews(listTarget); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + reconcilePreviewServerSessions(threadRef, result.value.sessions); + state = readThreadPreviewState(threadRef); + } + tabId = request.tabId ?? state.snapshot?.tabId ?? null; + const unavailableTarget = { + requestId: request.requestId, + operation: request.operation, + environmentId, + threadId: request.threadId, + tabId, + bridgeAvailable: Boolean(previewBridge), + }; + const requireReadyTab = async () => { + const bridge = previewBridge; + const readyTabId = tabId; + if (!bridge || !readyTabId) { + throw new PreviewAutomationTargetUnavailableError(unavailableTarget); + } + await waitForDesktopOverlay(threadRef, request.requestId, readyTabId, request.timeoutMs); + return { bridge, tabId: readyTabId }; + }; + switch (request.operation) { + case "status": + return await currentStatus(threadRef, tabId); + case "open": { + const input = request.input as PreviewAutomationOpenInput; + let activeTabId = + (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + const reusedExistingTab = activeTabId !== null; + tabId = activeTabId; + if (!activeTabId) { + const result = await open({ + environmentId, + input: { + threadId: request.threadId, + ...(input.url ? { url: input.url } : {}), + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + const snapshot = result.value; + applyPreviewServerSnapshot(threadRef, snapshot); + activeTabId = snapshot.tabId; + tabId = activeTabId; + } + if (input.show ?? true) { + useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + } + await waitForDesktopOverlay( + threadRef, + request.requestId, + activeTabId, + request.timeoutMs, + ); + if (reusedExistingTab && input.url && previewBridge) { + const resolution = resolveBrowserNavigationTarget(environmentId, { + kind: "url", + url: input.url, + }); + await previewBridge.navigate(activeTabId, resolution.resolvedUrl); + await waitForNavigationReadiness( + threadRef, + request.requestId, + activeTabId, + "load", + request.timeoutMs, + ); + } + return await currentStatus(threadRef, activeTabId); + } + case "navigate": { + const ready = await requireReadyTab(); + const input = request.input as PreviewAutomationNavigateInput; + const resolution = resolveBrowserNavigationTarget( + environmentId, + input.target ?? { + kind: "url", + url: input.url!, + }, + ); + await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl); + await waitForNavigationReadiness( + threadRef, + request.requestId, + ready.tabId, + input.readiness ?? "load", + input.timeoutMs ?? request.timeoutMs, + ); + return await currentStatus(threadRef, ready.tabId); + } + case "resize": { + const ready = await requireReadyTab(); + const input = request.input as PreviewAutomationResizeInput; + const setting = resolvePreviewViewport(input); + const result = await resize({ + environmentId, + input: { + threadId: request.threadId, + tabId: ready.tabId, + viewport: setting, + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + updatePreviewServerSnapshot(threadRef, result.value); + const viewport = await waitForRenderedViewport( + ready.tabId, + setting, + input.timeoutMs ?? request.timeoutMs, + { + requestId: request.requestId, + environmentId, + threadId: request.threadId, + }, + ); + return { + tabId: ready.tabId, + setting, + viewport, + } satisfies PreviewAutomationResizeResult; + } + case "snapshot": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.snapshot(ready.tabId); + } + case "click": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.click( + ready.tabId, + request.input as Parameters[1], + ); + } + case "type": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.type( + ready.tabId, + request.input as Parameters[1], + ); + } + case "press": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.press( + ready.tabId, + request.input as Parameters[1], + ); + } + case "scroll": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.scroll( + ready.tabId, + request.input as Parameters[1], + ); + } + case "evaluate": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.evaluate( + ready.tabId, + request.input as Parameters[1], + ); + } + case "waitFor": { + const ready = await requireReadyTab(); + return await ready.bridge.automation.waitFor( + ready.tabId, + request.input as Parameters[1], + ); + } + case "recordingStart": { + const ready = await requireReadyTab(); + const startedAt = await startBrowserRecording(ready.tabId); + return { + tabId: ready.tabId, + recording: true, + startedAt, + }; + } + case "recordingStop": { + const recordingTabId = readActiveBrowserRecordingTabId(); + const stopTabId = resolveBrowserRecordingStopTarget(recordingTabId); + const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; + if (!artifact) { + throw new PreviewAutomationRecordingNotActiveError({ + requestId: request.requestId, + environmentId, + threadId: request.threadId, + tabId, + }); + } + return artifact; + } + } + } catch (cause) { + throw PreviewAutomationOperationError.fromCause({ + requestId: request.requestId, + operation: request.operation, + environmentId, + threadId: request.threadId, + tabId, + cause, + }); + } + }, + [environmentId, listPreviews, open, registry, resize], + ); + const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest })); + const setRequestHandler = useAtomSet(requestHandlerAtom); + useEffect(() => { + setRequestHandler({ handle: handleRequest }); + }, [handleRequest, setRequestHandler]); + + const automationRequestConsumerAtom = useMemo( + () => + createPreviewAutomationRequestConsumerAtom({ + requestsAtom: automationRequestsAtom, + clientId: automationClientId, + connectionAtom: automationConnectionAtom, + environmentId, + requestHandlerAtom, + respond: (response) => + respondToAutomation({ + environmentId, + input: response, + }), + label: `preview:automation-host:${environmentId}:${automationClientId}`, + }), + [ + automationClientId, + automationConnectionAtom, + automationRequestsAtom, + requestHandlerAtom, + respondToAutomation, + environmentId, + ], + ); + useAtomValue(automationRequestConsumerAtom); + + useEffect(() => { + const report = () => { + if (!automationConnectionId) return; + void focusAutomationHost({ + environmentId, + input: { + clientId: automationClientId, + environmentId, + connectionId: automationConnectionId, + focused: document.hasFocus(), + }, + }); + }; + report(); + window.addEventListener("focus", report); + window.addEventListener("blur", report); + return () => { + window.removeEventListener("focus", report); + window.removeEventListener("blur", report); + }; + }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]); + + return null; +} diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts b/apps/web/src/components/preview/PreviewAutomationOwner.test.ts deleted file mode 100644 index fe11ea75aa6..00000000000 --- a/apps/web/src/components/preview/PreviewAutomationOwner.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { observeAutomationOwnerConnectedGeneration } from "./PreviewAutomationOwner"; - -describe("observeAutomationOwnerConnectedGeneration", () => { - it("reports ownership when the initial transport generation connects", () => { - const initial = observeAutomationOwnerConnectedGeneration(null, 1); - expect(initial).toEqual({ - nextGeneration: 1, - shouldReport: true, - }); - - const disconnected = observeAutomationOwnerConnectedGeneration(initial.nextGeneration, null); - expect(disconnected).toEqual({ - nextGeneration: 1, - shouldReport: false, - }); - - expect(observeAutomationOwnerConnectedGeneration(disconnected.nextGeneration, 2)).toEqual({ - nextGeneration: 2, - shouldReport: true, - }); - }); - - it("does not re-report for repeated connected state from the same generation", () => { - expect(observeAutomationOwnerConnectedGeneration(3, 3)).toEqual({ - nextGeneration: 3, - shouldReport: false, - }); - }); -}); diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx deleted file mode 100644 index 2be14363624..00000000000 --- a/apps/web/src/components/preview/PreviewAutomationOwner.tsx +++ /dev/null @@ -1,426 +0,0 @@ -"use client"; - -import { useAtomValue } from "@effect/atom-react"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { - type PreviewAutomationNavigateInput, - type PreviewAutomationOpenInput, - type PreviewAutomationOwner as PreviewAutomationOwnerState, - type PreviewAutomationRequest, - type PreviewAutomationStatus, - type ScopedThreadRef, -} from "@t3tools/contracts"; -import { useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react"; - -import { - applyPreviewServerSnapshot, - readThreadPreviewState, - subscribeThreadPreviewState, -} from "~/previewStateStore"; -import { useRightPanelStore } from "~/rightPanelStore"; -import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; -import { startBrowserRecording, stopBrowserRecording } from "~/browser/browserRecording"; -import { previewEnvironment } from "~/state/preview"; -import { useEnvironmentConnectionState } from "~/state/environments"; -import { useAtomCommand } from "~/state/use-atom-command"; - -import { previewBridge } from "./previewBridge"; -import { - PreviewAutomationNavigationTimeoutError, - PreviewAutomationOperationError, - PreviewAutomationOverlayTimeoutError, - PreviewAutomationRecordingNotActiveError, - PreviewAutomationStaleOwnerError, - PreviewAutomationTargetUnavailableError, -} from "./previewAutomationErrors"; -import { - createLatestPreviewAutomationRequestHandler, - createPreviewAutomationRequestConsumerAtom, -} from "./previewAutomationRequestConsumer"; - -export function observeAutomationOwnerConnectedGeneration( - previousGeneration: number | null, - connectedGeneration: number | null, -): { - readonly nextGeneration: number | null; - readonly shouldReport: boolean; -} { - if (connectedGeneration === null) { - return { - nextGeneration: previousGeneration, - shouldReport: false, - }; - } - return { - nextGeneration: connectedGeneration, - shouldReport: previousGeneration !== connectedGeneration, - }; -} - -const waitForDesktopOverlay = async ( - threadRef: ScopedThreadRef, - requestId: string, - timeoutMs: number, -): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - const state = readThreadPreviewState(threadRef); - const tabId = state.snapshot?.tabId; - if (tabId && state.desktopOverlay && previewBridge) { - const status = await previewBridge.automation.status(tabId); - if (status.available) return; - } - await new Promise((resolve) => window.setTimeout(resolve, 50)); - } - throw new PreviewAutomationOverlayTimeoutError({ - requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - timeoutMs, - }); -}; - -const waitForNavigationReadiness = async ( - threadRef: ScopedThreadRef, - requestId: string, - tabId: string, - readiness: PreviewAutomationNavigateInput["readiness"], - timeoutMs: number, -): Promise => { - const targetReadiness = readiness ?? "load"; - if (!previewBridge || targetReadiness === "none") return; - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - if (targetReadiness === "domContentLoaded") { - const readyState = await previewBridge.automation.evaluate(tabId, { - expression: "document.readyState", - }); - if (readyState === "interactive" || readyState === "complete") return; - } else { - const status = await previewBridge.automation.status(tabId); - if (!status.loading) return; - } - await new Promise((resolve) => window.setTimeout(resolve, 50)); - } - throw new PreviewAutomationNavigationTimeoutError({ - requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - readiness: targetReadiness, - timeoutMs, - }); -}; - -const currentStatus = async ( - threadRef: ScopedThreadRef, - visible: boolean, -): Promise => { - const state = readThreadPreviewState(threadRef); - const tabId = state.snapshot?.tabId ?? null; - if (tabId && previewBridge && state.desktopOverlay) { - const status = await previewBridge.automation.status(tabId); - return { ...status, visible }; - } - const navStatus = state.snapshot?.navStatus; - return { - available: Boolean(previewBridge?.automation), - visible, - tabId, - url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, - title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, - loading: navStatus?._tag === "Loading", - }; -}; - -export function PreviewAutomationOwner(props: { - readonly threadRef: ScopedThreadRef; - readonly visible: boolean; -}) { - const { threadRef, visible } = props; - const automationClientId = useId(); - const initialAutomationOwner = useMemo( - () => ({ - clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId: null, - visible: false, - supportsAutomation: Boolean(previewBridge?.automation), - focusedAt: new Date().toISOString(), - }), - [automationClientId, threadRef.environmentId, threadRef.threadId], - ); - const automationRequestsAtom = previewEnvironment.automationRequests({ - environmentId: threadRef.environmentId, - input: initialAutomationOwner, - }); - const connectionState = useEnvironmentConnectionState(threadRef.environmentId).data; - const connectedGeneration = - connectionState?.phase === "connected" ? connectionState.generation : null; - const open = useAtomCommand(previewEnvironment.open, { - reportFailure: false, - }); - const respondToAutomation = useAtomCommand( - previewEnvironment.respondToAutomation, - "preview automation response", - ); - const reportAutomationOwner = useAtomCommand( - previewEnvironment.reportAutomationOwner, - "preview automation owner report", - ); - const clearAutomationOwner = useAtomCommand( - previewEnvironment.clearAutomationOwner, - "preview automation owner clear", - ); - const connectedGenerationRef = useRef(null); - const reportCurrentAutomationOwner = useEffectEvent(() => { - const state = readThreadPreviewState(threadRef); - return reportAutomationOwner({ - environmentId: threadRef.environmentId, - input: { - clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId: state.snapshot?.tabId ?? null, - visible, - supportsAutomation: Boolean(previewBridge?.automation), - focusedAt: new Date().toISOString(), - }, - }); - }); - useEffect(() => { - void reportCurrentAutomationOwner(); - }, [threadRef, visible]); - - const handleRequest = useCallback( - async (request: PreviewAutomationRequest): Promise => { - let tabId = request.tabId ?? null; - try { - if (request.threadId !== threadRef.threadId) { - throw new PreviewAutomationStaleOwnerError({ - requestId: request.requestId, - environmentId: threadRef.environmentId, - expectedThreadId: threadRef.threadId, - requestedThreadId: request.threadId, - }); - } - const state = readThreadPreviewState(threadRef); - tabId = request.tabId ?? state.snapshot?.tabId ?? null; - const unavailableTarget = { - requestId: request.requestId, - operation: request.operation, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - bridgeAvailable: Boolean(previewBridge), - }; - switch (request.operation) { - case "status": - return await currentStatus(threadRef, visible); - case "open": { - const input = request.input as PreviewAutomationOpenInput; - let activeTabId = - (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; - tabId = activeTabId; - if (!activeTabId) { - const result = await open({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - ...(input.url ? { url: input.url } : {}), - }, - }); - if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); - } - const snapshot = result.value; - applyPreviewServerSnapshot(threadRef, snapshot); - activeTabId = snapshot.tabId; - tabId = activeTabId; - } else if (input.url && previewBridge) { - await previewBridge.navigate(activeTabId, input.url); - } - if (input.show ?? true) { - useRightPanelStore.getState().openBrowser(threadRef, activeTabId); - } - await waitForDesktopOverlay(threadRef, request.requestId, request.timeoutMs); - return await currentStatus(threadRef, input.show ?? true); - } - case "navigate": { - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( - threadRef.environmentId, - input.target ?? { kind: "url", url: input.url! }, - ); - await previewBridge.navigate(tabId, resolution.resolvedUrl); - await waitForNavigationReadiness( - threadRef, - request.requestId, - tabId, - input.readiness ?? "load", - input.timeoutMs ?? request.timeoutMs, - ); - return await currentStatus(threadRef, visible); - } - case "snapshot": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.snapshot(tabId); - case "click": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.click( - tabId, - request.input as Parameters[1], - ); - case "type": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.type( - tabId, - request.input as Parameters[1], - ); - case "press": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.press( - tabId, - request.input as Parameters[1], - ); - case "scroll": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.scroll( - tabId, - request.input as Parameters[1], - ); - case "evaluate": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.evaluate( - tabId, - request.input as Parameters[1], - ); - case "waitFor": - if (!previewBridge || !tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - return await previewBridge.automation.waitFor( - tabId, - request.input as Parameters[1], - ); - case "recordingStart": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const startedAt = await startBrowserRecording(tabId); - return { - tabId, - recording: true, - startedAt, - }; - } - case "recordingStop": { - if (!tabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - const artifact = await stopBrowserRecording(tabId); - if (!artifact) { - throw new PreviewAutomationRecordingNotActiveError({ - requestId: request.requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - }); - } - return artifact; - } - } - } catch (cause) { - throw PreviewAutomationOperationError.fromCause({ - requestId: request.requestId, - operation: request.operation, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - cause, - }); - } - }, - [open, threadRef, visible], - ); - const [requestHandler] = useState(() => - createLatestPreviewAutomationRequestHandler(handleRequest), - ); - useEffect(() => { - requestHandler.set(handleRequest); - }, [handleRequest, requestHandler]); - - const automationRequestConsumerAtom = useMemo( - () => - createPreviewAutomationRequestConsumerAtom({ - requestsAtom: automationRequestsAtom, - environmentId: threadRef.environmentId, - handleRequest: requestHandler.handle, - respond: (response) => - respondToAutomation({ - environmentId: threadRef.environmentId, - input: response, - }), - label: `preview:automation-request-consumer:${automationClientId}`, - }), - [ - automationClientId, - automationRequestsAtom, - requestHandler, - respondToAutomation, - threadRef.environmentId, - ], - ); - useAtomValue(automationRequestConsumerAtom); - - useEffect(() => { - const observation = observeAutomationOwnerConnectedGeneration( - connectedGenerationRef.current, - connectedGeneration, - ); - connectedGenerationRef.current = observation.nextGeneration; - if (!observation.shouldReport) return; - - void reportCurrentAutomationOwner(); - }, [connectedGeneration]); - - useEffect(() => { - const report = () => void reportCurrentAutomationOwner(); - window.addEventListener("focus", report); - const unsubscribe = subscribeThreadPreviewState(threadRef, (state, previous) => { - if (state.snapshot?.tabId !== previous.snapshot?.tabId) { - report(); - } - }); - return () => { - window.removeEventListener("focus", report); - unsubscribe(); - void clearAutomationOwner({ - environmentId: threadRef.environmentId, - input: { - clientId: automationClientId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - }, - }); - }; - }, [automationClientId, clearAutomationOwner, threadRef]); - - return null; -} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index f11ff4d2d30..13ddcf57e9e 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -19,6 +19,10 @@ interface Props { hasWebContents: boolean; /** Current zoom factor as a number (1.0 = 100%). */ zoomFactor: number; + /** Fixed viewport modes expose the device toolbar and resize rails. */ + deviceToolbarVisible: boolean; + /** Switches between fill-panel mode and a fixed responsive viewport. */ + onToggleDeviceToolbar: () => void; } /** @@ -26,7 +30,13 @@ interface Props { * controls, and storage-clearing actions. Only mounted by `PreviewView` * when the desktop bridge is present, so we can call it unconditionally. */ -export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { +export function PreviewMoreMenu({ + tabId, + hasWebContents, + zoomFactor, + deviceToolbarVisible, + onToggleDeviceToolbar, +}: Props) { if (!previewBridge) return null; const bridge = previewBridge; const tabDisabled = !tabId || !hasWebContents; @@ -59,6 +69,10 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { Open DevTools + + {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} + + {/* Zoom row: label + inline control cluster. `closeOnClick=false` keeps the menu open while the user clicks the +/− buttons. diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 861a8df616b..bb1c4d409eb 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,13 +1,22 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { type ScopedThreadRef } from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + FILL_PREVIEW_VIEWPORT, + type PreviewViewportSetting, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; -import { rememberPreviewUrl, useThreadPreviewState } from "~/previewStateStore"; +import { + rememberPreviewUrl, + updatePreviewServerSnapshot, + useThreadPreviewState, +} from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; @@ -20,10 +29,16 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; +import { + commitBrowserViewportChange, + subscribeBrowserViewportChange, +} from "~/browser/browserViewportActions"; +import { resolveResponsiveBrowserViewportSize } from "~/browser/browserViewportLayout"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; +import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { useLoadingProgress } from "./useLoadingProgress"; import { usePreviewSession } from "./usePreviewSession"; import { ZoomIndicator } from "./ZoomIndicator"; @@ -60,6 +75,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const environment = useEnvironment(threadRef.environmentId); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); const open = useAtomCommand(previewEnvironment.open); + const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); usePreviewSession(threadRef); @@ -91,6 +107,10 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, environmentHttpBaseUrl, }) ?? undefined) : undefined; + const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; + const panelRect = useBrowserSurfaceStore((state) => + tabId ? (state.byTabId[tabId]?.rect ?? null) : null, + ); const handleSubmitUrl = useCallback( async (next: string) => { @@ -131,6 +151,51 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, if (previewBridge && tabId) void previewBridge.resetZoom(tabId); }, [tabId]); + const handleViewportChange = useCallback( + async (nextViewport: PreviewViewportSetting) => { + if (!tabId) return; + const result = await resize({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + tabId, + viewport: nextViewport, + }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Unable to resize browser viewport", + description: error instanceof Error ? error.message : "An error occurred.", + }); + throw error; + } + updatePreviewServerSnapshot(threadRef, result.value); + }, + [resize, tabId, threadRef], + ); + + const handleToggleDeviceToolbar = () => { + if (!tabId) return; + if (viewport._tag !== "fill") { + void commitBrowserViewportChange(tabId, FILL_PREVIEW_VIEWPORT).catch(() => undefined); + return; + } + + const responsiveSize = panelRect + ? resolveResponsiveBrowserViewportSize(panelRect, desktopOverlay?.zoomFactor) + : { width: 1024, height: 768 }; + void commitBrowserViewportChange(tabId, { _tag: "freeform", ...responsiveSize }).catch( + () => undefined, + ); + }; + + useEffect(() => { + if (!tabId) return; + return subscribeBrowserViewportChange(tabId, handleViewportChange); + }, [handleViewportChange, tabId]); + const handleBack = useCallback(() => { if (previewBridge && tabId) void previewBridge.goBack(tabId); }, [tabId]); @@ -527,6 +592,8 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, tabId={tabId} hasWebContents={desktopOverlay !== null} zoomFactor={desktopOverlay?.zoomFactor ?? 1} + deviceToolbarVisible={viewport._tag !== "fill"} + onToggleDeviceToolbar={handleToggleDeviceToolbar} /> ) : null } diff --git a/apps/web/src/components/preview/previewAutomationClientId.test.ts b/apps/web/src/components/preview/previewAutomationClientId.test.ts new file mode 100644 index 00000000000..f51732d91a4 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationClientId.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { createPreviewAutomationClientId } from "./previewAutomationClientId"; + +describe("createPreviewAutomationClientId", () => { + it("creates bounded cryptographically random identities for independent host lifetimes", () => { + const clientIds = Array.from({ length: 32 }, createPreviewAutomationClientId); + + expect(new Set(clientIds).size).toBe(clientIds.length); + expect(clientIds.every((clientId) => clientId.startsWith("preview-"))).toBe(true); + expect(clientIds.every((clientId) => clientId.length <= 128)).toBe(true); + }); +}); diff --git a/apps/web/src/components/preview/previewAutomationClientId.ts b/apps/web/src/components/preview/previewAutomationClientId.ts new file mode 100644 index 00000000000..2d243de3031 --- /dev/null +++ b/apps/web/src/components/preview/previewAutomationClientId.ts @@ -0,0 +1,4 @@ +export function createPreviewAutomationClientId(): string { + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + return `preview-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index c4ca445458c..dcf35de53f2 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -1,6 +1,6 @@ import { EnvironmentId, - type PreviewAutomationOwner, + type PreviewAutomationHost, PreviewAutomationOperation, type PreviewAutomationRequest, type PreviewAutomationResponse, @@ -13,7 +13,7 @@ import * as Schema from "effect/Schema"; export interface PreviewAutomationOperationContext { readonly requestId: PreviewAutomationRequest["requestId"]; readonly operation: PreviewAutomationRequest["operation"]; - readonly environmentId: PreviewAutomationOwner["environmentId"]; + readonly environmentId: PreviewAutomationHost["environmentId"]; readonly threadId: PreviewAutomationRequest["threadId"]; readonly tabId: Exclude | null; } @@ -56,21 +56,22 @@ export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorC } } -export class PreviewAutomationStaleOwnerError extends Schema.TaggedErrorClass()( - "PreviewAutomationStaleOwnerError", +export class PreviewAutomationViewportTimeoutError extends Schema.TaggedErrorClass()( + "PreviewAutomationViewportTimeoutError", { requestId: TrimmedNonEmptyString, environmentId: EnvironmentId, - expectedThreadId: ThreadId, - requestedThreadId: ThreadId, + threadId: ThreadId, + tabId: PreviewTabId, + timeoutMs: Schema.Int, }, ) { get responseTag() { - return "PreviewAutomationUnavailableError" as const; + return "PreviewAutomationTimeoutError" as const; } override get message(): string { - return `Preview automation request ${this.requestId} targeted thread ${this.requestedThreadId}, but the owner for environment ${this.environmentId} is attached to thread ${this.expectedThreadId}.`; + return `Preview viewport for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} was not rendered within ${this.timeoutMs}ms.`; } } @@ -100,7 +101,7 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError requestId: TrimmedNonEmptyString, environmentId: EnvironmentId, threadId: ThreadId, - tabId: PreviewTabId, + tabId: Schema.NullOr(PreviewTabId), }, ) { get responseTag() { @@ -108,10 +109,65 @@ export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedError } override get message(): string { - return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId} on environment ${this.environmentId} thread ${this.threadId}.`; + return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId ?? "unassigned"} on environment ${this.environmentId} thread ${this.threadId}.`; } } +export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedErrorClass()( + "PreviewAutomationTargetNotEditableHostError", + { + requestId: TrimmedNonEmptyString, + operation: PreviewAutomationOperation, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])), + selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + }, +) { + get responseTag() { + return "PreviewAutomationTargetNotEditableError" as const; + } + + override get message(): string { + return `Preview automation ${this.operation} request ${this.requestId} requires an editable target in tab ${this.tabId ?? "unassigned"}.`; + } +} + +const targetNotEditableDiagnostics = ( + cause: unknown, +): { + readonly selectorKind?: "focused-element" | "locator" | "selector"; + readonly selectorLength?: number; +} | null => { + if ( + typeof cause !== "object" || + cause === null || + !("_tag" in cause) || + cause._tag !== "PreviewAutomationTargetNotEditableError" + ) { + return null; + } + const selectorKind = + "selectorKind" in cause && + (cause.selectorKind === "focused-element" || + cause.selectorKind === "locator" || + cause.selectorKind === "selector") + ? cause.selectorKind + : undefined; + const selectorLength = + "selectorLength" in cause && + typeof cause.selectorLength === "number" && + Number.isInteger(cause.selectorLength) && + cause.selectorLength >= 0 + ? cause.selectorLength + : undefined; + return { + ...(selectorKind === undefined ? {} : { selectorKind }), + ...(selectorLength === undefined ? {} : { selectorLength }), + }; +}; + export class PreviewAutomationOperationError extends Schema.TaggedErrorClass()( "PreviewAutomationOperationError", { @@ -125,9 +181,18 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass
     {
       const detail = Object.fromEntries(
         Object.entries(error).filter(
    diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts
    index 905a014d5af..af3a95c32c7 100644
    --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts
    +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts
    @@ -2,13 +2,18 @@ import {
       EnvironmentId,
       type PreviewAutomationRequest,
       type PreviewAutomationResponse,
    +  type PreviewAutomationStreamEvent,
       PreviewTabId,
       ThreadId,
     } from "@t3tools/contracts";
     import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
     import { describe, expect, it, vi } from "vite-plus/test";
     
    -import { PreviewAutomationTargetUnavailableError } from "./previewAutomationErrors";
    +import {
    +  PreviewAutomationRecordingNotActiveError,
    +  PreviewAutomationTargetUnavailableError,
    +  PreviewAutomationViewportTimeoutError,
    +} from "./previewAutomationErrors";
     import {
       createPreviewAutomationRequestConsumerAtom,
       serializePreviewAutomationError,
    @@ -17,6 +22,8 @@ import {
     const environmentId = EnvironmentId.make("environment-1");
     const threadId = ThreadId.make("thread-1");
     const tabId = PreviewTabId.make("tab-1");
    +const clientId = "client-1";
    +const connectionId = "connection-1";
     
     const request = (
       requestId: string,
    @@ -30,10 +37,88 @@ const request = (
       ...overrides,
     });
     
    +const requestEvent = (
    +  requestId: string,
    +  overrides: Partial = {},
    +  eventConnectionId = connectionId,
    +): PreviewAutomationStreamEvent => ({
    +  type: "request",
    +  connectionId: eventConnectionId,
    +  request: request(requestId, overrides),
    +});
    +
    +const consumerState = (handleRequest: (request: PreviewAutomationRequest) => Promise) => ({
    +  connectionAtom: Atom.make(null),
    +  requestHandlerAtom: Atom.make({ handle: handleRequest }),
    +});
    +
     describe("previewAutomationRequestConsumer", () => {
    +  it("acknowledges a replacement stream before consuming requests from it", async () => {
    +    const requestsAtom = Atom.make(
    +      AsyncResult.success({
    +        type: "connected",
    +        connectionId,
    +      }),
    +    );
    +    const handleRequest = vi.fn(async () => undefined);
    +    const respond = vi.fn(async () => undefined);
    +    const state = consumerState(handleRequest);
    +    const consumerAtom = createPreviewAutomationRequestConsumerAtom({
    +      requestsAtom,
    +      clientId,
    +      connectionAtom: state.connectionAtom,
    +      environmentId,
    +      requestHandlerAtom: state.requestHandlerAtom,
    +      respond,
    +      label: "test:preview-automation-connected",
    +    });
    +    const registry = AtomRegistry.make();
    +
    +    registry.mount(consumerAtom);
    +    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-after-connect")));
    +
    +    await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe(connectionId));
    +    await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
    +    expect(handleRequest).toHaveBeenCalledTimes(1);
    +    registry.dispose();
    +  });
    +
    +  it("drops late requests from an older stream generation", async () => {
    +    const requestsAtom = Atom.make(
    +      AsyncResult.success({
    +        type: "connected",
    +        connectionId: "connection-2",
    +      }),
    +    );
    +    const handleRequest = vi.fn(async () => undefined);
    +    const respond = vi.fn(async () => undefined);
    +    const state = consumerState(handleRequest);
    +    const consumerAtom = createPreviewAutomationRequestConsumerAtom({
    +      requestsAtom,
    +      clientId,
    +      connectionAtom: state.connectionAtom,
    +      environmentId,
    +      requestHandlerAtom: state.requestHandlerAtom,
    +      respond,
    +      label: "test:preview-automation-stale-generation",
    +    });
    +    const registry = AtomRegistry.make();
    +
    +    registry.mount(consumerAtom);
    +    registry.set(
    +      requestsAtom,
    +      AsyncResult.success(requestEvent("request-stale", {}, "connection-1")),
    +    );
    +
    +    await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe("connection-2"));
    +    expect(handleRequest).not.toHaveBeenCalled();
    +    expect(respond).not.toHaveBeenCalled();
    +    registry.dispose();
    +  });
    +
       it("consumes every request emitted before React can render", async () => {
    -    const requestsAtom = Atom.make>(
    -      AsyncResult.initial(false),
    +    const requestsAtom = Atom.make>(
    +      AsyncResult.initial(false),
         );
         const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({
           requestId: value.requestId,
    @@ -42,18 +127,21 @@ describe("previewAutomationRequestConsumer", () => {
         const respond = vi.fn(async (response: PreviewAutomationResponse) => {
           responses.push(response);
         });
    +    const state = consumerState(handleRequest);
         const consumerAtom = createPreviewAutomationRequestConsumerAtom({
           requestsAtom,
    +      clientId,
    +      connectionAtom: state.connectionAtom,
           environmentId,
    -      handleRequest,
    +      requestHandlerAtom: state.requestHandlerAtom,
           respond,
           label: "test:preview-automation-consumer",
         });
         const registry = AtomRegistry.make();
         registry.mount(consumerAtom);
     
    -    registry.set(requestsAtom, AsyncResult.success(request("request-1")));
    -    registry.set(requestsAtom, AsyncResult.success(request("request-2")));
    +    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-1")));
    +    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-2")));
     
         await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
         expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([
    @@ -64,15 +152,50 @@ describe("previewAutomationRequestConsumer", () => {
         registry.dispose();
       });
     
    +  it("uses the latest request handler without rebuilding the stream consumer", async () => {
    +    const requestsAtom = Atom.make>(
    +      AsyncResult.initial(false),
    +    );
    +    const firstHandler = vi.fn(async () => "first");
    +    const secondHandler = vi.fn(async () => "second");
    +    const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
    +    const state = consumerState(firstHandler);
    +    const consumerAtom = createPreviewAutomationRequestConsumerAtom({
    +      requestsAtom,
    +      clientId,
    +      connectionAtom: state.connectionAtom,
    +      environmentId,
    +      requestHandlerAtom: state.requestHandlerAtom,
    +      respond,
    +      label: "test:preview-automation-latest-handler",
    +    });
    +    const registry = AtomRegistry.make();
    +    registry.mount(consumerAtom);
    +
    +    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-first")));
    +    await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
    +    registry.set(state.requestHandlerAtom, { handle: secondHandler });
    +    registry.set(requestsAtom, AsyncResult.success(requestEvent("request-second")));
    +
    +    await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2));
    +    expect(firstHandler).toHaveBeenCalledTimes(1);
    +    expect(secondHandler).toHaveBeenCalledTimes(1);
    +    expect(respond.mock.calls.map(([response]) => response.result)).toEqual(["first", "second"]);
    +    registry.dispose();
    +  });
    +
       it("consumes a request that arrived immediately before the consumer mounted", async () => {
         const requestsAtom = Atom.make(
    -      AsyncResult.success(request("request-ready")),
    +      AsyncResult.success(requestEvent("request-ready")),
         );
         const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined);
    +    const state = consumerState(async () => undefined);
         const consumerAtom = createPreviewAutomationRequestConsumerAtom({
           requestsAtom,
    +      clientId,
    +      connectionAtom: state.connectionAtom,
           environmentId,
    -      handleRequest: async () => undefined,
    +      requestHandlerAtom: state.requestHandlerAtom,
           respond,
           label: "test:preview-automation-initial-request",
         });
    @@ -81,7 +204,12 @@ describe("previewAutomationRequestConsumer", () => {
         registry.mount(consumerAtom);
     
         await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
    -    expect(respond).toHaveBeenCalledWith({ requestId: "request-ready", ok: true });
    +    expect(respond).toHaveBeenCalledWith({
    +      clientId,
    +      connectionId,
    +      requestId: "request-ready",
    +      ok: true,
    +    });
         registry.dispose();
       });
     
    @@ -118,6 +246,84 @@ describe("previewAutomationRequestConsumer", () => {
         });
       });
     
    +  it("reports a missing recording even when no preview tab remains", () => {
    +    const error = new PreviewAutomationRecordingNotActiveError({
    +      requestId: "request-recording-stop",
    +      environmentId,
    +      threadId,
    +      tabId: null,
    +    });
    +
    +    expect(
    +      serializePreviewAutomationError(error, {
    +        requestId: "request-recording-stop",
    +        operation: "recordingStop",
    +        environmentId,
    +        threadId,
    +        tabId: null,
    +      }),
    +    ).toMatchObject({
    +      _tag: "PreviewAutomationExecutionError",
    +      detail: { tabId: null },
    +    });
    +  });
    +
    +  it("preserves viewport render timeouts as timeout responses", () => {
    +    const error = new PreviewAutomationViewportTimeoutError({
    +      requestId: "request-resize",
    +      environmentId,
    +      threadId,
    +      tabId,
    +      timeoutMs: 2_500,
    +    });
    +
    +    expect(
    +      serializePreviewAutomationError(error, {
    +        requestId: "request-resize",
    +        operation: "resize",
    +        environmentId,
    +        threadId,
    +        tabId,
    +      }),
    +    ).toMatchObject({
    +      _tag: "PreviewAutomationTimeoutError",
    +      detail: { tabId: "tab-1", timeoutMs: 2_500 },
    +    });
    +  });
    +
    +  it("maps desktop non-editable targets to the public typed response", () => {
    +    expect(
    +      serializePreviewAutomationError(
    +        {
    +          _tag: "PreviewAutomationTargetNotEditableError",
    +          tabId: "tab-1",
    +          selectorKind: "selector",
    +          selectorLength: 6,
    +        },
    +        {
    +          requestId: "request-type",
    +          operation: "type",
    +          environmentId,
    +          threadId,
    +          tabId,
    +        },
    +      ),
    +    ).toEqual({
    +      _tag: "PreviewAutomationTargetNotEditableError",
    +      message:
    +        "Preview automation type request request-type requires an editable target in tab tab-1.",
    +      detail: {
    +        requestId: "request-type",
    +        operation: "type",
    +        environmentId: "environment-1",
    +        threadId: "thread-1",
    +        tabId: "tab-1",
    +        selectorKind: "selector",
    +        selectorLength: 6,
    +      },
    +    });
    +  });
    +
       it("correlates unexpected failures without exposing cause details", () => {
         const cause = new Error("private bridge token: preview-secret");
         const context = {
    @@ -145,16 +351,19 @@ describe("previewAutomationRequestConsumer", () => {
       });
     
       it("sanitizes unexpected handler failures at the response boundary", async () => {
    -    const requestsAtom = Atom.make>(
    -      AsyncResult.initial(false),
    +    const requestsAtom = Atom.make>(
    +      AsyncResult.initial(false),
         );
         const responses: PreviewAutomationResponse[] = [];
    +    const state = consumerState(async () => {
    +      throw new Error("desktop IPC secret: do-not-return");
    +    });
         const consumerAtom = createPreviewAutomationRequestConsumerAtom({
           requestsAtom,
    +      clientId,
    +      connectionAtom: state.connectionAtom,
           environmentId,
    -      handleRequest: async () => {
    -        throw new Error("desktop IPC secret: do-not-return");
    -      },
    +      requestHandlerAtom: state.requestHandlerAtom,
           respond: async (response) => {
             responses.push(response);
           },
    @@ -166,7 +375,7 @@ describe("previewAutomationRequestConsumer", () => {
         registry.set(
           requestsAtom,
           AsyncResult.success(
    -        request("request-failed", {
    +        requestEvent("request-failed", {
               operation: "click",
               tabId,
             }),
    @@ -175,6 +384,8 @@ describe("previewAutomationRequestConsumer", () => {
     
         await vi.waitFor(() => expect(responses).toHaveLength(1));
         expect(responses[0]).toEqual({
    +      clientId,
    +      connectionId,
           requestId: "request-failed",
           ok: false,
           error: {
    diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
    index 37983b0255e..89a9387e4af 100644
    --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
    +++ b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts
    @@ -1,87 +1,121 @@
     import type {
    -  PreviewAutomationOwner,
    +  PreviewAutomationHost,
       PreviewAutomationRequest,
       PreviewAutomationResponse,
    +  PreviewAutomationStreamEvent,
     } from "@t3tools/contracts";
     import { AsyncResult, Atom } from "effect/unstable/reactivity";
     
     import {
       PreviewAutomationOperationError,
       type PreviewAutomationOperationContext,
    -  serializePreviewAutomationOwnerError,
    +  serializePreviewAutomationHostError,
     } from "./previewAutomationErrors";
     
    -type AutomationRequestResult = AsyncResult.AsyncResult;
    -type AutomationRequestHandler = (request: PreviewAutomationRequest) => Promise;
    -
    -export function createLatestPreviewAutomationRequestHandler(initial: AutomationRequestHandler): {
    -  readonly set: (handler: AutomationRequestHandler) => void;
    -  readonly handle: AutomationRequestHandler;
    -} {
    -  let current = initial;
    -  return {
    -    set: (handler) => {
    -      current = handler;
    -    },
    -    handle: (request) => current(request),
    -  };
    -}
    +type AutomationStreamResult = AsyncResult.AsyncResult;
     
     export function serializePreviewAutomationError(
       error: unknown,
       context: PreviewAutomationOperationContext,
     ): NonNullable {
    -  return serializePreviewAutomationOwnerError(
    +  return serializePreviewAutomationHostError(
         PreviewAutomationOperationError.fromCause({ ...context, cause: error }),
       );
     }
     
     export function createPreviewAutomationRequestConsumerAtom(options: {
    -  readonly requestsAtom: Atom.Atom>;
    -  readonly environmentId: PreviewAutomationOwner["environmentId"];
    -  readonly handleRequest: (request: PreviewAutomationRequest) => Promise;
    +  readonly requestsAtom: Atom.Atom>;
    +  readonly clientId: PreviewAutomationHost["clientId"];
    +  readonly connectionAtom: Atom.Writable;
    +  readonly environmentId: PreviewAutomationHost["environmentId"];
    +  readonly requestHandlerAtom: Atom.Atom<{
    +    readonly handle: (request: PreviewAutomationRequest) => Promise;
    +  }>;
       readonly respond: (response: PreviewAutomationResponse) => Promise;
       readonly label: string;
     }): Atom.Atom {
       return Atom.make((get) => {
    +    get.mount(options.connectionAtom);
    +    get.mount(options.requestHandlerAtom);
         let disposed = false;
    +    let activeConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
    +    let connectionExplicitlyAnnounced = false;
    +    let reportedConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null;
         let requestsVersion = 0;
     
    -    const consume = (result: AutomationRequestResult) => {
    +    const consume = (result: AutomationStreamResult) => {
           if (!AsyncResult.isSuccess(result)) return;
    -      const request = result.value;
    -      void options.handleRequest(request).then(
    -        (value) =>
    -          options.respond({
    -            requestId: request.requestId,
    -            ok: true,
    -            ...(value === undefined ? {} : { result: value }),
    -          }),
    -        (error) =>
    -          options.respond({
    -            requestId: request.requestId,
    -            ok: false,
    -            error: serializePreviewAutomationError(error, {
    +      const event = result.value;
    +      if (event.type === "connected") {
    +        activeConnectionId = event.connectionId;
    +        connectionExplicitlyAnnounced = true;
    +      } else if (activeConnectionId === null) {
    +        activeConnectionId = event.connectionId;
    +      } else if (activeConnectionId !== event.connectionId) {
    +        if (connectionExplicitlyAnnounced) return;
    +        activeConnectionId = event.connectionId;
    +      }
    +      if (reportedConnectionId !== event.connectionId) {
    +        reportedConnectionId = event.connectionId;
    +        get.set(options.connectionAtom, event.connectionId);
    +      }
    +      if (event.type === "connected") {
    +        return;
    +      }
    +      const request = event.request;
    +      void get
    +        .once(options.requestHandlerAtom)
    +        .handle(request)
    +        .then(
    +          (value) =>
    +            options.respond({
    +              clientId: options.clientId,
    +              connectionId: event.connectionId,
    +              requestId: request.requestId,
    +              ok: true,
    +              ...(value === undefined ? {} : { result: value }),
    +            }),
    +          (error) =>
    +            options.respond({
    +              clientId: options.clientId,
    +              connectionId: event.connectionId,
                   requestId: request.requestId,
    -              operation: request.operation,
    -              environmentId: options.environmentId,
    -              threadId: request.threadId,
    -              tabId: request.tabId ?? null,
    +              ok: false,
    +              error: serializePreviewAutomationError(error, {
    +                requestId: request.requestId,
    +                operation: request.operation,
    +                environmentId: options.environmentId,
    +                threadId: request.threadId,
    +                tabId: request.tabId ?? null,
    +              }),
                 }),
    -          }),
    -      );
    +        );
         };
     
         get.addFinalizer(() => {
           disposed = true;
         });
         const initialRequest = get.once(options.requestsAtom);
    +    if (AsyncResult.isSuccess(initialRequest)) {
    +      activeConnectionId = initialRequest.value.connectionId;
    +      connectionExplicitlyAnnounced = initialRequest.value.type === "connected";
    +      if (initialRequest.value.type === "connected") {
    +        reportedConnectionId = initialRequest.value.connectionId;
    +        get.set(options.connectionAtom, initialRequest.value.connectionId);
    +      }
    +    }
         get.subscribe(options.requestsAtom, (result) => {
           requestsVersion += 1;
           consume(result);
         });
         queueMicrotask(() => {
    -      if (!disposed && requestsVersion === 0) consume(initialRequest);
    +      const initialConnectionWasSkipped =
    +        AsyncResult.isSuccess(initialRequest) &&
    +        initialRequest.value.connectionId === activeConnectionId &&
    +        initialRequest.value.connectionId !== reportedConnectionId;
    +      if (!disposed && (requestsVersion === 0 || initialConnectionWasSkipped)) {
    +        consume(initialRequest);
    +      }
         });
       }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label));
     }
    diff --git a/apps/web/src/components/preview/previewAutomationTarget.test.ts b/apps/web/src/components/preview/previewAutomationTarget.test.ts
    new file mode 100644
    index 00000000000..379e3519057
    --- /dev/null
    +++ b/apps/web/src/components/preview/previewAutomationTarget.test.ts
    @@ -0,0 +1,45 @@
    +import type { PreviewSessionSnapshot } from "@t3tools/contracts";
    +import { describe, expect, it } from "vite-plus/test";
    +
    +import {
    +  needsPreviewAutomationSessionSync,
    +  resolvePreviewAutomationTarget,
    +} from "./previewAutomationTarget";
    +
    +const snapshot = (tabId: string): PreviewSessionSnapshot => ({
    +  threadId: "thread-1",
    +  tabId,
    +  navStatus: { _tag: "Idle" },
    +  canGoBack: false,
    +  canGoForward: false,
    +  updatedAt: "2026-01-01T00:00:00.000Z",
    +});
    +
    +describe("preview automation target selection", () => {
    +  it("refreshes authoritative sessions whenever the caller relies on the active tab", () => {
    +    const active = snapshot("tab-active");
    +    expect(
    +      needsPreviewAutomationSessionSync(
    +        { snapshot: active, sessions: { [active.tabId]: active } },
    +        undefined,
    +      ),
    +    ).toBe(true);
    +  });
    +
    +  it("refreshes an explicit tab only when it is absent locally", () => {
    +    const active = snapshot("tab-active");
    +    const state = { snapshot: active, sessions: { [active.tabId]: active } };
    +    expect(needsPreviewAutomationSessionSync(state, active.tabId)).toBe(false);
    +    expect(needsPreviewAutomationSessionSync(state, "tab-missing")).toBe(true);
    +  });
    +
    +  it("does not report the active tab under an unknown requested tab id", () => {
    +    const active = snapshot("tab-active");
    +    expect(
    +      resolvePreviewAutomationTarget(
    +        { snapshot: active, sessions: { [active.tabId]: active } },
    +        "tab-missing",
    +      ),
    +    ).toEqual({ tabId: null, snapshot: null });
    +  });
    +});
    diff --git a/apps/web/src/components/preview/previewAutomationTarget.ts b/apps/web/src/components/preview/previewAutomationTarget.ts
    new file mode 100644
    index 00000000000..1dc9f17f78a
    --- /dev/null
    +++ b/apps/web/src/components/preview/previewAutomationTarget.ts
    @@ -0,0 +1,25 @@
    +import type { PreviewSessionSnapshot } from "@t3tools/contracts";
    +
    +interface PreviewAutomationSessionIndex {
    +  readonly snapshot: PreviewSessionSnapshot | null;
    +  readonly sessions: Readonly>;
    +}
    +
    +export function needsPreviewAutomationSessionSync(
    +  state: PreviewAutomationSessionIndex,
    +  requestedTabId: string | undefined,
    +): boolean {
    +  return (
    +    Object.keys(state.sessions).length === 0 ||
    +    requestedTabId === undefined ||
    +    state.sessions[requestedTabId] === undefined
    +  );
    +}
    +
    +export function resolvePreviewAutomationTarget(
    +  state: PreviewAutomationSessionIndex,
    +  requestedTabId: string | null,
    +): { readonly tabId: string | null; readonly snapshot: PreviewSessionSnapshot | null } {
    +  const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot;
    +  return { tabId: snapshot?.tabId ?? null, snapshot };
    +}
    diff --git a/apps/web/src/components/preview/previewViewportReadiness.test.ts b/apps/web/src/components/preview/previewViewportReadiness.test.ts
    new file mode 100644
    index 00000000000..c47ffc3ff08
    --- /dev/null
    +++ b/apps/web/src/components/preview/previewViewportReadiness.test.ts
    @@ -0,0 +1,72 @@
    +import { describe, expect, it } from "vite-plus/test";
    +
    +import { browserViewportSettingKey } from "~/browser/browserViewportLayout";
    +
    +import { isPreviewViewportReady } from "./previewViewportReadiness";
    +
    +describe("isPreviewViewportReady", () => {
    +  const landscape = {
    +    _tag: "preset",
    +    width: 844,
    +    height: 390,
    +    presetId: "iphone-12-pro",
    +  } as const;
    +
    +  it("rejects a stale same-mode preset while React applies the requested orientation", () => {
    +    expect(
    +      isPreviewViewportReady({
    +        setting: landscape,
    +        appliedSettingKey: "preset:390:844:iphone-12-pro",
    +        declaredViewport: { width: 390, height: 844 },
    +        renderedViewport: { width: 390, height: 844 },
    +      }),
    +    ).toBe(false);
    +  });
    +
    +  it("requires both the declaration and guest viewport to match a fixed request", () => {
    +    const appliedSettingKey = browserViewportSettingKey(landscape);
    +    expect(
    +      isPreviewViewportReady({
    +        setting: landscape,
    +        appliedSettingKey,
    +        declaredViewport: { width: 390, height: 844 },
    +        renderedViewport: { width: 844, height: 390 },
    +      }),
    +    ).toBe(false);
    +    expect(
    +      isPreviewViewportReady({
    +        setting: landscape,
    +        appliedSettingKey,
    +        declaredViewport: { width: 844, height: 390 },
    +        renderedViewport: { width: 844, height: 390 },
    +      }),
    +    ).toBe(true);
    +  });
    +
    +  it("allows one pixel of Electron rounding tolerance in every mode", () => {
    +    expect(
    +      isPreviewViewportReady({
    +        setting: { _tag: "fill" },
    +        appliedSettingKey: "fill",
    +        declaredViewport: { width: 500, height: 700 },
    +        renderedViewport: { width: 501, height: 699 },
    +      }),
    +    ).toBe(true);
    +    expect(
    +      isPreviewViewportReady({
    +        setting: landscape,
    +        appliedSettingKey: browserViewportSettingKey(landscape),
    +        declaredViewport: { width: 844, height: 390 },
    +        renderedViewport: { width: 845, height: 389 },
    +      }),
    +    ).toBe(true);
    +    expect(
    +      isPreviewViewportReady({
    +        setting: landscape,
    +        appliedSettingKey: browserViewportSettingKey(landscape),
    +        declaredViewport: { width: 844, height: 390 },
    +        renderedViewport: { width: 846, height: 390 },
    +      }),
    +    ).toBe(false);
    +  });
    +});
    diff --git a/apps/web/src/components/preview/previewViewportReadiness.ts b/apps/web/src/components/preview/previewViewportReadiness.ts
    new file mode 100644
    index 00000000000..5ff963fbceb
    --- /dev/null
    +++ b/apps/web/src/components/preview/previewViewportReadiness.ts
    @@ -0,0 +1,37 @@
    +import type { PreviewRenderedViewportSize, PreviewViewportSetting } from "@t3tools/contracts";
    +
    +import { browserViewportSettingKey } from "~/browser/browserViewportLayout";
    +
    +export function isPreviewViewportReady(input: {
    +  readonly setting: PreviewViewportSetting;
    +  readonly appliedSettingKey: string | null;
    +  readonly declaredViewport: PreviewRenderedViewportSize | null;
    +  readonly renderedViewport: PreviewRenderedViewportSize | null;
    +}): boolean {
    +  const { setting, appliedSettingKey, declaredViewport, renderedViewport } = input;
    +  if (
    +    appliedSettingKey !== browserViewportSettingKey(setting) ||
    +    declaredViewport === null ||
    +    renderedViewport === null
    +  ) {
    +    return false;
    +  }
    +
    +  const expectedViewport =
    +    setting._tag === "fill" ? declaredViewport : { width: setting.width, height: setting.height };
    +  if (
    +    setting._tag !== "fill" &&
    +    (declaredViewport.width !== expectedViewport.width ||
    +      declaredViewport.height !== expectedViewport.height)
    +  ) {
    +    return false;
    +  }
    +
    +  // Electron rounds CSS pixels through the guest's fractional zoom/device scale,
    +  // so a successfully applied fixed viewport can measure one pixel either way.
    +  const tolerance = 1;
    +  return (
    +    Math.abs(renderedViewport.width - expectedViewport.width) <= tolerance &&
    +    Math.abs(renderedViewport.height - expectedViewport.height) <= tolerance
    +  );
    +}
    diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts
    index 2a82f627574..9bc2cc84c37 100644
    --- a/apps/web/src/components/preview/usePreviewSession.ts
    +++ b/apps/web/src/components/preview/usePreviewSession.ts
    @@ -11,6 +11,7 @@ import {
       applyPreviewServerEvent,
       applyPreviewServerSnapshot,
       readThreadPreviewState,
    +  reconcilePreviewServerSessions,
     } from "~/previewStateStore";
     import { previewEnvironment } from "~/state/preview";
     
    @@ -50,9 +51,7 @@ const previewSessionSyncAtom = Atom.family((threadKey: string) => {
           if (result.value.sessions.length > 0) {
             recoveringUrl = null;
             recoveryId += 1;
    -        for (const snapshot of result.value.sessions) {
    -          applyPreviewServerSnapshot(threadRef, snapshot);
    -        }
    +        reconcilePreviewServerSessions(threadRef, result.value.sessions);
             return;
           }
     
    diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts
    index d2bf2e7c260..c908e23f9ba 100644
    --- a/apps/web/src/previewStateStore.test.ts
    +++ b/apps/web/src/previewStateStore.test.ts
    @@ -11,10 +11,12 @@ import {
       cancelPreviewSessionClose,
       previewStateAtom,
       readThreadPreviewState,
    +  reconcilePreviewServerSessions,
       rememberPreviewUrl,
       removePreviewThread,
       resetPreviewStateForTests,
       setActivePreviewTab,
    +  updatePreviewServerSnapshot,
     } from "./previewStateStore";
     
     const environmentId = "env-1" as EnvironmentId;
    @@ -108,6 +110,34 @@ describe("previewStateStore (single-tab)", () => {
         }
       });
     
    +  it("resized event updates tab viewport without changing the active tab", () => {
    +    const active = makeSnapshot({ tabId: "tab_a" });
    +    const background = makeSnapshot({ tabId: "tab_b" });
    +    applyPreviewServerSnapshot(ref, background);
    +    applyPreviewServerSnapshot(ref, active);
    +
    +    applyPreviewServerEvent(ref, {
    +      type: "resized",
    +      threadId: "thread-1",
    +      tabId: background.tabId,
    +      createdAt: "2026-01-01T00:00:01.000Z",
    +      snapshot: {
    +        ...background,
    +        viewport: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 },
    +        updatedAt: "2026-01-01T00:00:01.000Z",
    +      },
    +    });
    +
    +    const state = readThreadPreviewState(ref);
    +    expect(state.activeTabId).toBe(active.tabId);
    +    expect(state.sessions[background.tabId]?.viewport).toEqual({
    +      _tag: "preset",
    +      presetId: "pixel-8",
    +      width: 412,
    +      height: 915,
    +    });
    +  });
    +
       it("failed event flips the snapshot to LoadFailed when tabId matches", () => {
         const snapshot = makeSnapshot();
         applyPreviewServerEvent(ref, {
    @@ -292,6 +322,64 @@ describe("previewStateStore (single-tab)", () => {
         expect(state.desktopOverlay?.canGoBack).toBe(true);
       });
     
    +  it("updates a background snapshot without changing the active tab", () => {
    +    const background = makeSnapshot({ tabId: "tab_a" });
    +    const active = makeSnapshot({
    +      tabId: "tab_b",
    +      updatedAt: "2026-01-01T00:00:01.000Z",
    +    });
    +    applyPreviewServerSnapshot(ref, background);
    +    applyPreviewServerSnapshot(ref, active);
    +
    +    const resized = {
    +      ...background,
    +      viewport: { _tag: "freeform" as const, width: 900, height: 700 },
    +      updatedAt: "2026-01-01T00:00:02.000Z",
    +    };
    +    updatePreviewServerSnapshot(ref, resized);
    +
    +    const state = readThreadPreviewState(ref);
    +    expect(state.activeTabId).toBe(active.tabId);
    +    expect(state.snapshot?.tabId).toBe(active.tabId);
    +    expect(state.sessions[background.tabId]).toEqual(resized);
    +  });
    +
    +  it("reconciles an authoritative session list without focusing a background tab", () => {
    +    const active = makeSnapshot({ tabId: "tab_a" });
    +    const stale = makeSnapshot({
    +      tabId: "tab_stale",
    +      updatedAt: "2026-01-01T00:00:01.000Z",
    +    });
    +    applyPreviewServerSnapshot(ref, stale);
    +    applyPreviewServerSnapshot(ref, active);
    +    applyPreviewDesktopState(ref, stale.tabId, {
    +      canGoBack: false,
    +      canGoForward: false,
    +      loading: false,
    +      zoomFactor: 1,
    +      controller: "none",
    +    });
    +
    +    reconcilePreviewServerSessions(ref, [active]);
    +
    +    const state = readThreadPreviewState(ref);
    +    expect(Object.keys(state.sessions)).toEqual([active.tabId]);
    +    expect(state.activeTabId).toBe(active.tabId);
    +    expect(state.snapshot).toEqual(active);
    +    expect(state.desktopByTabId[stale.tabId]).toBeUndefined();
    +  });
    +
    +  it("clears stale sessions when an authoritative list is empty", () => {
    +    applyPreviewServerSnapshot(ref, makeSnapshot());
    +
    +    reconcilePreviewServerSessions(ref, []);
    +
    +    const state = readThreadPreviewState(ref);
    +    expect(state.sessions).toEqual({});
    +    expect(state.activeTabId).toBeNull();
    +    expect(state.snapshot).toBeNull();
    +  });
    +
       it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
         const snapshot = makeSnapshot();
         applyPreviewServerSnapshot(ref, snapshot);
    diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts
    index 7f8f8576130..e44a464ed24 100644
    --- a/apps/web/src/previewStateStore.ts
    +++ b/apps/web/src/previewStateStore.ts
    @@ -112,14 +112,26 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => {
       return next.slice(0, PREVIEW_RECENT_URL_LIMIT);
     };
     
    +const rememberSnapshotUrl = (
    +  recentlySeenUrls: string[],
    +  snapshot: PreviewSessionSnapshot,
    +): string[] =>
    +  snapshot.navStatus._tag === "Idle"
    +    ? recentlySeenUrls
    +    : dedupeRecentUrls(recentlySeenUrls, snapshot.navStatus.url);
    +
    +const latestSnapshot = (
    +  sessions: Record,
    +): PreviewSessionSnapshot | null =>
    +  Object.values(sessions)
    +    .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
    +    .at(-1) ?? null;
    +
     const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => {
       if (!current.sessions[tabId]) return current;
       const { [tabId]: _closed, ...sessions } = current.sessions;
       const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;
    -  const nextSnapshot =
    -    Object.values(sessions)
    -      .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
    -      .at(-1) ?? null;
    +  const nextSnapshot = latestSnapshot(sessions);
       const activeTabId =
         current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId;
       const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;
    @@ -163,7 +175,8 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven
       updateThreadPreviewState(ref, (current) => {
         switch (event.type) {
           case "opened":
    -      case "navigated": {
    +      case "navigated":
    +      case "resized": {
             const snapshot = event.snapshot;
             if (current.suppressedTabIds.has(snapshot.tabId)) return current;
             const recentlySeenUrls =
    @@ -228,10 +241,7 @@ export function applyPreviewServerSnapshot(
         if (current.suppressedTabIds.has(snapshot.tabId)) return current;
         const existing = current.sessions[snapshot.tabId];
         if (existing && existing.updatedAt > snapshot.updatedAt) return current;
    -    const recentlySeenUrls =
    -      snapshot.navStatus._tag !== "Idle"
    -        ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
    -        : current.recentlySeenUrls;
    +    const recentlySeenUrls = rememberSnapshotUrl(current.recentlySeenUrls, snapshot);
         return {
           ...current,
           snapshot,
    @@ -243,6 +253,76 @@ export function applyPreviewServerSnapshot(
       });
     }
     
    +/**
    + * Merge a server mutation without changing which tab the user is viewing.
    + *
    + * Commands such as resize can target background tabs. Their response is
    + * authoritative for that tab, but it is not a request to focus the tab.
    + */
    +export function updatePreviewServerSnapshot(
    +  ref: ScopedThreadRef,
    +  snapshot: PreviewSessionSnapshot,
    +): void {
    +  updateThreadPreviewState(ref, (current) => {
    +    if (current.suppressedTabIds.has(snapshot.tabId)) return current;
    +    const existing = current.sessions[snapshot.tabId];
    +    if (existing && existing.updatedAt > snapshot.updatedAt) return current;
    +    const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };
    +    const activeTabId =
    +      current.activeTabId && sessions[current.activeTabId] ? current.activeTabId : snapshot.tabId;
    +    const activeSnapshot = sessions[activeTabId] ?? snapshot;
    +    return {
    +      ...current,
    +      sessions,
    +      activeTabId,
    +      snapshot: activeSnapshot,
    +      desktopOverlay: current.desktopByTabId[activeTabId] ?? null,
    +      recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, snapshot),
    +    };
    +  });
    +}
    +
    +/**
    + * Replace the local session index from an authoritative preview.list result.
    + * Missing tabs are removed while the current active tab is preserved whenever
    + * it still exists in the server result.
    + */
    +export function reconcilePreviewServerSessions(
    +  ref: ScopedThreadRef,
    +  snapshots: ReadonlyArray,
    +): void {
    +  updateThreadPreviewState(ref, (current) => {
    +    const sessions: Record = {};
    +    let recentlySeenUrls = current.recentlySeenUrls;
    +    for (const snapshot of snapshots) {
    +      if (current.suppressedTabIds.has(snapshot.tabId)) continue;
    +      const existing = current.sessions[snapshot.tabId];
    +      const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot;
    +      sessions[next.tabId] = next;
    +      recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next);
    +    }
    +
    +    const fallback = latestSnapshot(sessions);
    +    const activeTabId =
    +      current.activeTabId && sessions[current.activeTabId]
    +        ? current.activeTabId
    +        : (fallback?.tabId ?? null);
    +    const snapshot = activeTabId ? (sessions[activeTabId] ?? null) : null;
    +    const desktopByTabId = Object.fromEntries(
    +      Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),
    +    );
    +    return {
    +      ...current,
    +      sessions,
    +      activeTabId,
    +      snapshot,
    +      desktopByTabId,
    +      desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null,
    +      recentlySeenUrls,
    +    };
    +  });
    +}
    +
     export function applyPreviewDesktopState(
       ref: ScopedThreadRef,
       tabId: string,
    diff --git a/packages/client-runtime/src/state/preview.test.ts b/packages/client-runtime/src/state/preview.test.ts
    new file mode 100644
    index 00000000000..7a83bccde98
    --- /dev/null
    +++ b/packages/client-runtime/src/state/preview.test.ts
    @@ -0,0 +1,18 @@
    +import { describe, expect, it } from "vite-plus/test";
    +
    +import { previewAutomationHostFocusConcurrencyKey } from "./preview.ts";
    +
    +describe("preview state commands", () => {
    +  it("keeps focus updates from replacement host connections independent", () => {
    +    const first = previewAutomationHostFocusConcurrencyKey({
    +      environmentId: "environment-1",
    +      input: { clientId: "client-1", connectionId: "connection-1" },
    +    });
    +    const replacement = previewAutomationHostFocusConcurrencyKey({
    +      environmentId: "environment-1",
    +      input: { clientId: "client-1", connectionId: "connection-2" },
    +    });
    +
    +    expect(first).not.toBe(replacement);
    +  });
    +});
    diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts
    index 800fc5efac1..f9469ee96a5 100644
    --- a/packages/client-runtime/src/state/preview.ts
    +++ b/packages/client-runtime/src/state/preview.ts
    @@ -9,6 +9,14 @@ import {
       createEnvironmentRpcSubscriptionAtomFamily,
     } from "./runtime.ts";
     
    +export const previewAutomationHostFocusConcurrencyKey = (value: {
    +  readonly environmentId: string;
    +  readonly input: {
    +    readonly clientId: string;
    +    readonly connectionId: string;
    +  };
    +}): string => JSON.stringify([value.environmentId, value.input.clientId, value.input.connectionId]);
    +
     export function createPreviewEnvironmentAtoms(
       runtime: Atom.AtomRuntime,
     ) {
    @@ -54,6 +62,12 @@ export function createPreviewEnvironmentAtoms(
           scheduler: lifecycleScheduler,
           concurrency: lifecycleConcurrency,
         }),
    +    resize: createEnvironmentRpcCommand(runtime, {
    +      label: "environment-data:preview:resize",
    +      tag: WS_METHODS.previewResize,
    +      scheduler: lifecycleScheduler,
    +      concurrency: lifecycleConcurrency,
    +    }),
         refresh: createEnvironmentRpcCommand(runtime, {
           label: "environment-data:preview:refresh",
           tag: WS_METHODS.previewRefresh,
    @@ -82,25 +96,17 @@ export function createPreviewEnvironmentAtoms(
           scheduler: automationScheduler,
           concurrency: {
             mode: "singleFlight",
    -        key: ({ environmentId, input }) => JSON.stringify([environmentId, input.requestId]),
    -      },
    -    }),
    -    reportAutomationOwner: createEnvironmentRpcCommand(runtime, {
    -      label: "environment-data:preview:automation-report-owner",
    -      tag: WS_METHODS.previewAutomationReportOwner,
    -      scheduler: automationScheduler,
    -      concurrency: {
    -        mode: "serial",
    -        key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]),
    +        key: ({ environmentId, input }) =>
    +          JSON.stringify([environmentId, input.connectionId, input.requestId]),
           },
         }),
    -    clearAutomationOwner: createEnvironmentRpcCommand(runtime, {
    -      label: "environment-data:preview:automation-clear-owner",
    -      tag: WS_METHODS.previewAutomationClearOwner,
    +    focusAutomationHost: createEnvironmentRpcCommand(runtime, {
    +      label: "environment-data:preview:automation-focus-host",
    +      tag: WS_METHODS.previewAutomationFocusHost,
           scheduler: automationScheduler,
           concurrency: {
    -        mode: "serial",
    -        key: ({ environmentId, input }) => JSON.stringify([environmentId, input.clientId]),
    +        mode: "latest",
    +        key: previewAutomationHostFocusConcurrencyKey,
           },
         }),
       };
    diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
    index 9d6ed04c286..6a8dce87ca3 100644
    --- a/packages/contracts/src/ipc.ts
    +++ b/packages/contracts/src/ipc.ts
    @@ -69,19 +69,20 @@ import type {
       PreviewOpenInput,
       PreviewRefreshInput,
       PreviewReportStatusInput,
    +  PreviewResizeInput,
       PreviewSessionSnapshot,
     } from "./preview.ts";
     import {
       PreviewAutomationClickInput,
       PreviewAutomationEvaluateInput,
    -  PreviewAutomationOwner,
    -  PreviewAutomationOwnerIdentity,
    +  PreviewAutomationHost,
    +  PreviewAutomationHostFocus,
       PreviewAutomationPressInput,
    -  PreviewAutomationRequest,
       PreviewAutomationResponse,
       PreviewAutomationScrollInput,
       PreviewAutomationSnapshot,
       PreviewAutomationStatus,
    +  PreviewAutomationStreamEvent,
       PreviewAutomationTypeInput,
       PreviewAutomationWaitForInput,
     } from "./previewAutomation.ts";
    @@ -1154,19 +1155,19 @@ export interface EnvironmentApi {
       preview: {
         open: (input: typeof PreviewOpenInput.Encoded) => Promise;
         navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise;
    +    resize: (input: typeof PreviewResizeInput.Encoded) => Promise;
         refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise;
         close: (input: typeof PreviewCloseInput.Encoded) => Promise;
         list: (input: typeof PreviewListInput.Encoded) => Promise;
         reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise;
         automation: {
           connect: (
    -        input: { clientId: string },
    -        callback: (request: PreviewAutomationRequest) => void,
    +        input: PreviewAutomationHost,
    +        callback: (event: PreviewAutomationStreamEvent) => void,
             options?: { onResubscribe?: () => void },
           ) => () => void;
           respond: (response: PreviewAutomationResponse) => Promise;
    -      reportOwner: (owner: PreviewAutomationOwner) => Promise;
    -      clearOwner: (input: PreviewAutomationOwnerIdentity) => Promise;
    +      focusHost: (input: PreviewAutomationHostFocus) => Promise;
         };
         onEvent: (
           callback: (event: PreviewEvent) => void,
    diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts
    index e4e6757b441..80044793873 100644
    --- a/packages/contracts/src/preview.test.ts
    +++ b/packages/contracts/src/preview.test.ts
    @@ -6,12 +6,26 @@ import {
       PreviewEvent,
       PreviewNavStatus,
       PreviewSessionSnapshot,
    +  PreviewViewportSetting,
     } from "./preview.ts";
    +import {
    +  PreviewAutomationHost,
    +  PreviewAutomationError,
    +  PreviewAutomationResizeInput,
    +  PreviewAutomationResizeResult,
    +  PreviewAutomationStatus,
    +} from "./previewAutomation.ts";
     
     const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);
     const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);
     const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);
     const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);
    +const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting);
    +const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput);
    +const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult);
    +const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost);
    +const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError);
    +const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus);
     
     describe("PreviewNavStatus", () => {
       it("decodes Idle", () => {
    @@ -68,6 +82,117 @@ describe("PreviewSessionSnapshot", () => {
       });
     });
     
    +describe("PreviewViewportSetting", () => {
    +  it("decodes fill, freeform, and preset modes", () => {
    +    expect(decodeViewport({ _tag: "fill" })).toEqual({ _tag: "fill" });
    +    expect(decodeViewport({ _tag: "freeform", width: 1024, height: 768 })).toEqual({
    +      _tag: "freeform",
    +      width: 1024,
    +      height: 768,
    +    });
    +    expect(
    +      decodeViewport({
    +        _tag: "preset",
    +        presetId: "iphone-15-pro",
    +        width: 393,
    +        height: 852,
    +      }),
    +    ).toMatchObject({ _tag: "preset", presetId: "iphone-15-pro" });
    +  });
    +
    +  it("rejects unsafe dimensions and oversized render areas", () => {
    +    expect(() => decodeViewport({ _tag: "freeform", width: 100, height: 800 })).toThrow();
    +    expect(() => decodeViewport({ _tag: "freeform", width: 3840, height: 3840 })).toThrow();
    +  });
    +});
    +
    +describe("PreviewAutomationResizeInput", () => {
    +  it("requires fields that match the selected mode", () => {
    +    expect(decodeResizeInput({ mode: "fill" })).toEqual({ mode: "fill" });
    +    expect(
    +      decodeResizeInput({ mode: "preset", preset: "pixel-7", orientation: "landscape" }),
    +    ).toMatchObject({ mode: "preset", preset: "pixel-7" });
    +    expect(() => decodeResizeInput({ mode: "preset", preset: "pixel-8" })).toThrow();
    +    expect(() => decodeResizeInput({ mode: "freeform", width: 1024 })).toThrow();
    +    expect(() => decodeResizeInput({ mode: "fill", width: 1024, height: 768 })).toThrow();
    +  });
    +
    +  it("allows fill-mode measurements below the minimum selectable fixed size", () => {
    +    expect(
    +      decodeResizeResult({
    +        tabId: "preview-t",
    +        setting: { _tag: "fill" },
    +        viewport: { width: 180, height: 120 },
    +      }).viewport,
    +    ).toEqual({ width: 180, height: 120 });
    +  });
    +});
    +
    +describe("PreviewAutomationHost", () => {
    +  it("accepts legacy hosts and current operation advertisements", () => {
    +    expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({
    +      clientId: "legacy",
    +      environmentId: "environment-1",
    +    });
    +    expect(
    +      decodeAutomationHost({
    +        clientId: "current",
    +        environmentId: "environment-1",
    +        supportedOperations: ["status", "resize"],
    +      }).supportedOperations,
    +    ).toEqual(["status", "resize"]);
    +  });
    +});
    +
    +describe("PreviewAutomationError", () => {
    +  it("preserves a typed non-editable target failure", () => {
    +    const error = decodeAutomationError({
    +      _tag: "PreviewAutomationTargetNotEditableError",
    +      operation: "type",
    +      environmentId: "environment-1",
    +      threadId: "thread-1",
    +      providerSessionId: "provider-session-1",
    +      providerInstanceId: "codex",
    +      clientId: "client-1",
    +      connectionId: "connection-1",
    +      requestId: "request-1",
    +      tabId: "tab-1",
    +      timeoutMs: 1_000,
    +      remoteTag: "PreviewAutomationTargetNotEditableError",
    +      remoteMessageLength: 12,
    +      cause: {},
    +      selectorKind: "focused-element",
    +    });
    +
    +    expect(error._tag).toBe("PreviewAutomationTargetNotEditableError");
    +    if (error._tag === "PreviewAutomationTargetNotEditableError") {
    +      expect(error.selectorKind).toBe("focused-element");
    +      expect(error.message).toBe("Preview automation type requires an editable focused element.");
    +    }
    +  });
    +});
    +
    +describe("PreviewAutomationStatus", () => {
    +  it("accepts old hosts without viewport data and exposes it from current hosts", () => {
    +    const base = {
    +      available: true,
    +      visible: false,
    +      tabId: "preview-t",
    +      url: "https://example.com",
    +      title: "Example",
    +      loading: false,
    +    };
    +    expect(decodeAutomationStatus(base)).toEqual(base);
    +    expect(
    +      decodeAutomationStatus({
    +        ...base,
    +        viewportSetting: { _tag: "preset", presetId: "pixel-8", width: 412, height: 915 },
    +        viewport: { width: 412, height: 915 },
    +      }).viewport,
    +    ).toEqual({ width: 412, height: 915 });
    +  });
    +});
    +
     describe("PreviewEvent", () => {
       it("decodes opened", () => {
         const event = decodePreviewEvent({
    @@ -104,6 +229,25 @@ describe("PreviewEvent", () => {
         }
       });
     
    +  it("decodes resized with tab viewport state", () => {
    +    const event = decodePreviewEvent({
    +      type: "resized",
    +      threadId: "t",
    +      tabId: "preview-t",
    +      createdAt: "2026-01-01T00:00:00.000Z",
    +      snapshot: {
    +        threadId: "t",
    +        tabId: "preview-t",
    +        navStatus: { _tag: "Idle" },
    +        canGoBack: false,
    +        canGoForward: false,
    +        viewport: { _tag: "freeform", width: 1024, height: 768 },
    +        updatedAt: "2026-01-01T00:00:00.000Z",
    +      },
    +    });
    +    expect(event.type).toBe("resized");
    +  });
    +
       it("decodes closed without snapshot", () => {
         const event = decodePreviewEvent({
           type: "closed",
    diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts
    index 457e66ee07f..c00f878bcbe 100644
    --- a/packages/contracts/src/preview.ts
    +++ b/packages/contracts/src/preview.ts
    @@ -17,6 +17,100 @@ const Title = Schema.String.check(Schema.isMaxLength(512));
     export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
     export type PreviewTabId = typeof PreviewTabId.Type;
     
    +export const PREVIEW_VIEWPORT_MIN_DIMENSION = 240;
    +export const PREVIEW_VIEWPORT_MAX_DIMENSION = 3840;
    +export const PREVIEW_VIEWPORT_MAX_AREA = 3840 * 2160;
    +
    +const PreviewViewportDimension = Schema.Int.check(
    +  Schema.isBetween({
    +    minimum: PREVIEW_VIEWPORT_MIN_DIMENSION,
    +    maximum: PREVIEW_VIEWPORT_MAX_DIMENSION,
    +  }),
    +);
    +
    +const viewportAreaFilter = Schema.makeFilter(
    +  ({ width, height }: { readonly width: number; readonly height: number }) =>
    +    width * height <= PREVIEW_VIEWPORT_MAX_AREA ||
    +    `Viewport area must not exceed ${PREVIEW_VIEWPORT_MAX_AREA} pixels.`,
    +);
    +
    +export const PreviewViewportSize = Schema.Struct({
    +  width: PreviewViewportDimension,
    +  height: PreviewViewportDimension,
    +}).check(viewportAreaFilter);
    +export type PreviewViewportSize = typeof PreviewViewportSize.Type;
    +
    +/**
    + * The page's measured viewport can be smaller than the minimum selectable
    + * fixed size while fill mode follows a narrow panel. Keep measurement
    + * validation separate from the stricter user-selectable size constraints.
    + */
    +export const PreviewRenderedViewportSize = Schema.Struct({
    +  width: Schema.Int.check(Schema.isGreaterThan(0)),
    +  height: Schema.Int.check(Schema.isGreaterThan(0)),
    +});
    +export type PreviewRenderedViewportSize = typeof PreviewRenderedViewportSize.Type;
    +
    +export const PREVIEW_VIEWPORT_PRESET_IDS = [
    +  "iphone-se",
    +  "iphone-xr",
    +  "iphone-12-pro",
    +  "iphone-14-pro-max",
    +  "pixel-7",
    +  "samsung-galaxy-s8-plus",
    +  "samsung-galaxy-s20-ultra",
    +  "ipad-mini",
    +  "ipad-air",
    +  "ipad-pro",
    +  "surface-pro-7",
    +  "surface-duo",
    +  "galaxy-z-fold-5",
    +  "asus-zenbook-fold",
    +  "samsung-galaxy-a51-71",
    +  "nest-hub",
    +  "nest-hub-max",
    +] as const;
    +
    +export const PreviewViewportPresetId = Schema.Literals(PREVIEW_VIEWPORT_PRESET_IDS);
    +export type PreviewViewportPresetId = typeof PreviewViewportPresetId.Type;
    +
    +/**
    + * Preset IDs shipped before the Chrome-compatible catalog. Existing sessions
    + * can still reconnect with these values, but new resize requests only expose
    + * PREVIEW_VIEWPORT_PRESET_IDS.
    + */
    +const LEGACY_PREVIEW_VIEWPORT_PRESET_IDS = [
    +  "desktop-1920x1080",
    +  "desktop-1440x900",
    +  "laptop-1366x768",
    +  "laptop-1280x800",
    +  "ipad-pro-11",
    +  "iphone-15-pro",
    +  "pixel-8",
    +  "galaxy-s24",
    +] as const;
    +
    +const StoredPreviewViewportPresetId = Schema.Literals([
    +  ...PREVIEW_VIEWPORT_PRESET_IDS,
    +  ...LEGACY_PREVIEW_VIEWPORT_PRESET_IDS,
    +]);
    +
    +export const PreviewViewportSetting = Schema.Union([
    +  Schema.TaggedStruct("fill", {}),
    +  Schema.TaggedStruct("freeform", {
    +    ...PreviewViewportSize.fields,
    +  }).check(viewportAreaFilter),
    +  Schema.TaggedStruct("preset", {
    +    ...PreviewViewportSize.fields,
    +    presetId: StoredPreviewViewportPresetId,
    +  }).check(viewportAreaFilter),
    +]);
    +export type PreviewViewportSetting = typeof PreviewViewportSetting.Type;
    +
    +export const FILL_PREVIEW_VIEWPORT = {
    +  _tag: "fill",
    +} as const satisfies PreviewViewportSetting;
    +
     export const PreviewNavStatus = Schema.Union([
       Schema.TaggedStruct("Idle", {}),
       Schema.TaggedStruct("Loading", {
    @@ -42,6 +136,8 @@ export const PreviewSessionSnapshot = Schema.Struct({
       navStatus: PreviewNavStatus,
       canGoBack: Schema.Boolean,
       canGoForward: Schema.Boolean,
    +  /** Missing snapshots from older servers are treated as fill-panel mode. */
    +  viewport: Schema.optional(PreviewViewportSetting),
       updatedAt: Schema.String,
     });
     export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
    @@ -76,6 +172,13 @@ export const PreviewRefreshInput = Schema.Struct({
     });
     export type PreviewRefreshInput = typeof PreviewRefreshInput.Type;
     
    +export const PreviewResizeInput = Schema.Struct({
    +  threadId: ThreadId,
    +  tabId: PreviewTabId,
    +  viewport: PreviewViewportSetting,
    +});
    +export type PreviewResizeInput = typeof PreviewResizeInput.Type;
    +
     export const PreviewCloseInput = Schema.Struct({
       threadId: ThreadId,
       tabId: Schema.optional(PreviewTabId),
    @@ -110,6 +213,12 @@ const PreviewNavigatedEvent = Schema.Struct({
       snapshot: PreviewSessionSnapshot,
     });
     
    +const PreviewResizedEvent = Schema.Struct({
    +  ...PreviewEventBaseSchema.fields,
    +  type: Schema.Literal("resized"),
    +  snapshot: PreviewSessionSnapshot,
    +});
    +
     const PreviewFailedEvent = Schema.Struct({
       ...PreviewEventBaseSchema.fields,
       type: Schema.Literal("failed"),
    @@ -127,6 +236,7 @@ const PreviewClosedEvent = Schema.Struct({
     export const PreviewEvent = Schema.Union([
       PreviewOpenedEvent,
       PreviewNavigatedEvent,
    +  PreviewResizedEvent,
       PreviewFailedEvent,
       PreviewClosedEvent,
     ]);
    diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts
    index 118fb892737..47dc7fc249e 100644
    --- a/packages/contracts/src/previewAutomation.ts
    +++ b/packages/contracts/src/previewAutomation.ts
    @@ -1,7 +1,14 @@
     import { Schema } from "effect";
     
     import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
    -import { PreviewTabId } from "./preview.ts";
    +import {
    +  PREVIEW_VIEWPORT_MAX_AREA,
    +  PreviewRenderedViewportSize,
    +  PreviewTabId,
    +  PreviewViewportPresetId,
    +  PreviewViewportSetting,
    +  PreviewViewportSize,
    +} from "./preview.ts";
     import { ProviderInstanceId } from "./providerInstance.ts";
     
     const BoundedUrl = Schema.String.check(Schema.isTrimmed())
    @@ -18,7 +25,8 @@ const OptionalTimeoutMs = Schema.optional(
         .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }),
     ).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." });
     
    -export const PreviewAutomationOperation = Schema.Literals([
    +/** Operations understood by desktop hosts predating viewport resizing. */
    +export const PREVIEW_AUTOMATION_V1_OPERATIONS = [
       "status",
       "open",
       "navigate",
    @@ -31,7 +39,15 @@ export const PreviewAutomationOperation = Schema.Literals([
       "waitFor",
       "recordingStart",
       "recordingStop",
    -]);
    +] as const;
    +
    +/** Advertised by current desktop hosts for mixed-version routing. */
    +export const PREVIEW_AUTOMATION_OPERATIONS = [
    +  ...PREVIEW_AUTOMATION_V1_OPERATIONS,
    +  "resize",
    +] as const;
    +
    +export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS);
     export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
     
     export const PreviewAutomationStatus = Schema.Struct({
    @@ -41,6 +57,10 @@ export const PreviewAutomationStatus = Schema.Struct({
       url: Schema.NullOr(Schema.String),
       title: Schema.NullOr(Schema.String),
       loading: Schema.Boolean,
    +  /** Optional for compatibility with desktop hosts predating viewport sizing. */
    +  viewportSetting: Schema.optional(PreviewViewportSetting),
    +  /** Measured guest-page viewport in CSS pixels when a webview is ready. */
    +  viewport: Schema.optional(PreviewRenderedViewportSize),
     });
     export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
     
    @@ -134,6 +154,80 @@ export const PreviewAutomationNavigateInput = Schema.Struct({
       });
     export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;
     
    +export const PreviewAutomationResizeInput = Schema.Struct({
    +  mode: Schema.Literals(["fill", "freeform", "preset"]).annotate({
    +    description:
    +      "Viewport mode: fill follows the preview panel, freeform uses exact independently resizable dimensions, and preset uses a named device size.",
    +  }),
    +  preset: Schema.optional(
    +    PreviewViewportPresetId.annotate({
    +      description: "Named viewport from Chrome DevTools' standard device catalog.",
    +    }),
    +  ).annotate({
    +    description: "Named device size. Required only when mode is preset.",
    +  }),
    +  width: Schema.optional(
    +    PreviewViewportSize.fields.width.annotate({
    +      description: "Freeform viewport width in CSS pixels. Required only in freeform mode.",
    +    }),
    +  ).annotate({
    +    description: "Freeform viewport width in CSS pixels. Required only in freeform mode.",
    +  }),
    +  height: Schema.optional(
    +    PreviewViewportSize.fields.height.annotate({
    +      description: "Freeform viewport height in CSS pixels. Required only in freeform mode.",
    +    }),
    +  ).annotate({
    +    description: "Freeform viewport height in CSS pixels. Required only in freeform mode.",
    +  }),
    +  orientation: Schema.optional(
    +    Schema.Literals(["portrait", "landscape"]).annotate({
    +      description:
    +        "Orientation for a fixed device preset. Defaults to the preset's native orientation.",
    +    }),
    +  ).annotate({
    +    description:
    +      "Orientation for a named device preset. It is not accepted in fill or freeform mode.",
    +  }),
    +  timeoutMs: OptionalTimeoutMs,
    +})
    +  .check(
    +    Schema.makeFilter((input) => {
    +      const hasPreset = input.preset !== undefined;
    +      const hasWidth = input.width !== undefined;
    +      const hasHeight = input.height !== undefined;
    +      if (hasWidth !== hasHeight) return "Custom dimensions require both width and height.";
    +      if (input.mode === "fill") {
    +        return !hasPreset && !hasWidth && input.orientation === undefined
    +          ? true
    +          : "Fill mode does not accept a preset, dimensions, or orientation.";
    +      }
    +      if (input.mode === "freeform") {
    +        if (!hasWidth || !hasHeight || hasPreset || input.orientation !== undefined) {
    +          return "Freeform mode requires width and height and does not accept a preset or orientation.";
    +        }
    +      } else if (!hasPreset || hasWidth || hasHeight) {
    +        return "Preset mode requires a preset and does not accept custom dimensions.";
    +      }
    +      if (hasWidth && hasHeight && input.width! * input.height! > PREVIEW_VIEWPORT_MAX_AREA) {
    +        return `Custom viewport area must not exceed ${PREVIEW_VIEWPORT_MAX_AREA} pixels.`;
    +      }
    +      return true;
    +    }),
    +  )
    +  .annotate({
    +    description:
    +      "Sets the active browser tab to fill-panel, independently resizable freeform, or named device-preset sizing.",
    +  });
    +export type PreviewAutomationResizeInput = typeof PreviewAutomationResizeInput.Type;
    +
    +export const PreviewAutomationResizeResult = Schema.Struct({
    +  tabId: PreviewTabId,
    +  setting: PreviewViewportSetting,
    +  viewport: PreviewRenderedViewportSize,
    +});
    +export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type;
    +
     const Locator = TrimmedNonEmptyString.annotate({
       description:
         "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",
    @@ -411,21 +505,33 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({
     });
     export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type;
     
    -export const PreviewAutomationOwnerIdentity = Schema.Struct({
    -  clientId: TrimmedNonEmptyString,
    +export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
    +export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type;
    +export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64));
    +export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type;
    +
    +export const PreviewAutomationHostIdentity = Schema.Struct({
    +  clientId: PreviewAutomationClientId,
       environmentId: EnvironmentId,
    -  threadId: ThreadId,
     });
    -export type PreviewAutomationOwnerIdentity = typeof PreviewAutomationOwnerIdentity.Type;
    +export type PreviewAutomationHostIdentity = typeof PreviewAutomationHostIdentity.Type;
    +
    +export const PreviewAutomationHost = Schema.Struct({
    +  ...PreviewAutomationHostIdentity.fields,
    +  /**
    +   * Missing means the pre-capability-negotiation V1 operation set. This lets
    +   * a newer server safely coexist with an older desktop during rollout.
    +   */
    +  supportedOperations: Schema.optional(Schema.Array(PreviewAutomationOperation)),
    +});
    +export type PreviewAutomationHost = typeof PreviewAutomationHost.Type;
     
    -export const PreviewAutomationOwner = Schema.Struct({
    -  ...PreviewAutomationOwnerIdentity.fields,
    -  tabId: Schema.NullOr(PreviewTabId),
    -  visible: Schema.Boolean,
    -  supportsAutomation: Schema.Boolean,
    -  focusedAt: Schema.String,
    +export const PreviewAutomationHostFocus = Schema.Struct({
    +  ...PreviewAutomationHostIdentity.fields,
    +  connectionId: PreviewAutomationConnectionId,
    +  focused: Schema.Boolean,
     });
    -export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type;
    +export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type;
     
     export const PreviewAutomationRequest = Schema.Struct({
       requestId: TrimmedNonEmptyString,
    @@ -437,7 +543,22 @@ export const PreviewAutomationRequest = Schema.Struct({
     });
     export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type;
     
    +export const PreviewAutomationStreamEvent = Schema.Union([
    +  Schema.Struct({
    +    type: Schema.Literal("connected"),
    +    connectionId: PreviewAutomationConnectionId,
    +  }),
    +  Schema.Struct({
    +    type: Schema.Literal("request"),
    +    connectionId: PreviewAutomationConnectionId,
    +    request: PreviewAutomationRequest,
    +  }),
    +]);
    +export type PreviewAutomationStreamEvent = typeof PreviewAutomationStreamEvent.Type;
    +
     export const PreviewAutomationResponse = Schema.Struct({
    +  clientId: PreviewAutomationClientId,
    +  connectionId: PreviewAutomationConnectionId,
       requestId: TrimmedNonEmptyString,
       ok: Schema.Boolean,
       result: Schema.optional(Schema.Unknown),
    @@ -477,6 +598,7 @@ const PreviewAutomationScopeErrorFields = {
     const PreviewAutomationRequestErrorFields = {
       ...PreviewAutomationScopeErrorFields,
       clientId: TrimmedNonEmptyString,
    +  connectionId: PreviewAutomationConnectionId,
       requestId: TrimmedNonEmptyString,
       tabId: Schema.optional(PreviewTabId),
       timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)),
    @@ -500,11 +622,12 @@ const PreviewAutomationOptionalRemoteDiagnosticFields = {
       cause: Schema.optional(Schema.Defect()),
     };
     
    -export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass()(
    -  "PreviewAutomationNoFocusedOwnerError",
    +export class PreviewAutomationNoAvailableHostError extends Schema.TaggedErrorClass()(
    +  "PreviewAutomationNoAvailableHostError",
       {
         ...PreviewAutomationScopeErrorFields,
         clientId: Schema.optional(TrimmedNonEmptyString),
    +    connectionId: Schema.optional(PreviewAutomationConnectionId),
         requestId: Schema.optional(TrimmedNonEmptyString),
         tabId: Schema.optional(PreviewTabId),
         timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
    @@ -512,7 +635,7 @@ export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClas
       },
     ) {
       override get message(): string {
    -    const summary = `No focused preview automation owner is available for ${this.operation} in thread ${this.threadId}.`;
    +    const summary = `No preview automation host is available for ${this.operation} in environment ${this.environmentId}.`;
         return summary;
       }
     }
    @@ -598,6 +721,26 @@ export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorCla
       }
     }
     
    +export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass()(
    +  "PreviewAutomationTargetNotEditableError",
    +  {
    +    ...PreviewAutomationRequestErrorFields,
    +    ...PreviewAutomationRemoteDiagnosticFields,
    +    selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])),
    +    selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
    +  },
    +) {
    +  override get message(): string {
    +    if (this.selectorKind === "focused-element") {
    +      return `Preview automation ${this.operation} requires an editable focused element.`;
    +    }
    +    if (this.selectorKind !== undefined && this.selectorLength !== undefined) {
    +      return `Preview automation ${this.operation} requires an editable ${this.selectorKind} (${this.selectorLength} characters).`;
    +    }
    +    return `Preview automation ${this.operation} requires an editable target.`;
    +  }
    +}
    +
     export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()(
       "PreviewAutomationResultTooLargeError",
       {
    @@ -615,18 +758,6 @@ export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClas
       }
     }
     
    -export class PreviewAutomationHostNotConnectedError extends Schema.TaggedErrorClass()(
    -  "PreviewAutomationHostNotConnectedError",
    -  {
    -    ...PreviewAutomationScopeErrorFields,
    -    clientId: TrimmedNonEmptyString,
    -  },
    -) {
    -  override get message(): string {
    -    return `Preview automation host ${this.clientId} is not connected for ${this.operation}.`;
    -  }
    -}
    -
     export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass()(
       "PreviewAutomationClientDisconnectedError",
       PreviewAutomationRequestErrorFields,
    @@ -668,15 +799,15 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC
     
     export const PreviewAutomationError = Schema.Union([
       PreviewAutomationUnavailableError,
    -  PreviewAutomationNoFocusedOwnerError,
    +  PreviewAutomationNoAvailableHostError,
       PreviewAutomationUnsupportedClientError,
       PreviewAutomationTabNotFoundError,
       PreviewAutomationTimeoutError,
       PreviewAutomationControlInterruptedError,
       PreviewAutomationExecutionError,
       PreviewAutomationInvalidSelectorError,
    +  PreviewAutomationTargetNotEditableError,
       PreviewAutomationResultTooLargeError,
    -  PreviewAutomationHostNotConnectedError,
       PreviewAutomationClientDisconnectedError,
       PreviewAutomationRequestQueueClosedError,
       PreviewAutomationRemoteUnavailableError,
    diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
    index a2a8e9106aa..48c5d9a774d 100644
    --- a/packages/contracts/src/rpc.ts
    +++ b/packages/contracts/src/rpc.ts
    @@ -103,14 +103,15 @@ import {
       PreviewOpenInput,
       PreviewRefreshInput,
       PreviewReportStatusInput,
    +  PreviewResizeInput,
       PreviewSessionSnapshot,
     } from "./preview.ts";
     import {
       PreviewAutomationError,
    -  PreviewAutomationOwner,
    -  PreviewAutomationOwnerIdentity,
    -  PreviewAutomationRequest,
    +  PreviewAutomationHost,
    +  PreviewAutomationHostFocus,
       PreviewAutomationResponse,
    +  PreviewAutomationStreamEvent,
     } from "./previewAutomation.ts";
     import {
       ServerConfigStreamEvent,
    @@ -190,14 +191,14 @@ export const WS_METHODS = {
       // Preview methods
       previewOpen: "preview.open",
       previewNavigate: "preview.navigate",
    +  previewResize: "preview.resize",
       previewRefresh: "preview.refresh",
       previewClose: "preview.close",
       previewList: "preview.list",
       previewReportStatus: "preview.reportStatus",
       previewAutomationConnect: "previewAutomation.connect",
       previewAutomationRespond: "previewAutomation.respond",
    -  previewAutomationReportOwner: "previewAutomation.reportOwner",
    -  previewAutomationClearOwner: "previewAutomation.clearOwner",
    +  previewAutomationFocusHost: "previewAutomation.focusHost",
     
       // Server meta
       serverGetConfig: "server.getConfig",
    @@ -528,6 +529,12 @@ export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, {
       error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
     });
     
    +export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, {
    +  payload: PreviewResizeInput,
    +  success: PreviewSessionSnapshot,
    +  error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
    +});
    +
     export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, {
       payload: PreviewRefreshInput,
       error: Schema.Union([PreviewError, EnvironmentAuthorizationError]),
    @@ -550,8 +557,8 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus,
     });
     
     export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, {
    -  payload: PreviewAutomationOwner,
    -  success: PreviewAutomationRequest,
    +  payload: PreviewAutomationHost,
    +  success: PreviewAutomationStreamEvent,
       error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
       stream: true,
     });
    @@ -561,14 +568,9 @@ export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomati
       error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
     });
     
    -export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, {
    -  payload: PreviewAutomationOwner,
    -  error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
    -});
    -
    -export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, {
    -  payload: PreviewAutomationOwnerIdentity,
    -  error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]),
    +export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, {
    +  payload: PreviewAutomationHostFocus,
    +  error: EnvironmentAuthorizationError,
     });
     
     export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, {
    @@ -728,14 +730,14 @@ export const WsRpcGroup = RpcGroup.make(
       WsSubscribeTerminalMetadataRpc,
       WsPreviewOpenRpc,
       WsPreviewNavigateRpc,
    +  WsPreviewResizeRpc,
       WsPreviewRefreshRpc,
       WsPreviewCloseRpc,
       WsPreviewListRpc,
       WsPreviewReportStatusRpc,
       WsPreviewAutomationConnectRpc,
       WsPreviewAutomationRespondRpc,
    -  WsPreviewAutomationReportOwnerRpc,
    -  WsPreviewAutomationClearOwnerRpc,
    +  WsPreviewAutomationFocusHostRpc,
       WsSubscribePreviewEventsRpc,
       WsSubscribeDiscoveredLocalServersRpc,
       WsSubscribeServerConfigRpc,
    diff --git a/packages/shared/package.json b/packages/shared/package.json
    index 5c56aaf6997..f0eab2c8b34 100644
    --- a/packages/shared/package.json
    +++ b/packages/shared/package.json
    @@ -163,6 +163,10 @@
           "types": "./src/preview.ts",
           "import": "./src/preview.ts"
         },
    +    "./previewViewport": {
    +      "types": "./src/previewViewport.ts",
    +      "import": "./src/previewViewport.ts"
    +    },
         "./filePreview": {
           "types": "./src/filePreview.ts",
           "import": "./src/filePreview.ts"
    diff --git a/packages/shared/src/previewViewport.test.ts b/packages/shared/src/previewViewport.test.ts
    new file mode 100644
    index 00000000000..3222e90d7be
    --- /dev/null
    +++ b/packages/shared/src/previewViewport.test.ts
    @@ -0,0 +1,70 @@
    +import { describe, expect, it } from "vite-plus/test";
    +
    +import {
    +  PREVIEW_VIEWPORT_PRESETS,
    +  previewViewportLabel,
    +  previewViewportPresetOrientation,
    +  resolvePreviewViewport,
    +} from "./previewViewport.ts";
    +
    +describe("previewViewport", () => {
    +  it("resolves fill and exact freeform viewports", () => {
    +    expect(resolvePreviewViewport({ mode: "fill" })).toEqual({ _tag: "fill" });
    +    expect(resolvePreviewViewport({ mode: "freeform", width: 1024, height: 768 })).toEqual({
    +      _tag: "freeform",
    +      width: 1024,
    +      height: 768,
    +    });
    +  });
    +
    +  it("resolves device presets in either orientation", () => {
    +    expect(resolvePreviewViewport({ mode: "preset", preset: "iphone-12-pro" })).toEqual({
    +      _tag: "preset",
    +      width: 390,
    +      height: 844,
    +      presetId: "iphone-12-pro",
    +    });
    +    expect(
    +      resolvePreviewViewport({
    +        mode: "preset",
    +        preset: "iphone-12-pro",
    +        orientation: "landscape",
    +      }),
    +    ).toEqual({
    +      _tag: "preset",
    +      width: 844,
    +      height: 390,
    +      presetId: "iphone-12-pro",
    +    });
    +  });
    +
    +  it("matches Chrome's standard device catalog ordering", () => {
    +    expect(PREVIEW_VIEWPORT_PRESETS.map((preset) => preset.label)).toEqual([
    +      "iPhone SE",
    +      "iPhone XR",
    +      "iPhone 12 Pro",
    +      "iPhone 14 Pro Max",
    +      "Pixel 7",
    +      "Samsung Galaxy S8+",
    +      "Samsung Galaxy S20 Ultra",
    +      "iPad Mini",
    +      "iPad Air",
    +      "iPad Pro",
    +      "Surface Pro 7",
    +      "Surface Duo",
    +      "Galaxy Z Fold 5",
    +      "Asus Zenbook Fold",
    +      "Samsung Galaxy A51/71",
    +      "Nest Hub",
    +      "Nest Hub Max",
    +    ]);
    +  });
    +
    +  it("formats settings for compact UI", () => {
    +    expect(previewViewportLabel({ _tag: "fill" })).toBe("Fill panel");
    +    expect(previewViewportLabel({ _tag: "freeform", width: 393, height: 852 })).toBe("393 × 852");
    +    expect(previewViewportPresetOrientation({ _tag: "freeform", width: 852, height: 393 })).toBe(
    +      "landscape",
    +    );
    +  });
    +});
    diff --git a/packages/shared/src/previewViewport.ts b/packages/shared/src/previewViewport.ts
    new file mode 100644
    index 00000000000..1d70bca5dfb
    --- /dev/null
    +++ b/packages/shared/src/previewViewport.ts
    @@ -0,0 +1,186 @@
    +import type {
    +  PreviewAutomationResizeInput,
    +  PreviewViewportPresetId,
    +  PreviewViewportSetting,
    +} from "@t3tools/contracts";
    +import { PREVIEW_VIEWPORT_PRESET_IDS } from "@t3tools/contracts";
    +
    +export interface PreviewViewportPreset {
    +  readonly id: PreviewViewportPresetId;
    +  readonly label: string;
    +  readonly category: "Desktop" | "Tablet" | "Phone";
    +  readonly detail: string;
    +  readonly width: number;
    +  readonly height: number;
    +}
    +
    +type PreviewViewportPresetDefinition = Omit;
    +
    +// Keep this in Chrome DevTools' default-device order. Dimensions are CSS
    +// viewport sizes from Chromium's EmulatedDevices.ts standard catalog.
    +const PREVIEW_VIEWPORT_PRESET_DEFINITIONS = {
    +  "iphone-se": {
    +    label: "iPhone SE",
    +    category: "Phone",
    +    detail: "375 × 667",
    +    width: 375,
    +    height: 667,
    +  },
    +  "iphone-xr": {
    +    label: "iPhone XR",
    +    category: "Phone",
    +    detail: "414 × 896",
    +    width: 414,
    +    height: 896,
    +  },
    +  "iphone-12-pro": {
    +    label: "iPhone 12 Pro",
    +    category: "Phone",
    +    detail: "390 × 844",
    +    width: 390,
    +    height: 844,
    +  },
    +  "iphone-14-pro-max": {
    +    label: "iPhone 14 Pro Max",
    +    category: "Phone",
    +    detail: "430 × 932",
    +    width: 430,
    +    height: 932,
    +  },
    +  "pixel-7": {
    +    label: "Pixel 7",
    +    category: "Phone",
    +    detail: "412 × 915",
    +    width: 412,
    +    height: 915,
    +  },
    +  "samsung-galaxy-s8-plus": {
    +    label: "Samsung Galaxy S8+",
    +    category: "Phone",
    +    detail: "360 × 740",
    +    width: 360,
    +    height: 740,
    +  },
    +  "samsung-galaxy-s20-ultra": {
    +    label: "Samsung Galaxy S20 Ultra",
    +    category: "Phone",
    +    detail: "412 × 915",
    +    width: 412,
    +    height: 915,
    +  },
    +  "ipad-mini": {
    +    label: "iPad Mini",
    +    category: "Tablet",
    +    detail: "768 × 1024",
    +    width: 768,
    +    height: 1024,
    +  },
    +  "ipad-air": {
    +    label: "iPad Air",
    +    category: "Tablet",
    +    detail: "820 × 1180",
    +    width: 820,
    +    height: 1180,
    +  },
    +  "ipad-pro": {
    +    label: "iPad Pro",
    +    category: "Tablet",
    +    detail: "1024 × 1366",
    +    width: 1024,
    +    height: 1366,
    +  },
    +  "surface-pro-7": {
    +    label: "Surface Pro 7",
    +    category: "Tablet",
    +    detail: "912 × 1368",
    +    width: 912,
    +    height: 1368,
    +  },
    +  "surface-duo": {
    +    label: "Surface Duo",
    +    category: "Phone",
    +    detail: "540 × 720",
    +    width: 540,
    +    height: 720,
    +  },
    +  "galaxy-z-fold-5": {
    +    label: "Galaxy Z Fold 5",
    +    category: "Phone",
    +    detail: "344 × 882",
    +    width: 344,
    +    height: 882,
    +  },
    +  "asus-zenbook-fold": {
    +    label: "Asus Zenbook Fold",
    +    category: "Tablet",
    +    detail: "853 × 1280",
    +    width: 853,
    +    height: 1280,
    +  },
    +  "samsung-galaxy-a51-71": {
    +    label: "Samsung Galaxy A51/71",
    +    category: "Phone",
    +    detail: "412 × 914",
    +    width: 412,
    +    height: 914,
    +  },
    +  "nest-hub": {
    +    label: "Nest Hub",
    +    category: "Tablet",
    +    detail: "1024 × 600",
    +    width: 1024,
    +    height: 600,
    +  },
    +  "nest-hub-max": {
    +    label: "Nest Hub Max",
    +    category: "Tablet",
    +    detail: "1280 × 800",
    +    width: 1280,
    +    height: 800,
    +  },
    +} as const satisfies Record;
    +
    +export const PREVIEW_VIEWPORT_PRESETS: ReadonlyArray =
    +  PREVIEW_VIEWPORT_PRESET_IDS.map((id) => ({
    +    id,
    +    ...PREVIEW_VIEWPORT_PRESET_DEFINITIONS[id],
    +  }));
    +
    +export function resolvePreviewViewport(
    +  input: PreviewAutomationResizeInput,
    +): PreviewViewportSetting {
    +  if (input.mode === "fill") return { _tag: "fill" };
    +  if (input.mode === "preset" && input.preset !== undefined) {
    +    const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === input.preset);
    +    if (!preset) throw new Error(`Unknown preview viewport preset: ${input.preset}`);
    +    const landscape = input.orientation === "landscape";
    +    const portrait = input.orientation === "portrait";
    +    const nativePortrait = preset.height >= preset.width;
    +    const shouldSwap = (landscape && nativePortrait) || (portrait && !nativePortrait);
    +    return {
    +      _tag: "preset",
    +      width: shouldSwap ? preset.height : preset.width,
    +      height: shouldSwap ? preset.width : preset.height,
    +      presetId: preset.id,
    +    };
    +  }
    +  if (input.width === undefined || input.height === undefined) {
    +    throw new Error("Custom preview viewport requires width and height");
    +  }
    +  return {
    +    _tag: "freeform",
    +    width: input.width,
    +    height: input.height,
    +  };
    +}
    +
    +export function previewViewportLabel(viewport: PreviewViewportSetting): string {
    +  return viewport._tag === "fill" ? "Fill panel" : `${viewport.width} × ${viewport.height}`;
    +}
    +
    +export function previewViewportPresetOrientation(
    +  viewport: PreviewViewportSetting,
    +): "portrait" | "landscape" | null {
    +  if (viewport._tag === "fill" || viewport.width === viewport.height) return null;
    +  return viewport.width > viewport.height ? "landscape" : "portrait";
    +}
    diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts
    index 85d57c4181f..432b50cf728 100644
    --- a/scripts/dev-runner.test.ts
    +++ b/scripts/dev-runner.test.ts
    @@ -253,6 +253,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
                 T3CODE_MODE: "web",
                 T3CODE_NO_BROWSER: "0",
                 T3CODE_HOST: "0.0.0.0",
    +            VITE_DEV_SERVER_URL: "http://127.0.0.1:8526",
                 VITE_WS_URL: "ws://localhost:13773",
               },
               serverOffset: 0,
    diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts
    index fb82310bbd3..2953a2f4dec 100644
    --- a/scripts/dev-runner.ts
    +++ b/scripts/dev-runner.ts
    @@ -158,12 +158,6 @@ const optionalIntegerConfig = (name: string): Config.Config
         Config.option,
         Config.map((value) => Option.getOrUndefined(value)),
       );
    -const optionalUrlConfig = (name: string): Config.Config =>
    -  Config.url(name).pipe(
    -    Config.option,
    -    Config.map((value) => Option.getOrUndefined(value)),
    -  );
    -
     const OffsetConfig = Config.all({
       portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"),
       devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"),
    @@ -621,8 +615,11 @@ const devRunnerCli = Command.make("dev-runner", {
       ),
       devUrl: Flag.string("dev-url").pipe(
         Flag.withSchema(Schema.URLFromString),
    -    Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."),
    -    Flag.withFallbackConfig(optionalUrlConfig("VITE_DEV_SERVER_URL")),
    +    Flag.withDescription(
    +      "Explicit web dev URL override (forwards to VITE_DEV_SERVER_URL). Ambient VITE_DEV_SERVER_URL values are ignored so a parent dev app cannot redirect the child runner.",
    +    ),
    +    Flag.optional,
    +    Flag.map(Option.getOrUndefined),
       ),
       dryRun: Flag.boolean("dry-run").pipe(
         Flag.withDescription("Resolve mode/ports/env and print, but do not spawn Vite+."),
    
    From e9ed70c5b935d8653c887c6a2f5c1f93013579d4 Mon Sep 17 00:00:00 2001
    From: Julius Marminge 
    Date: Thu, 25 Jun 2026 20:08:27 -0700
    Subject: [PATCH 21/28] [codex] Fix preview automation edge cases (#3561)
    
    Co-authored-by: codex 
    ---
     apps/desktop/src/preview/Manager.test.ts      |  2 +
     apps/desktop/src/preview/Manager.ts           |  6 ++-
     .../src/preview/PreviewKeyboard.test.ts       | 15 +++++-
     apps/desktop/src/preview/PreviewKeyboard.ts   | 40 ++++++++++++++++
     .../preview/PreviewAutomationHosts.tsx        | 19 +++++---
     .../previewAutomationOpenReadiness.test.ts    | 46 +++++++++++++++++++
     .../preview/previewAutomationOpenReadiness.ts |  8 ++++
     7 files changed, 128 insertions(+), 8 deletions(-)
     create mode 100644 apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts
     create mode 100644 apps/web/src/components/preview/previewAutomationOpenReadiness.ts
    
    diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
    index ab76e4a3f24..6e2df118c34 100644
    --- a/apps/desktop/src/preview/Manager.test.ts
    +++ b/apps/desktop/src/preview/Manager.test.ts
    @@ -1,4 +1,5 @@
     import { it as effectIt } from "@effect/vitest";
    +import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
     import * as Cause from "effect/Cause";
     import * as Effect from "effect/Effect";
     import * as Exit from "effect/Exit";
    @@ -91,6 +92,7 @@ const layer = PreviewManager.layer.pipe(
       Layer.provideMerge(environmentLayer),
       Layer.provideMerge(fileSystemLayer),
       Layer.provideMerge(Path.layer),
    +  Layer.provideMerge(Layer.succeed(HostProcessPlatform, "linux")),
     );
     const encodePreviewManagerError = Schema.encodeSync(PreviewManager.PreviewManagerError);
     
    diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
    index 43b2259ead0..2d0360ef72c 100644
    --- a/apps/desktop/src/preview/Manager.ts
    +++ b/apps/desktop/src/preview/Manager.ts
    @@ -25,6 +25,7 @@ import type {
       PreviewAutomationTypeInput,
       PreviewAutomationWaitForInput,
     } from "@t3tools/contracts";
    +import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
     import { normalizePreviewUrl } from "@t3tools/shared/preview";
     import {
       type BrowserWindow,
    @@ -379,6 +380,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
       artifactDirectory: string,
     ) {
       const fileSystem = yield* FileSystem.FileSystem;
    +  const hostPlatform = yield* HostProcessPlatform;
       const path = yield* Path.Path;
       const parentScope = yield* Scope.Scope;
       const context = yield* Effect.context();
    @@ -2226,7 +2228,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
         sendCleanup: SendCommand,
       ) {
         yield* prepareAutomationInput(send, false);
    -    const keySequence = makePreviewAutomationKeySequence(input);
    +    const keySequence = makePreviewAutomationKeySequence(input, {
    +      isMac: hostPlatform === "darwin",
    +    });
         const previouslyFocused = yield* attempt(
           { operation: "automationPress.getFocusedWebContents", tabId, webContentsId: wc.id },
           () => webContents.getFocusedWebContents(),
    diff --git a/apps/desktop/src/preview/PreviewKeyboard.test.ts b/apps/desktop/src/preview/PreviewKeyboard.test.ts
    index 3acdcf0d1e5..7a9a7373fe3 100644
    --- a/apps/desktop/src/preview/PreviewKeyboard.test.ts
    +++ b/apps/desktop/src/preview/PreviewKeyboard.test.ts
    @@ -42,7 +42,9 @@ describe("preview keyboard packets", () => {
       });
     
       it("suppresses text and uses raw key-down for shortcuts", () => {
    -    expect(makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown).toEqual({
    +    expect(
    +      makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }, { isMac: true }).keyDown,
    +    ).toEqual({
           type: "rawKeyDown",
           key: "a",
           code: "KeyA",
    @@ -50,9 +52,20 @@ describe("preview keyboard packets", () => {
           windowsVirtualKeyCode: 65,
           location: 0,
           isKeypad: false,
    +      commands: ["selectAll"],
         });
       });
     
    +  it("maps common macOS editing shortcuts without changing other platforms", () => {
    +    expect(
    +      makePreviewAutomationKeySequence({ key: "z", modifiers: ["Shift", "Meta"] }, { isMac: true })
    +        .keyDown.commands,
    +    ).toEqual(["redo"]);
    +    expect(
    +      makePreviewAutomationKeySequence({ key: "a", modifiers: ["Meta"] }).keyDown,
    +    ).not.toHaveProperty("commands");
    +  });
    +
       it("resolves shifted printable keys to their browser values", () => {
         const sequence = makePreviewAutomationKeySequence({ key: "1", modifiers: ["Shift"] });
         expect(sequence.keyDown).toMatchObject({
    diff --git a/apps/desktop/src/preview/PreviewKeyboard.ts b/apps/desktop/src/preview/PreviewKeyboard.ts
    index 6246cf5e558..0d231b86f4c 100644
    --- a/apps/desktop/src/preview/PreviewKeyboard.ts
    +++ b/apps/desktop/src/preview/PreviewKeyboard.ts
    @@ -20,6 +20,7 @@ export interface PreviewAutomationKeyEvent {
       readonly isKeypad: boolean;
       readonly text?: string;
       readonly unmodifiedText?: string;
    +  readonly commands?: ReadonlyArray;
     }
     
     export interface PreviewAutomationKeySequence {
    @@ -79,6 +80,42 @@ const PRINTABLE_KEYS: ReadonlyArray = [
       { code: "Slash", key: "/", shiftedKey: "?", keyCode: 191 },
     ];
     
    +/**
    + * Chromium does not infer macOS editing commands from synthetic Meta chords.
    + * Keep the common browser editing/navigation shortcuts explicit so dispatched
    + * key events behave like their physical-key equivalents.
    + */
    +const MAC_EDITING_COMMANDS: Readonly> = {
    +  "Meta+Backspace": "deleteToBeginningOfLine",
    +  "Meta+ArrowUp": "moveToBeginningOfDocument",
    +  "Meta+ArrowDown": "moveToEndOfDocument",
    +  "Meta+ArrowLeft": "moveToLeftEndOfLine",
    +  "Meta+ArrowRight": "moveToRightEndOfLine",
    +  "Shift+Meta+ArrowUp": "moveToBeginningOfDocumentAndModifySelection",
    +  "Shift+Meta+ArrowDown": "moveToEndOfDocumentAndModifySelection",
    +  "Shift+Meta+ArrowLeft": "moveToLeftEndOfLineAndModifySelection",
    +  "Shift+Meta+ArrowRight": "moveToRightEndOfLineAndModifySelection",
    +  "Meta+KeyA": "selectAll",
    +  "Meta+KeyC": "copy",
    +  "Meta+KeyX": "cut",
    +  "Meta+KeyV": "paste",
    +  "Meta+KeyZ": "undo",
    +  "Shift+Meta+KeyZ": "redo",
    +};
    +const SHORTCUT_MODIFIER_ORDER = ["Shift", "Control", "Alt", "Meta"] as const;
    +
    +const macEditingCommands = (
    +  code: string,
    +  modifiers: PreviewAutomationPressInput["modifiers"],
    +): ReadonlyArray => {
    +  const shortcut = [
    +    ...SHORTCUT_MODIFIER_ORDER.filter((modifier) => modifiers?.includes(modifier)),
    +    code,
    +  ].join("+");
    +  const command = MAC_EDITING_COMMANDS[shortcut];
    +  return command ? [command] : [];
    +};
    +
     const modifierMask = (modifiers: PreviewAutomationPressInput["modifiers"]): number =>
       (modifiers ?? []).reduce((value, modifier) => {
         switch (modifier) {
    @@ -136,12 +173,14 @@ function resolveKeyDefinition(input: PreviewAutomationPressInput): KeyDefinition
      */
     export function makePreviewAutomationKeySequence(
       input: PreviewAutomationPressInput,
    +  options?: { readonly isMac?: boolean },
     ): PreviewAutomationKeySequence {
       const definition = resolveKeyDefinition(input);
       const modifiers = modifierMask(input.modifiers);
       const suppressText = input.modifiers?.some((modifier) => modifier !== "Shift") ?? false;
       const text = suppressText ? "" : (definition.text ?? "");
       const location = definition.location ?? 0;
    +  const commands = options?.isMac ? macEditingCommands(definition.code, input.modifiers) : [];
       const shared = {
         key: definition.key,
         code: definition.code,
    @@ -156,6 +195,7 @@ export function makePreviewAutomationKeySequence(
           type: text ? "keyDown" : "rawKeyDown",
           ...shared,
           ...(text ? { text, unmodifiedText: text } : {}),
    +      ...(commands.length > 0 ? { commands } : {}),
         },
         keyUp: { type: "keyUp", ...shared },
         signal: { kind: "key", key: definition.key, code: definition.code },
    diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
    index 2cd8494691f..94d8c13a3cb 100644
    --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx
    +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx
    @@ -51,6 +51,7 @@ import {
       PreviewAutomationTargetUnavailableError,
       PreviewAutomationViewportTimeoutError,
     } from "./previewAutomationErrors";
    +import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness";
     import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer";
     import { createPreviewAutomationClientId } from "./previewAutomationClientId";
     import {
    @@ -333,6 +334,9 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
                 const input = request.input as PreviewAutomationOpenInput;
                 let activeTabId =
                   (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;
    +            let activeSnapshot = activeTabId
    +              ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined)
    +              : undefined;
                 const reusedExistingTab = activeTabId !== null;
                 tabId = activeTabId;
                 if (!activeTabId) {
    @@ -349,17 +353,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
                   const snapshot = result.value;
                   applyPreviewServerSnapshot(threadRef, snapshot);
                   activeTabId = snapshot.tabId;
    +              activeSnapshot = snapshot;
                   tabId = activeTabId;
                 }
                 if (input.show ?? true) {
                   useRightPanelStore.getState().openBrowser(threadRef, activeTabId);
                 }
    -            await waitForDesktopOverlay(
    -              threadRef,
    -              request.requestId,
    -              activeTabId,
    -              request.timeoutMs,
    -            );
    +            if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) {
    +              await waitForDesktopOverlay(
    +                threadRef,
    +                request.requestId,
    +                activeTabId,
    +                request.timeoutMs,
    +              );
    +            }
                 if (reusedExistingTab && input.url && previewBridge) {
                   const resolution = resolveBrowserNavigationTarget(environmentId, {
                     kind: "url",
    diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts
    new file mode 100644
    index 00000000000..90de86f799d
    --- /dev/null
    +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts
    @@ -0,0 +1,46 @@
    +import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts";
    +import { describe, expect, it } from "vite-plus/test";
    +
    +import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness";
    +
    +const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({
    +  threadId: "thread-1",
    +  tabId: "tab-1",
    +  navStatus,
    +  canGoBack: false,
    +  canGoForward: false,
    +  updatedAt: "2026-06-26T00:00:00.000Z",
    +});
    +
    +describe("preview automation open readiness", () => {
    +  it("does not wait for a desktop overlay when opening an empty tab", () => {
    +    expect(
    +      previewAutomationOpenNeedsOverlay(
    +        {} as PreviewAutomationOpenInput,
    +        snapshot({ _tag: "Idle" }),
    +      ),
    +    ).toBe(false);
    +  });
    +
    +  it("waits when an empty tab is immediately given a URL", () => {
    +    expect(
    +      previewAutomationOpenNeedsOverlay(
    +        { url: "https://example.com" } as PreviewAutomationOpenInput,
    +        snapshot({ _tag: "Idle" }),
    +      ),
    +    ).toBe(true);
    +  });
    +
    +  it("waits for existing tabs that already have rendered content", () => {
    +    expect(
    +      previewAutomationOpenNeedsOverlay(
    +        {} as PreviewAutomationOpenInput,
    +        snapshot({
    +          _tag: "Success",
    +          url: "https://example.com/",
    +          title: "Example",
    +        }),
    +      ),
    +    ).toBe(true);
    +  });
    +});
    diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts
    new file mode 100644
    index 00000000000..416c2f87c64
    --- /dev/null
    +++ b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts
    @@ -0,0 +1,8 @@
    +import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts";
    +
    +export function previewAutomationOpenNeedsOverlay(
    +  input: PreviewAutomationOpenInput,
    +  snapshot: PreviewSessionSnapshot,
    +): boolean {
    +  return input.url !== undefined || snapshot.navStatus._tag !== "Idle";
    +}
    
    From 52b04b947e3604e426386be53e6d20c6a4366fef Mon Sep 17 00:00:00 2001
    From: Mike Olson 
    Date: Fri, 26 Jun 2026 02:34:08 -0400
    Subject: [PATCH 22/28] fix(grok): Harden ACP resume with replay-idle load
     readiness (#3156)
    
    Co-authored-by: Julius Marminge 
    Co-authored-by: codex 
    ---
     apps/server/scripts/acp-mock-agent.ts         | 273 +++++-
     .../src/provider/Layers/CursorAdapter.test.ts |  22 +-
     .../src/provider/Layers/CursorAdapter.ts      |   3 +
     .../src/provider/Layers/GrokAdapter.test.ts   | 829 +++++++++++++++++-
     .../server/src/provider/Layers/GrokAdapter.ts | 568 ++++++++++--
     .../provider/acp/AcpJsonRpcConnection.test.ts | 222 +++++
     .../src/provider/acp/AcpRuntimeModel.test.ts  |  91 ++
     .../src/provider/acp/AcpRuntimeModel.ts       |  91 ++
     .../src/provider/acp/AcpSessionRuntime.ts     | 223 ++++-
     .../server/src/provider/acp/GrokAcpSupport.ts |   4 +-
     .../src/provider/acp/XAiAcpExtension.test.ts  |  84 +-
     .../src/provider/acp/XAiAcpExtension.ts       | 259 ++++++
     .../web/src/components/ChatView.logic.test.ts |  25 +
     apps/web/src/components/ChatView.logic.ts     |  11 +
     apps/web/src/components/ChatView.tsx          |  10 +-
     .../chat/MessagesTimeline.logic.test.ts       |  61 ++
     .../components/chat/MessagesTimeline.logic.ts |  25 +-
     .../components/chat/MessagesTimeline.test.tsx |   1 +
     .../src/components/chat/MessagesTimeline.tsx  |   4 +
     packages/effect-acp/src/client.test.ts        | 129 ++-
     20 files changed, 2802 insertions(+), 133 deletions(-)
    
    diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts
    index 0d89775844d..bc7828dd854 100644
    --- a/apps/server/scripts/acp-mock-agent.ts
    +++ b/apps/server/scripts/acp-mock-agent.ts
    @@ -19,6 +19,23 @@ const emitInterleavedAssistantToolCalls =
     const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
     const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
     const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
    +const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1";
    +const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";
    +const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";
    +const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";
    +const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1";
    +const omitXAiPromptCompleteStopReason =
    +  process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1";
    +const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1";
    +const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1";
    +const hangLoadSessionAfterReplay = process.env.T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY === "1";
    +const delayLoadSessionAfterReplay = process.env.T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY === "1";
    +const loadSessionDelayMs = Number(process.env.T3_ACP_LOAD_SESSION_DELAY_MS ?? "5000");
    +const emitStaleXAiPromptCompleteBeforeSecondHang =
    +  process.env.T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG === "1";
    +const emitOverlappingXAiPromptCompleteOutOfOrder =
    +  process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1";
    +const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";
     const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
     const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
     const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
    @@ -36,8 +53,21 @@ let parameterizedModelPicker = false;
     let currentReasoning = "medium";
     let currentContext = "272k";
     let currentFast = false;
    +let promptCount = 0;
    +let overlappingFirstPromptId: string | undefined;
     const cancelledSessions = new Set();
     
    +function promptIdFromRequestMeta(
    +  request: Pick,
    +): string | undefined {
    +  const meta = request._meta;
    +  if (meta === null || typeof meta !== "object") {
    +    return undefined;
    +  }
    +  const promptId = meta.promptId ?? meta.requestId;
    +  return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined;
    +}
    +
     function logExit(reason: string): void {
       if (!exitLogPath) {
         return;
    @@ -45,6 +75,10 @@ function logExit(reason: string): void {
       NodeFS.appendFileSync(exitLogPath, `${reason}\n`, "utf8");
     }
     
    +function writeJsonRpcNotification(method: string, params: unknown): void {
    +  process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
    +}
    +
     process.once("SIGTERM", () => {
       logExit("SIGTERM");
       process.exit(0);
    @@ -284,22 +318,66 @@ const program = Effect.gen(function* () {
         }),
       );
     
    +  const emitLoadReplayNotifications = (requestedSessionId: string) => {
    +    writeJsonRpcNotification("session/update", {
    +      _meta: { isReplay: true },
    +      sessionId: requestedSessionId,
    +      update: {
    +        sessionUpdate: "tool_call",
    +        toolCallId: "replay-tool-1",
    +        title: "Replay tool",
    +        kind: "search",
    +        status: "completed",
    +      },
    +    });
    +    writeJsonRpcNotification("session/update", {
    +      _meta: { isReplay: true },
    +      sessionId: requestedSessionId,
    +      update: {
    +        sessionUpdate: "agent_message_chunk",
    +        content: { type: "text", text: "replayed assistant text" },
    +      },
    +    });
    +  };
    +
       yield* agent.handleLoadSession((request) =>
    -    agent.client
    -      .sessionUpdate({
    -        sessionId: String(request.sessionId ?? sessionId),
    +    Effect.gen(function* () {
    +      const requestedSessionId = String(request.sessionId ?? sessionId);
    +      if (failLoadSession) {
    +        return yield* AcpError.AcpRequestError.internalError("Mock load session failure");
    +      }
    +      if (hangLoadSessionAfterReplay || delayLoadSessionAfterReplay) {
    +        emitLoadReplayNotifications(requestedSessionId);
    +        yield* agent.client.sessionUpdate({
    +          sessionId: requestedSessionId,
    +          update: {
    +            sessionUpdate: "user_message_chunk",
    +            content: { type: "text", text: "replay-tail" },
    +          },
    +        });
    +        yield* Effect.sleep(loadSessionDelayMs);
    +        return {
    +          modes: modeState(),
    +          models: modelState(),
    +          configOptions: configOptions(),
    +        };
    +      }
    +      if (emitLoadReplay) {
    +        emitLoadReplayNotifications(requestedSessionId);
    +      }
    +      yield* agent.client.sessionUpdate({
    +        sessionId: requestedSessionId,
             update: {
               sessionUpdate: "user_message_chunk",
               content: { type: "text", text: "replay" },
             },
    -      })
    -      .pipe(
    -        Effect.as({
    -          modes: modeState(),
    -          models: modelState(),
    -          configOptions: configOptions(),
    -        }),
    -      ),
    +      });
    +      return {
    +        modes: modeState(),
    +        models: modelState(),
    +        configOptions: configOptions(),
    +      };
    +    }),
       );
     
       yield* agent.handleSetSessionModel((request) =>
    @@ -356,19 +434,152 @@ const program = Effect.gen(function* () {
       );
     
       yield* agent.handleCancel(({ sessionId }) =>
    -    Effect.sync(() => {
    -      cancelledSessions.add(String(sessionId ?? "mock-session-1"));
    +    Effect.gen(function* () {
    +      const cancelledSessionId = String(sessionId ?? "mock-session-1");
    +      cancelledSessions.add(cancelledSessionId);
    +      if (emitLateUpdateAfterCancel) {
    +        yield* Effect.sleep("50 millis");
    +        yield* Effect.sync(() => {
    +          writeJsonRpcNotification("session/update", {
    +            sessionId: cancelledSessionId,
    +            update: {
    +              sessionUpdate: "agent_message_chunk",
    +              content: { type: "text", text: "late after cancel" },
    +            },
    +          });
    +        });
    +      }
         }),
       );
     
       yield* agent.handlePrompt((request) =>
         Effect.gen(function* () {
           const requestedSessionId = String(request.sessionId ?? sessionId);
    +      promptCount += 1;
     
           if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {
             yield* Effect.sleep(`${promptDelayMs} millis`);
           }
     
    +      if (failPrompt) {
    +        return yield* AcpError.AcpRequestError.internalError("Mock prompt failure");
    +      }
    +
    +      if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) {
    +        return {
    +          stopReason: "end_turn",
    +          _meta: {
    +            promptId: "mock-stale-xai-prompt-1",
    +            requestId: "mock-stale-xai-prompt-1",
    +          },
    +        };
    +      }
    +
    +      if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 2) {
    +        const currentPromptId = promptIdFromRequestMeta(request) ?? "mock-current-xai-prompt-2";
    +        writeJsonRpcNotification("_x.ai/session/prompt_complete", {
    +          sessionId: requestedSessionId,
    +          promptId: "mock-stale-xai-prompt-1",
    +          stopReason: "end_turn",
    +          agentResult: null,
    +        });
    +
    +        writeJsonRpcNotification("_x.ai/session/prompt_complete", {
    +          sessionId: requestedSessionId,
    +          promptId: currentPromptId,
    +          stopReason: "end_turn",
    +          agentResult: null,
    +        });
    +
    +        return yield* Effect.never;
    +      }
    +
    +      if (emitOverlappingXAiPromptCompleteOutOfOrder && promptCount === 1) {
    +        overlappingFirstPromptId = promptIdFromRequestMeta(request);
    +        return yield* Effect.never;
    +      }
    +
    +      if (emitOverlappingXAiPromptCompleteOutOfOrder && promptCount === 2) {
    +        const secondPromptId = promptIdFromRequestMeta(request);
    +        if (overlappingFirstPromptId !== undefined && secondPromptId !== undefined) {
    +          writeJsonRpcNotification("_x.ai/session/prompt_complete", {
    +            sessionId: requestedSessionId,
    +            promptId: secondPromptId,
    +            stopReason: "end_turn",
    +            agentResult: null,
    +          });
    +          writeJsonRpcNotification("_x.ai/session/prompt_complete", {
    +            sessionId: requestedSessionId,
    +            promptId: overlappingFirstPromptId,
    +            stopReason: "end_turn",
    +            agentResult: null,
    +          });
    +        }
    +        return yield* Effect.never;
    +      }
    +
    +      if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) {
    +        return yield* Effect.never;
    +      }
    +
    +      if (emitXAiPromptCompleteThenHang) {
    +        writeJsonRpcNotification("session/update", {
    +          sessionId: requestedSessionId,
    +          update: {
    +            sessionUpdate: "agent_message_chunk",
    +            content: { type: "text", text: "hello from " },
    +          },
    +        });
    +
    +        if (emitForeignSessionUpdates) {
    +          writeJsonRpcNotification("session/update", {
    +            sessionId: "mock-child-session-1",
    +            update: {
    +              sessionUpdate: "agent_message_chunk",
    +              content: { type: "text", text: "child before completion" },
    +            },
    +          });
    +        }
    +
    +        writeJsonRpcNotification("_x.ai/session/prompt_complete", {
    +          sessionId: requestedSessionId,
    +          promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1",
    +          ...(omitXAiPromptCompleteStopReason ? {} : { stopReason: "end_turn" }),
    +          agentResult: null,
    +        });
    +
    +        if (emitForeignSessionUpdates) {
    +          writeJsonRpcNotification("session/update", {
    +            sessionId: "mock-child-session-1",
    +            update: {
    +              sessionUpdate: "tool_call",
    +              toolCallId: "child-tool-call-1",
    +              title: "Child-only tool",
    +              kind: "other",
    +              status: "pending",
    +              rawInput: {},
    +            },
    +          });
    +          writeJsonRpcNotification("session/update", {
    +            sessionId: "mock-child-session-1",
    +            update: {
    +              sessionUpdate: "agent_message_chunk",
    +              content: { type: "text", text: "child after completion" },
    +            },
    +          });
    +        }
    +
    +        writeJsonRpcNotification("session/update", {
    +          sessionId: requestedSessionId,
    +          update: {
    +            sessionUpdate: "agent_message_chunk",
    +            content: { type: "text", text: "mock" },
    +          },
    +        });
    +
    +        return yield* Effect.never;
    +      }
    +
           if (emitInterleavedAssistantToolCalls) {
             const toolCallId = "tool-call-1";
     
    @@ -599,6 +810,42 @@ const program = Effect.gen(function* () {
             return { stopReason: "end_turn" };
           }
     
    +      if (emitForeignSessionUpdates) {
    +        yield* agent.client.sessionUpdate({
    +          sessionId: requestedSessionId,
    +          update: {
    +            sessionUpdate: "agent_message_chunk",
    +            content: { type: "text", text: "root before child" },
    +          },
    +        });
    +        yield* agent.client.sessionUpdate({
    +          sessionId: "mock-child-session-1",
    +          update: {
    +            sessionUpdate: "agent_message_chunk",
    +            content: { type: "text", text: "child content" },
    +          },
    +        });
    +        yield* agent.client.sessionUpdate({
    +          sessionId: "mock-child-session-1",
    +          update: {
    +            sessionUpdate: "tool_call",
    +            toolCallId: "child-tool-call-1",
    +            title: "Child-only tool",
    +            kind: "other",
    +            status: "pending",
    +            rawInput: {},
    +          },
    +        });
    +        yield* agent.client.sessionUpdate({
    +          sessionId: requestedSessionId,
    +          update: {
    +            sessionUpdate: "agent_message_chunk",
    +            content: { type: "text", text: " root after child" },
    +          },
    +        });
    +        return { stopReason: "end_turn" };
    +      }
    +
           yield* agent.client.sessionUpdate({
             sessionId: requestedSessionId,
             update: {
    diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts
    index 9795e5a0680..73dc0967622 100644
    --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts
    +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts
    @@ -113,6 +113,23 @@ async function waitForFileContent(filePath: string, attempts = 40) {
       throw new Error(`Timed out waiting for file content at ${filePath}`);
     }
     
    +function waitForJsonLogMatch(
    +  filePath: string,
    +  predicate: (entry: Record) => boolean,
    +  attempts = 40,
    +) {
    +  return Effect.gen(function* () {
    +    for (let attempt = 0; attempt < attempts; attempt += 1) {
    +      const requests = yield* Effect.promise(() => readJsonLines(filePath));
    +      if (requests.some(predicate)) {
    +        return requests;
    +      }
    +      yield* Effect.yieldNow;
    +    }
    +    return yield* Effect.promise(() => readJsonLines(filePath));
    +  });
    +}
    +
     // Tests mutate `ServerSettingsService` mid-flight (e.g. setting
     // `providers.cursor.binaryPath` to a mock ACP wrapper). The adapter
     // captures `cursorSettings` once at construction, so without a resolver
    @@ -1004,7 +1021,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
             assert.equal(turnCompleted.payload.stopReason, "cancelled");
           }
     
    -      const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
    +      const requests = yield* waitForJsonLogMatch(
    +        requestLogPath,
    +        (entry) => entry.method === "session/cancel",
    +      );
           assert.isTrue(requests.some((entry) => entry.method === "session/cancel"));
           assert.isTrue(
             requests.some(
    diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts
    index 9760b2f81fb..59788a2d225 100644
    --- a/apps/server/src/provider/Layers/CursorAdapter.ts
    +++ b/apps/server/src/provider/Layers/CursorAdapter.ts
    @@ -785,6 +785,9 @@ export function makeCursorAdapter(
                 Stream.mapEffect(acp.getEvents(), (event) =>
                   Effect.gen(function* () {
                     switch (event._tag) {
    +                  case "EventStreamBarrier":
    +                    yield* Deferred.succeed(event.acknowledge, undefined);
    +                    return;
                       case "ModeChanged":
                         return;
                       case "AssistantItemStarted":
    diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts
    index c871e3c2fc4..7b6f0972ae8 100644
    --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts
    +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts
    @@ -10,20 +10,23 @@ import * as Deferred from "effect/Deferred";
     import * as Effect from "effect/Effect";
     import * as Fiber from "effect/Fiber";
     import * as Layer from "effect/Layer";
    +import * as Ref from "effect/Ref";
     import * as Schema from "effect/Schema";
     import * as Stream from "effect/Stream";
    +import * as TestClock from "effect/testing/TestClock";
     
     import {
       ApprovalRequestId,
       GrokSettings,
       ProviderDriverKind,
    -  ThreadId,
       ProviderInstanceId,
    +  ThreadId,
    +  TurnId,
       type ProviderRuntimeEvent,
     } from "@t3tools/contracts";
     
     import { ServerConfig } from "../../config.ts";
    -import { makeGrokAdapter } from "./GrokAdapter.ts";
    +import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts";
     const decodeGrokSettings = Schema.decodeSync(GrokSettings);
     
     const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
    @@ -45,7 +48,11 @@ exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@"
       return wrapperPath;
     }
     
    -function waitForFileContent(filePath: string, attempts = 40): Effect.Effect {
    +function waitForFileContent(
    +  filePath: string,
    +  attempts = 40,
    +  expectedContent?: string,
    +): Effect.Effect {
       const readAttempt = (remainingAttempts: number): Effect.Effect =>
         Effect.gen(function* () {
           if (remainingAttempts <= 0) {
    @@ -54,7 +61,10 @@ function waitForFileContent(filePath: string, attempts = 40): Effect.Effect NodeFSP.readFile(filePath, "utf8")).pipe(
             Effect.orElseSucceed(() => ""),
           );
    -      if (raw.trim().length > 0) {
    +      if (
    +        raw.trim().length > 0 &&
    +        (expectedContent === undefined || raw.includes(expectedContent))
    +      ) {
             return raw;
           }
           yield* Effect.sleep("25 millis");
    @@ -79,6 +89,39 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), {
     const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) =>
       makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie);
     
    +it("requires a settlement to match the live Grok turn", () => {
    +  const staleTurnId = TurnId.make("stale-turn");
    +  const replacementTurnId = TurnId.make("replacement-turn");
    +
    +  assert.isFalse(
    +    grokPromptSettlementBelongsToContext({
    +      liveAcpSessionId: "session-1",
    +      expectedAcpSessionId: "session-1",
    +      liveActiveTurnId: replacementTurnId,
    +      liveSessionActiveTurnId: replacementTurnId,
    +      turnId: staleTurnId,
    +    }),
    +  );
    +  assert.isFalse(
    +    grokPromptSettlementBelongsToContext({
    +      liveAcpSessionId: "replacement-session",
    +      expectedAcpSessionId: "stale-session",
    +      liveActiveTurnId: staleTurnId,
    +      liveSessionActiveTurnId: staleTurnId,
    +      turnId: staleTurnId,
    +    }),
    +  );
    +  assert.isTrue(
    +    grokPromptSettlementBelongsToContext({
    +      liveAcpSessionId: "session-1",
    +      expectedAcpSessionId: "session-1",
    +      liveActiveTurnId: staleTurnId,
    +      liveSessionActiveTurnId: staleTurnId,
    +      turnId: staleTurnId,
    +    }),
    +  );
    +});
    +
     it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
       it.effect("starts a session and maps mock ACP prompt flow to runtime events", () =>
         Effect.gen(function* () {
    @@ -175,6 +218,783 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
         }),
       );
     
    +  it.effect("reports a Grok session running only while the prompt is in flight", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-session-ready-after-prompt");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_EMIT_TOOL_CALLS: "1",
    +        }),
    +      );
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +      const requestOpened =
    +        yield* Deferred.make>();
    +      const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        event.type === "request.opened"
    +          ? Deferred.succeed(requestOpened, event).pipe(Effect.ignore)
    +          : Effect.void,
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "approval-required",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +      });
    +
    +      const sendTurnFiber = yield* adapter
    +        .sendTurn({ threadId, input: "check lifecycle", attachments: [] })
    +        .pipe(Effect.forkChild);
    +      const requestOpenedEvent = yield* Deferred.await(requestOpened);
    +
    +      const runningSessions = yield* adapter.listSessions();
    +      const runningSession = runningSessions.find((session) => session.threadId === threadId);
    +      assert.equal(runningSession?.status, "running");
    +      assert.isDefined(runningSession?.activeTurnId);
    +
    +      yield* adapter.respondToRequest(
    +        threadId,
    +        ApprovalRequestId.make(String(requestOpenedEvent.requestId)),
    +        "accept",
    +      );
    +      yield* Fiber.join(sendTurnFiber);
    +
    +      const readySessions = yield* adapter.listSessions();
    +      const readySession = readySessions.find((session) => session.threadId === threadId);
    +      assert.equal(readySession?.status, "ready");
    +      assert.isUndefined(readySession?.activeTurnId);
    +
    +      yield* Fiber.interrupt(eventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }),
    +  );
    +
    +  it.effect("restores ready without completing an unstarted turn when preparation fails", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-preparation-failure-while-connecting");
    +      const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +
    +      const runtimeEvents: ProviderRuntimeEvent[] = [];
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        Effect.sync(() => {
    +          runtimeEvents.push(event);
    +        }),
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +      });
    +
    +      const error = yield* Effect.flip(
    +        adapter.sendTurn({
    +          threadId,
    +          input: "prepare invalid attachment",
    +          attachments: [
    +            {
    +              type: "image",
    +              id: "missing-image",
    +              name: "missing.png",
    +              mimeType: "image/png",
    +              sizeBytes: 1,
    +            },
    +          ],
    +        }),
    +      );
    +      for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
    +        yield* Effect.yieldNow;
    +      }
    +
    +      const turnCompletedEvent = runtimeEvents.find(
    +        (event): event is Extract =>
    +          event.type === "turn.completed",
    +      );
    +      const readySessions = yield* adapter.listSessions();
    +      const readySession = readySessions.find((session) => session.threadId === threadId);
    +
    +      assert.equal(error._tag, "ProviderAdapterRequestError");
    +      assert.isUndefined(turnCompletedEvent);
    +      assert.equal(readySession?.status, "ready");
    +      assert.isUndefined(readySession?.activeTurnId);
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }),
    +  );
    +
    +  it.effect("completes a Grok turn from xAI prompt completion when the prompt RPC hangs", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-xai-prompt-complete-fallback");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1",
    +          T3_ACP_EMIT_FOREIGN_SESSION_UPDATES: "1",
    +        }),
    +      );
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +
    +      const runtimeEvents: ProviderRuntimeEvent[] = [];
    +      const turnCompleted = yield* Deferred.make();
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        Effect.sync(() => {
    +          runtimeEvents.push(event);
    +        }).pipe(
    +          Effect.andThen(
    +            event.type === "turn.completed"
    +              ? Deferred.succeed(turnCompleted, undefined)
    +              : Effect.void,
    +          ),
    +        ),
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +      });
    +
    +      const sendTurnResult = yield* adapter.sendTurn({
    +        threadId,
    +        input: "exercise fallback",
    +        attachments: [],
    +      });
    +
    +      yield* Deferred.await(turnCompleted);
    +      for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
    +        yield* Effect.yieldNow;
    +      }
    +      const readySessions = yield* adapter.listSessions();
    +      const readySession = readySessions.find((session) => session.threadId === threadId);
    +      const turnCompletedEvent = runtimeEvents.find(
    +        (event): event is Extract =>
    +          event.type === "turn.completed",
    +      );
    +      const eventTypes = runtimeEvents.map((event) => event.type);
    +      const content = runtimeEvents
    +        .filter(
    +          (event): event is Extract =>
    +            event.type === "content.delta" && String(event.threadId) === String(threadId),
    +        )
    +        .map((event) => event.payload.delta)
    +        .join("");
    +      const terminalIndex = runtimeEvents.findIndex(
    +        (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
    +      );
    +      const turnOutputTypes = new Set([
    +        "content.delta",
    +        "item.started",
    +        "item.updated",
    +        "item.completed",
    +        "turn.plan.updated",
    +      ]);
    +      const outputAfterTerminal = runtimeEvents
    +        .slice(terminalIndex + 1)
    +        .filter(
    +          (event) => String(event.threadId) === String(threadId) && turnOutputTypes.has(event.type),
    +        );
    +      const toolTitles = runtimeEvents.flatMap((event) =>
    +        event.type === "item.updated" && event.payload.title ? [event.payload.title] : [],
    +      );
    +
    +      assert.equal(sendTurnResult.threadId, threadId);
    +      assert.include(eventTypes, "turn.completed");
    +      assert.equal(content, "hello from mock");
    +      assert.isAtLeast(terminalIndex, 0);
    +      assert.deepEqual(outputAfterTerminal, []);
    +      assert.notInclude(toolTitles, "Child-only tool");
    +      assert.equal(turnCompletedEvent?.payload.stopReason, "end_turn");
    +      assert.equal(readySession?.status, "ready");
    +      assert.isUndefined(readySession?.activeTurnId);
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }),
    +  );
    +
    +  it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1",
    +        }),
    +      );
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +      const contentDelta = yield* Deferred.make();
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        event.type === "content.delta" ? Deferred.succeed(contentDelta, undefined) : Effect.void,
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +      });
    +
    +      const sendTurnFiber = yield* adapter
    +        .sendTurn({
    +          threadId,
    +          input: "interrupt after prompt",
    +          attachments: [],
    +        })
    +        .pipe(Effect.forkChild);
    +
    +      yield* Deferred.await(contentDelta);
    +      for (let yieldAttempt = 0; yieldAttempt < 6; yieldAttempt += 1) {
    +        yield* Effect.yieldNow;
    +      }
    +      yield* Fiber.interrupt(sendTurnFiber);
    +      for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
    +        yield* Effect.yieldNow;
    +      }
    +
    +      const snapshot = yield* adapter.readThread(threadId);
    +      assert.equal(snapshot.turns.length, 1);
    +      assert.equal(snapshot.turns[0]?.items.length, 1);
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }),
    +  );
    +
    +  it.effect("does not report a synthetic stop reason when xAI omits one", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-xai-prompt-complete-missing-stop-reason");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1",
    +          T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON: "1",
    +        }),
    +      );
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +
    +      const runtimeEvents: ProviderRuntimeEvent[] = [];
    +      const turnCompleted = yield* Deferred.make();
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        Effect.sync(() => {
    +          runtimeEvents.push(event);
    +        }).pipe(
    +          Effect.andThen(
    +            event.type === "turn.completed"
    +              ? Deferred.succeed(turnCompleted, undefined)
    +              : Effect.void,
    +          ),
    +        ),
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +      });
    +
    +      yield* adapter.sendTurn({
    +        threadId,
    +        input: "exercise missing stop reason",
    +        attachments: [],
    +      });
    +
    +      yield* Deferred.await(turnCompleted);
    +      const turnCompletedEvent = runtimeEvents.find(
    +        (event): event is Extract =>
    +          event.type === "turn.completed",
    +      );
    +
    +      assert.equal(turnCompletedEvent?.payload.state, "completed");
    +      assert.isNull(turnCompletedEvent?.payload.stopReason);
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }),
    +  );
    +
    +  it.effect("lets Stop unblock a fully silent Grok prompt and accept a follow-up turn", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-stop-after-full-silence");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1",
    +        }),
    +      );
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +
    +      const runtimeEvents: ProviderRuntimeEvent[] = [];
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        Effect.sync(() => {
    +          runtimeEvents.push(event);
    +        }),
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +      });
    +
    +      yield* Effect.gen(function* () {
    +        yield* Effect.sleep("500 millis");
    +        yield* adapter.interruptTurn(threadId);
    +      }).pipe(Effect.forkChild({ startImmediately: true }));
    +
    +      yield* adapter.sendTurn({
    +        threadId,
    +        input: "hang forever",
    +        attachments: [],
    +      });
    +      for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
    +        yield* Effect.yieldNow;
    +      }
    +
    +      const cancelledEvents = runtimeEvents.filter(
    +        (event): event is Extract =>
    +          event.type === "turn.completed" && String(event.threadId) === String(threadId),
    +      );
    +      const readySessions = yield* adapter.listSessions();
    +      const readySession = readySessions.find((session) => session.threadId === threadId);
    +
    +      assert.lengthOf(cancelledEvents, 1);
    +      assert.equal(cancelledEvents[0]?.payload.state, "cancelled");
    +      assert.equal(readySession?.status, "ready");
    +      assert.isUndefined(readySession?.activeTurnId);
    +
    +      const followUpEventsBefore = runtimeEvents.length;
    +      yield* adapter.sendTurn({
    +        threadId,
    +        input: "continue after stop",
    +        attachments: [],
    +      });
    +      for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
    +        yield* Effect.yieldNow;
    +      }
    +
    +      const followUpCompletedEvents = runtimeEvents
    +        .slice(followUpEventsBefore)
    +        .filter(
    +          (event): event is Extract =>
    +            event.type === "turn.completed" && String(event.threadId) === String(threadId),
    +        );
    +      assert.lengthOf(followUpCompletedEvents, 1);
    +      assert.equal(followUpCompletedEvents[0]?.payload.state, "completed");
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }).pipe(TestClock.withLive),
    +  );
    +
    +  it.effect("does not let a cancelled prompt settlement consume the follow-up prompt slot", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-cancelled-settlement-before-follow-up");
    +      const tempDir = yield* Effect.promise(() =>
    +        NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-cancel-race-")),
    +      );
    +      const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1",
    +          T3_ACP_REQUEST_LOG_PATH: requestLogPath,
    +        }),
    +      );
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +
    +      const runtimeEvents: ProviderRuntimeEvent[] = [];
    +      const firstTurnStarted = yield* Deferred.make();
    +      const twoTurnsCompleted = yield* Deferred.make();
    +      const completedCountRef = yield* Ref.make(0);
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        Effect.gen(function* () {
    +          runtimeEvents.push(event);
    +          if (String(event.threadId) !== String(threadId)) {
    +            return;
    +          }
    +          if (event.type === "turn.started" && event.turnId !== undefined) {
    +            yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore);
    +            return;
    +          }
    +          if (event.type !== "turn.completed") {
    +            return;
    +          }
    +          const completedCount = yield* Ref.updateAndGet(completedCountRef, (count) => count + 1);
    +          if (completedCount === 2) {
    +            yield* Deferred.succeed(twoTurnsCompleted, undefined);
    +          }
    +        }),
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +      });
    +
    +      const firstSendTurnFiber = yield* adapter
    +        .sendTurn({ threadId, input: "cancel this prompt", attachments: [] })
    +        .pipe(Effect.forkChild);
    +      const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds"));
    +      yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"');
    +
    +      yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds"));
    +      const followUp = yield* adapter
    +        .sendTurn({ threadId, input: "complete the follow-up", attachments: [] })
    +        .pipe(Effect.timeout("2 seconds"));
    +      yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("2 seconds"));
    +      yield* Deferred.await(twoTurnsCompleted).pipe(Effect.timeout("2 seconds"));
    +
    +      const turnCompletedEvents = runtimeEvents.filter(
    +        (event): event is Extract =>
    +          event.type === "turn.completed" && String(event.threadId) === String(threadId),
    +      );
    +      const readySessions = yield* adapter.listSessions();
    +      const readySession = readySessions.find((session) => session.threadId === threadId);
    +
    +      assert.notEqual(String(followUp.turnId), String(firstTurnId));
    +      assert.deepEqual(
    +        turnCompletedEvents.map((event) => [String(event.turnId), event.payload.state]),
    +        [
    +          [String(firstTurnId), "cancelled"],
    +          [String(followUp.turnId), "completed"],
    +        ],
    +      );
    +      assert.equal(readySession?.status, "ready");
    +      assert.isUndefined(readySession?.activeTurnId);
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }).pipe(TestClock.withLive),
    +  );
    +
    +  it.effect("drops late ACP notifications after a turn is cancelled", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-drop-late-cancelled-notifications");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_HANG_PROMPT_FOREVER: "1",
    +          T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1",
    +        }),
    +      );
    +      const lateNativeUpdate = yield* Deferred.make();
    +      const adapter = yield* makeTestAdapter(wrapperPath, {
    +        nativeEventLogger: {
    +          filePath: "memory://grok-cancelled-native-events",
    +          write: (record: unknown) =>
    +            JSON.stringify(record).includes("late after cancel")
    +              ? Deferred.succeed(lateNativeUpdate, undefined).pipe(Effect.asVoid)
    +              : Effect.void,
    +          close: () => Effect.void,
    +        },
    +      });
    +
    +      const runtimeEvents: ProviderRuntimeEvent[] = [];
    +      const turnStarted = yield* Deferred.make();
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        Effect.sync(() => {
    +          runtimeEvents.push(event);
    +        }).pipe(
    +          Effect.andThen(
    +            event.type === "turn.started" &&
    +              event.turnId !== undefined &&
    +              String(event.threadId) === String(threadId)
    +              ? Deferred.succeed(turnStarted, event.turnId).pipe(Effect.asVoid)
    +              : Effect.void,
    +          ),
    +        ),
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +      });
    +
    +      const sendTurnFiber = yield* adapter
    +        .sendTurn({ threadId, input: "cancel before the late update", attachments: [] })
    +        .pipe(Effect.forkChild);
    +      const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("2 seconds"));
    +      yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds"));
    +      yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds"));
    +      yield* Deferred.await(lateNativeUpdate).pipe(Effect.timeout("2 seconds"));
    +      for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
    +        yield* Effect.yieldNow;
    +      }
    +
    +      const cancelledIndex = runtimeEvents.findIndex(
    +        (event) =>
    +          event.type === "turn.completed" &&
    +          String(event.threadId) === String(threadId) &&
    +          String(event.turnId) === String(turnId) &&
    +          event.payload.state === "cancelled",
    +      );
    +      const turnOutputTypes = new Set([
    +        "content.delta",
    +        "item.started",
    +        "item.updated",
    +        "item.completed",
    +        "turn.plan.updated",
    +      ]);
    +      const outputAfterCancellation = runtimeEvents
    +        .slice(cancelledIndex + 1)
    +        .filter(
    +          (event) => String(event.threadId) === String(threadId) && turnOutputTypes.has(event.type),
    +        );
    +
    +      assert.isAtLeast(cancelledIndex, 0);
    +      assert.deepEqual(outputAfterCancellation, []);
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }).pipe(TestClock.withLive),
    +  );
    +
    +  it.effect("lets Stop cancel during the xAI completion drain window", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-stop-during-completion-drain");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1",
    +        }),
    +      );
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +
    +      const runtimeEvents: ProviderRuntimeEvent[] = [];
    +      const activeTurnIdRef = yield* Ref.make(undefined);
    +      const trailingChunkTurnId = yield* Deferred.make();
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        Effect.gen(function* () {
    +          runtimeEvents.push(event);
    +          if (String(event.threadId) !== String(threadId)) {
    +            return;
    +          }
    +          if (event.type === "turn.started") {
    +            yield* Ref.set(activeTurnIdRef, event.turnId);
    +          }
    +          if (event.type !== "content.delta" || event.payload.delta !== "mock") {
    +            return;
    +          }
    +          const turnId = event.turnId ?? (yield* Ref.get(activeTurnIdRef));
    +          if (turnId === undefined) {
    +            return;
    +          }
    +          yield* Deferred.succeed(trailingChunkTurnId, turnId).pipe(Effect.ignore);
    +        }),
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +      });
    +
    +      const sendTurnFiber = yield* adapter
    +        .sendTurn({
    +          threadId,
    +          input: "cancel during completion drain",
    +          attachments: [],
    +        })
    +        .pipe(Effect.forkChild);
    +
    +      const turnId = yield* Deferred.await(trailingChunkTurnId).pipe(Effect.timeout("2 seconds"));
    +      yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds"));
    +      yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds"));
    +
    +      const turnCompletedEvents = runtimeEvents.filter(
    +        (event): event is Extract =>
    +          event.type === "turn.completed" && String(event.threadId) === String(threadId),
    +      );
    +      const readySessions = yield* adapter.listSessions();
    +      const readySession = readySessions.find((session) => session.threadId === threadId);
    +
    +      assert.lengthOf(turnCompletedEvents, 1);
    +      assert.equal(turnCompletedEvents[0]?.payload.state, "cancelled");
    +      assert.equal(readySession?.status, "ready");
    +      assert.isUndefined(readySession?.activeTurnId);
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }),
    +  );
    +
    +  it.effect("settles the in-flight prompt before emitting completion", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-completion-before-next-turn");
    +      const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +      const completedCountRef = yield* Ref.make(0);
    +      const secondTurnCompleted = yield* Deferred.make();
    +
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
    +        if (event.type !== "turn.completed" || String(event.threadId) !== String(threadId)) {
    +          return Effect.void;
    +        }
    +
    +        return Ref.modify(completedCountRef, (count) => {
    +          const nextCount = count + 1;
    +          return [nextCount, nextCount] as const;
    +        }).pipe(
    +          Effect.flatMap((count) => {
    +            if (count === 1) {
    +              return adapter
    +                .sendTurn({
    +                  threadId,
    +                  input: "second turn after completion",
    +                  attachments: [],
    +                })
    +                .pipe(Effect.forkChild, Effect.asVoid);
    +            }
    +            if (count === 2) {
    +              return Deferred.succeed(secondTurnCompleted, undefined).pipe(Effect.asVoid);
    +            }
    +            return Effect.void;
    +          }),
    +        );
    +      }).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +      });
    +
    +      yield* adapter.sendTurn({
    +        threadId,
    +        input: "first turn",
    +        attachments: [],
    +      });
    +      yield* Deferred.await(secondTurnCompleted);
    +
    +      const completedCount = yield* Ref.get(completedCountRef);
    +      const readySessions = yield* adapter.listSessions();
    +      const readySession = readySessions.find((session) => session.threadId === threadId);
    +
    +      assert.equal(completedCount, 2);
    +      assert.equal(readySession?.status, "ready");
    +      assert.isUndefined(readySession?.activeTurnId);
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }),
    +  );
    +
    +  it.effect("restores a Grok session to ready when the prompt RPC fails", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-prompt-failure-ready");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_FAIL_PROMPT: "1",
    +        }),
    +      );
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +      const runtimeEvents: ProviderRuntimeEvent[] = [];
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        Effect.sync(() => {
    +          runtimeEvents.push(event);
    +        }),
    +      ).pipe(Effect.forkChild);
    +
    +      yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +      });
    +
    +      const error = yield* Effect.flip(
    +        adapter.sendTurn({
    +          threadId,
    +          input: "fail prompt",
    +          attachments: [],
    +        }),
    +      );
    +      const readySessions = yield* adapter.listSessions();
    +      const readySession = readySessions.find((session) => session.threadId === threadId);
    +      const failedTurnCompleted = runtimeEvents.find(
    +        (event) => event.type === "turn.completed" && event.threadId === threadId,
    +      );
    +
    +      assert.equal(error._tag, "ProviderAdapterRequestError");
    +      assert.equal(readySession?.status, "ready");
    +      assert.isUndefined(readySession?.activeTurnId);
    +      assert.equal(failedTurnCompleted?.type, "turn.completed");
    +      if (failedTurnCompleted?.type === "turn.completed") {
    +        assert.equal(failedTurnCompleted.payload.state, "failed");
    +        assert.isString(failedTurnCompleted.payload.errorMessage);
    +      }
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }),
    +  );
    +
    +  it.effect("ignores replayed session/load updates when resuming a Grok session", () =>
    +    Effect.gen(function* () {
    +      const threadId = ThreadId.make("grok-load-replay-filter");
    +      const wrapperPath = yield* Effect.promise(() =>
    +        makeMockGrokWrapper({
    +          T3_ACP_EMIT_LOAD_REPLAY: "1",
    +        }),
    +      );
    +      const adapter = yield* makeTestAdapter(wrapperPath);
    +      const runtimeEvents: ProviderRuntimeEvent[] = [];
    +      const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
    +        Effect.sync(() => {
    +          runtimeEvents.push(event);
    +        }),
    +      ).pipe(Effect.forkChild);
    +
    +      const session = yield* adapter.startSession({
    +        threadId,
    +        provider: ProviderDriverKind.make("grok"),
    +        cwd: process.cwd(),
    +        runtimeMode: "full-access",
    +        modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
    +        resumeCursor: { schemaVersion: 1, sessionId: "mock-session-1" },
    +      });
    +
    +      yield* adapter.sendTurn({
    +        threadId,
    +        input: "after resume",
    +        attachments: [],
    +      });
    +
    +      assert.deepStrictEqual(session.resumeCursor, {
    +        schemaVersion: 1,
    +        sessionId: "mock-session-1",
    +      });
    +      assert.isFalse(
    +        runtimeEvents.some(
    +          (event) => event.type === "item.completed" && event.payload.title === "Replay tool",
    +        ),
    +      );
    +      assert.isFalse(
    +        runtimeEvents.some(
    +          (event) =>
    +            event.type === "content.delta" && event.payload.delta === "replayed assistant text",
    +        ),
    +      );
    +
    +      yield* Fiber.interrupt(runtimeEventsFiber);
    +      yield* adapter.stopSession(threadId);
    +    }),
    +  );
    +
       it.effect("rejects startSession when provider mismatches", () =>
         Effect.gen(function* () {
           const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());
    @@ -331,6 +1151,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
           assert.deepEqual(resolvedEvent.payload.answers, {
             "Which scope should Grok use?": "Workspace",
           });
    +      assert.equal(String(resolvedEvent.turnId), String(requestedEvent.turnId));
           yield* Fiber.join(sendTurnFiber);
     
           yield* Fiber.interrupt(eventsFiber);
    diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts
    index 40f425cbaa1..c22b2180183 100644
    --- a/apps/server/src/provider/Layers/GrokAdapter.ts
    +++ b/apps/server/src/provider/Layers/GrokAdapter.ts
    @@ -22,6 +22,7 @@ import * as FileSystem from "effect/FileSystem";
     import * as Option from "effect/Option";
     import * as Path from "effect/Path";
     import * as PubSub from "effect/PubSub";
    +import * as Ref from "effect/Ref";
     import * as Schema from "effect/Schema";
     import * as Scope from "effect/Scope";
     import * as Semaphore from "effect/Semaphore";
    @@ -62,6 +63,7 @@ import {
       extractXAiAskUserQuestions,
       makeXAiAskUserQuestionCancelledResponse,
       makeXAiAskUserQuestionResponse,
    +  promptResponseHasMissingXAiStopReason,
       XAiAskUserQuestionRequest,
     } from "../acp/XAiAcpExtension.ts";
     import { type GrokAdapterShape } from "../Services/GrokAdapter.ts";
    @@ -108,6 +110,8 @@ interface GrokSessionContext {
       turns: Array<{ id: TurnId; items: Array }>;
       lastPlanFingerprint: string | undefined;
       activeTurnId: TurnId | undefined;
    +  /** Turns already interrupted; late prompt RPCs must not resurrect them. */
    +  interruptedTurnIds: Set;
       /** Number of sendTurn prompts currently in flight or being prepared.
        * >0 means a turn is actively running, so a new sendTurn is a steer that
        * continues it, and only the last remaining prompt settles the turn. */
    @@ -136,10 +140,38 @@ function settlePendingUserInputsAsCancelled(
       );
     }
     
    +function appendPromptResultToTurn(
    +  ctx: GrokSessionContext,
    +  turnId: TurnId,
    +  promptParts: ReadonlyArray,
    +  result: EffectAcpSchema.PromptResponse,
    +): void {
    +  const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId);
    +  ctx.turns = existingTurnRecord
    +    ? ctx.turns.map((turn) =>
    +        turn.id === turnId
    +          ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] }
    +          : turn,
    +      )
    +    : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }];
    +}
    +
     function isRecord(value: unknown): value is Record {
       return typeof value === "object" && value !== null && !Array.isArray(value);
     }
     
    +const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId;
    +
    +const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId;
    +
    +const resolveSessionCallbackTurnId = (
    +  sessions: ReadonlyMap,
    +  threadId: ThreadId,
    +): TurnId | undefined => {
    +  const ctx = sessions.get(threadId);
    +  return ctx ? resolveCallbackTurnId(ctx) : undefined;
    +};
    +
     function parseGrokResume(raw: unknown): { sessionId: string } | undefined {
       if (!isRecord(raw)) return undefined;
       if (raw.schemaVersion !== GROK_RESUME_VERSION) return undefined;
    @@ -170,6 +202,28 @@ function selectAutoApprovedPermissionOption(
       );
     }
     
    +function completedStopReasonFromPromptResponse(
    +  response: EffectAcpSchema.PromptResponse | undefined,
    +): EffectAcpSchema.StopReason | null {
    +  if (response === undefined || promptResponseHasMissingXAiStopReason(response)) {
    +    return null;
    +  }
    +  return response.stopReason;
    +}
    +
    +export function grokPromptSettlementBelongsToContext(input: {
    +  readonly liveAcpSessionId: string;
    +  readonly expectedAcpSessionId: string;
    +  readonly liveActiveTurnId: TurnId | undefined;
    +  readonly liveSessionActiveTurnId: TurnId | undefined;
    +  readonly turnId: TurnId;
    +}): boolean {
    +  return (
    +    input.liveAcpSessionId === input.expectedAcpSessionId &&
    +    (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId)
    +  );
    +}
    +
     export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) {
       return Effect.gen(function* () {
         const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok");
    @@ -240,6 +294,144 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
         const withThreadLock = (threadId: string, effect: Effect.Effect) =>
           Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
     
    +    const settlePromptInFlight = (
    +      threadId: ThreadId,
    +      turnId: TurnId,
    +      expectedAcpSessionId: string,
    +      options?: {
    +        readonly errorMessage?: string;
    +        readonly completedStopReason?: EffectAcpSchema.StopReason | null;
    +        readonly emitTurnCompletion?: boolean;
    +        /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */
    +        readonly settleAllPrompts?: boolean;
    +      },
    +    ) =>
    +      Effect.gen(function* () {
    +        const liveCtx = sessions.get(threadId);
    +        if (!liveCtx) {
    +          return;
    +        }
    +        const settlementBelongsToLiveContext = grokPromptSettlementBelongsToContext({
    +          liveAcpSessionId: liveCtx.acpSessionId,
    +          expectedAcpSessionId,
    +          liveActiveTurnId: liveCtx.activeTurnId,
    +          liveSessionActiveTurnId: liveCtx.session.activeTurnId,
    +          turnId,
    +        });
    +        if (!settlementBelongsToLiveContext) {
    +          // interruptTurn already consumed every prompt slot for this turn. A
    +          // late prompt result must neither emit a second terminal event nor
    +          // consume a slot belonging to a newer turn on the same ACP session.
    +          if (
    +            liveCtx.acpSessionId !== expectedAcpSessionId ||
    +            liveCtx.interruptedTurnIds.has(turnId)
    +          ) {
    +            return;
    +          }
    +          if (options?.emitTurnCompletion !== false) {
    +            if (options?.errorMessage !== undefined) {
    +              yield* offerRuntimeEvent({
    +                type: "turn.completed",
    +                ...(yield* makeEventStamp()),
    +                provider: PROVIDER,
    +                threadId,
    +                turnId,
    +                payload: {
    +                  state: "failed",
    +                  errorMessage: options.errorMessage,
    +                },
    +              });
    +            } else if (options?.completedStopReason !== undefined) {
    +              yield* offerRuntimeEvent({
    +                type: "turn.completed",
    +                ...(yield* makeEventStamp()),
    +                provider: PROVIDER,
    +                threadId,
    +                turnId,
    +                payload: {
    +                  state: options.completedStopReason === "cancelled" ? "cancelled" : "completed",
    +                  stopReason: options.completedStopReason ?? null,
    +                },
    +              });
    +            }
    +          }
    +          return;
    +        }
    +        let settleTurnId = turnId;
    +        if (options?.settleAllPrompts) {
    +          liveCtx.promptsInFlight = 0;
    +          if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) {
    +            const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId;
    +            if (!fallbackTurnId) {
    +              if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") {
    +                const updatedAt = yield* nowIso;
    +                const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session;
    +                liveCtx.activeTurnId = undefined;
    +                liveCtx.session = {
    +                  ...readySession,
    +                  status: "ready",
    +                  updatedAt,
    +                };
    +              }
    +              return;
    +            }
    +            settleTurnId = fallbackTurnId;
    +          }
    +        } else {
    +          const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1);
    +          if (
    +            remainingPrompts > 0 ||
    +            liveCtx.activeTurnId !== settleTurnId ||
    +            liveCtx.session.activeTurnId !== settleTurnId
    +          ) {
    +            liveCtx.promptsInFlight = remainingPrompts;
    +            return;
    +          }
    +          liveCtx.promptsInFlight = remainingPrompts;
    +        }
    +        const updatedAt = yield* nowIso;
    +        const canEmitTurnCompletion =
    +          liveCtx.session.status === "running" || liveCtx.session.status === "connecting";
    +        const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion;
    +        const shouldEmitCompletedTurn =
    +          options?.completedStopReason !== undefined && canEmitTurnCompletion;
    +        const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session;
    +        liveCtx.activeTurnId = undefined;
    +        liveCtx.session = {
    +          ...readySession,
    +          status: "ready",
    +          updatedAt,
    +        };
    +        if (options?.emitTurnCompletion === false) {
    +          return;
    +        }
    +        if (shouldEmitFailedTurn) {
    +          yield* offerRuntimeEvent({
    +            type: "turn.completed",
    +            ...(yield* makeEventStamp()),
    +            provider: PROVIDER,
    +            threadId,
    +            turnId: settleTurnId,
    +            payload: {
    +              state: "failed",
    +              errorMessage: options.errorMessage,
    +            },
    +          });
    +        } else if (shouldEmitCompletedTurn) {
    +          yield* offerRuntimeEvent({
    +            type: "turn.completed",
    +            ...(yield* makeEventStamp()),
    +            provider: PROVIDER,
    +            threadId,
    +            turnId: settleTurnId,
    +            payload: {
    +              state: options.completedStopReason === "cancelled" ? "cancelled" : "completed",
    +              stopReason: options.completedStopReason ?? null,
    +            },
    +          });
    +        }
    +      });
    +
         const logNative = (threadId: ThreadId, method: string, payload: unknown) =>
           Effect.gen(function* () {
             if (!nativeEventLogger) return;
    @@ -271,6 +463,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
     
         const emitPlanUpdate = (
           ctx: GrokSessionContext,
    +      turnId: TurnId | undefined,
    +      stamp: { readonly eventId: EventId; readonly createdAt: string },
           payload: {
             readonly explanation?: string | null;
             readonly plan: ReadonlyArray<{
    @@ -282,17 +476,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
           method: string,
         ) =>
           Effect.gen(function* () {
    -        const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`;
    +        const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`;
             if (ctx.lastPlanFingerprint === fingerprint) {
               return;
             }
             ctx.lastPlanFingerprint = fingerprint;
             yield* offerRuntimeEvent(
               makeAcpPlanUpdatedEvent({
    -            stamp: yield* makeEventStamp(),
    +            stamp,
                 provider: PROVIDER,
                 threadId: ctx.threadId,
    -            turnId: ctx.activeTurnId,
    +            turnId,
                 payload,
                 source: "acp.jsonrpc",
                 method,
    @@ -424,13 +618,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                           const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
                           const runtimeRequestId = RuntimeRequestId.make(requestId);
                           const resolution = yield* Deferred.make();
    +                      const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
                           pendingUserInputs.set(requestId, { resolution });
                           yield* offerRuntimeEvent({
                             type: "user-input.requested",
                             ...(yield* makeEventStamp()),
                             provider: PROVIDER,
                             threadId: input.threadId,
    -                        turnId: sessions.get(input.threadId)?.activeTurnId,
    +                        turnId,
                             requestId: runtimeRequestId,
                             payload: { questions: extractXAiAskUserQuestions(params) },
                             raw: {
    @@ -447,7 +642,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                             ...(yield* makeEventStamp()),
                             provider: PROVIDER,
                             threadId: input.threadId,
    -                        turnId: sessions.get(input.threadId)?.activeTurnId,
    +                        turnId,
                             requestId: runtimeRequestId,
                             payload: { answers: resolvedAnswers },
                             raw: {
    @@ -486,13 +681,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                       const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
                       const runtimeRequestId = RuntimeRequestId.make(requestId);
                       const decision = yield* Deferred.make();
    +                  const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
                       pendingApprovals.set(requestId, { decision });
                       yield* offerRuntimeEvent(
                         makeAcpRequestOpenedEvent({
                           stamp: yield* makeEventStamp(),
                           provider: PROVIDER,
                           threadId: input.threadId,
    -                      turnId: sessions.get(input.threadId)?.activeTurnId,
    +                      turnId,
                           requestId: runtimeRequestId,
                           permissionRequest,
                           detail:
    @@ -512,7 +708,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                           stamp: yield* makeEventStamp(),
                           provider: PROVIDER,
                           threadId: input.threadId,
    -                      turnId: sessions.get(input.threadId)?.activeTurnId,
    +                      turnId,
                           requestId: runtimeRequestId,
                           permissionRequest,
                           decision: resolved,
    @@ -578,6 +774,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                 turns: [],
                 lastPlanFingerprint: undefined,
                 activeTurnId: undefined,
    +            interruptedTurnIds: new Set(),
                 promptsInFlight: 0,
                 currentModelId: boundModelId,
                 stopped: false,
    @@ -586,14 +783,39 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
               const nf = yield* Stream.runDrain(
                 Stream.mapEffect(acp.getEvents(), (event) =>
                   Effect.gen(function* () {
    +                if (event._tag === "EventStreamBarrier") {
    +                  yield* Deferred.succeed(event.acknowledge, undefined);
    +                  return;
    +                }
    +                if (
    +                  event._tag === "PlanUpdated" ||
    +                  event._tag === "ToolCallUpdated" ||
    +                  event._tag === "ContentDelta"
    +                ) {
    +                  yield* logNative(ctx.threadId, "session/update", event.rawPayload);
    +                }
    +
    +                if (event._tag === "ModeChanged") {
    +                  return;
    +                }
    +
    +                const notificationTurnId = resolveNotificationTurnId(ctx);
    +                if (
    +                  notificationTurnId === undefined ||
    +                  ctx.interruptedTurnIds.has(notificationTurnId)
    +                ) {
    +                  return;
    +                }
    +                const stamp = yield* makeEventStamp();
    +
                     switch (event._tag) {
                       case "AssistantItemStarted":
                         yield* offerRuntimeEvent(
                           makeAcpAssistantItemEvent({
    -                        stamp: yield* makeEventStamp(),
    +                        stamp,
                             provider: PROVIDER,
                             threadId: ctx.threadId,
    -                        turnId: ctx.activeTurnId,
    +                        turnId: notificationTurnId,
                             itemId: event.itemId,
                             lifecycle: "item.started",
                           }),
    @@ -602,40 +824,44 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                       case "AssistantItemCompleted":
                         yield* offerRuntimeEvent(
                           makeAcpAssistantItemEvent({
    -                        stamp: yield* makeEventStamp(),
    +                        stamp,
                             provider: PROVIDER,
                             threadId: ctx.threadId,
    -                        turnId: ctx.activeTurnId,
    +                        turnId: notificationTurnId,
                             itemId: event.itemId,
                             lifecycle: "item.completed",
                           }),
                         );
                         return;
                       case "PlanUpdated":
    -                    yield* logNative(ctx.threadId, "session/update", event.rawPayload);
    -                    yield* emitPlanUpdate(ctx, event.payload, event.rawPayload, "session/update");
    +                    yield* emitPlanUpdate(
    +                      ctx,
    +                      notificationTurnId,
    +                      stamp,
    +                      event.payload,
    +                      event.rawPayload,
    +                      "session/update",
    +                    );
                         return;
                       case "ToolCallUpdated":
    -                    yield* logNative(ctx.threadId, "session/update", event.rawPayload);
                         yield* offerRuntimeEvent(
                           makeAcpToolCallEvent({
    -                        stamp: yield* makeEventStamp(),
    +                        stamp,
                             provider: PROVIDER,
                             threadId: ctx.threadId,
    -                        turnId: ctx.activeTurnId,
    +                        turnId: notificationTurnId,
                             toolCall: event.toolCall,
                             rawPayload: event.rawPayload,
                           }),
                         );
                         return;
                       case "ContentDelta":
    -                    yield* logNative(ctx.threadId, "session/update", event.rawPayload);
                         yield* offerRuntimeEvent(
                           makeAcpContentDeltaEvent({
    -                        stamp: yield* makeEventStamp(),
    +                        stamp,
                             provider: PROVIDER,
                             threadId: ctx.threadId,
    -                        turnId: ctx.activeTurnId,
    +                        turnId: notificationTurnId,
                             ...(event.itemId ? { itemId: event.itemId } : {}),
                             text: event.text,
                             rawPayload: event.rawPayload,
    @@ -697,6 +923,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                 // resolving from here on does not settle the turn; decremented on
                 // preparation failure here, and after the prompt below otherwise.
                 ctx.promptsInFlight += 1;
    +            // Bind the turn id before cooperative yields so interruptTurn can
    +            // settle this prompt even if stop arrives during preparation.
    +            ctx.activeTurnId = turnId;
    +            ctx.session = {
    +              ...ctx.session,
    +              status: steeringTurnId === undefined ? "connecting" : "running",
    +              activeTurnId: turnId,
    +              updatedAt: yield* nowIso,
    +            };
     
                 return yield* Effect.gen(function* () {
                   const turnModelSelection =
    @@ -765,12 +1000,27 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                   const displayModel = currentModelId
                     ? resolveGrokAcpBaseModelId(currentModelId)
                     : undefined;
    -              ctx.activeTurnId = turnId;
    +              for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
    +                yield* Effect.yieldNow;
    +              }
    +              if (ctx.interruptedTurnIds.has(turnId)) {
    +                yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, {
    +                  completedStopReason: "cancelled",
    +                  emitTurnCompletion: false,
    +                  settleAllPrompts: true,
    +                });
    +                return yield* new ProviderAdapterRequestError({
    +                  provider: PROVIDER,
    +                  method: "session/prompt",
    +                  detail: "Grok prompt was interrupted during preparation.",
    +                });
    +              }
                   if (steeringTurnId === undefined) {
                     ctx.lastPlanFingerprint = undefined;
                   }
                   ctx.session = {
                     ...ctx.session,
    +                status: "running",
                     activeTurnId: turnId,
                     updatedAt: yield* nowIso,
                     ...(displayModel ? { model: displayModel } : {}),
    @@ -796,13 +1046,27 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                   };
                 }).pipe(
                   Effect.tapCause(() =>
    -                Effect.sync(() => {
    -                  ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1);
    +                Effect.gen(function* () {
    +                  const liveCtx = sessions.get(input.threadId);
    +                  if (!liveCtx) {
    +                    return;
    +                  }
    +                  yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, {
    +                    errorMessage: "Grok prompt preparation failed.",
    +                    emitTurnCompletion: false,
    +                  });
                     }),
                   ),
                 );
               }),
             );
    +        const promptSettled = yield* Ref.make(false);
    +        const promptRpcSucceeded = yield* Ref.make(false);
    +        const promptResultRef = yield* Ref.make(
    +          undefined,
    +        );
    +
    +        const promptFailureMessageRef = yield* Ref.make(undefined);
     
             return yield* Effect.gen(function* () {
               const result = yield* prepared.acp
    @@ -810,6 +1074,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                   prompt: prepared.promptParts,
                 })
                 .pipe(
    +              Effect.tap((promptResult) =>
    +                Effect.all([
    +                  Ref.set(promptRpcSucceeded, true),
    +                  Ref.set(promptResultRef, promptResult),
    +                ]),
    +              ),
    +              Effect.tapError((error) =>
    +                Ref.set(
    +                  promptFailureMessageRef,
    +                  mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message,
    +                ).pipe(Effect.andThen(prepared.acp.drainEvents)),
    +              ),
                   Effect.mapError((error) =>
                     mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
                   ),
    @@ -820,38 +1096,88 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                 Effect.gen(function* () {
                   const ctx = yield* requireSession(input.threadId);
                   if (ctx.acpSessionId !== prepared.acpSessionId) {
    +                yield* settlePromptInFlight(
    +                  input.threadId,
    +                  prepared.turnId,
    +                  prepared.acpSessionId,
    +                  {
    +                    errorMessage: "Grok session changed before the turn completed.",
    +                    settleAllPrompts: true,
    +                  },
    +                );
    +                yield* Ref.set(promptSettled, true);
                     return yield* new ProviderAdapterRequestError({
                       provider: PROVIDER,
                       method: "session/prompt",
                       detail: "Grok session changed before the turn completed.",
                     });
                   }
    +              // Keep prompt settlement atomic with respect to Stop and steering.
    +              // interruptTurn marks its target before waiting for this lock, so
    +              // cancellation can still win while queued ACP events are drained.
    +              for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) {
    +                yield* Effect.yieldNow;
    +              }
    +              yield* prepared.acp.drainEvents;
    +              if (ctx.interruptedTurnIds.has(prepared.turnId)) {
    +                yield* Ref.set(promptSettled, true);
    +                return {
    +                  threadId: input.threadId,
    +                  turnId: prepared.turnId,
    +                  resumeCursor: ctx.session.resumeCursor,
    +                };
    +              }
     
    -              const existingTurnRecord = ctx.turns.find((turn) => turn.id === prepared.turnId);
    -              ctx.turns = existingTurnRecord
    -                ? ctx.turns.map((turn) =>
    -                    turn.id === prepared.turnId
    -                      ? {
    -                          ...turn,
    -                          items: [...turn.items, { prompt: prepared.promptParts, result }],
    -                        }
    -                      : turn,
    -                  )
    -                : [
    -                    ...ctx.turns,
    -                    { id: prepared.turnId, items: [{ prompt: prepared.promptParts, result }] },
    -                  ];
    +              if (
    +                ctx.promptsInFlight <= 0 ||
    +                ctx.activeTurnId !== prepared.turnId ||
    +                ctx.session.activeTurnId !== prepared.turnId
    +              ) {
    +                yield* Ref.set(promptSettled, true);
    +                return {
    +                  threadId: input.threadId,
    +                  turnId: prepared.turnId,
    +                  resumeCursor: ctx.session.resumeCursor,
    +                };
    +              }
    +
    +              appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result);
                   ctx.session = {
                     ...ctx.session,
    +                status: "running",
                     activeTurnId: prepared.turnId,
                     updatedAt: yield* nowIso,
                     ...(prepared.displayModel ? { model: prepared.displayModel } : {}),
                   };
    +              const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1);
    +              ctx.promptsInFlight = remainingPrompts;
     
    -              // Only the last remaining prompt settles the turn — a steer-
    -              // superseded prompt resolving (usually cancelled) while another
    -              // is in flight or pending must leave the merged turn running.
    -              if (ctx.promptsInFlight === 1) {
    +              // Only the last remaining prompt settles the turn. A steer-
    +              // superseded prompt resolving while another is in flight or
    +              // pending must leave the merged turn running.
    +              if (
    +                remainingPrompts === 0 &&
    +                ctx.activeTurnId === prepared.turnId &&
    +                ctx.session.activeTurnId === prepared.turnId
    +              ) {
    +                if (ctx.interruptedTurnIds.has(prepared.turnId)) {
    +                  yield* Ref.set(promptSettled, true);
    +                  return {
    +                    threadId: input.threadId,
    +                    turnId: prepared.turnId,
    +                    resumeCursor: ctx.session.resumeCursor,
    +                  };
    +                }
    +                const completedAt = yield* nowIso;
    +                const { activeTurnId: _completedTurnId, ...readySession } = ctx.session;
    +                ctx.activeTurnId = undefined;
    +                ctx.session = {
    +                  ...readySession,
    +                  status: "ready",
    +                  updatedAt: completedAt,
    +                  ...(prepared.displayModel ? { model: prepared.displayModel } : {}),
    +                };
    +                const completedStopReason = completedStopReasonFromPromptResponse(result);
                     yield* offerRuntimeEvent({
                       type: "turn.completed",
                       ...(yield* makeEventStamp()),
    @@ -860,9 +1186,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
                       turnId: prepared.turnId,
                       payload: {
                         state: result.stopReason === "cancelled" ? "cancelled" : "completed",
    -                    stopReason: result.stopReason ?? null,
    +                    stopReason: completedStopReason,
                       },
                     });
    +                ctx.interruptedTurnIds.delete(prepared.turnId);
    +                yield* Ref.set(promptSettled, true);
    +              } else if (remainingPrompts > 0) {
    +                yield* Ref.set(promptSettled, true);
                   }
     
                   return {
    @@ -874,27 +1204,153 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
               );
             }).pipe(
               Effect.ensuring(
    -            Effect.sync(() => {
    -              const liveCtx = sessions.get(input.threadId);
    -              if (liveCtx) {
    -                liveCtx.promptsInFlight = Math.max(0, liveCtx.promptsInFlight - 1);
    +            Effect.gen(function* () {
    +              if (yield* Ref.get(promptSettled)) {
    +                return;
                   }
    -            }),
    +
    +              if (yield* Ref.get(promptRpcSucceeded)) {
    +                const promptResult = yield* Ref.get(promptResultRef);
    +                if (promptResult === undefined) {
    +                  return;
    +                }
    +                yield* withThreadLock(
    +                  input.threadId,
    +                  Effect.gen(function* () {
    +                    const ctx = yield* requireSession(input.threadId);
    +                    if (ctx.acpSessionId !== prepared.acpSessionId) {
    +                      yield* settlePromptInFlight(
    +                        input.threadId,
    +                        prepared.turnId,
    +                        prepared.acpSessionId,
    +                        {
    +                          errorMessage: "Grok session changed before the turn completed.",
    +                          settleAllPrompts: true,
    +                        },
    +                      );
    +                      return;
    +                    }
    +                    if (ctx.interruptedTurnIds.has(prepared.turnId)) {
    +                      return;
    +                    }
    +                    if (
    +                      ctx.promptsInFlight <= 0 ||
    +                      ctx.activeTurnId !== prepared.turnId ||
    +                      ctx.session.activeTurnId !== prepared.turnId
    +                    ) {
    +                      return;
    +                    }
    +                    appendPromptResultToTurn(
    +                      ctx,
    +                      prepared.turnId,
    +                      prepared.promptParts,
    +                      promptResult,
    +                    );
    +                    yield* settlePromptInFlight(
    +                      input.threadId,
    +                      prepared.turnId,
    +                      prepared.acpSessionId,
    +                      {
    +                        completedStopReason: completedStopReasonFromPromptResponse(promptResult),
    +                      },
    +                    );
    +                  }),
    +                );
    +                return;
    +              }
    +
    +              const errorMessage = yield* Ref.get(promptFailureMessageRef);
    +              yield* withThreadLock(
    +                input.threadId,
    +                settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, {
    +                  errorMessage: errorMessage ?? "Grok prompt request failed.",
    +                }),
    +              );
    +            }).pipe(Effect.catch(() => Effect.void)),
               ),
             );
           });
     
    -    const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId) =>
    +    const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId, turnId) =>
           Effect.gen(function* () {
    -        const ctx = yield* requireSession(threadId);
    -        yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
    -        yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs);
    -        yield* Effect.ignore(
    -          ctx.acp.cancel.pipe(
    -            Effect.mapError((error) =>
    -              mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error),
    -            ),
    -          ),
    +        const observed = yield* Effect.sync(() => {
    +          const ctx = sessions.get(threadId);
    +          if (!ctx || ctx.stopped) {
    +            return {
    +              _tag: "Proceed" as const,
    +              acpSessionId: undefined,
    +              interruptedTurnId: turnId,
    +            };
    +          }
    +          const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId;
    +          if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) {
    +            return { _tag: "Ignore" as const };
    +          }
    +          const interruptedTurnId = turnId ?? activeTurnId;
    +          if (interruptedTurnId !== undefined) {
    +            ctx.interruptedTurnIds.add(interruptedTurnId);
    +          }
    +          return {
    +            _tag: "Proceed" as const,
    +            acpSessionId: ctx.acpSessionId,
    +            interruptedTurnId,
    +          };
    +        });
    +        if (observed._tag === "Ignore") {
    +          return;
    +        }
    +
    +        yield* withThreadLock(
    +          threadId,
    +          Effect.gen(function* () {
    +            const ctx = yield* requireSession(threadId);
    +            if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) {
    +              return;
    +            }
    +            const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId;
    +            if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) {
    +              return;
    +            }
    +            if (
    +              observed.interruptedTurnId !== undefined &&
    +              activeTurnId !== undefined &&
    +              activeTurnId !== observed.interruptedTurnId
    +            ) {
    +              return;
    +            }
    +            const interruptedTurnId =
    +              observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId;
    +            yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
    +            yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs);
    +            yield* Effect.ignore(
    +              ctx.acp.cancel.pipe(
    +                Effect.mapError((error) =>
    +                  mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error),
    +                ),
    +              ),
    +            );
    +            if (interruptedTurnId) {
    +              ctx.interruptedTurnIds.add(interruptedTurnId);
    +              yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, {
    +                completedStopReason: "cancelled",
    +                settleAllPrompts: true,
    +              });
    +            } else if (
    +              ctx.promptsInFlight > 0 ||
    +              ctx.session.status === "running" ||
    +              ctx.session.status === "connecting"
    +            ) {
    +              const updatedAt = yield* nowIso;
    +              ctx.promptsInFlight = 0;
    +              ctx.activeTurnId = undefined;
    +              const { activeTurnId: _activeTurnId, ...readySession } = ctx.session;
    +              ctx.session = {
    +                ...readySession,
    +                status: "ready",
    +                updatedAt,
    +              };
    +            }
    +          }),
             );
           });
     
    diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts
    index 5533a04bc83..4e9700dab7d 100644
    --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts
    +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts
    @@ -7,6 +7,9 @@ import * as NodeFS from "node:fs";
     import * as NodeServices from "@effect/platform-node/NodeServices";
     import { it } from "@effect/vitest";
     import * as Effect from "effect/Effect";
    +import * as Fiber from "effect/Fiber";
    +import * as Option from "effect/Option";
    +import * as TestClock from "effect/testing/TestClock";
     import * as Stream from "effect/Stream";
     import { describe, expect } from "vite-plus/test";
     
    @@ -113,6 +116,122 @@ describe("AcpSessionRuntime", () => {
         ),
       );
     
    +  it.effect("drops session updates emitted for a child ACP session", () =>
    +    Effect.gen(function* () {
    +      const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
    +      yield* runtime.start();
    +
    +      const promptResult = yield* runtime.prompt({
    +        prompt: [{ type: "text", text: "hi" }],
    +      });
    +      expect(promptResult).toMatchObject({ stopReason: "end_turn" });
    +
    +      const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
    +      expect(notes.map((note) => note._tag)).toEqual([
    +        "AssistantItemStarted",
    +        "ContentDelta",
    +        "ContentDelta",
    +        "AssistantItemCompleted",
    +      ]);
    +      expect(
    +        notes
    +          .filter((note) => note._tag === "ContentDelta")
    +          .map((note) => note.text)
    +          .join(""),
    +      ).toBe("root before child root after child");
    +      expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false);
    +    }).pipe(
    +      Effect.provide(
    +        AcpSessionRuntime.layer({
    +          spawn: {
    +            command: mockAgentCommand,
    +            args: mockAgentArgs,
    +            env: {
    +              T3_ACP_EMIT_FOREIGN_SESSION_UPDATES: "1",
    +            },
    +          },
    +          cwd: process.cwd(),
    +          clientInfo: { name: "t3-test", version: "0.0.0" },
    +          authMethodId: "test",
    +        }),
    +      ),
    +      Effect.scoped,
    +      Effect.provide(NodeServices.layer),
    +    ),
    +  );
    +
    +  it.effect("supports successive standard ACP prompts", () =>
    +    Effect.gen(function* () {
    +      const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
    +      yield* runtime.start();
    +
    +      const firstPromptResult = yield* runtime.prompt({
    +        prompt: [{ type: "text", text: "first" }],
    +      });
    +      const secondPromptResult = yield* runtime.prompt({
    +        prompt: [{ type: "text", text: "second" }],
    +      });
    +
    +      expect(firstPromptResult).toMatchObject({ stopReason: "end_turn" });
    +      expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" });
    +    }).pipe(
    +      Effect.provide(
    +        AcpSessionRuntime.layer({
    +          spawn: {
    +            command: mockAgentCommand,
    +            args: mockAgentArgs,
    +          },
    +          cwd: process.cwd(),
    +          clientInfo: { name: "t3-test", version: "0.0.0" },
    +          authMethodId: "test",
    +        }),
    +      ),
    +      Effect.scoped,
    +      Effect.provide(NodeServices.layer),
    +    ),
    +  );
    +
    +  it.effect("releases a fully silent prompt when session/cancel is requested", () =>
    +    Effect.gen(function* () {
    +      const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
    +      yield* runtime.start();
    +
    +      const promptFiber = yield* runtime
    +        .prompt({
    +          prompt: [{ type: "text", text: "hang forever" }],
    +        })
    +        .pipe(Effect.forkChild({ startImmediately: true }));
    +
    +      yield* TestClock.adjust("500 millis");
    +      yield* runtime.cancel;
    +
    +      const firstPromptResult = yield* Fiber.join(promptFiber);
    +      expect(firstPromptResult).toMatchObject({ stopReason: "cancelled" });
    +
    +      const secondPromptResult = yield* runtime.prompt({
    +        prompt: [{ type: "text", text: "second" }],
    +      });
    +      expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" });
    +    }).pipe(
    +      Effect.provide(
    +        AcpSessionRuntime.layer({
    +          spawn: {
    +            command: mockAgentCommand,
    +            args: mockAgentArgs,
    +            env: {
    +              T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1",
    +            },
    +          },
    +          cwd: process.cwd(),
    +          clientInfo: { name: "t3-test", version: "0.0.0" },
    +          authMethodId: "test",
    +        }),
    +      ),
    +      Effect.scoped,
    +      Effect.provide(NodeServices.layer),
    +    ),
    +  );
    +
       it.effect("segments assistant text around ACP tool calls", () =>
         Effect.gen(function* () {
           const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
    @@ -346,6 +465,109 @@ describe("AcpSessionRuntime", () => {
         );
       });
     
    +  it.effect("fails session startup when session/load returns an error", () =>
    +    Effect.gen(function* () {
    +      const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
    +      const error = yield* runtime.start().pipe(Effect.flip);
    +
    +      expect(error._tag).toBe("AcpRequestError");
    +    }).pipe(
    +      Effect.provide(
    +        AcpSessionRuntime.layer({
    +          authMethodId: "test",
    +          spawn: {
    +            command: mockAgentCommand,
    +            args: mockAgentArgs,
    +            env: {
    +              T3_ACP_FAIL_LOAD_SESSION: "1",
    +            },
    +          },
    +          cwd: process.cwd(),
    +          resumeSessionId: "stale-session-id",
    +          clientInfo: { name: "t3-test", version: "0.0.0" },
    +        }),
    +      ),
    +      Effect.scoped,
    +      Effect.provide(NodeServices.layer),
    +    ),
    +  );
    +
    +  it.effect("ignores session/update replay notifications during session/load", () =>
    +    Effect.gen(function* () {
    +      const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
    +      yield* runtime.start();
    +
    +      yield* runtime.prompt({
    +        prompt: [{ type: "text", text: "hi" }],
    +      });
    +      const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
    +      expect(notes.map((note) => note._tag)).toEqual([
    +        "PlanUpdated",
    +        "AssistantItemStarted",
    +        "ContentDelta",
    +        "AssistantItemCompleted",
    +      ]);
    +      expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false);
    +    }).pipe(
    +      Effect.provide(
    +        AcpSessionRuntime.layer({
    +          authMethodId: "test",
    +          spawn: {
    +            command: mockAgentCommand,
    +            args: mockAgentArgs,
    +            env: {
    +              T3_ACP_EMIT_LOAD_REPLAY: "1",
    +            },
    +          },
    +          cwd: process.cwd(),
    +          resumeSessionId: "mock-session-1",
    +          clientInfo: { name: "t3-test", version: "0.0.0" },
    +        }),
    +      ),
    +      Effect.scoped,
    +      Effect.provide(NodeServices.layer),
    +    ),
    +  );
    +
    +  it.effect("completes session/load after replay becomes idle while its RPC stays pending", () =>
    +    Effect.gen(function* () {
    +      const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
    +      const started = yield* runtime.start().pipe(Effect.timeout("2 seconds"));
    +
    +      expect(started.sessionId).toBe("mock-session-1");
    +      expect(started.sessionSetupResult._meta).toMatchObject({
    +        t3SessionLoadReady: "replay_idle",
    +      });
    +
    +      const unexpectedReplayEvent = yield* Stream.runHead(runtime.getEvents()).pipe(
    +        Effect.timeoutOption("100 millis"),
    +      );
    +      expect(Option.isNone(unexpectedReplayEvent)).toBe(true);
    +    }).pipe(
    +      Effect.provide(
    +        AcpSessionRuntime.layer({
    +          authMethodId: "test",
    +          spawn: {
    +            command: mockAgentCommand,
    +            args: mockAgentArgs,
    +            env: {
    +              T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1",
    +              T3_ACP_LOAD_SESSION_DELAY_MS: "10000",
    +            },
    +          },
    +          cwd: process.cwd(),
    +          resumeSessionId: "mock-session-1",
    +          sessionLoadReplayIdleGap: "50 millis",
    +          sessionLoadTimeout: "1 second",
    +          clientInfo: { name: "t3-test", version: "0.0.0" },
    +        }),
    +      ),
    +      Effect.scoped,
    +      Effect.provide(NodeServices.layer),
    +      TestClock.withLive,
    +    ),
    +  );
    +
       it.effect("rejects invalid config option values before sending session/set_config_option", () => {
         const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-"));
         const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
    diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts
    index 54b9e5390e0..7682c5f5f9c 100644
    --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts
    +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts
    @@ -8,6 +8,8 @@ import {
       parsePermissionRequest,
       parseSessionModeState,
       parseSessionUpdateEvent,
    +  sessionUpdateIsReplay,
    +  syntheticLoadSessionResponseFromInitialize,
     } from "./AcpRuntimeModel.ts";
     
     describe("AcpRuntimeModel", () => {
    @@ -59,6 +61,95 @@ describe("AcpRuntimeModel", () => {
         expect(modelConfigId).toBe("model");
       });
     
    +  it("detects Grok session replay updates from _meta.isReplay", () => {
    +    expect(
    +      sessionUpdateIsReplay({
    +        _meta: { isReplay: true },
    +        sessionId: "session-1",
    +        update: {
    +          sessionUpdate: "agent_message_chunk",
    +          content: { type: "text", text: "replayed" },
    +        },
    +      } satisfies EffectAcpSchema.SessionNotification),
    +    ).toBe(true);
    +    expect(
    +      sessionUpdateIsReplay({
    +        sessionId: "session-1",
    +        update: {
    +          sessionUpdate: "agent_message_chunk",
    +          content: { type: "text", text: "live" },
    +        },
    +      } satisfies EffectAcpSchema.SessionNotification),
    +    ).toBe(false);
    +  });
    +
    +  it("builds a synthetic load response from initialize model state", () => {
    +    const response = syntheticLoadSessionResponseFromInitialize({
    +      protocolVersion: 1,
    +      _meta: {
    +        modelState: {
    +          currentModelId: "grok-build",
    +          availableModels: [{ modelId: "grok-build", name: "Grok Build" }],
    +        },
    +      },
    +    } satisfies EffectAcpSchema.InitializeResponse);
    +
    +    expect(response.models?.currentModelId).toBe("grok-build");
    +    expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" });
    +  });
    +
    +  it("accepts initialize model descriptions with null", () => {
    +    const response = syntheticLoadSessionResponseFromInitialize({
    +      protocolVersion: 1,
    +      _meta: {
    +        modelState: {
    +          currentModelId: "grok-build",
    +          availableModels: [{ modelId: "grok-build", name: "Grok Build", description: null }],
    +        },
    +      },
    +    } satisfies EffectAcpSchema.InitializeResponse);
    +
    +    expect(response.models?.availableModels[0]?.description).toBeNull();
    +  });
    +
    +  it("ignores malformed initialize model state in synthetic load responses", () => {
    +    const response = syntheticLoadSessionResponseFromInitialize({
    +      protocolVersion: 1,
    +      _meta: {
    +        modelState: {
    +          currentModelId: "grok-build",
    +          availableModels: [null],
    +        },
    +        modeState: {
    +          currentModeId: "code",
    +          availableModes: [{ id: "code", name: 12 }],
    +        },
    +      },
    +    } as EffectAcpSchema.InitializeResponse);
    +
    +    expect(response.models).toBeUndefined();
    +    expect(response.modes).toBeUndefined();
    +    expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" });
    +  });
    +
    +  it("builds a synthetic load response with initialize mode state", () => {
    +    const response = syntheticLoadSessionResponseFromInitialize({
    +      protocolVersion: 1,
    +      _meta: {
    +        modeState: {
    +          currentModeId: "code",
    +          availableModes: [
    +            { id: "ask", name: "Ask" },
    +            { id: "code", name: "Code" },
    +          ],
    +        },
    +      },
    +    } satisfies EffectAcpSchema.InitializeResponse);
    +
    +    expect(response.modes?.currentModeId).toBe("code");
    +    expect(response.modes?.availableModes).toHaveLength(2);
    +  });
    +
       it("projects typed ACP tool call updates into runtime events", () => {
         const created = parseSessionUpdateEvent({
           sessionId: "session-1",
    diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts
    index 3587d703dbd..e6bfc127e6e 100644
    --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts
    +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts
    @@ -1,3 +1,8 @@
    +import * as Clock from "effect/Clock";
    +import * as Duration from "effect/Duration";
    +import * as Effect from "effect/Effect";
    +import * as Option from "effect/Option";
    +import * as Ref from "effect/Ref";
     import type * as EffectAcpSchema from "effect-acp/schema";
     import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity";
     import type { ToolLifecycleItemType } from "@t3tools/contracts";
    @@ -6,6 +11,40 @@ function isRecord(value: unknown): value is Record {
       return typeof value === "object" && value !== null && !Array.isArray(value);
     }
     
    +function isSessionModelState(value: unknown): value is EffectAcpSchema.SessionModelState {
    +  if (!isRecord(value) || typeof value.currentModelId !== "string") {
    +    return false;
    +  }
    +  if (!Array.isArray(value.availableModels)) {
    +    return false;
    +  }
    +  return value.availableModels.every(
    +    (model) =>
    +      isRecord(model) &&
    +      typeof model.modelId === "string" &&
    +      typeof model.name === "string" &&
    +      (model.description === undefined ||
    +        model.description === null ||
    +        typeof model.description === "string"),
    +  );
    +}
    +
    +function isSessionModeState(value: unknown): value is EffectAcpSchema.SessionModeState {
    +  if (!isRecord(value) || typeof value.currentModeId !== "string") {
    +    return false;
    +  }
    +  if (!Array.isArray(value.availableModes)) {
    +    return false;
    +  }
    +  return value.availableModes.every(
    +    (mode) =>
    +      isRecord(mode) &&
    +      typeof mode.id === "string" &&
    +      typeof mode.name === "string" &&
    +      (mode.description === undefined || typeof mode.description === "string"),
    +  );
    +}
    +
     export interface AcpSessionMode {
       readonly id: string;
       readonly name: string;
    @@ -414,6 +453,58 @@ export function parsePermissionRequest(
       };
     }
     
    +export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotification): boolean {
    +  const meta = params._meta;
    +  return isRecord(meta) && meta.isReplay === true;
    +}
    +
    +export interface SessionLoadGate {
    +  readonly active: boolean;
    +  readonly lastActivityAtMillis: number | undefined;
    +  readonly idleGap: Duration.Duration;
    +  readonly initializeResult: EffectAcpSchema.InitializeResponse;
    +}
    +
    +export const waitForSessionLoadReplayIdle = (input: {
    +  readonly gateRef: Ref.Ref>;
    +}): Effect.Effect =>
    +  Effect.gen(function* () {
    +    const pollInterval = Duration.millis(25);
    +    while (true) {
    +      const gate = yield* Ref.get(input.gateRef);
    +      if (
    +        Option.isSome(gate) &&
    +        gate.value.active &&
    +        gate.value.lastActivityAtMillis !== undefined
    +      ) {
    +        const idleGapMillis = Duration.toMillis(gate.value.idleGap);
    +        const nowMillis = yield* Clock.currentTimeMillis;
    +        if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) {
    +          return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult);
    +        }
    +      }
    +      yield* Effect.sleep(pollInterval);
    +    }
    +  });
    +
    +export function syntheticLoadSessionResponseFromInitialize(
    +  initializeResult: EffectAcpSchema.InitializeResponse,
    +): EffectAcpSchema.LoadSessionResponse {
    +  const meta = initializeResult._meta;
    +  const modelState = isRecord(meta) ? meta.modelState : undefined;
    +  const modeState = isRecord(meta) ? meta.modeState : undefined;
    +  const models = isSessionModelState(modelState) ? modelState : undefined;
    +  const modes = isSessionModeState(modeState) ? modeState : undefined;
    +
    +  return {
    +    ...(models ? { models } : {}),
    +    ...(modes ? { modes } : {}),
    +    _meta: {
    +      t3SessionLoadReady: "replay_idle",
    +    },
    +  };
    +}
    +
     export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): {
       readonly modeId?: string;
       readonly events: ReadonlyArray;
    diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts
    index 4fc2c443e11..bc2df3aa8d4 100644
    --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts
    +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts
    @@ -1,12 +1,16 @@
     import * as Cause from "effect/Cause";
    +import * as Clock from "effect/Clock";
     import * as Context from "effect/Context";
     import * as Deferred from "effect/Deferred";
    +import * as Duration from "effect/Duration";
     import * as Effect from "effect/Effect";
    -import * as Exit from "effect/Exit";
    +import * as Fiber from "effect/Fiber";
     import * as Layer from "effect/Layer";
    +import * as Option from "effect/Option";
     import * as Queue from "effect/Queue";
     import * as Ref from "effect/Ref";
     import * as Scope from "effect/Scope";
    +import * as Semaphore from "effect/Semaphore";
     import * as Stream from "effect/Stream";
     import * as ChildProcess from "effect/unstable/process/ChildProcess";
     import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
    @@ -23,6 +27,9 @@ import {
       mergeToolCallState,
       parseSessionModeState,
       parseSessionUpdateEvent,
    +  sessionUpdateIsReplay,
    +  waitForSessionLoadReplayIdle,
    +  type SessionLoadGate,
       type AcpParsedSessionEvent,
       type AcpSessionModeState,
       type AcpToolCallState,
    @@ -32,6 +39,16 @@ function formatConfigOptionValue(value: string | boolean): string {
       return JSON.stringify(value);
     }
     
    +export interface AcpSessionEventStreamBarrier {
    +  readonly _tag: "EventStreamBarrier";
    +  readonly acknowledge: Deferred.Deferred;
    +}
    +
    +export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier;
    +
    +const defaultSessionLoadTimeout = Duration.seconds(90);
    +const defaultSessionLoadReplayIdleGap = Duration.seconds(2);
    +
     export interface AcpSpawnInput {
       readonly command: string;
       readonly args: ReadonlyArray;
    @@ -43,6 +60,8 @@ export interface AcpSessionRuntimeOptions {
       readonly spawn: AcpSpawnInput;
       readonly cwd: string;
       readonly resumeSessionId?: string;
    +  readonly sessionLoadTimeout?: Duration.Input;
    +  readonly sessionLoadReplayIdleGap?: Duration.Input;
       readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];
       readonly clientInfo: {
         readonly name: string;
    @@ -160,7 +179,9 @@ export class AcpSessionRuntime extends Context.Service<
          */
         readonly start: () => Effect.Effect;
         /** Stream of parsed ACP session events emitted after startup. */
    -    readonly getEvents: () => Stream.Stream;
    +    readonly getEvents: () => Stream.Stream;
    +    /** Waits until the current event consumer has processed every queued event. */
    +    readonly drainEvents: Effect.Effect;
         /** Latest mode state observed from session setup and `session/update` notifications. */
         readonly getModeState: Effect.Effect;
         /** Latest configuration options observed from session setup and configuration writes. */
    @@ -254,12 +275,17 @@ export const make = (
       Effect.gen(function* () {
         const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
         const runtimeScope = yield* Scope.Scope;
    -    const eventQueue = yield* Queue.unbounded();
    +    const eventQueue = yield* Queue.unbounded();
         const modeStateRef = yield* Ref.make(undefined);
         const toolCallsRef = yield* Ref.make(new Map());
         const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 });
         const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));
         const startStateRef = yield* Ref.make({ _tag: "NotStarted" });
    +    const promptSerializationSemaphore = yield* Semaphore.make(1);
    +    const activePromptFiberRef = yield* Ref.make<
    +      Option.Option>
    +    >(Option.none());
    +    const sessionLoadGateRef = yield* Ref.make>(Option.none());
     
         const logRequest = (event: AcpSessionRequestLogEvent) =>
           options.requestLogger ? options.requestLogger(event) : Effect.void;
    @@ -331,15 +357,40 @@ export const make = (
         const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext));
     
         yield* acp.handleSessionUpdate((notification) =>
    -      handleSessionUpdate({
    -        queue: eventQueue,
    -        modeStateRef,
    -        toolCallsRef,
    -        assistantSegmentRef,
    -        params: notification,
    +      Effect.gen(function* () {
    +        const gate = yield* Ref.get(sessionLoadGateRef);
    +        if (Option.isSome(gate) && gate.value.active) {
    +          const lastActivityAtMillis = yield* Clock.currentTimeMillis;
    +          yield* Ref.set(
    +            sessionLoadGateRef,
    +            Option.some({
    +              ...gate.value,
    +              lastActivityAtMillis,
    +            }),
    +          );
    +          return;
    +        }
    +        if (sessionUpdateIsReplay(notification)) {
    +          return;
    +        }
    +        const startState = yield* Ref.get(startStateRef);
    +        // One runtime projects one root ACP session. Child-session updates need
    +        // explicit lineage routing and must never be flattened into this stream.
    +        if (
    +          startState._tag !== "Started" ||
    +          notification.sessionId !== startState.result.sessionId
    +        ) {
    +          return;
    +        }
    +        yield* handleSessionUpdate({
    +          queue: eventQueue,
    +          modeStateRef,
    +          toolCallsRef,
    +          assistantSegmentRef,
    +          params: notification,
    +        });
           }),
         );
    -
         const initializeClientCapabilities = {
           fs: {
             readTextFile: false,
    @@ -499,27 +550,74 @@ export const make = (
               cwd: options.cwd,
               mcpServers: options.mcpServers ?? [],
             } satisfies EffectAcpSchema.LoadSessionRequest;
    -        const resumed = yield* runLoggedRequest(
    -          "session/load",
    -          loadPayload,
    -          acp.agent.loadSession(loadPayload),
    -        ).pipe(Effect.exit);
    -        if (Exit.isSuccess(resumed)) {
    -          sessionId = options.resumeSessionId;
    -          sessionSetupResult = resumed.value;
    -        } else {
    -          const createPayload = {
    -            cwd: options.cwd,
    -            mcpServers: options.mcpServers ?? [],
    -          } satisfies EffectAcpSchema.NewSessionRequest;
    -          const created = yield* runLoggedRequest(
    -            "session/new",
    -            createPayload,
    -            acp.agent.createSession(createPayload),
    +        const sessionLoadTimeout = Duration.fromInputUnsafe(
    +          options.sessionLoadTimeout ?? defaultSessionLoadTimeout,
    +        );
    +        const sessionLoadReplayIdleGap = Duration.fromInputUnsafe(
    +          options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap,
    +        );
    +
    +        yield* Ref.set(
    +          sessionLoadGateRef,
    +          Option.some({
    +            active: true,
    +            lastActivityAtMillis: undefined,
    +            idleGap: sessionLoadReplayIdleGap,
    +            initializeResult,
    +          }),
    +        );
    +
    +        sessionId = options.resumeSessionId;
    +        sessionSetupResult = yield* Effect.gen(function* () {
    +          yield* logRequest({
    +            method: "session/load",
    +            payload: loadPayload,
    +            status: "started",
    +          });
    +
    +          const idleFiber = yield* waitForSessionLoadReplayIdle({
    +            gateRef: sessionLoadGateRef,
    +          }).pipe(Effect.forkIn(runtimeScope));
    +          const loaded = yield* Effect.raceFirst(
    +            acp.agent.loadSession(loadPayload),
    +            Fiber.join(idleFiber),
    +          ).pipe(
    +            Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)),
    +            Effect.timeoutOption(sessionLoadTimeout),
    +            Effect.flatMap((result) =>
    +              Option.match(result, {
    +                onNone: () =>
    +                  Effect.fail(
    +                    new EffectAcpErrors.AcpTransportError({
    +                      operation: "call-rpc",
    +                      method: "session/load",
    +                      detail: "session/load timed out waiting for RPC response or replay idle gap",
    +                      cause: undefined,
    +                    }),
    +                  ),
    +                onSome: Effect.succeed,
    +              }),
    +            ),
    +            Effect.tap((result) =>
    +              logRequest({
    +                method: "session/load",
    +                payload: loadPayload,
    +                status: "succeeded",
    +                result,
    +              }),
    +            ),
    +            Effect.onError((cause) =>
    +              logRequest({
    +                method: "session/load",
    +                payload: loadPayload,
    +                status: "failed",
    +                cause,
    +              }),
    +            ),
               );
    -          sessionId = created.sessionId;
    -          sessionSetupResult = created;
    -        }
    +
    +          return loaded;
    +        }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none())));
           } else {
             const createPayload = {
               cwd: options.cwd,
    @@ -596,25 +694,48 @@ export const make = (
           handleExtNotification: acp.handleExtNotification,
           start: () => start,
           getEvents: () => Stream.fromQueue(eventQueue),
    +      drainEvents: Effect.gen(function* () {
    +        const acknowledge = yield* Deferred.make();
    +        yield* Queue.offer(eventQueue, {
    +          _tag: "EventStreamBarrier",
    +          acknowledge,
    +        });
    +        yield* Deferred.await(acknowledge);
    +      }),
           getModeState: Ref.get(modeStateRef),
           getConfigOptions: Ref.get(configOptionsRef),
           prompt: (payload) =>
    -        getStartedState.pipe(
    -          Effect.flatMap((started) => {
    +        promptSerializationSemaphore.withPermit(
    +          Effect.gen(function* () {
    +            const started = yield* getStartedState;
    +            yield* closeActiveAssistantSegment({
    +              queue: eventQueue,
    +              assistantSegmentRef,
    +            });
                 const requestPayload = {
                   sessionId: started.sessionId,
                   ...payload,
                 } satisfies EffectAcpSchema.PromptRequest;
    -            return closeActiveAssistantSegment({
    -              queue: eventQueue,
    -              assistantSegmentRef,
    -            }).pipe(
    -              Effect.andThen(
    -                runLoggedRequest(
    -                  "session/prompt",
    -                  requestPayload,
    -                  acp.agent.prompt(requestPayload),
    -                ),
    +            const cancelledResponse = {
    +              stopReason: "cancelled",
    +            } satisfies EffectAcpSchema.PromptResponse;
    +            const promptRpcFiber = yield* runLoggedRequest(
    +              "session/prompt",
    +              requestPayload,
    +              acp.agent.prompt(requestPayload),
    +            ).pipe(Effect.forkIn(runtimeScope));
    +            yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber));
    +            return yield* Fiber.join(promptRpcFiber).pipe(
    +              Effect.catchCause((cause) =>
    +                Cause.hasInterruptsOnly(cause)
    +                  ? Effect.succeed(cancelledResponse)
    +                  : Effect.failCause(cause),
    +              ),
    +              Effect.ensuring(
    +                Effect.gen(function* () {
    +                  yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore);
    +                  yield* Ref.set(activePromptFiberRef, Option.none());
    +                }),
                   ),
                   Effect.tap(() =>
                     closeActiveAssistantSegment({
    @@ -626,7 +747,17 @@ export const make = (
               }),
             ),
           cancel: getStartedState.pipe(
    -        Effect.flatMap((started) => acp.agent.cancel({ sessionId: started.sessionId })),
    +        Effect.flatMap((started) =>
    +          Effect.gen(function* () {
    +            const activePromptFiber = yield* Ref.get(activePromptFiberRef);
    +            if (Option.isSome(activePromptFiber)) {
    +              yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore);
    +            }
    +            yield* acp.agent
    +              .cancel({ sessionId: started.sessionId })
    +              .pipe(Effect.ignore, Effect.forkIn(runtimeScope));
    +          }),
    +        ),
           ),
           setMode: (modeId) =>
             Ref.get(modeStateRef).pipe(
    @@ -705,7 +836,7 @@ const handleSessionUpdate = ({
       assistantSegmentRef,
       params,
     }: {
    -  readonly queue: Queue.Queue;
    +  readonly queue: Queue.Queue;
       readonly modeStateRef: Ref.Ref;
       readonly toolCallsRef: Ref.Ref>;
       readonly assistantSegmentRef: Ref.Ref;
    @@ -801,7 +932,7 @@ const ensureActiveAssistantSegment = ({
       assistantSegmentRef,
       sessionId,
     }: {
    -  readonly queue: Queue.Queue;
    +  readonly queue: Queue.Queue;
       readonly assistantSegmentRef: Ref.Ref;
       readonly sessionId: string;
     }) =>
    @@ -838,7 +969,7 @@ const closeActiveAssistantSegment = ({
       queue,
       assistantSegmentRef,
     }: {
    -  readonly queue: Queue.Queue;
    +  readonly queue: Queue.Queue;
       readonly assistantSegmentRef: Ref.Ref;
     }) =>
       Ref.modify(assistantSegmentRef, (current) => {
    diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts
    index ee8af1e5266..3e6d63a5393 100644
    --- a/apps/server/src/provider/acp/GrokAcpSupport.ts
    +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts
    @@ -8,6 +8,7 @@ import type * as EffectAcpSchema from "effect-acp/schema";
     import { normalizeModelSlug } from "@t3tools/shared/model";
     
     import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
    +import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts";
     
     const GROK_API_KEY_ENV = "XAI_API_KEY";
     const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER";
    @@ -68,9 +69,10 @@ export const makeGrokAcpRuntime = (
             ),
           ),
         );
    -    return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
    +    const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
           Effect.provide(acpContext),
         );
    +    return yield* makeXAiPromptCompletionRuntime(runtime);
       });
     
     export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {
    diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts
    index a42561d9ae2..c435269fd76 100644
    --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts
    +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts
    @@ -1,12 +1,39 @@
    -import { describe, expect, it } from "vite-plus/test";
    +// @effect-diagnostics nodeBuiltinImport:off
    +import * as NodePath from "node:path";
    +import * as NodeURL from "node:url";
    +
    +import * as NodeServices from "@effect/platform-node/NodeServices";
    +import { it } from "@effect/vitest";
    +import * as Effect from "effect/Effect";
     import * as Schema from "effect/Schema";
    +import { describe, expect } from "vite-plus/test";
     
     import {
       extractXAiAskUserQuestions,
       makeXAiAskUserQuestionCancelledResponse,
       makeXAiAskUserQuestionResponse,
    +  makeXAiPromptCompletionRuntime,
       XAiAskUserQuestionRequest,
     } from "./XAiAcpExtension.ts";
    +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
    +
    +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
    +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
    +
    +const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) =>
    +  Effect.gen(function* () {
    +    const runtime = yield* AcpSessionRuntime.make({
    +      spawn: {
    +        command: process.execPath,
    +        args: [mockAgentPath],
    +        env,
    +      },
    +      cwd: process.cwd(),
    +      clientInfo: { name: "t3-test", version: "0.0.0" },
    +      authMethodId: "test",
    +    });
    +    return yield* makeXAiPromptCompletionRuntime(runtime);
    +  });
     
     const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);
     
    @@ -247,4 +274,59 @@ describe("XAiAcpExtension", () => {
           },
         });
       });
    +
    +  it.effect("resolves a hung standard prompt from xAI prompt completion", () =>
    +    Effect.gen(function* () {
    +      const runtime = yield* makePromptCompletionRuntime({
    +        T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1",
    +      });
    +      yield* runtime.start();
    +
    +      const promptResult = yield* runtime.prompt({
    +        prompt: [{ type: "text", text: "hi" }],
    +      });
    +      const promptId = promptResult._meta?.promptId;
    +
    +      expect(typeof promptId).toBe("string");
    +      expect(promptResult).toMatchObject({
    +        stopReason: "end_turn",
    +        _meta: {
    +          sessionId: "mock-session-1",
    +          promptId,
    +          requestId: promptId,
    +        },
    +      });
    +    }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
    +  );
    +
    +  it.effect("ignores stale xAI completion from an already settled prompt", () =>
    +    Effect.gen(function* () {
    +      const runtime = yield* makePromptCompletionRuntime({
    +        T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG: "1",
    +      });
    +      yield* runtime.start();
    +
    +      const firstPromptResult = yield* runtime.prompt({
    +        prompt: [{ type: "text", text: "first" }],
    +      });
    +      expect(firstPromptResult).toMatchObject({
    +        stopReason: "end_turn",
    +        _meta: { promptId: "mock-stale-xai-prompt-1" },
    +      });
    +
    +      const secondPromptResult = yield* runtime.prompt({
    +        prompt: [{ type: "text", text: "second" }],
    +      });
    +      const secondPromptId = secondPromptResult._meta?.promptId;
    +      expect(typeof secondPromptId).toBe("string");
    +      expect(secondPromptId).not.toBe("mock-stale-xai-prompt-1");
    +      expect(secondPromptResult).toMatchObject({
    +        stopReason: "end_turn",
    +        _meta: {
    +          promptId: secondPromptId,
    +          requestId: secondPromptId,
    +        },
    +      });
    +    }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
    +  );
     });
    diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts
    index 6c774c7f8d5..d36a5fcfc89 100644
    --- a/apps/server/src/provider/acp/XAiAcpExtension.ts
    +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts
    @@ -1,5 +1,29 @@
     import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
    +import * as Deferred from "effect/Deferred";
    +import * as Effect from "effect/Effect";
    +import * as Ref from "effect/Ref";
     import * as Schema from "effect/Schema";
    +import type * as EffectAcpSchema from "effect-acp/schema";
    +
    +import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
    +
    +const XAiPromptCompleteNotification = Schema.Struct({
    +  sessionId: Schema.String,
    +  promptId: Schema.optional(Schema.String),
    +  stopReason: Schema.optional(Schema.String),
    +  agentResult: Schema.optional(Schema.NullOr(Schema.Unknown)),
    +});
    +
    +type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;
    +
    +interface PendingXAiPromptCompletion {
    +  readonly sessionId: string;
    +  readonly promptId: string;
    +  readonly deferred: Deferred.Deferred;
    +}
    +
    +const completedXAiPromptIdLimit = 128;
    +const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
     
     const XAiAskUserQuestionOption = Schema.Struct({
       label: Schema.String,
    @@ -171,3 +195,238 @@ export function makeXAiAskUserQuestionResponse(
     export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse {
       return { outcome: "cancelled" };
     }
    +
    +/**
    + * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime.
    + * The underlying runtime remains unaware of xAI methods and metadata.
    + */
    +export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")(
    +  function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) {
    +    const activeSessionIdRef = yield* Ref.make(undefined);
    +    const pendingRef = yield* Ref.make>([]);
    +    const completedPromptIdsRef = yield* Ref.make>([]);
    +    let nextPromptFallbackId = 0;
    +    const allocatePromptFallbackId = Effect.sync(() => {
    +      nextPromptFallbackId += 1;
    +      return `t3-xai-prompt-${nextPromptFallbackId}`;
    +    });
    +
    +    yield* runtime.handleExtNotification(
    +      "_x.ai/session/prompt_complete",
    +      XAiPromptCompleteNotification,
    +      (notification) =>
    +        resolveXAiPromptCompletionFallback({
    +          pendingRef,
    +          completedPromptIdsRef,
    +          notification,
    +        }),
    +    );
    +
    +    return {
    +      ...runtime,
    +      start: () =>
    +        runtime
    +          .start()
    +          .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))),
    +      prompt: (payload) =>
    +        Effect.gen(function* () {
    +          const sessionId = yield* Ref.get(activeSessionIdRef);
    +          if (sessionId === undefined) {
    +            return yield* runtime.prompt(payload);
    +          }
    +
    +          const promptId = yield* allocatePromptFallbackId;
    +          const fallback = yield* registerXAiPromptCompletionFallback(
    +            pendingRef,
    +            sessionId,
    +            promptId,
    +          );
    +          const requestPayload = {
    +            ...payload,
    +            _meta: {
    +              ...payload._meta,
    +              promptId: fallback.promptId,
    +              requestId: fallback.promptId,
    +            },
    +          } satisfies Omit;
    +
    +          return yield* Effect.raceFirst(
    +            runtime.prompt(requestPayload),
    +            Deferred.await(fallback.deferred),
    +          ).pipe(
    +            Effect.tap((response) =>
    +              rememberCompletedXAiPromptId(completedPromptIdsRef, response, fallback.promptId),
    +            ),
    +            Effect.ensuring(unregisterXAiPromptCompletionFallback(pendingRef, fallback.deferred)),
    +          );
    +        }),
    +      cancel: Ref.get(activeSessionIdRef).pipe(
    +        Effect.flatMap((sessionId) =>
    +          sessionId === undefined
    +            ? runtime.cancel
    +            : abortPendingPromptCompletions(pendingRef, sessionId).pipe(
    +                Effect.andThen(runtime.cancel),
    +              ),
    +        ),
    +      ),
    +    } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"];
    +  },
    +);
    +
    +const registerXAiPromptCompletionFallback = (
    +  pendingRef: Ref.Ref>,
    +  sessionId: string,
    +  promptId: string,
    +) =>
    +  Deferred.make().pipe(
    +    Effect.tap((deferred) =>
    +      Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),
    +    ),
    +    Effect.map((deferred) => ({ deferred, promptId })),
    +  );
    +
    +const unregisterXAiPromptCompletionFallback = (
    +  pendingRef: Ref.Ref>,
    +  deferred: Deferred.Deferred,
    +) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));
    +
    +const abortPendingPromptCompletions = (
    +  pendingRef: Ref.Ref>,
    +  sessionId: string,
    +) =>
    +  Ref.modify(pendingRef, (pending) => {
    +    const [toAbort, remaining] = pending.reduce<
    +      [ReadonlyArray, ReadonlyArray]
    +    >(
    +      ([aborting, kept], entry) =>
    +        entry.sessionId === sessionId ? [[...aborting, entry], kept] : [aborting, [...kept, entry]],
    +      [[], []],
    +    );
    +    if (toAbort.length === 0) {
    +      return [Effect.void, pending] as const;
    +    }
    +    return [
    +      Effect.forEach(
    +        toAbort,
    +        (entry) =>
    +          Deferred.succeed(
    +            entry.deferred,
    +            promptResponseFromXAi({
    +              sessionId: entry.sessionId,
    +              promptId: entry.promptId,
    +              stopReason: "cancelled",
    +              agentResult: null,
    +            }),
    +          ),
    +        { concurrency: "unbounded" },
    +      ).pipe(Effect.asVoid),
    +      remaining,
    +    ] as const;
    +  }).pipe(Effect.flatten);
    +
    +const resolveXAiPromptCompletionFallback = ({
    +  pendingRef,
    +  completedPromptIdsRef,
    +  notification,
    +}: {
    +  readonly pendingRef: Ref.Ref>;
    +  readonly completedPromptIdsRef: Ref.Ref>;
    +  readonly notification: XAiPromptCompleteNotification;
    +}) =>
    +  Ref.get(completedPromptIdsRef).pipe(
    +    Effect.flatMap((completedPromptIds) => {
    +      if (
    +        notification.promptId !== undefined &&
    +        completedPromptIds.includes(notification.promptId)
    +      ) {
    +        return Effect.void;
    +      }
    +      return Ref.modify(pendingRef, (pending) => {
    +        const index =
    +          notification.promptId !== undefined
    +            ? pending.findIndex(
    +                (entry) =>
    +                  entry.sessionId === notification.sessionId &&
    +                  entry.promptId === notification.promptId,
    +              )
    +            : pending.findIndex((entry) => entry.sessionId === notification.sessionId);
    +        if (index < 0) {
    +          return [Effect.void, pending] as const;
    +        }
    +        const entry = pending[index];
    +        if (!entry) {
    +          return [Effect.void, pending] as const;
    +        }
    +        return [
    +          Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid),
    +          [...pending.slice(0, index), ...pending.slice(index + 1)],
    +        ] as const;
    +      }).pipe(Effect.flatten);
    +    }),
    +  );
    +
    +const rememberCompletedXAiPromptId = (
    +  completedPromptIdsRef: Ref.Ref>,
    +  response: EffectAcpSchema.PromptResponse,
    +  fallbackPromptId: string,
    +) => {
    +  const promptId = promptIdFromResponse(response) ?? fallbackPromptId;
    +  return Ref.update(completedPromptIdsRef, (completedPromptIds) => {
    +    if (completedPromptIds.includes(promptId)) {
    +      return completedPromptIds;
    +    }
    +    return [...completedPromptIds, promptId].slice(-completedXAiPromptIdLimit);
    +  });
    +};
    +
    +function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined {
    +  const meta = response._meta;
    +  if (meta === null || typeof meta !== "object") {
    +    return undefined;
    +  }
    +  const promptId = meta.promptId ?? meta.requestId;
    +  return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined;
    +}
    +
    +export function promptResponseHasMissingXAiStopReason(
    +  response: EffectAcpSchema.PromptResponse,
    +): boolean {
    +  const meta = response._meta;
    +  return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true;
    +}
    +
    +function promptResponseFromXAi(
    +  notification: XAiPromptCompleteNotification,
    +): EffectAcpSchema.PromptResponse {
    +  const stopReason = normalizeXAiStopReason(notification.stopReason);
    +  const meta: Record = {
    +    sessionId: notification.sessionId,
    +  };
    +  if (notification.stopReason === undefined) {
    +    meta[xAiStopReasonMissingMetaKey] = true;
    +  }
    +  if (notification.promptId !== undefined) {
    +    meta.promptId = notification.promptId;
    +    meta.requestId = notification.promptId;
    +  }
    +  if (notification.agentResult !== undefined) {
    +    meta.agentResult = notification.agentResult;
    +  }
    +  return {
    +    stopReason,
    +    _meta: meta,
    +  };
    +}
    +
    +function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason {
    +  switch (value) {
    +    case "cancelled":
    +    case "end_turn":
    +    case "max_tokens":
    +    case "max_turn_requests":
    +    case "refusal":
    +      return value;
    +    default:
    +      return "end_turn";
    +  }
    +}
    diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
    index 43ed895c0db..0a0103df183 100644
    --- a/apps/web/src/components/ChatView.logic.test.ts
    +++ b/apps/web/src/components/ChatView.logic.test.ts
    @@ -6,6 +6,7 @@ import {
       MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
       MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
       buildExpiredTerminalContextToastCopy,
    +  buildThreadTurnInterruptInput,
       createLocalDispatchSnapshot,
       deriveComposerSendState,
       getStartedThreadModelChangeBlockReason,
    @@ -69,6 +70,30 @@ const readySession = {
       updatedAt: "2026-03-29T00:00:10.000Z",
     };
     
    +describe("buildThreadTurnInterruptInput", () => {
    +  it("targets the session's active running turn", () => {
    +    const activeTurnId = TurnId.make("turn-running");
    +
    +    expect(
    +      buildThreadTurnInterruptInput(
    +        makeThread({
    +          session: {
    +            ...readySession,
    +            status: "running",
    +            activeTurnId,
    +          },
    +        }),
    +      ),
    +    ).toEqual({ threadId, turnId: activeTurnId });
    +  });
    +
    +  it("omits a turn id when the session is not running", () => {
    +    expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({
    +      threadId,
    +    });
    +  });
    +});
    +
     describe("deriveComposerSendState", () => {
       it("treats expired terminal pills as non-sendable content", () => {
         const state = deriveComposerSendState({
    diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
    index 36947caae6f..705793ec77e 100644
    --- a/apps/web/src/components/ChatView.logic.ts
    +++ b/apps/web/src/components/ChatView.logic.ts
    @@ -74,6 +74,17 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: {
       );
     }
     
    +export function buildThreadTurnInterruptInput(thread: Pick): {
    +  threadId: ThreadId;
    +  turnId?: TurnId;
    +} {
    +  const runningTurnId = thread.session?.status === "running" ? thread.session.activeTurnId : null;
    +  return {
    +    threadId: thread.id,
    +    ...(runningTurnId !== null ? { turnId: runningTurnId } : {}),
    +  };
    +}
    +
     export function reconcileMountedTerminalThreadIds(input: {
       currentThreadIds: ReadonlyArray;
       openThreadIds: ReadonlyArray;
    diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
    index 5249ee0219d..d9ae6e4810f 100644
    --- a/apps/web/src/components/ChatView.tsx
    +++ b/apps/web/src/components/ChatView.tsx
    @@ -214,6 +214,7 @@ import {
       MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
       buildExpiredTerminalContextToastCopy,
       buildLocalDraftThread,
    +  buildThreadTurnInterruptInput,
       collectUserMessageBlobPreviewUrls,
       createLocalDispatchSnapshot,
       deriveComposerSendState,
    @@ -3941,9 +3942,7 @@ function ChatViewContent(props: ChatViewProps) {
         if (!activeThread) return;
         const result = await interruptThreadTurn({
           environmentId,
    -      input: {
    -        threadId: activeThread.id,
    -      },
    +      input: buildThreadTurnInterruptInput(activeThread),
         });
         if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
           const error = squashAtomCommandFailure(result);
    @@ -4771,6 +4770,11 @@ function ChatViewContent(props: ChatViewProps) {
                     listRef={legendListRef}
                     timelineEntries={timelineEntries}
                     latestTurn={activeLatestTurn}
    +                runningTurnId={
    +                  activeThread.session?.status === "running"
    +                    ? activeThread.session.activeTurnId
    +                    : null
    +                }
                     turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
                     activeThreadEnvironmentId={activeThread.environmentId}
                     routeThreadKey={routeThreadKey}
    diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
    index 50ee10b4169..1676d2d7c85 100644
    --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
    +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
    @@ -794,6 +794,67 @@ describe("deriveMessagesTimelineRows", () => {
         ]);
       });
     
    +  it("does not fold the session's running turn when latestTurn regresses", () => {
    +    const rows = deriveMessagesTimelineRows({
    +      timelineEntries: [
    +        {
    +          id: "previous-work-entry",
    +          kind: "work",
    +          createdAt: "2026-01-01T00:00:05Z",
    +          entry: {
    +            id: "previous-work",
    +            createdAt: "2026-01-01T00:00:05Z",
    +            turnId: "turn-1" as never,
    +            label: "Read files",
    +            tone: "tool" as const,
    +          },
    +        },
    +        {
    +          id: "user-followup-entry",
    +          kind: "message",
    +          createdAt: "2026-01-01T00:01:00Z",
    +          message: {
    +            id: "user-followup" as never,
    +            role: "user",
    +            text: "continue",
    +            turnId: null,
    +            createdAt: "2026-01-01T00:01:00Z",
    +            updatedAt: "2026-01-01T00:01:00Z",
    +            streaming: false,
    +          },
    +        },
    +        {
    +          id: "running-work-entry",
    +          kind: "work",
    +          createdAt: "2026-01-01T00:01:05Z",
    +          entry: {
    +            id: "running-work",
    +            createdAt: "2026-01-01T00:01:05Z",
    +            turnId: "turn-2" as never,
    +            label: "Searched files",
    +            tone: "tool" as const,
    +          },
    +        },
    +      ],
    +      latestTurn: {
    +        turnId: "turn-1" as never,
    +        state: "completed",
    +        startedAt: "2026-01-01T00:00:00Z",
    +        completedAt: "2026-01-01T00:00:25Z",
    +      },
    +      runningTurnId: "turn-2" as never,
    +      isWorking: true,
    +      activeTurnStartedAt: "2026-01-01T00:01:00Z",
    +      turnDiffSummaryByAssistantMessageId: new Map(),
    +      revertTurnCountByUserMessageId: new Map(),
    +    });
    +
    +    expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
    +      "turn-1",
    +    ]);
    +    expect(rows.map((row) => row.id)).toContain("running-work-entry");
    +  });
    +
       it("only shows assistant metadata on the terminal assistant message", () => {
         const rows = deriveMessagesTimelineRows({
           timelineEntries: [
    diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
    index 1426f1deee2..86212576f0b 100644
    --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
    +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
    @@ -149,13 +149,20 @@ interface TurnFold {
     }
     
     /**
    - * The latest turn counts as unsettled while it is still running (or has not
    - * recorded a completion). This is deliberately keyed on the turn's own
    - * lifecycle rather than transient working state: right after the user sends
    - * a message, the previous turn is still the "active" one until the server
    - * creates the new turn, and folding must not flicker through that window.
    + * The session's running turn is authoritative when latestTurn briefly lags or
    + * regresses behind it. Otherwise, the latest turn counts as unsettled while it
    + * is still running (or has not recorded a completion). This is deliberately
    + * keyed on turn lifecycle rather than transient working state: right after the
    + * user sends a message, the previous turn is still the "active" one until the
    + * server creates the new turn, and folding must not flicker through that window.
      */
    -function deriveUnsettledTurnId(latestTurn: TimelineLatestTurn | null): TurnId | null {
    +function deriveUnsettledTurnId(
    +  latestTurn: TimelineLatestTurn | null,
    +  runningTurnId: TurnId | null,
    +): TurnId | null {
    +  if (runningTurnId !== null) {
    +    return runningTurnId;
    +  }
       if (!latestTurn) {
         return null;
       }
    @@ -291,6 +298,7 @@ function deriveTurnFolds(input: {
     export function deriveMessagesTimelineRows(input: {
       timelineEntries: ReadonlyArray;
       latestTurn?: TimelineLatestTurn | null;
    +  runningTurnId?: TurnId | null;
       expandedTurnIds?: ReadonlySet;
       isWorking: boolean;
       activeTurnStartedAt: string | null;
    @@ -302,7 +310,10 @@ export function deriveMessagesTimelineRows(input: {
         input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
       );
       const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);
    -  const unsettledTurnId = deriveUnsettledTurnId(input.latestTurn ?? null);
    +  const unsettledTurnId = deriveUnsettledTurnId(
    +    input.latestTurn ?? null,
    +    input.runningTurnId ?? null,
    +  );
       const foldsByAnchorEntryId = deriveTurnFolds({
         timelineEntries: input.timelineEntries,
         terminalAssistantMessageIds,
    diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
    index 3008bd2ba9e..a69ce17c7d2 100644
    --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
    +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
    @@ -109,6 +109,7 @@ function buildProps() {
         activeTurnStartedAt: null,
         listRef: createRef(),
         latestTurn: null,
    +    runningTurnId: null,
         turnDiffSummaryByAssistantMessageId: new Map(),
         routeThreadKey: "environment-local:thread-1",
         onOpenTurnDiff: () => {},
    diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
    index 55c982c64be..cf8224d51f6 100644
    --- a/apps/web/src/components/chat/MessagesTimeline.tsx
    +++ b/apps/web/src/components/chat/MessagesTimeline.tsx
    @@ -153,6 +153,7 @@ interface MessagesTimelineProps {
       listRef: React.RefObject;
       timelineEntries: ReturnType;
       latestTurn: TimelineLatestTurn | null;
    +  runningTurnId: TurnId | null;
       turnDiffSummaryByAssistantMessageId: Map;
       routeThreadKey: string;
       onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
    @@ -182,6 +183,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
       listRef,
       timelineEntries,
       latestTurn,
    +  runningTurnId,
       turnDiffSummaryByAssistantMessageId,
       routeThreadKey,
       onOpenTurnDiff,
    @@ -272,6 +274,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
           deriveMessagesTimelineRows({
             timelineEntries,
             latestTurn,
    +        runningTurnId,
             expandedTurnIds,
             isWorking,
             activeTurnStartedAt,
    @@ -281,6 +284,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
         [
           timelineEntries,
           latestTurn,
    +      runningTurnId,
           expandedTurnIds,
           isWorking,
           activeTurnStartedAt,
    diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts
    index c732f80ef35..779a6499e4b 100644
    --- a/packages/effect-acp/src/client.test.ts
    +++ b/packages/effect-acp/src/client.test.ts
    @@ -17,18 +17,59 @@ import { it, assert } from "@effect/vitest";
     import * as AcpClient from "./client.ts";
     import * as AcpSchema from "./_generated/schema.gen.ts";
     import * as AcpError from "./errors.ts";
    -import { encodeJsonl, jsonRpcRequest, jsonRpcResponse } from "./_internal/shared.ts";
    +import {
    +  encodeJsonl,
    +  jsonRpcNotification,
    +  jsonRpcRequest,
    +  jsonRpcResponse,
    +} from "./_internal/shared.ts";
     import { makeInMemoryStdio } from "./_internal/stdio.ts";
     
     const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest);
     const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);
     const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
     const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
    +const PromptRequest = jsonRpcRequest("session/prompt", AcpSchema.PromptRequest);
    +const PromptResponse = jsonRpcResponse(AcpSchema.PromptResponse);
    +const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest));
    +const XAiPromptCompleteNotification = jsonRpcNotification(
    +  "_x.ai/session/prompt_complete",
    +  Schema.Struct({
    +    sessionId: Schema.String,
    +    promptId: Schema.String,
    +    stopReason: Schema.String,
    +    agentResult: Schema.NullOr(Schema.Unknown),
    +  }),
    +);
    +const XAiQueueChangedNotification = jsonRpcNotification(
    +  "_x.ai/queue/changed",
    +  Schema.Struct({
    +    sessionId: Schema.String,
    +    entries: Schema.Array(Schema.Unknown),
    +  }),
    +);
    +const XAiSessionsChangedNotification = jsonRpcNotification(
    +  "_x.ai/sessions/changed",
    +  Schema.Struct({
    +    upserted: Schema.Array(Schema.Unknown),
    +    removed: Schema.Array(Schema.Unknown),
    +  }),
    +);
     const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
       path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
     );
     const mockPeerArgs = (path: string) => [path];
     
    +function concatBytes(chunks: ReadonlyArray): Uint8Array {
    +  const batch = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
    +  let offset = 0;
    +  for (const chunk of chunks) {
    +    batch.set(chunk, offset);
    +    offset += chunk.length;
    +  }
    +  return batch;
    +}
    +
     it.layer(NodeServices.layer)("effect-acp client", (it) => {
       const makeHandle = (env?: Record) =>
         Effect.gen(function* () {
    @@ -446,4 +487,90 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {
           yield* Scope.close(scope, Exit.void);
         }),
       );
    +
    +  it.effect(
    +    "routes a standard prompt response after Grok extension notifications in the same batch",
    +    () =>
    +      Effect.gen(function* () {
    +        const { stdio, input, output } = yield* makeInMemoryStdio();
    +        const scope = yield* Scope.make();
    +        const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope));
    +
    +        const promptFiber = yield* acp.agent
    +          .prompt({
    +            sessionId: "grok-session-1",
    +            prompt: [{ type: "text", text: "run the ls command" }],
    +          })
    +          .pipe(Effect.forkScoped);
    +
    +        const outbound = yield* Queue.take(output);
    +        const decodedPrompt = yield* decodePromptRequestLine(outbound);
    +
    +        const responseBatch = concatBytes(
    +          yield* Effect.all([
    +            encodeJsonl(XAiQueueChangedNotification, {
    +              jsonrpc: "2.0",
    +              method: "_x.ai/queue/changed",
    +              params: { sessionId: "grok-session-1", entries: [] },
    +            }),
    +            encodeJsonl(XAiPromptCompleteNotification, {
    +              jsonrpc: "2.0",
    +              method: "_x.ai/session/prompt_complete",
    +              params: {
    +                sessionId: "grok-session-1",
    +                promptId: "prompt-1",
    +                stopReason: "end_turn",
    +                agentResult: null,
    +              },
    +            }),
    +            encodeJsonl(XAiSessionsChangedNotification, {
    +              jsonrpc: "2.0",
    +              method: "_x.ai/sessions/changed",
    +              params: {
    +                upserted: [
    +                  {
    +                    sessionId: "grok-session-1",
    +                    title: null,
    +                    cwd: process.cwd(),
    +                    isWorktree: false,
    +                    modelId: "grok-composer-2.5-fast",
    +                    yolo: false,
    +                    activity: "idle",
    +                    resident: true,
    +                    lastChangeUnixMs: 1_710_000_000_000,
    +                    origin: { kind: "local" },
    +                  },
    +                ],
    +                removed: [],
    +              },
    +            }),
    +            encodeJsonl(PromptResponse, {
    +              jsonrpc: "2.0",
    +              id: decodedPrompt.id,
    +              result: {
    +                stopReason: "end_turn",
    +                _meta: {
    +                  sessionId: "grok-session-1",
    +                  requestId: "prompt-1",
    +                  promptId: "prompt-1",
    +                  modelId: "grok-composer-2.5-fast",
    +                },
    +              },
    +            }),
    +          ]),
    +        );
    +        yield* Queue.offer(input, responseBatch);
    +
    +        assert.deepEqual(yield* Fiber.join(promptFiber), {
    +          stopReason: "end_turn",
    +          _meta: {
    +            sessionId: "grok-session-1",
    +            requestId: "prompt-1",
    +            promptId: "prompt-1",
    +            modelId: "grok-composer-2.5-fast",
    +          },
    +        });
    +        yield* Scope.close(scope, Exit.void);
    +      }),
    +  );
     });
    
    From 24abab789ec68c6aba865a34e0901f8be8743595 Mon Sep 17 00:00:00 2001
    From: Julius Marminge 
    Date: Fri, 26 Jun 2026 14:26:36 -0700
    Subject: [PATCH 23/28] Stabilize chat scroll anchoring after send (#3564)
    
    ---
     .../src/features/threads/ThreadFeed.tsx       | 162 +++++++-----
     .../src/features/threads/thread-work-log.tsx  | 109 +++++----
     apps/mobile/src/lib/threadActivity.test.ts    |  61 ++++-
     apps/mobile/src/lib/threadActivity.ts         |  69 +++++-
     apps/web/src/components/ChatView.tsx          |  87 ++++++-
     .../chat/MessagesTimeline.logic.test.ts       |  71 ++++++
     .../components/chat/MessagesTimeline.logic.ts |  73 +++++-
     .../components/chat/MessagesTimeline.test.tsx |  80 ++++--
     .../src/components/chat/MessagesTimeline.tsx  | 231 +++++++++---------
     9 files changed, 693 insertions(+), 250 deletions(-)
    
    diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
    index 565ce2ae5ce..a82e7c49ccd 100644
    --- a/apps/mobile/src/features/threads/ThreadFeed.tsx
    +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
    @@ -74,7 +74,7 @@ import {
       type ThreadFeedLatestTurn,
     } from "../../lib/threadActivity";
     import type { ThreadContentPresentation } from "./threadContentPresentation";
    -import { ThreadWorkLog } from "./thread-work-log";
    +import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";
     import { useAssetUrl } from "../../state/assets";
     import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
     
    @@ -655,7 +655,6 @@ function renderFeedEntry(
       info: { item: ThreadFeedEntry; index: number },
       props: Pick & {
         readonly copiedRowId: string | null;
    -    readonly expandedWorkGroups: Record;
         readonly expandedWorkRows: Record;
         readonly terminalAssistantMessageIds: ReadonlySet;
         readonly unsettledTurnId: TurnId | null;
    @@ -698,6 +697,18 @@ function renderFeedEntry(
         );
       }
     
    +  if (entry.type === "work-toggle") {
    +    return (
    +       props.onToggleWorkGroup(entry.groupId)}
    +      />
    +    );
    +  }
    +
       if (entry.type === "message") {
         const { message } = entry;
         const isUser = message.role === "user";
    @@ -825,11 +836,9 @@ function renderFeedEntry(
          props.onToggleWorkGroup(entry.id)}
           onToggleRow={props.onToggleWorkRow}
         />
       );
    @@ -1110,9 +1119,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
       const copyFeedbackTimeoutRef = useRef | null>(null);
       const foldSettleFrameRef = useRef(null);
       const foldSettleSecondFrameRef = useRef(null);
    +  const disclosureAnchorKeyRef = useRef(null);
       const previousLatestTurnRef = useRef(props.latestTurn);
       const { width: viewportWidth } = useWindowDimensions();
    -  const [foldToggleSettling, setFoldToggleSettling] = useState(false);
    +  const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
       const [interactionState, setInteractionState] = useState<{
         readonly copiedRowId: string | null;
         readonly expandedWorkGroups: Record;
    @@ -1173,7 +1183,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
       const listAppearanceData = useMemo(
         () => ({
           copiedRowId,
    -      expandedWorkGroups,
           expandedWorkRows,
           iconSubtleColor,
           markdownStyles,
    @@ -1182,7 +1191,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
         }),
         [
           copiedRowId,
    -      expandedWorkGroups,
           expandedWorkRows,
           iconSubtleColor,
           markdownStyles,
    @@ -1190,9 +1198,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
           userBubbleColor,
         ],
       );
    +  const expandedWorkGroupIds = useMemo(() => {
    +    const ids = new Set();
    +    for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
    +      if (expanded) {
    +        ids.add(groupId);
    +      }
    +    }
    +    return ids;
    +  }, [expandedWorkGroups]);
       const presentedFeed = useMemo(
    -    () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds),
    -    [expandedTurnIds, props.feed, props.latestTurn],
    +    () =>
    +      deriveThreadFeedPresentation(
    +        props.feed,
    +        props.latestTurn,
    +        expandedTurnIds,
    +        expandedWorkGroupIds,
    +      ),
    +    [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
       );
       const anchoredEndSpace = useMemo(
         () =>
    @@ -1259,6 +1282,39 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
         };
       }, []);
     
    +  const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
    +    disclosureAnchorKeyRef.current = anchorKey;
    +    setDisclosureToggleSettling(true);
    +    if (foldSettleFrameRef.current !== null) {
    +      cancelAnimationFrame(foldSettleFrameRef.current);
    +    }
    +    if (foldSettleSecondFrameRef.current !== null) {
    +      cancelAnimationFrame(foldSettleSecondFrameRef.current);
    +    }
    +    foldSettleFrameRef.current = requestAnimationFrame(() => {
    +      foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
    +        disclosureAnchorKeyRef.current = null;
    +        setDisclosureToggleSettling(false);
    +        foldSettleFrameRef.current = null;
    +        foldSettleSecondFrameRef.current = null;
    +      });
    +    });
    +  }, []);
    +
    +  const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => {
    +    const disclosureAnchorKey = disclosureAnchorKeyRef.current;
    +    return disclosureAnchorKey === null || entry.id === disclosureAnchorKey;
    +  }, []);
    +
    +  const maintainVisibleContentPosition = useMemo(
    +    () => ({
    +      data: true,
    +      size: true,
    +      shouldRestorePosition: shouldRestoreVisibleContentPosition,
    +    }),
    +    [shouldRestoreVisibleContentPosition],
    +  );
    +
       const onCopyWorkRow = useCallback((rowId: string, value: string) => {
         copyTextWithHaptic(value, {
           target: "thread-work-row",
    @@ -1276,51 +1332,49 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
         }, 1200);
       }, []);
     
    -  const onToggleWorkGroup = useCallback((groupId: string) => {
    -    setInteractionState((current) => ({
    -      ...current,
    -      expandedWorkGroups: {
    -        ...current.expandedWorkGroups,
    -        [groupId]: !(current.expandedWorkGroups[groupId] ?? false),
    -      },
    -    }));
    -  }, []);
    +  const onToggleWorkGroup = useCallback(
    +    (groupId: string) => {
    +      suspendEndScrollMaintenanceForDisclosure(`work-toggle:${groupId}`);
    +      setInteractionState((current) => ({
    +        ...current,
    +        expandedWorkGroups: {
    +          ...current.expandedWorkGroups,
    +          [groupId]: !(current.expandedWorkGroups[groupId] ?? false),
    +        },
    +      }));
    +    },
    +    [suspendEndScrollMaintenanceForDisclosure],
    +  );
     
    -  const onToggleWorkRow = useCallback((rowId: string) => {
    -    setInteractionState((current) => ({
    -      ...current,
    -      expandedWorkRows: {
    -        ...current.expandedWorkRows,
    -        [rowId]: !(current.expandedWorkRows[rowId] ?? false),
    -      },
    -    }));
    -  }, []);
    +  const onToggleWorkRow = useCallback(
    +    (rowId: string) => {
    +      suspendEndScrollMaintenanceForDisclosure(rowId);
    +      setInteractionState((current) => ({
    +        ...current,
    +        expandedWorkRows: {
    +          ...current.expandedWorkRows,
    +          [rowId]: !(current.expandedWorkRows[rowId] ?? false),
    +        },
    +      }));
    +    },
    +    [suspendEndScrollMaintenanceForDisclosure],
    +  );
     
    -  const onToggleTurnFold = useCallback((turnId: TurnId) => {
    -    setFoldToggleSettling(true);
    -    if (foldSettleFrameRef.current !== null) {
    -      cancelAnimationFrame(foldSettleFrameRef.current);
    -    }
    -    if (foldSettleSecondFrameRef.current !== null) {
    -      cancelAnimationFrame(foldSettleSecondFrameRef.current);
    -    }
    -    setInteractionState((current) => {
    -      const next = new Set(current.expandedTurnIds);
    -      if (next.has(turnId)) {
    -        next.delete(turnId);
    -      } else {
    -        next.add(turnId);
    -      }
    -      return { ...current, expandedTurnIds: next };
    -    });
    -    foldSettleFrameRef.current = requestAnimationFrame(() => {
    -      foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
    -        setFoldToggleSettling(false);
    -        foldSettleFrameRef.current = null;
    -        foldSettleSecondFrameRef.current = null;
    +  const onToggleTurnFold = useCallback(
    +    (turnId: TurnId) => {
    +      suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
    +      setInteractionState((current) => {
    +        const next = new Set(current.expandedTurnIds);
    +        if (next.has(turnId)) {
    +          next.delete(turnId);
    +        } else {
    +          next.add(turnId);
    +        }
    +        return { ...current, expandedTurnIds: next };
           });
    -    });
    -  }, []);
    +    },
    +    [suspendEndScrollMaintenanceForDisclosure],
    +  );
     
       const onPressImage = useCallback((uri: string, headers?: Record) => {
         setExpandedImage({ uri, headers });
    @@ -1331,7 +1385,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
           renderFeedEntry(info, {
             environmentId: props.environmentId,
             copiedRowId,
    -        expandedWorkGroups,
             expandedWorkRows,
             terminalAssistantMessageIds,
             unsettledTurnId,
    @@ -1351,7 +1404,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
           }),
         [
           copiedRowId,
    -      expandedWorkGroups,
           expandedWorkRows,
           terminalAssistantMessageIds,
           unsettledTurnId,
    @@ -1410,7 +1462,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
               contentInsetEndAdjustment={props.contentInsetEndAdjustment}
               freeze={props.freeze}
               maintainScrollAtEnd={
    -            foldToggleSettling
    +            disclosureToggleSettling
                   ? false
                   : {
                       animated: false,
    @@ -1421,7 +1473,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
                       },
                     }
               }
    -          maintainVisibleContentPosition
    +          maintainVisibleContentPosition={maintainVisibleContentPosition}
               data={presentedFeed}
               extraData={listAppearanceData}
               renderItem={renderItem}
    diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
    index 707e1a24f0d..a788f8a409e 100644
    --- a/apps/mobile/src/features/threads/thread-work-log.tsx
    +++ b/apps/mobile/src/features/threads/thread-work-log.tsx
    @@ -6,7 +6,6 @@ import { AppText as Text } from "../../components/AppText";
     import { cn } from "../../lib/cn";
     import type { ThreadFeedActivity } from "../../lib/threadActivity";
     
    -const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
     const WORK_LOG_LAYOUT_ANIMATION = {
       duration: 180,
       create: {
    @@ -72,11 +71,9 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol {
     export function ThreadWorkLog(props: {
       readonly activities: ReadonlyArray;
       readonly copiedRowId: string | null;
    -  readonly expanded: boolean;
       readonly expandedRows: Readonly>;
       readonly iconSubtleColor: import("react-native").ColorValue;
       readonly onCopyRow: (rowId: string, value: string) => void;
    -  readonly onToggleGroup: () => void;
       readonly onToggleRow: (rowId: string) => void;
     }) {
       const colorScheme = useColorScheme();
    @@ -89,14 +86,10 @@ export function ThreadWorkLog(props: {
         return null;
       }
     
    -  const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
    -  const visibleRows =
    -    hasOverflow && !props.expanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows;
    -  const hiddenCount = rows.length - visibleRows.length;
       const onlyToolRows = rows.every((row) => row.toolLike);
     
       return (
    -    
    +    
           {!onlyToolRows ? (
             
               work log
    @@ -104,7 +97,7 @@ export function ThreadWorkLog(props: {
           ) : null}
     
           
    -        {visibleRows.map((row) => {
    +        {rows.map((row) => {
               const expanded = props.expandedRows[row.id] ?? false;
               const canExpand = row.fullDetail !== null;
               const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
    @@ -132,13 +125,13 @@ export function ThreadWorkLog(props: {
                     style={({ pressed }) => ({
                       backgroundColor: pressed ? pressedBackground : "transparent",
                     })}
    -                className="rounded-md px-0.5 py-0.5"
    +                className="rounded-md px-0.5 py-0"
                   >
    -                
    -                  
    +                
    +                  
                         
     
                       
                         
     
                   {expanded && row.fullDetail ? (
    -                
    +                
                       
    +    
    +  );
    +}
     
    -      {hasOverflow ? (
    -         {
    -            triggerDisclosureFeedback();
    -            props.onToggleGroup();
    -          }}
    -          style={({ pressed }) => ({
    -            backgroundColor: pressed ? pressedBackground : "transparent",
    -          })}
    -          className="min-h-9 flex-row items-center gap-1.5 rounded-md px-0.5 py-0.5"
    -        >
    -          
    -            
    -          
    -          
    -            {props.expanded
    -              ? "Show fewer tool calls"
    -              : `+${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`}
    -          
    -        
    -      ) : null}
    +export function ThreadWorkGroupToggle(props: {
    +  readonly expanded: boolean;
    +  readonly hiddenCount: number;
    +  readonly iconSubtleColor: import("react-native").ColorValue;
    +  readonly onlyToolActivities: boolean;
    +  readonly onToggle: () => void;
    +}) {
    +  const colorScheme = useColorScheme();
    +  const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
    +  const noun = props.onlyToolActivities
    +    ? props.hiddenCount === 1
    +      ? "tool call"
    +      : "tool calls"
    +    : props.hiddenCount === 1
    +      ? "log entry"
    +      : "log entries";
    +  const collapsedLabel = `Show ${props.hiddenCount} previous ${noun}`;
    +  const expandedLabel = props.onlyToolActivities
    +    ? "Show fewer tool calls"
    +    : "Show fewer log entries";
    +
    +  return (
    +    
    +       {
    +          void Haptics.selectionAsync();
    +          props.onToggle();
    +        }}
    +        style={({ pressed }) => ({
    +          backgroundColor: pressed ? pressedBackground : "transparent",
    +        })}
    +        className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0"
    +      >
    +        
    +          
    +        
    +        
    +          {props.expanded ? expandedLabel : `+${props.hiddenCount} previous ${noun}`}
    +        
    +      
         
       );
     }
    diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts
    index e093acee319..23b47fc625f 100644
    --- a/apps/mobile/src/lib/threadActivity.test.ts
    +++ b/apps/mobile/src/lib/threadActivity.test.ts
    @@ -11,7 +11,12 @@ import {
       type OrchestrationThreadActivity,
     } from "@t3tools/contracts";
     
    -import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity";
    +import {
    +  buildThreadFeed,
    +  deriveThreadFeedPresentation,
    +  type ThreadFeedActivity,
    +  type ThreadFeedEntry,
    +} from "./threadActivity";
     
     function makeActivity(
       input: Partial &
    @@ -406,4 +411,58 @@ describe("buildThreadFeed", () => {
           activities: [{ status: "failure" }],
         });
       });
    +
    +  it("models work-log overflow as list rows", () => {
    +    const activity = (
    +      id: string,
    +      createdAt: string,
    +      status: ThreadFeedActivity["status"] = "success",
    +    ): ThreadFeedActivity => ({
    +      id,
    +      createdAt,
    +      turnId: null,
    +      summary: `Tool ${id}`,
    +      detail: null,
    +      fullDetail: null,
    +      copyText: id,
    +      icon: "command",
    +      toolLike: true,
    +      status,
    +    });
    +    const feed: ThreadFeedEntry[] = [
    +      {
    +        type: "activity-group",
    +        id: "work-group-1",
    +        createdAt: "2026-04-01T00:00:01.000Z",
    +        turnId: null,
    +        activities: [
    +          activity("activity-1", "2026-04-01T00:00:01.000Z"),
    +          activity("activity-neutral", "2026-04-01T00:00:02.000Z", "neutral"),
    +          activity("activity-2", "2026-04-01T00:00:03.000Z"),
    +          activity("activity-3", "2026-04-01T00:00:04.000Z"),
    +        ],
    +      },
    +    ];
    +
    +    const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
    +    expect(collapsed.map((entry) => entry.id)).toEqual(["activity-3", "work-toggle:work-group-1"]);
    +    expect(collapsed[1]).toMatchObject({
    +      type: "work-toggle",
    +      groupId: "work-group-1",
    +      hiddenCount: 2,
    +      expanded: false,
    +    });
    +
    +    const expanded = deriveThreadFeedPresentation(feed, null, new Set(), new Set(["work-group-1"]));
    +    expect(expanded.map((entry) => entry.id)).toEqual([
    +      "activity-1",
    +      "activity-2",
    +      "activity-3",
    +      "work-toggle:work-group-1",
    +    ]);
    +    expect(expanded.at(-1)).toMatchObject({
    +      type: "work-toggle",
    +      expanded: true,
    +    });
    +  });
     });
    diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
    index f6008057a0e..9f79a90550d 100644
    --- a/apps/mobile/src/lib/threadActivity.ts
    +++ b/apps/mobile/src/lib/threadActivity.ts
    @@ -55,6 +55,8 @@ export interface ThreadFeedActivity {
       readonly status: "success" | "failure" | "neutral" | null;
     }
     
    +const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
    +
     type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
     
     interface WorkLogEntry {
    @@ -103,6 +105,16 @@ export type ThreadFeedEntry =
           readonly turnId: TurnId | null;
           readonly activities: ReadonlyArray;
         }
    +  | {
    +      readonly type: "work-toggle";
    +      readonly id: string;
    +      readonly createdAt: string;
    +      readonly turnId: TurnId | null;
    +      readonly groupId: string;
    +      readonly hiddenCount: number;
    +      readonly expanded: boolean;
    +      readonly onlyToolActivities: boolean;
    +    }
       | {
           readonly type: "turn-fold";
           readonly id: string;
    @@ -1092,8 +1104,11 @@ export function deriveThreadFeedPresentation(
       feed: ReadonlyArray,
       latestTurn: ThreadFeedLatestTurn | null,
       expandedTurnIds: ReadonlySet,
    +  expandedWorkGroupIds: ReadonlySet = new Set(),
     ): ThreadFeedEntry[] {
    -  const sourceFeed = feed.filter((entry) => entry.type !== "turn-fold");
    +  const sourceFeed = feed.filter(
    +    (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle",
    +  );
       const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
       const collapsedEntryIds = new Set();
       for (const fold of foldsByAnchorId.values()) {
    @@ -1118,12 +1133,62 @@ export function deriveThreadFeedPresentation(
           });
         }
         if (!collapsedEntryIds.has(entry.id)) {
    -      result.push(entry);
    +      appendPresentedFeedEntry(result, entry, expandedWorkGroupIds);
         }
       }
       return result;
     }
     
    +function appendPresentedFeedEntry(
    +  result: ThreadFeedEntry[],
    +  entry: Exclude,
    +  expandedWorkGroupIds: ReadonlySet,
    +): void {
    +  if (entry.type !== "activity-group") {
    +    result.push(entry);
    +    return;
    +  }
    +
    +  const activities = entry.activities.filter(
    +    (activity) => !(activity.toolLike && activity.status === "neutral"),
    +  );
    +  if (activities.length === 0) {
    +    return;
    +  }
    +  if (activities.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
    +    result.push({
    +      ...entry,
    +      activities,
    +    });
    +    return;
    +  }
    +
    +  const groupId = entry.id;
    +  const expanded = expandedWorkGroupIds.has(groupId);
    +  const hiddenCount = activities.length - MAX_VISIBLE_WORK_LOG_ENTRIES;
    +  const visibleActivities = expanded ? activities : activities.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES);
    +
    +  for (const activity of visibleActivities) {
    +    result.push({
    +      type: "activity-group",
    +      id: activity.id,
    +      createdAt: activity.createdAt,
    +      turnId: activity.turnId,
    +      activities: [activity],
    +    });
    +  }
    +  result.push({
    +    type: "work-toggle",
    +    id: `work-toggle:${groupId}`,
    +    createdAt: entry.createdAt,
    +    turnId: entry.turnId,
    +    groupId,
    +    hiddenCount,
    +    expanded,
    +    onlyToolActivities: activities.every((activity) => activity.toolLike),
    +  });
    +}
    +
     export function derivePendingApprovals(
       activities: ReadonlyArray,
     ): PendingApproval[] {
    diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
    index d9ae6e4810f..4f99fb5d5d7 100644
    --- a/apps/web/src/components/ChatView.tsx
    +++ b/apps/web/src/components/ChatView.tsx
    @@ -36,6 +36,7 @@ import {
       createModelSelection,
       resolvePromptInjectedEffort,
     } from "@t3tools/shared/model";
    +import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
     import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
     import { truncate } from "@t3tools/shared/String";
     import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
    @@ -3147,10 +3148,83 @@ function ChatViewContent(props: ChatViewProps) {
         ],
       );
     
    -  // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd.
    +  // Scrolling is explicit so streamed timeline updates never take control away
    +  // from the user after the newly sent row has been positioned once.
       const scrollToEnd = useCallback((animated = false) => {
         void legendListRef.current?.scrollToEnd?.({ animated });
       }, []);
    +  const positionedTimelineAnchorRef = useRef(null);
    +  const settledTimelineAnchorRef = useRef(null);
    +  const pendingAnchorScrollRestoreRef = useRef<{
    +    readonly messageId: MessageId;
    +    readonly offset: number;
    +  } | null>(null);
    +  const anchorScrollRestoreFrameRef = useRef(null);
    +  const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
    +    if (positionedTimelineAnchorRef.current === messageId) {
    +      return;
    +    }
    +    positionedTimelineAnchorRef.current = messageId;
    +    settledTimelineAnchorRef.current = null;
    +    requestAnimationFrame(() => {
    +      requestAnimationFrame(() => {
    +        if (positionedTimelineAnchorRef.current !== messageId) {
    +          return;
    +        }
    +        const list = legendListRef.current;
    +        if (!list) {
    +          return;
    +        }
    +        const scrollNode = list.getScrollableNode();
    +        let finished = false;
    +        const finishAnimatedPositioning = () => {
    +          if (finished) {
    +            return;
    +          }
    +          finished = true;
    +          window.clearTimeout(fallbackTimer);
    +          scrollNode.removeEventListener("scrollend", finishAnimatedPositioning);
    +          if (positionedTimelineAnchorRef.current !== messageId) {
    +            return;
    +          }
    +          const scrollOffset = list.getState().scroll;
    +          void list.scrollToOffset({ offset: scrollOffset, animated: false });
    +          settledTimelineAnchorRef.current = messageId;
    +        };
    +        const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750);
    +        scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true });
    +        void list.scrollToIndex({
    +          index: anchorIndex,
    +          animated: true,
    +          viewPosition: 0,
    +          viewOffset: CHAT_LIST_ANCHOR_OFFSET,
    +        });
    +      });
    +    });
    +  }, []);
    +  const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => {
    +    if (settledTimelineAnchorRef.current !== messageId) {
    +      return;
    +    }
    +    const scrollOffset = legendListRef.current?.getState().scroll;
    +    if (scrollOffset === undefined) {
    +      return;
    +    }
    +    if (pendingAnchorScrollRestoreRef.current === null) {
    +      pendingAnchorScrollRestoreRef.current = { messageId, offset: scrollOffset };
    +    }
    +    if (anchorScrollRestoreFrameRef.current !== null) {
    +      return;
    +    }
    +    anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => {
    +      anchorScrollRestoreFrameRef.current = null;
    +      const pending = pendingAnchorScrollRestoreRef.current;
    +      pendingAnchorScrollRestoreRef.current = null;
    +      if (pending && settledTimelineAnchorRef.current === pending.messageId) {
    +        void legendListRef.current?.scrollToOffset({ offset: pending.offset, animated: false });
    +      }
    +    });
    +  }, []);
     
       // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
       // thread switches.  LegendList fires scroll events with isAtEnd=false while
    @@ -3751,8 +3825,6 @@ function ChatViewContent(props: ChatViewProps) {
             streaming: false,
           },
         ]);
    -    void legendListRef.current?.scrollToEnd?.({ animated: false });
    -
         setThreadError(threadIdForSend, null);
         if (expiredTerminalContextCount > 0) {
           const toastCopy = buildExpiredTerminalContextToastCopy(
    @@ -4165,11 +4237,14 @@ function ChatViewContent(props: ChatViewProps) {
           beginLocalDispatch({ preparingWorktree: false });
           setThreadError(threadIdForSend, null);
     
    -      // Scroll to the current end *before* adding the optimistic message.
    +      // Position this sent row once LegendList has measured the anchored tail.
           isAtEndRef.current = true;
           showScrollDebouncer.current.cancel();
           setShowScrollToBottom(false);
    -      await legendListRef.current?.scrollToEnd?.({ animated: false });
    +      setTimelineAnchor({
    +        threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
    +        messageId: messageIdForSend,
    +      });
     
           setOptimisticUserMessages((existing) => [
             ...existing,
    @@ -4789,6 +4864,8 @@ function ChatViewContent(props: ChatViewProps) {
                     workspaceRoot={activeWorkspaceRoot}
                     skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
                     anchorMessageId={timelineAnchorMessageId}
    +                onAnchorReady={onTimelineAnchorReady}
    +                onAnchorSizeChanged={onTimelineAnchorSizeChanged}
                     contentInsetEndAdjustment={composerOverlayHeight}
                     onIsAtEndChange={onIsAtEndChange}
                   />
    diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
    index 1676d2d7c85..6d74204bc1c 100644
    --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
    +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
    @@ -940,6 +940,77 @@ describe("deriveMessagesTimelineRows", () => {
         expect(assistantRow?.showAssistantMeta).toBe(false);
         expect(assistantRow?.showAssistantCopyButton).toBe(false);
       });
    +
    +  it("models work log overflow expansion as inserted list rows", () => {
    +    const timelineEntries = [
    +      {
    +        id: "work-entry-1",
    +        kind: "work" as const,
    +        createdAt: "2026-01-01T00:00:01Z",
    +        entry: {
    +          id: "work-1",
    +          createdAt: "2026-01-01T00:00:01Z",
    +          label: "read",
    +          detail: "Reading package.json",
    +          tone: "tool" as const,
    +        },
    +      },
    +      {
    +        id: "work-entry-2",
    +        kind: "work" as const,
    +        createdAt: "2026-01-01T00:00:02Z",
    +        entry: {
    +          id: "work-2",
    +          createdAt: "2026-01-01T00:00:02Z",
    +          label: "edit",
    +          detail: "Editing MessagesTimeline.tsx",
    +          tone: "tool" as const,
    +        },
    +      },
    +      {
    +        id: "work-entry-3",
    +        kind: "work" as const,
    +        createdAt: "2026-01-01T00:00:03Z",
    +        entry: {
    +          id: "work-3",
    +          createdAt: "2026-01-01T00:00:03Z",
    +          label: "test",
    +          detail: "Running tests",
    +          tone: "tool" as const,
    +        },
    +      },
    +    ];
    +
    +    const baseInput = {
    +      timelineEntries,
    +      isWorking: false,
    +      activeTurnStartedAt: null,
    +      turnDiffSummaryByAssistantMessageId: new Map(),
    +      revertTurnCountByUserMessageId: new Map(),
    +    };
    +    const collapsedRows = deriveMessagesTimelineRows(baseInput);
    +    const expandedRows = deriveMessagesTimelineRows({
    +      ...baseInput,
    +      expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
    +    });
    +
    +    expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]);
    +    expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
    +      groupId: "work-group:work-entry-1",
    +      hiddenCount: 2,
    +      expanded: false,
    +      onlyToolEntries: true,
    +    });
    +    expect(expandedRows.map((row) => row.id)).toEqual([
    +      "work-1",
    +      "work-2",
    +      "work-3",
    +      "work-toggle:work-entry-1",
    +    ]);
    +    expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
    +      expanded: true,
    +    });
    +  });
     });
     
     describe("computeStableMessagesTimelineRows", () => {
    diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
    index 86212576f0b..d93ec0d0314 100644
    --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
    +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
    @@ -1,5 +1,11 @@
     import * as Equal from "effect/Equal";
    -import { formatDuration, type TimelineEntry, type WorkLogEntry } from "../../session-logic";
    +import {
    +  formatDuration,
    +  workEntryIndicatesToolNeutralStatus,
    +  workLogEntryIsToolLike,
    +  type TimelineEntry,
    +  type WorkLogEntry,
    +} from "../../session-logic";
     import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
     import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
     
    @@ -42,6 +48,15 @@ export type MessagesTimelineRow =
           createdAt: string;
           groupedEntries: WorkLogEntry[];
         }
    +  | {
    +      kind: "work-toggle";
    +      id: string;
    +      createdAt: string;
    +      groupId: string;
    +      hiddenCount: number;
    +      expanded: boolean;
    +      onlyToolEntries: boolean;
    +    }
       | {
           kind: "turn-fold";
           id: string;
    @@ -300,6 +315,7 @@ export function deriveMessagesTimelineRows(input: {
       latestTurn?: TimelineLatestTurn | null;
       runningTurnId?: TurnId | null;
       expandedTurnIds?: ReadonlySet;
    +  expandedWorkGroupIds?: ReadonlySet;
       isWorking: boolean;
       activeTurnStartedAt: string | null;
       turnDiffSummaryByAssistantMessageId: ReadonlyMap;
    @@ -367,12 +383,44 @@ export function deriveMessagesTimelineRows(input: {
             groupedEntries.push(nextEntry.entry);
             cursor += 1;
           }
    -      nextRows.push({
    -        kind: "work",
    -        id: timelineEntry.id,
    -        createdAt: timelineEntry.createdAt,
    -        groupedEntries,
    -      });
    +      const visibleGroupedEntries = groupedEntries.filter(
    +        (entry) => !workEntryIndicatesToolNeutralStatus(entry),
    +      );
    +      if (visibleGroupedEntries.length > 0) {
    +        if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
    +          nextRows.push({
    +            kind: "work",
    +            id: timelineEntry.id,
    +            createdAt: timelineEntry.createdAt,
    +            groupedEntries: visibleGroupedEntries,
    +          });
    +        } else {
    +          const groupId = `work-group:${timelineEntry.id}`;
    +          const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
    +          const hiddenEntries = visibleGroupedEntries.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
    +          const visibleEntries = visibleGroupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES);
    +          const renderedEntries = expanded ? [...hiddenEntries, ...visibleEntries] : visibleEntries;
    +
    +          for (const workEntry of renderedEntries) {
    +            nextRows.push({
    +              kind: "work",
    +              id: workEntry.id,
    +              createdAt: workEntry.createdAt,
    +              groupedEntries: [workEntry],
    +            });
    +          }
    +
    +          nextRows.push({
    +            kind: "work-toggle",
    +            id: `work-toggle:${timelineEntry.id}`,
    +            createdAt: timelineEntry.createdAt,
    +            groupId,
    +            hiddenCount: hiddenEntries.length,
    +            expanded,
    +            onlyToolEntries: visibleGroupedEntries.every((entry) => workLogEntryIsToolLike(entry)),
    +          });
    +        }
    +      }
           index = cursor - 1;
           continue;
         }
    @@ -473,6 +521,17 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
         case "work":
           return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
     
    +    case "work-toggle": {
    +      const bw = b as typeof a;
    +      return (
    +        a.createdAt === bw.createdAt &&
    +        a.groupId === bw.groupId &&
    +        a.hiddenCount === bw.hiddenCount &&
    +        a.expanded === bw.expanded &&
    +        a.onlyToolEntries === bw.onlyToolEntries
    +      );
    +    }
    +
         case "message": {
           const bm = b as typeof a;
           return (
    diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
    index a69ce17c7d2..55ef6bfd5a2 100644
    --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
    +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
    @@ -17,24 +17,59 @@ vi.mock("@legendapp/list/react", async () => {
           anchorIndex: number;
           anchorMaxSize?: number;
           anchorOffset?: number;
    +      onReady?: (info: { anchorIndex: number }) => void;
    +      onSizeChanged?: (size: number) => void;
         };
         contentInsetEndAdjustment?: number;
    +    className?: string;
    +    maintainScrollAtEnd?: boolean;
    +    maintainVisibleContentPosition?:
    +      | boolean
    +      | {
    +          data?: boolean;
    +          size?: boolean;
    +          shouldRestorePosition?: (item: { id: string }) => boolean;
    +        };
         ref?: Ref;
    -  }) => (
    -    
    - {props.ListHeaderComponent} - {props.data.map((item) => ( -
    {props.renderItem({ item })}
    - ))} - {props.ListFooterComponent} -
    - ); + }) => { + if (props.anchoredEndSpace) { + props.anchoredEndSpace.onSizeChanged?.(240); + props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); + } + return ( +
    + {props.ListHeaderComponent} + {props.data.map((item) => ( +
    {props.renderItem({ item })}
    + ))} + {props.ListFooterComponent} +
    + ); + }; return { LegendList }; }); @@ -123,6 +158,8 @@ function buildProps() { timestampFormat: "locale" as const, workspaceRoot: undefined, anchorMessageId: null, + onAnchorReady: () => {}, + onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, onIsAtEndChange: () => {}, }; @@ -154,6 +191,8 @@ function buildUserTimelineEntry(text: string) { describe("MessagesTimeline", () => { it("anchors a sent attachment message using its measured height", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); + const onAnchorReady = vi.fn(); + const onAnchorSizeChanged = vi.fn(); const firstEntry = buildUserTimelineEntry("First prompt."); const secondEntry = { ...buildUserTimelineEntry("Newest prompt."), @@ -177,6 +216,8 @@ describe("MessagesTimeline", () => { , @@ -184,8 +225,17 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-anchor-index="1"'); expect(markup).toContain('data-anchor-offset="16"'); + expect(markup).toContain('data-anchor-on-ready="true"'); expect(markup).not.toContain("data-anchor-max-size="); expect(markup).toContain('data-content-inset-end="144"'); + expect(markup).toContain("[overflow-anchor:none]"); + expect(markup).not.toContain("data-maintain-scroll-at-end="); + expect(markup).toContain('data-maintain-visible-content-position="object"'); + expect(markup).toContain('data-maintain-visible-content-position-data="true"'); + expect(markup).toContain('data-maintain-visible-content-position-size="false"'); + expect(onAnchorReady).toHaveBeenCalledOnce(); + expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); + expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240); }); it("renders collapse controls for long user messages", async () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index cf8224d51f6..0134f5493f8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,13 +14,13 @@ import { use, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, } from "react"; +import { flushSync } from "react-dom"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { @@ -43,7 +43,6 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, - ChevronUpIcon, CircleAlertIcon, EyeIcon, GlobeIcon, @@ -67,7 +66,6 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, - MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, @@ -128,6 +126,7 @@ interface TimelineRowSharedState { onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; + onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; } interface TimelineRowActivityState { @@ -168,6 +167,8 @@ interface MessagesTimelineProps { workspaceRoot: string | undefined; skills?: ReadonlyArray>; anchorMessageId: MessageId | null; + onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; + onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; onIsAtEndChange: (isAtEnd: boolean) => void; } @@ -198,20 +199,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, anchorMessageId, + onAnchorReady, + onAnchorSizeChanged, contentInsetEndAdjustment, onIsAtEndChange, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); + const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); - // Toggling a fold inserts/removes rows between the fold row and the final - // message — everything above the trigger is unchanged, so the trigger stays - // put as long as the list doesn't re-anchor. maintainScrollAtEnd would do - // exactly that (pin the bottom content when row data changes while scrolled - // to the end), yanking the trigger out of view. Suppress it for the frames - // in which the toggle's data change and item measurements settle. - const [foldToggleSettling, setFoldToggleSettling] = useState(false); const onToggleTurnFold = useCallback((turnId: TurnId) => { - setFoldToggleSettling(true); setExpandedTurnIds((existing) => { const next = new Set(existing); if (next.has(turnId)) { @@ -222,23 +218,39 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return next; }); }, []); - useEffect(() => { - if (!foldToggleSettling) { - return; - } - let secondFrameId: number | null = null; - const firstFrameId = window.requestAnimationFrame(() => { - secondFrameId = window.requestAnimationFrame(() => { - setFoldToggleSettling(false); + const onToggleWorkGroup = useCallback( + (groupId: string, anchorElement?: HTMLElement) => { + const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null; + + flushSync(() => { + setExpandedWorkGroupIds((existing) => { + const next = new Set(existing); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); }); - }); - return () => { - window.cancelAnimationFrame(firstFrameId); - if (secondFrameId !== null) { - window.cancelAnimationFrame(secondFrameId); + + if (anchorBottomBeforeToggle === null || !anchorElement) { + return; } - }; - }, [foldToggleSettling]); + + const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle; + if (Math.abs(delta) < 0.5) { + return; + } + + const list = listRef.current; + const currentScroll = list?.getState?.().scroll; + if (list && typeof currentScroll === "number") { + list.scrollToOffset({ offset: currentScroll + delta, animated: false }); + } + }, + [listRef], + ); // An in-session interrupt leaves its turn expanded so the user keeps their // place; the next turn (or a reload, since this is local state) folds it. @@ -276,6 +288,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ latestTurn, runningTurnId, expandedTurnIds, + expandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, @@ -286,6 +299,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ latestTurn, runningTurnId, expandedTurnIds, + expandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, @@ -293,13 +307,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); const rows = useStableRows(rawRows); - const anchoredEndSpace = useMemo( - () => - resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => - row.kind === "message" ? row.message.id : null, - ), - [anchorMessageId, rows], + const handleAnchorReady = useCallback( + (info: { anchorIndex: number | undefined }) => { + if (anchorMessageId !== null && info.anchorIndex !== undefined) { + onAnchorReady(anchorMessageId, info.anchorIndex); + } + }, + [anchorMessageId, onAnchorReady], + ); + const handleAnchorSizeChanged = useCallback( + (size: number) => { + if (anchorMessageId !== null) { + onAnchorSizeChanged(anchorMessageId, size); + } + }, + [anchorMessageId, onAnchorSizeChanged], ); + const anchoredEndSpace = useMemo(() => { + const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => + row.kind === "message" ? row.message.id : null, + ); + return config + ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged } + : undefined; + }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -322,6 +353,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onOpenTurnDiff, onToggleTurnFold, + onToggleWorkGroup, }), [ timestampFormat, @@ -335,6 +367,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onOpenTurnDiff, onToggleTurnFold, + onToggleWorkGroup, ], ); const activityState = useMemo( @@ -380,11 +413,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ initialScrollAtEnd {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} - maintainScrollAtEnd={!foldToggleSettling} - maintainScrollAtEndThreshold={0.1} - maintainVisibleContentPosition + maintainVisibleContentPosition={{ + data: true, + size: false, + }} onScroll={handleScroll} - className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 sm:px-5" + className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5" ListHeaderComponent={TIMELINE_LIST_HEADER} ListFooterComponent={TIMELINE_LIST_FOOTER} /> @@ -417,7 +451,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time // Commentary (non-terminal assistant) rows carry no metadata row, so // they sit closer to the work that follows them. (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || - row.kind === "work" + row.kind === "work" || + row.kind === "work-toggle" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -428,6 +463,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time data-message-role={row.kind === "message" ? row.message.role : undefined} > {row.kind === "work" ? : null} + {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} {row.kind === "message" && row.message.role === "assistant" ? ( @@ -720,26 +756,17 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { // re-render only the affected row, not the entire list. // --------------------------------------------------------------------------- -/** Collapsed state shows the earliest chunk so "Show more" only appends rows downward. */ +/** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, }: { groupedEntries: Extract["groupedEntries"]; }) { const { workspaceRoot } = use(TimelineRowCtx); - const [isExpanded, setIsExpanded] = useState(false); - const sectionRef = useRef(null); - const anchorBottomBeforeToggleRef = useRef(null); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)), [groupedEntries], ); - const hasOverflow = nonEmptyEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? nonEmptyEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : nonEmptyEntries; - const hiddenCount = nonEmptyEntries.length - visibleEntries.length; const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry)); const groupLabel = onlyToolEntries ? nonEmptyEntries.length === 1 @@ -747,49 +774,17 @@ const WorkGroupSection = memo(function WorkGroupSection({ : `${nonEmptyEntries.length} tool calls` : "Work Log"; - useLayoutEffect(() => { - const anchorBottomBeforeToggle = anchorBottomBeforeToggleRef.current; - anchorBottomBeforeToggleRef.current = null; - - if (anchorBottomBeforeToggle === null) { - return; - } - - const section = sectionRef.current; - if (!section) { - return; - } - - const delta = section.getBoundingClientRect().bottom - anchorBottomBeforeToggle; - if (Math.abs(delta) < 0.5) { - return; - } - - const scroller = findNearestVerticalScroller(section); - if (scroller) { - scroller.scrollTop += delta; - } else { - window.scrollBy(0, delta); - } - }, [isExpanded]); - - const toggleExpanded = () => { - anchorBottomBeforeToggleRef.current = - sectionRef.current?.getBoundingClientRect().bottom ?? null; - setIsExpanded((v) => !v); - }; - if (nonEmptyEntries.length === 0) return null; return ( -
    +
    {!onlyToolEntries && (

    {groupLabel}

    )}
    - {visibleEntries.map((workEntry) => ( + {nonEmptyEntries.map((workEntry) => ( ))}
    - {hasOverflow && ( - - )}
    ); }); -function findNearestVerticalScroller(element: HTMLElement): HTMLElement | null { - let parent = element.parentElement; - while (parent) { - const { overflowY } = window.getComputedStyle(parent); - if ( - (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") && - parent.scrollHeight > parent.clientHeight - ) { - return parent; - } - parent = parent.parentElement; - } - return null; +function WorkGroupToggleTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + const labelNoun = row.onlyToolEntries ? "tool call" : "log entry"; + + return ( + + ); } /** Subscribes directly to the UI state store for expand/collapse state, From 6245c547c2d88e26434ac7b8e08213f2d9ef8577 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 26 Jun 2026 16:55:44 -0700 Subject: [PATCH 24/28] Fix native composer lag with revision-gated updates (#3574) --- .../ios/T3ComposerEditorModule.swift | 10 +- .../ios/T3ComposerEditorView.swift | 47 +- .../src/native/T3ComposerEditor.ios.tsx | 110 +++- .../src/native/composerEditorRevision.test.ts | 109 ++++ .../src/native/composerEditorRevision.ts | 78 +++ pnpm-lock.yaml | 502 +++++++++--------- 6 files changed, 572 insertions(+), 284 deletions(-) create mode 100644 apps/mobile/src/native/composerEditorRevision.test.ts create mode 100644 apps/mobile/src/native/composerEditorRevision.ts diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 5d3b33094cb..d3b12770b04 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -5,14 +5,8 @@ public class T3ComposerEditorModule: Module { Name("T3ComposerEditor") View(T3ComposerEditorView.self) { - Prop("value") { (view: T3ComposerEditorView, value: String) in - view.setValue(value) - } - Prop("tokensJson") { (view: T3ComposerEditorView, tokensJson: String) in - view.setTokensJson(tokensJson) - } - Prop("selectionJson") { (view: T3ComposerEditorView, selectionJson: String) in - view.setSelectionJson(selectionJson) + Prop("controlledDocumentJson") { (view: T3ComposerEditorView, documentJson: String) in + view.setControlledDocumentJson(documentJson) } Prop("themeJson") { (view: T3ComposerEditorView, themeJson: String) in view.setThemeJson(themeJson) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 6f4dc575b12..afb3ba2d94f 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -15,6 +15,14 @@ private struct ComposerSelectionPayload: Decodable { let end: Int } +private struct ComposerControlledDocumentPayload: Decodable { + let value: String + let selection: ComposerSelectionPayload? + let tokensJson: String + let mostRecentEventCount: Int + let isNativeEcho: Bool +} + private struct ComposerThemePayload: Decodable { let text: String let placeholder: String @@ -281,6 +289,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { private var shouldAutoFocus = false private var didAutoFocus = false private var isApplyingControlledValue = false + private var nativeEventCount = 0 private var lastContentSize = CGSize.zero private var iconImages: [String: UIImage] = [:] private var pendingIconUris = Set() @@ -350,32 +359,28 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { } } - func setValue(_ value: String) { - self.value = value - applyControlledDocument(force: tokensNeedRebuild) - if tokensMatchCurrentValue() { - tokensNeedRebuild = false + func setControlledDocumentJson(_ documentJson: String) { + guard let document = decode(ComposerControlledDocumentPayload.self, from: documentJson), + document.mostRecentEventCount >= nativeEventCount else { + return } - } - - func setTokensJson(_ tokensJson: String) { - guard self.tokensJson != tokensJson else { + if document.isNativeEcho && textView.serializedText() != document.value { return } - self.tokensJson = tokensJson - tokens = decode([ComposerTokenPayload].self, from: tokensJson) ?? [] - tokensNeedRebuild = true - applyControlledDocument(force: true) + if tokensJson != document.tokensJson { + tokensJson = document.tokensJson + tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? [] + tokensNeedRebuild = true + } + value = document.value + requestedSelection = document.selection + applyControlledDocument(force: tokensNeedRebuild) + applyRequestedSelection() if tokensMatchCurrentValue() { tokensNeedRebuild = false } } - func setSelectionJson(_ selectionJson: String) { - requestedSelection = decode(ComposerSelectionPayload.self, from: selectionJson) - applyRequestedSelection() - } - func setThemeJson(_ themeJson: String) { guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else { return @@ -700,18 +705,23 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { } value = textView.serializedText() let selection = sourceSelection() + nativeEventCount += 1 onComposerChange([ "value": value, "selection": ["start": selection.start, "end": selection.end], + "eventCount": nativeEventCount, ]) updatePlaceholderVisibility() emitContentSizeIfNeeded() } private func emitSelection() { + let currentValue = textView.serializedText() let selection = sourceSelection() onComposerSelectionChange([ + "value": currentValue, "selection": ["start": selection.start, "end": selection.end], + "eventCount": nativeEventCount, ]) } @@ -752,6 +762,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { isApplyingControlledValue = true textView.selectedRange = nextRange isApplyingControlledValue = false + self.requestedSelection = nil } private func updatePlaceholderVisibility() { diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 7dd92ff067f..eb935030e81 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -1,6 +1,14 @@ import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { requireNativeView } from "expo"; -import { useImperativeHandle, useMemo, useRef, type Ref } from "react"; +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type Ref, +} from "react"; import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; import { Image, StyleSheet } from "react-native"; @@ -8,6 +16,13 @@ import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useThemeColor } from "../lib/useThemeColor"; +import { + acknowledgeComposerNativeEvent, + isComposerNativeEcho, + pruneAcknowledgedComposerNativeEvents, + resolveComposerControlledEventCount, + type ComposerNativeEventSnapshot, +} from "./composerEditorRevision"; import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; const NATIVE_MODULE_NAME = "T3ComposerEditor"; @@ -16,10 +31,13 @@ const EMPTY_SKILLS: NonNullable = []; type NativeEditorEvent = NativeSyntheticEvent<{ readonly value: string; readonly selection: ComposerEditorSelection; + readonly eventCount: number; }>; type NativeSelectionEvent = NativeSyntheticEvent<{ + readonly value: string; readonly selection: ComposerEditorSelection; + readonly eventCount: number; }>; type NativePasteImagesEvent = NativeSyntheticEvent<{ @@ -34,9 +52,7 @@ interface NativeComposerEditorRef { interface NativeComposerEditorProps extends ViewProps { readonly ref?: Ref; - readonly value: string; - readonly tokensJson: string; - readonly selectionJson: string; + readonly controlledDocumentJson: string; readonly themeJson: string; readonly placeholder: string; readonly fontFamily: string; @@ -81,6 +97,13 @@ export function ComposerEditor({ ...props }: ComposerEditorProps) { const nativeRef = useRef(null); + const mostRecentEventCountRef = useRef(0); + const [mostRecentEventCount, setMostRecentEventCount] = useState(0); + const [nativeEventSequence, setNativeEventSequence] = useState(0); + const previousRenderedEventSequenceRef = useRef(0); + const nativeEventSnapshotsRef = useRef([ + { eventCount: 0, value: props.value, selection: selection ?? null }, + ]); const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); const textColor = useThemeColor("--color-foreground"); const placeholderColor = useThemeColor("--color-placeholder"); @@ -126,6 +149,61 @@ export function ComposerEditor({ })), ); }, [props.value, skillLabels]); + const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current; + const controlledEventCount = includesNativeEvent + ? resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ) + : mostRecentEventCount; + const acknowledgesLatestNativeEvent = isComposerNativeEcho( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); + const isNativeEcho = + includesNativeEvent && + controlledEventCount === mostRecentEventCount && + acknowledgesLatestNativeEvent; + const controlledDocumentJson = JSON.stringify({ + value: props.value, + selection: isNativeEcho ? null : (selection ?? null), + tokensJson, + mostRecentEventCount: controlledEventCount, + isNativeEcho, + }); + useEffect(() => { + previousRenderedEventSequenceRef.current = nativeEventSequence; + }, [nativeEventSequence]); + useEffect(() => { + if (!acknowledgesLatestNativeEvent) return; + nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( + nativeEventSnapshotsRef.current, + mostRecentEventCount, + ); + }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + const acceptNativeEvent = useCallback( + (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { + const acknowledgedEventCount = acknowledgeComposerNativeEvent( + mostRecentEventCountRef.current, + eventCount, + ); + if (acknowledgedEventCount === null) { + return false; + } + mostRecentEventCountRef.current = acknowledgedEventCount; + nativeEventSnapshotsRef.current.push({ + eventCount: acknowledgedEventCount, + value, + selection: nextSelection, + }); + return acknowledgedEventCount; + }, + [], + ); const themeJson = JSON.stringify({ text: String(textColor), placeholder: String(placeholderColor), @@ -141,9 +219,7 @@ export function ComposerEditor({ return ( } onComposerChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerSelectionChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); }} - onComposerSelectionChange={(event) => onSelectionChange?.(event.nativeEvent.selection)} onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} onComposerFocus={onFocus} onComposerBlur={onBlur} diff --git a/apps/mobile/src/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts new file mode 100644 index 00000000000..9b255a5477a --- /dev/null +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + acknowledgeComposerNativeEvent, + isComposerNativeEcho, + pruneAcknowledgedComposerNativeEvents, + resolveComposerControlledEventCount, +} from "./composerEditorRevision"; + +describe("acknowledgeComposerNativeEvent", () => { + it("advances to newer native text revisions", () => { + expect(acknowledgeComposerNativeEvent(4, 5)).toBe(5); + }); + + it("accepts a duplicate event from the current native revision", () => { + expect(acknowledgeComposerNativeEvent(5, 5)).toBe(5); + }); + + it("rejects events older than the latest native text revision", () => { + expect(acknowledgeComposerNativeEvent(5, 4)).toBeNull(); + }); + + it("rejects malformed revision counters", () => { + expect(acknowledgeComposerNativeEvent(5, Number.NaN)).toBeNull(); + expect(acknowledgeComposerNativeEvent(5, 5.5)).toBeNull(); + }); +}); + +describe("isComposerNativeEcho", () => { + const snapshots = [{ eventCount: 3, value: "native", selection: { start: 6, end: 6 } }]; + + it("matches the exact native text revision and selection", () => { + expect(isComposerNativeEcho("native", { start: 6, end: 6 }, 3, snapshots)).toBe(true); + }); + + it("does not claim parent-driven selection or repeated-text updates", () => { + expect(isComposerNativeEcho("native", { start: 2, end: 2 }, 3, snapshots)).toBe(false); + expect(isComposerNativeEcho("native", { start: 6, end: 6 }, 4, snapshots)).toBe(false); + expect(isComposerNativeEcho("parent edit", { start: 6, end: 6 }, 3, snapshots)).toBe(false); + }); + + it("matches value and revision when selection is uncontrolled", () => { + expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true); + }); +}); + +describe("resolveComposerControlledEventCount", () => { + const snapshots = [ + { eventCount: 0, value: "", selection: { start: 0, end: 0 } }, + { eventCount: 2, value: "a", selection: { start: 1, end: 1 } }, + { eventCount: 4, value: "ab", selection: { start: 2, end: 2 } }, + ]; + + it("tags a delayed parent value with the native revision that produced it", () => { + expect(resolveComposerControlledEventCount("a", { start: 1, end: 1 }, 4, snapshots)).toBe(2); + }); + + it("does not acknowledge the pre-edit parent value as the latest revision", () => { + expect(resolveComposerControlledEventCount("", { start: 0, end: 0 }, 4, snapshots)).toBe(0); + }); + + it("acknowledges the latest native value at the latest revision", () => { + expect(resolveComposerControlledEventCount("ab", { start: 2, end: 2 }, 4, snapshots)).toBe(4); + }); + + it("allows an unmatched parent-driven edit at the latest native revision", () => { + expect(resolveComposerControlledEventCount("/plan ", { start: 6, end: 6 }, 4, snapshots)).toBe( + 4, + ); + }); + + it("uses the newest revision when selection events repeat the same value", () => { + expect( + resolveComposerControlledEventCount("ab", { start: 1, end: 1 }, 5, [ + ...snapshots, + { eventCount: 5, value: "ab", selection: { start: 1, end: 1 } }, + ]), + ).toBe(5); + }); + + it("keeps a stale selection paired with current text behind the native revision", () => { + expect(resolveComposerControlledEventCount("ab", { start: 1, end: 1 }, 4, snapshots)).toBe(3); + }); + + it("does not control selection when no selection prop is provided", () => { + expect(resolveComposerControlledEventCount("ab", null, 4, snapshots)).toBe(4); + }); +}); + +describe("pruneAcknowledgedComposerNativeEvents", () => { + it("releases an arbitrarily long acknowledged backlog without a fixed-size cliff", () => { + const snapshots = Array.from({ length: 1_000 }, (_, eventCount) => ({ + eventCount, + value: `value-${eventCount}`, + selection: { start: eventCount, end: eventCount }, + })); + + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([]); + }); + + it("retains native events that arrive after the acknowledged render", () => { + const snapshots = [ + { eventCount: 40, value: "a", selection: { start: 1, end: 1 } }, + { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } }, + ]; + + expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual([snapshots[1]]); + }); +}); diff --git a/apps/mobile/src/native/composerEditorRevision.ts b/apps/mobile/src/native/composerEditorRevision.ts new file mode 100644 index 00000000000..ea18d153d53 --- /dev/null +++ b/apps/mobile/src/native/composerEditorRevision.ts @@ -0,0 +1,78 @@ +export interface ComposerNativeEventSnapshot { + readonly eventCount: number; + readonly value: string; + readonly selection: ComposerEditorSelection | null; +} + +interface ComposerEditorSelection { + readonly start: number; + readonly end: number; +} + +export function acknowledgeComposerNativeEvent( + mostRecentEventCount: number, + incomingEventCount: number, +): number | null { + if (!Number.isSafeInteger(incomingEventCount) || incomingEventCount < mostRecentEventCount) { + return null; + } + return incomingEventCount; +} + +export function resolveComposerControlledEventCount( + value: string, + selection: ComposerEditorSelection | null, + mostRecentEventCount: number, + snapshots: ReadonlyArray, +): number { + let newestValueEventCount: number | null = null; + for (let index = snapshots.length - 1; index >= 0; index -= 1) { + const snapshot = snapshots[index]; + if (snapshot?.value !== value) continue; + + newestValueEventCount ??= snapshot.eventCount; + if ( + selection === null || + (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end) + ) { + return snapshot.eventCount; + } + } + + // A value emitted by native paired with a different selection is an + // intermediate React render. Keep it behind the native revision so it + // cannot move the caret while newer keystrokes are being processed. + if (newestValueEventCount !== null && mostRecentEventCount > 0) { + return Math.min(newestValueEventCount, mostRecentEventCount - 1); + } + + return mostRecentEventCount; +} + +export function isComposerNativeEcho( + value: string, + selection: ComposerEditorSelection | null, + eventCount: number, + snapshots: ReadonlyArray, +): boolean { + for (let index = snapshots.length - 1; index >= 0; index -= 1) { + const snapshot = snapshots[index]; + if ( + snapshot !== undefined && + snapshot.eventCount === eventCount && + snapshot.value === value && + (selection === null || + (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end)) + ) { + return true; + } + } + return false; +} + +export function pruneAcknowledgedComposerNativeEvents( + snapshots: ReadonlyArray, + acknowledgedEventCount: number, +): ComposerNativeEventSnapshot[] { + return snapshots.filter((snapshot) => snapshot.eventCount > acknowledgedEventCount); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48b4ed2ceb1..4645ecc34e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,10 +192,10 @@ importers: dependencies: '@callstack/liquid-glass': specifier: ^0.7.1 - version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: 3.5.3-snapshot.v20260622234151 - version: 3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -204,10 +204,10 @@ importers: version: 0.4.2 '@expo/ui': specifier: ~56.0.8 - version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) + version: 56.0.15(961c4aa6f32829b318e3c87ef20ad401) '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -219,7 +219,7 @@ importers: version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -240,7 +240,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984) + version: file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -261,40 +261,40 @@ importers: version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: specifier: ^56.0.0 - version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + version: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-asset: specifier: ~56.0.15 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-auth-session: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-build-properties: specifier: ~56.0.15 version: 56.0.16(expo@56.0.8) expo-camera: specifier: ~56.0.7 - version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-clipboard: specifier: ~56.0.3 - version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-constants: specifier: ~56.0.16 - version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) expo-dev-client: specifier: ~56.0.16 - version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-file-system: specifier: ~56.0.7 - version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-glass-effect: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: specifier: ~56.0.3 version: 56.0.3(expo@56.0.8) @@ -303,19 +303,19 @@ importers: version: 56.0.15(expo@56.0.8) expo-linking: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-network: specifier: ~56.0.5 version: 56.0.5(expo@56.0.8)(react@19.2.3) expo-notifications: specifier: ~56.0.14 - version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-paste-input: specifier: ^0.1.15 - version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-router: specifier: ~56.2.7 - version: 56.2.8(c021de11d02907bd585610408f5252e8) + version: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) expo-secure-store: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) @@ -324,16 +324,16 @@ importers: version: 56.0.10(expo@56.0.8)(typescript@6.0.3) expo-symbols: specifier: ~56.0.5 - version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-updates: specifier: ~56.0.17 - version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-web-browser: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-widgets: specifier: ~56.0.15 - version: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) + version: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) punycode: specifier: ^2.3.1 version: 2.3.1 @@ -345,43 +345,43 @@ importers: version: 19.2.3(react@19.2.3) react-native: specifier: 0.85.3 - version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-gesture-handler: specifier: ~2.31.1 - version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-image-viewing: specifier: ^0.2.2 - version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.7 - version: 1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: 0.35.9 - version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.3.1 - version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 - version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 - version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 - version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-webview: specifier: ^13.16.1 - version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: specifier: 0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) shiki: specifier: 4.2.0 version: 4.2.0 @@ -390,7 +390,7 @@ importers: version: 3.6.0 uniwind: specifier: ^1.6.2 - version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) + version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 @@ -11109,10 +11109,10 @@ snapshots: '@bruits/satteri-win32-x64-msvc@0.9.3': optional: true - '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@capsizecss/unpack@4.0.1': dependencies: @@ -11216,23 +11216,23 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 optionalDependencies: - expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) expo-secure-store: 56.0.4(expo@56.0.8) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': @@ -11815,7 +11815,7 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': + '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 56.0.9(typescript@6.0.3) @@ -11825,7 +11825,7 @@ snapshots: '@expo/image-utils': 0.10.1(typescript@6.0.3) '@expo/inline-modules': 0.0.10(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/metro-file-map': 56.0.3 @@ -11850,7 +11850,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-server: 56.0.4 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -11876,8 +11876,8 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -11939,18 +11939,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@expo/env@2.3.0': dependencies: @@ -12011,13 +12011,13 @@ snapshots: - supports-color - typescript - '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)': @@ -12047,7 +12047,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) transitivePeerDependencies: - bufferutil - supports-color @@ -12065,14 +12065,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -12147,14 +12147,14 @@ snapshots: '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 react: 19.2.3 optionalDependencies: - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-router: 56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12169,18 +12169,18 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': + '@expo/ui@56.0.15(961c4aa6f32829b318e3c87ef20ad401)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -12441,13 +12441,13 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -13270,15 +13270,15 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 - '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@react-native/assets-registry@0.85.3': {} @@ -13338,7 +13338,7 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) debug: 4.4.3 @@ -13348,7 +13348,7 @@ snapshots: metro-core: 0.84.4 semver: 7.8.5 optionalDependencies: - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -13396,7 +13396,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.3(@babel/core@7.29.7)': + '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@react-native/js-polyfills': 0.85.3 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) @@ -13404,16 +13404,18 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.85.3': {} - '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 @@ -13798,15 +13800,15 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(f0ff241d48c23db461fff5d47e450578)': dependencies: - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 56.0.3(expo@56.0.8) - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -14921,8 +14923,8 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-widgets: 56.0.16(961c4aa6f32829b318e3c87ef20ad401) transitivePeerDependencies: - '@babel/core' - supports-color @@ -15819,29 +15821,29 @@ snapshots: expo-application@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.8) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color @@ -15849,119 +15851,119 @@ snapshots: expo-build-properties@56.0.16(expo@56.0.8): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) resolve-from: 5.0.0 semver: 7.8.5 - expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color expo-crypto@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 56.0.1(expo@56.0.8) expo-manifests: 56.0.4(expo@56.0.8) expo-updates-interface: 56.0.2(expo@56.0.8) transitivePeerDependencies: - react-native - expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 56.0.4(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@56.0.1(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-dev-menu-interface: 56.0.1(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-eas-client@56.0.1: {} - expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) fontfaceobserver: 2.3.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-image-loader@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-image-picker@56.0.15(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-image-loader: 56.0.3(expo@56.0.8) expo-json-utils@56.0.0: {} expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color expo-manifests@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.14(typescript@6.0.3): @@ -15974,66 +15976,66 @@ snapshots: - supports-color - typescript - expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/expo-modules-macros-plugin': 0.0.9 - expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-network@56.0.5(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-application: 56.0.3(expo@56.0.8) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-router@56.2.8(c021de11d02907bd585610408f5252e8): + expo-router@56.2.8(5bfdf39b8f760e4dc2d5c3acffc97310): dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) + '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -16041,18 +16043,18 @@ snapshots: react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(0e9729601f58a7a7ae26c76fe6017455) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -16064,7 +16066,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-server@56.0.4: {} @@ -16072,7 +16074,7 @@ snapshots: dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -16080,20 +16082,20 @@ snapshots: expo-structured-headers@56.0.0: {} - expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 expo-updates-interface@56.0.2(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) - expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/plist': 0.7.0 @@ -16101,7 +16103,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) expo-eas-client: 56.0.1 expo-manifests: 56.0.4(expo@56.0.8) expo-structured-headers: 56.0.0 @@ -16111,25 +16113,25 @@ snapshots: ignore: 5.3.2 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 optionalDependencies: - expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) transitivePeerDependencies: - supports-color - expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): + expo-widgets@56.0.16(961c4aa6f32829b318e3c87ef20ad401): dependencies: '@expo/plist': 0.7.0 - '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + expo: 56.0.8(63f7aade424ad9e7b1154b679fa2a14d) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@babel/core' - '@types/react' @@ -16138,37 +16140,37 @@ snapshots: - react-native-reanimated - react-native-worklets - expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): + expo@56.0.8(63f7aade424ad9e7b1154b679fa2a14d): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/config': 56.0.9(typescript@6.0.3) '@expo/config-plugins': 56.0.8(typescript@6.0.3) - '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/fingerprint': 0.19.3 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@ungap/structured-clone': 1.3.1 babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2) - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) expo-modules-autolinking: 56.0.14(typescript@6.0.3) - expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) - react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -18589,102 +18591,102 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-drawer-layout@4.2.4(0e9729601f58a7a7ae26c76fe6017455): dependencies: color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) - react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) optionalDependencies: - react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.5 - react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) whatwg-url-without-unicode: 8.0.0-3 - react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -18696,23 +18698,23 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) convert-source-map: 2.0.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.5 transitivePeerDependencies: - supports-color - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: '@react-native/assets-registry': 0.85.3 '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@react-native/gradle-plugin': 0.85.3 '@react-native/js-polyfills': 0.85.3 '@react-native/normalize-colors': 0.85.3 - '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -19761,14 +19763,14 @@ snapshots: universalify@2.0.1: {} - uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): + uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 culori: 4.0.2 lightningcss: 1.30.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) tailwindcss: 4.3.0 unpipe@1.0.0: {} From 44fb34ad5383badfb02c9f7daa1d8486277eeb01 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 27 Jun 2026 08:18:28 -0700 Subject: [PATCH 25/28] Stabilize preview browser surfaces, automation, and recording (#3565) --- apps/server/src/mcp/McpHttpServer.test.ts | 88 +++--- .../src/mcp/PreviewAutomationBroker.test.ts | 248 ++++++++++++++- .../server/src/mcp/PreviewAutomationBroker.ts | 62 +++- .../src/mcp/toolkits/preview/handlers.ts | 46 ++- .../src/mcp/toolkits/preview/tools.test.ts | 4 + apps/server/src/mcp/toolkits/preview/tools.ts | 29 +- .../src/provider/Layers/CursorAdapter.test.ts | 29 +- apps/web/src/browser/BrowserSurfaceSlot.tsx | 12 +- apps/web/src/browser/HostedBrowserWebview.tsx | 47 ++- apps/web/src/browser/browserRecording.test.ts | 294 ++++++++++++++++++ apps/web/src/browser/browserRecording.ts | 250 ++++++++++++--- .../src/browser/browserRecordingScope.test.ts | 7 +- apps/web/src/browser/browserRecordingScope.ts | 8 +- .../src/browser/browserSurfaceStore.test.ts | 48 ++- apps/web/src/browser/browserSurfaceStore.ts | 63 +++- .../browser/hostedBrowserWebviewStyle.test.ts | 43 +++ .../src/browser/hostedBrowserWebviewStyle.ts | 49 +++ .../preview/PreviewAutomationHosts.tsx | 43 ++- .../preview/previewAutomationTarget.test.ts | 14 + .../preview/previewAutomationTarget.ts | 12 + packages/contracts/src/preview.test.ts | 16 + packages/contracts/src/previewAutomation.ts | 43 ++- 22 files changed, 1248 insertions(+), 207 deletions(-) create mode 100644 apps/web/src/browser/browserRecording.test.ts create mode 100644 apps/web/src/browser/hostedBrowserWebviewStyle.test.ts create mode 100644 apps/web/src/browser/hostedBrowserWebviewStyle.ts diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 14bd4c20576..ca3341be7f3 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -15,6 +15,7 @@ import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const environmentId = EnvironmentId.make("environment-mcp-test"); const threadId = ThreadId.make("thread-mcp-test"); const tabId = PreviewTabId.make("tab-mcp-test"); +const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); const invocation = { environmentId, threadId, @@ -154,49 +155,53 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.gen(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const routedRequests: Array<{ + readonly operation: string; + readonly tabId?: string | undefined; + }> = []; const events = yield* broker.connect({ clientId: "mcp-test-client", environmentId, }); - yield* Stream.runForEach(events, (event) => - event.type === "connected" - ? Effect.void - : broker.respond({ - clientId: "mcp-test-client", - connectionId: event.connectionId, - requestId: event.request.requestId, - ok: true, - result: - event.request.operation === "snapshot" - ? { - url: "http://example.test/", - title: "Example", - loading: false, - visibleText: "Example", - interactiveElements: [], - accessibilityTree: {}, - consoleEntries: [], - networkEntries: [], - actionTimeline: [], - screenshot: { - mimeType: "image/png", - data: Buffer.from("png").toString("base64"), - width: 10, - height: 5, - }, - } - : event.request.operation === "press" - ? undefined - : { - available: true, - visible: true, - tabId, - url: "http://example.test/", - title: "Example", - loading: false, - }, - }), - ).pipe(Effect.forkScoped); + yield* Stream.runForEach(events, (event) => { + if (event.type === "connected") return Effect.void; + routedRequests.push(event.request); + return broker.respond({ + clientId: "mcp-test-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: + event.request.operation === "snapshot" + ? { + url: "http://example.test/", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + screenshot: { + mimeType: "image/png", + data: Buffer.from("png").toString("base64"), + width: 10, + height: 5, + }, + } + : event.request.operation === "press" + ? undefined + : { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }, + }); + }).pipe(Effect.forkScoped); yield* Effect.yieldNow; const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); @@ -239,7 +244,7 @@ it.effect("registers annotated tools and preserves authenticated request context expect(malformed.isError).toBe(true); const snapshot = yield* server - .callTool({ name: "preview_snapshot", arguments: {} }) + .callTool({ name: "preview_snapshot", arguments: { tabId: alternateTabId } }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), @@ -249,6 +254,9 @@ it.effect("registers annotated tools and preserves authenticated request context expect(snapshot.structuredContent).toMatchObject({ screenshot: { mimeType: "image/png", width: 10, height: 5 }, }); + expect(routedRequests.find(({ operation }) => operation === "snapshot")?.tabId).toBe( + alternateTabId, + ); const press = yield* server .callTool({ name: "preview_press", arguments: { key: "Enter" } }) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 66003557303..ffda427d815 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -15,6 +15,7 @@ import { type PreviewAutomationStreamEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; @@ -84,6 +85,187 @@ it.effect("atomically registers a connected host and correlates its response", ( ), ); +it.effect("targets multiple tabs explicitly while retaining a default tab", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const appTabId = PreviewTabId.make("tab-web-app"); + const simulatorTabId = PreviewTabId.make("tab-ios-simulator"); + const openedTabIds = [appTabId, simulatorTabId]; + let openIndex = 0; + const routedRequests: RoutedRequest[] = []; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => { + routedRequests.push(request); + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: + request.operation === "open" + ? { available: true, tabId: openedTabIds[openIndex++] } + : { url: "http://localhost:3200" }, + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); + yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + yield* broker.invoke({ scope, operation: "snapshot", input: {}, tabId: appTabId }); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + + expect(routedRequests).toHaveLength(5); + expect(routedRequests[0]?.tabId).toBeUndefined(); + expect(routedRequests[1]?.tabId).toBe(appTabId); + expect(routedRequests[2]?.tabId).toBe(simulatorTabId); + expect(routedRequests[2]?.tabIdExplicit).toBe(false); + expect(routedRequests[3]?.tabId).toBe(appTabId); + expect(routedRequests[3]?.tabIdExplicit).toBe(true); + expect(routedRequests[4]?.tabId).toBe(appTabId); + }), + ), +); + +it.effect("does not let an older response replace a newer explicit tab target", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const olderTabId = PreviewTabId.make("tab-older-request"); + const newerTabId = PreviewTabId.make("tab-newer-request"); + const releaseOlderResponse = yield* Deferred.make(); + const routedRequests: RoutedRequest[] = []; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => { + routedRequests.push(request); + const response = Effect.gen(function* () { + if (request.tabId === olderTabId) { + yield* Deferred.await(releaseOlderResponse); + } + yield* broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { url: "http://localhost:3200" }, + }); + if (request.tabId === newerTabId) { + yield* Deferred.succeed(releaseOlderResponse, undefined); + } + }); + return response.pipe(Effect.forkScoped, Effect.asVoid); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const older = yield* broker + .invoke({ scope, operation: "snapshot", input: {}, tabId: olderTabId }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const newer = yield* broker + .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) + .pipe(Effect.forkScoped); + yield* Fiber.join(newer); + yield* Fiber.join(older); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + + expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); + }), + ), +); + +it.effect("does not replace the default tab with a globally stopped recording tab", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const browsingTabId = PreviewTabId.make("tab-session-b"); + const recordingTabId = PreviewTabId.make("tab-session-a-recording"); + const routedRequests: RoutedRequest[] = []; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => { + routedRequests.push(request); + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: + request.operation === "open" + ? { available: true, tabId: browsingTabId } + : request.operation === "recordingStop" + ? { id: "recording-1", tabId: recordingTabId } + : { url: "http://localhost:3200" }, + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "open", input: {} }); + yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + + expect(routedRequests.at(-1)?.tabId).toBe(browsingTabId); + }), + ), +); + +it.effect("does not let a no-tab response suppress an earlier tab decision", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const initialTabId = PreviewTabId.make("tab-initial"); + const openedTabId = PreviewTabId.make("tab-opened-late"); + const releaseOpenResponse = yield* Deferred.make(); + const routedRequests: RoutedRequest[] = []; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => { + routedRequests.push(request); + const marker = + typeof request.input === "object" && request.input !== null && "marker" in request.input + ? request.input.marker + : undefined; + const response = Effect.gen(function* () { + if (marker === "older") { + yield* Deferred.await(releaseOpenResponse); + } + yield* broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: + request.operation === "open" + ? { available: true, tabId: marker === "older" ? openedTabId : initialTabId } + : { url: "http://localhost:3200" }, + }); + if (marker === "newer") { + yield* Deferred.succeed(releaseOpenResponse, undefined); + } + }); + return response.pipe(Effect.forkScoped, Effect.asVoid); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "open", input: {} }); + const older = yield* broker + .invoke({ + scope, + operation: "open", + input: { marker: "older", reuseExistingTab: false }, + }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const newer = yield* broker + .invoke({ scope, operation: "snapshot", input: { marker: "newer" } }) + .pipe(Effect.forkScoped); + yield* Fiber.join(newer); + yield* Fiber.join(older); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + + expect(routedRequests.at(-1)?.tabId).toBe(openedTabId); + }), + ), +); + it.effect("announces a live replacement stream before delivering requests", () => Effect.scoped( Effect.gen(function* () { @@ -632,7 +814,9 @@ it.effect("fails over a pinned provider session only after its host disconnects" Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; + const firstTabId = PreviewTabId.make("tab-on-first-host"); let firstConnectionId = ""; + let secondRoutedTabId: PreviewTabId | undefined; const firstRequests = requestsFrom( yield* broker.connect(makeHost({ clientId: "client-first" })), (connectionId) => { @@ -648,18 +832,19 @@ it.effect("fails over a pinned provider session only after its host disconnects" connectionId: request.connectionId, requestId: request.requestId, ok: true, - result: "first", + result: request.operation === "open" ? { host: "first", tabId: firstTabId } : "first", }), ).pipe(Effect.forkScoped); - yield* Stream.runForEach(secondRequests, (request) => - broker.respond({ + yield* Stream.runForEach(secondRequests, (request) => { + secondRoutedTabId = request.tabId; + return broker.respond({ clientId: "client-second", connectionId: request.connectionId, requestId: request.requestId, ok: true, result: "second", - }), - ).pipe(Effect.forkScoped); + }); + }).pipe(Effect.forkScoped); yield* Effect.yieldNow; yield* broker.focusHost({ @@ -668,7 +853,10 @@ it.effect("fails over a pinned provider session only after its host disconnects" connectionId: firstConnectionId, focused: true, }); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("first"); + expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ + host: "first", + tabId: firstTabId, + }); yield* Fiber.interrupt(firstConsumer); yield* Effect.yieldNow; @@ -676,6 +864,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( "second", ); + expect(secondRoutedTabId).toBeUndefined(); }), ), ); @@ -740,6 +929,53 @@ it.effect("keeps a replacement stream authoritative when the old stream finalize ), ); +it.effect("does not carry a tab id across a replacement automation stream", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const openedTabId = PreviewTabId.make("tab-first-webcontents"); + const firstRequests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(firstRequests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: + request.operation === "open" + ? { host: "first", tabId: openedTabId } + : { host: "first" }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ + host: "first", + tabId: openedTabId, + }); + + const routedRequests: RoutedRequest[] = []; + const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(replacementRequests, (request) => { + routedRequests.push(request); + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "replacement", + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "replacement", + ); + expect(routedRequests.at(-1)?.tabId).toBeUndefined(); + }), + ), +); + it.effect("fails requests assigned to the stream that is replaced", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 688383d7b1c..7ed77aabdf1 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -13,13 +13,13 @@ import { PreviewAutomationTargetNotEditableError, PreviewAutomationTimeoutError, PreviewAutomationUnsupportedClientError, + PreviewTabId, type PreviewAutomationError, type PreviewAutomationOperation, type PreviewAutomationHost, type PreviewAutomationHostFocus, type PreviewAutomationResponse, type PreviewAutomationStreamEvent, - type PreviewTabId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; @@ -29,6 +29,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -79,6 +80,8 @@ interface HostAssignment { readonly connectionId: ClientConnection["connectionId"]; readonly queue: ClientConnection["queue"]; readonly expiresAt: number; + readonly tabId?: PreviewTabId; + readonly tabSequence?: number; } interface PreviewAutomationRequestErrorContext { @@ -144,6 +147,14 @@ const selectorDiagnosticsFromInput = ( const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => `${scope.environmentId}\u0000${scope.providerSessionId}`; +const isPreviewTabId = Schema.is(PreviewTabId); + +const readResultTabId = (result: unknown): PreviewTabId | null | undefined => { + if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined; + const tabId = result.tabId; + return tabId === null || isPreviewTabId(tabId) ? tabId : undefined; +}; + const supportsOperation = ( connection: ClientConnection, operation: PreviewAutomationOperation, @@ -454,15 +465,24 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { if (!hasLiveAssignment) assignments.delete(assignmentKey); return [undefined, { ...current, assignments }] as const; } + const canReuseAssignedTab = + assigned !== undefined && + assigned.connectionId === connection.connectionId && + assigned.queue === connection.queue; assignments.set(assignmentKey, { clientId: connection.clientId, connectionId: connection.connectionId, queue: connection.queue, expiresAt: input.scope.expiresAt, + ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}), + ...(canReuseAssignedTab && assigned.tabSequence !== undefined + ? { tabSequence: assigned.tabSequence } + : {}), }); - const requestId = `preview-${current.requestSequence}`; - const tabId = input.tabId; + const requestSequence = current.requestSequence; + const requestId = `preview-${requestSequence}`; + const tabId = input.tabId ?? (canReuseAssignedTab ? assigned.tabId : undefined); const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); const context: PreviewAutomationRequestErrorContext = { operation: input.operation, @@ -480,7 +500,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const pending = new Map(current.pending); pending.set(requestId, { queue: connection.queue, deferred, context }); return [ - { connection, requestId, requestContext: context }, + { connection, requestId, requestContext: context, requestSequence }, { ...current, assignments, pending, requestSequence: current.requestSequence + 1 }, ] as const; }); @@ -493,7 +513,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { providerInstanceId: input.scope.providerInstanceId, }); } - const { connection, requestId, requestContext } = route; + const { connection, requestId, requestContext, requestSequence } = route; const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; const pending = new Map(next.pending); @@ -508,6 +528,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { requestId, threadId: input.scope.threadId, tabId: requestContext.tabId, + tabIdExplicit: input.tabId !== undefined, operation: input.operation, input: input.input, timeoutMs, @@ -526,7 +547,36 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { onSome: (value) => Effect.succeed(value as A), }); }); - return yield* awaitResponse().pipe(Effect.ensuring(removePending)); + const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); + // A stop artifact identifies the globally recorded tab, not the caller's browsing target. + const responseTabId = input.operation === "recordingStop" ? undefined : readResultTabId(result); + const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; + if (resultTabId === undefined) return result; + const assignmentKey = hostAssignmentKey(input.scope); + yield* SynchronizedRef.update(state, (current) => { + const assignment = current.assignments.get(assignmentKey); + if ( + !assignment || + assignment.connectionId !== connection.connectionId || + assignment.queue !== connection.queue || + (assignment.tabSequence ?? -1) > requestSequence + ) { + return current; + } + const assignments = new Map(current.assignments); + if (resultTabId === null) { + const { tabId: _tabId, ...withoutTabId } = assignment; + assignments.set(assignmentKey, { ...withoutTabId, tabSequence: requestSequence }); + } else { + assignments.set(assignmentKey, { + ...assignment, + ...(resultTabId === undefined ? {} : { tabId: resultTabId }), + tabSequence: requestSequence, + }); + } + return { ...current, assignments }; + }); + return result; }); return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke }); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 700b81a0bfc..64d6ba02b1d 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -6,6 +6,7 @@ import type { PreviewAutomationResizeResult, PreviewAutomationSnapshot, PreviewAutomationStatus, + PreviewTabId, } from "@t3tools/contracts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -16,6 +17,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function*
    ( operation: PreviewAutomationOperation, input: unknown, timeoutMs?: number, + tabId?: PreviewTabId, ): Effect.fn.Return< A, import("@t3tools/contracts").PreviewAutomationError, @@ -28,31 +30,49 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( operation, input, ...(timeoutMs === undefined ? {} : { timeoutMs }), + ...(tabId === undefined ? {} : { tabId }), }); }); +const invokeTargeted = ( + operation: PreviewAutomationOperation, + input: { + readonly tabId?: PreviewTabId | undefined; + readonly [key: string]: unknown; + }, + timeoutMs?: number, +) => { + const { tabId, ...operationInput } = input; + return invoke(operation, operationInput, timeoutMs, tabId); +}; + const handlers = { - preview_status: () => invoke("status", {}), + preview_status: (input) => invokeTargeted("status", input ?? {}), preview_open: (input) => - invoke("open", { + invokeTargeted("open", { ...input, show: input.show ?? true, reuseExistingTab: input.reuseExistingTab ?? true, }), - preview_navigate: (input) => invoke("navigate", input, input.timeoutMs), + preview_navigate: (input) => + invokeTargeted("navigate", input, input.timeoutMs), preview_resize: (input) => - invoke("resize", input, input.timeoutMs), - preview_snapshot: () => invoke("snapshot", {}), - preview_click: (input) => invoke("click", input, input.timeoutMs).pipe(Effect.as(null)), - preview_type: (input) => invoke("type", input, input.timeoutMs).pipe(Effect.as(null)), - preview_press: (input) => invoke("press", input).pipe(Effect.as(null)), - preview_scroll: (input) => invoke("scroll", input).pipe(Effect.as(null)), + invokeTargeted("resize", input, input.timeoutMs), + preview_snapshot: (input) => invokeTargeted("snapshot", input ?? {}), + preview_click: (input) => + invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as(null)), + preview_type: (input) => + invokeTargeted("type", input, input.timeoutMs).pipe(Effect.as(null)), + preview_press: (input) => invokeTargeted("press", input).pipe(Effect.as(null)), + preview_scroll: (input) => invokeTargeted("scroll", input).pipe(Effect.as(null)), preview_evaluate: (input) => - invoke("evaluate", input).pipe(Effect.map((result) => result ?? null)), + invokeTargeted("evaluate", input).pipe(Effect.map((result) => result ?? null)), preview_wait_for: (input) => - invoke("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), - preview_recording_start: () => invoke("recordingStart", {}), - preview_recording_stop: () => invoke("recordingStop", {}), + invokeTargeted("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), + preview_recording_start: (input) => + invokeTargeted("recordingStart", input ?? {}), + preview_recording_stop: (input) => + invokeTargeted("recordingStop", input ?? {}), } satisfies Parameters[0]; const { preview_snapshot, ...standardHandlers } = handlers; diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index 1347e0db0ec..d00ff459b9d 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -27,6 +27,10 @@ it("exports provider-compatible object schemas with described parameters", () => expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); + expect( + schema.properties?.tabId, + `${tool.name} must allow an explicit collaborative browser tab target`, + ).toBeDefined(); for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { expect( schemaHasDescription(fieldSchema), diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index d9567b01c3c..c729fc20ece 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -12,6 +12,7 @@ import { PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, + PreviewAutomationTabTargetInput, PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "@t3tools/contracts"; @@ -37,7 +38,8 @@ const readonlyBrowserTool = (tool: T): T => export const PreviewStatusTool = Tool.make("preview_status", { description: - "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size.", + "Report whether a collaborative browser tab is automation-capable, including its URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", + parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationStatus, failure: PreviewAutomationError, dependencies, @@ -50,7 +52,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { export const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: - "Show and initialize the browser preview for the scoped thread, optionally reusing its current tab and navigating to a URL.", + "Show and initialize a collaborative browser tab. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.", parameters: PreviewAutomationOpenInput, success: PreviewAutomationStatus, failure: PreviewAutomationError, @@ -63,7 +65,7 @@ export const PreviewOpenTool = browserTool( export const PreviewNavigateTool = safeBrowserTool( Tool.make("preview_navigate", { description: - "Navigate the active collaborative browser tab. Pass {url:'https://t3.chat'} for a website, or {target:{kind:'environment-port',port:5173}} for a dev server in the current environment. Exactly one of url or target is required. Defaults to waiting for page loading to stop.", + "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.", parameters: PreviewAutomationNavigateInput, success: PreviewAutomationStatus, failure: PreviewAutomationError, @@ -74,7 +76,7 @@ export const PreviewNavigateTool = safeBrowserTool( export const PreviewResizeTool = safeBrowserTool( Tool.make("preview_resize", { description: - "Set the active collaborative browser tab to fill-panel sizing, an independently resizable freeform size, or a Chrome-standard device preset. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", + "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", parameters: PreviewAutomationResizeInput, success: PreviewAutomationResizeResult, failure: PreviewAutomationError, @@ -87,7 +89,8 @@ export const PreviewResizeTool = safeBrowserTool( export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: - "Inspect the current page before interacting. Returns URL/title/loading state, visible text, semantic interactive elements with reusable selectors and coordinates, accessibility data, recent console/network failures, action history, and a PNG screenshot.", + "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot.", + parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationSnapshot, failure: PreviewAutomationError, dependencies, @@ -97,7 +100,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( export const PreviewClickTool = browserTool( Tool.make("preview_click", { description: - "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", + "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.", parameters: PreviewAutomationClickInput, success: Schema.Null, failure: PreviewAutomationError, @@ -108,7 +111,7 @@ export const PreviewClickTool = browserTool( export const PreviewTypeTool = browserTool( Tool.make("preview_type", { description: - "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", + "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.", parameters: PreviewAutomationTypeInput, success: Schema.Null, failure: PreviewAutomationError, @@ -119,7 +122,7 @@ export const PreviewTypeTool = browserTool( export const PreviewPressTool = browserTool( Tool.make("preview_press", { description: - "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", + "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", parameters: PreviewAutomationPressInput, success: Schema.Null, failure: PreviewAutomationError, @@ -130,7 +133,7 @@ export const PreviewPressTool = browserTool( export const PreviewScrollTool = safeBrowserTool( Tool.make("preview_scroll", { description: - "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", + "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.", parameters: PreviewAutomationScrollInput, success: Schema.Null, failure: PreviewAutomationError, @@ -141,7 +144,7 @@ export const PreviewScrollTool = safeBrowserTool( export const PreviewEvaluateTool = browserTool( Tool.make("preview_evaluate", { description: - "Evaluate a JavaScript expression in the page's main frame and return a serializable result up to 64 KB. Prefer preview_snapshot and semantic click/type/wait tools; use this for inspection or interactions those tools cannot express. The expression may mutate page state.", + "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns a serializable result up to 64 KB; the expression may mutate page state.", parameters: PreviewAutomationEvaluateInput, success: Schema.Unknown, failure: PreviewAutomationError, @@ -152,7 +155,7 @@ export const PreviewEvaluateTool = browserTool( export const PreviewWaitForTool = readonlyBrowserTool( Tool.make("preview_wait_for", { description: - "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", + "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.", parameters: PreviewAutomationWaitForInput, success: Schema.Null, failure: PreviewAutomationError, @@ -163,7 +166,8 @@ export const PreviewWaitForTool = readonlyBrowserTool( export const PreviewRecordingStartTool = safeBrowserTool( Tool.make("preview_recording_start", { description: - "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", + "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", + parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationRecordingStatus, failure: PreviewAutomationError, dependencies, @@ -173,6 +177,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( export const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { description: "Stop the active browser recording and save it as a local evidence artifact.", + parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationRecordingArtifact, failure: PreviewAutomationError, dependencies, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 73dc0967622..89e9c56eb8a 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1021,24 +1021,19 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { assert.equal(turnCompleted.payload.stopReason, "cancelled"); } - const requests = yield* waitForJsonLogMatch( - requestLogPath, - (entry) => entry.method === "session/cancel", - ); + const isCancelledApprovalResponse = (entry: Record) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "outcome" in entry.result.outcome && + entry.result.outcome.outcome === "cancelled"; + yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "session/cancel"); + const requests = yield* waitForJsonLogMatch(requestLogPath, isCancelledApprovalResponse); assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); - assert.isTrue( - requests.some( - (entry) => - !("method" in entry) && - typeof entry.result === "object" && - entry.result !== null && - "outcome" in entry.result && - typeof entry.result.outcome === "object" && - entry.result.outcome !== null && - "outcome" in entry.result.outcome && - entry.result.outcome.outcome === "cancelled", - ), - ); + assert.isTrue(requests.some(isCancelledApprovalResponse)); yield* adapter.stopSession(threadId); }), diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index 90769f8fb69..de74cfa2a90 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useLayoutEffect, useRef } from "react"; -import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { acquireBrowserSurface } from "./browserSurfaceStore"; export function BrowserSurfaceSlot(props: { readonly tabId: string; @@ -12,13 +12,13 @@ export function BrowserSurfaceSlot(props: { const { tabId, visible, className } = props; const elementRef = useRef(null); - useEffect(() => { + useLayoutEffect(() => { const element = elementRef.current; if (!element) return; + const lease = acquireBrowserSurface(tabId); const update = () => { const rect = element.getBoundingClientRect(); - useBrowserSurfaceStore.getState().present( - tabId, + lease.present( { x: Math.round(rect.x), y: Math.round(rect.y), @@ -37,7 +37,7 @@ export function BrowserSurfaceSlot(props: { observer.disconnect(); window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); - useBrowserSurfaceStore.getState().hide(tabId); + lease.release(); }; }, [tabId, visible]); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 2907a067aad..a72da49c6fa 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -8,13 +8,13 @@ import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn } from "~/lib/utils"; -import { useActiveBrowserRecordingTabId } from "./browserRecording"; +import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; import { browserViewportSettingKey } from "./browserViewportLayout"; -import { reconcileLockedAspectRatio } from "./browserDeviceToolbarState"; import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; +import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; import { useBrowserViewportResize } from "./useBrowserViewportResize"; @@ -46,7 +46,8 @@ export function HostedBrowserWebview(props: { const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); - const [lockedAspectRatio, setLockedAspectRatio] = useState(null); + const [aspectRatioLocked, setAspectRatioLocked] = useState(false); + const activeRecordingTabId = useActiveBrowserRecordingTabId(); const presentation = useBrowserSurfaceStore( useShallow((state) => { const current = state.byTabId[tabId]; @@ -56,10 +57,13 @@ export function HostedBrowserWebview(props: { }; }), ); - const recording = useActiveBrowserRecordingTabId() === tabId; - usePreviewBridge({ threadRef, tabId }); + useEffect(() => { + if (presentation.visible || activeRecordingTabId !== tabId) return; + void stopBrowserRecording(tabId).catch(() => undefined); + }, [activeRecordingTabId, presentation.visible, tabId]); + useEffect(() => { const lease = acquireDesktopTab(tabId); tabLeaseRef.current = lease; @@ -115,9 +119,11 @@ export function HostedBrowserWebview(props: { const viewportHeight = viewport._tag === "fill" ? null : viewport.height; const viewportAspectRatio = viewportWidth === null || viewportHeight === null ? null : viewportWidth / viewportHeight; - useEffect(() => { - setLockedAspectRatio((current) => reconcileLockedAspectRatio(current, viewportAspectRatio)); - }, [viewportAspectRatio]); + const lockedAspectRatio = + aspectRatioLocked && viewportAspectRatio !== null ? viewportAspectRatio : null; + const handleAspectRatioChange = useCallback((aspectRatio: number | null) => { + setAspectRatioLocked(aspectRatio !== null); + }, []); const hiddenSize = viewport._tag !== "fill" ? { @@ -170,24 +176,11 @@ export function HostedBrowserWebview(props: { if (!config) return null; - const wrapperStyle = - active && lastRect - ? { - left: lastRect.x, - top: lastRect.y, - width: lastRect.width, - height: lastRect.height, - zIndex: 30, - pointerEvents: "auto" as const, - } - : { - left: 0, - top: 0, - width: hiddenSize.width, - height: hiddenSize.height, - zIndex: recording ? 0 : -1, - pointerEvents: "none" as const, - }; + const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ + active, + rect: lastRect, + hiddenSize, + }); return (
    ) : null} diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts new file mode 100644 index 00000000000..9810987c9b9 --- /dev/null +++ b/apps/web/src/browser/browserRecording.test.ts @@ -0,0 +1,294 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { events, onFrame, registrySet, save, startScreencast, stopScreencast, surfaceState } = + vi.hoisted(() => { + const events: string[] = []; + const surfaceState = { + byTabId: {} as Record, + }; + return { + events, + onFrame: vi.fn(() => vi.fn()), + registrySet: vi.fn((_atom: unknown, value: string | null) => { + events.push(value === null ? "clear" : `publish:${value}`); + }), + save: vi.fn(async () => ({ + id: "recording-test", + tabId: "recording-tab", + path: "/tmp/recording-test.webm", + mimeType: "video/webm" as const, + sizeBytes: 0, + createdAt: "2026-06-26T00:00:00.000Z", + })), + startScreencast: vi.fn(async () => { + events.push("start-screencast"); + }), + stopScreencast: vi.fn(async () => undefined), + surfaceState, + }; + }); + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { + recording: { onFrame, save, startScreencast, stopScreencast }, + }, +})); + +vi.mock("~/rpc/atomRegistry", () => ({ + appAtomRegistry: { set: registrySet }, +})); + +vi.mock("./browserSurfaceStore", () => ({ + useBrowserSurfaceStore: { + getState: () => surfaceState, + }, +})); + +import { + BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, + BrowserRecordingConflictError, + BrowserRecordingOperationError, + BrowserRecordingRequiresVisibleTabError, + startBrowserRecording, + stopBrowserRecording, +} from "./browserRecording"; + +class FakeMediaRecorder { + static isTypeSupported(): boolean { + return true; + } + + state: RecordingState = "inactive"; + private readonly listeners = new Map>(); + + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + start(): void { + this.state = "recording"; + } + + stop(): void { + this.state = "inactive"; + for (const listener of this.listeners.get("stop") ?? []) { + if (typeof listener === "function") listener(new Event("stop")); + else listener.handleEvent(new Event("stop")); + } + } +} + +describe("browser recording", () => { + beforeEach(() => { + events.length = 0; + surfaceState.byTabId = { + "recording-tab": { + visible: true, + rect: { x: 0, y: 0, width: 800, height: 600 }, + content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 }, + }, + }; + vi.clearAllMocks(); + vi.stubGlobal("window", globalThis); + vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); + vi.stubGlobal("document", { + createElement: () => ({ + width: 0, + height: 0, + captureStream: () => ({}), + getContext: () => ({ drawImage: vi.fn() }), + }), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("starts recording for a visible tab", async () => { + await startBrowserRecording("recording-tab"); + + expect(events).toEqual(["start-screencast", "publish:recording-tab"]); + + await stopBrowserRecording("recording-tab"); + }); + + it("rejects recording for a hidden tab before starting screencast", async () => { + surfaceState.byTabId = { + "recording-tab": { + visible: false, + rect: { x: 0, y: 0, width: 800, height: 600 }, + content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 }, + }, + }; + + await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( + BrowserRecordingRequiresVisibleTabError, + ); + + expect(startScreencast).not.toHaveBeenCalled(); + expect(registrySet).not.toHaveBeenCalled(); + }); + + it("does not report success for a second start while the first is still starting", async () => { + let finishStartingScreencast: (() => void) | undefined; + startScreencast.mockImplementationOnce(async () => { + events.push("start-screencast"); + await new Promise((resolve) => { + finishStartingScreencast = resolve; + }); + }); + + const firstStart = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); + + await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( + BrowserRecordingConflictError, + ); + + finishStartingScreencast?.(); + await firstStart; + await stopBrowserRecording("recording-tab"); + }); + + it("does not report success for a start while the recording is stopping", async () => { + let finishStoppingScreencast: (() => void) | undefined; + stopScreencast.mockImplementationOnce(async () => { + await new Promise((resolve) => { + finishStoppingScreencast = resolve; + }); + return undefined; + }); + + await startBrowserRecording("recording-tab"); + const stopPromise = stopBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); + + await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( + BrowserRecordingConflictError, + ); + + finishStoppingScreencast?.(); + await stopPromise; + }); + + it("shares an in-progress stop with duplicate callers", async () => { + let finishStoppingScreencast: (() => void) | undefined; + stopScreencast.mockImplementationOnce(async () => { + await new Promise((resolve) => { + finishStoppingScreencast = resolve; + }); + return undefined; + }); + + await startBrowserRecording("recording-tab"); + const firstStop = stopBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); + const duplicateStop = stopBrowserRecording("recording-tab"); + + finishStoppingScreencast?.(); + const [firstArtifact, duplicateArtifact] = await Promise.all([firstStop, duplicateStop]); + + expect(duplicateArtifact).toEqual(firstArtifact); + expect(stopScreencast).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledOnce(); + }); + + it("stops a screencast that finishes starting after cancellation", async () => { + let finishStartingScreencast: (() => void) | undefined; + startScreencast.mockImplementationOnce(async () => { + events.push("start-screencast"); + await new Promise((resolve) => { + finishStartingScreencast = resolve; + }); + }); + + const startPromise = startBrowserRecording("recording-tab"); + const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( + BrowserRecordingOperationError, + ); + await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); + + const stopPromise = stopBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); + finishStartingScreencast?.(); + + await rejectedStart; + await stopPromise; + expect(stopScreencast).toHaveBeenCalledTimes(2); + expect(events.at(-1)).toBe("clear"); + }); + + it("does not release the recording slot until a cancelled start settles", async () => { + let finishStartingScreencast: (() => void) | undefined; + startScreencast.mockImplementationOnce(async () => { + events.push("start-screencast"); + await new Promise((resolve) => { + finishStartingScreencast = resolve; + }); + }); + + const firstStart = startBrowserRecording("recording-tab"); + const rejectedFirstStart = expect(firstStart).rejects.toBeInstanceOf( + BrowserRecordingOperationError, + ); + await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); + + const stopPromise = stopBrowserRecording("recording-tab"); + const restartAfterStop = stopPromise.then(() => startBrowserRecording("recording-tab")); + await new Promise((resolve) => setTimeout(resolve, 0)); + const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; + + finishStartingScreencast?.(); + await rejectedFirstStart; + await stopPromise; + await restartAfterStop; + await stopBrowserRecording("recording-tab"); + + expect(startCallsBeforeFirstSettled).toBe(1); + }); + + it("fails a stop that waits too long for startup without freeing the recording slot", async () => { + vi.useFakeTimers(); + let finishStartingScreencast: (() => void) | undefined; + startScreencast.mockImplementationOnce(async () => { + events.push("start-screencast"); + await new Promise((resolve) => { + finishStartingScreencast = resolve; + }); + }); + + const startPromise = startBrowserRecording("recording-tab"); + const rejectedStart = expect(startPromise).rejects.toBeInstanceOf( + BrowserRecordingOperationError, + ); + expect(startScreencast).toHaveBeenCalledOnce(); + + const stopPromise = stopBrowserRecording("recording-tab"); + await Promise.resolve(); + await Promise.resolve(); + expect(stopScreencast).toHaveBeenCalledOnce(); + + const rejection = expect(stopPromise).rejects.toMatchObject({ + operation: "wait-startup", + tabId: "recording-tab", + }); + await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); + + await rejection; + expect(save).not.toHaveBeenCalled(); + await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( + BrowserRecordingConflictError, + ); + + finishStartingScreencast?.(); + await rejectedStart; + const cleanupResult = await stopBrowserRecording("recording-tab"); + expect(cleanupResult).toBeNull(); + expect(save).not.toHaveBeenCalled(); + expect(events.at(-1)).toBe("clear"); + }); +}); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index f4580628800..01da1e4a3c1 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -46,6 +46,17 @@ export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorCl } } +export class BrowserRecordingRequiresVisibleTabError extends Schema.TaggedErrorClass()( + "BrowserRecordingRequiresVisibleTabError", + { + tabId: Schema.String, + }, +) { + override get message(): string { + return `Browser recording requires tab ${this.tabId} to be visible.`; + } +} + export class BrowserRecordingOperationError extends Schema.TaggedErrorClass()( "BrowserRecordingOperationError", { @@ -55,6 +66,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass; + }; + interface ActiveRecording { readonly tabId: string; readonly canvas: HTMLCanvasElement; @@ -76,6 +98,8 @@ interface ActiveRecording { readonly chunks: Blob[]; readonly mimeType: string; readonly startedAt: string; + readonly startupSettled: Promise; + lifecycle: BrowserRecordingLifecycle; } const activeBrowserRecordingTabIdAtom = Atom.make(null).pipe( @@ -90,6 +114,8 @@ export function useActiveBrowserRecordingTabId(): string | null { let active: ActiveRecording | null = null; let unsubscribeFrames: (() => void) | null = null; +export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; + export function readActiveBrowserRecordingTabId(): string | null { return active?.tabId ?? null; } @@ -131,17 +157,58 @@ const clearActiveRecording = (recording: ActiveRecording): void => { appAtomRegistry.set(activeBrowserRecordingTabIdAtom, null); }; +const recordingStartupCancelledError = ( + recording: ActiveRecording, + cause: unknown = new Error(`Browser recording startup was cancelled for tab ${recording.tabId}.`), +): BrowserRecordingOperationError => + new BrowserRecordingOperationError({ + operation: "start-screencast", + tabId: recording.tabId, + cause, + }); + +const isRecordingStarting = (recording: ActiveRecording): boolean => + active === recording && recording.lifecycle.phase === "starting"; + +const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise => { + let timeout: ReturnType | null = null; + try { + await Promise.race([ + recording.startupSettled, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`Browser recording startup did not settle for tab ${recording.tabId}.`)); + }, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); + }), + ]); + } catch (cause) { + throw new BrowserRecordingOperationError({ + operation: "wait-startup", + tabId: recording.tabId, + cause, + }); + } finally { + if (timeout !== null) clearTimeout(timeout); + } +}; + +const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => + isBrowserRecordingOperationError(error) && error.operation === "wait-startup"; + export async function startBrowserRecording(tabId: string): Promise { const bridge = previewBridge; if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); if (active) { - if (active.tabId === tabId) return active.startedAt; + if (active.tabId === tabId && active.lifecycle.phase === "recording") { + return active.startedAt; + } throw new BrowserRecordingConflictError({ requestedTabId: tabId, activeTabId: active.tabId, }); } const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; + if (!surface?.visible) throw new BrowserRecordingRequiresVisibleTabError({ tabId }); const recordingSize = surface?.content ?? surface?.rect; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, recordingSize?.width ?? 1280); @@ -171,65 +238,104 @@ export async function startBrowserRecording(tabId: string): Promise { } const startedAt = new Date().toISOString(); const chunks: Blob[] = []; + let settleStartup: (() => void) | undefined; + const startupSettled = new Promise((resolve) => { + settleStartup = resolve; + }); recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); }); - const recording = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; + const recording: ActiveRecording = { + tabId, + canvas, + context, + recorder, + chunks, + mimeType, + startedAt, + startupSettled, + lifecycle: { phase: "starting" }, + }; active = recording; try { - unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); - } catch (cause) { - clearActiveRecording(recording); - throw new BrowserRecordingOperationError({ - operation: "subscribe-frames", - tabId, - cause, - }); - } - try { - recorder.start(1_000); - } catch (cause) { - clearActiveRecording(recording); - throw new BrowserRecordingOperationError({ - operation: "start-media-recorder", - tabId, - cause, - }); - } - try { - await bridge.recording.startScreencast(tabId); - } catch (cause) { - let cleanupCause: unknown; try { - await stopMediaRecorder(recorder); - } catch (error) { - cleanupCause = error; - } finally { + unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); + } catch (cause) { clearActiveRecording(recording); + throw new BrowserRecordingOperationError({ + operation: "subscribe-frames", + tabId, + cause, + }); } - throw new BrowserRecordingOperationError({ - operation: "start-screencast", - tabId, - cause: - cleanupCause === undefined - ? cause - : new AggregateError( - [cause, cleanupCause], - `Browser recording start and cleanup failed for tab ${tabId}.`, - { cause }, - ), - }); + try { + recorder.start(1_000); + } catch (cause) { + clearActiveRecording(recording); + throw new BrowserRecordingOperationError({ + operation: "start-media-recorder", + tabId, + cause, + }); + } + if (!isRecordingStarting(recording)) { + throw recordingStartupCancelledError(recording); + } + try { + await bridge.recording.startScreencast(tabId); + } catch (cause) { + if (!isRecordingStarting(recording)) { + throw recordingStartupCancelledError(recording, cause); + } + let cleanupCause: unknown; + try { + await stopMediaRecorder(recorder); + } catch (error) { + cleanupCause = error; + } finally { + clearActiveRecording(recording); + } + throw new BrowserRecordingOperationError({ + operation: "start-screencast", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser recording start and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); + } + if (!isRecordingStarting(recording)) { + try { + await bridge.recording.stopScreencast(tabId); + } catch (cause) { + throw recordingStartupCancelledError( + recording, + new AggregateError( + [new Error(`Browser recording startup was cancelled for tab ${tabId}.`), cause], + `Browser recording startup cancellation failed for tab ${tabId}.`, + { cause }, + ), + ); + } + throw recordingStartupCancelledError(recording); + } + recording.lifecycle = { phase: "recording" }; + appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); + return startedAt; + } finally { + settleStartup?.(); } - appAtomRegistry.set(activeBrowserRecordingTabIdAtom, tabId); - return startedAt; } -export async function stopBrowserRecording( - tabId: string, -): Promise { - const bridge = previewBridge; - const recording = active; - if (!bridge || !recording || recording.tabId !== tabId) return null; +const finalizeBrowserRecording = async ( + bridge: NonNullable, + recording: ActiveRecording, +): Promise => { + const { tabId } = recording; let result: | { readonly _tag: "Success"; readonly artifact: DesktopPreviewRecordingArtifact } | { readonly _tag: "Failure"; readonly error: unknown }; @@ -243,6 +349,7 @@ export async function stopBrowserRecording( cause, }); } + await waitForRecordingStartupToSettle(recording); try { await stopMediaRecorder(recording.recorder); } catch (cause) { @@ -271,6 +378,14 @@ export async function stopBrowserRecording( result = { _tag: "Failure", error }; } + if (result._tag === "Failure" && isStartupWaitTimeout(result.error)) { + // Do not clear `active` yet. The renderer-side start promise can still + // resolve later, and its cancellation path will call `stopScreencast`. + // Keeping the slot reserved prevents a newer recording for this tab from + // being started and then accidentally stopped by the older late cleanup. + throw result.error; + } + let cleanupError: BrowserRecordingOperationError | undefined; try { await stopMediaRecorder(recording.recorder); @@ -300,4 +415,41 @@ export async function stopBrowserRecording( } if (cleanupError) throw cleanupError; return result.artifact; +}; + +const discardBrowserRecording = async ( + bridge: NonNullable, + recording: ActiveRecording, +): Promise => { + try { + await bridge.recording.stopScreencast(recording.tabId).catch(() => undefined); + await stopMediaRecorder(recording.recorder).catch(() => undefined); + return null; + } finally { + clearActiveRecording(recording); + } +}; + +export function stopBrowserRecording( + tabId: string, +): Promise { + const bridge = previewBridge; + const recording = active; + if (!bridge || !recording || recording.tabId !== tabId) return Promise.resolve(null); + if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; + + const stopPromise = Promise.resolve() + .then(() => finalizeBrowserRecording(bridge, recording)) + .catch((error) => { + if (isStartupWaitTimeout(error) && active === recording) { + const cleanupAfterStartup = recording.startupSettled.then(() => + discardBrowserRecording(bridge, recording), + ); + recording.lifecycle = { phase: "stopping", stopPromise: cleanupAfterStartup }; + void cleanupAfterStartup.catch(() => undefined); + } + throw error; + }); + recording.lifecycle = { phase: "stopping", stopPromise }; + return stopPromise; } diff --git a/apps/web/src/browser/browserRecordingScope.test.ts b/apps/web/src/browser/browserRecordingScope.test.ts index ae91f1a7fdc..6b3adf2219e 100644 --- a/apps/web/src/browser/browserRecordingScope.test.ts +++ b/apps/web/src/browser/browserRecordingScope.test.ts @@ -3,9 +3,14 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; describe("resolveBrowserRecordingStopTarget", () => { - it("stops the active recording even after the requested tab changes", () => { + it("stops the active recording when no explicit tab was requested", () => { expect(resolveBrowserRecordingStopTarget("tab-a")).toBe("tab-a"); expect(resolveBrowserRecordingStopTarget("tab-b")).toBe("tab-b"); expect(resolveBrowserRecordingStopTarget(null)).toBeNull(); }); + + it("only stops an explicitly requested tab when it owns the recording", () => { + expect(resolveBrowserRecordingStopTarget("tab-a", "tab-a")).toBe("tab-a"); + expect(resolveBrowserRecordingStopTarget("tab-a", "tab-b")).toBeNull(); + }); }); diff --git a/apps/web/src/browser/browserRecordingScope.ts b/apps/web/src/browser/browserRecordingScope.ts index 807b5d0c9a6..92f58016aa1 100644 --- a/apps/web/src/browser/browserRecordingScope.ts +++ b/apps/web/src/browser/browserRecordingScope.ts @@ -1,3 +1,7 @@ -export function resolveBrowserRecordingStopTarget(activeTabId: string | null): string | null { - return activeTabId; +export function resolveBrowserRecordingStopTarget( + activeTabId: string | null, + requestedTabId?: string, +): string | null { + if (activeTabId === null) return null; + return requestedTabId === undefined || requestedTabId === activeTabId ? activeTabId : null; } diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 1445303b8f7..ecfce8cb432 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it } from "vite-plus/test"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { + acquireBrowserSurface, + resolveBrowserSurfacePanelRect, + useBrowserSurfaceStore, +} from "./browserSurfaceStore"; describe("browserSurfaceStore", () => { + beforeEach(() => { + useBrowserSurfaceStore.setState({ byTabId: {} }); + }); + it("tracks content dimensions for a browser that has never been visible", () => { const tabId = "hidden-browser-surface-content-test"; useBrowserSurfaceStore.getState().presentContent(tabId, { @@ -28,11 +36,43 @@ describe("browserSurfaceStore", () => { expect( resolveBrowserSurfacePanelRect( { - hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1 }, - active: { rect: liveRect, visible: true, content: null, updatedAt: 2 }, + hidden: { rect: staleRect, visible: false, content: null, updatedAt: 1, owner: null }, + active: { rect: liveRect, visible: true, content: null, updatedAt: 2, owner: null }, }, "hidden", ), ).toEqual(liveRect); }); + + it("ignores updates and releases from a stale surface lease", () => { + const tabId = "leased-browser-surface"; + const staleRect = { x: 0, y: 0, width: 500, height: 700 }; + const liveRect = { x: 10, y: 20, width: 900, height: 640 }; + const staleLease = acquireBrowserSurface(tabId); + staleLease.present(staleRect, true); + + const liveLease = acquireBrowserSurface(tabId); + liveLease.present(liveRect, true); + staleLease.present(staleRect, true); + staleLease.release(); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + rect: liveRect, + visible: true, + }); + }); + + it("hides a surface when its current lease is released", () => { + const tabId = "released-browser-surface"; + const lease = acquireBrowserSurface(tabId); + lease.present({ x: 10, y: 20, width: 900, height: 640 }, true); + + lease.release(); + lease.present({ x: 0, y: 0, width: 1, height: 1 }, true); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + visible: false, + owner: null, + }); + }); }); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 79095f0bdbb..58012a11a30 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -12,6 +12,7 @@ export interface BrowserSurfacePresentation { readonly visible: boolean; readonly content: BrowserSurfaceContentPresentation | null; readonly updatedAt: number; + readonly owner: symbol | null; } export interface BrowserSurfaceContentPresentation { @@ -26,9 +27,20 @@ export interface BrowserSurfaceContentPresentation { interface BrowserSurfaceStoreState { readonly byTabId: Record; - readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; + readonly claim: (tabId: string, owner: symbol) => void; + readonly present: ( + tabId: string, + owner: symbol, + rect: BrowserSurfaceRect, + visible: boolean, + ) => void; readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; - readonly hide: (tabId: string) => void; + readonly release: (tabId: string, owner: symbol) => void; +} + +export interface BrowserSurfaceLease { + readonly present: (rect: BrowserSurfaceRect, visible: boolean) => void; + readonly release: () => void; } export function resolveBrowserSurfacePanelRect( @@ -60,14 +72,32 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): export const useBrowserSurfaceStore = create()((set) => ({ byTabId: {}, - present: (tabId, rect, visible) => + claim: (tabId, owner) => + set((state) => { + const current = state.byTabId[tabId]; + if (current?.owner === owner) return state; + return { + byTabId: { + ...state.byTabId, + [tabId]: { + rect: current?.rect ?? null, + visible: false, + content: current?.content ?? null, + updatedAt: Date.now(), + owner, + }, + }, + }; + }), + present: (tabId, owner, rect, visible) => set((state) => { const current = state.byTabId[tabId]; + if (current?.owner !== owner) return state; if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; return { byTabId: { ...state.byTabId, - [tabId]: { rect, visible, content: current?.content ?? null, updatedAt: Date.now() }, + [tabId]: { ...current, rect, visible, updatedAt: Date.now() }, }, }; }), @@ -83,6 +113,7 @@ export const useBrowserSurfaceStore = create()((set) = visible: false, content, updatedAt: Date.now(), + owner: null, }, }, }; @@ -107,15 +138,33 @@ export const useBrowserSurfaceStore = create()((set) = }, }; }), - hide: (tabId) => + release: (tabId, owner) => set((state) => { const current = state.byTabId[tabId]; - if (!current || !current.visible) return state; + if (current?.owner !== owner) return state; return { byTabId: { ...state.byTabId, - [tabId]: { ...current, visible: false, updatedAt: Date.now() }, + [tabId]: { ...current, visible: false, updatedAt: Date.now(), owner: null }, }, }; }), })); + +export function acquireBrowserSurface(tabId: string): BrowserSurfaceLease { + const owner = Symbol(`browser-surface:${tabId}`); + let released = false; + useBrowserSurfaceStore.getState().claim(tabId, owner); + + return { + present: (rect, visible) => { + if (released) return; + useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible); + }, + release: () => { + if (released) return; + released = true; + useBrowserSurfaceStore.getState().release(tabId, owner); + }, + }; +} diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts new file mode 100644 index 00000000000..826684bb06f --- /dev/null +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + HIDDEN_BROWSER_WEBVIEW_OFFSET, + resolveHostedBrowserWebviewWrapperStyle, +} from "./hostedBrowserWebviewStyle"; + +describe("resolveHostedBrowserWebviewWrapperStyle", () => { + it("places an active webview on its presented surface", () => { + expect( + resolveHostedBrowserWebviewWrapperStyle({ + active: true, + rect: { x: 12, y: 34, width: 800, height: 600 }, + hiddenSize: { width: 1280, height: 800 }, + }), + ).toEqual({ + left: 12, + top: 34, + width: 800, + height: 600, + zIndex: 30, + pointerEvents: "auto", + }); + }); + + it("keeps an inactive webview paintable while moving it offscreen", () => { + const style = resolveHostedBrowserWebviewWrapperStyle({ + active: false, + rect: { x: 12, y: 34, width: 800, height: 600 }, + hiddenSize: { width: 393, height: 852 }, + }); + + expect(style).toEqual({ + left: HIDDEN_BROWSER_WEBVIEW_OFFSET, + top: HIDDEN_BROWSER_WEBVIEW_OFFSET, + width: 393, + height: 852, + zIndex: -1, + pointerEvents: "none", + visibility: "visible", + }); + }); +}); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts new file mode 100644 index 00000000000..4dade986e1f --- /dev/null +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -0,0 +1,49 @@ +import type { BrowserSurfaceRect } from "./browserSurfaceStore"; + +export interface HostedBrowserWebviewSize { + readonly width: number; + readonly height: number; +} + +export interface HostedBrowserWebviewWrapperStyle { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; + readonly zIndex: number; + readonly pointerEvents: "auto" | "none"; + readonly visibility?: "visible"; +} + +export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; + +export function resolveHostedBrowserWebviewWrapperStyle(input: { + readonly active: boolean; + readonly rect: BrowserSurfaceRect | null; + readonly hiddenSize: HostedBrowserWebviewSize; +}): HostedBrowserWebviewWrapperStyle { + const { active, hiddenSize, rect } = input; + if (active && rect) { + return { + left: rect.x, + top: rect.y, + width: rect.width, + height: rect.height, + zIndex: 30, + pointerEvents: "auto", + }; + } + + return { + left: HIDDEN_BROWSER_WEBVIEW_OFFSET, + top: HIDDEN_BROWSER_WEBVIEW_OFFSET, + width: hiddenSize.width, + height: hiddenSize.height, + zIndex: -1, + pointerEvents: "none", + // Keep the guest CSS-visible even while physically offscreen. Electron + // webviews can keep metadata/status alive under `visibility:hidden` while + // CDP Runtime/Input commands stall, which breaks offscreen automation. + visibility: "visible", + }; +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 94d8c13a3cb..d637611a786 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -56,6 +56,7 @@ import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationR import { createPreviewAutomationClientId } from "./previewAutomationClientId"; import { needsPreviewAutomationSessionSync, + resolvePreviewAutomationOpenTab, resolvePreviewAutomationTarget, } from "./previewAutomationTarget"; import { isPreviewViewportReady } from "./previewViewportReadiness"; @@ -230,6 +231,16 @@ const currentStatus = async ( }; }; +const raiseAtomCommandFailure = (result: Parameters[0]): never => { + throw squashAtomCommandFailure(result); +}; + +const raisePreviewAutomationHostError = ( + error: PreviewAutomationRecordingNotActiveError, +): never => { + throw error; +}; + export function PreviewAutomationHosts() { const { environments } = useEnvironments(); if (!isElectron || !previewBridge?.automation) return null; @@ -304,7 +315,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) registry.refresh(previewEnvironment.list(listTarget)); const result = await listPreviews(listTarget); if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); + return raiseAtomCommandFailure(result); } reconcilePreviewServerSessions(threadRef, result.value.sessions); state = readThreadPreviewState(threadRef); @@ -332,8 +343,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return await currentStatus(threadRef, tabId); case "open": { const input = request.input as PreviewAutomationOpenInput; - let activeTabId = - (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + let activeTabId = resolvePreviewAutomationOpenTab( + state, + request.tabId, + input.reuseExistingTab ?? true, + ); let activeSnapshot = activeTabId ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined) : undefined; @@ -348,7 +362,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }, }); if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); + return raiseAtomCommandFailure(result); } const snapshot = result.value; applyPreviewServerSnapshot(threadRef, snapshot); @@ -416,7 +430,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }, }); if (result._tag === "Failure") { - throw squashAtomCommandFailure(result); + return raiseAtomCommandFailure(result); } updatePreviewServerSnapshot(threadRef, result.value); const viewport = await waitForRenderedViewport( @@ -492,15 +506,20 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } case "recordingStop": { const recordingTabId = readActiveBrowserRecordingTabId(); - const stopTabId = resolveBrowserRecordingStopTarget(recordingTabId); + const stopTabId = resolveBrowserRecordingStopTarget( + recordingTabId, + request.tabIdExplicit ? request.tabId : undefined, + ); const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; if (!artifact) { - throw new PreviewAutomationRecordingNotActiveError({ - requestId: request.requestId, - environmentId, - threadId: request.threadId, - tabId, - }); + return raisePreviewAutomationHostError( + new PreviewAutomationRecordingNotActiveError({ + requestId: request.requestId, + environmentId, + threadId: request.threadId, + tabId, + }), + ); } return artifact; } diff --git a/apps/web/src/components/preview/previewAutomationTarget.test.ts b/apps/web/src/components/preview/previewAutomationTarget.test.ts index 379e3519057..11a70951b8f 100644 --- a/apps/web/src/components/preview/previewAutomationTarget.test.ts +++ b/apps/web/src/components/preview/previewAutomationTarget.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { needsPreviewAutomationSessionSync, + resolvePreviewAutomationOpenTab, resolvePreviewAutomationTarget, } from "./previewAutomationTarget"; @@ -42,4 +43,17 @@ describe("preview automation target selection", () => { ), ).toEqual({ tabId: null, snapshot: null }); }); + + it("reuses the provider session's pinned tab instead of the mutable UI tab", () => { + const uiActive = snapshot("tab-ui-active"); + const agentTab = snapshot("tab-opened-by-agent"); + const state = { + snapshot: uiActive, + sessions: { [uiActive.tabId]: uiActive, [agentTab.tabId]: agentTab }, + }; + + expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, true)).toBe(agentTab.tabId); + expect(resolvePreviewAutomationOpenTab(state, undefined, true)).toBe(uiActive.tabId); + expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, false)).toBeNull(); + }); }); diff --git a/apps/web/src/components/preview/previewAutomationTarget.ts b/apps/web/src/components/preview/previewAutomationTarget.ts index 1dc9f17f78a..f1858f87d18 100644 --- a/apps/web/src/components/preview/previewAutomationTarget.ts +++ b/apps/web/src/components/preview/previewAutomationTarget.ts @@ -23,3 +23,15 @@ export function resolvePreviewAutomationTarget( const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot; return { tabId: snapshot?.tabId ?? null, snapshot }; } + +export function resolvePreviewAutomationOpenTab( + state: PreviewAutomationSessionIndex, + requestedTabId: string | undefined, + reuseExistingTab: boolean, +): string | null { + if (!reuseExistingTab) return null; + if (requestedTabId !== undefined) { + return state.sessions[requestedTabId]?.tabId ?? null; + } + return state.snapshot?.tabId ?? null; +} diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 80044793873..e2e8336cda9 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -11,6 +11,7 @@ import { import { PreviewAutomationHost, PreviewAutomationError, + PreviewAutomationOpenInput, PreviewAutomationResizeInput, PreviewAutomationResizeResult, PreviewAutomationStatus, @@ -22,6 +23,7 @@ const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting); const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput); +const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput); const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult); const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); @@ -128,6 +130,20 @@ describe("PreviewAutomationResizeInput", () => { }); }); +describe("preview automation tab targeting", () => { + it("accepts an explicit tab and rejects contradictory open behavior", () => { + expect(decodeResizeInput({ tabId: "tab-app", mode: "fill" })).toMatchObject({ + tabId: "tab-app", + mode: "fill", + }); + expect(decodeOpenInput({ tabId: "tab-app", reuseExistingTab: true })).toMatchObject({ + tabId: "tab-app", + reuseExistingTab: true, + }); + expect(() => decodeOpenInput({ tabId: "tab-app", reuseExistingTab: false })).toThrow(); + }); +}); + describe("PreviewAutomationHost", () => { it("accepts legacy hosts and current operation advertisements", () => { expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({ diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 47dc7fc249e..6431dd0dcfd 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -50,6 +50,21 @@ export const PREVIEW_AUTOMATION_OPERATIONS = [ export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; +const PreviewAutomationTabTargetFields = { + tabId: Schema.optional( + PreviewTabId.annotate({ + description: + "Exact collaborative browser tab to target. Omit to use this agent session's current tab.", + }), + ).annotate({ + description: + "Exact collaborative browser tab to target. Omit to use this agent session's current tab.", + }), +}; + +export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields); +export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type; + export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean, visible: Schema.Boolean, @@ -65,6 +80,7 @@ export const PreviewAutomationStatus = Schema.Struct({ export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; export const PreviewAutomationOpenInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, url: Schema.optional(BoundedUrl).annotate({ description: "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.", @@ -77,13 +93,21 @@ export const PreviewAutomationOpenInput = Schema.Struct({ reuseExistingTab: Schema.optional( Schema.Boolean.annotate({ description: - "Reuse the thread's active browser tab when available. Defaults to true; set false to create a new tab.", + "Reuse tabId when supplied, otherwise this agent session's current tab. Defaults to true; set false to create a new tab.", }), ), -}).annotate({ - description: - "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.", -}); +}) + .check( + Schema.makeFilter( + (input) => + !(input.tabId !== undefined && input.reuseExistingTab === false) || + "tabId cannot be combined with reuseExistingTab=false.", + ), + ) + .annotate({ + description: + "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.", + }); export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; export const BrowserNavigationTarget = Schema.Union([ @@ -117,6 +141,7 @@ export const BrowserNavigationTarget = Schema.Union([ export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type; export const PreviewAutomationNavigateInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, url: Schema.optional(BoundedUrl).annotate({ description: "Website URL, for example https://t3.chat. Use this for public pages and directly reachable URLs.", @@ -155,6 +180,7 @@ export const PreviewAutomationNavigateInput = Schema.Struct({ export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; export const PreviewAutomationResizeInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, mode: Schema.Literals(["fill", "freeform", "preset"]).annotate({ description: "Viewport mode: fill follows the preview panel, freeform uses exact independently resizable dimensions, and preset uses a named device size.", @@ -239,6 +265,7 @@ const LegacySelector = TrimmedNonEmptyString.annotate({ }); export const PreviewAutomationClickInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, selector: Schema.optional(LegacySelector).annotate({ description: "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", @@ -277,6 +304,7 @@ export const PreviewAutomationClickInput = Schema.Struct({ export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; export const PreviewAutomationTypeInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, text: Schema.String.annotate({ description: "Literal text to insert." }), selector: Schema.optional(LegacySelector).annotate({ description: "Legacy CSS selector for the input. Prefer locator.", @@ -306,6 +334,7 @@ export const PreviewAutomationTypeInput = Schema.Struct({ export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; export const PreviewAutomationPressInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, key: Schema.String.check(Schema.isTrimmed()) .check( Schema.isNonEmpty({ @@ -326,6 +355,7 @@ export const PreviewAutomationPressInput = Schema.Struct({ export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; export const PreviewAutomationScrollInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, deltaX: Schema.optional( Schema.Finite.annotate({ description: "Horizontal scroll delta in CSS pixels. Positive scrolls right. Defaults to 0.", @@ -360,6 +390,7 @@ export const PreviewAutomationScrollInput = Schema.Struct({ export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; export const PreviewAutomationEvaluateInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, expression: Schema.String.check(Schema.isTrimmed()) .check( Schema.isNonEmpty({ @@ -388,6 +419,7 @@ export const PreviewAutomationEvaluateInput = Schema.Struct({ export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; export const PreviewAutomationWaitForInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, selector: Schema.optional(LegacySelector).annotate({ description: "Legacy CSS selector that must match an element. Prefer locator.", }), @@ -537,6 +569,7 @@ export const PreviewAutomationRequest = Schema.Struct({ requestId: TrimmedNonEmptyString, threadId: ThreadId, tabId: Schema.optional(PreviewTabId), + tabIdExplicit: Schema.optional(Schema.Boolean), operation: PreviewAutomationOperation, input: Schema.Unknown, timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), From a9b1190a11276927caf573d92c33c5346fc4c076 Mon Sep 17 00:00:00 2001 From: Jgratton24 <53677915+Jgratton24@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:53:34 -0400 Subject: [PATCH 26/28] Desktop: parallel WSL + Windows backends with mode picker (#2751) --- .github/workflows/release.yml | 69 +- apps/desktop/src/app/DesktopApp.ts | 37 +- .../src/app/DesktopBackendOutputLog.test.ts | 122 -- .../src/app/DesktopBackendOutputLog.ts | 385 ------ .../src/app/DesktopObservability.test.ts | 5 +- apps/desktop/src/app/DesktopObservability.ts | 384 +++++- .../DesktopBackendConfiguration.test.ts | 570 ++++++++- .../backend/DesktopBackendConfiguration.ts | 598 +++++++++- .../src/backend/DesktopBackendManager.test.ts | 1036 ++++++++--------- .../src/backend/DesktopBackendManager.ts | 665 ++++++----- .../src/backend/DesktopBackendPool.test.ts | 137 +++ .../desktop/src/backend/DesktopBackendPool.ts | 470 ++++++++ .../DesktopLocalEnvironmentAuth.test.ts | 28 +- .../backend/DesktopLocalEnvironmentAuth.ts | 15 +- .../src/backend/DesktopServerExposure.test.ts | 5 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 10 +- apps/desktop/src/ipc/channels.ts | 6 +- apps/desktop/src/ipc/methods/window.test.ts | 128 ++ apps/desktop/src/ipc/methods/window.ts | 144 ++- apps/desktop/src/ipc/methods/wsl.test.ts | 274 +++++ apps/desktop/src/ipc/methods/wsl.ts | 122 ++ apps/desktop/src/main.ts | 20 +- apps/desktop/src/preload.ts | 15 +- .../src/settings/DesktopAppSettings.test.ts | 112 ++ .../src/settings/DesktopAppSettings.ts | 127 ++ .../src/updates/DesktopUpdates.test.ts | 15 +- apps/desktop/src/updates/DesktopUpdates.ts | 18 +- .../src/window/DesktopApplicationMenu.test.ts | 4 +- apps/desktop/src/window/DesktopWindow.test.ts | 207 +++- apps/desktop/src/window/DesktopWindow.ts | 161 ++- .../desktop/src/wsl/DesktopWslBackend.test.ts | 199 ++++ apps/desktop/src/wsl/DesktopWslBackend.ts | 268 +++++ .../src/wsl/DesktopWslEnvironment.test.ts | 237 ++++ apps/desktop/src/wsl/DesktopWslEnvironment.ts | 849 ++++++++++++++ apps/desktop/src/wsl/wslPathParsing.test.ts | 206 ++++ apps/desktop/src/wsl/wslPathParsing.ts | 129 ++ .../server/src/auth/PairingGrantStore.test.ts | 16 +- apps/server/src/auth/PairingGrantStore.ts | 18 +- apps/server/src/bootstrap.ts | 5 +- .../providerMaintenanceRunner.test.ts | 80 ++ .../src/provider/providerMaintenanceRunner.ts | 9 +- apps/server/src/server.test.ts | 11 +- apps/web/src/authBootstrap.test.ts | 58 +- apps/web/src/components/CommandPalette.tsx | 195 +++- .../ProviderUpdateEnvironmentRows.test.tsx | 226 ++++ .../ProviderUpdateEnvironmentRows.tsx | 397 +++++++ ...erUpdateLaunchNotification.environments.ts | 97 ++ ...iderUpdateLaunchNotification.logic.test.ts | 449 ++++++- .../ProviderUpdateLaunchNotification.logic.ts | 293 +++++ .../ProviderUpdateLaunchNotification.tsx | 392 +++---- .../ProviderUpdatePrimaryNotification.tsx | 331 ++++++ apps/web/src/components/Sidebar.tsx | 127 +- .../ConnectionsSettings.logic.test.ts | 75 ++ .../settings/ConnectionsSettings.logic.ts | 21 + .../settings/ConnectionsSettings.tsx | 508 +++++++- apps/web/src/connection/desktopLocal.test.ts | 107 ++ apps/web/src/connection/desktopLocal.ts | 104 ++ apps/web/src/connection/platform.test.ts | 139 ++- apps/web/src/connection/platform.ts | 320 ++++- .../connection/useDesktopLocalBootstraps.ts | 28 + apps/web/src/environments/primary/auth.ts | 22 +- .../environments/primary/bootstrap.test.ts | 28 +- apps/web/src/environments/primary/index.ts | 1 + apps/web/src/environments/primary/target.ts | 8 +- apps/web/src/sidebarProjectGrouping.ts | 27 +- apps/web/src/state/desktopWslState.test.ts | 97 ++ apps/web/src/state/desktopWslState.ts | 60 + apps/web/src/wslPaths.test.ts | 297 +++++ apps/web/src/wslPaths.ts | 159 +++ .../client-runtime/src/connection/catalog.ts | 15 + .../client-runtime/src/connection/layer.ts | 2 +- .../client-runtime/src/connection/registry.ts | 165 ++- .../client-runtime/src/platform/source.ts | 8 +- packages/contracts/src/desktopBootstrap.ts | 5 +- packages/contracts/src/ipc.test.ts | 38 + packages/contracts/src/ipc.ts | 74 +- packages/shared/package.json | 4 + packages/shared/src/httpReadiness.ts | 168 +++ packages/ssh/src/tunnel.ts | 180 +-- pnpm-lock.yaml | 127 +- pnpm-workspace.yaml | 14 + scripts/build-desktop-artifact.test.ts | 37 +- scripts/build-desktop-artifact.ts | 176 ++- 83 files changed, 11424 insertions(+), 2071 deletions(-) delete mode 100644 apps/desktop/src/app/DesktopBackendOutputLog.test.ts delete mode 100644 apps/desktop/src/app/DesktopBackendOutputLog.ts create mode 100644 apps/desktop/src/backend/DesktopBackendPool.test.ts create mode 100644 apps/desktop/src/backend/DesktopBackendPool.ts create mode 100644 apps/desktop/src/ipc/methods/window.test.ts create mode 100644 apps/desktop/src/ipc/methods/wsl.test.ts create mode 100644 apps/desktop/src/ipc/methods/wsl.ts create mode 100644 apps/desktop/src/wsl/DesktopWslBackend.test.ts create mode 100644 apps/desktop/src/wsl/DesktopWslBackend.ts create mode 100644 apps/desktop/src/wsl/DesktopWslEnvironment.test.ts create mode 100644 apps/desktop/src/wsl/DesktopWslEnvironment.ts create mode 100644 apps/desktop/src/wsl/wslPathParsing.test.ts create mode 100644 apps/desktop/src/wsl/wslPathParsing.ts create mode 100644 apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx create mode 100644 apps/web/src/components/ProviderUpdateEnvironmentRows.tsx create mode 100644 apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts create mode 100644 apps/web/src/components/ProviderUpdatePrimaryNotification.tsx create mode 100644 apps/web/src/components/settings/ConnectionsSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/ConnectionsSettings.logic.ts create mode 100644 apps/web/src/connection/desktopLocal.test.ts create mode 100644 apps/web/src/connection/desktopLocal.ts create mode 100644 apps/web/src/connection/useDesktopLocalBootstraps.ts create mode 100644 apps/web/src/state/desktopWslState.test.ts create mode 100644 apps/web/src/state/desktopWslState.ts create mode 100644 apps/web/src/wslPaths.test.ts create mode 100644 apps/web/src/wslPaths.ts create mode 100644 packages/contracts/src/ipc.test.ts create mode 100644 packages/shared/src/httpReadiness.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 168c000c38b..12ac0370c3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,10 +252,64 @@ jobs: echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" + # node-pty publishes no Linux prebuilt and the WSL backend runs under the + # distro's own (Linux) Node, which can't load the Windows/Electron binary. We + # build the Linux pty.node here, on Linux, and hand it to the Windows packaging + # job — the Windows artifact then ships a ready WSL backend binary with no + # cross-compiling and no first-launch compiler/node-gyp/network on the user's + # machine. node-pty is N-API, so one binary works across all WSL Node versions. + build_wsl_node_pty: + name: Build WSL node-pty (linux-x64) + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + fetch-depth: 0 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Build node-pty linux-x64 prebuild + shell: bash + run: | + set -euo pipefail + # Resolve node-pty from apps/server (where it's a dependency) and build + # its native binary from source for Linux. node-addon-api resolves from + # node-pty's own dependency tree, so node-gyp has everything it needs. + pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" + pty_dir="$(dirname "$pty_pkg")" + ( cd "$pty_dir" && npx --yes node-gyp rebuild ) + mkdir -p wsl-prebuild + cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node + file wsl-prebuild/pty.node + + - name: Upload node-pty linux-x64 prebuild + uses: actions/upload-artifact@v7 + with: + name: wsl-node-pty-x64 + path: wsl-prebuild/pty.node + if-no-files-found: error + build: name: Build ${{ matrix.label }} - needs: [preflight, relay_public_config] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} + # build_wsl_node_pty stays in `needs` so it runs first and its artifact is + # available to download, but only the Windows matrix entry consumes it. We + # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring + # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux + # builds. `!cancelled()` (not `!failure()`) lets the job run even when + # build_wsl_node_pty failed; the Windows-only download step below then fails + # that single platform if the prebuild is missing. + needs: [preflight, relay_public_config, build_wsl_node_pty] + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 env: @@ -323,6 +377,13 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + - name: Download WSL node-pty prebuild + if: matrix.platform == 'win' + uses: actions/download-artifact@v7 + with: + name: wsl-node-pty-x64 + path: wsl-prebuild + - name: Install Spectre-mitigated MSVC libs if: matrix.platform == 'win' shell: pwsh @@ -476,6 +537,10 @@ jobs: echo "macOS signing disabled (missing one or more Apple signing secrets)." fi elif [[ "${{ matrix.platform }}" == "win" ]]; then + # Bundle the Linux node-pty binary built by the build_wsl_node_pty job + # so the packaged WSL backend ships a ready binary (no first-launch + # compile). Required for a working WSL backend on Windows. + args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") if has_all \ "$AZURE_TENANT_ID" \ "$AZURE_CLIENT_ID" \ diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 214fd383e04..fd86c5f05d2 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -13,7 +13,8 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; -import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; @@ -23,6 +24,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -135,11 +137,14 @@ const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); const bootstrap = Effect.gen(function* () { - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* logBootstrapInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { @@ -193,8 +198,20 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - yield* backendManager.start; + // In wsl-only mode the renderer is served by the WSL backend, which can be + // slow to cold-boot — show a "Connecting to WSL" splash immediately so the + // app feels responsive instead of presenting no window until WSL is ready. + // (Dual mode opens fast off the Windows primary, so no splash there.) + if (settings.wslOnly === true && settings.wslBackendEnabled === true) { + yield* desktopWindow.showConnectingSplash; + } + yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + // Bring up the WSL backend if the user previously enabled it. The + // primary is already starting; reconcile fires off the WSL register + // in parallel rather than blocking primary readiness on a possibly + // slow first wsl.exe spawn. + yield* Effect.forkScoped(wslBackend.reconcile); } }).pipe(Effect.withSpan("desktop.bootstrap")); @@ -241,10 +258,20 @@ const scopedProgram = Effect.scoped( yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); const shutdown = yield* DesktopShutdown.DesktopShutdown; - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; yield* Effect.addFinalizer(() => - backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), + Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + // Stop every backend in the pool, not just the primary. The + // electronApp.quit() path can race ahead of the layer-scope + // cascade, so leaving the WSL instance for its parent scope + // finalizer means it gets hard-killed by the OS instead of + // receiving SIGTERM + grace. Stops run concurrently. + const instances = yield* pool.list; + yield* Effect.forEach(instances, (instance) => instance.stop(), { + concurrency: "unbounded", + }); + }).pipe(Effect.ensuring(shutdown.markComplete)), ); yield* startup; diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.test.ts b/apps/desktop/src/app/DesktopBackendOutputLog.test.ts deleted file mode 100644 index 18bba9486cb..00000000000 --- a/apps/desktop/src/app/DesktopBackendOutputLog.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; - -import * as DesktopBackendOutputLog from "./DesktopBackendOutputLog.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -const LOG_FILE_PATH = "/Users/alice/.t3/userdata/logs/server-child.log"; - -const environmentLayer = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/dist-electron", - homeDirectory: "/Users/alice", - platform: "darwin", - processArch: "arm64", - appVersion: "1.2.3", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", - isPackaged: true, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", - runningUnderArm64Translation: false, -}).pipe(Layer.provide(Layer.merge(Path.layer, DesktopConfig.layerTest({})))); - -const withOutputLog = ( - effect: Effect.Effect, - fileSystemLayer: Layer.Layer, - messages: Array>, -) => { - const logger = Logger.make(({ message }) => { - messages.push(Array.isArray(message) ? message : [message]); - }); - const outputLogLayer = DesktopBackendOutputLog.layer.pipe( - Layer.provide(Layer.mergeAll(fileSystemLayer, Path.layer, environmentLayer)), - Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })), - ); - return effect.pipe(Effect.provide(outputLogLayer)); -}; - -const loggedError = (messages: ReadonlyArray>): unknown => - messages.flat().find((value) => typeof value === "object" && value !== null && "error" in value) - ?.error; - -describe("DesktopBackendOutputLog", () => { - it.effect("logs setup failures with the log path and exact cause", () => { - const messages: Array> = []; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "makeDirectory", - pathOrDescriptor: "/Users/alice/.t3/userdata/logs", - description: "private setup diagnostic", - }); - const fileSystemLayer = FileSystem.layerNoop({ - makeDirectory: () => Effect.fail(cause), - }); - - return withOutputLog( - Effect.gen(function* () { - const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; - yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); - - const error = loggedError(messages); - assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogSetupError); - assert.equal(error.logFilePath, LOG_FILE_PATH); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Failed to initialize the desktop backend output log at ${LOG_FILE_PATH}.`, - ); - assert.notInclude(error.message, "private setup diagnostic"); - }), - fileSystemLayer, - messages, - ); - }); - - it.effect("logs record write failures with the operation and exact cause", () => { - const messages: Array> = []; - const missingCause = PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "stat", - pathOrDescriptor: LOG_FILE_PATH, - }); - const writeCause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "writeFile", - pathOrDescriptor: LOG_FILE_PATH, - description: "private write diagnostic", - }); - const fileSystemLayer = FileSystem.layerNoop({ - makeDirectory: () => Effect.void, - stat: () => Effect.fail(missingCause), - readDirectory: () => Effect.succeed([]), - writeFile: () => Effect.fail(writeCause), - }); - - return withOutputLog( - Effect.gen(function* () { - const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; - yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); - - const error = loggedError(messages); - assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogWriteError); - assert.equal(error.operation, "write-record"); - assert.equal(error.logFilePath, LOG_FILE_PATH); - assert.strictEqual(error.cause, writeCause); - assert.equal( - error.message, - `Desktop backend output log operation "write-record" failed at ${LOG_FILE_PATH}.`, - ); - assert.notInclude(error.message, "private write diagnostic"); - }), - fileSystemLayer, - messages, - ); - }); -}); diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.ts b/apps/desktop/src/app/DesktopBackendOutputLog.ts deleted file mode 100644 index cad83229deb..00000000000 --- a/apps/desktop/src/app/DesktopBackendOutputLog.ts +++ /dev/null @@ -1,385 +0,0 @@ -import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as References from "effect/References"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; - -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; -export const DESKTOP_LOG_FILE_MAX_FILES = 10; - -const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; - -interface RotatingLogFileWriter { - readonly filePath: string; - readonly writeBytes: ( - chunk: Uint8Array, - ) => Effect.Effect; - readonly writeText: ( - chunk: string, - ) => Effect.Effect; -} - -class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass()( - "DesktopLogFileWriterConfigurationError", - { - option: Schema.Literals(["maxBytes", "maxFiles"]), - value: Schema.Number, - }, -) { - override get message(): string { - return `${this.option} must be >= 1 (received ${this.value})`; - } -} - -class DesktopLogFileWriterRecoveryError extends Schema.TaggedErrorClass()( - "DesktopLogFileWriterRecoveryError", - { - logFilePath: Schema.String, - cause: Schema.Defect(), - recoveryCause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to refresh desktop backend output log size after a write failure at ${this.logFilePath}.`; - } -} - -export class DesktopBackendOutputLogSetupError extends Schema.TaggedErrorClass()( - "DesktopBackendOutputLogSetupError", - { - logFilePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to initialize the desktop backend output log at ${this.logFilePath}.`; - } -} - -export class DesktopBackendOutputLogWriteError extends Schema.TaggedErrorClass()( - "DesktopBackendOutputLogWriteError", - { - operation: Schema.Literals(["encode-record", "write-record"]), - logFilePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Desktop backend output log operation "${this.operation}" failed at ${this.logFilePath}.`; - } -} - -export class DesktopBackendConsoleWriteError extends Schema.TaggedErrorClass()( - "DesktopBackendConsoleWriteError", - { - streamName: Schema.Literals(["stdout", "stderr"]), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to mirror desktop backend output to ${this.streamName}.`; - } -} - -export class DesktopBackendOutputLog extends Context.Service< - DesktopBackendOutputLog, - { - readonly writeSessionBoundary: (input: { - readonly phase: "START" | "END"; - readonly details: string; - }) => Effect.Effect; - readonly writeOutputChunk: ( - streamName: "stdout" | "stderr", - chunk: Uint8Array, - ) => Effect.Effect; - } ->()("@t3tools/desktop/app/DesktopBackendOutputLog") {} - -type DesktopLogFileWriterError = - | DesktopLogFileWriterConfigurationError - | PlatformError.PlatformError; - -const DesktopBackendChildLogRecord = Schema.Struct({ - message: Schema.String, - level: Schema.Literals(["INFO", "ERROR"]), - timestamp: Schema.String, - annotations: Schema.Record(Schema.String, Schema.Unknown), - spans: Schema.Record(Schema.String, Schema.Unknown), - fiberId: Schema.String, -}); - -const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( - Schema.fromJsonString(DesktopBackendChildLogRecord), -); - -const DesktopBackendOutputLogNoop: DesktopBackendOutputLog["Service"] = { - writeSessionBoundary: () => Effect.void, - writeOutputChunk: () => Effect.void, -}; - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); - -const currentDesktopRunId = Effect.gen(function* () { - const annotations = yield* References.CurrentLogAnnotations; - const runId = annotations.runId; - return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; -}); - -const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); - -const refreshFileSize = ( - fileSystem: FileSystem.FileSystem, - filePath: string, -): Effect.Effect => - fileSystem.stat(filePath).pipe( - Effect.map((stat) => Number(stat.size)), - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed(0) : Effect.fail(error), - }), - ); - -const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { - readonly filePath: string; - readonly maxBytes?: number; - readonly maxFiles?: number; -}): Effect.fn.Return< - RotatingLogFileWriter, - DesktopLogFileWriterError, - FileSystem.FileSystem | Path.Path -> { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; - const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; - const directory = path.dirname(input.filePath); - const baseName = path.basename(input.filePath); - - if (maxBytes < 1) { - return yield* new DesktopLogFileWriterConfigurationError({ - option: "maxBytes", - value: maxBytes, - }); - } - if (maxFiles < 1) { - return yield* new DesktopLogFileWriterConfigurationError({ - option: "maxFiles", - value: maxFiles, - }); - } - - yield* fileSystem.makeDirectory(directory, { recursive: true }); - - const withSuffix = (index: number) => `${input.filePath}.${index}`; - const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); - const mutex = yield* Semaphore.make(1); - - const recoverCurrentSize = ( - cause: PlatformError.PlatformError, - ): Effect.Effect => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.matchEffect({ - onFailure: (recoveryCause) => - Effect.fail( - new DesktopLogFileWriterRecoveryError({ - logFilePath: input.filePath, - cause, - recoveryCause, - }), - ), - onSuccess: (size) => Ref.set(currentSize, size).pipe(Effect.andThen(Effect.fail(cause))), - }), - ); - - const pruneOverflowBackups = Effect.gen(function* () { - const entries = yield* fileSystem.readDirectory(directory); - for (const entry of entries) { - if (!entry.startsWith(`${baseName}.`)) continue; - const suffix = Number(entry.slice(baseName.length + 1)); - if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; - yield* fileSystem.remove(path.join(directory, entry), { force: true }); - } - }); - - const rotate = Effect.gen(function* () { - yield* fileSystem.remove(withSuffix(maxFiles), { force: true }); - for (let index = maxFiles - 1; index >= 1; index -= 1) { - const source = withSuffix(index); - const sourceExists = yield* fileSystem.exists(source); - if (sourceExists) { - yield* fileSystem.rename(source, withSuffix(index + 1)); - } - } - const currentExists = yield* fileSystem.exists(input.filePath); - if (currentExists) { - yield* fileSystem.rename(input.filePath, withSuffix(1)); - } - yield* Ref.set(currentSize, 0); - }); - - const writeBytes = ( - chunk: Uint8Array, - ): Effect.Effect => { - if (chunk.byteLength === 0) return Effect.void; - - return mutex.withPermits(1)( - Effect.gen(function* () { - const beforeSize = yield* Ref.get(currentSize); - if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { - yield* rotate; - } - - yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); - const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; - yield* Ref.set(currentSize, afterSize); - - if (afterSize > maxBytes) { - yield* rotate; - } - }).pipe( - Effect.catchTags({ - PlatformError: recoverCurrentSize, - }), - ), - ); - }; - - yield* pruneOverflowBackups; - - return { - filePath: input.filePath, - writeBytes, - writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), - } satisfies RotatingLogFileWriter; -}); - -const writeDevelopmentConsoleOutput = ( - streamName: "stdout" | "stderr", - chunk: Uint8Array, -): Effect.Effect => - Effect.try({ - try: () => { - const output = streamName === "stderr" ? process.stderr : process.stdout; - output.write(chunk); - }, - catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }), - }).pipe( - Effect.catchTags({ - DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }), - }), - ); - -const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( - function* ( - logFile: RotatingLogFileWriter, - input: { - readonly message: string; - readonly level: "INFO" | "ERROR"; - readonly annotations: Record; - }, - ): Effect.fn.Return { - return yield* Effect.gen(function* () { - const timestamp = DateTime.formatIso(yield* DateTime.now); - const encoded = yield* encodeDesktopBackendChildLogRecord({ - message: input.message, - level: input.level, - timestamp, - annotations: input.annotations, - spans: {}, - fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, - }).pipe( - Effect.mapError( - (cause) => - new DesktopBackendOutputLogWriteError({ - operation: "encode-record", - logFilePath: logFile.filePath, - cause, - }), - ), - ); - yield* logFile.writeText(`${encoded}\n`).pipe( - Effect.mapError( - (cause) => - new DesktopBackendOutputLogWriteError({ - operation: "write-record", - logFilePath: logFile.filePath, - cause, - }), - ), - ); - }).pipe( - Effect.catchTags({ - DesktopBackendOutputLogWriteError: (error) => Effect.logError(error.message, { error }), - }), - ); - }, -); - -export const make = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const logFilePath = environment.path.join(environment.logDir, "server-child.log"); - const writer = yield* makeRotatingLogFileWriter({ - filePath: logFilePath, - }).pipe( - Effect.mapError((cause) => new DesktopBackendOutputLogSetupError({ logFilePath, cause })), - Effect.map(Option.some), - Effect.catchTags({ - DesktopBackendOutputLogSetupError: (error) => - Effect.logError(error.message, { error }).pipe(Effect.as(Option.none())), - }), - ); - - const service = Option.match(writer, { - onNone: () => DesktopBackendOutputLogNoop, - onSome: (logFile) => - ({ - writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( - function* ({ phase, details }) { - const runId = yield* currentDesktopRunId; - yield* writeBackendChildLogRecord(logFile, { - message: `backend child process session ${phase.toLowerCase()}`, - level: "INFO", - annotations: { - component: "desktop-backend-child", - runId, - phase, - details: sanitizeLogValue(details), - }, - }); - }, - ), - writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( - function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); - } - const runId = yield* currentDesktopRunId; - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: streamName === "stderr" ? "ERROR" : "INFO", - annotations: { - component: "desktop-backend-child", - runId, - stream: streamName, - text: textDecoder.decode(chunk), - }, - }); - }, - ), - }) satisfies DesktopBackendOutputLog["Service"], - }); - - return DesktopBackendOutputLog.of(service); -}); - -export const layer = Layer.effect(DesktopBackendOutputLog, make); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index a78de48d5e1..9438175f602 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -125,7 +125,8 @@ describe("DesktopObservability", () => { }).pipe(Effect.provide(environmentLayer)); yield* Effect.gen(function* () { - const outputLog = yield* DesktopObservability.DesktopBackendOutputLog; + const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; + const outputLog = yield* factory.forInstance("primary"); yield* outputLog.writeSessionBoundary({ phase: "START", details: "pid=123 port=3773 cwd=/repo", @@ -145,6 +146,7 @@ describe("DesktopObservability", () => { assert.equal(boundary.level, "INFO"); assert.equal(boundary.annotations.component, "desktop-backend-child"); assert.equal(boundary.annotations.runId, "test-run"); + assert.equal(boundary.annotations.instanceId, "primary"); assert.equal(boundary.annotations.phase, "START"); assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); @@ -152,6 +154,7 @@ describe("DesktopObservability", () => { assert.equal(output.level, "INFO"); assert.equal(output.annotations.component, "desktop-backend-child"); assert.equal(output.annotations.runId, "test-run"); + assert.equal(output.annotations.instanceId, "primary"); assert.equal(output.annotations.stream, "stdout"); assert.equal(output.annotations.text, "hello server\n"); }).pipe( diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 21dd27ba28d..42451e45ded 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,20 +1,65 @@ +import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; -import * as DesktopBackendOutputLogModule from "./DesktopBackendOutputLog.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; +const DESKTOP_LOG_FILE_MAX_FILES = 10; +const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; -export { DesktopBackendOutputLog } from "./DesktopBackendOutputLog.ts"; +export interface RotatingLogFileWriter { + readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; + readonly writeText: (chunk: string) => Effect.Effect; +} + +export interface DesktopBackendOutputLogShape { + readonly writeSessionBoundary: (input: { + readonly phase: "START" | "END"; + readonly details: string; + }) => Effect.Effect; + readonly writeOutputChunk: ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, + ) => Effect.Effect; +} + +// Factory for per-instance backend output logs. `forInstance(id)` returns +// a writer that targets a distinct rotating log file — the primary +// instance keeps `server-child.log` so the historical path stays stable +// for ops; other instances get `server-child-.log`. +// +// Writers are cached per id within a single factory instance so repeated +// `forInstance` calls (e.g. during a backend restart that re-resolves +// services) reuse the same rotating writer rather than racing each other +// on the same file. +export class DesktopBackendOutputLogFactory extends Context.Service< + DesktopBackendOutputLogFactory, + { + readonly forInstance: (id: string) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLogFactory") {} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); export type DesktopLogAnnotations = Record; @@ -50,7 +95,166 @@ export function makeComponentLogger(component: string): DesktopComponentLogger { }; } -const readPersistedOtlpTracesUrl = Effect.gen(function* () { +class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass()( + "DesktopLogFileWriterConfigurationError", + { + option: Schema.Literals(["maxBytes", "maxFiles"]), + value: Schema.Number, + }, +) { + override get message() { + return `${this.option} must be >= 1 (received ${this.value})`; + } +} + +type DesktopLogFileWriterError = + | DesktopLogFileWriterConfigurationError + | PlatformError.PlatformError; + +const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); + +const DesktopBackendChildLogRecord = Schema.Struct({ + message: Schema.String, + level: Schema.Literals(["INFO", "ERROR"]), + timestamp: Schema.String, + annotations: Schema.Record(Schema.String, Schema.Unknown), + spans: Schema.Record(Schema.String, Schema.Unknown), + fiberId: Schema.String, +}); + +const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( + Schema.fromJsonString(DesktopBackendChildLogRecord), +); + +const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, +}; + +const currentDesktopRunId = Effect.gen(function* () { + const annotations = yield* References.CurrentLogAnnotations; + const runId = annotations.runId; + return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; +}); + +const refreshFileSize = ( + fileSystem: FileSystem.FileSystem, + filePath: string, +): Effect.Effect => + fileSystem.stat(filePath).pipe( + Effect.map((stat) => Number(stat.size)), + Effect.orElseSucceed(() => 0), + ); + +const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { + readonly filePath: string; + readonly maxBytes?: number; + readonly maxFiles?: number; +}): Effect.fn.Return< + RotatingLogFileWriter, + DesktopLogFileWriterError, + FileSystem.FileSystem | Path.Path +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; + const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; + const directory = path.dirname(input.filePath); + const baseName = path.basename(input.filePath); + + if (maxBytes < 1) { + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxBytes", + value: maxBytes, + }); + } + if (maxFiles < 1) { + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxFiles", + value: maxFiles, + }); + } + + yield* fileSystem.makeDirectory(directory, { recursive: true }); + + const withSuffix = (index: number) => `${input.filePath}.${index}`; + const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); + const mutex = yield* Semaphore.make(1); + + const pruneOverflowBackups = Effect.gen(function* () { + const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries) { + if (!entry.startsWith(`${baseName}.`)) continue; + const suffix = Number(entry.slice(baseName.length + 1)); + if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; + yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); + } + }); + + const rotate = Effect.gen(function* () { + yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); + for (let index = maxFiles - 1; index >= 1; index -= 1) { + const source = withSuffix(index); + const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); + if (sourceExists) { + yield* fileSystem.rename(source, withSuffix(index + 1)); + } + } + const currentExists = yield* fileSystem + .exists(input.filePath) + .pipe(Effect.orElseSucceed(() => false)); + if (currentExists) { + yield* fileSystem.rename(input.filePath, withSuffix(1)); + } + yield* Ref.set(currentSize, 0); + }).pipe( + Effect.catch(() => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.flatMap((size) => Ref.set(currentSize, size)), + ), + ), + ); + + const writeBytes = (chunk: Uint8Array): Effect.Effect => { + if (chunk.byteLength === 0) return Effect.void; + + return mutex.withPermits(1)( + Effect.gen(function* () { + const beforeSize = yield* Ref.get(currentSize); + if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { + yield* rotate; + } + + yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); + const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; + yield* Ref.set(currentSize, afterSize); + + if (afterSize > maxBytes) { + yield* rotate; + } + }).pipe( + Effect.catch(() => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.flatMap((size) => Ref.set(currentSize, size)), + ), + ), + ), + ); + }; + + yield* pruneOverflowBackups; + + return { + writeBytes, + writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), + } satisfies RotatingLogFileWriter; +}); + +const readPersistedOtlpTracesUrl: Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); @@ -70,6 +274,170 @@ const resolveOtlpTracesUrl = Effect.gen(function* () { return yield* readPersistedOtlpTracesUrl; }); +const writeDevelopmentConsoleOutput = ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, +): Effect.Effect => + Effect.sync(() => { + const output = streamName === "stderr" ? process.stderr : process.stdout; + output.write(chunk); + }).pipe(Effect.ignore); + +const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( + function* ( + logFile: RotatingLogFileWriter, + input: { + readonly message: string; + readonly level: "INFO" | "ERROR"; + readonly annotations: Record; + }, + ): Effect.fn.Return { + return yield* Effect.gen(function* () { + const timestamp = DateTime.formatIso(yield* DateTime.now); + const encoded = yield* encodeDesktopBackendChildLogRecord({ + message: input.message, + level: input.level, + timestamp, + annotations: input.annotations, + spans: {}, + fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, + }); + yield* logFile.writeText(`${encoded}\n`); + }).pipe(Effect.ignore({ log: true })); + }, +); + +const PRIMARY_BACKEND_LOG_INSTANCE_ID = PRIMARY_LOCAL_ENVIRONMENT_ID; + +const sanitizeInstanceIdForFileName = (id: string): string => id.replace(/[^a-zA-Z0-9._-]+/g, "_"); + +const backendLogFilePathForInstance = ( + environment: DesktopEnvironment.DesktopEnvironment["Service"], + id: string, +): string => { + // Primary keeps the historical "server-child.log" path so ops scripts + // and packaged-build log inspection still find it where it always lived. + if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { + return environment.path.join(environment.logDir, "server-child.log"); + } + const sanitized = sanitizeInstanceIdForFileName(id); + return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); +}; + +// Just the IO sink. Cacheable by resolved file path so two ids that +// sanitize to the same filename share a single RotatingLogFileWriter +// (no race on currentSize tracking). Splitting the sink off from the +// per-call shape lets the shape annotate writes with the *caller's* +// id rather than whatever id created the cached writer first. +const makeBackendOutputSinkForInstance = ( + environment: DesktopEnvironment.DesktopEnvironment["Service"], + id: string, +): Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | Path.Path | Scope.Scope +> => + makeRotatingLogFileWriter({ + filePath: backendLogFilePathForInstance(environment, id), + }).pipe(Effect.option); + +const makeBackendOutputLogShape = ( + environment: DesktopEnvironment.DesktopEnvironment["Service"], + id: string, + sink: Option.Option, +): DesktopBackendOutputLogShape => + Option.match(sink, { + onNone: () => DesktopBackendOutputLogNoop, + onSome: (logFile) => + ({ + writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")( + function* ({ phase, details }) { + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: `backend child process session ${phase.toLowerCase()}`, + level: "INFO", + annotations: { + component: "desktop-backend-child", + runId, + instanceId: id, + phase, + details: sanitizeLogValue(details), + }, + }); + }, + ), + writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( + function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId, + instanceId: id, + stream: streamName, + text: textDecoder.decode(chunk), + }, + }); + }, + ), + }) satisfies DesktopBackendOutputLogShape, + }); + +const backendOutputLogFactoryLayer = Layer.effect( + DesktopBackendOutputLogFactory, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const factoryScope = yield* Scope.Scope; + // Per-file-path cache of the IO sink only. The per-call shape + // wraps the sink with the caller's instance id so a cache hit on + // a path collision (e.g. "wsl:default" and "wsl_default" both + // resolve to server-child-wsl_default.log) doesn't attribute the + // second caller's writes to the first caller's id. Each sink pins + // itself to the factory's scope so all log resources tear down + // together at app exit. Mutex serializes concurrent first-time + // lookups for the same file path. + const cacheRef = yield* SynchronizedRef.make< + ReadonlyMap> + >(new Map()); + + const makeForId = (id: string): Effect.Effect => + SynchronizedRef.modifyEffect(cacheRef, (cache) => { + const cacheKey = backendLogFilePathForInstance(environment, id); + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return Effect.succeed([ + makeBackendOutputLogShape(environment, id, cached), + cache, + ] as const); + } + return makeBackendOutputSinkForInstance(environment, id).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Scope.provide(factoryScope), + Effect.map((sink) => { + const next = new Map(cache); + next.set(cacheKey, sink); + return [ + makeBackendOutputLogShape(environment, id, sink), + next as ReadonlyMap>, + ] as const; + }), + ); + }); + + return DesktopBackendOutputLogFactory.of({ + forInstance: (id) => makeForId(id), + }); + }), +); + const desktopLoggerLayer = Layer.mergeAll( Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), Layer.succeed(References.MinimumLogLevel, "Info"), @@ -82,8 +450,8 @@ const tracerLayer = Layer.unwrap( const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); const sink = yield* makeTraceSink({ filePath: tracePath, - maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, - maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, }); const delegate = Option.isNone(otlpTracesUrl) @@ -101,8 +469,8 @@ const tracerLayer = Layer.unwrap( }); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, - maxBytes: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_BYTES, - maxFiles: DesktopBackendOutputLogModule.DESKTOP_LOG_FILE_MAX_FILES, + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, sink, ...(delegate ? { delegate } : {}), @@ -113,7 +481,7 @@ const tracerLayer = Layer.unwrap( ).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); export const layer = Layer.mergeAll( - DesktopBackendOutputLogModule.layer, + backendOutputLogFactoryLayer, desktopLoggerLayer, tracerLayer, Layer.succeed(Tracer.MinimumTraceLevel, "Info"), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 43e77a0c4cb..24c90943ec3 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -4,13 +4,19 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const PersistedServerObservabilitySettingsDocument = Schema.Struct({ observability: Schema.Struct({ @@ -45,19 +51,22 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp function makeEnvironmentLayer( baseDir: string, options?: { + readonly appPath?: string; readonly isPackaged?: boolean; readonly devServerUrl?: string; + readonly platform?: NodeJS.Platform; + readonly resourcesPath?: string; }, ) { return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: baseDir, - platform: "darwin", + platform: options?.platform ?? "darwin", processArch: "x64", appVersion: "1.2.3", - appPath: "/repo", + appPath: options?.appPath ?? "/repo", isPackaged: options?.isPackaged ?? true, - resourcesPath: "/missing/resources", + resourcesPath: options?.resourcesPath ?? "/missing/resources", runningUnderArm64Translation: false, }).pipe( Layer.provide( @@ -75,6 +84,14 @@ function makeEnvironmentLayer( ); } +const restoreEnv = (name: string, value: string | undefined) => { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +}; + const withHarness = ( effect: Effect.Effect< A, @@ -95,6 +112,8 @@ const withHarness = ( Effect.provide( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), ), ), @@ -102,14 +121,14 @@ const withHarness = ( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); describe("DesktopBackendConfiguration", () => { - it.effect("resolves backend start config with a stable scoped bootstrap token", () => + it.effect("resolvePrimary produces a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const first = yield* configuration.resolve; - const second = yield* configuration.resolve; + const first = yield* configuration.resolvePrimary; + const second = yield* configuration.resolvePrimary; assert.equal(first.executablePath, process.execPath); assert.equal(first.entryPath, environment.backendEntryPath); @@ -133,7 +152,167 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("includes persisted backend observability endpoints when present", () => + it.effect("resolveWsl reuses the primary's bootstrap token", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + const primary = yield* configuration.resolvePrimary; + const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); + + assert.equal(wsl.bootstrap.desktopBootstrapToken, primary.bootstrap.desktopBootstrapToken); + }), + ), + ); + + it.effect("resolveWsl pins a default-tracking run to the concrete default distro", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fileSystem.writeFileString(entryPath, ""); + + const observedDistros: Array = []; + const config = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolveWsl({ port: 5000, distro: null }); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [ + { name: "Debian", isDefault: false, version: 2 }, + { name: "Ubuntu", isDefault: true, version: 2 }, + ], + windowsToWslPath: (distro) => { + observedDistros.push(distro); + return Option.some("/repo/apps/server/dist/bin.mjs"); + }, + ensureNodePty: (distro) => { + observedDistros.push(distro); + return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + }, + getDistroIp: (distro) => { + observedDistros.push(distro); + return Option.some("172.27.0.99"); + }, + }), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: baseDir, + platform: "win32", + resourcesPath: baseDir, + }), + ), + ), + ), + ); + + assert.equal(config.runningDistro, "Ubuntu"); + assert.deepEqual(config.args.slice(0, 2), ["-d", "Ubuntu"]); + assert.deepEqual(observedDistros, ["Ubuntu", "Ubuntu", "Ubuntu"]); + assert.isTrue(Option.isNone(config.preflightFailure)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "resolveWsl preserves inherited PATH with quote-sensitive values as separate args", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fileSystem.writeFileString(entryPath, ""); + + const nodePath = "/home/test user's/.nvm/versions/node/v22.0.0/bin/node"; + const linuxEntryPath = "/tmp/t3 code's launch/entry file.mjs"; + const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; + const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world"; + const config = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + windowsToWslPath: () => Option.some(linuxEntryPath), + ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: baseDir, + devServerUrl, + isPackaged: true, + platform: "win32", + resourcesPath: baseDir, + }), + ), + ), + ), + ); + + assert.equal(config.bootstrapDelivery, "stdin"); + assert.deepEqual(config.args, [ + "-d", + "Ubuntu", + "--exec", + "env", + "PATH=/home/test user's/.nvm/versions/node/v22.0.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin", + nodePath, + linuxEntryPath, + "--bootstrap-fd", + "0", + "--dev-url", + devServerUrl, + ]); + assert.notInclude(config.args, "bash"); + assert.notInclude(config.args, "/bin/sh"); + assert.notInclude(config.args, "-c"); + assert.isTrue(Option.isNone(config.preflightFailure)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolvePrimary and resolveWsl share one token under concurrent resolution", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + // Resolve both before any token is cached, concurrently, so the + // generate step (a yield point) can interleave. The atomic + // get-or-create must still hand both the same token; a non-atomic + // Ref would let each generate its own and break the shared-token + // invariant. + const [primary, wsl] = yield* Effect.all( + [configuration.resolvePrimary, configuration.resolveWsl({ port: 5000, distro: null })], + { concurrency: "unbounded" }, + ); + + assert.equal(wsl.bootstrap.desktopBootstrapToken, primary.bootstrap.desktopBootstrapToken); + }), + ), + ); + + it.effect("resolvePrimary surfaces persisted backend observability endpoints", () => withHarness( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -153,18 +332,18 @@ describe("DesktopBackendConfiguration", () => { }), ); - const config = yield* configuration.resolve; + const config = yield* configuration.resolvePrimary; assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); }), ), ); - it.effect("omits backend observability endpoints when settings are missing", () => + it.effect("resolvePrimary omits backend observability endpoints when settings are missing", () => withHarness( Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolve; + const config = yield* configuration.resolvePrimary; assert.isUndefined(config.bootstrap.otlpTracesUrl); assert.isUndefined(config.bootstrap.otlpMetricsUrl); @@ -175,10 +354,11 @@ describe("DesktopBackendConfiguration", () => { it.effect("logs structured context when persisted observability settings cannot be read", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const settingsPath = `${baseDir}/userdata/settings.json`; + const settingsPath = path.join(baseDir, "userdata", "settings.json"); const cause = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -198,12 +378,14 @@ describe("DesktopBackendConfiguration", () => { const config = yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - return yield* configuration.resolve; + return yield* configuration.resolvePrimary; }).pipe( Effect.provide( Layer.mergeAll( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), Layer.provideMerge(failingFileSystemLayer), ), @@ -228,7 +410,7 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("captures backend output in development so child process logs can be persisted", () => + it.effect("resolvePrimary captures backend output in dev so child logs can be persisted", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -237,12 +419,14 @@ describe("DesktopBackendConfiguration", () => { yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolve; + const config = yield* configuration.resolvePrimary; assert.equal(config.captureOutput, true); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { isPackaged: false, @@ -254,4 +438,362 @@ describe("DesktopBackendConfiguration", () => { ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const previousWslEnv = process.env.WSLENV; + const previousOpenAiKey = process.env.OPENAI_API_KEY; + const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; + try { + process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; + process.env.OPENAI_API_KEY = "openai-key"; + process.env.ANTHROPIC_API_KEY = "anthropic-key"; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.executablePath, "wsl.exe"); + assert.equal(config.bootstrap.port, 5050); + // Binds to 0.0.0.0 inside WSL so the backend is reachable via + // both wslhost-forwarded localhost and the distro's eth0 IP. + assert.equal(config.bootstrap.host, "0.0.0.0"); + assert.equal(config.bootstrap.tailscaleServeEnabled, false); + // httpBaseUrl uses the resolved distro IP from the test stub, + // not localhost — the renderer reaches the backend directly to + // avoid relying on wslhost forwarding. + assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); + assert.equal(config.env.OPENAI_API_KEY, "openai-key"); + assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); + // The existing WSLENV is preserved byte-for-byte (note the empty + // "::" segment survives — WSL ignores it, so we don't normalize + // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is + // already declared, so it isn't forwarded twice. + assert.equal( + config.env.WSLENV, + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY", + ); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + restoreEnv("WSLENV", previousWslEnv); + restoreEnv("OPENAI_API_KEY", previousOpenAiKey); + restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "resolvePrimary falls back to the Windows primary when wsl-only but WSL is unavailable", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolvePrimary; + + // wsl-only is persisted but WSL is unavailable, so the primary must + // not spawn wsl.exe (which would loop on preflight failures while the + // Connections backend control is hidden). Resolve the Windows primary. + assert.equal(config.executablePath, process.execPath); + assert.equal(config.bootstrap.t3Home, environment.baseDir); + assert.isTrue(Option.isNone(config.preflightFailure)); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "resolvePrimary marks a removed persisted WSL distro as a fatal preflight failure", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolvePrimary; + const failure = Option.getOrThrow(config.preflightFailure); + + assert.equal(config.executablePath, "wsl.exe"); + assert.isTrue(failure.fatal); + assert.include(failure.reason, "Removed-Distro"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + wslDistro: "Removed-Distro", + }), + ), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolveWsl keeps a transient distro-list failure retryable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isFalse(failure.fatal); + assert.equal(failure.retryLimit, 12); + assert.include(failure.reason, "timed out"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distroListError: new DesktopWslEnvironment.DesktopWslDistroListError({ + reason: "wsl.exe --list --verbose timed out", + }), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolveWsl marks a missing packaged server entry as fatal", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isTrue(failure.fatal); + assert.include(failure.reason, "missing server entry"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolveWsl marks a missing selected distro as a fatal preflight failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: "Removed-Distro" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isTrue(failure.fatal); + assert.include(failure.reason, "Removed-Distro"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolvePrimaryLabel reports the WSL distro when wsl-only and WSL is available", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const label = yield* configuration.resolvePrimaryLabel; + assert.equal(label, "WSL (Ubuntu)"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + wslDistro: "Ubuntu", + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const label = yield* configuration.resolvePrimaryLabel; + assert.equal(label, "Local environment"); + }), + ), + ); + + it.effect("resolvePrimaryLabel reports Windows when wsl-only but WSL is unavailable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + // Mirrors the resolvePrimary fall-back: the label must follow the + // backend that actually resolves, not the persisted preference, so the + // env switcher can't show "WSL" for a Windows backend. + const label = yield* configuration.resolvePrimaryLabel; + assert.equal(label, "Windows"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + wslDistro: "Ubuntu", + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it("resolvePrimaryLabel is runSync-safe against the real WSL availability probe", async () => { + // getLocalEnvironmentBootstraps is a sync IPC method: it resolves the + // primary instance's lazy label through Effect.runSync. The label chains + // to wslEnvironment.isAvailable, whose real layer probes the filesystem. + // That probe must run once at layer build and expose a resolved value, not + // a live async effect — otherwise runSync throws in the handler. Build the + // real WSL layer (not the sync test stub) and resolve the label with a + // top-level runSync, exactly as the handler does. + // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This test intentionally replicates the sync IPC handler's runSync path to catch a regression to async-only resolution; it.effect would mask it. + const runtime = ManagedRuntime.make( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layer), + // isAvailable on win32 only touches the filesystem, never the spawner, + // so a die-stub is enough to satisfy the layer's deps. + Layer.provideMerge( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.die("spawner should not be used while probing WSL availability"), + ), + ), + ), + Layer.provideMerge(makeEnvironmentLayer("/tmp/t3-wsl-isavailable", { platform: "win32" })), + Layer.provide(NodeServices.layer), + ), + ); + try { + const configuration = await runtime.runPromise( + DesktopBackendConfiguration.DesktopBackendConfiguration, + ); + // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- Same reason: this is the synchronous resolution the IPC handler performs. + const label = Effect.runSync(configuration.resolvePrimaryLabel); + assert.equal(typeof label, "string"); + } finally { + await runtime.dispose(); + } + }); }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d8bd1a13dcb..2559b69e675 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -7,12 +9,16 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import serverPackageJson from "../../../server/package.json" with { type: "json" }; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()( "DesktopBackendObservabilitySettingsReadError", @@ -29,10 +35,31 @@ export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedE export class DesktopBackendConfiguration extends Context.Service< DesktopBackendConfiguration, { - readonly resolve: Effect.Effect< + // Build the Windows-native primary backend's start config. Reads the + // primary's port/host/exposure from DesktopServerExposure. Can fail + // with PlatformError because bootstrap token generation now uses + // crypto.randomBytes under the hood (post Effect 4 migration). + readonly resolvePrimary: Effect.Effect< DesktopBackendManager.DesktopBackendStartConfig, PlatformError.PlatformError >; + // Build a WSL backend start config for the given distro on the given + // port. The WSL backend is always loopback-only (the primary owns LAN + // exposure when the user opts in), so this takes the port directly and + // hardcodes 127.0.0.1. Distro=null means "WSL default distro" and is + // forwarded to wsl.exe with no -d flag. + readonly resolveWsl: (input: { + readonly port: number; + readonly distro: string | null; + }) => Effect.Effect< + DesktopBackendManager.DesktopBackendStartConfig, + PlatformError.PlatformError + >; + // The renderer-facing label for the primary instance, derived from the + // same decision resolvePrimary makes (including the WSL-availability + // fall-back to Windows), so the env switcher can't show "WSL" for a + // backend that actually resolved to Windows. + readonly resolvePrimaryLabel: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} @@ -59,9 +86,48 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; +// Sensitive env vars that the WSL backend needs but Windows process.env won't +// forward across the wsl.exe boundary without WSLENV. The dev-server URL is +// handled separately via a `--dev-url` CLI flag because WSLENV translation of +// URL-shaped values (colons / slashes) is unreliable. +const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; + +const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); +const getWslEnvEntryName = (entry: string): string => { + const slashIndex = entry.indexOf("/"); + return slashIndex === -1 ? entry : entry.slice(0, slashIndex); +}; + +const mergeWslEnv = ( + existingWslEnv: string | undefined, + forwardedEnvNames: ReadonlyArray, +): string | undefined => { + const existing = existingWslEnv?.trim() ?? ""; + + // Names already declared, so we don't forward a duplicate. We parse the + // existing value only for this membership test — the string itself is + // preserved verbatim below rather than re-serialized. + const seenNames = new Set( + existing + .split(":") + .map((entry) => getWslEnvEntryName(entry.trim())) + .filter((name) => name.length > 0), + ); + + const additions = forwardedEnvNames.filter((name) => !seenNames.has(name)); + + // Preserve the user's WSLENV exactly as Windows handed it to us — empty + // "::" segments and duplicate entries are harmless no-ops to WSL and not + // ours to normalize — and only append the secrets we need to forward + // across the wsl.exe boundary. + const parts = [existing, ...additions].filter((part) => part.length > 0); + return parts.length > 0 ? parts.join(":") : undefined; +}; + const logBackendObservabilitySettingsReadFailure = ( settingsPath: string, cause: PlatformError.PlatformError, @@ -100,11 +166,168 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { }; }); -const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( - function* (input: { - readonly bootstrapToken: string; - readonly observabilitySettings: BackendObservabilitySettings; - }): Effect.fn.Return< +interface SharedBootstrapInput { + readonly bootstrapToken: string; + readonly observabilitySettings: BackendObservabilitySettings; +} + +interface WslPreflightSuccess { + readonly _tag: "Ready"; + readonly runningDistro: string; + readonly linuxEntryPath: string; + // Absolute path to the node binary the preflight validated after the shared + // remote resolver repaired PATH. The launch must use this exact path so it + // doesn't fall through to a different/old node than the one node-pty was + // built against. + readonly nodePath: string; + // PATH captured from the same login shell after the shared resolver loaded + // version managers. The launch forwards this value directly without a shell. + readonly resolvedPath: string; +} + +interface WslPreflightFailure { + readonly _tag: "Failed"; + readonly reason: string; + // Fatal: the WSL distro is misconfigured (no node, wrong version, missing + // build tools) and retrying won't help — surface it and (wsl-only) fall back + // to Windows. Non-fatal: transient (WSL not ready yet, wslpath while it + // boots), with a bounded window for self-healing before fallback. + readonly fatal: boolean; + readonly retryLimit?: number; +} + +const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12; + +const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: { + readonly distro: string | null; + readonly windowsEntryPath: string; + readonly windowsRepoRoot: string; + readonly allowBuild: boolean; +}): Effect.fn.Return< + WslPreflightSuccess | WslPreflightFailure, + never, + DesktopWslEnvironment.DesktopWslEnvironment | FileSystem.FileSystem +> { + const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + + const wslAvailable = yield* wslEnv.isAvailable; + if (!wslAvailable) { + return { + _tag: "Failed", + reason: "WSL is not available on this system", + fatal: false, + } as const; + } + + const distroProbe = yield* wslEnv.probeDistros.pipe( + Effect.map((distros) => ({ _tag: "Success", distros }) as const), + Effect.catch((error) => Effect.succeed({ _tag: "Failure", error } as const)), + ); + if (distroProbe._tag === "Failure") { + return { + _tag: "Failed", + reason: `Unable to list WSL distributions: ${distroProbe.error.message}`, + fatal: false, + } as const; + } + + const installedDistros = distroProbe.distros; + const runningDistro = input.distro + ? (installedDistros.find( + (installed) => installed.name.toLowerCase() === input.distro?.toLowerCase(), + )?.name ?? null) + : (installedDistros.find((installed) => installed.isDefault)?.name ?? null); + if (runningDistro === null) { + return { + _tag: "Failed", + reason: input.distro + ? `WSL distro is not installed: ${input.distro}` + : installedDistros.length === 0 + ? "WSL has no installed distributions" + : "WSL has no default distribution", + fatal: true, + } as const; + } + + const entryExists = yield* fileSystem + .exists(input.windowsEntryPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!entryExists) { + return { + _tag: "Failed", + reason: `missing server entry at ${input.windowsEntryPath}`, + fatal: true, + } as const; + } + + const linuxEntry = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsEntryPath); + if (Option.isNone(linuxEntry)) { + return { + _tag: "Failed", + reason: `wslpath conversion failed for ${input.windowsEntryPath}`, + fatal: false, + } as const; + } + + const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, input.windowsRepoRoot, { + allowBuild: input.allowBuild, + nodeEngineRange: serverPackageJson.engines.node, + }); + if (!nodePtyResult.ok) { + return { + _tag: "Failed", + reason: `WSL node-pty unavailable: ${nodePtyResult.reason}`, + fatal: nodePtyResult.fatal, + ...(nodePtyResult.retryLimit === undefined ? {} : { retryLimit: nodePtyResult.retryLimit }), + } as const; + } + + return { + _tag: "Ready", + runningDistro, + linuxEntryPath: linuxEntry.value, + nodePath: nodePtyResult.nodePath, + resolvedPath: nodePtyResult.resolvedPath, + } as const; +}); + +// True when the given IPv4 belongs to a Windows-side network +// interface. In WSL2 mirrored mode the distro's eth0 IP equals the +// host's, which is the signature we use to detect that mode and +// switch the renderer URL to loopback. +const isLocalHostIpv4 = (ip: string): boolean => { + const interfaces = NodeOS.networkInterfaces(); + for (const list of Object.values(interfaces)) { + if (!list) continue; + for (const entry of list) { + // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on + // the Node build Electron ships (41 / Node 22, verified), but some Node + // builds report the numeric 4. Normalize to a string so a future runtime + // bump can't silently break mirrored-mode detection and leave the + // renderer pointed at the distro IP instead of loopback. + const family = String(entry.family); + if ((family === "IPv4" || family === "4") && entry.address === ip) return true; + } + } + return false; +}; + +const buildObservabilityFragment = (observabilitySettings: BackendObservabilitySettings) => ({ + ...Option.match(observabilitySettings.otlpTracesUrl, { + onNone: () => ({}), + onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), + }), + ...Option.match(observabilitySettings.otlpMetricsUrl, { + onNone: () => ({}), + onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), + }), +}); + +const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( + function* ( + input: SharedBootstrapInput, + ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, never, DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure @@ -113,70 +336,349 @@ const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolv const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const backendExposure = yield* serverExposure.backendConfig; + const bootstrap = { + mode: "desktop" as const, + noBrowser: true, + port: backendExposure.port, + t3Home: environment.baseDir, + host: backendExposure.bindHost, + desktopBootstrapToken: input.bootstrapToken, + tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, + tailscaleServePort: backendExposure.tailscaleServePort, + ...buildObservabilityFragment(input.observabilitySettings), + }; + return { executablePath: process.execPath, + args: [environment.backendEntryPath, "--bootstrap-fd", "3"], entryPath: environment.backendEntryPath, cwd: environment.backendCwd, env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", }, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: backendExposure.port, - t3Home: environment.baseDir, - host: backendExposure.bindHost, - desktopBootstrapToken: input.bootstrapToken, - tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, - tailscaleServePort: backendExposure.tailscaleServePort, - ...Option.match(input.observabilitySettings.otlpTracesUrl, { - onNone: () => ({}), - onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), - }), - ...Option.match(input.observabilitySettings.otlpMetricsUrl, { - onNone: () => ({}), - onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), - }), - }, + // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). + extendEnv: true, + bootstrap, + bootstrapDelivery: "fd3", httpBaseUrl: backendExposure.httpBaseUrl, captureOutput: true, - }; + preflightFailure: Option.none(), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; }, ); +const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl")(function* ( + input: SharedBootstrapInput & { + readonly port: number; + readonly distro: string | null; + }, +): Effect.fn.Return< + DesktopBackendManager.DesktopBackendStartConfig, + never, + | DesktopEnvironment.DesktopEnvironment + | DesktopWslEnvironment.DesktopWslEnvironment + | FileSystem.FileSystem +> { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + + // Bind to 0.0.0.0 inside WSL so the backend is reachable both via + // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 + // -> WSL 127.0.0.1) AND via the distro's eth0 IP directly from + // Windows. wslhost forwarding is unreliable on some Windows hosts: + // the desktop's readiness probe and the renderer's saved-env-style + // fetch both saw "Failed to fetch" when the backend only bound to + // 127.0.0.1 inside WSL. Binding to 0.0.0.0 plus advertising the + // WSL IP as the renderer-visible URL avoids that dependency. + // Security-wise this is acceptable for the local-only WSL backend: + // the network it exposes on is the WSL-vEthernet network, not the + // LAN; the primary owns LAN exposure when the user opts in. + const wslBindHost = "0.0.0.0"; + + const bootstrap = { + mode: "desktop" as const, + noBrowser: true, + port: input.port, + // Omit t3Home so the Linux backend uses its own home dir instead of + // the Windows-side baseDir (which would be a /mnt/c path and share + // the SQLite file with the primary). + host: wslBindHost, + desktopBootstrapToken: input.bootstrapToken, + // PortSchema rejects 0, so when tailscale serve is disabled we still + // need a valid number in this slot. The backend reads tailscaleServePort + // only when tailscaleServeEnabled is true, so the actual value here is + // inert. + tailscaleServeEnabled: false, + tailscaleServePort: 443, + ...buildObservabilityFragment(input.observabilitySettings), + }; + + // In packaged builds environment.appRoot is .../resources/app.asar — an + // archive FILE. The Windows primary reads its entry through + // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain + // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks + // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts) + // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is + // already a real directory, so this is a no-op. + const wslAppRoot = environment.isPackaged + ? environment.path.join(environment.resourcesPath, "app.asar.unpacked") + : environment.appRoot; + const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs"); + + const preflight = yield* runWslPreflight({ + distro: input.distro, + windowsEntryPath: wslEntryPath, + windowsRepoRoot: wslAppRoot, + // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and + // attached to the Windows artifact — see build-desktop-artifact.ts), so the + // WSL backend never needs a compiler, node-gyp, or network on first launch. + // Compiling from source is a dev-only convenience: a checkout has no shipped + // prebuilt, and developers have the toolchain. In packaged builds we instead + // surface a clear diagnostic if the prebuilt can't load (unsupported + // arch/distro), rather than silently dropping into a fragile runtime build. + allowBuild: !environment.isPackaged, + }); + + // Every operation after preflight uses the same concrete distro. In + // default-tracking mode this closes the race where the system default + // changes between probing and spawning the backend. + const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; + const distroForConfig = runningDistro ?? input.distro; + + // Resolve the selected distro's IPv4 address. In mirrored mode the distro + // reports a host interface, so use loopback instead; a failed probe also + // falls back to loopback and preserves the previous behavior. + const distroIp = yield* wslEnvironment.getDistroIp(distroForConfig); + const usesSharedNetworkStack = Option.match(distroIp, { + onNone: () => false, + onSome: (ip) => isLocalHostIpv4(ip), + }); + const rendererHost = usesSharedNetworkStack + ? "127.0.0.1" + : Option.getOrElse(distroIp, () => "127.0.0.1"); + const httpBaseUrl = new URL(`http://${rendererHost}:${input.port}`); + + const distroArgs = distroForConfig ? ["-d", distroForConfig] : []; + const forwardedEnv: Record = {}; + const forwardedEnvNames: string[] = []; + for (const name of WSL_FORWARDED_ENV_NAMES) { + const value = process.env[name]; + if (value !== undefined && value.length > 0) { + forwardedEnv[name] = value; + forwardedEnvNames.push(name); + } + } + + // Build an explicit copy of process.env minus T3CODE_HOME (dev-runner + // exports the Windows-side base dir for the primary; if it leaks into + // the WSL backend the Linux side ends up sharing C:\Users\...\.t3 via + // /mnt/c, which means both backends read/write the same database and + // their env-ids collide). + const parentEnvWithoutT3Home: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key === "T3CODE_HOME") continue; + parentEnvWithoutT3Home[key] = value; + } + const wslEnv = mergeWslEnv(parentEnvWithoutT3Home.WSLENV, forwardedEnvNames); + + const baseConfig = { + executablePath: "wsl.exe", + entryPath: wslEntryPath, + cwd: environment.backendCwd, + env: { + ...parentEnvWithoutT3Home, + ...backendChildEnvPatch(), + ...forwardedEnv, + ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), + }, + // env is already a complete process.env minus T3CODE_HOME; pass it + // verbatim instead of letting the spawner re-merge process.env on top. + extendEnv: false, + bootstrap, + bootstrapDelivery: "stdin" as const, + httpBaseUrl, + captureOutput: true, + ...(runningDistro !== null ? { runningDistro } : {}), + }; + + // Forward the dev-server URL as an explicit CLI flag so the WSL backend's + // config resolution lands in dev/ instead of userdata/. Inheriting through + // WSLENV is unreliable in practice (URL-shaped values with colons / + // slashes get translated unpredictably depending on flags), and the + // packaged build leaves devServerUrl as None anyway. + const devUrlArgs = Option.match(environment.devServerUrl, { + onNone: () => [] as ReadonlyArray, + onSome: (url) => ["--dev-url", url.href], + }); + + if (preflight._tag === "Failed") { + const retryLimit = + preflight.retryLimit ?? (preflight.fatal ? undefined : WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT); + return { + ...baseConfig, + args: [...distroArgs, "--", "node", "--version"], + preflightFailure: Option.some({ + reason: preflight.reason, + fatal: preflight.fatal, + ...(retryLimit === undefined ? {} : { retryLimit }), + }), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; + } + + // The WSL server spawns commands its providers reference by name — `npm`/`npx` + // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those + // live in the resolved Node's bin dir, which `wsl.exe -- node` does NOT put on + // the process PATH, so `npm install -g ...` fails with NotFound. Pass the + // user PATH entries captured by the login-shell preflight. Every dynamic + // value is a separate argv entry under `wsl.exe --exec`; no shell command is + // involved, so Windows cannot mangle nested quotes and stdin remains reserved + // for the bootstrap envelope. + const lastSlash = preflight.nodePath.lastIndexOf("/"); + const nodeBinDir = lastSlash > 0 ? preflight.nodePath.slice(0, lastSlash) : "/usr/bin"; + const launchPath = `${nodeBinDir}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; + + return { + ...baseConfig, + args: [ + ...distroArgs, + "--exec", + "env", + `PATH=${launchPath}`, + preflight.nodePath, + preflight.linuxEntryPath, + "--bootstrap-fd", + "0", + ...devUrlArgs, + ], + preflightFailure: Option.none(), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; +}); + export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const settings = yield* DesktopAppSettings.DesktopAppSettings; const crypto = yield* Crypto.Crypto; - const tokenRef = yield* Ref.make(Option.none()); - const getOrCreateBootstrapToken = Effect.gen(function* () { - const existing = yield* Ref.get(tokenRef); - if (Option.isSome(existing)) { - return existing.value; - } + // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. + // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can + // resolve concurrently; with a plain Ref both could observe None, generate + // distinct tokens, and one would overwrite the other — leaving the two + // backends holding mismatched tokens and breaking the shared-token + // invariant the renderer relies on. modifyEffect serializes the whole + // get-or-create so the first caller wins and the rest reuse its token. + const tokenRef = yield* SynchronizedRef.make(Option.none()); + const getOrCreateBootstrapToken = SynchronizedRef.modifyEffect(tokenRef, (current) => + Option.match(current, { + onSome: (token) => Effect.succeed([token, current] as const), + onNone: () => + crypto.randomBytes(24).pipe( + Effect.map((bytes) => { + const token = Encoding.encodeHex(bytes); + return [token, Option.some(token)] as const; + }), + ), + }), + ); + + // Both resolvers share the same bootstrap token: the renderer holds a + // single token and uses it against whichever backend it's currently + // talking to. Observability settings get re-read each resolve so a + // hot-swap of the server-settings file is picked up on the next + // restart cycle without having to bounce the desktop process. + const sharedInputs = Effect.gen(function* () { + const bootstrapToken = yield* getOrCreateBootstrapToken; + const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return { bootstrapToken, observabilitySettings } satisfies SharedBootstrapInput; + }); - const token = Encoding.encodeHex(yield* crypto.randomBytes(24)); - yield* Ref.set(tokenRef, Option.some(token)); - return token; + const buildWslPrimaryConfig = Effect.gen(function* () { + // wsl-only mode pipes the WSL backend through the same port the + // Windows primary would normally take. That way the renderer + // still loads from the local-only endpoint advertised by + // DesktopServerExposure, and primary-aware code paths (cookie + // auth, the env switcher's "primary" id) keep working without + // a parallel "secondary" registration. + const backendExposure = yield* serverExposure.backendConfig; + const persistedSettings = yield* settings.get; + const shared = yield* sharedInputs; + return yield* resolveWslStartConfig({ + ...shared, + port: backendExposure.port, + distro: persistedSettings.wslDistro, + }).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + }); + + const buildWindowsPrimaryConfig = Effect.gen(function* () { + const shared = yield* sharedInputs; + return yield* resolvePrimaryStartConfig(shared).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), + ); + }); + + // Single source of truth for what the primary actually runs as. Both + // the start-config dispatch and the renderer-facing label derive from + // this, so they can't disagree — e.g. the label reading "WSL" while the + // config silently fell back to Windows because WSL is unavailable. + // Dispatch happens at resolve time so toggling wsl-only between restarts + // is picked up on the next start cycle (the pool's primary instance is + // created once at layer init, but configResolve fires on each restart). + const describePrimary = Effect.gen(function* () { + const persistedSettings = yield* settings.get; + const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled; + // Only honor wsl-only when WSL is actually usable. If the user + // persisted wsl-only but WSL has since become unavailable (wsl.exe + // removed, no distro), fall back to the Windows primary instead of + // looping forever on preflight failures: the Connections backend + // control is hidden while WSL is unavailable, so a stuck WSL primary + // would otherwise leave no in-app way back to Windows. + const useWsl = wslRequested && (yield* wslEnvironment.isAvailable); + return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; }); return DesktopBackendConfiguration.of({ - resolve: Effect.gen(function* () { - const bootstrapToken = yield* getOrCreateBootstrapToken; - const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - ); - return yield* resolveBackendStartConfig({ - bootstrapToken, - observabilitySettings, + resolvePrimary: Effect.gen(function* () { + const { useWsl, wslRequested } = yield* describePrimary; + if (useWsl) { + return yield* buildWslPrimaryConfig; + } + if (wslRequested) { + yield* Effect.logWarning( + "WSL-only backend requested but WSL is unavailable; starting the Windows primary instead.", + ); + } + return yield* buildWindowsPrimaryConfig; + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimary")), + resolvePrimaryLabel: Effect.gen(function* () { + const { useWsl, distro } = yield* describePrimary; + if (!useWsl) { + return environment.platform === "win32" ? "Windows" : "Local environment"; + } + return distro ? `WSL (${distro})` : "WSL"; + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimaryLabel")), + resolveWsl: (input) => + Effect.gen(function* () { + const shared = yield* sharedInputs; + return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); }).pipe( - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), - ); - }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), + Effect.withSpan("desktop.backendConfiguration.resolveWsl", { + attributes: { port: input.port, distro: input.distro ?? null }, + }), + ), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 3c0a513c9b5..858c9b0d560 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -3,15 +3,12 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -23,18 +20,15 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; -import * as DesktopState from "../app/DesktopState.ts"; -import * as DesktopWindow from "../window/DesktopWindow.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), ); -const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "/electron", + args: ["/server/bin.mjs", "--bootstrap-fd", "3"], entryPath: "/server/bin.mjs", cwd: "/server", env: { ELECTRON_RUN_AS_NODE: "1" }, @@ -48,8 +42,11 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { tailscaleServeEnabled: false, tailscaleServePort: 443, }, + bootstrapDelivery: "fd3", + extendEnv: true, httpBaseUrl: new URL("http://127.0.0.1:3773"), captureOutput: true, + preflightFailure: Option.none(), }; const configWithObservability: DesktopBackendBootstrapValue = { @@ -59,9 +56,9 @@ const configWithObservability: DesktopBackendBootstrapValue = { }; function makeProcess(options?: { - readonly stdout?: Stream.Stream; - readonly stderr?: Stream.Stream; - readonly exitCode?: Effect.Effect; + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; + readonly exitCode?: Effect.Effect; readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; }): ChildProcessSpawner.ChildProcessHandle { return ChildProcessSpawner.makeHandle({ @@ -105,114 +102,98 @@ function decodeBootstrap(raw: string) { return decodeDesktopBackendBootstrap(raw); } -function makeManagerLayer(input: { +interface MakeInstanceInput { readonly spawnerLayer: Layer.Layer; readonly httpClientLayer?: Layer.Layer; - readonly backendOutputLog?: Partial; - readonly desktopState?: DesktopState.DesktopState["Service"]; - readonly desktopWindow?: Partial; + readonly backendOutputLog?: Partial; + readonly onReady?: Effect.Effect; + readonly onShutdown?: Effect.Effect; + readonly onPreflightFailed?: ( + failure: DesktopBackendManager.PreflightFailure, + ) => Effect.Effect; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; -}) { - return DesktopBackendManager.layer.pipe( - Layer.provide( - Layer.mergeAll( - FileSystem.layerNoop({ - exists: () => Effect.succeed(true), - }), - Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { - resolve: Effect.succeed(input.config ?? baseConfig), - }), - input.spawnerLayer, - input.httpClientLayer ?? healthyHttpClientLayer, - input.desktopState - ? Layer.succeed(DesktopState.DesktopState, input.desktopState) - : DesktopState.layer, - Layer.succeed(DesktopObservability.DesktopBackendOutputLog, { - writeSessionBoundary: () => Effect.void, - writeOutputChunk: () => Effect.void, - ...input.backendOutputLog, - } satisfies DesktopObservability.DesktopBackendOutputLog["Service"]), - Layer.succeed(DesktopWindow.DesktopWindow, { - createMain: Effect.die("unexpected createMain"), - ensureMain: Effect.die("unexpected ensureMain"), - revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), - activate: Effect.void, - createMainIfBackendReady: Effect.void, - handleBackendReady: Effect.void, - dispatchMenuAction: () => Effect.void, - syncAppearance: Effect.void, - ...input.desktopWindow, - } satisfies DesktopWindow.DesktopWindow["Service"]), - ), - ), - ); + readonly configResolve?: Effect.Effect; } -describe("DesktopBackendManager", () => { - it("preserves the complete restart cause and schedule context", () => { - const cause = Cause.combine( - Cause.fail(new Error("start failed")), - Cause.die(new Error("restart defect")), - ); - const error = new DesktopBackendManager.DesktopBackendRestartError({ - reason: "backend exited with code 1", - delayMs: 500, - cause, - }); - - assert.strictEqual(error.cause, cause); - assert.equal(error.reason, "backend exited with code 1"); - assert.equal(error.delayMs, 500); - assert.equal(error.message, "Desktop backend restart failed after a scheduled 500ms delay."); +// Helper that constructs a primary backend instance using the factory +// directly. The factory's deps (FileSystem, ChildProcessSpawner, +// HttpClient, DesktopBackendOutputLogFactory) are provided per-test via +// a scoped layer; tests yield the returned Effect inside `Effect.scoped` +// to drive the instance's lifecycle. +function makeTestInstance(input: MakeInstanceInput) { + const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, + ...input.backendOutputLog, + }; + const servicesLayer = Layer.mergeAll( + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + }), + input.spawnerLayer, + input.httpClientLayer ?? healthyHttpClientLayer, + Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { + forInstance: () => Effect.succeed(stubLog), + } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), + ); + + const instance = DesktopBackendManager.makeBackendInstance({ + id: DesktopBackendManager.PRIMARY_INSTANCE_ID, + label: Effect.succeed("Windows"), + configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig), + ...(input.onReady ? { onReady: () => input.onReady! } : {}), + ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), + ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), }); + return instance.pipe(Effect.provide(servicesLayer)); +} + +describe("DesktopBackendManager", () => { it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.Command | undefined; - let bootstrapJson = ""; - let readyCount = 0; - const ready = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - spawnedCommand = command; - if (command._tag === "StandardCommand") { - const fd3 = command.options.additionalFds?.fd3; - if (fd3?.type === "input" && fd3.stream) { - bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + Effect.scoped( + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let readyCount = 0; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + } } - } - return makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); - - const managerLayer = makeManagerLayer({ - config: { - ...baseConfig, - bootstrap: configWithObservability, - }, - spawnerLayer, - desktopWindow: { - handleBackendReady: Effect.sync(() => { + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + config: { + ...baseConfig, + bootstrap: configWithObservability, + }, + spawnerLayer, + onReady: Effect.sync(() => { readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), - }, - backendOutputLog: { - writeSessionBoundary: ({ phase }) => - phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, - }, - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + }, + }); + + yield* instance.start; yield* Queue.take(exited); assert.equal(readyCount, 1); @@ -235,292 +216,52 @@ describe("DesktopBackendManager", () => { ); assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); - }).pipe(Effect.provide(managerLayer)); - }), - ); - - it.effect("preserves the readiness timeout cause and process context", () => - Effect.gen(function* () { - const requested = yield* Deferred.make(); - const layer = Layer.merge( - TestClock.layer(), - httpClientLayer((request) => - Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), - ), - ); - - yield* Effect.gen(function* () { - const readiness = yield* DesktopBackendManager.waitForHttpReady({ - executablePath: baseConfig.executablePath, - entryPath: baseConfig.entryPath, - cwd: baseConfig.cwd, - httpBaseUrl: baseConfig.httpBaseUrl, - timeout: Duration.millis(50), - }).pipe(Effect.flip, Effect.forkChild); - - const request = yield* Deferred.await(requested); - assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); - - yield* TestClock.adjust(Duration.millis(50)); - const error = yield* Fiber.join(readiness); - - assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); - assert.equal(error.timeoutMs, 50); - assert.isTrue(Cause.isTimeoutError(error.cause)); - assert.equal( - error.message, - "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", - ); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("reports bootstrap encoding failures with stable process context", () => - Effect.gen(function* () { - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - const error = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - bootstrap: { - ...baseConfig.bootstrap, - port: 0, - }, - }).pipe( - Effect.flip, - Effect.scoped, - Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), - ); - - if (error._tag !== "BackendProcessBootstrapEncodeError") { - return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.isDefined(error.cause); - assert.equal( - error.message, - "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", - ); - assert.isTrue(isBackendProcessError(error)); - }), - ); - - it.effect("preserves spawn failures without deriving their message from the cause", () => - Effect.gen(function* () { - const spawnCause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcessSpawner", - method: "spawn", - pathOrDescriptor: baseConfig.executablePath, - description: "low-level detail that must not become the public message", - }); - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.fail(spawnCause)), - ); - const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( - Effect.flip, - Effect.scoped, - Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), - ); - - if (error._tag !== "BackendProcessSpawnError") { - return assert.fail(`Expected backend spawn error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.strictEqual(error.cause, spawnCause); - assert.equal( - error.message, - "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", - ); - assert.notInclude(error.message, spawnCause.message); - assert.isTrue(isBackendProcessError(error)); - }), - ); - - it.effect("preserves exit-status failures without copying their detail into the message", () => - Effect.gen(function* () { - const exitCause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "exitCode", - description: "exit-status-secret-sentinel", - }); - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Effect.fail(exitCause), - }), - ), - ), - ); - const error = yield* DesktopBackendManager.runBackendProcess(baseConfig).pipe( - Effect.flip, - Effect.scoped, - Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)), - ); - - if (error._tag !== "BackendProcessExitStatusError") { - return assert.fail(`Expected backend exit-status error, received ${error._tag}`); - } - assert.equal(error.pid, 123); - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.strictEqual(error.cause, exitCause); - assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); - assert.notInclude(error.message, "exit-status-secret-sentinel"); - assert.isTrue(isBackendProcessError(error)); - }), - ); - - it.effect("reports output stream failures with process and stream context", () => - Effect.gen(function* () { - const outputCause = PlatformError.systemError({ - _tag: "BadResource", - module: "ChildProcess", - method: "stdout", - description: "output-stream-secret-sentinel", - }); - const reported = yield* Deferred.make(); - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.fail(outputCause), - exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }), - ), - ), - ); - - const exit = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), - }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); - const error = yield* Deferred.await(reported); - - assert.equal(exit.code.pipe(Option.getOrUndefined), 0); - if (error._tag !== "BackendProcessOutputReadError") { - return assert.fail(`Expected output read error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.equal(error.pid, 123); - assert.equal(error.streamName, "stdout"); - assert.strictEqual(error.cause, outputCause); - assert.equal(error.message, "Failed to read stdout from desktop backend process 123."); - assert.notInclude(error.message, "output-stream-secret-sentinel"); - }), + }), + ), ); - it.effect("reports output handler failures separately from stream read failures", () => - Effect.gen(function* () { - const chunk = new TextEncoder().encode("backend output"); - const outputCause = new Error("output-handler-secret-sentinel"); - const reported = yield* Deferred.make(); - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.make(chunk), - exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }), + it.effect("retries HTTP readiness before reporting the backend ready", () => + Effect.scoped( + Effect.gen(function* () { + const requestUrls: Array = []; + const statuses = [503, 200]; + let readyCount = 0; + const firstRequest = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), ), - ), - ); - - const exit = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - onOutput: () => Effect.fail(outputCause), - onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), - }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer))); - const error = yield* Deferred.await(reported); - - assert.equal(exit.code.pipe(Option.getOrUndefined), 0); - if (error._tag !== "BackendProcessOutputHandlingError") { - return assert.fail(`Expected output handling error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.equal(error.pid, 123); - assert.equal(error.streamName, "stdout"); - assert.equal(error.chunkByteLength, chunk.byteLength); - assert.strictEqual(error.cause, outputCause); - assert.equal( - error.message, - `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`, - ); - assert.notInclude(error.message, "output-handler-secret-sentinel"); - }), - ); + ); - it.effect("retries HTTP readiness before reporting the backend ready", () => - Effect.gen(function* () { - const requestUrls: Array = []; - const statuses = [503, 200]; - let readyCount = 0; - const firstRequest = yield* Deferred.make(); - const ready = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer((request) => + Effect.gen(function* () { + const status = statuses.shift(); + assert.isDefined(status); + requestUrls.push(request.url); + yield* Deferred.succeed(firstRequest, void 0); + return responseForRequest(request, status); }), ), - ), - ); - - const managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer((request) => - Effect.gen(function* () { - const status = statuses.shift(); - assert.isDefined(status); - requestUrls.push(request.url); - yield* Deferred.succeed(firstRequest, void 0); - return responseForRequest(request, status); - }), - ), - desktopWindow: { - handleBackendReady: Effect.sync(() => { + onReady: Effect.sync(() => { readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), - }, - backendOutputLog: { - writeSessionBoundary: ({ phase }) => - phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, - }, - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, + }, + }); + + yield* instance.start; yield* Deferred.await(firstRequest); assert.equal(readyCount, 0); @@ -534,106 +275,136 @@ describe("DesktopBackendManager", () => { "http://127.0.0.1:3773/.well-known/t3/environment", "http://127.0.0.1:3773/.well-known/t3/environment", ]); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); - }), + }).pipe(Effect.provide(TestClock.layer())), + ), ); it.effect("starts the configured backend and closes the scoped process on stop", () => - Effect.gen(function* () { - let startCount = 0; - let closedCount = 0; - const closed = yield* Deferred.make(); - const startedPids = yield* Queue.unbounded(); - const ready = yield* Deferred.make(); - const backendReady = yield* Ref.make(false); - const quitting = yield* Ref.make(false); - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - startCount += 1; - yield* Queue.offer(startedPids, 123); - const close = Effect.sync(() => { - closedCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); - - yield* Scope.addFinalizer(scope, close); - - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - kill: () => close, - }); - }), - ), - ); - - const managerLayer = makeManagerLayer({ - spawnerLayer, - desktopState: { - backendReady, - quitting, - }, - desktopWindow: { - handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), - }, - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - assert.isTrue(Option.isNone(yield* manager.currentConfig)); - - yield* manager.start; + Effect.scoped( + Effect.gen(function* () { + let startCount = 0; + let closedCount = 0; + const closed = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + const ready = yield* Deferred.make(); + const backendReadyFlag = yield* Ref.make(false); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + startCount += 1; + yield* Queue.offer(startedPids, 123); + const close = Effect.sync(() => { + closedCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); + + yield* Scope.addFinalizer(scope, close); + + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + onReady: Ref.set(backendReadyFlag, true).pipe( + Effect.andThen(Deferred.succeed(ready, void 0)), + Effect.asVoid, + ), + onShutdown: Ref.set(backendReadyFlag, false), + }); + assert.isTrue(Option.isNone(yield* instance.currentConfig)); + + yield* instance.start; assert.equal(yield* Queue.take(startedPids), 123); yield* Deferred.await(ready); - assert.isTrue(yield* Ref.get(backendReady)); - assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); + assert.isTrue(yield* Ref.get(backendReadyFlag)); + assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); - const runningSnapshot = yield* manager.snapshot; + const runningSnapshot = yield* instance.snapshot; assert.equal(runningSnapshot.ready, true); assert.deepEqual(runningSnapshot.activePid, Option.some(123)); - yield* manager.stop(); + yield* instance.stop(); assert.equal(startCount, 1); assert.equal(closedCount, 1); - const stoppedSnapshot = yield* manager.snapshot; - assert.isFalse(yield* Ref.get(backendReady)); + const stoppedSnapshot = yield* instance.snapshot; + assert.isFalse(yield* Ref.get(backendReadyFlag)); assert.equal(stoppedSnapshot.desiredRunning, false); assert.equal(stoppedSnapshot.ready, false); assert.equal(Option.isNone(stoppedSnapshot.activePid), true); - }).pipe(Effect.provide(managerLayer)); - }), + }), + ), ); - it.effect("restarts an unexpectedly exited backend with the Effect clock", () => - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - let startCount = 0; - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), - }); + it.effect("does not notify shutdown before the first start has prior state", () => + Effect.scoped( + Effect.gen(function* () { + let shutdownCount = 0; + const closed = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + yield* Queue.offer(startedPids, 123); + const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid); + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + onShutdown: Effect.sync(() => { + shutdownCount += 1; }), - ), - ); + }); - const managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), - }); + yield* instance.start; + assert.equal(yield* Queue.take(startedPids), 123); + assert.equal(shutdownCount, 0); + }), + ), + ); - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + it.effect("restarts an unexpectedly exited backend with the Effect clock", () => + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* instance.start; assert.equal(yield* Queue.take(starts), 1); @@ -646,107 +417,300 @@ describe("DesktopBackendManager", () => { assert.equal(yield* Queue.size(starts), 0); yield* TestClock.adjust(Duration.millis(1)); assert.equal(yield* Queue.take(starts), 3); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); - }), + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("does not notify shutdown when a scheduled restart starts from non-ready state", () => + Effect.scoped( + Effect.gen(function* () { + let shutdownCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + preflightFailure: Option.some({ reason: "preflight failed", fatal: false }), + }, + onShutdown: Effect.sync(() => { + shutdownCount += 1; + }), + }); + + yield* instance.start; + assert.equal(shutdownCount, 0); + + yield* TestClock.adjust(Duration.millis(500)); + assert.equal(shutdownCount, 0); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("surfaces a fatal preflight failure once and stops looping after the cap", () => + Effect.scoped( + Effect.gen(function* () { + const failures: string[] = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + preflightFailure: Option.some({ reason: "Node.js not found", fatal: true }), + }, + onPreflightFailed: (failure) => + Effect.sync(() => { + failures.push(failure.reason); + }).pipe(Effect.as(false)), + }); + + yield* instance.start; + assert.deepEqual(failures, []); + + // Five fatal attempts with exponential backoff (500ms, 1s, 2s, 4s) reach + // the cap, at which point the failure is surfaced exactly once. + yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.seconds(1)); + yield* TestClock.adjust(Duration.seconds(2)); + yield* TestClock.adjust(Duration.seconds(4)); + assert.deepEqual(failures, ["Node.js not found"]); + + // Past the cap the loop stops and nothing else is surfaced. + yield* TestClock.adjust(Duration.seconds(8)); + yield* TestClock.adjust(Duration.seconds(30)); + assert.deepEqual(failures, ["Node.js not found"]); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("can be started again after a fatal preflight cap once config recovers", () => + Effect.scoped( + Effect.gen(function* () { + const failing = yield* Ref.make(true); + const starts = yield* Queue.unbounded(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Queue.offer(starts, 123).pipe( + Effect.as( + makeProcess({ + exitCode: Effect.never, + }), + ), + ), + ), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + configResolve: Ref.get(failing).pipe( + Effect.map((isFailing) => + isFailing + ? { + ...baseConfig, + preflightFailure: Option.some({ + reason: "Node.js not found", + fatal: true, + }), + } + : baseConfig, + ), + ), + }); + + yield* instance.start; + yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.seconds(1)); + yield* TestClock.adjust(Duration.seconds(2)); + yield* TestClock.adjust(Duration.seconds(4)); + yield* TestClock.adjust(Duration.seconds(8)); + + const parked = yield* instance.snapshot; + assert.equal(parked.desiredRunning, false); + assert.equal(parked.ready, false); + assert.isTrue(Option.isNone(parked.activePid)); + assert.equal(parked.restartScheduled, false); + assert.equal(yield* Queue.size(starts), 0); + + yield* Ref.set(failing, false); + yield* instance.start; + + assert.equal(yield* Queue.take(starts), 123); + const running = yield* instance.snapshot; + assert.equal(running.desiredRunning, true); + assert.deepEqual(running.activePid, Option.some(123)); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("keeps retrying a transient (non-fatal) preflight failure without surfacing", () => + Effect.scoped( + Effect.gen(function* () { + const failures: string[] = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + preflightFailure: Option.some({ reason: "wslpath conversion failed", fatal: false }), + }, + onPreflightFailed: (failure) => + Effect.sync(() => { + failures.push(failure.reason); + }).pipe(Effect.as(false)), + }); + + yield* instance.start; + // Well beyond the fatal cap's worth of time: a transient failure must + // keep retrying (self-heal) and never surface. + yield* TestClock.adjust(Duration.minutes(2)); + assert.deepEqual(failures, []); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("surfaces a bounded transient preflight failure after its retry limit", () => + Effect.scoped( + Effect.gen(function* () { + const failures: string[] = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + preflightFailure: Option.some({ + reason: "WSL toolchain probe timed out", + fatal: false, + retryLimit: 3, + }), + }, + onPreflightFailed: (failure) => + Effect.sync(() => { + failures.push(failure.reason); + }).pipe(Effect.as(false)), + }); + + yield* instance.start; + yield* TestClock.adjust(Duration.millis(500)); + assert.deepEqual(failures, []); + + yield* TestClock.adjust(Duration.seconds(1)); + assert.deepEqual(failures, ["WSL toolchain probe timed out"]); + }).pipe(Effect.provide(TestClock.layer())), + ), ); it.effect("cancels a scheduled restart when start is requested manually", () => - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const secondClosed = yield* Deferred.make(); - let startCount = 0; - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - startCount += 1; - yield* Queue.offer(starts, startCount); - - if (startCount === 1) { + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const secondClosed = yield* Deferred.make(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + + if (startCount === 1) { + return makeProcess({ + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }); + } + + const scope = yield* Scope.Scope; + const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); + yield* Scope.addFinalizer(scope, close); return makeProcess({ - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + exitCode: Deferred.await(secondClosed).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + kill: () => close, }); - } - - const scope = yield* Scope.Scope; - const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); - yield* Scope.addFinalizer(scope, close); - return makeProcess({ - exitCode: Deferred.await(secondClosed).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - kill: () => close, - }); - }), - ), - ); + }), + ), + ); - const managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), - }); + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + yield* instance.start; assert.equal(yield* Queue.take(starts), 1); - yield* manager.start; + yield* instance.start; assert.equal(yield* Queue.take(starts), 2); - yield* manager.stop(); + yield* instance.stop(); yield* TestClock.adjust(Duration.millis(500)); assert.equal(yield* Queue.size(starts), 0); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); - }), + }).pipe(Effect.provide(TestClock.layer())), + ), ); it.effect("does not restart after stop cancels a scheduled restart", () => - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - let startCount = 0; - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), - }); - }), - ), - ); + Effect.scoped( + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); - const managerLayer = makeManagerLayer({ - spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), - }); + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; + yield* instance.start; assert.equal(yield* Queue.take(starts), 1); let restartScheduled = false; while (!restartScheduled) { - restartScheduled = (yield* manager.snapshot).restartScheduled; + restartScheduled = (yield* instance.snapshot).restartScheduled; if (!restartScheduled) { yield* Effect.yieldNow; } } - yield* manager.stop(); + yield* instance.stop(); yield* TestClock.adjust(Duration.millis(500)); assert.equal(yield* Queue.size(starts), 0); - assert.equal((yield* manager.snapshot).desiredRunning, false); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); - }), + assert.equal((yield* instance.snapshot).desiredRunning, false); + }).pipe(Effect.provide(TestClock.layer())), + ), ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index d92f62d16b7..e3a4de661ac 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,35 +1,63 @@ +// Per-instance backend factory. Replaces the legacy singleton +// `DesktopBackendManager` Context.Service: each call to +// `makeBackendInstance(spec)` constructs an isolated backend lifecycle — +// its own state Ref, mutex, restart loop, and active child process. The +// returned `DesktopBackendInstance` exposes start/stop/snapshot/wait +// methods that operate on that single backend. +// +// The pool layer (`DesktopBackendPool.ts`) calls this factory once per +// backend it wants to run. Today that's the Windows primary; follow-up +// commits add a second call for the WSL instance. +// +// Singleton couplings that the legacy service held inline are now +// parameterized via the spec: +// - configResolve replaces the legacy `DesktopBackendConfiguration.resolve` +// so each instance can resolve its own start config — the primary wires +// `configuration.resolvePrimary`, the WSL orchestrator wires a +// `configuration.resolveWsl({ port, distro })` closure. +// - onReady / onShutdown drive UI side effects (window auto-open, +// readiness latch) only for instances that want them — the primary's +// spec passes the window's handleBackendReady/handleBackendNotReady, +// other pool instances pass nothing. +// - log writes go through a per-instance writer that the factory +// pulls from `DesktopBackendOutputLogFactory.forInstance(spec.id)`, +// so each instance lands in its own rotating file. + +import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; 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 FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as ChildProcess from "effect/unstable/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { DesktopBackendBootstrap, type DesktopBackendBootstrap as DesktopBackendBootstrapValue, + PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; +import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; -import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; -import * as DesktopState from "../app/DesktopState.ts"; -import * as DesktopWindow from "../window/DesktopWindow.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); +// After this many consecutive fatal preflight failures, stop the silent +// restart loop and surface the reason via onPreflightFailed. Transient +// failures may instead provide their own larger retryLimit when they should +// self-heal for a while but must not leave the app connecting forever. +const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); @@ -42,145 +70,91 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; export type BackendProcessOutputStream = "stdout" | "stderr"; -export interface BackendProcessContext { +export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; + +export interface DesktopBackendStartConfig { readonly executablePath: string; + readonly args: ReadonlyArray; readonly entryPath: string; readonly cwd: string; - readonly httpBaseUrl: URL; -} - -export interface DesktopBackendStartConfig extends BackendProcessContext { readonly env: Record; + // When true the spawner merges the desktop process.env on top of `env`; + // when false `env` is passed verbatim. WSL mode opts out so a leaking + // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3. + readonly extendEnv: boolean; readonly bootstrap: DesktopBackendBootstrapValue; + readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; + readonly httpBaseUrl: URL; readonly captureOutput: boolean; + readonly preflightFailure: Option.Option; + // Present for a WSL run after the configured/default distro has been + // resolved to the concrete distro passed to wsl.exe. + readonly runningDistro?: string; +} + +// A preflight failure records whether it is fatal. Transient failures (WSL +// cold-starting, wslpath while the VM boots) keep retrying so the backend can +// self-heal; fatal ones (no node, wrong version, missing build tools) are +// surfaced via onPreflightFailed and stop the restart loop after +// MAX_PREFLIGHT_FAILURE_ATTEMPTS. +export interface PreflightFailure { + readonly reason: string; + readonly fatal: boolean; + readonly retryLimit?: number; } interface BackendProcessExit { readonly code: Option.Option; readonly reason: string; + readonly result: Result.Result; } -const backendProcessContextSchema = { - executablePath: Schema.String, - entryPath: Schema.String, - cwd: Schema.String, - httpBaseUrl: Schema.URL, -}; - -export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass()( - "BackendReadinessTimeoutError", +export class BackendTimeoutError extends Schema.TaggedErrorClass()( + "BackendTimeoutError", { - ...backendProcessContextSchema, - readinessUrl: Schema.URL, - timeoutMs: Schema.Number, - cause: Schema.Defect(), + url: Schema.instanceOf(URL), }, ) { - override get message(): string { - return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`; + override get message() { + return `Timed out waiting for backend readiness at ${this.url.href}.`; } } -export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( +class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass()( "BackendProcessBootstrapEncodeError", { - ...backendProcessContextSchema, + entryPath: Schema.String, cause: Schema.Defect(), }, ) { - override get message(): string { + override get message() { return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`; } } -export class BackendProcessSpawnError extends Schema.TaggedErrorClass()( +class BackendProcessSpawnError extends Schema.TaggedErrorClass()( "BackendProcessSpawnError", { - ...backendProcessContextSchema, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`; - } -} - -export class BackendProcessOutputReadError extends Schema.TaggedErrorClass()( - "BackendProcessOutputReadError", - { - ...backendProcessContextSchema, - pid: Schema.Number, - streamName: Schema.Literals(["stdout", "stderr"]), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to read ${this.streamName} from desktop backend process ${this.pid}.`; - } -} - -export class BackendProcessOutputHandlingError extends Schema.TaggedErrorClass()( - "BackendProcessOutputHandlingError", - { - ...backendProcessContextSchema, - pid: Schema.Number, - streamName: Schema.Literals(["stdout", "stderr"]), - chunkByteLength: Schema.Number, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to handle ${this.chunkByteLength} bytes from ${this.streamName} of desktop backend process ${this.pid}.`; - } -} - -export type BackendProcessOutputError = - | BackendProcessOutputReadError - | BackendProcessOutputHandlingError; - -export class BackendProcessExitStatusError extends Schema.TaggedErrorClass()( - "BackendProcessExitStatusError", - { - ...backendProcessContextSchema, - pid: Schema.Number, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to read the exit status of desktop backend process ${this.pid}.`; - } -} - -export class DesktopBackendRestartError extends Schema.TaggedErrorClass()( - "DesktopBackendRestartError", - { - reason: Schema.String, - delayMs: Schema.Number, + executablePath: Schema.String, cause: Schema.Defect(), }, ) { - override get message(): string { - return `Desktop backend restart failed after a scheduled ${this.delayMs}ms delay.`; + override get message() { + return `Failed to spawn the desktop backend process at ${this.executablePath}.`; } } -export const BackendProcessError = Schema.Union([ - BackendProcessBootstrapEncodeError, - BackendProcessSpawnError, - BackendProcessExitStatusError, -]); -export type BackendProcessError = typeof BackendProcessError.Type; +type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly readinessTimeout?: Duration.Duration; readonly onStarted?: (pid: number) => Effect.Effect; readonly onReady?: () => Effect.Effect; - readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect; + readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; readonly onOutput?: ( streamName: BackendProcessOutputStream, chunk: Uint8Array, - ) => Effect.Effect; - readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect; + ) => Effect.Effect; } export interface DesktopBackendSnapshot { @@ -191,18 +165,60 @@ export interface DesktopBackendSnapshot { readonly restartScheduled: boolean; } -export class DesktopBackendManager extends Context.Service< - DesktopBackendManager, - { - readonly start: Effect.Effect; - readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; - readonly currentConfig: Effect.Effect>; - readonly snapshot: Effect.Effect; - } ->()("@t3tools/desktop/backend/DesktopBackendManager") {} +// Opaque identifier for one backend process inside the pool. Today only +// PRIMARY_INSTANCE_ID is registered. Follow-up commits add WSL distros +// under ids derived from the distro name (e.g. "wsl:ubuntu"). Eventually +// these map 1:1 with environment ids on the frontend; keeping them +// desktop-local for now avoids leaking the contracts dependency. +export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">; +export const BackendInstanceId = Brand.nominal(); + +export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( + PRIMARY_LOCAL_ENVIRONMENT_ID, +); + +// One pooled backend instance. Same lifecycle surface as the legacy +// `DesktopBackendManagerShape`; the id and label give the pool registry +// + UI something to route on. +export interface DesktopBackendInstance { + readonly id: BackendInstanceId; + readonly label: Effect.Effect; + readonly start: Effect.Effect; + readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; + readonly currentConfig: Effect.Effect>; + readonly snapshot: Effect.Effect; + // Polls desiredRunning + the instance's own ready flag until the + // backend reports ready, or the timeout elapses. Returns true on + // ready, false on timeout. Used by the WSL backend swap to drive its + // rollback path. + readonly waitForReady: (timeout: Duration.Duration) => Effect.Effect; +} -const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = - DesktopObservability.makeComponentLogger("desktop-backend-manager"); +// Spec describing one backend instance to spawn. The configResolve +// effect is awaited each time the instance is (re)started so live +// settings changes are picked up on the next start cycle. onReady and +// onShutdown let the primary instance trigger UI side effects (window +// open, global readiness flag) without coupling the factory to those +// concerns; other instances pass them as undefined. +export interface BackendInstanceSpec { + readonly id: BackendInstanceId; + readonly label: Effect.Effect; + // configResolve can now fail with PlatformError because the + // bootstrap-token closure inside DesktopBackendConfiguration uses + // crypto.randomBytes (Effect 4 beta.73 migration). + readonly configResolve: Effect.Effect; + // Receives the *resolved* httpBaseUrl of the run that just became + // ready. The window service uses this to decide what URL to load + // (the WSL backend reports its distro IP, the Windows backend reports + // 127.0.0.1). Splitting this off from configResolve avoids races + // between "fired onReady" and "currentConfig already advanced". + readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; + readonly onShutdown?: () => Effect.Effect; + // Fired once when a fatal or bounded preflight failure has exhausted its + // retries. Returns true when the callback changed configuration and the + // manager should resolve once more; false stops the failed instance. + readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; +} interface ActiveBackendRun { readonly id: number; @@ -217,6 +233,9 @@ interface BackendManagerState { readonly config: Option.Option; readonly active: Option.Option; readonly restartAttempt: number; + // Consecutive bounded/fatal preflight failures, reset on a clean or + // unbounded-transient preflight. restartAttempt counts all restarts. + readonly preflightFailureAttempt: number; readonly restartFiber: Option.Option>; readonly nextRunId: number; } @@ -227,6 +246,7 @@ const initialState: BackendManagerState = { config: Option.none(), active: Option.none(), restartAttempt: 0, + preflightFailureAttempt: 0, restartFiber: Option.none(), nextRunId: 1, }; @@ -259,198 +279,135 @@ const closeRun = ( ).pipe(Effect.ignore); }; -export const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( - options: BackendProcessContext & { readonly timeout: Duration.Duration }, -): Effect.fn.Return { - const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); - const client = (yield* HttpClient.HttpClient).pipe( - HttpClient.filterStatusOk, - HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), - HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)), - ); +const waitForHttpReady = ( + baseUrl: URL, + timeout: Duration.Duration, +): Effect.Effect => { + const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); + return waitForHttpReadyShared({ + baseUrl: baseUrl.href, + path: BACKEND_READINESS_PATH, + timeoutMs: Duration.toMillis(timeout), + intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL), + probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT), + makeError: () => new BackendTimeoutError({ url: readinessUrl }), + }); +}; - yield* client.get(readinessUrl).pipe( - Effect.asVoid, - Effect.timeout(options.timeout), - Effect.mapError( - (cause) => - new BackendReadinessTimeoutError({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - readinessUrl, - timeoutMs: Duration.toMillis(options.timeout), - cause, - }), - ), - ); -}); +function describeProcessExit( + result: Result.Result, +): BackendProcessExit { + if (Result.isSuccess(result)) { + return { + code: Option.some(result.success), + reason: `code=${result.success}`, + result, + }; + } + + return { + code: Option.none(), + reason: result.failure.message, + result, + }; +} function drainBackendOutput( - context: BackendProcessContext & { readonly pid: number }, streamName: BackendProcessOutputStream, stream: Stream.Stream, - onOutput: ( - streamName: BackendProcessOutputStream, - chunk: Uint8Array, - ) => Effect.Effect, - onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect, + onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect, ): Effect.Effect { return stream.pipe( - Stream.mapError( - (cause) => - new BackendProcessOutputReadError({ - ...context, - streamName, - cause, - }), - ), - Stream.runForEach((chunk) => - onOutput(streamName, chunk).pipe( - Effect.mapError( - (cause) => - new BackendProcessOutputHandlingError({ - ...context, - streamName, - chunkByteLength: chunk.byteLength, - cause, - }), - ), - ), - ), - Effect.catchTags({ - BackendProcessOutputReadError: onOutputFailure, - BackendProcessOutputHandlingError: onOutputFailure, - }), + Stream.runForEach((chunk) => onOutput(streamName, chunk)), + Effect.ignore, ); } const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); -export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( +const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( Effect.mapError( - (cause) => - new BackendProcessBootstrapEncodeError({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - cause, - }), + (cause) => new BackendProcessBootstrapEncodeError({ entryPath: options.entryPath, cause }), ), ); const onOutput = options.onOutput ?? (() => Effect.void); - const command = ChildProcess.make( - options.executablePath, - [options.entryPath, "--bootstrap-fd", "3"], - { - cwd: options.cwd, - env: options.env, - extendEnv: true, - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. - stdin: "ignore", - stdout: options.captureOutput ? "pipe" : "inherit", - stderr: options.captureOutput ? "pipe" : "inherit", - killSignal: "SIGTERM", - forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, - additionalFds: { - fd3: { - type: "input", - stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), - }, - }, - }, - ); + const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`)); + const command = ChildProcess.make(options.executablePath, options.args, { + cwd: options.cwd, + env: options.env, + extendEnv: options.extendEnv, + // In Electron main, process.execPath points to the Electron binary. + // Run the child in Node mode so this backend process does not become a GUI app instance. + stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore", + stdout: options.captureOutput ? "pipe" : "inherit", + stderr: options.captureOutput ? "pipe" : "inherit", + killSignal: "SIGTERM", + forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, + // wsl.exe drops additional file descriptors when forwarding to the Linux + // side, so the WSL spawn path delivers the bootstrap envelope via stdin + // (`--bootstrap-fd 0`) instead. + ...(options.bootstrapDelivery === "fd3" + ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } } + : {}), + }); - const handle = yield* spawner.spawn(command).pipe( - Effect.mapError( - (cause) => - new BackendProcessSpawnError({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - cause, - }), - ), - ); + const handle = yield* spawner + .spawn(command) + .pipe( + Effect.mapError( + (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }), + ), + ); yield* options.onStarted?.(handle.pid) ?? Effect.void; if (options.captureOutput) { - const outputContext = { - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - pid: Number(handle.pid), - }; - const onOutputFailure = options.onOutputFailure ?? (() => Effect.void); - yield* drainBackendOutput( - outputContext, - "stdout", - handle.stdout, - onOutput, - onOutputFailure, - ).pipe(Effect.forkScoped); - yield* drainBackendOutput( - outputContext, - "stderr", - handle.stderr, - onOutput, - onOutputFailure, - ).pipe(Effect.forkScoped); + yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); + yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); } - yield* waitForHttpReady({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - }).pipe( + yield* waitForHttpReady( + options.httpBaseUrl, + options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + ).pipe( Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catchTags({ - BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void, - }), + Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), Effect.forkScoped, ); - const exitCode = yield* handle.exitCode.pipe( - Effect.mapError( - (cause) => - new BackendProcessExitStatusError({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - pid: Number(handle.pid), - cause, - }), - ), - ); - return { - code: Option.some(exitCode), - reason: `code=${exitCode}`, - } satisfies BackendProcessExit; + return describeProcessExit(yield* Effect.result(handle.exitCode)); }); -export const make = Effect.gen(function* () { +// Factory for one pooled backend instance. The returned instance owns +// its own state Ref, mutex, restart loop, and active child process; +// nothing is shared between instances created from separate +// makeBackendInstance calls. The instance shuts down automatically when +// the calling scope closes (typically the application scope). +export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( + spec: BackendInstanceSpec, +): Effect.fn.Return< + DesktopBackendInstance, + never, + | FileSystem.FileSystem + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient + | DesktopObservability.DesktopBackendOutputLogFactory + | Scope.Scope +> { const parentScope = yield* Scope.Scope; const fileSystem = yield* FileSystem.FileSystem; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; - const desktopState = yield* DesktopState.DesktopState; - const desktopWindow = yield* DesktopWindow.DesktopWindow; + const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; + const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); const mutex = yield* Semaphore.make(1); + const { logWarning: logInstanceWarning, logError: logInstanceError } = + DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); + const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => Ref.update(state, withActiveRun(runId, f)); @@ -490,11 +447,16 @@ export const make = Effect.gen(function* () { return; } - yield* Ref.set(desktopState.backendReady, false); - const config = yield* configuration.resolve.pipe( + if (current.ready) { + yield* spec.onShutdown?.() ?? Effect.void; + yield* Ref.update(state, (latest) => + latest.ready ? { ...latest, ready: false } : latest, + ); + } + const config = yield* spec.configResolve.pipe( Effect.tapError((error) => - logBackendManagerError("failed to generate desktop backend configuration", { - cause: error, + logInstanceError("failed to generate desktop backend configuration", { + cause: error.message, }), ), Effect.option, @@ -506,14 +468,84 @@ export const make = Effect.gen(function* () { .exists(config.value.entryPath) .pipe(Effect.orElseSucceed(() => false)); + const resetFatalPreflightCounter = + !current.desiredRunning && current.preflightFailureAttempt > 0; yield* cancelRestart; yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: true, ready: false, config: Option.some(config.value), + preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt, })); + const preflightFailure = config.value.preflightFailure; + if (Option.isSome(preflightFailure)) { + const { reason, fatal, retryLimit } = preflightFailure.value; + if (!fatal && retryLimit === undefined) { + // Transient (WSL cold-starting, wslpath while the VM boots). Keep + // retrying so the backend self-heals once WSL is ready. Reset a + // prior bounded/fatal streak because this is a different failure. + yield* Ref.update(state, (latest) => + latest.preflightFailureAttempt === 0 + ? latest + : { ...latest, preflightFailureAttempt: 0 }, + ); + yield* scheduleRestart(reason); + return; + } + const attemptLimit = retryLimit ?? MAX_PREFLIGHT_FAILURE_ATTEMPTS; + const attempt = yield* Ref.modify(state, (latest) => { + const next = latest.preflightFailureAttempt + 1; + return [next, { ...latest, preflightFailureAttempt: next }] as const; + }); + if (attempt > attemptLimit) { + // We already surfaced and asked for the Windows fallback, yet we're + // still resolving the WSL primary — the fallback didn't take (e.g. + // the settings write failed). Stop rather than loop forever. + yield* logInstanceError("backend preflight still failing after fallback; stopping", { + reason, + attempt, + }); + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: false, + ready: false, + })); + return; + } + if (attempt === attemptLimit) { + // Fatal/bounded and out of retries. Surface the reason (onPreflightFailed, + // on the primary, shows a dialog and persists Windows mode), then + // schedule one more restart so the next resolve picks up the Windows + // primary and a window can open. + yield* logInstanceError( + "backend preflight failed repeatedly; surfacing and falling back", + { reason, attempt }, + ); + const shouldRestart = yield* ( + spec.onPreflightFailed?.(preflightFailure.value) ?? Effect.succeed(false) + ); + if (shouldRestart) { + yield* scheduleRestart(reason); + } else { + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: false, + ready: false, + })); + } + return; + } + yield* scheduleRestart(reason); + return; + } + // Clean preflight — reset the fatal counter so a later failure gets a + // fresh allowance. + yield* Ref.update(state, (latest) => + latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, + ); + if (!entryExists) { yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`); return; @@ -534,7 +566,7 @@ export const make = Effect.gen(function* () { }, ]); - const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( + const finalizeRun = Effect.fn("desktop.backendInstance.finalizeRun")(function* ( reason: string, ) { yield* mutex.withPermits(1)( @@ -586,7 +618,7 @@ export const make = Effect.gen(function* () { details: `pid=${pid.value} ${reason}`, }); } - yield* Ref.set(desktopState.backendReady, false); + yield* spec.onShutdown?.() ?? Effect.void; } if (isCurrentRun && nextState.desiredRunning) { @@ -598,7 +630,7 @@ export const make = Effect.gen(function* () { const program = runBackendProcess({ ...config.value, - onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { + onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) { yield* updateActiveRun(runId, (run) => ({ ...run, pid: Option.some(pid), @@ -608,7 +640,7 @@ export const make = Effect.gen(function* () { details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, }); }), - onReady: Effect.fn("desktop.backendManager.onReady")(function* () { + onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { const isCurrentRun = yield* Ref.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { @@ -628,30 +660,19 @@ export const make = Effect.gen(function* () { return; } - yield* Ref.set(desktopState.backendReady, true); - yield* desktopWindow.handleBackendReady.pipe( - Effect.catch((error) => - logBackendManagerError("failed to open main window after backend readiness", { - cause: error, - }), - ), - ); + yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; }), onReadinessFailure: (error) => - logBackendManagerWarning("backend readiness check failed during bootstrap", { - error, + logInstanceWarning("backend readiness check failed during bootstrap", { + error: error.message, }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), - onOutputFailure: (error) => logBackendManagerError(error.message, { error }), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), Effect.matchEffect({ - onFailure: (error) => - logBackendManagerError(error.message, { error }).pipe( - Effect.andThen(finalizeRun(error.message)), - ), + onFailure: (error) => finalizeRun(error.message), onSuccess: (exit) => finalizeRun(exit.reason), }), Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), @@ -664,9 +685,9 @@ export const make = Effect.gen(function* () { })); }), ), - ).pipe(Effect.withSpan("desktop.backendManager.start")); + ).pipe(Effect.withSpan("desktop.backendInstance.start", { attributes: { id: spec.id } })); - const scheduleRestart = Effect.fn("desktop.backendManager.scheduleRestart")(function* ( + const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* ( reason: string, ) { const scheduled = yield* Ref.modify(state, (latest) => { @@ -686,8 +707,8 @@ export const make = Effect.gen(function* () { yield* Option.match(scheduled, { onNone: () => Effect.void, - onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) { - yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", { + onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) { + yield* logInstanceError("backend exited unexpectedly; restart scheduled", { reason, delayMs: Duration.toMillis(delay), }); @@ -706,17 +727,11 @@ export const make = Effect.gen(function* () { }), ), Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.void; - } - const error = new DesktopBackendRestartError({ - reason, - delayMs: Duration.toMillis(delay), - cause, - }); - return logBackendManagerError(error.message, { error }); - }), + Effect.catchCause((cause) => + logInstanceError("desktop backend restart fiber failed", { + cause: Cause.pretty(cause), + }), + ), ), parentScope, ); @@ -732,7 +747,7 @@ export const make = Effect.gen(function* () { }); }); - const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { + const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: { readonly timeout?: Duration.Duration; }) { const { active, restartFiber } = yield* mutex.withPermits(1)( @@ -750,7 +765,15 @@ export const make = Effect.gen(function* () { restartFiber: Option.none>(), }, ]); - yield* Ref.set(desktopState.backendReady, false); + // Ignore failures from spec.onShutdown so a downstream throw + // can't abort the rest of stop(). Ref.modify above already + // flipped state to "no active run / no restart fiber", and the + // physical cleanup (Fiber.interrupt + closeRun) runs after the + // mutex releases. If onShutdown were allowed to propagate, both + // would be skipped and the child process + restart fiber would + // be orphaned while state claimed nothing was running — the + // next start() would then spawn a second backend on top. + yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore); return result; }), ); @@ -765,14 +788,32 @@ export const make = Effect.gen(function* () { }); }); + const waitForReady = (timeout: Duration.Duration): Effect.Effect => + Effect.gen(function* () { + const current = yield* Ref.get(state); + // Return false early if an external `stop()` flipped desiredRunning off + // — no point polling for a backend that is being torn down. + if (!current.desiredRunning) return { done: true, ready: false }; + return current.ready ? { done: true, ready: true } : { done: false, ready: false }; + }).pipe( + Effect.repeat({ + until: (status) => status.done, + schedule: Schedule.spaced(Duration.millis(100)), + }), + Effect.map((status) => status.ready), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrElse(() => false)), + ); + yield* Effect.addFinalizer(() => stop()); - return DesktopBackendManager.of({ + return { + id: spec.id, + label: spec.label, start, stop, currentConfig, snapshot, - }); + waitForReady, + } satisfies DesktopBackendInstance; }); - -export const layer = Layer.effect(DesktopBackendManager, make); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts new file mode 100644 index 00000000000..5e6a3f5164d --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -0,0 +1,137 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopBackendPool from "./DesktopBackendPool.ts"; +import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; + +function makeStubInstance( + id: DesktopBackendPool.BackendInstanceId, + label: string, +): DesktopBackendPool.DesktopBackendInstance { + const snapshot: DesktopBackendSnapshot = { + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + }; + return { + id, + label: Effect.succeed(label), + start: Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed(snapshot), + waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), + }; +} + +function makePoolLayer( + labelRef: Ref.Ref, +): Layer.Layer { + return DesktopBackendPool.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + FileSystem.layerNoop({}), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), + ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HTTP request")), + ), + Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, { + forInstance: () => + Effect.succeed({ + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, + } satisfies DesktopObservability.DesktopBackendOutputLogShape), + } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), + Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { + resolvePrimary: Effect.die("unexpected primary config resolve"), + resolvePrimaryLabel: Ref.get(labelRef), + resolveWsl: () => Effect.die("unexpected WSL config resolve"), + } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), + DesktopAppSettings.layerTest(), + ElectronDialog.layer, + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected window create"), + ensureMain: Effect.die("unexpected window ensure"), + revealOrCreateMain: Effect.die("unexpected window reveal"), + activate: Effect.die("unexpected window activate"), + createMainIfBackendReady: Effect.die("unexpected window create"), + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + dispatchMenuAction: () => Effect.die("unexpected menu action"), + syncAppearance: Effect.void, + } satisfies DesktopWindow.DesktopWindow["Service"]), + ), + ), + ); +} + +describe("DesktopBackendPool", () => { + it.effect("layerTest exposes registered instances by id", () => + Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const fetchedPrimary = yield* pool.get(DesktopBackendPool.PRIMARY_INSTANCE_ID); + const fetchedWsl = yield* pool.get(DesktopBackendPool.BackendInstanceId("wsl:ubuntu")); + const fetchedMissing = yield* pool.get(DesktopBackendPool.BackendInstanceId("missing")); + const all = yield* pool.list; + const resolvedPrimary = yield* pool.primary; + + assert.equal(yield* Option.getOrThrow(fetchedPrimary).label, "Windows"); + assert.equal(yield* Option.getOrThrow(fetchedWsl).label, "WSL (Ubuntu)"); + assert.isTrue(Option.isNone(fetchedMissing)); + assert.lengthOf(all, 2); + // First instance becomes primary in layerTest so single-instance + // stubs don't have to wire an explicit primary. + assert.equal(resolvedPrimary.id, DesktopBackendPool.PRIMARY_INSTANCE_ID); + }).pipe( + Effect.provide( + DesktopBackendPool.layerTest([ + makeStubInstance(DesktopBackendPool.PRIMARY_INSTANCE_ID, "Windows"), + makeStubInstance(DesktopBackendPool.BackendInstanceId("wsl:ubuntu"), "WSL (Ubuntu)"), + ]), + ), + ), + ); + + it.effect("layerTest dies when no instances are supplied", () => + Effect.exit( + Effect.gen(function* () { + yield* DesktopBackendPool.DesktopBackendPool; + }).pipe(Effect.provide(DesktopBackendPool.layerTest([]))), + ).pipe(Effect.map((exit) => assert.equal(exit._tag, "Failure"))), + ); + + it.effect("resolves the primary label lazily after pool layer construction", () => + Effect.scoped( + Effect.gen(function* () { + const labelRef = yield* Ref.make("Windows"); + const pool = yield* DesktopBackendPool.DesktopBackendPool.pipe( + Effect.provide(makePoolLayer(labelRef)), + ); + const primary = yield* pool.primary; + + yield* Ref.set(labelRef, "WSL (Ubuntu)"); + + assert.equal(yield* primary.label, "WSL (Ubuntu)"); + }), + ), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts new file mode 100644 index 00000000000..258f8731fa1 --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -0,0 +1,470 @@ +// Pool registry for multiple backend processes. This file is the entry +// point for the concurrent-Windows+WSL-backend feature; see the design +// notes below before extending it. +// +// Current state: +// - `DesktopBackendManager.ts` exposes a per-instance factory +// (`makeBackendInstance(spec)`); the pool calls it once for the +// Windows primary at startup, and `DesktopWslBackend.reconcile` +// calls it through `pool.register` to bring up the WSL instance +// when the user enables it. +// - The primary spec wires `configResolve` to +// `DesktopBackendConfiguration.resolvePrimary` and the +// `onReady`/`onShutdown` callbacks to the window service. WSL +// instances wire `configResolve: configuration.resolveWsl(...)` +// and skip onReady/onShutdown — the window only follows the primary. +// - The pool exposes `register(spec)` and `unregister(id)`. Each +// registered instance gets its own child scope, so unregister can +// stop it cleanly without tearing down the pool. The primary's id +// refuses unregister. +// - Settings: `wslBackendEnabled: boolean` + `wslDistro: string | null`. +// The legacy `wslMode: "local" | "wsl"` swap setting is migrated on +// load. IPC surface is `setWslBackendEnabled(boolean)` + +// `setWslDistro(string | null)`; both persist and then call the +// orchestrator's reconcile. No swap, no rollback, primary stays up. +// - `getLocalEnvironmentBootstraps()` (plural) returns one entry per +// pool instance currently registered with bootstrap info. The +// primary keeps the "primary" id; WSL instances are "wsl:default" +// or "wsl:". +// - `pickFolder` accepts an optional `targetEnvironmentId`. Omitting +// it gives the Windows picker — what every existing caller gets, +// and what non-WSL users see. WSL targets route to the wsl helpers. +// - Web settings UX: a plain toggle for "WSL backend" plus a distro +// picker that shows up when the toggle is on. Default-off, so +// users who never opted in see the same surface as before. +// +// Renderer-side wiring (apps/web/src/environments/local/): +// - reconcileLocalSecondaryEnvironments() runs at app boot and after +// WSL settings changes. It reads getLocalEnvironmentBootstraps(), +// skips the primary (which the existing primary/ runtime owns), +// and for every other entry POSTs the shared bootstrap token to +// /api/auth/bootstrap/bearer on that backend's URL, fetches the +// descriptor, builds a SavedEnvironmentRecord marked desktopLocal, +// writes the bearer to the secret store, and opens a connection +// through the same saved-env path remote envs use. +// - The desktopLocal marker filters records out of saved-env +// persistence, so toggling WSL off or switching distros doesn't +// pollute the user's settings file. The sidebar, CommandPalette, +// env switcher, and project-id routing all read the saved-env +// registry, so the WSL backend shows up there without any +// per-surface changes. +// +// Browser validation (2026-05-17, dev:desktop with wslBackendEnabled=true, +// wslDistro="Ubuntu"): +// - Two backends listening on distinct loopback ports +// (server.log: 13773 primary, 13774 wsl). +// - Per-instance log files: server-child.log + server-child-wsl_Ubuntu.log. +// - Distinct environment ids reported by each backend's +// /.well-known/t3/environment (Windows vs Linux platform). +// - Renderer completes the bearer-token bootstrap against the WSL +// backend (POST /api/auth/bootstrap/bearer 200), obtains a +// ws-token (POST /api/auth/ws-token 200), and holds an +// ESTABLISHED WebSocket connection to both ports (netstat). +// +// Migration history (commits): +// 1. Reshape `DesktopBackendManager` into an instance factory and route +// consumers through the pool. Pool held a single instance. (a8fc7845) +// 2. Drop `DesktopState.backendReady`. The window owns its own +// readiness latch via onReady / onShutdown callbacks. (425c7d0b) +// 3. Per-instance log routing via DesktopBackendOutputLogFactory. (563820ed) +// 4. Add register/unregister to the pool. (a0eaf560) +// 5. Wire WSL through the pool: settings rename, BackendConfiguration +// split, DesktopWslBackend orchestrator, new IPC, web compat. +// (b1622191 + 31ce3add + 627c80cb) +// 6. Widen getLocalEnvironmentBootstrap to *Bootstraps (plural). (bad66041) +// 7. pickFolder takes optional targetEnvironmentId. (5d80468d) +// 8. Settings UX: toggle + distro picker, no swap dialog. (eb5a03ea) +// 9. Register WSL backend as desktop-local saved env via +// reconcileLocalSecondaryEnvironments. (1c7e7873 + c17897bd) +// 10. CommandPalette enables file-manager picker for desktop-local +// envs, routes pickFolder by env id. (38e8477a) + +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as FileSystem from "effect/FileSystem"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; + +const { logWarning: logBackendPoolWarning } = + DesktopObservability.makeComponentLogger("desktop-backend-pool"); + +export type BackendInstanceId = DesktopBackendManager.BackendInstanceId; +export const BackendInstanceId = DesktopBackendManager.BackendInstanceId; +export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID; +export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance; +export type BackendInstanceSpec = DesktopBackendManager.BackendInstanceSpec; + +// Caller tried to register an id that's already in the pool. The pool +// refuses overwrites so two independent orchestrators racing on the +// same id surface as a typed failure instead of one silently winning. +export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Schema.TaggedErrorClass()( + "DesktopBackendPoolInstanceAlreadyRegisteredError", + { + id: Schema.String, + }, +) { + override get message() { + return `Backend instance "${this.id}" is already registered in the pool.`; + } +} + +// Primary instance is registered for the pool's lifetime. Unregister is +// a no-op for it today (no real callers), but if someone wires it up +// later it's a clear bug rather than something to "handle". +export class DesktopBackendPoolCannotUnregisterPrimaryError extends Schema.TaggedErrorClass()( + "DesktopBackendPoolCannotUnregisterPrimaryError", + {}, +) { + override get message() { + return "Refusing to unregister the primary backend from the pool."; + } +} + +export class DesktopBackendPool extends Context.Service< + DesktopBackendPool, + { + // Look up a registered instance. None when no backend with that id is + // currently registered (e.g. WSL backend disabled). + readonly get: (id: BackendInstanceId) => Effect.Effect>; + // Snapshot of all currently-registered instances. Order is unspecified; + // callers that need a canonical "primary first" view should sort by id. + readonly list: Effect.Effect; + // Convenience accessor for the always-registered primary instance. + // Currently equivalent to `get(PRIMARY_INSTANCE_ID)` unwrapped, but + // exposed as a typed effect so consumers don't have to handle the + // Option for the case that's guaranteed to be present. + readonly primary: Effect.Effect; + // Build a fresh DesktopBackendInstance from `spec` and add it to the + // registry. The pool owns the instance's scope: unregister(id) or pool + // teardown closes it and runs the instance's auto-stop finalizer. The + // returned instance has not been started — callers decide when to + // start it (and can call start more than once if a retry-after-failure + // story makes sense for them). + readonly register: ( + spec: BackendInstanceSpec, + ) => Effect.Effect; + // Stop the named instance and remove it from the registry. Closing the + // instance's scope triggers its auto-stop finalizer; the registry is + // updated atomically with the scope close so subsequent get(id) calls + // observe the unregister before the underlying child process has fully + // exited. + readonly unregister: ( + id: BackendInstanceId, + ) => Effect.Effect; + } +>()("@t3tools/desktop/backend/DesktopBackendPool") {} + +// Services required by makeBackendInstance — exported so caller +// orchestrators that build their own specs can confirm the layer graph +// satisfies them at compile time. +export type BackendInstanceFactoryRequirements = + | FileSystem.FileSystem + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient + | DesktopObservability.DesktopBackendOutputLogFactory; + +interface ActiveRegisteredInstance { + readonly _tag: "Active"; + readonly instance: DesktopBackendInstance; + // None for the primary (which lives in the pool's own layer scope and + // is never unregistered); Some for instances added via register, whose + // scope unregister closes to stop them. + readonly scope: Option.Option; +} + +interface ClosingRegisteredInstance { + readonly _tag: "Closing"; + readonly done: Deferred.Deferred; +} + +type RegisteredInstance = ActiveRegisteredInstance | ClosingRegisteredInstance; + +type RegisterAction = + | { readonly _tag: "Registered"; readonly instance: DesktopBackendInstance } + | { readonly _tag: "Wait"; readonly done: Deferred.Deferred }; + +type UnregisterAction = + | { readonly _tag: "Absent" } + | { readonly _tag: "Wait"; readonly done: Deferred.Deferred } + | { readonly _tag: "Close"; readonly entry: ActiveRegisteredInstance }; + +export const layer = Layer.effect( + DesktopBackendPool, + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + // Anchor the pool's lifetime to its layer scope so registered + // instance scopes can be forked off it. Without this, instance + // scopes are orphaned: they only close via explicit unregister() + // calls, so on app shutdown the WSL backend child process gets + // hard-killed by the OS instead of receiving the graceful + // SIGTERM + grace period the instance's stop finalizer would + // otherwise run. + const layerScope = yield* Scope.Scope; + // Capture the services needed to build any future instance from the + // pool's layer scope. register() runs `makeBackendInstance` against + // a fresh child scope but reuses these services so the instance gets + // the same FileSystem, spawner, HTTP client and log factory the + // primary instance uses. + const factoryContext = yield* Effect.context(); + + // A WSL preflight failure on the primary only happens in wsl-only mode. + // Fatal configuration failures persist the Windows fallback. Bounded + // transport failures use an in-memory fallback for this launch so the app + // opens without overwriting the user's WSL preference. + const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( + function* (failure: DesktopBackendManager.PreflightFailure) { + const { reason, fatal } = failure; + if (!fatal) { + yield* logBackendPoolWarning( + "primary WSL preflight retry window exhausted; using Windows for this launch", + { reason }, + ); + yield* electronDialog.showErrorBox( + "WSL backend is still unavailable", + `${reason}\n\nT3 Code will use the Windows backend for this launch and retry WSL the next time the app starts.`, + ); + yield* appSettings.applyWslWindowsFallbackInMemory; + return true; + } + + yield* logBackendPoolWarning("primary WSL preflight failed; falling back to Windows", { + reason, + }); + yield* electronDialog.showErrorBox( + "WSL backend couldn't start", + `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, + ); + // Fully disable the WSL backend — both flags, matching the "Switch to + // Windows" recovery path — so the manager's next restart re-resolves the + // primary as Windows and reconcile won't register a secondary WSL backend + // against the same broken setup. Clearing wslBackendEnabled alone would + // leave a stale wslOnly:true that silently re-traps the user in wsl-only + // mode the next time they enable WSL. If the persisted write fails, keep + // this process recoverable by applying the fallback to in-memory settings. + yield* appSettings.applyWslWindowsFallback.pipe( + Effect.catch((error) => + logBackendPoolWarning( + "failed to persist Windows fallback after WSL preflight failure", + { + error: error.message, + }, + ).pipe(Effect.andThen(appSettings.applyWslWindowsFallbackInMemory)), + ), + ); + return true; + }, + ); + + const primary = yield* DesktopBackendManager.makeBackendInstance({ + id: DesktopBackendManager.PRIMARY_INSTANCE_ID, + // Keep this lazy. The pool layer is initialized before startup loads + // persisted desktop settings, so resolving the primary label here would + // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only + // primaries as Windows. + label: configuration.resolvePrimaryLabel, + configResolve: configuration.resolvePrimary, + // Window creation errors propagating out of handleBackendReady must + // not block the readiness callback (that would prevent restartAttempt + // from being reset), so we absorb them here. The window service only + // logs on success, so log the failure here before swallowing it — + // otherwise a post-readiness window-open failure vanishes silently and + // is near-impossible to diagnose in production. + onReady: (httpBaseUrl) => + desktopWindow.handleBackendReady(httpBaseUrl).pipe( + Effect.catch((error) => + logBackendPoolWarning("failed to open main window after backend readiness", { + error: error.message, + }), + ), + ), + onShutdown: () => desktopWindow.handleBackendNotReady, + onPreflightFailed: handlePrimaryPreflightFailure, + }); + + const instancesRef = yield* SynchronizedRef.make< + ReadonlyMap + >( + new Map([ + [ + DesktopBackendManager.PRIMARY_INSTANCE_ID, + { _tag: "Active", instance: primary, scope: Option.none() }, + ], + ]), + ); + + const register: DesktopBackendPool["Service"]["register"] = (spec) => + Effect.suspend(() => + SynchronizedRef.modifyEffect( + instancesRef, + ( + current, + ): Effect.Effect< + readonly [RegisterAction, ReadonlyMap], + DesktopBackendPoolInstanceAlreadyRegisteredError + > => { + const existing = current.get(spec.id); + if (existing?._tag === "Active") { + return Effect.fail( + new DesktopBackendPoolInstanceAlreadyRegisteredError({ id: spec.id }), + ); + } + if (existing?._tag === "Closing") { + return Effect.succeed([ + { _tag: "Wait", done: existing.done } as const, + current, + ] as const); + } + return Effect.gen(function* () { + // Provide the captured factory services first, then the child scope + // last so instance finalizers are owned by the unregisterable scope. + const instanceScope = yield* Scope.fork(layerScope, "sequential"); + const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( + Effect.provide(factoryContext), + Scope.provide(instanceScope), + ); + const next = new Map(current); + next.set(spec.id, { + _tag: "Active", + instance, + scope: Option.some(instanceScope), + }); + return [ + { _tag: "Registered", instance } as const, + next as ReadonlyMap, + ] as const; + }); + }, + ).pipe( + Effect.flatMap((result) => + result._tag === "Registered" + ? Effect.succeed(result.instance) + : Deferred.await(result.done).pipe(Effect.andThen(register(spec))), + ), + ), + ); + + const unregister: DesktopBackendPool["Service"]["unregister"] = (id) => + Effect.gen(function* () { + if (id === DesktopBackendManager.PRIMARY_INSTANCE_ID) { + return yield* new DesktopBackendPoolCannotUnregisterPrimaryError(); + } + const done = yield* Deferred.make(); + const action = yield* SynchronizedRef.modifyEffect( + instancesRef, + ( + current, + ): Effect.Effect< + readonly [UnregisterAction, ReadonlyMap] + > => { + const entry = current.get(id); + if (entry === undefined) { + return Effect.succeed([{ _tag: "Absent" } as const, current] as const); + } + if (entry._tag === "Closing") { + return Effect.succeed([ + { _tag: "Wait", done: entry.done } as const, + current, + ] as const); + } + const next = new Map(current); + next.set(id, { _tag: "Closing", done }); + return Effect.succeed([ + { _tag: "Close", entry } as const, + next as ReadonlyMap, + ] as const); + }, + ); + + if (action._tag === "Absent") return; + if (action._tag === "Wait") { + yield* Deferred.await(action.done); + return; + } + + const finish = SynchronizedRef.modifyEffect(instancesRef, (current) => { + const closing = current.get(id); + if (closing?._tag !== "Closing" || closing.done !== done) { + return Effect.succeed([undefined, current] as const); + } + const next = new Map(current); + next.delete(id); + return Effect.succeed([ + undefined, + next as ReadonlyMap, + ] as const); + }).pipe(Effect.andThen(Deferred.succeed(done, undefined)), Effect.asVoid); + yield* Option.match(action.entry.scope, { + onNone: () => Effect.void, + onSome: (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), + }).pipe(Effect.ensuring(finish)); + }); + + return DesktopBackendPool.of({ + get: (id) => + SynchronizedRef.get(instancesRef).pipe( + Effect.map((instances) => { + const entry = instances.get(id); + return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); + }), + ), + list: SynchronizedRef.get(instancesRef).pipe( + Effect.map((instances) => + Array.from(instances.values()).flatMap((entry) => + entry._tag === "Active" ? [entry.instance] : [], + ), + ), + ), + primary: Effect.succeed(primary), + register, + unregister, + }); + }), +); + +// Test layer for unit tests that want to assert against a known pool +// composition without standing up the full manager. Each provided +// instance is registered under its own id; the first one is also +// surfaced as `primary` so callers can stub a single-instance pool. +// `register` and `unregister` are stubbed to die so tests that +// accidentally exercise pool registration fail loudly instead of +// silently noop'ing. +export const layerTest = ( + instances: readonly DesktopBackendInstance[], +): Layer.Layer => + Layer.effect( + DesktopBackendPool, + Effect.gen(function* () { + if (instances.length === 0) { + return yield* Effect.die("DesktopBackendPool.layerTest requires at least one instance"); + } + const byId = new Map( + instances.map((instance) => [instance.id, instance] as const), + ); + const primary = instances[0]!; + return DesktopBackendPool.of({ + get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), + list: Effect.succeed(Array.from(byId.values())), + primary: Effect.succeed(primary), + register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), + unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), + }); + }), + ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index cd54c46c89a..e7a58baef14 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -5,11 +5,12 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; -import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts"; -const config: DesktopBackendManager.DesktopBackendStartConfig = { +const config = { executablePath: "/electron", entryPath: "/server/bin.mjs", cwd: "/server", @@ -54,20 +55,17 @@ describe("DesktopLocalEnvironmentAuth", () => { ), ), ); - const managerLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { - start: Effect.void, - stop: () => Effect.void, - currentConfig: Effect.succeed(Option.some(config)), - snapshot: Effect.succeed({ - desiredRunning: true, - ready: true, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - }), - }); + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + label: Effect.succeed("Windows"), + currentConfig: Effect.succeed(Option.some(config)), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( - Layer.provide(Layer.mergeAll(managerLayer, httpClientLayer)), + Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)), ); const [first, second] = yield* Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index e619b330d83..201492f0e4c 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -1,4 +1,5 @@ import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; +import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -8,7 +9,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopBackendPool from "./DesktopBackendPool.ts"; export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass()( "DesktopLocalEnvironmentAuthBackendNotConfiguredError", @@ -42,7 +43,7 @@ export class DesktopLocalEnvironmentAuth extends Context.Service< >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} export const make = Effect.gen(function* () { - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const pool = yield* DesktopBackendPool.DesktopBackendPool; const httpClient = yield* HttpClient.HttpClient; const tokenRef = yield* Ref.make(Option.none()); const mutex = yield* Semaphore.make(1); @@ -55,14 +56,20 @@ export const make = Effect.gen(function* () { return cached.value; } - const configOption = yield* backendManager.currentConfig; + const instances = yield* pool.list; + const primary = instances.find((instance) => instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID); + const configOption = primary === undefined ? Option.none() : yield* primary.currentConfig; if (Option.isNone(configOption)) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } const config = configOption.value; + const credential = config.bootstrap.desktopBootstrapToken; + if (!credential) { + return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); + } const session = yield* bootstrapRemoteBearerSession({ httpBaseUrl: config.httpBaseUrl.href, - credential: config.bootstrap.desktopBootstrapToken, + credential, clientMetadata: { label: "T3 Code Desktop", deviceType: "desktop", diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 8b934fd8d85..1a107c5c856 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -253,6 +253,11 @@ describe("DesktopServerExposure", () => { setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); return withHarness( diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 180e44e52d9..ec4284f5d1a 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -33,21 +33,22 @@ import { import { confirm, getAppBranding, + getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, - getLocalEnvironmentBootstrap, openExternal, pickFolder, setTheme, showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); - yield* ipc.handleSync(getLocalEnvironmentBootstrap); + yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); @@ -70,6 +71,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTailscaleServeEnabled); yield* ipc.handle(getAdvertisedEndpoints); + yield* ipc.handle(getWslState); + yield* ipc.handle(setWslBackendEnabled); + yield* ipc.handle(setWslDistro); + yield* ipc.handle(setWslOnly); + yield* ipc.handle(pickFolder); yield* ipc.handle(confirm); yield* ipc.handle(setTheme); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index cc2a92ca8fd..d28c507fa7f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -11,7 +11,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; -export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; @@ -32,6 +32,10 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state"; +export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"; +export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; +export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts new file mode 100644 index 00000000000..a67b3246fb4 --- /dev/null +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -0,0 +1,128 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import { getLocalEnvironmentBootstraps } from "./window.ts"; + +const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { + executablePath: "wsl.exe", + args: ["-d", "Ubuntu", "--", "node", "/app/bin.mjs"], + entryPath: "/app/bin.mjs", + cwd: "/app", + env: {}, + extendEnv: false, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3774, + host: "0.0.0.0", + desktopBootstrapToken: "bootstrap-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + bootstrapDelivery: "stdin", + httpBaseUrl: new URL("http://127.0.0.1:3774"), + captureOutput: true, + preflightFailure: Option.none(), + runningDistro: "Ubuntu", +}; + +const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = { + id: DesktopBackendManager.BackendInstanceId("wsl:default"), + label: Effect.succeed("WSL (default distro)"), + start: Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.some(readyWslConfig)), + snapshot: Effect.succeed({ + desiredRunning: true, + ready: true, + activePid: Option.some(123), + restartAttempt: 0, + restartScheduled: false, + }), + waitForReady: () => Effect.succeed(true), +}; + +describe("getLocalEnvironmentBootstraps", () => { + it.effect("publishes the concrete running distro without replacing the stable instance id", () => + Effect.gen(function* () { + const result = yield* getLocalEnvironmentBootstraps.handler(); + + assert.deepEqual(result, [ + { + id: "wsl:default", + label: "WSL (Ubuntu)", + runningDistro: "Ubuntu", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + bootstrapToken: "bootstrap-token", + }, + ]); + }).pipe(Effect.provide(DesktopBackendPool.layerTest([defaultWslInstance]))), + ); + + it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => { + const retryingConfig: DesktopBackendManager.DesktopBackendStartConfig = { + ...readyWslConfig, + preflightFailure: Option.some({ + reason: "WSL probe timed out", + fatal: false, + retryLimit: 12, + }), + }; + const retryingInstance: DesktopBackendManager.DesktopBackendInstance = { + ...defaultWslInstance, + currentConfig: Effect.succeed(Option.some(retryingConfig)), + snapshot: Effect.succeed({ + desiredRunning: true, + ready: false, + activePid: Option.none(), + restartAttempt: 2, + restartScheduled: true, + }), + }; + + return Effect.gen(function* () { + const result = yield* getLocalEnvironmentBootstraps.handler(); + assert.deepEqual(result, [ + { + id: "wsl:default", + label: "WSL (default distro)", + runningDistro: null, + httpBaseUrl: null, + wsBaseUrl: null, + }, + ]); + }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance]))); + }); + + it.effect("omits a bounded transient bootstrap after retries stop", () => { + const stoppedInstance: DesktopBackendManager.DesktopBackendInstance = { + ...defaultWslInstance, + currentConfig: Effect.succeed( + Option.some({ + ...readyWslConfig, + preflightFailure: Option.some({ + reason: "WSL probe timed out", + fatal: false, + retryLimit: 12, + }), + }), + ), + snapshot: Effect.succeed({ + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 12, + restartScheduled: false, + }), + }; + + return Effect.gen(function* () { + const result = yield* getLocalEnvironmentBootstraps.handler(); + assert.deepEqual(result, []); + }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 3cb705d0361..94357c7a161 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -4,14 +4,19 @@ import { DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, PickFolderOptionsSchema, + PRIMARY_LOCAL_ENVIRONMENT_ID, + type DesktopEnvironmentBootstrap, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; @@ -19,6 +24,11 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; +import { + extractDistroFromUncPath, + resolveWslPickFolderDefaultPath, + wslUncPathToLinuxPath, +} from "../../wsl/wslPathParsing.ts"; const ContextMenuPosition = Schema.Struct({ x: Schema.Number, @@ -45,26 +55,79 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); -export const getLocalEnvironmentBootstrap = DesktopIpc.makeSyncIpcMethod({ - channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, - result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), - handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; - const config = yield* backendManager.currentConfig; - return Option.match(config, { - onNone: () => null, - onSome: ({ bootstrap, httpBaseUrl }) => ({ - label: "Local environment", +export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, + result: Schema.Array(DesktopEnvironmentBootstrapSchema), + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instances = yield* pool.list; + const bootstraps: DesktopEnvironmentBootstrap[] = []; + for (const instance of instances) { + const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; + const config = yield* instance.currentConfig; + const snapshot = yield* instance.snapshot; + // A secondary backend (e.g. a parallel WSL backend) that hasn't produced + // a config yet (mid-registration, before its first start cycle) or that + // is retrying a *transient* preflight failure (WSL VM still booting, a + // not-yet-built linux server entry) is not listening on a port. We + // surface it as a *pending* bootstrap (null endpoints, no token) so the + // renderer can show a "Connecting…" indicator while it retries — null + // endpoints keep the renderer from dialing the dead port, avoiding the + // needless /api/auth/bootstrap/bearer error cycles a real endpoint would + // trigger. + if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { + // Skip the primary (same-origin, no "connecting" affordance) and skip a + // secondary whose preflight failed *fatally* (no node, wrong version, + // missing build tools): it has stopped retrying, so an indefinite + // "Connecting…" would be misleading — its error is surfaced by the + // WSL-state UI instead. + const fatalPreflight = + Option.isSome(config) && + Option.isSome(config.value.preflightFailure) && + config.value.preflightFailure.value.fatal; + const stoppedPreflight = + Option.isSome(config) && + Option.isSome(config.value.preflightFailure) && + (!snapshot.desiredRunning || !snapshot.restartScheduled); + if (isPrimary || fatalPreflight || stoppedPreflight) continue; + bootstraps.push({ + id: instance.id, + label: yield* instance.label, + runningDistro: null, + httpBaseUrl: null, + wsBaseUrl: null, + }); + continue; + } + const { bootstrap, httpBaseUrl } = config.value; + const runningDistro = config.value.runningDistro ?? null; + bootstraps.push({ + id: instance.id, + label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`, + runningDistro, httpBaseUrl: httpBaseUrl.href, wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), ...(bootstrap.desktopBootstrapToken ? { bootstrapToken: bootstrap.desktopBootstrapToken } : {}), - }), - }); + }); + } + return bootstraps; }), }); +// Pull the distro selection out of a backend instance id like +// "wsl:ubuntu". Returns null for "wsl:default", which is the sentinel +// for "track the user's WSL default distro" and maps to the +// wslEnv-derived default at picker time. +function extractWslDistroFromEnvironmentId(envId: string): string | null { + if (!envId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX)) { + return null; + } + const suffix = envId.slice(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX.length); + return suffix === "default" || suffix.length === 0 ? null : suffix; +} + export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL, payload: Schema.Void, @@ -83,11 +146,62 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ const dialog = yield* ElectronDialog.ElectronDialog; const electronWindow = yield* ElectronWindow.ElectronWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + // Three picker modes: + // - targetEnvironmentId omitted: default to the primary picker. Keeps + // the historical behavior unchanged for users who never enabled the + // WSL backend, and is what unfamiliar callers should get out of the + // box. + // - targetEnvironmentId starts with "wsl:": route to the WSL picker + // using the distro encoded in the id (or the user's selected + // wslDistro when the id is the "wsl:default" sentinel). + // - anything else (incl. PRIMARY_LOCAL_ENVIRONMENT_ID): primary picker. + const targetId = options?.targetEnvironmentId; + const wslDistroFromTarget = + targetId !== undefined && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX) + ? extractWslDistroFromEnvironmentId(targetId) + : null; + const useWsl = + targetId !== undefined && + targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && + targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); + const settings = yield* appSettings.get; + // Fall back to the persisted wslDistro when the id is the + // "wsl:default" sentinel; the orchestrator uses the same fallback + // for the actual backend. + const wslDistro = useWsl ? (wslDistroFromTarget ?? settings.wslDistro) : null; + const defaultPath = useWsl + ? Option.fromNullishOr( + resolveWslPickFolderDefaultPath( + options, + { distro: wslDistro }, + yield* wslEnvironment.listDistros, + Option.getOrNull(yield* wslEnvironment.getUserHome(wslDistro)), + ), + ) + : environment.resolvePickFolderDefaultPath(options); const selectedPath = yield* dialog.pickFolder({ owner: yield* electronWindow.focusedMainOrFirst, - defaultPath: environment.resolvePickFolderDefaultPath(options), + defaultPath, }); - return Option.getOrNull(selectedPath); + if (Option.isNone(selectedPath)) { + return null; + } + if (!useWsl) { + return selectedPath.value; + } + + const linuxUncPath = wslUncPathToLinuxPath(selectedPath.value); + if (linuxUncPath !== null) { + return linuxUncPath; + } + + const converted = yield* wslEnvironment.windowsToWslPath( + extractDistroFromUncPath(selectedPath.value) ?? wslDistro, + selectedPath.value, + ); + return Option.getOrElse(converted, () => selectedPath.value); }), }); diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts new file mode 100644 index 00000000000..3e07ae7f39b --- /dev/null +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -0,0 +1,274 @@ +import { DesktopWslStateSchema } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; +import * as DesktopState from "../../app/DesktopState.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; +import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; +import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; + +const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); + +const invokeSetWslBackendEnabled = (enabled: boolean) => + setWslBackendEnabled.handler(enabled).pipe(Effect.flatMap(decodeWslState)); +const invokeSetWslDistro = (distro: string | null) => + setWslDistro.handler(distro).pipe(Effect.flatMap(decodeWslState)); +const invokeSetWslOnly = (enabled: boolean) => + setWslOnly.handler(enabled).pipe(Effect.flatMap(decodeWslState)); + +function makeWslBackendLayer(input: { readonly onReconcile?: Effect.Effect } = {}) { + return Layer.succeed( + DesktopWslBackend.DesktopWslBackend, + DesktopWslBackend.DesktopWslBackend.of({ + reconcile: input.onReconcile ?? Effect.void, + lastPreflightError: Effect.succeed(Option.none()), + }), + ); +} + +function makeLifecycleLayer(relaunchReasons: Array) { + return Layer.succeed( + DesktopLifecycle.DesktopLifecycle, + DesktopLifecycle.DesktopLifecycle.of({ + relaunch: (reason) => + Effect.sync(() => { + relaunchReasons.push(reason); + }), + register: Effect.void, + }), + ); +} + +const unusedLifecycleRuntimeLayer = Layer.mergeAll( + DesktopShutdown.layer, + DesktopState.layer, + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of( + {} as DesktopEnvironment.DesktopEnvironment["Service"], + ), + ), + Layer.succeed( + DesktopWindow.DesktopWindow, + DesktopWindow.DesktopWindow.of({} as DesktopWindow.DesktopWindow["Service"]), + ), + Layer.succeed( + ElectronApp.ElectronApp, + ElectronApp.ElectronApp.of({} as ElectronApp.ElectronApp["Service"]), + ), + Layer.succeed( + ElectronTheme.ElectronTheme, + ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), + ), +); + +describe("WSL IPC", () => { + it.effect("stages dual-backend preferences before enabling without relaunching", () => { + const relaunchReasons: Array = []; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: false, + wslOnly: true, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer(), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + yield* invokeSetWslOnly(false); + yield* invokeSetWslDistro("Debian"); + const state = yield* invokeSetWslBackendEnabled(true); + + assert.deepEqual(state, { + enabled: true, + distro: "Debian", + available: true, + wslOnly: false, + distros: [], + preflightError: null, + }); + assert.deepEqual(relaunchReasons, []); + }).pipe(Effect.provide(layer)); + }); + + it.effect("stages WSL-only preferences and relaunches only after enabling", () => { + const relaunchReasons: Array = []; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: false, + wslOnly: false, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer(), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const stagedMode = yield* invokeSetWslOnly(true); + assert.equal(stagedMode.enabled, false); + assert.equal(stagedMode.wslOnly, true); + assert.deepEqual(relaunchReasons, []); + + yield* invokeSetWslDistro("Debian"); + assert.deepEqual(relaunchReasons, []); + + const state = yield* invokeSetWslBackendEnabled(true); + assert.deepEqual(state, { + enabled: true, + distro: "Debian", + available: true, + wslOnly: true, + distros: [], + preflightError: null, + }); + assert.deepEqual(relaunchReasons, ["wslBackendEnabled=true"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("relaunches when enabling the WSL backend while wsl-only is already persisted", () => { + const relaunchReasons: Array = []; + let reconcileCount = 0; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: false, + wslOnly: true, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer({ + onReconcile: Effect.sync(() => { + reconcileCount += 1; + }), + }), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const state = yield* invokeSetWslBackendEnabled(true); + + assert.deepEqual(state, { + enabled: true, + distro: null, + available: true, + wslOnly: true, + distros: [], + preflightError: null, + }); + assert.equal(reconcileCount, 0); + assert.deepEqual(relaunchReasons, ["wslBackendEnabled=true"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reconciles in dual-backend mode without relaunching", () => { + const relaunchReasons: Array = []; + let reconcileCount = 0; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: false, + wslOnly: false, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer({ + onReconcile: Effect.sync(() => { + reconcileCount += 1; + }), + }), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const state = yield* invokeSetWslBackendEnabled(true); + + assert.equal(state.enabled, true); + assert.equal(state.wslOnly, false); + assert.equal(reconcileCount, 1); + assert.deepEqual(relaunchReasons, []); + }).pipe(Effect.provide(layer)); + }); + + it.effect("clears wsl-only before relaunching when disabling a WSL-only backend", () => { + const relaunchReasons: Array = []; + let reconcileCount = 0; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: true, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer({ + onReconcile: Effect.sync(() => { + reconcileCount += 1; + }), + }), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const state = yield* invokeSetWslBackendEnabled(false); + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const settings = yield* appSettings.get; + + assert.deepEqual(state, { + enabled: false, + distro: null, + available: true, + wslOnly: false, + distros: [], + preflightError: null, + }); + assert.equal(settings.wslBackendEnabled, false); + assert.equal(settings.wslOnly, false); + assert.equal(reconcileCount, 0); + assert.deepEqual(relaunchReasons, ["wslBackendEnabled=false"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("clears dual-backend WSL without relaunching", () => { + const relaunchReasons: Array = []; + let reconcileCount = 0; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: false, + }), + DesktopWslEnvironment.layerTest({ isAvailable: true }), + makeWslBackendLayer({ + onReconcile: Effect.sync(() => { + reconcileCount += 1; + }), + }), + makeLifecycleLayer(relaunchReasons), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const state = yield* invokeSetWslBackendEnabled(false); + + assert.equal(state.enabled, false); + assert.equal(state.wslOnly, false); + assert.equal(reconcileCount, 1); + assert.deepEqual(relaunchReasons, []); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts new file mode 100644 index 00000000000..1d0dc262bae --- /dev/null +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -0,0 +1,122 @@ +import { DesktopWslStateSchema, type DesktopWslState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const readWslState: Effect.Effect< + DesktopWslState, + never, + | DesktopAppSettings.DesktopAppSettings + | DesktopWslEnvironment.DesktopWslEnvironment + | DesktopWslBackend.DesktopWslBackend +> = Effect.gen(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + const settings = yield* appSettings.get; + const available = yield* wslEnvironment.isAvailable; + // Only enumerate distros when WSL is actually available — listDistros on a + // non-WSL host would spawn wsl.exe and hit the timeout for nothing. + const distros = available ? yield* wslEnvironment.listDistros : []; + const preflightError = yield* wslBackend.lastPreflightError; + return { + enabled: settings.wslBackendEnabled, + distro: settings.wslDistro, + available, + wslOnly: settings.wslOnly, + distros, + // Only the dual-mode secondary records this; a wsl-only failure surfaces via + // a dialog + Windows fallback, so it stays null there. + preflightError: settings.wslOnly ? null : Option.getOrNull(preflightError), + }; +}); + +export const getWslState = makeIpcMethod({ + channel: IpcChannels.GET_WSL_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.getState")(function* () { + return yield* readWslState; + }), +}); + +export const setWslBackendEnabled = makeIpcMethod({ + channel: IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, + payload: Schema.Boolean, + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.setEnabled")(function* (enabled) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const previousSettings = yield* appSettings.get; + const updateSettings = enabled + ? appSettings.setWslBackendEnabled(true) + : appSettings.applyWslWindowsFallback; + const change = yield* updateSettings; + const settings = yield* appSettings.get; + const changedWslOnlyPrimary = enabled + ? settings.wslOnly + : previousSettings.wslBackendEnabled && previousSettings.wslOnly; + if (changedWslOnlyPrimary && change.changed) { + const state = yield* readWslState; + yield* lifecycle.relaunch(`wslBackendEnabled=${enabled}`); + return state; + } + // Reconcile is idempotent and never fails; no need for a swap-style + // rollback when the WSL side has trouble coming up. With both + // backends running side by side, "WSL didn't start" is a transient + // state on one instance — the primary stays up either way. + yield* wslBackend.reconcile; + return yield* readWslState; + }), +}); + +export const setWslDistro = makeIpcMethod({ + channel: IpcChannels.SET_WSL_DISTRO_CHANNEL, + payload: Schema.NullOr(Schema.String), + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.setDistro")(function* (distro) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const change = yield* appSettings.setWslDistro(distro); + const settings = yield* appSettings.get; + // In active wsl-only mode the pool's primary IS the WSL backend, and its + // distro is captured when that backend starts, so relaunch to replace it. + // When WSL is disabled, this only stages a preference for the next enable. + if (settings.wslBackendEnabled && settings.wslOnly && change.changed) { + const state = yield* readWslState; + yield* lifecycle.relaunch(`wslDistro=${distro ?? "default"}`); + return state; + } + yield* wslBackend.reconcile; + return yield* readWslState; + }), +}); + +export const setWslOnly = makeIpcMethod({ + channel: IpcChannels.SET_WSL_ONLY_CHANNEL, + payload: Schema.Boolean, + result: DesktopWslStateSchema, + handler: Effect.fn("desktop.ipc.wsl.setOnly")(function* (enabled) { + // wsl-only decides which backend the pool spins up as "primary", and that + // decision is captured once at layer init. A disabled WSL backend always + // leaves Windows primary active, so mode changes can be staged without a + // relaunch and applied by the subsequent enable call. + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const change = yield* appSettings.setWslOnly(enabled); + const state = yield* readWslState; + if (state.enabled && change.changed) { + yield* lifecycle.relaunch(`wslOnly=${enabled}`); + } + return state; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b88eb18e57f..7a51700e0fd 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -31,7 +31,7 @@ import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; -import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; @@ -50,6 +50,8 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; +import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; +import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { @@ -143,12 +145,25 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( Layer.provideMerge(desktopPreviewLayer), ); -const desktopBackendLayer = DesktopBackendManager.layer.pipe( +// Pool layer instantiates the backend factory once for the Windows +// primary instance and exposes it via pool.primary. Consumers go through +// the pool now; the legacy DesktopBackendManager service is gone. The +// WSL second instance gets registered later in the migration. See +// DesktopBackendPool.ts header for the full rollout plan. +const desktopBackendLayer = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), + Layer.provideMerge(DesktopWslEnvironment.layer), Layer.provideMerge(desktopWindowLayer), ); +// WSL orchestrator hangs off the backend layer because it needs the +// pool + configuration + serverExposure; it pulls NetService and the +// foundation services through the same provideMerge chain. +const desktopWslBackendLayer = DesktopWslBackend.layer.pipe( + Layer.provideMerge(desktopBackendLayer), +); + const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( Layer.provideMerge(desktopBackendLayer), ); @@ -160,6 +175,7 @@ const desktopApplicationLayer = Layer.mergeAll( desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge(desktopWslBackendLayer), Layer.provideMerge(desktopLocalEnvironmentAuthLayer), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 6f126f41334..4b5c0d2f656 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -35,12 +35,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, - getLocalEnvironmentBootstrap: () => { - const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); - if (typeof result !== "object" || result === null) { - return null; + getLocalEnvironmentBootstraps: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); + if (!Array.isArray(result)) { + return []; } - return result as ReturnType; + return result as ReturnType; }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), @@ -91,6 +91,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { setTailscaleServeEnabled: (input) => ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), + getWslState: () => ipcRenderer.invoke(IpcChannels.GET_WSL_STATE_CHANNEL), + setWslBackendEnabled: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, enabled), + setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), + setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index c76ffa8bbda..70b26798266 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -16,6 +16,10 @@ const DesktopSettingsPatch = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), + wslBackendEnabled: Schema.optionalKey(Schema.Boolean), + wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), + wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), + wslOnly: Schema.optionalKey(Schema.Boolean), }); const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); @@ -92,6 +96,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopAppSettings.DesktopSettings, ); }); @@ -114,6 +121,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopAppSettings.DesktopSettings); const exposure = yield* settings.setServerExposureMode("local-only"); @@ -214,6 +224,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopAppSettings.DesktopSettings); }), ), @@ -253,6 +266,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -275,6 +291,9 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: true, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -296,8 +315,101 @@ describe("DesktopSettings", () => { tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslOnly: false, + wslDistro: null, } satisfies DesktopAppSettings.DesktopSettings); }), ), ); + + it.effect("persists wsl backend toggle and normalizes invalid distro names", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const enable = yield* settings.setWslBackendEnabled(true); + assert.isTrue(enable.changed); + assert.equal(enable.settings.wslBackendEnabled, true); + + const distro = yield* settings.setWslDistro("Ubuntu-22.04"); + assert.isTrue(distro.changed); + assert.equal(distro.settings.wslDistro, "Ubuntu-22.04"); + + const reloaded = yield* settings.load; + assert.equal(reloaded.wslBackendEnabled, true); + assert.equal(reloaded.wslDistro, "Ubuntu-22.04"); + + const reject = yield* settings.setWslDistro("bad name!"); + assert.equal(reject.settings.wslDistro, null); + + const noop = yield* settings.setWslDistro(null); + assert.isFalse(noop.changed); + }), + ), + ); + + it.effect("applies WSL Windows fallback with persisted and volatile updates", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setWslBackendEnabled(true); + yield* settings.setWslOnly(true); + + const persistedFallback = yield* settings.applyWslWindowsFallback; + assert.isTrue(persistedFallback.changed); + assert.equal(persistedFallback.settings.wslBackendEnabled, false); + assert.equal(persistedFallback.settings.wslOnly, false); + + const persistedReload = yield* settings.load; + assert.equal(persistedReload.wslBackendEnabled, false); + assert.equal(persistedReload.wslOnly, false); + + yield* settings.setWslBackendEnabled(true); + yield* settings.setWslOnly(true); + + const volatileFallback = yield* settings.applyWslWindowsFallbackInMemory; + assert.isTrue(volatileFallback.changed); + assert.equal(volatileFallback.settings.wslBackendEnabled, false); + assert.equal(volatileFallback.settings.wslOnly, false); + + const current = yield* settings.get; + assert.equal(current.wslBackendEnabled, false); + assert.equal(current.wslOnly, false); + + const diskReload = yield* settings.load; + assert.equal(diskReload.wslBackendEnabled, true); + assert.equal(diskReload.wslOnly, true); + }), + ), + ); + + it.effect("migrates legacy wslMode=wsl to wslBackendEnabled on load", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + wslMode: "wsl", + wslDistro: "Ubuntu-22.04", + }); + const loaded = yield* settings.load; + assert.equal(loaded.wslBackendEnabled, true); + assert.equal(loaded.wslDistro, "Ubuntu-22.04"); + }), + ), + ); + + it.effect("drops invalid persisted wsl distro values on load", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + wslBackendEnabled: true, + wslDistro: "bad/name", + }); + const loaded = yield* settings.load; + assert.equal(loaded.wslBackendEnabled, true); + assert.equal(loaded.wslDistro, null); + }), + ), + ); }); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 81aae92f0a3..6a26bf5a6e2 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; +import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; @@ -24,6 +25,21 @@ export interface DesktopSettings { readonly tailscaleServePort: number; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; + // Was a "local" | "wsl" swap mode in an earlier iteration of the WSL + // integration. We now run Windows and WSL backends side by side, so the + // setting is just whether the WSL backend should be running alongside the + // primary. Persisted documents that still carry the legacy `wslMode: "wsl"` + // value are migrated to `wslBackendEnabled: true` on load. + readonly wslBackendEnabled: boolean; + readonly wslDistro: string | null; + // When true (and wslBackendEnabled is also true) the desktop runs only + // the WSL backend as the primary, and the Windows-side Node backend is + // not started. Designed for users who develop entirely inside WSL and + // don't want a second backend process running. Defaults to false so + // existing setups stay on the parallel-backends behavior. Changing + // this requires a desktop restart because the pool's primary spec is + // chosen once at layer init. + readonly wslOnly: boolean; } export interface DesktopSettingsChange { @@ -39,6 +55,9 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, updateChannel: "latest", updateChannelConfiguredByUser: false, + wslBackendEnabled: false, + wslDistro: null, + wslOnly: false, }; const DesktopSettingsDocument = Schema.Struct({ @@ -47,6 +66,13 @@ const DesktopSettingsDocument = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), + // Newer form of the WSL toggle. `wslMode` is still accepted on load so + // existing on-disk settings keep working; on the next persist we write the + // new boolean and the legacy key drops out. + wslBackendEnabled: Schema.optionalKey(Schema.Boolean), + wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), + wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), + wslOnly: Schema.optionalKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -98,6 +124,20 @@ export class DesktopAppSettings extends Context.Service< readonly setUpdateChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; + readonly setWslBackendEnabled: ( + enabled: boolean, + ) => Effect.Effect; + readonly setWslDistro: ( + distro: string | null, + ) => Effect.Effect; + readonly setWslOnly: ( + enabled: boolean, + ) => Effect.Effect; + readonly applyWslWindowsFallback: Effect.Effect< + DesktopSettingsChange, + DesktopSettingsWriteError + >; + readonly applyWslWindowsFallbackInMemory: Effect.Effect; } >()("@t3tools/desktop/settings/DesktopAppSettings") {} @@ -114,6 +154,10 @@ function normalizeTailscaleServePort(value: unknown): number { : DEFAULT_TAILSCALE_SERVE_PORT; } +function normalizeWslDistro(value: unknown): string | null { + return typeof value === "string" && isValidDistroName(value) ? value : null; +} + function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, @@ -125,6 +169,13 @@ function normalizeDesktopSettingsDocument( parsed.updateChannelConfiguredByUser === true || (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); + // Newer form wins when both are present; otherwise fall back to the legacy + // `wslMode === "wsl"` signal so users coming off the swap-mode build keep + // their WSL backend enabled. + const wslBackendEnabled = + parsed.wslBackendEnabled === true || + (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); + return { serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", @@ -134,6 +185,9 @@ function normalizeDesktopSettingsDocument( ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) : defaultSettings.updateChannel, updateChannelConfiguredByUser, + wslBackendEnabled, + wslDistro: normalizeWslDistro(parsed.wslDistro), + wslOnly: parsed.wslOnly === true, }; } @@ -158,6 +212,15 @@ function toDesktopSettingsDocument( if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; } + if (settings.wslBackendEnabled !== defaults.wslBackendEnabled) { + document.wslBackendEnabled = settings.wslBackendEnabled; + } + if (settings.wslDistro !== defaults.wslDistro) { + document.wslDistro = settings.wslDistro; + } + if (settings.wslOnly !== defaults.wslOnly) { + document.wslOnly = settings.wslOnly; + } return document; } @@ -204,6 +267,38 @@ function setUpdateChannel( }; } +function setWslBackendEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.wslBackendEnabled === enabled + ? settings + : { + ...settings, + wslBackendEnabled: enabled, + }; +} + +function setWslDistro(settings: DesktopSettings, distro: string | null): DesktopSettings { + const normalized = normalizeWslDistro(distro); + return settings.wslDistro === normalized + ? settings + : { + ...settings, + wslDistro: normalized, + }; +} + +function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.wslOnly === enabled + ? settings + : { + ...settings, + wslOnly: enabled, + }; +} + +function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { + return setWslOnly(setWslBackendEnabled(settings, false), false); +} + function readSettings( fileSystem: FileSystem.FileSystem, settingsPath: string, @@ -287,6 +382,12 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); + const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) => + SynchronizedRef.modify(settingsRef, (settings) => { + const nextSettings = update(settings); + return [settingsChange(nextSettings, nextSettings !== settings), nextSettings] as const; + }); + const persist = ( update: (settings: DesktopSettings) => DesktopSettings, ): Effect.Effect => @@ -342,6 +443,26 @@ export const make = Effect.gen(function* () { persist((settings) => setUpdateChannel(settings, channel)).pipe( Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), ), + setWslBackendEnabled: (enabled) => + persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), + ), + setWslDistro: (distro) => + persist((settings) => setWslDistro(settings, distro)).pipe( + Effect.withSpan("desktop.settings.setWslDistro", { + attributes: { distro: distro ?? null }, + }), + ), + setWslOnly: (enabled) => + persist((settings) => setWslOnly(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), + ), + applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( + Effect.withSpan("desktop.settings.applyWslWindowsFallback"), + ), + applyWslWindowsFallbackInMemory: updateInMemory(applyWslWindowsFallback).pipe( + Effect.withSpan("desktop.settings.applyWslWindowsFallbackInMemory"), + ), }); }); @@ -371,6 +492,12 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), + setWslBackendEnabled: (enabled) => + update((settings) => setWslBackendEnabled(settings, enabled)), + setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), + setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), + applyWslWindowsFallback: update(applyWslWindowsFallback), + applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); }), ); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 4c90afb2a12..696bd755506 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -13,7 +13,7 @@ import * as References from "effect/References"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; -import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; @@ -107,7 +107,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindow["Service"]); - const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { + const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: Effect.succeed("Windows"), start: Effect.void, stop: () => options.stopBackend ?? Effect.void, currentConfig: Effect.succeed(Option.none()), @@ -118,7 +120,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { restartAttempt: 0, restartScheduled: false, }), - }); + waitForReady: () => Effect.succeed(true), + }; + const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -152,6 +156,11 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) : DesktopAppSettings.layer; diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aecbdcfc3e8..aabb0830b0f 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -18,7 +18,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; @@ -241,7 +241,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const pool = yield* DesktopBackendPool.DesktopBackendPool; const desktopState = yield* DesktopState.DesktopState; const electronUpdater = yield* ElectronUpdater.ElectronUpdater; const electronWindow = yield* ElectronWindow.ElectronWindow; @@ -458,7 +458,19 @@ export const make = Effect.gen(function* () { yield* Ref.set(updateInstallInFlightRef, true); return yield* Effect.gen(function* () { - yield* backendManager.stop({ timeout: Duration.seconds(5) }); + // Stop every backend in the pool, not just the primary. With + // parallel WSL + Windows backends, leaving the WSL instance up + // means quitAndInstall's app.quit() exits before the pool's + // scope cascade has a chance to run its stop finalizer, so the + // WSL child gets hard-killed by the OS instead of receiving + // SIGTERM + grace. Stops run concurrently with the same 5s + // budget the primary had on its own. + const instances = yield* pool.list; + yield* Effect.forEach( + instances, + (instance) => instance.stop({ timeout: Duration.seconds(5) }), + { concurrency: "unbounded" }, + ); yield* electronWindow.destroyAll; yield* electronUpdater.quitAndInstall({ isSilent: true, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 04a1971ce46..ba77292fdc8 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -73,7 +73,9 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), activate: Effect.void, createMainIfBackendReady: Effect.void, - handleBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 7b9bdaf0886..280f2109fec 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -28,6 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -62,6 +63,7 @@ function makeFakeBrowserWindow() { }; const window = { + close: vi.fn(), focus: vi.fn(), isDestroyed: vi.fn(() => false), isMinimized: vi.fn(() => false), @@ -83,6 +85,7 @@ function makeFakeBrowserWindow() { loadURL: window.loadURL, openDevTools: webContents.openDevTools, reload: webContents.reload, + send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, }; @@ -191,6 +194,100 @@ function makeTestLayer(input: { ); } +// Builds a DesktopWindow over a fake ElectronWindow whose `create` returns the +// given outcomes in order (null => simulated open failure), and whose +// currentMainOrFirst mirrors the real fallback to the first live window (the +// splash, before any main is registered). Reveal targets are recorded so tests +// can assert what activation actually surfaced. +const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | null)[]) => + Effect.gen(function* () { + const createdWindows = yield* Ref.make([]); + const createCalls = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const revealedWindows = yield* Ref.make([]); + const fallbackWindow = createOutcomes.find( + (window): window is Electron.BrowserWindow => window !== null, + ); + + const currentMainOrFirst = Effect.gen(function* () { + const registered = yield* Ref.get(mainWindow); + if (Option.isSome(registered)) { + return registered; + } + const created = yield* Ref.get(createdWindows); + return Option.fromNullishOr(created[0] ?? null); + }); + + const electronWindowShape = { + create: () => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(createCalls, (count) => count + 1); + const outcome = createOutcomes[index] ?? null; + if (outcome === null) { + return yield* new ElectronWindow.ElectronWindowCreateError({ + options: { + title: null, + width: null, + height: null, + minWidth: null, + minHeight: null, + show: null, + modal: null, + frame: null, + transparent: null, + backgroundColor: null, + webPreferences: { + preload: null, + partition: null, + sandbox: null, + contextIsolation: null, + nodeIntegration: null, + webviewTag: null, + }, + }, + cause: new Error("simulated window-open failure"), + }); + } + yield* Ref.update(createdWindows, (windows) => [...windows, outcome]); + return outcome; + }), + main: Ref.get(mainWindow), + currentMainOrFirst, + focusedMainOrFirst: currentMainOrFirst, + setMain: (window) => Ref.set(mainWindow, Option.some(window)), + clearMain: () => Ref.set(mainWindow, Option.none()), + reveal: (window) => Ref.update(revealedWindows, (windows) => [...windows, window]), + sendAll: () => Effect.void, + destroyAll: Effect.void, + syncAllAppearance: (sync) => (fallbackWindow ? sync(fallbackWindow) : Effect.void), + } satisfies ElectronWindow.ElectronWindow["Service"]; + + const layer = DesktopWindow.layer.pipe( + Layer.provide( + Layer.mergeAll( + desktopAssetsLayer, + desktopEnvironmentLayer, + desktopServerExposureLayer, + electronMenuLayer, + Layer.succeed(ElectronShell.ElectronShell, { + openExternal: () => Effect.succeed(true), + copyText: () => Effect.void, + } satisfies ElectronShell.ElectronShell["Service"]), + electronThemeLayer, + Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), + Layer.mock(PreviewManager.PreviewManager)({ + getBrowserSession: () => Effect.succeed({} as Electron.Session), + setMainWindow: () => Effect.void, + isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), + }), + ), + ), + ); + + return { layer, createCalls, mainWindow, revealedWindows } as const; + }); + describe("DesktopWindow", () => { it("recognizes only same-origin renderer navigations", () => { assert.isTrue( @@ -231,7 +328,7 @@ describe("DesktopWindow", () => { yield* desktopWindow.activate; assert.equal(yield* Ref.get(createCount), 0); - yield* desktopWindow.handleBackendReady; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); assert.equal(yield* Ref.get(createCount), 1); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); @@ -254,7 +351,7 @@ describe("DesktopWindow", () => { yield* Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); @@ -323,7 +420,7 @@ describe("DesktopWindow", () => { yield* Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); const willNavigate = fakeWindow.webContentsListeners.get("will-navigate"); if (!willNavigate) { @@ -345,4 +442,108 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(layer)); }), ); + + it.effect( + "retries opening the real main on activate when a failed post-readiness open left only the splash", + () => + Effect.gen(function* () { + const splash = makeFakeBrowserWindow(); + const main = makeFakeBrowserWindow(); + // create #1 -> splash, #2 -> fails (the pool swallows this post-readiness + // window-open error), #3 -> the real main on activate's retry. + const scenario = yield* makeSplashScenario([splash.window, null, main.window]); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + + // 1. WSL-only boot shows the connecting splash. + yield* desktopWindow.showConnectingSplash; + assert.equal(yield* Ref.get(scenario.createCalls), 1); + + // 2. Backend reports ready, but opening the real main fails. The pool + // swallows that error in production, so handleBackendReady fails + // here without a registered main window -- only the splash is open. + const readyExit = yield* Effect.exit( + desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), + ); + assert.equal(readyExit._tag, "Failure"); + assert.equal(yield* Ref.get(scenario.createCalls), 2); + assert.isTrue(Option.isNone(yield* Ref.get(scenario.mainWindow))); + + // 3. Activating must not mistake the splash for the main window: it + // retries the open and brings up the real main instead of leaving + // the user stranded on "Connecting to WSL". + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(scenario.createCalls), 3); + const registeredMain = yield* Ref.get(scenario.mainWindow); + assert.isTrue(Option.isSome(registeredMain)); + assert.equal(Option.getOrThrow(registeredMain), main.window); + }).pipe(Effect.provide(scenario.layer)); + }), + ); + + it.effect( + "re-reveals the connecting splash on activate while the backend is still cold-booting", + () => + Effect.gen(function* () { + const splash = makeFakeBrowserWindow(); + // Only the splash is ever created; the backend never reports ready. + const scenario = yield* makeSplashScenario([splash.window]); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + + yield* desktopWindow.showConnectingSplash; + assert.equal(yield* Ref.get(scenario.createCalls), 1); + + // Taskbar/dock activation during cold boot must bring the splash back + // rather than no-op and leave it hidden until the backend finishes. + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(scenario.createCalls), 1); + assert.deepEqual(yield* Ref.get(scenario.revealedWindows), [splash.window]); + }).pipe(Effect.provide(scenario.layer)); + }), + ); + + it.effect("does not dispatch menu actions to the splash before the backend is ready", () => + Effect.gen(function* () { + const splash = makeFakeBrowserWindow(); + const main = makeFakeBrowserWindow(); + const scenario = yield* makeSplashScenario([splash.window, main.window]); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + + yield* desktopWindow.showConnectingSplash; + yield* desktopWindow.dispatchMenuAction("open-settings"); + + assert.equal(yield* Ref.get(scenario.createCalls), 1); + assert.equal(splash.send.mock.calls.length, 0); + assert.equal(main.send.mock.calls.length, 0); + }).pipe(Effect.provide(scenario.layer)); + }), + ); + + it.effect("dispatches menu actions after backend readiness when no main window exists", () => + Effect.gen(function* () { + const splash = makeFakeBrowserWindow(); + const main = makeFakeBrowserWindow(); + const scenario = yield* makeSplashScenario([splash.window, null, main.window]); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + + yield* desktopWindow.showConnectingSplash; + const readyExit = yield* Effect.exit( + desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), + ); + assert.equal(readyExit._tag, "Failure"); + + yield* desktopWindow.dispatchMenuAction("open-settings"); + + assert.equal(yield* Ref.get(scenario.createCalls), 3); + assert.deepEqual(main.send.mock.calls, [[MENU_ACTION_CHANNEL, "open-settings"]]); + }).pipe(Effect.provide(scenario.layer)); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 41bbbfd5944..b1dfabe7ab4 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -10,7 +10,6 @@ import type * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; -import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; @@ -42,7 +41,6 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets - | DesktopState.DesktopState | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -61,7 +59,22 @@ export class DesktopWindow extends Context.Service< readonly revealOrCreateMain: Effect.Effect; readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; - readonly handleBackendReady: Effect.Effect; + // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only + // mode), before the WSL backend that serves the renderer is ready. It is + // dismissed automatically once the real main window reveals. + readonly showConnectingSplash: Effect.Effect; + // Marks the primary backend as ready so `createMainIfBackendReady` and the + // macOS "activate without windows" path may open the real main window. The + // renderer now always loads the local client URL (getDesktopUrl) and connects + // to the backend through the connection layer, so the reported httpBaseUrl is + // no longer used to point the window at the backend — it is kept only for the + // readiness log and to preserve the callback contract the backend pool drives. + readonly handleBackendReady: (httpBaseUrl: URL) => Effect.Effect; + // Called when the backend transitions back to "not ready" (clean stop, + // restart, crash). Clears the latch that lets `activate` auto-create a + // window so a "macOS dock click" while the backend is down doesn't + // produce a stranded window pointing at nothing. + readonly handleBackendNotReady: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -86,6 +99,18 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } +// A self-contained "Connecting to WSL" splash, shown immediately in wsl-only +// mode while the WSL backend (which serves the renderer) cold-boots. Inlined as +// a data URL so it needs no bundled asset and no backend — pure CSS, no JS. +function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { + const background = getInitialWindowBackgroundColor(shouldUseDarkColors); + const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; + const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; + const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; + const html = `
    Connecting to WSL…
    `; + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} + export function isSameOriginRendererNavigation(input: { readonly applicationUrl: string; readonly navigationUrl: string; @@ -177,11 +202,45 @@ export const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; - const state = yield* DesktopState.DesktopState; + // Window-side latch for the primary backend's readiness. Set by + // handleBackendReady (driven by the pool's onReady callback), cleared + // by handleBackendNotReady (driven by onShutdown). Only consumed by + // createMainIfBackendReady, which gates the post-readiness window + // open in development and the macOS "activate without windows" path. + const backendReadyRef = yield* Ref.make(false); + // The transient "Connecting to WSL" splash window, tracked separately so it + // is never mistaken for the real main window. + const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + const dismissConnectingSplash = Effect.gen(function* () { + const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); + if (Option.isSome(splash) && !splash.value.isDestroyed()) { + splash.value.close(); + } + }); + + // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", + // which during WSL-only boot is the connecting splash. The splash is never + // registered via setMain, so it must be treated as "no real main window" -- + // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never + // open (or retry) the real main. That is the failure the pool's swallowed + // post-readiness window-open error would otherwise strand the user in: + // splash up, backend ready, no main, and activation only re-reveals splash. + const withoutSplash = (window: Option.Option) => + Ref.get(splashWindowRef).pipe( + Effect.map((splash) => + Option.isSome(splash) && Option.isSome(window) && window.value === splash.value + ? Option.none() + : window, + ), + ); + + const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash)); + const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); + const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< Electron.BrowserWindow, DesktopWindowError @@ -402,7 +461,9 @@ export const make = Effect.gen(function* () { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } bindFirstRevealTrigger(revealSubscribers, () => { - void runPromise(electronWindow.reveal(window)); + // Reveal the real window, then close the connecting splash (if any) so the + // two don't overlap and there's no blank gap between them. + void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); }); loadApplication(); @@ -426,7 +487,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.createMain")); const ensureMain = Effect.gen(function* () { - const existingWindow = yield* electronWindow.currentMainOrFirst; + const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { return existingWindow.value; } @@ -440,35 +501,101 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); const createMainIfBackendReady = Effect.gen(function* () { - const backendReady = yield* Ref.get(state.backendReady); + const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) return; - const existingWindow = yield* electronWindow.currentMainOrFirst; + const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) return; yield* createMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); + const showConnectingSplash = Effect.gen(function* () { + // Only when nothing is shown yet: no real window, no existing splash. + const existingSplash = yield* Ref.get(splashWindowRef); + if (Option.isSome(existingSplash)) return; + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) return; + + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const splash = yield* electronWindow.create({ + width: 360, + height: 220, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + frame: false, + center: true, + show: false, + skipTaskbar: false, + backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + title: environment.displayName, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + yield* Ref.set(splashWindowRef, Option.some(splash)); + splash.once("closed", () => { + void runPromise(Ref.set(splashWindowRef, Option.none())); + }); + splash.once("ready-to-show", () => { + if (!splash.isDestroyed()) { + splash.show(); + } + }); + void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); + yield* logWindowInfo("connecting splash shown"); + }).pipe( + // The splash is best-effort UX — never let it fail startup. + Effect.catch((error) => + logWindowWarning("failed to show connecting splash", { message: error.message }), + ), + Effect.withSpan("desktop.window.showConnectingSplash"), + ); + return DesktopWindow.of({ createMain, ensureMain, revealOrCreateMain, activate: Effect.gen(function* () { - const existingWindow = yield* electronWindow.currentMainOrFirst; + const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { yield* electronWindow.reveal(existingWindow.value); - } else { - yield* createMainIfBackendReady; + return; + } + // No real main window yet. While the backend is still cold-booting, + // re-reveal the connecting splash so taskbar/dock activation brings it + // back instead of doing nothing. Once the backend is ready we fall + // through to (re)create the real main -- including retrying a previously + // failed open the pool swallowed -- rather than latching onto the splash. + const backendReady = yield* Ref.get(backendReadyRef); + if (!backendReady) { + const splash = yield* Ref.get(splashWindowRef); + if (Option.isSome(splash)) { + yield* electronWindow.reveal(splash.value); + return; + } } + yield* createMainIfBackendReady; }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, - handleBackendReady: Effect.gen(function* () { - yield* Ref.set(state.backendReady, true); - yield* logWindowInfo("backend ready", { source: "http" }); + showConnectingSplash, + handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { + yield* Ref.set(backendReadyRef, true); + yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); yield* createMainIfBackendReady; - }).pipe(Effect.withSpan("desktop.window.handleBackendReady")), + }), + handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( + Effect.withSpan("desktop.window.handleBackendNotReady"), + ), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); - const existingWindow = yield* electronWindow.focusedMainOrFirst; - const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain; + const existingWindow = yield* focusedMainWindow; + if (Option.isNone(existingWindow) && !(yield* Ref.get(backendReadyRef))) { + return; + } + const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; const send = () => { if (targetWindow.isDestroyed()) return; diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts new file mode 100644 index 00000000000..2f58c6adcfb --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -0,0 +1,199 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as NetService from "@t3tools/shared/Net"; + +import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; +import type { + DesktopBackendSnapshot, + DesktopBackendStartConfig, +} from "../backend/DesktopBackendManager.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; +import * as DesktopWslBackend from "./DesktopWslBackend.ts"; + +function makeStubInstance(input: { + readonly id: DesktopBackendPool.BackendInstanceId; + readonly label: string; + readonly snapshot: DesktopBackendSnapshot; + readonly start?: Effect.Effect; +}): DesktopBackendPool.DesktopBackendInstance { + return { + id: input.id, + label: Effect.succeed(input.label), + start: input.start ?? Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed(input.snapshot), + waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), + }; +} + +const idleSnapshot: DesktopBackendSnapshot = { + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 5, + restartScheduled: false, +}; + +const primarySnapshot: DesktopBackendSnapshot = { + desiredRunning: true, + ready: true, + activePid: Option.some(123), + restartAttempt: 0, + restartScheduled: false, +}; + +const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.succeed({ + port: 3773, + bindHost: "127.0.0.1", + httpBaseUrl: new URL("http://127.0.0.1:3773"), + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.succeed([]), +} satisfies DesktopServerExposure.DesktopServerExposure["Service"]); + +const backendConfigurationLayer = Layer.succeed( + DesktopBackendConfiguration.DesktopBackendConfiguration, + { + resolvePrimary: Effect.die("unexpected resolvePrimary"), + resolvePrimaryLabel: Effect.succeed("Windows"), + resolveWsl: () => Effect.die("unexpected resolveWsl"), + } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"], +); + +const netLayer = Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(41773), + findAvailablePort: (preferred) => Effect.succeed(preferred), +} satisfies NetService.NetService["Service"]); + +describe("DesktopWslBackend", () => { + it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { + let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; + const primary = makeStubInstance({ + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: "Windows", + snapshot: primarySnapshot, + }); + const wsl = makeStubInstance({ + id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), + label: "WSL (Ubuntu)", + snapshot: primarySnapshot, + }); + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + get: (id) => + Effect.succeed( + id === DesktopBackendPool.PRIMARY_INSTANCE_ID + ? Option.some(primary) + : Option.none(), + ), + list: Effect.succeed([primary]), + primary: Effect.succeed(primary), + register: (spec) => + Effect.sync(() => { + registeredSpec = spec; + return wsl; + }), + unregister: () => Effect.die("unexpected unregister"), + } satisfies DesktopBackendPool.DesktopBackendPool["Service"]); + + return Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + + yield* backend.reconcile; + const spec = registeredSpec; + assert.isDefined(spec); + if (spec === undefined) { + throw new Error("Expected WSL backend registration"); + } + const recordFailure = spec.onPreflightFailed; + const clearFailure = spec.onReady; + assert.isDefined(recordFailure); + assert.isDefined(clearFailure); + if (recordFailure === undefined || clearFailure === undefined) { + throw new Error("Expected WSL backend callbacks"); + } + + assert.isFalse(yield* recordFailure({ reason: "Node.js not found", fatal: true })); + assert.deepEqual(yield* backend.lastPreflightError, Option.some("Node.js not found")); + + yield* clearFailure(new URL("http://127.0.0.1:41773")); + assert.deepEqual(yield* backend.lastPreflightError, Option.none()); + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provideMerge(poolLayer), + Layer.provideMerge(backendConfigurationLayer), + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(netLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: false, + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), + ), + ), + ); + }); + + it.effect("retries an unchanged WSL instance when it is idle after failed preflight", () => { + let startCount = 0; + const primary = makeStubInstance({ + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: "Windows", + snapshot: primarySnapshot, + }); + const wsl = makeStubInstance({ + id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), + label: "WSL (Ubuntu)", + snapshot: idleSnapshot, + start: Effect.sync(() => { + startCount += 1; + }), + }); + + return Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + + yield* backend.reconcile; + + assert.equal(startCount, 1); + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provideMerge(DesktopBackendPool.layerTest([primary, wsl])), + Layer.provideMerge(backendConfigurationLayer), + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(netLayer), + Layer.provideMerge( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: false, + }), + ), + Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), + ), + ), + ); + }); +}); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts new file mode 100644 index 00000000000..605f4e7a477 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -0,0 +1,268 @@ +// Orchestrator that keeps the WSL pool instance in sync with the user's +// settings. `reconcile` is the single entry point — bootstrap calls it +// once after the primary backend starts, and the wsl.ts IPC calls it +// after persisting a `wslBackendEnabled` or `wslDistro` change. The +// effect is idempotent and never fails: errors (WSL not available, port +// allocation failed, register failed) get logged and reconcile returns +// having left the pool in a consistent state (either the previous WSL +// instance is still running, or none is). +// +// The instance id encodes the desired distro selection — `wsl:default` +// when the user picked "track the WSL default" (settings.wslDistro is +// null) and `wsl:` otherwise. Changing the distro setting +// changes the id, so reconcile unregisters the old instance before +// registering the new one. The label that the frontend env switcher +// renders is derived from the same field. +// +// Port allocation: each WSL instance gets a freshly scanned port to +// avoid colliding with the primary or with a previously-registered WSL +// instance that's still tearing down. The scan only checks loopback +// (127.0.0.1) since the WSL backend is loopback-only — the primary +// owns LAN exposure when the user opts in. + +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +import * as NetService from "@t3tools/shared/Net"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; + +// Exported so callers that parse pool ids (e.g. the pickFolder IPC +// handler in ipc/methods/window.ts) reference the same prefix this +// module produces. Keeping it inline in two places risks silent +// divergence if one ever gets renamed. +export const WSL_INSTANCE_ID_PREFIX = "wsl:"; +const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; +const MAX_TCP_PORT = 65_535; + +export class DesktopWslBackend extends Context.Service< + DesktopWslBackend, + { + // Bring the pool in line with the current persisted WSL settings. + // Idempotent. Never fails (errors are logged); callers can chain it + // after persisting settings without an error-handling dance. + readonly reconcile: Effect.Effect; + // Reason the dual-mode WSL secondary last failed preflight (no node, wrong + // version, missing build tools), or None. Read by the getWslState IPC so + // Connections settings can show it inline. None in wsl-only mode (that path + // surfaces via a dialog + Windows fallback). + readonly lastPreflightError: Effect.Effect>; + } +>()("@t3tools/desktop/wsl/DesktopWslBackend") {} + +const { logInfo: logWslBackendInfo, logWarning: logWslBackendWarning } = + DesktopObservability.makeComponentLogger("desktop-wsl-backend"); + +const resolveTargetInstanceId = (distro: string | null): DesktopBackendPool.BackendInstanceId => + DesktopBackendPool.BackendInstanceId( + distro === null ? WSL_DEFAULT_DISTRO_ID : `${WSL_INSTANCE_ID_PREFIX}${distro}`, + ); + +const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean => + id.startsWith(WSL_INSTANCE_ID_PREFIX); + +const buildLabel = (distro: string | null): string => + distro === null ? "WSL (default distro)" : `WSL (${distro})`; + +// Loopback-only port scan starting one above the primary's port. The +// WSL backend is reachable via 127.0.0.1 from Windows (wslhost +// auto-forwards), so we only need to verify the IPv4 loopback can bind. +const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* ( + startPort: number, +): Effect.fn.Return { + const net = yield* NetService.NetService; + for (let port = startPort; port <= MAX_TCP_PORT; port += 1) { + if (yield* net.canListenOnHost(port, "127.0.0.1")) { + return port; + } + } + return yield* new NetService.NetError({ + message: `No loopback port available for WSL backend between ${startPort} and ${MAX_TCP_PORT}.`, + }); +}); + +export const layer = Layer.effect( + DesktopWslBackend, + Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const net = yield* NetService.NetService; + // Serialize reconcile so the bootstrap fork and the IPC handlers + // (setWslBackendEnabled, setWslDistro) can't interleave. Without + // this, two reconciles could both observe "no WSL instance + // registered" between their pool reads and both call startNew + // with different distros, leaving the loser stranded. + const reconcileMutex = yield* Semaphore.make(1); + + // Last fatal preflight failure from the dual-mode WSL *secondary*, surfaced + // inline in Connections settings. The primary's failure is handled by the + // pool (dialog + Windows fallback) instead; here the app stays usable on + // Windows, so we record the reason rather than interrupting. Cleared on any + // reconcile state change so it reflects the current attempt. + const preflightErrorRef = yield* Ref.make(Option.none()); + + const findExistingWslInstance = pool.list.pipe( + Effect.map((instances) => instances.find((instance) => isWslInstanceId(instance.id))), + Effect.map(Option.fromNullishOr), + ); + + const stopExisting = (id: DesktopBackendPool.BackendInstanceId) => + pool.unregister(id).pipe( + Effect.catchTags({ + DesktopBackendPoolCannotUnregisterPrimaryError: (cause) => + // Should never happen — wsl: ids are not the primary id — but + // log loudly if the logic ever drifts. + logWslBackendWarning("refusing to unregister primary as wsl instance", { + id, + error: cause.message, + }), + }), + ); + + const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: { + readonly distro: string | null; + }) { + const primaryConfig = yield* serverExposure.backendConfig; + const port = yield* scanForWslPort(primaryConfig.port + 1).pipe( + Effect.provideService(NetService.NetService, net), + Effect.map((value) => Option.some(value)), + Effect.catch((error) => + logWslBackendWarning("could not allocate port for WSL backend", { + error: error.message, + }).pipe(Effect.as(Option.none())), + ), + ); + + if (Option.isNone(port)) { + return; + } + const allocatedPort = port.value; + + const targetId = resolveTargetInstanceId(input.distro); + yield* logWslBackendInfo("registering WSL backend with pool", { + id: targetId, + port: allocatedPort, + distro: input.distro ?? null, + }); + + const instance = yield* pool + .register({ + id: targetId, + label: Effect.succeed(buildLabel(input.distro)), + configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }), + // Dual-mode secondary: record a fatal preflight failure so Connections + // settings can show why the WSL backend never appeared. No dialog or + // fallback — Windows is the primary and keeps working. + onPreflightFailed: (failure) => + Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)), + onReady: () => Ref.set(preflightErrorRef, Option.none()), + }) + .pipe( + Effect.map((registered) => Option.some(registered)), + Effect.catch((error) => + logWslBackendWarning("WSL backend already registered, skipping start", { + id: targetId, + error: error.message, + }).pipe(Effect.as(Option.none())), + ), + ); + + yield* Option.match(instance, { + onNone: () => Effect.void, + onSome: (registered) => registered.start, + }); + }); + + const reconcileBody = Effect.gen(function* () { + const settings = yield* appSettings.get; + const available = yield* wslEnvironment.isAvailable; + const existing = yield* findExistingWslInstance; + const existingId = Option.map(existing, (instance) => instance.id); + + // In wsl-only mode the pool's primary IS the WSL backend (see + // DesktopBackendConfiguration.resolvePrimary), so the + // orchestrator skips registering a parallel "wsl:" + // secondary. Without this skip we'd spin up two WSL processes + // on the same distro for users who explicitly asked for one. + const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly; + const targetId = shouldRun + ? Option.some(resolveTargetInstanceId(settings.wslDistro)) + : Option.none(); + + // No-op if the desired state already matches what's registered. + if (Option.isNone(targetId) && Option.isNone(existingId)) { + return; + } + if ( + Option.isSome(targetId) && + Option.isSome(existing) && + targetId.value === existing.value.id + ) { + const existingInstance = existing.value; + const snapshot = yield* existingInstance.snapshot; + const isIdle = + !snapshot.ready && Option.isNone(snapshot.activePid) && !snapshot.restartScheduled; + if (isIdle) { + yield* logWslBackendInfo("retrying idle WSL backend", { id: existingInstance.id }); + yield* Ref.set(preflightErrorRef, Option.none()); + yield* existingInstance.start; + } + return; + } + + // A real state change is happening (start, stop, or distro swap). Clear + // any stale secondary preflight error so it reflects this fresh attempt; + // onPreflightFailed re-sets it only if the new secondary exhausts retries. + yield* Ref.set(preflightErrorRef, Option.none()); + + if (Option.isSome(existingId)) { + yield* logWslBackendInfo("tearing down WSL backend", { id: existingId.value }); + yield* stopExisting(existingId.value); + } + + if (Option.isSome(targetId)) { + // Pre-warm the WSL VM before registering so the readiness probe + // doesn't race wsl.exe's first-spawn cold start. preWarm tolerates + // distro=null (uses the WSL default) and is bounded by its own + // timeout, so it's safe to await unconditionally here. + yield* wslEnvironment.preWarm(settings.wslDistro); + yield* startNew({ distro: settings.wslDistro }); + } + }); + + // Top-level safety net. Every internal step today already catches + // its own failures (port allocation, register, preWarm), so the + // inferred error type is `never` and this catch is a no-op in + // steady state. It's here to enforce the file-header contract + // ("reconcile never fails; errors are logged") if a future change + // introduces an unhandled failure path — otherwise IPC callers + // like setWslBackendEnabled would surface it to the renderer as + // an opaque error. + const reconcile = reconcileMutex + .withPermits(1)(reconcileBody) + .pipe( + Effect.catchCause((cause) => + logWslBackendWarning("reconcile failed", { cause: Cause.pretty(cause) }), + ), + Effect.withSpan("desktop.wslBackend.reconcile"), + ); + + return DesktopWslBackend.of({ + reconcile, + lastPreflightError: Ref.get(preflightErrorRef), + }); + }), +); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts new file mode 100644 index 00000000000..cdb3fc78286 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -0,0 +1,237 @@ +import { describe, it } from "@effect/vitest"; +import { expect } from "vite-plus/test"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildWslNodeEnvPreamble, + DesktopWslDistroListError, + formatMissingToolsReason, + formatNodePtyProbeFailureReason, + formatWslShellTransportFailureReason, + parseNodePath, + parseResolvedPath, + parseToolchainReport, + probeWslDistros, +} from "./DesktopWslEnvironment.ts"; + +const encoder = new TextEncoder(); + +const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: + result.exitCode === undefined + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), + isRunning: Effect.succeed(result.exitCode === undefined), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout ?? "")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + +describe("probeWslDistros", () => { + it.effect("preserves a successful empty distro list", () => + Effect.gen(function* () { + const distros = yield* probeWslDistros; + expect(distros).toEqual([]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeDistroListSpawner({ stdout: "", exitCode: 0 }), + ), + ), + ); + + it.effect("fails when the distro-list command exits unsuccessfully", () => + Effect.gen(function* () { + const error = yield* probeWslDistros.pipe(Effect.flip); + expect(error).toBeInstanceOf(DesktopWslDistroListError); + expect(error.message).toContain("exited with code 1"); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeDistroListSpawner({ exitCode: 1 }), + ), + ), + ); + + it.effect("fails when the distro-list command times out", () => { + const layer = Layer.merge( + TestClock.layer(), + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})), + ); + return Effect.gen(function* () { + const fiber = yield* probeWslDistros.pipe(Effect.flip, Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(8)); + const error = yield* Fiber.join(fiber); + expect(error).toBeInstanceOf(DesktopWslDistroListError); + expect(error.message).toContain("timed out"); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("formatNodePtyProbeFailureReason", () => { + it("identifies a packaged build that omitted the Linux node-pty prebuild", () => { + const reason = formatNodePtyProbeFailureReason(4); + + expect(reason).toContain("packaged Linux node-pty binary was not included"); + expect(reason).toContain("--wsl-prebuild"); + }); + + it("leaves other node-pty load failures to the compatibility diagnostic", () => { + expect(formatNodePtyProbeFailureReason(1)).toBeNull(); + }); +}); + +describe("formatWslShellTransportFailureReason", () => { + it("distinguishes timeouts and spawn failures from normal shell exit codes", () => { + expect(formatWslShellTransportFailureReason("timeout")).toContain("timed out"); + expect(formatWslShellTransportFailureReason("spawn")).toContain("could not start wsl.exe"); + expect(formatWslShellTransportFailureReason("process")).toContain("lost communication"); + expect(formatWslShellTransportFailureReason(null)).toBeNull(); + }); +}); + +describe("buildWslNodeEnvPreamble", () => { + it("passes the required Node engine range into the shared resolver", () => { + const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10"); + + expect(preamble).toContain("T3_NODE_ENGINE_RANGE='^22.16 || ^23.11 || >=24.10'"); + expect(preamble.indexOf("T3_NODE_ENGINE_RANGE=")).toBeLessThan( + preamble.lastIndexOf("ensure_remote_node_path || true"), + ); + }); + + it("keeps the shared resolver permissive when no Node engine range is provided", () => { + expect(buildWslNodeEnvPreamble()).toContain("T3_NODE_ENGINE_RANGE=''"); + }); +}); + +describe("parseToolchainReport", () => { + it("returns no missing tools and no node version on empty output", () => { + expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); + }); + + it("collects all missing: lines", () => { + const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n"); + expect(parseToolchainReport(stdout)).toEqual({ + missingTools: ["make", "g++"], + nodeVersion: "24.10.0", + }); + }); + + it("ignores blank lines and trims whitespace", () => { + const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n"); + expect(parseToolchainReport(stdout)).toEqual({ + missingTools: ["python3"], + nodeVersion: "v22.16.0", + }); + }); + + it("returns null node version when value after prefix is empty", () => { + expect(parseToolchainReport("nodeVersion:")).toEqual({ + missingTools: [], + nodeVersion: null, + }); + }); +}); + +describe("parseNodePath", () => { + it("extracts the absolute node path from a nodePath: line", () => { + const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node"; + expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node"); + }); + + it("returns null when node was not found (empty value after prefix)", () => { + expect(parseNodePath("nodePath:")).toBeNull(); + }); + + it("returns null when there is no nodePath line at all", () => { + expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull(); + }); + + it("ignores surrounding noise and trims whitespace", () => { + const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n"); + expect(parseNodePath(stdout)).toBe("/usr/bin/node"); + }); +}); + +describe("parseResolvedPath", () => { + it("preserves spaces and apostrophes in the resolved login-shell PATH", () => { + const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; + expect(parseResolvedPath(`nodePath:/usr/bin/node\nresolvedPath:${resolvedPath}\n`)).toBe( + resolvedPath, + ); + }); + + it("accepts CRLF output without retaining the carriage return", () => { + expect(parseResolvedPath("resolvedPath:/usr/local/bin:/usr/bin\r\n")).toBe( + "/usr/local/bin:/usr/bin", + ); + }); + + it("returns null when the resolved PATH is absent or empty", () => { + expect(parseResolvedPath("nodePath:/usr/bin/node\n")).toBeNull(); + expect(parseResolvedPath("resolvedPath:\n")).toBeNull(); + }); +}); + +describe("formatMissingToolsReason", () => { + it("returns null when everything is present and node is in range", () => { + expect( + formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"), + ).toBeNull(); + }); + + it("returns null when range is not specified and tools are present", () => { + expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull(); + }); + + it("flags missing node first", () => { + const reason = formatMissingToolsReason( + { missingTools: ["node", "make"], nodeVersion: null }, + "^24.10", + ); + expect(reason).toContain("node"); + expect(reason).toContain("^24.10"); + expect(reason).toContain("make"); + expect(reason).toContain("nvm"); + }); + + it("flags an out-of-range node version with the actual version surfaced", () => { + const reason = formatMissingToolsReason( + { missingTools: [], nodeVersion: "20.0.0" }, + "^24.10 || ^22.16", + ); + expect(reason).toContain("node 20.0.0"); + expect(reason).toContain("requires ^24.10 || ^22.16"); + }); + + it("flags missing build tools without node when node is fine", () => { + const reason = formatMissingToolsReason( + { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" }, + "^24.10", + ); + expect(reason).toContain("g++"); + expect(reason).toContain("python3"); + expect(reason).toContain("build-essential"); + expect(reason).not.toContain("nvm"); + }); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts new file mode 100644 index 00000000000..b38675f0e1f --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -0,0 +1,849 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; + +const PROCESS_TERMINATE_GRACE = Duration.seconds(1); +const LIST_TIMEOUT = Duration.seconds(8); +const PRE_WARM_TIMEOUT = Duration.seconds(10); +const WSLPATH_TIMEOUT = Duration.seconds(10); +const PROBE_TIMEOUT = Duration.seconds(10); +const TOOLCHAIN_TIMEOUT = Duration.seconds(10); +const BUILD_TIMEOUT = Duration.minutes(5); +const USER_HOME_TIMEOUT = Duration.seconds(5); +const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; +const BUILD_TRANSPORT_RETRY_LIMIT = 2; + +export interface EnsureWslNodePtyOptions { + readonly allowBuild?: boolean; + readonly nodeEngineRange?: string | null; +} + +export type EnsureWslNodePtyResult = + | { readonly ok: true; readonly nodePath: string; readonly resolvedPath: string } + | { + readonly ok: false; + readonly reason: string; + readonly fatal: boolean; + readonly retryLimit?: number; + }; + +export class DesktopWslDistroListError extends Schema.TaggedErrorClass()( + "DesktopWslDistroListError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + +const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError); + +export class DesktopWslEnvironment extends Context.Service< + DesktopWslEnvironment, + { + readonly isAvailable: Effect.Effect; + // Best-effort enumeration for renderer UX. Backend health checks must use + // probeDistros so a transient command failure is not mistaken for a + // successful empty installation. + readonly listDistros: Effect.Effect; + readonly probeDistros: Effect.Effect; + readonly preWarm: (distro: string | null) => Effect.Effect; + readonly windowsToWslPath: ( + distro: string | null, + windowsPath: string, + ) => Effect.Effect>; + // Resolves the user's Linux home dir inside the chosen distro (e.g. + // "/home/josh"). Used by the folder picker to expand `~` correctly. + readonly getUserHome: (distro: string | null) => Effect.Effect>; + // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter + // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's + // httpBaseUrl so the renderer can reach it without relying on wslhost's + // localhost→WSL automatic forwarding, which is flaky in practice + // (the backend can be listening for 30+ seconds before wslhost starts + // forwarding 127.0.0.1:port to WSL-side localhost). + readonly getDistroIp: (distro: string | null) => Effect.Effect>; + readonly ensureNodePty: ( + distro: string | null, + windowsRepoRoot: string, + options?: EnsureWslNodePtyOptions, + ) => Effect.Effect; + } +>()("@t3tools/desktop/wsl/DesktopWslEnvironment") {} + +const buildDistroArgs = (distro: string | null): ReadonlyArray => + distro ? ["-d", distro] : []; + +const concatChunks = (arrays: ReadonlyArray): Uint8Array => { + let totalLength = 0; + for (const arr of arrays) totalLength += arr.byteLength; + const out = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + out.set(arr, offset); + offset += arr.byteLength; + } + return out; +}; + +const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes); + +interface ShellResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly transportFailure: "timeout" | "spawn" | "process" | null; +} + +const TIMEOUT_RESULT: ShellResult = { + exitCode: 124, + stdout: "", + stderr: "\n[timeout]", + transportFailure: "timeout", +}; + +export const formatWslShellTransportFailureReason = ( + failure: ShellResult["transportFailure"], +): string | null => { + switch (failure) { + case "timeout": + return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; + case "spawn": + return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; + case "process": + return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; + case null: + return null; + } +}; + +// Reuse the SSH remote resolver so WSL and SSH discover version-managed Node +// the same way. Passing the engine range lets the resolver fall through to +// version managers like nvm when a system node exists but is too old. +export const buildWslNodeEnvPreamble = ( + nodeEngineRange?: string | null, +): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} +ensure_remote_node_path || true +`; + +// wsl.exe re-escapes args before forwarding them to the Linux side, which +// mangles quotes inside `bash -lc "