From 97db94c9bf6fa5d83f94c8fff85566d7fc96276e Mon Sep 17 00:00:00 2001
From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com>
Date: Thu, 13 Aug 2026 15:45:33 +0300
Subject: [PATCH 001/144] fix(web): keep pull request panel within viewport
(#6451)
---
.../src/components/preview/PreviewPanelShell.test.ts | 12 +++++++++++-
.../web/src/components/preview/PreviewPanelShell.tsx | 2 +-
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts
index 4ac086157a2f..31258b166bdf 100644
--- a/apps/web/src/components/preview/PreviewPanelShell.test.ts
+++ b/apps/web/src/components/preview/PreviewPanelShell.test.ts
@@ -1,6 +1,8 @@
+import { jsx } from "react/jsx-runtime";
+import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";
-import { getPreviewPanelMaxWidth } from "./PreviewPanelShell";
+import { getPreviewPanelMaxWidth, PreviewPanelShell } from "./PreviewPanelShell";
describe("getPreviewPanelMaxWidth", () => {
it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => {
@@ -10,4 +12,12 @@ describe("getPreviewPanelMaxWidth", () => {
it("rounds fractional CSS pixels down", () => {
expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400);
});
+
+ it("keeps inline panels inside their containing workspace", () => {
+ const markup = renderToStaticMarkup(
+ jsx(PreviewPanelShell, { mode: "inline", defaultWidth: 1_000, children: "Panel" }),
+ );
+
+ expect(markup).toContain("max-w-full");
+ });
});
diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx
index 30a0c9eed0ff..17ca389feab2 100644
--- a/apps/web/src/components/preview/PreviewPanelShell.tsx
+++ b/apps/web/src/components/preview/PreviewPanelShell.tsx
@@ -51,7 +51,7 @@ export function PreviewPanelShell(props: {
return (
Date: Thu, 13 Aug 2026 10:26:36 -0400
Subject: [PATCH 002/144] Add bil0000 to VOUCHED contributors list (#6462)
---
.github/VOUCHED.td | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td
index 29910f522516..71e576e5c7e4 100644
--- a/.github/VOUCHED.td
+++ b/.github/VOUCHED.td
@@ -38,3 +38,4 @@ github:jappyjan
github:justsomelegs
github:UtkarshUsername
github:SunkenInTime
+github:bil0000
From 2ab188f1c0afe99ba269739704e2e96f0bbbe78f Mon Sep 17 00:00:00 2001
From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com>
Date: Thu, 13 Aug 2026 14:51:31 +0000
Subject: [PATCH 003/144] fix: ignore pull request actions in latency tracker
(#6476)
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
---
apps/web/src/rpc/requestLatencyState.test.ts | 10 ++++++++++
apps/web/src/rpc/requestLatencyState.ts | 6 +++++-
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts
index e5b3144d2520..68433035fd18 100644
--- a/apps/web/src/rpc/requestLatencyState.test.ts
+++ b/apps/web/src/rpc/requestLatencyState.test.ts
@@ -59,6 +59,16 @@ describe("requestLatencyState", () => {
expect(getSlowRpcAckRequests()).toEqual([]);
});
+ it.each(Object.values(WS_METHODS).filter((method) => method.startsWith("pullRequests.")))(
+ "ignores pull request workspace request %s",
+ (method) => {
+ trackRpcRequestSent("1", method);
+ vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);
+
+ expect(getSlowRpcAckRequests()).toEqual([]);
+ },
+ );
+
it("keeps ignoring untracked methods when a display tag is supplied", () => {
trackRpcRequestSent(
"1",
diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts
index 4736d3783c3b..4ec5b56f9e2b 100644
--- a/apps/web/src/rpc/requestLatencyState.ts
+++ b/apps/web/src/rpc/requestLatencyState.ts
@@ -49,7 +49,11 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray
{
}
function shouldTrackRpcAck(method: string): boolean {
- return !method.includes("subscribe") && !untrackedRpcAckMethods.has(method);
+ return (
+ !method.includes("subscribe") &&
+ !method.startsWith("pullRequests.") &&
+ !untrackedRpcAckMethods.has(method)
+ );
}
function rpcAckThresholdMs(method: string): number {
From 9e201941aaa9cfece3e0ffaa4cc24bbe880d1be4 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 13 Aug 2026 17:04:42 +0200
Subject: [PATCH 004/144] Remove rebase requirement before opening PR (#6479)
---
AGENTS.md | 1 -
1 file changed, 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 1b41f833ce58..12f357747991 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -114,7 +114,6 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real
- Never make a PR unless the developer explicitly asks you to do so.
- Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`.
- Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work.
-- **Rebase onto latest main before opening.** Stale branches conflict and burn a review round.
- UI changes need before/after images. Motion or timing needs a short video.
- One concern per PR. If the description says "also", split it.
- When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit.
From fd51561b4e2de1893cb7eb4069937256d702572c Mon Sep 17 00:00:00 2001
From: Chris Deeming
Date: Thu, 13 Aug 2026 19:00:03 +0100
Subject: [PATCH 005/144] fix(mobile): extend blockquotes across wrapped lines
(#6482)
---
.../src/NativeMarkdownBlock.ios.tsx | 31 ++++++++---
.../src/SelectableMarkdownText.ios.tsx | 1 +
.../src/nativeMarkdownText.ts | 1 +
.../mobile/src/lib/nativeMarkdownText.test.ts | 54 ++++++++++++++++++-
4 files changed, 79 insertions(+), 8 deletions(-)
diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
index e6a045b3cd97..5fbe6d4dff44 100644
--- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
@@ -4,16 +4,13 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { CopyTextButton } from "./CopyTextButton";
import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
-import {
- nativeMarkdownDocumentRuns,
- nativeMarkdownListItemBlocks,
- nativeMarkdownTextRuns,
-} from "./nativeMarkdownText";
+import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
import type {
MarkdownCodeHighlighter,
MarkdownHighlightedToken,
NativeMarkdownTextStyle,
+ SelectableMarkdownSkill,
} from "./SelectableMarkdownText.types";
type HighlightedCode = ReadonlyArray>;
@@ -48,12 +45,13 @@ function documentFor(node: MarkdownNode): MarkdownNode {
function SelectableNode(props: {
readonly node: MarkdownNode;
+ readonly skills: ReadonlyArray;
readonly textStyle: NativeMarkdownTextStyle;
readonly onLinkPress?: (href: string) => void;
}) {
return (
@@ -322,6 +320,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] {
function NativeTable(props: {
readonly node: MarkdownNode;
+ readonly skills: ReadonlyArray;
readonly textStyle: NativeMarkdownTextStyle;
readonly onLinkPress?: (href: string) => void;
}) {
@@ -359,7 +358,7 @@ function NativeTable(props: {
}}
>
+ runs={nativeMarkdownDocumentRuns(documentFor(cell), props.skills).map((run) =>
rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run,
)}
textStyle={props.textStyle}
@@ -376,6 +375,7 @@ function NativeTable(props: {
function NativeMarkdownImage(props: {
readonly node: MarkdownNode;
+ readonly skills: ReadonlyArray;
readonly textStyle: NativeMarkdownTextStyle;
readonly onLinkPress?: (href: string) => void;
}) {
@@ -384,6 +384,7 @@ function NativeMarkdownImage(props: {
return (
@@ -445,6 +446,7 @@ function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] {
function NativeMixedParagraph(props: {
readonly node: MarkdownNode;
+ readonly skills: ReadonlyArray;
readonly textStyle: NativeMarkdownTextStyle;
readonly onLinkPress?: (href: string) => void;
}) {
@@ -455,6 +457,7 @@ function NativeMixedParagraph(props: {
@@ -462,6 +465,7 @@ function NativeMixedParagraph(props: {
@@ -473,6 +477,7 @@ function NativeMixedParagraph(props: {
function NativeList(props: {
readonly node: MarkdownNode;
+ readonly skills: ReadonlyArray;
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly onLinkPress?: (href: string) => void;
@@ -534,6 +539,7 @@ function NativeList(props: {
;
readonly textStyle: NativeMarkdownTextStyle;
readonly highlightCode: MarkdownCodeHighlighter;
readonly onLinkPress?: (href: string) => void;
@@ -566,6 +573,7 @@ export function NativeMarkdownBlock(props: {
@@ -595,6 +604,7 @@ export function NativeMarkdownBlock(props: {
return (
@@ -624,6 +634,7 @@ export function NativeMarkdownBlock(props: {
child.type === "image") ? (
) : (
@@ -673,6 +687,7 @@ export function NativeMarkdownBlock(props: {
>
@@ -690,6 +705,7 @@ export function NativeMarkdownBlock(props: {
diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx
index 56321ba01ada..7860ff592a69 100644
--- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx
@@ -69,6 +69,7 @@ export function SelectableMarkdownText({
chunk.kind === "rich" ? (
{
]);
});
+ it("decorates known skill references inside blockquotes", () => {
+ const node: MarkdownNode = {
+ type: "blockquote",
+ children: [
+ {
+ type: "paragraph",
+ children: [{ type: "text", content: "Use $ui for this." }],
+ },
+ ],
+ };
+
+ expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toContainEqual({
+ text: "$ui",
+ role: "body",
+ skillName: "ui",
+ skillLabel: "UI",
+ });
+ });
+
it("leaves unknown skill-like text unchanged", () => {
const node: MarkdownNode = {
type: "document",
@@ -328,7 +347,7 @@ describe("nativeMarkdownDocumentRuns", () => {
]);
});
- it("includes quotes and fenced code in the same selectable string", () => {
+ it("preserves quotes and fenced code in document runs", () => {
const node: MarkdownNode = {
type: "document",
children: [
@@ -414,6 +433,39 @@ describe("nativeMarkdownListItemBlocks", () => {
});
describe("nativeMarkdownDocumentChunks", () => {
+ it("renders plain blockquotes as rich blocks so their marker spans wrapped lines", () => {
+ const blockquote: MarkdownNode = {
+ type: "blockquote",
+ beg: 0,
+ end: 120,
+ children: [
+ {
+ type: "paragraph",
+ children: [
+ {
+ type: "text",
+ content:
+ "Persistent random per-result keys are the strongest design, even when this text wraps.",
+ },
+ ],
+ },
+ ],
+ };
+
+ expect(
+ nativeMarkdownDocumentChunks({
+ type: "document",
+ children: [blockquote],
+ }),
+ ).toEqual([
+ {
+ kind: "rich",
+ key: "rich:blockquote:0:120",
+ node: blockquote,
+ },
+ ]);
+ });
+
it("keeps headings and plain lists in one selectable document", () => {
const document: MarkdownNode = {
type: "document",
From 83ad26c3a3aeb877ee8bf07c05145ad804717e2e Mon Sep 17 00:00:00 2001
From: Simone
Date: Thu, 13 Aug 2026 21:08:13 +0200
Subject: [PATCH 006/144] fix(mobile): prevent invalid HTML entities from
crashing markdown (#6495)
Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com>
---
.../t3-markdown-text/src/nativeMarkdownText.ts | 11 +++++++++--
apps/mobile/src/lib/nativeMarkdownText.test.ts | 16 ++++++++++++++++
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
index 719070f3dcd3..8db904b5a6ca 100644
--- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
+++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
@@ -70,15 +70,22 @@ const EMPTY_CONTEXT: RunContext = {
const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi;
+function decodeCodePoint(codePoint: number, entity: string): string {
+ if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
+ return entity;
+ }
+ return String.fromCodePoint(codePoint);
+}
+
function decodeHtmlEntitiesOnce(value: string): string {
return value.replace(
/&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi,
(entity, decimal: string | undefined, hexadecimal: string | undefined) => {
if (decimal) {
- return String.fromCodePoint(Number.parseInt(decimal, 10));
+ return decodeCodePoint(Number.parseInt(decimal, 10), entity);
}
if (hexadecimal) {
- return String.fromCodePoint(Number.parseInt(hexadecimal, 16));
+ return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity);
}
switch (entity.toLowerCase()) {
case "&":
diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts
index 5ad2cca26f07..867d9e983017 100644
--- a/apps/mobile/src/lib/nativeMarkdownText.test.ts
+++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts
@@ -126,6 +126,22 @@ describe("nativeMarkdownTextRuns", () => {
]);
});
+ it.each([
+ ["😀", "😀"],
+ ["🚀", "🚀"],
+ ["", ""],
+ ["", ""],
+ ["�", ""],
+ ["�", ""],
+ ])("normalizes numeric entity %s without throwing", (content, expected) => {
+ const node: MarkdownNode = {
+ type: "paragraph",
+ children: [{ type: "text", content }],
+ };
+
+ expect(nativeMarkdownTextRuns(node)).toEqual([{ text: expected }]);
+ });
+
it("reads inline content from nested text nodes", () => {
const node: MarkdownNode = {
type: "paragraph",
From 1b16ed663ffe475490a644d1004873a8aae6bb90 Mon Sep 17 00:00:00 2001
From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com>
Date: Thu, 13 Aug 2026 15:08:28 -0400
Subject: [PATCH 007/144] fix(web): avoid Clerk close button overlap (#6442)
Co-authored-by: t3-code[bot] <236186684+t3-code[bot]@users.noreply.github.com>
Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
---
apps/web/src/components/clerk/ClerkUserProfilePage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx
index 09021aaad51c..00f20e53fbe1 100644
--- a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx
+++ b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx
@@ -19,7 +19,7 @@ export function ClerkUserProfilePage({
}) {
return (
-
+
{title}
{description ? (
From 2fab18e289bdb2b1b767f05a307729d0a0d1c002 Mon Sep 17 00:00:00 2001
From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>
Date: Fri, 14 Aug 2026 01:33:48 +0530
Subject: [PATCH 008/144] fix(web): show unlinked icon when viewport aspect
ratio is unlocked (#6509)
---
apps/web/src/browser/BrowserDeviceToolbar.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx
index f20ab0b37109..cd33bd216d87 100644
--- a/apps/web/src/browser/BrowserDeviceToolbar.tsx
+++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx
@@ -7,7 +7,7 @@ import {
type PreviewViewportSetting,
} from "@t3tools/contracts";
import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport";
-import { Link2, X } from "lucide-react";
+import { Link2, Unlink2, X } from "lucide-react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
@@ -310,7 +310,11 @@ export function BrowserDeviceToolbar({
onPointerDown={(event) => event.preventDefault()}
onClick={toggleAspectRatio}
>
-
+ {aspectRatio === null ? (
+
+ ) : (
+
+ )}
)
) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
From db1507e986591ae8e82f8fa1e173a9013309c64e Mon Sep 17 00:00:00 2001
From: Theo Browne
Date: Thu, 13 Aug 2026 19:18:15 -0400
Subject: [PATCH 014/144] feat: allow disabling auto-settle on merge (#5880)
---
.../settings/DesktopClientSettings.test.ts | 1 +
apps/mobile/src/features/home/HomeScreen.tsx | 9 +++--
.../features/settings/SettingsRouteScreen.tsx | 12 +++++++
.../threads/ThreadNavigationSidebar.tsx | 12 +++++--
.../features/threads/thread-list-v2-items.tsx | 4 +--
.../src/features/threads/threadListV2.test.ts | 15 ++++++++
.../src/features/threads/threadListV2.ts | 14 +++++---
.../src/persistence/mobile-preferences.ts | 5 +++
apps/web/src/components/ChatView.tsx | 12 ++++---
apps/web/src/components/Sidebar.tsx | 30 ++++++++++------
.../components/settings/SettingsPanels.tsx | 34 ++++++++++++++++++-
.../src/components/settings/settingsSearch.ts | 5 +++
apps/web/src/hooks/useThreadActionMenu.ts | 3 ++
.../src/state/threadSettled.test.ts | 34 +++++++++++++++++++
.../client-runtime/src/state/threadSettled.ts | 21 ++++++++----
packages/contracts/src/settings.test.ts | 12 ++++++-
packages/contracts/src/settings.ts | 2 ++
17 files changed, 192 insertions(+), 33 deletions(-)
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 861f72178a68..44c12cc554ad 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
+ sidebarAutoSettleOnMerge: true,
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 6c364787301c..60cb1b475569 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -207,6 +207,9 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
+ const autoSettleOnMerge =
+ !AsyncResult.isSuccess(preferencesResult) ||
+ preferencesResult.value.autoSettleOnMerge !== false;
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef(null);
const listRef = useRef(null);
@@ -483,8 +486,8 @@ export function HomeScreen(props: HomeScreenProps) {
// Settled threads stay in the live shell stream (settled ≠ archived), so
// the partition works directly off live shells — no snapshot merging or
// optimistic holds.
- // PR states stream in per-row (rows own the VCS subscriptions); a merged or
- // closed PR auto-settles its thread on the next partition (mirrors web).
+ // PR states stream in per-row. The next partition applies the configured
+ // merge rule and the always-on close rule, matching web.
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap
>(() => new Map());
@@ -665,6 +668,7 @@ export function HomeScreen(props: HomeScreenProps) {
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
+ autoSettleOnMerge,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
@@ -676,6 +680,7 @@ export function HomeScreen(props: HomeScreenProps) {
});
}, [
changeRequestStateByKey,
+ autoSettleOnMerge,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index 4fb4b1a97a5a..c718558a2e66 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -522,9 +522,21 @@ function ConfiguredSettingsRouteScreen() {
}
function GeneralSettingsSection() {
+ const preferencesResult = useAtomValue(mobilePreferencesAtom);
+ const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+ const autoSettleOnMerge =
+ !AsyncResult.isSuccess(preferencesResult) ||
+ preferencesResult.value.autoSettleOnMerge !== false;
+
return (
+ savePreferences({ autoSettleOnMerge: value })}
+ />
);
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index b03ba9468d96..12e974fe830c 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -10,6 +10,7 @@ import {
import { LegendList } from "@legendapp/list/react-native";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
+import { AsyncResult } from "effect/unstable/reactivity";
import type { EnvironmentId } from "@t3tools/contracts";
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
@@ -29,6 +30,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useThemeColor } from "../../lib/useThemeColor";
import { useProjects, useThreadShells } from "../../state/entities";
+import { mobilePreferencesAtom } from "../../state/preferences";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
import { environmentServerConfigsAtom } from "../../state/server";
@@ -214,6 +216,10 @@ function ThreadNavigationSidebarPane(
regenerateThreadTitle,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
+ const preferencesResult = useAtomValue(mobilePreferencesAtom);
+ const autoSettleOnMerge =
+ !AsyncResult.isSuccess(preferencesResult) ||
+ preferencesResult.value.autoSettleOnMerge !== false;
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
@@ -411,8 +417,8 @@ function ThreadNavigationSidebarPane(
// Thread List v2 (beta) support — same model as the compact Home list
// (HomeScreen.tsx): flat creation-order card block + settled recency tail.
- // PR states stream in per-row; merged/closed PRs auto-settle their thread
- // on the next partition.
+ // PR states stream in per-row. The next partition applies the configured
+ // merge rule and the always-on close rule.
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap
>(() => new Map());
@@ -546,6 +552,7 @@ function ThreadNavigationSidebarPane(
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
+ autoSettleOnMerge,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
@@ -557,6 +564,7 @@ function ThreadNavigationSidebarPane(
});
}, [
changeRequestStateByKey,
+ autoSettleOnMerge,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index db2e805d0fc3..1c25f949ed7c 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -369,8 +369,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly canMovePinnedDown?: boolean;
readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
readonly onSwipeableClose: (methods: SwipeableMethods) => void;
- /** Reports this row's live PR state up so the partition can auto-settle
- merged/closed work (mirrors web's onChangeRequestState). */
+ /** Reports this row's live PR state for the partition's merge and close
+ rules. Mirrors web's onChangeRequestState. */
readonly onChangeRequestState?: (
threadKey: string,
state: "open" | "closed" | "merged" | null,
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index a9ea0138b845..c4a1a844c777 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -263,6 +263,21 @@ describe("sortThreadsForListV2", () => {
});
describe("buildThreadListV2Items", () => {
+ it("keeps a merged thread active when auto-settle on merge is off", () => {
+ const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" });
+ const layout = buildThreadListV2Items({
+ threads: [merged],
+ environmentId: null,
+ searchQuery: "",
+ changeRequestStateByKey: new Map([[`${environmentId}:${merged.id}`, "merged"]]),
+ autoSettleOnMerge: false,
+ now: NOW,
+ });
+
+ expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]);
+ expect(layout.settledCount).toBe(0);
+ });
+
it("hides snoozed threads and counts them — visibility parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index eba56ac8de5e..53b80e52c4f1 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -306,9 +306,8 @@ export function buildThreadListV2ListItems(input: {
/**
* Partitions visible threads into the active card block (creation order) and
- * the settled recency tail, matching the web v2 list. `autoSettleAfterDays`
- * mirrors the web default of 3 — mobile has no client-settings sync yet, so
- * the default is fixed here rather than user-configurable.
+ * the settled recency tail, matching the web v2 list. Mobile stores these
+ * auto-settle preferences per device.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray;
@@ -329,6 +328,7 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet;
readonly autoSettleAfterDays?: number;
+ readonly autoSettleOnMerge?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
@@ -349,6 +349,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
+ const autoSettleOnMerge = input.autoSettleOnMerge ?? true;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
@@ -405,7 +406,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
- effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
+ effectiveSettled(thread, {
+ now,
+ autoSettleAfterDays,
+ autoSettleOnMerge,
+ changeRequestState,
+ })
) {
settled.push(thread);
} else {
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index bf40acb053b7..b504fb190c6d 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -26,6 +26,7 @@ export interface Preferences {
/** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
readonly projectGroupingEnabled?: boolean;
readonly projectGroupingMode?: SidebarProjectGroupingMode;
+ readonly autoSettleOnMerge?: boolean;
/**
* Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has
* no client-settings sync, so the legacy grouped thread list is opted into
@@ -85,6 +86,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
collapsedProjectGroups?: readonly string[];
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
+ autoSettleOnMerge?: boolean;
legacyThreadListEnabled?: boolean;
} = {};
@@ -122,6 +124,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
) {
preferences.projectGroupingMode = parsed.projectGroupingMode;
}
+ if (typeof parsed.autoSettleOnMerge === "boolean") {
+ preferences.autoSettleOnMerge = parsed.autoSettleOnMerge;
+ }
if (typeof parsed.legacyThreadListEnabled === "boolean") {
preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 1f00c177c307..fdc7e7dee382 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -27,6 +27,7 @@ import {
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";
import {
+ changeRequestAutoSettles,
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
@@ -4101,6 +4102,7 @@ function ChatViewContent(props: ChatViewProps) {
// so the banner and the sidebar row never disagree.
const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
+ const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
const activeThreadPr = resolveThreadPr({
threadBranch: activeThread?.branch ?? null,
gitStatus: gitStatusQuery.data ?? null,
@@ -4141,15 +4143,14 @@ function ChatViewContent(props: ChatViewProps) {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
- // Mirror of the sidebar's Woke pill for the open thread: same visit
- // comparison, same merged/closed-PR suppression (finished work needs no
- // wake-up call). Drives the dismissible composer banner below.
+ // Mirror of the sidebar's Woke pill for the open thread. It uses the same
+ // visit comparison and change request settle rule.
const activeThreadLastVisitedAt = useUiStateStore((store) =>
activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
);
const activeThreadWokeVisible = useMemo(() => {
if (activeThreadWokeAt === null) return false;
- if (activeThreadPr?.state === "merged" || activeThreadPr?.state === "closed") return false;
+ if (changeRequestAutoSettles(activeThreadPr?.state, autoSettleOnMerge)) return false;
const wokeAtMs = Date.parse(activeThreadWokeAt);
if (Number.isNaN(wokeAtMs)) return false;
// Having the thread open counts as a visit at completedAt (the effect
@@ -4171,18 +4172,21 @@ function ChatViewContent(props: ChatViewProps) {
activeThreadLastVisitedAt,
activeThreadPr?.state,
activeThreadWokeAt,
+ autoSettleOnMerge,
]);
const activeThreadSettled = useMemo(() => {
if (activeThreadShell === null || !supportsSettlement) return false;
return effectiveSettled(activeThreadShell, {
now: `${nowMinute}:00.000Z`,
autoSettleAfterDays,
+ autoSettleOnMerge,
changeRequestState: activeThreadPr?.state ?? null,
});
}, [
activeThreadPr?.state,
activeThreadShell,
autoSettleAfterDays,
+ autoSettleOnMerge,
nowMinute,
supportsSettlement,
]);
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 31fd7cdbaf97..6b44479b0cb5 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
import { CSS } from "@dnd-kit/utilities";
import {
canSnooze,
+ changeRequestAutoSettles,
effectiveSettled,
effectiveSnoozed,
threadWokeAt,
@@ -657,6 +658,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// False on environments whose server predates thread.settle/unsettle:
// the lifecycle affordances hide entirely rather than fail on click.
settlementSupported: boolean;
+ autoSettleOnMerge: boolean;
// Same contract for thread.snooze/unsnooze.
snoozeSupported: boolean;
// Renders the pin glyph. Pinned cards keep the full settle/snooze quick
@@ -765,17 +767,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// deliberately static), so the pill has to carry the weight. Snoozing is
// an explicit act, so the pill clears only when the user re-engages:
// reading a completion-triggered wake, clicking the pill, sending a
- // message, settling, archiving — or finishing the work outright (merged
- // or closed PR). Timer wakes survive a mere visit. An unparseable visit
- // timestamp counts as never-visited — corrupt local data must not eat
- // the wake signal.
+ // message, settling, archiving, or a change request state that settles the
+ // thread. Timer wakes survive a mere visit. An unparseable visit timestamp
+ // counts as never-visited, so corrupt local data cannot eat the wake signal.
const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);
const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);
const isWoke =
wokeAtDate !== null &&
(lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
- prState !== "merged" &&
- prState !== "closed";
+ !changeRequestAutoSettles(prState, props.autoSettleOnMerge);
// In-flight rows (working, or waiting on approval/input) fade as a whole:
// there is nothing for the user to do yet, so prominence is reserved for
// rows that need a human — done (unread), read-but-unsettled, failed, and
@@ -851,8 +851,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
});
const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
- // Report the PR state up: the parent partitions rows with effectiveSettled,
- // and a merged/closed PR auto-settles a thread — data only rows have.
+ // Report the PR state so the parent can apply the configured merge rule
+ // and the always-on close rule during partitioning.
useEffect(() => {
onChangeRequestState(threadKey, prState);
}, [onChangeRequestState, prState, threadKey]);
@@ -1599,6 +1599,7 @@ export default function Sidebar() {
const { isMobile, setOpenMobile } = useSidebar();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
+ const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
@@ -1801,8 +1802,8 @@ export default function Sidebar() {
// fresh clock whenever it recomputes.
const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
- // PR states stream in per-row (rows own the VCS subscriptions); a merged or
- // closed PR auto-settles its thread on the next partition.
+ // PR states stream in per-row. The next partition applies the configured
+ // merge rule and the always-on close rule.
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap
>(() => new Map());
@@ -1953,7 +1954,12 @@ export default function Sidebar() {
pinned.push(thread);
} else if (
supportsSettlement &&
- effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
+ effectiveSettled(thread, {
+ now,
+ autoSettleAfterDays,
+ autoSettleOnMerge,
+ changeRequestState,
+ })
) {
settled.push(thread);
} else {
@@ -1988,6 +1994,7 @@ export default function Sidebar() {
};
}, [
autoSettleAfterDays,
+ autoSettleOnMerge,
changeRequestStateByKey,
nowMinute,
scopedProjectKeys,
@@ -3532,6 +3539,7 @@ export default function Sidebar() {
serverConfigs.get(thread.environmentId)?.environment.capabilities
.threadSettlement === true
}
+ autoSettleOnMerge={autoSettleOnMerge}
snoozeSupported={
serverConfigs.get(thread.environmentId)?.environment.capabilities
.threadSnooze === true
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index e4cfbe9ac033..9df7f88ab1dd 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -493,6 +493,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
? ["Auto-settle inactive threads"]
: []),
+ ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge
+ ? ["Auto-settle merged threads"]
+ : []),
...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
...getChangedTypographySettingLabels(settings),
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
@@ -547,6 +550,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.enableLegacyTokenStreaming,
settings.enableProviderUpdateChecks,
settings.sidebarAutoSettleAfterDays,
+ settings.sidebarAutoSettleOnMerge,
settings.sidebarProjectGroupingMode,
settings.sidebarThreadPreviewCount,
settings.timestampFormat,
@@ -628,6 +632,7 @@ export function useSettingsRestore(onRestored?: () => void) {
sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
+ sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming,
enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
@@ -1828,9 +1833,36 @@ export function GeneralSettingsPanel() {
}
/>
+
+ updateSettings({
+ sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
+ }
+ aria-label="Auto-settle merged threads"
+ />
+ }
+ />
+
s.markThreadUnread);
const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
+ const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const timestampFormat = useClientSettings((s) => s.timestampFormat);
const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
@@ -132,6 +133,7 @@ export function useThreadActionMenu(input: {
// parked-thread banner within the same minute.
now: `${now.toISOString().slice(0, 16)}:00.000Z`,
autoSettleAfterDays,
+ autoSettleOnMerge,
changeRequestState,
}),
isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }),
@@ -284,6 +286,7 @@ export function useThreadActionMenu(input: {
},
[
autoSettleAfterDays,
+ autoSettleOnMerge,
changeRequestState,
confirmThreadDelete,
copyBranchToClipboard,
diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts
index a7dd4b1eab83..97f397da3e80 100644
--- a/packages/client-runtime/src/state/threadSettled.test.ts
+++ b/packages/client-runtime/src/state/threadSettled.test.ts
@@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
canSettle,
+ changeRequestAutoSettles,
effectiveSettled,
hasQueuedTurnStart,
threadLastActivityAt,
@@ -19,6 +20,18 @@ const NOW = "2026-04-10T00:00:00.000Z";
const FRESH = "2026-04-09T00:00:00.000Z";
const STALE = "2026-04-06T23:59:59.999Z";
+describe("changeRequestAutoSettles", () => {
+ it.each([
+ ["open", true, false],
+ ["merged", true, true],
+ ["merged", false, false],
+ ["closed", false, true],
+ [null, false, false],
+ ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => {
+ expect(changeRequestAutoSettles(state, autoSettleOnMerge)).toBe(expected);
+ });
+});
+
function makeShell(input: {
readonly settledOverride?: "settled" | "active" | null;
readonly activityAt: string | null;
@@ -178,6 +191,27 @@ describe("effectiveSettled", () => {
}
});
+ it("can keep a merged change request active", () => {
+ const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
+ expect(
+ effectiveSettled(recentlyActive, {
+ now: NOW,
+ autoSettleAfterDays: null,
+ autoSettleOnMerge: false,
+ changeRequestState: "merged",
+ }),
+ ).toBe(false);
+
+ expect(
+ effectiveSettled(recentlyActive, {
+ now: NOW,
+ autoSettleAfterDays: null,
+ autoSettleOnMerge: false,
+ changeRequestState: "closed",
+ }),
+ ).toBe(true);
+ });
+
it("never auto-settles a stale thread with an open change request", () => {
const stale = makeShell({ activityAt: STALE });
expect(
diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts
index f8002d1c97b9..e2e93f288889 100644
--- a/packages/client-runtime/src/state/threadSettled.ts
+++ b/packages/client-runtime/src/state/threadSettled.ts
@@ -3,6 +3,14 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts";
export type ChangeRequestStateLike = "open" | "closed" | "merged";
+/** Returns whether the change request state settles the thread immediately. */
+export function changeRequestAutoSettles(
+ state: ChangeRequestStateLike | null | undefined,
+ autoSettleOnMerge = true,
+): boolean {
+ return state === "closed" || (state === "merged" && autoSettleOnMerge);
+}
+
const DAY_MS = 24 * 60 * 60 * 1_000;
export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null {
@@ -221,9 +229,9 @@ export function threadWokeAt(
* queued turn) are checked first and hold a thread active regardless of any
* override. Past the blockers, the explicit user override (thread.settle /
* thread.unsettle commands, projected into settledOverride + settledAt)
- * wins in both directions; without one, a thread auto-settles on a
- * merged/closed PR immediately or on inactivity past the window — except
- * that an open PR blocks the inactivity path entirely. The server
+ * wins in both directions; without one, a thread can auto-settle on a
+ * merged PR, always settles on a closed PR, or settles on inactivity past
+ * the window. An open PR blocks the inactivity path entirely. The server
* un-settles on real activity (user message, session start, approval/
* user-input request), so an override never goes stale silently.
*/
@@ -232,6 +240,7 @@ export function effectiveSettled(
options: {
readonly now: string;
readonly autoSettleAfterDays: number | null;
+ readonly autoSettleOnMerge?: boolean;
readonly changeRequestState?: ChangeRequestStateLike | null;
},
): boolean {
@@ -258,13 +267,13 @@ export function effectiveSettled(
// "active" is the explicit keep-active pin: it suppresses auto-settle
// until real activity clears it server-side.
if (shell.settledOverride === "active") return false;
- if (options.changeRequestState === "merged" || options.changeRequestState === "closed") {
+ if (changeRequestAutoSettles(options.changeRequestState, options.autoSettleOnMerge !== false)) {
return true;
}
// An open PR is unfinished business regardless of how long the thread has
// been quiet: review can take days, and hiding the thread would bury the
- // work waiting on it. Only merge/close (above) or an explicit user settle
- // resolves it.
+ // work waiting on it. A configured merge, a close, or an explicit user
+ // settle resolves it.
if (options.changeRequestState === "open") return false;
if (options.autoSettleAfterDays === null) return false;
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 46705837afa4..570157292b54 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -68,10 +68,11 @@ describe("ClientSettings environment identification", () => {
});
describe("ClientSettings sidebar", () => {
- it("defaults to the current sidebar with a three-day auto-settle threshold", () => {
+ it("defaults to the current sidebar with automatic merge and inactivity settling", () => {
const settings = decodeClientSettings({});
expect(settings.legacySidebarEnabled).toBe(false);
expect(settings.sidebarAutoSettleAfterDays).toBe(3);
+ expect(settings.sidebarAutoSettleOnMerge).toBe(true);
});
it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
@@ -97,6 +98,15 @@ describe("ClientSettings sidebar", () => {
).toBeNull();
});
+ it("allows auto-settle on merge to be disabled", () => {
+ expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe(
+ false,
+ );
+ expect(
+ decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge,
+ ).toBe(false);
+ });
+
it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {
expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow();
expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow();
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 388205649c85..ee1970639adf 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -180,6 +180,7 @@ export const ClientSettingsSchema = Schema.Struct({
sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
),
+ sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
),
@@ -793,6 +794,7 @@ export const ClientSettingsPatch = Schema.Struct({
planModeEnabled: Schema.optionalKey(Schema.Boolean),
legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
+ sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
sidebarProjectGroupingOverrides: Schema.optionalKey(
Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),
From 85389b9883a2c7b31022573563b59981d697c3b4 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 14 Aug 2026 01:59:27 +0200
Subject: [PATCH 015/144] Nest mobile task settings in bottom sheets (#6224)
Co-authored-by: codex
Co-authored-by: Claude Fable 5
---
.agents/skills/test-t3-mobile/SKILL.md | 32 +-
.../test-t3-mobile/scripts/pair-client.sh | 72 +
apps/mobile/src/Stack.tsx | 138 +-
...ToolbarTrigger.tsx => ComposerToolbar.tsx} | 70 +-
apps/mobile/src/components/GlassSurface.tsx | 8 +-
.../connection/ConnectionsNewRouteScreen.tsx | 67 +-
apps/mobile/src/features/home/HomeHeader.tsx | 1 +
.../layout/native-mail-search-toolbar.ts | 3 +
.../features/projects/AddProjectScreen.tsx | 70 +-
.../features/settings/SettingsRouteScreen.tsx | 13 +-
.../terminal/ThreadTerminalRouteScreen.tsx | 2 +-
.../threads/NewTaskContextPickerScreens.tsx | 473 ++++++
.../features/threads/NewTaskDraftScreen.tsx | 723 ++++----
.../features/threads/NewTaskRouteScreen.tsx | 111 +-
.../src/features/threads/ThreadComposer.tsx | 272 ++-
.../features/threads/ThreadSettingsSheet.tsx | 1461 ++++++++++++-----
.../features/threads/legacy-plan-mode.test.ts | 57 +
.../src/features/threads/legacy-plan-mode.ts | 29 +
.../new-task-context-presentation.test.ts | 128 ++
.../threads/new-task-context-presentation.ts | 83 +
.../threads/new-task-flow-provider.tsx | 160 +-
.../new-task-project-selection.test.ts | 30 +-
.../threads/new-task-project-selection.ts | 15 +-
.../threads/thread-settings-menu.test.ts | 284 ----
.../features/threads/thread-settings-menu.ts | 202 ---
.../threads/thread-settings-options.test.ts | 29 +
.../threads/thread-settings-options.ts | 46 +
.../thread-settings-sheet-state.test.ts | 22 +-
.../threads/thread-settings-sheet-state.ts | 34 +
.../threads/use-legacy-plan-mode-enabled.ts | 26 +
.../use-thread-settings-sheet-presentation.ts | 178 +-
apps/mobile/src/native/native-glass.ts | 4 +-
apps/mobile/src/native/sheet-surface.ts | 28 +
.../src/persistence/mobile-preferences.ts | 6 +
apps/mobile/src/state/queries.ts | 126 +-
.../src/state/use-composer-drafts.test.ts | 125 ++
apps/mobile/src/state/use-composer-drafts.ts | 51 +-
docs/user/permission-modes.md | 3 +-
...act-navigation%2Fnative-stack@7.17.6.patch | 22 +
patches/react-native-screens@4.25.2.patch | 176 +-
pnpm-lock.yaml | 108 +-
41 files changed, 3652 insertions(+), 1836 deletions(-)
create mode 100755 .agents/skills/test-t3-mobile/scripts/pair-client.sh
rename apps/mobile/src/components/{ComposerToolbarTrigger.tsx => ComposerToolbar.tsx} (77%)
create mode 100644 apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
create mode 100644 apps/mobile/src/features/threads/legacy-plan-mode.test.ts
create mode 100644 apps/mobile/src/features/threads/legacy-plan-mode.ts
create mode 100644 apps/mobile/src/features/threads/new-task-context-presentation.test.ts
create mode 100644 apps/mobile/src/features/threads/new-task-context-presentation.ts
delete mode 100644 apps/mobile/src/features/threads/thread-settings-menu.test.ts
delete mode 100644 apps/mobile/src/features/threads/thread-settings-menu.ts
create mode 100644 apps/mobile/src/features/threads/thread-settings-options.test.ts
create mode 100644 apps/mobile/src/features/threads/thread-settings-options.ts
create mode 100644 apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts
create mode 100644 apps/mobile/src/native/sheet-surface.ts
diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md
index 98c1c3b20224..fbcd52e697dd 100644
--- a/.agents/skills/test-t3-mobile/SKILL.md
+++ b/.agents/skills/test-t3-mobile/SKILL.md
@@ -125,31 +125,29 @@ Do not start, stop, erase, or reconfigure an emulator owned by another task. Tra
## Pair each client once
-Issue a fresh credential against the running backend's exact base directory:
+Use the bundled helper from the repository root. It issues a fresh credential against the running backend's exact base directory, opens the existing Add Environment route with the credential in an encoded query parameter, and asks that route to connect once:
```bash
-T3CODE_PORT= node apps/server/src/bin.ts auth pairing create \
- --base-dir \
- --base-url \
- --ttl 15m \
- --label agent-mobile-
+.agents/skills/test-t3-mobile/scripts/pair-client.sh \
+ ios
+
+.agents/skills/test-t3-mobile/scripts/pair-client.sh \
+ android
```
-In PowerShell, set `$env:T3CODE_PORT = ""` first and run the `node ... auth pairing create` command without the leading assignment.
+Run only the command for the selected platform. The helper uses `http://127.0.0.1:` for iOS and `http://10.0.2.2:` for Android. Pass a fifth argument only when testing a non-development URL scheme.
-If the visible Add Environment action is not exposed as a semantic target, open the app's registered route instead of guessing coordinates:
+The helper opens this registered route:
-```bash
-xcrun simctl openurl 't3code-dev://connections/new'
-adb -s shell am start -W \
- -a android.intent.action.VIEW \
- -d 't3code-dev://connections/new' \
- com.t3tools.t3code.dev
+```text
+t3code-dev://connections/new?pairingUrl=&autoConnect=1
```
-Run only the command for the selected platform.
+The Add Environment route owns the behavior: `pairingUrl` prefills its normal host and token inputs, while `autoConnect=1` submits once in development builds and returns to Home after success. Without `autoConnect`, the same route only prefills the form for manual inspection.
+
+Do not enter pairing hosts or tokens through simulator keyboard automation. Xcode's semantic typer sends HID-style key events through the simulator's active keyboard state, which can corrupt uppercase tokens and punctuation even when the host Mac uses a U.S. input source. The one-shot route is the deterministic pairing path. Use the visible form only as a fallback, and paste credentials rather than typing them character by character.
-In T3 Code Dev, open Add Environment and enter the complete `` and newly printed `Token`. Verify the expected seeded projects appear before exercising the affected flow.
+Verify the expected seeded projects appear before exercising the affected flow.
Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses.
@@ -183,6 +181,8 @@ Keep local verification focused. Do not turn this workflow into a full repositor
- **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app.
- **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory.
- **A second client cannot pair:** pairing tokens are single-use; issue another token.
+- **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`.
+- **Pairing text changes case or punctuation:** do not retry semantic typing. Use `scripts/pair-client.sh`; the simulator keyboard layout and HID input path are not reliable for credentials.
- **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`.
- **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL.
- **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator.
diff --git a/.agents/skills/test-t3-mobile/scripts/pair-client.sh b/.agents/skills/test-t3-mobile/scripts/pair-client.sh
new file mode 100755
index 000000000000..9caa060728ec
--- /dev/null
+++ b/.agents/skills/test-t3-mobile/scripts/pair-client.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+usage() {
+ echo "Usage: $0 [url-scheme]" >&2
+ exit 2
+}
+
+[[ $# -ge 4 && $# -le 5 ]] || usage
+
+platform="$1"
+device_id="$2"
+server_port="$3"
+base_dir="$4"
+url_scheme="${5:-t3code-dev}"
+
+case "$platform" in
+ ios)
+ mobile_origin="http://127.0.0.1:${server_port}"
+ ;;
+ android)
+ mobile_origin="http://10.0.2.2:${server_port}"
+ ;;
+ *)
+ usage
+ ;;
+esac
+
+repo_root="$(git rev-parse --show-toplevel)"
+cd "$repo_root"
+
+if ! pairing_output="$({
+ T3CODE_PORT="$server_port" node apps/server/src/bin.ts auth pairing create \
+ --base-dir "$base_dir" \
+ --base-url "$mobile_origin" \
+ --ttl 15m \
+ --label "agent-mobile-${device_id:0:8}"
+} 2>&1)"; then
+ echo "Could not mint a mobile pairing credential." >&2
+ exit 1
+fi
+
+pairing_url="$(printf '%s\n' "$pairing_output" | sed -n 's/^Pair URL: //p' | tail -n 1)"
+if [[ -z "$pairing_url" ]]; then
+ echo "Could not parse the mobile pairing URL." >&2
+ exit 1
+fi
+
+deep_link="$(PAIRING_URL="$pairing_url" URL_SCHEME="$url_scheme" node - <<'NODE'
+const query = new URLSearchParams({
+ pairingUrl: process.env.PAIRING_URL,
+ autoConnect: "1",
+});
+process.stdout.write(`${process.env.URL_SCHEME}://connections/new?${query}`);
+NODE
+)"
+
+case "$platform" in
+ ios)
+ xcrun simctl openurl "$device_id" "$deep_link"
+ ;;
+ android)
+ # adb shell re-joins its arguments and evaluates them through the device
+ # shell, so the deep link's `?`/`&` must be quoted once more for that shell.
+ adb -s "$device_id" shell \
+ "am start -W -a android.intent.action.VIEW -d '$deep_link' com.t3tools.t3code.dev" \
+ >/dev/null
+ ;;
+esac
+
+echo "Opened the existing Add Environment route with a fresh pairing credential."
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 93bb6165524c..20bba1f6062d 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -11,7 +11,7 @@ import {
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
import { useEffect, useRef } from "react";
-import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native";
+import { Platform, Pressable, ScrollView, StyleSheet } from "react-native";
import { useResolveClassNames } from "uniwind";
import { AppText as Text } from "./components/AppText";
@@ -39,6 +39,15 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";
import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen";
+import {
+ NewTaskBranchPickerRouteScreen,
+ NewTaskEnvironmentPickerRouteScreen,
+} from "./features/threads/NewTaskContextPickerScreens";
+import {
+ ExistingThreadSettingsRouteProvider,
+ ExistingThreadSettingsRouteScreen,
+ NewTaskThreadSettingsRouteScreen,
+} from "./features/threads/ThreadSettingsSheet";
import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
@@ -62,17 +71,15 @@ import {
} from "./features/sharing/incoming-share-presentation";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
+import {
+ FORM_SHEET_PRESENTATION_OPTIONS,
+ NATIVE_SHEET_SURFACE_COLOR,
+ NATIVE_SHEET_SURFACE_CONTENT_STYLE,
+} from "./native/sheet-surface";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
-// Matches --color-sheet in global.css (light/dark). DynamicColorIOS lets the header
-// background stay STATIC config while still adapting to appearance changes.
-const SHEET_BACKGROUND_COLOR =
- Platform.OS === "ios"
- ? DynamicColorIOS({ light: "rgba(242, 242, 247, 0.98)", dark: "rgba(14, 14, 14, 0.98)" })
- : undefined;
-
type AppScreenOptions = NativeStackNavigationOptions & {
readonly unstable_navigationItemStyle?: "editor";
};
@@ -91,8 +98,8 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
headerShown: true,
headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED
? { backgroundColor: "transparent" }
- : SHEET_BACKGROUND_COLOR !== undefined
- ? { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string }
+ : NATIVE_SHEET_SURFACE_COLOR !== undefined
+ ? { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
: undefined,
headerTitleStyle: { fontSize: 18, fontWeight: "800" },
headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
@@ -109,10 +116,10 @@ const SOLID_HEADER_OPTIONS: AppScreenOptions = {
headerShadowVisible: false,
headerShown: true,
headerStyle:
- SHEET_BACKGROUND_COLOR !== undefined
+ NATIVE_SHEET_SURFACE_COLOR !== undefined
? // native-stack types this as `string`, but the native side accepts any
// ColorValue including DynamicColorIOS.
- { backgroundColor: SHEET_BACKGROUND_COLOR as unknown as string }
+ { backgroundColor: NATIVE_SHEET_SURFACE_COLOR as unknown as string }
: undefined,
headerTitleStyle: { fontSize: 18, fontWeight: "800" },
headerTransparent: false,
@@ -125,6 +132,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = {
unstable_navigationItemStyle: undefined,
};
+// A native glass header for a sheet screen whose primary child is a scroll
+// view. The centered sheet title stays stable while UIKit supplies scroll-edge
+// fading from that child.
+const SHEET_GLASS_HEADER_OPTIONS: AppScreenOptions = {
+ ...GLASS_HEADER_OPTIONS,
+ unstable_navigationItemStyle: undefined,
+};
+
const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
...SHEET_SOLID_HEADER_OPTIONS,
headerBackVisible: false,
@@ -238,9 +253,16 @@ const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";
const NewTaskSheetStack = createNativeStackNavigator({
initialRouteName: "NewTask",
screenOptions: {
- ...GLASS_HEADER_OPTIONS,
- // Sheets read better with the iOS-default centered title (no editor style).
- unstable_navigationItemStyle: undefined,
+ ...SHEET_GLASS_HEADER_OPTIONS,
+ // The form-sheet host owns the one opaque adaptive surface. Child screens
+ // and the navigation bar stay transparent over it, avoiding visible color
+ // slabs as view controllers move horizontally.
+ contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined,
+ // UIKit's default push adds a dimming shadow and independently transitions
+ // the navigation bar. Both read as mismatched sheet backgrounds here.
+ // simple_push retains native push/pop gestures without either artifact.
+ animation: Platform.OS === "ios" ? "simple_push" : undefined,
+ animationDuration: Platform.OS === "ios" ? 350 : undefined,
},
screens: {
NewTask: createNativeStackScreen({
@@ -253,9 +275,39 @@ const NewTaskSheetStack = createNativeStackNavigator({
NewTaskDraft: createNativeStackScreen({
screen: NewTaskDraftRouteScreen,
linking: "draft",
- // The draft composer has no scroll view for glass to sample; a solid
- // header also lays the content out below the bar (no manual inset).
- options: SHEET_SOLID_HEADER_OPTIONS,
+ options: {
+ headerBackVisible: false,
+ title: "",
+ },
+ }),
+ NewTaskEnvironment: createNativeStackScreen({
+ screen: NewTaskEnvironmentPickerRouteScreen,
+ linking: "draft/environment",
+ options: {
+ title: "Environment",
+ },
+ }),
+ NewTaskBranch: createNativeStackScreen({
+ screen: NewTaskBranchPickerRouteScreen,
+ linking: "draft/branch",
+ options: {
+ title: "Branch",
+ },
+ }),
+ ThreadSettings: createNativeStackScreen({
+ screen: NewTaskThreadSettingsRouteScreen,
+ linking: "draft/settings",
+ options: {
+ gestureEnabled: true,
+ headerShown: false,
+ ...(Platform.OS === "android"
+ ? { presentation: "card" as const }
+ : {
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
+ sheetAllowedDetents: [1],
+ sheetGrabberVisible: true,
+ }),
+ },
}),
AddProject: createNativeStackScreen({
screen: AddProjectSourceRoute,
@@ -294,6 +346,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"SettingsLegal",
"SettingsSheet",
"ThreadReviewComment",
+ "ThreadSettingsSheet",
]);
/**
@@ -356,9 +409,11 @@ function RootStackLayout(props: {
-
- {props.children}
-
+
+
+ {props.children}
+
+
);
}
@@ -440,7 +495,9 @@ export const RootStack = createNativeStackNavigator({
options: {
// Android cannot host the keyboard-driven comment composer inside a
// formSheet; use a full-screen modal there instead.
- presentation: Platform.OS === "android" ? "fullScreenModal" : "formSheet",
+ ...(Platform.OS === "android"
+ ? { presentation: "fullScreenModal" as const }
+ : FORM_SHEET_PRESENTATION_OPTIONS),
sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92],
sheetGrabberVisible: Platform.OS !== "android",
},
@@ -450,10 +507,7 @@ export const RootStack = createNativeStackNavigator({
linking: `${THREAD_LINKING_PREFIX}/files`,
options: {
...GLASS_HEADER_OPTIONS,
- contentStyle:
- SHEET_BACKGROUND_COLOR !== undefined
- ? { backgroundColor: SHEET_BACKGROUND_COLOR }
- : undefined,
+ contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE,
title: "Files",
},
}),
@@ -462,11 +516,25 @@ export const RootStack = createNativeStackNavigator({
linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
options: SOLID_HEADER_OPTIONS,
}),
+ ThreadSettingsSheet: createNativeStackScreen({
+ screen: ExistingThreadSettingsRouteScreen,
+ options: {
+ gestureEnabled: true,
+ headerShown: false,
+ ...(Platform.OS === "android"
+ ? { presentation: "card" as const }
+ : {
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
+ sheetAllowedDetents: [1],
+ sheetGrabberVisible: true,
+ }),
+ },
+ }),
GitOverview: createNativeStackScreen({
screen: GitOverviewSheet,
linking: `${THREAD_LINKING_PREFIX}/git`,
options: {
- presentation: "formSheet",
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.92],
sheetGrabberVisible: true,
},
@@ -475,7 +543,7 @@ export const RootStack = createNativeStackNavigator({
screen: GitCommitSheet,
linking: `${THREAD_LINKING_PREFIX}/git/commit`,
options: {
- presentation: "formSheet",
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.92],
sheetGrabberVisible: true,
},
@@ -484,7 +552,7 @@ export const RootStack = createNativeStackNavigator({
screen: GitBranchesSheet,
linking: `${THREAD_LINKING_PREFIX}/git/branches`,
options: {
- presentation: "formSheet",
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.92],
sheetGrabberVisible: true,
},
@@ -493,7 +561,7 @@ export const RootStack = createNativeStackNavigator({
screen: GitConfirmSheet,
linking: `${THREAD_LINKING_PREFIX}/git-confirm`,
options: {
- presentation: "formSheet",
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.45, 0.7],
sheetGrabberVisible: true,
},
@@ -509,7 +577,7 @@ export const RootStack = createNativeStackNavigator({
...(Platform.OS === "android"
? { presentation: "card" as const }
: {
- presentation: "formSheet" as const,
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.7, 0.92],
sheetGrabberVisible: true,
}),
@@ -532,7 +600,7 @@ export const RootStack = createNativeStackNavigator({
...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS),
title: "Set up T3 Connect",
gestureEnabled: true,
- presentation: "formSheet",
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.6, 0.95],
sheetGrabberVisible: true,
},
@@ -547,7 +615,7 @@ export const RootStack = createNativeStackNavigator({
...(Platform.OS === "android"
? { presentation: "card" as const, headerShown: false }
: {
- presentation: "formSheet" as const,
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.7],
sheetGrabberVisible: true,
}),
@@ -557,7 +625,7 @@ export const RootStack = createNativeStackNavigator({
screen: ConnectionsNewRouteScreen,
linking: "connections/new",
options: {
- presentation: "formSheet",
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.7],
sheetGrabberVisible: true,
},
@@ -577,7 +645,7 @@ export const RootStack = createNativeStackNavigator({
...(Platform.OS === "android"
? { presentation: "card" as const }
: {
- presentation: "formSheet" as const,
+ ...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.92],
sheetGrabberVisible: true,
}),
diff --git a/apps/mobile/src/components/ComposerToolbarTrigger.tsx b/apps/mobile/src/components/ComposerToolbar.tsx
similarity index 77%
rename from apps/mobile/src/components/ComposerToolbarTrigger.tsx
rename to apps/mobile/src/components/ComposerToolbar.tsx
index 20187624964f..de2cca1f6028 100644
--- a/apps/mobile/src/components/ComposerToolbarTrigger.tsx
+++ b/apps/mobile/src/components/ComposerToolbar.tsx
@@ -17,11 +17,73 @@ import { cn } from "../lib/cn";
import { AppText as Text } from "./AppText";
import { SymbolView } from "./AppSymbol";
-export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44;
-export const COMPOSER_TOOLBAR_GAP = 8;
-export const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
+const COMPOSER_TOOLBAR_GAP = 8;
+const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4;
+/**
+ * Quiet inline composer control used inside cards and their context rows.
+ * Unlike ComposerToolbarButton, this does not draw another pill inside the
+ * composer surface, so model and workspace controls read as part of the card.
+ */
+export function ComposerInlineControl(props: {
+ readonly accessibilityHint?: string;
+ readonly accessibilityLabel?: string;
+ readonly disabled?: boolean;
+ readonly emphasized?: boolean;
+ readonly icon?: ComponentProps["name"];
+ readonly iconNode?: ReactNode;
+ readonly label: string;
+ readonly maxWidth?: number;
+ readonly onPress?: () => void;
+ readonly selected?: boolean;
+ readonly static?: boolean;
+ readonly chevronDirection?: "down" | "right";
+ readonly showChevron?: boolean;
+}) {
+ const iconColor = useThemeColor(
+ props.emphasized || props.selected ? "--color-icon" : "--color-icon-muted",
+ );
+
+ return (
+
+ {props.iconNode ? (
+ {props.iconNode}
+ ) : props.icon ? (
+
+ ) : null}
+
+ {props.label}
+
+ {props.showChevron === false ? null : (
+
+ )}
+
+ );
+}
+
export function ComposerToolbarRow(props: {
readonly children: ReactNode;
readonly paddingBottom?: number;
@@ -247,5 +309,3 @@ export function ComposerToolbarButton(props: {
);
}
-
-export const ComposerToolbarTrigger = ComposerToolbarButton;
diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx
index f34bd4e2836b..f0b1f863f98b 100644
--- a/apps/mobile/src/components/GlassSurface.tsx
+++ b/apps/mobile/src/components/GlassSurface.tsx
@@ -5,16 +5,19 @@ import {
useColorScheme,
View,
type ColorValue,
+ type StyleProp,
type ViewProps,
type ViewStyle,
} from "react-native";
import { useThemeColor } from "../lib/useThemeColor";
-export interface GlassSurfaceProps extends Omit {
+interface GlassSurfaceProps extends Omit {
readonly children: ReactNode;
readonly glassEffectStyle?: "clear" | "regular" | "none";
readonly tintColor?: ColorValue;
readonly chrome?: "default" | "none";
+ /** Styling used only when native Liquid Glass is unavailable. */
+ readonly fallbackStyle?: StyleProp;
}
export function GlassSurface({
@@ -22,6 +25,7 @@ export function GlassSurface({
glassEffectStyle = "regular",
chrome = "default",
tintColor,
+ fallbackStyle,
style,
...props
}: GlassSurfaceProps) {
@@ -67,7 +71,7 @@ export function GlassSurface({
}
return (
-
+
{children}
);
diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
index de3799ac8a8e..37d53cbd8eea 100644
--- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
+++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
@@ -2,7 +2,7 @@ import { CameraView, useCameraPermissions } from "expo-camera";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { AsyncResult } from "effect/unstable/reactivity";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
@@ -11,12 +11,13 @@ import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { ErrorBanner } from "../../components/ErrorBanner";
import { ConnectionSheetButton } from "./ConnectionSheetButton";
-import { extractPairingUrlFromQrPayload } from "./pairing";
+import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
-import { buildPairingUrl, parsePairingUrl } from "./pairing";
type ConnectionsNewRouteParams = {
readonly mode?: string;
+ readonly pairingUrl?: string;
+ readonly autoConnect?: string;
};
export function ConnectionsNewRouteScreen({
@@ -30,6 +31,13 @@ export function ConnectionsNewRouteScreen({
} = useRemoteConnections();
const navigation = useNavigation();
const params = route.params ?? {};
+ // Deep-link prefill exists for development automation only. A production
+ // link must not arrive with attacker-chosen host and token already filled.
+ const routePairingUrl = __DEV__ ? (params.pairingUrl?.trim() ?? "") : "";
+ const shouldAutoConnect =
+ __DEV__ &&
+ routePairingUrl.length > 0 &&
+ (params.autoConnect === "1" || params.autoConnect === "true");
const insets = useSafeAreaInsets();
const [hostInput, setHostInput] = useState("");
const [codeInput, setCodeInput] = useState("");
@@ -37,6 +45,7 @@ export function ConnectionsNewRouteScreen({
const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
const [scannerLocked, setScannerLocked] = useState(false);
+ const attemptedAutoConnectRef = useRef(null);
const headerIconColor = useThemeColor("--color-icon");
@@ -48,6 +57,16 @@ export function ConnectionsNewRouteScreen({
setCodeInput(code);
}, [connectionPairingUrl]);
+ useEffect(() => {
+ if (routePairingUrl.length === 0) {
+ return;
+ }
+
+ const { host, code } = parsePairingUrl(routePairingUrl);
+ setHostInput(host);
+ setCodeInput(code);
+ }, [routePairingUrl]);
+
useEffect(() => {
if (pairingConnectionError) {
setIsSubmitting(false);
@@ -116,22 +135,38 @@ export function ConnectionsNewRouteScreen({
[onChangeConnectionPairingUrl, scannerLocked],
);
+ const connectAndClose = useCallback(
+ async (pairingUrl: string, replaceWithHome: boolean) => {
+ setIsSubmitting(true);
+ onChangeConnectionPairingUrl(pairingUrl);
+ try {
+ const result = await onConnectPress(pairingUrl);
+ if (AsyncResult.isSuccess(result)) {
+ if (replaceWithHome || !navigation.canGoBack()) {
+ navigation.dispatch(StackActions.replace("Home"));
+ } else {
+ navigation.goBack();
+ }
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [navigation, onChangeConnectionPairingUrl, onConnectPress],
+ );
+
const handleSubmit = useCallback(async () => {
- setIsSubmitting(true);
+ await connectAndClose(buildPairingUrl(hostInput, codeInput), false);
+ }, [codeInput, connectAndClose, hostInput]);
- const pairingUrl = buildPairingUrl(hostInput, codeInput);
- onChangeConnectionPairingUrl(pairingUrl);
- const result = await onConnectPress(pairingUrl);
- if (AsyncResult.isSuccess(result)) {
- if (navigation.canGoBack()) {
- navigation.goBack();
- } else {
- navigation.dispatch(StackActions.replace("Home"));
- }
- } else {
- setIsSubmitting(false);
+ useEffect(() => {
+ if (!shouldAutoConnect || attemptedAutoConnectRef.current === routePairingUrl) {
+ return;
}
- }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]);
+
+ attemptedAutoConnectRef.current = routePairingUrl;
+ void connectAndClose(routePairingUrl, true);
+ }, [connectAndClose, routePairingUrl, shouldAutoConnect]);
return (
diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx
index f3d33934a9b2..e7ce41cb43bd 100644
--- a/apps/mobile/src/features/home/HomeHeader.tsx
+++ b/apps/mobile/src/features/home/HomeHeader.tsx
@@ -356,6 +356,7 @@ function IosHomeHeader(props: HomeHeaderProps) {
onSearchTextChange: props.onSearchQueryChange,
placeholder: "Search",
searchTextChangeId: "home-search-text",
+ showsSearchDismissButton: true,
}),
],
}
diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts
index 8770d96b124b..34d5570e6109 100644
--- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts
+++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts
@@ -11,6 +11,9 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
*/
export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED;
+/** Clearance for scroll content that must come to rest above the floating toolbar. */
+export const NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET = 56;
+
type NativeMailSearchToolbarInput = Omit<
HeaderBarButtonMailSearchToolbarItem,
"type" | "useFallbackSearchField"
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index 39e6bda3c44a..747a919a0c88 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -27,7 +27,7 @@ import {
inferProjectTitleFromPath,
} from "@t3tools/client-runtime/state/projects";
import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
-import { StackActions, useNavigation } from "@react-navigation/native";
+import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
@@ -402,13 +402,12 @@ function SourceControlRow(props: {
icon={icon}
isFirst={props.isFirst}
onPress={() =>
- navigation.navigate("NewTaskSheet", {
- screen: "AddProjectRepository",
- params: {
+ navigation.dispatch(
+ StackActions.push("AddProjectRepository", {
environmentId: props.selectedEnvironmentId,
source: props.source,
- },
- })
+ }),
+ )
}
/>
);
@@ -498,12 +497,11 @@ export function AddProjectSourceScreen() {
}
isFirst
onPress={() =>
- navigation.navigate("NewTaskSheet", {
- screen: "AddProjectLocal",
- params: {
+ navigation.dispatch(
+ StackActions.push("AddProjectLocal", {
environmentId: selectedEnvironment.environmentId,
- },
- })
+ }),
+ )
}
/>
{(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map(
@@ -547,10 +545,18 @@ function useCreateProject(environment: EnvironmentOption | null) {
if (existing) {
Alert.alert("Project already exists", existing.title);
navigation.dispatch(
- StackActions.replace("NewTaskDraft", {
- environmentId: existing.environmentId,
- projectId: existing.id,
- title: existing.title,
+ CommonActions.reset({
+ index: 0,
+ routes: [
+ {
+ name: "NewTaskDraft",
+ params: {
+ environmentId: existing.environmentId,
+ projectId: existing.id,
+ title: existing.title,
+ },
+ },
+ ],
}),
);
return;
@@ -571,10 +577,18 @@ function useCreateProject(environment: EnvironmentOption | null) {
return result;
}
navigation.dispatch(
- StackActions.replace("NewTaskDraft", {
- environmentId: environment.environmentId,
- projectId,
- title: inferProjectTitleFromPath(workspaceRoot),
+ CommonActions.reset({
+ index: 0,
+ routes: [
+ {
+ name: "NewTaskDraft",
+ params: {
+ environmentId: environment.environmentId,
+ projectId,
+ title: inferProjectTitleFromPath(workspaceRoot),
+ },
+ },
+ ],
}),
);
return result;
@@ -612,15 +626,14 @@ export function AddProjectRepositoryScreen(props: {
const provider = addProjectRemoteSourceProvider(source);
if (!provider) {
const remoteUrl = repositoryInput.trim();
- navigation.navigate("NewTaskSheet", {
- screen: "AddProjectDestination",
- params: {
+ navigation.dispatch(
+ StackActions.push("AddProjectDestination", {
environmentId: environment.environmentId,
source,
remoteUrl,
repositoryTitle: remoteUrl,
- },
- });
+ }),
+ );
setIsSubmitting(false);
return;
}
@@ -636,15 +649,14 @@ export function AddProjectRepositoryScreen(props: {
setError(errorMessage(Cause.squash(result.cause)));
} else {
const repository = result.value;
- navigation.navigate("NewTaskSheet", {
- screen: "AddProjectDestination",
- params: {
+ navigation.dispatch(
+ StackActions.push("AddProjectDestination", {
environmentId: environment.environmentId,
source,
remoteUrl: repository.sshUrl,
repositoryTitle: repository.nameWithOwner,
- },
- });
+ }),
+ );
}
setIsSubmitting(false);
}, [environment, isSubmitting, lookupRepositoryQuery, repositoryInput, navigation, source]);
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index c718558a2e66..84a5634e518b 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -549,7 +549,10 @@ function GeneralSettingsSection() {
*/
function LegacySettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+ const preferences = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
+ const planModeEnabled =
+ AsyncResult.isSuccess(preferences) && preferences.value.planModeEnabled === true;
return (
@@ -560,10 +563,16 @@ function LegacySettingsSection() {
value={!threadListV2Enabled}
onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })}
/>
+ savePreferences({ planModeEnabled: value })}
+ />
- Brings back the original grouped thread list. The default list is flat, in creation order:
- active work renders as cards; settled threads collapse to compact rows.
+ Opt into retired interfaces kept for compatibility. Plan Mode restores the Build/Plan
+ control; otherwise every task runs in Build mode.
);
diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
index cb281bf4aed8..a80d90d82cd7 100644
--- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
+++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
@@ -18,7 +18,7 @@ import {
ComposerToolbarButton,
ComposerToolbarRow,
ComposerToolbarScroller,
-} from "../../components/ComposerToolbarTrigger";
+} from "../../components/ComposerToolbar";
import { ControlPillMenu } from "../../components/ControlPill";
import { EmptyState } from "../../components/EmptyState";
import { GlassSurface } from "../../components/GlassSurface";
diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
new file mode 100644
index 000000000000..68bf0d05c59d
--- /dev/null
+++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
@@ -0,0 +1,473 @@
+import type { VcsRef } from "@t3tools/client-runtime/state/vcs";
+import { LegendList } from "@legendapp/list/react-native";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import * as Haptics from "expo-haptics";
+import { useNavigation } from "@react-navigation/native";
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ Platform,
+ Pressable,
+ ScrollView,
+ Switch,
+ TextInput,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
+import { SymbolView } from "../../components/AppSymbol";
+import { AppText as Text } from "../../components/AppText";
+import { cn } from "../../lib/cn";
+import { useFontFamily } from "../../lib/useFontFamily";
+import { useThemeColor } from "../../lib/useThemeColor";
+import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { vcsEnvironment } from "../../state/vcs";
+import {
+ createNativeMailSearchToolbarItem,
+ NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
+ NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
+} from "../layout/native-mail-search-toolbar";
+import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
+import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation";
+
+function SelectionRow(props: {
+ readonly icon?: "arrow.triangle.branch" | "desktopcomputer";
+ readonly onPress: () => void;
+ readonly disabled?: boolean;
+ readonly selected: boolean;
+ readonly isLast?: boolean;
+ readonly subtitle?: string;
+ readonly title: string;
+}) {
+ const iconColor = useThemeColor("--color-icon-muted");
+ const checkmarkColor = useThemeColor("--color-icon");
+
+ return (
+
+ {props.icon ? (
+
+ ) : null}
+
+
+ {props.title}
+
+ {props.subtitle ? (
+
+ {props.subtitle}
+
+ ) : null}
+
+ {props.selected ? (
+
+ ) : null}
+
+ );
+}
+
+function ToggleRow(props: {
+ readonly title: string;
+ readonly value: boolean;
+ readonly onValueChange: (value: boolean) => void;
+}) {
+ return (
+
+
+ {props.title}
+
+
+
+ );
+}
+
+function BranchSelectionRow(props: {
+ readonly badge: string | null;
+ readonly branch: VcsRef;
+ readonly disabled: boolean;
+ readonly isFirst: boolean;
+ readonly isLast: boolean;
+ readonly onSelect: (branch: VcsRef) => void;
+ readonly selected: boolean;
+}) {
+ const onPress = useCallback(() => props.onSelect(props.branch), [props.branch, props.onSelect]);
+
+ return (
+
+
+
+ );
+}
+
+function PickerSurface(props: { readonly children: ReactNode }) {
+ return {props.children};
+}
+
+export function NewTaskEnvironmentPickerRouteScreen() {
+ const flow = useNewTaskFlow();
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+
+ return (
+
+
+ {Platform.OS === "android" ? (
+ navigation.goBack()} />
+ ) : null}
+
+
+ {flow.environments.map((environment, index) => (
+ {
+ void Haptics.selectionAsync();
+ flow.selectEnvironment(environment.environmentId);
+ navigation.goBack();
+ }}
+ selected={flow.selectedEnvironmentId === environment.environmentId}
+ title={environment.environmentLabel}
+ />
+ ))}
+
+
+
+ );
+}
+
+export function NewTaskBranchPickerRouteScreen() {
+ const flow = useNewTaskFlow();
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const placeholderColor = useThemeColor("--color-placeholder");
+ const foregroundColor = useThemeColor("--color-foreground");
+ const fontFamily = useFontFamily("regular");
+ const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
+ const [switchingBranchName, setSwitchingBranchName] = useState(null);
+ const selectingBranchNameRef = useRef(null);
+ const allowSelectionNavigationRef = useRef(false);
+ const mountedRef = useRef(true);
+ const screenTitle = flow.workspaceMode === "worktree" ? "Base branch" : "Branch";
+ const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
+ const selectedBranchName =
+ flow.selectedBranchName ??
+ flow.availableBranches.find((branch) => branch.current)?.name ??
+ flow.availableBranches.find((branch) => branch.isDefault)?.name ??
+ null;
+ const branchListContentStyle = useMemo(
+ () => ({
+ paddingBottom: usesNativeMailSearchToolbar
+ ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 16
+ : Platform.OS === "ios"
+ ? 16
+ : Math.max(insets.bottom, 16) + 16,
+ paddingHorizontal: 16,
+ paddingTop: 12,
+ }),
+ [insets.bottom, usesNativeMailSearchToolbar],
+ );
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ flow.setBranchQuery("");
+ };
+ }, [flow.setBranchQuery]);
+
+ useEffect(
+ () =>
+ navigation.addListener("beforeRemove", (event) => {
+ if (selectingBranchNameRef.current !== null && !allowSelectionNavigationRef.current) {
+ event.preventDefault();
+ }
+ }),
+ [navigation],
+ );
+
+ const selectBranch = useCallback(
+ async (branch: VcsRef) => {
+ if (selectingBranchNameRef.current !== null) {
+ return;
+ }
+ selectingBranchNameRef.current = branch.name;
+ void Haptics.selectionAsync();
+
+ try {
+ let selectedBranch = branch;
+ const needsCheckout = shouldCheckoutNewTaskBranch({
+ branchIsCurrent: branch.current,
+ branchWorktreePath: branch.worktreePath,
+ workspaceMode: flow.workspaceMode,
+ });
+ if (needsCheckout && flow.selectedProject) {
+ setSwitchingBranchName(branch.name);
+ const result = await switchRef({
+ environmentId: flow.selectedProject.environmentId,
+ input: {
+ cwd: flow.selectedProject.workspaceRoot,
+ refName: branch.name,
+ },
+ });
+ if (result._tag === "Failure") {
+ if (mountedRef.current && navigation.isFocused() && !isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ Alert.alert(
+ "Could not switch branch",
+ error instanceof Error ? error.message : "The branch could not be checked out.",
+ );
+ }
+ return;
+ }
+ selectedBranch = {
+ ...branch,
+ current: true,
+ isRemote: false,
+ name: result.value.refName ?? branch.name,
+ };
+ }
+
+ // The checkout has already changed the repository. Persist the matching
+ // draft selection even if the native sheet was dismissed while the
+ // command was in flight; only visible-screen work is focus-gated below.
+ flow.selectBranch(selectedBranch);
+ if (!mountedRef.current || !navigation.isFocused()) {
+ return;
+ }
+ flow.setBranchQuery("");
+ allowSelectionNavigationRef.current = true;
+ navigation.goBack();
+ } finally {
+ selectingBranchNameRef.current = null;
+ allowSelectionNavigationRef.current = false;
+ if (mountedRef.current) {
+ setSwitchingBranchName(null);
+ }
+ }
+ },
+ [
+ flow.selectBranch,
+ flow.selectedProject,
+ flow.setBranchQuery,
+ flow.workspaceMode,
+ navigation,
+ switchRef,
+ ],
+ );
+
+ const renderBranch = useCallback(
+ ({ item, index }: { readonly item: VcsRef; readonly index: number }) => (
+
+ ),
+ [
+ flow.filteredBranches.length,
+ flow.selectedProject,
+ selectBranch,
+ selectedBranchName,
+ switchingBranchName,
+ ],
+ );
+
+ const branchListHeader =
+ flow.workspaceMode === "worktree" ? (
+
+
+
+ ) : null;
+
+ const branchContent =
+ flow.filteredBranches.length === 0 ? (
+
+ {branchListHeader}
+
+ {flow.branchesLoading ? : null}
+
+ {flow.branchesLoading
+ ? "Loading branches…"
+ : flow.branchesError
+ ? flow.branchesError
+ : flow.branchQuery
+ ? "No matching branches"
+ : "No branches available"}
+
+ {!flow.branchesLoading && flow.branchesError ? (
+
+ Try again
+
+ ) : null}
+
+
+ ) : (
+
+ `${branch.remoteName ?? "local"}:${branch.name}:${branch.worktreePath ?? ""}`
+ }
+ ListHeaderComponent={branchListHeader}
+ ListFooterComponent={
+ flow.branchesFetchingNextPage ? (
+
+
+
+ ) : null
+ }
+ onEndReached={flow.hasMoreBranches ? flow.loadMoreBranches : undefined}
+ onEndReachedThreshold={0.35}
+ renderItem={renderBranch}
+ showsVerticalScrollIndicator={false}
+ />
+ );
+
+ if (Platform.OS === "android") {
+ return (
+
+
+ navigation.goBack()} />
+
+
+
+ {branchContent}
+
+ );
+ }
+
+ return (
+ <>
+ [
+ createNativeMailSearchToolbarItem({
+ onSearchTextChange: flow.setBranchQuery,
+ placeholder: "Find a branch",
+ searchTextChangeId: "new-task-branch-search-text",
+ showsSearchDismissButton: true,
+ }),
+ ]
+ : undefined,
+ headerSearchBarOptions: usesNativeMailSearchToolbar
+ ? undefined
+ : {
+ allowToolbarIntegration: true,
+ autoCapitalize: "none",
+ hideNavigationBar: false,
+ obscureBackground: false,
+ placeholder: "Find a branch",
+ onChangeText: (event) => {
+ flow.setBranchQuery(event.nativeEvent.text);
+ },
+ onCancelButtonPress: () => {
+ flow.setBranchQuery("");
+ },
+ },
+ }}
+ />
+ {usesNativeMailSearchToolbar ? null : (
+
+
+
+ )}
+ {branchContent}
+ >
+ );
+}
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index 1ece23ca0551..87b12ad22f5f 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -1,9 +1,14 @@
-import { NativeStackScreenOptions } from "../../native/StackHeader";
-import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
+import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import {
- KeyboardAvoidingView,
+ StackActions,
+ useFocusEffect,
+ useNavigation,
+ usePreventRemove,
+} from "@react-navigation/native";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Alert, Platform, Pressable, ScrollView, View, useColorScheme } from "react-native";
+import {
+ KeyboardController,
KeyboardStickyView,
useKeyboardState,
} from "react-native-keyboard-controller";
@@ -11,7 +16,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
import { useFontFamily } from "../../lib/useFontFamily";
-import { EnvironmentId } from "@t3tools/contracts";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
@@ -19,22 +23,24 @@ import {
import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
import {
+ ComposerInlineControl,
ComposerToolbarButton,
ComposerToolbarRow,
ComposerToolbarScroller,
- ComposerToolbarTrigger,
-} from "../../components/ComposerToolbarTrigger";
+} from "../../components/ComposerToolbar";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
-import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
+import { SymbolView } from "../../components/AppSymbol";
+import { AppText as Text } from "../../components/AppText";
import { ComposerSurface } from "./ThreadComposer";
-import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
-import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";
+import {
+ useThreadSettingsSheetPresentation,
+ type NavigationWithFinishTransitioning,
+} from "./use-thread-settings-sheet-presentation";
import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
-import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
import {
clearComposerDraftContent,
@@ -49,21 +55,33 @@ import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
-import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
+import { useNewTaskFlow } from "./new-task-flow-provider";
import { useCreateProjectThread } from "./use-project-actions";
import { resolveDraftProjectSelection } from "./new-task-project-selection";
+import {
+ resolveNewTaskBranchLabel,
+ resolveNewTaskWorkspaceLabel,
+} from "./new-task-context-presentation";
import { useIncomingShare } from "../sharing/IncomingShareProvider";
-function formatWorkspaceLabel(input: {
- readonly workspaceMode: string;
- readonly currentBranchName: string | null;
- readonly selectedBranchName: string | null;
-}): string {
- const branchName = input.selectedBranchName ?? input.currentBranchName;
- if (input.workspaceMode === "worktree") {
- return branchName ? `New worktree · ${branchName}` : "New worktree";
+function NewTaskWorkspaceIcon(props: {
+ readonly workspaceMode: "local" | "worktree";
+ readonly worktreePath: string | null;
+}) {
+ const iconColor = useThemeColor("--color-icon-muted");
+
+ if (props.workspaceMode === "local" && props.worktreePath === null) {
+ return ;
}
- return branchName ? `Current · ${branchName}` : "Current checkout";
+
+ return (
+
+
+
+
+
+
+ );
}
export function NewTaskDraftScreen(props: {
@@ -90,7 +108,8 @@ export function NewTaskDraftScreen(props: {
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
- const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
+ const controlsBottomPadding = Math.max(insets.bottom, 10);
+ const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);
const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow;
const { connectedEnvironments } = useRemoteConnectionStatus();
const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
@@ -108,6 +127,49 @@ export function NewTaskDraftScreen(props: {
editorRef: promptInputRef,
isEditorFocused: isComposerFocused,
});
+ useEffect(() => {
+ if (Platform.OS !== "ios") {
+ return;
+ }
+
+ navigation.getParent()?.setOptions({ gestureEnabled: !isKeyboardVisible });
+ }, [isKeyboardVisible, navigation]);
+ useEffect(() => {
+ return () => {
+ if (Platform.OS === "ios") {
+ navigation.getParent()?.setOptions({ gestureEnabled: true });
+ }
+ };
+ }, [navigation]);
+ const settingsRoutePresentedRef = useRef(false);
+ useEffect(() => {
+ if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) {
+ return;
+ }
+
+ settingsRoutePresentedRef.current = true;
+ navigation.dispatch(StackActions.push("ThreadSettings"));
+ }, [navigation, settingsSheetPresentation.isVisible]);
+ useFocusEffect(
+ useCallback(() => {
+ if (!settingsRoutePresentedRef.current) {
+ return;
+ }
+
+ settingsRoutePresentedRef.current = false;
+ settingsSheetPresentation.onDismissed();
+ }, [settingsSheetPresentation.onDismissed]),
+ );
+ useEffect(
+ () =>
+ // UIKit's completion callback for the sheet dismissal, surfaced by the
+ // native-stack patch. This is when the queued keyboard restore runs.
+ (navigation as unknown as NavigationWithFinishTransitioning).addListener(
+ "finishTransitioning",
+ settingsSheetPresentation.onStackTransitionsFinished,
+ ),
+ [navigation, settingsSheetPresentation.onStackTransitionsFinished],
+ );
const [importingShareKey, setImportingShareKey] = useState(null);
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null);
@@ -227,9 +289,9 @@ export function NewTaskDraftScreen(props: {
}, [props.pendingTaskId, cancelEditingPendingTask]);
const foregroundColor = useThemeColor("--color-foreground");
+ const projectUnderlineColor = useThemeColor("--color-foreground-muted");
const regularFontFamily = useFontFamily("regular");
const bodyText = useScaledTextRole("body");
- const headlineText = useScaledTextRole("headline");
const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
@@ -315,7 +377,7 @@ export function NewTaskDraftScreen(props: {
return;
}
loadedBranchesProjectKeyRef.current = projectKey;
- void flow.loadBranches();
+ flow.loadBranches();
}, [flow.loadBranches, selectedProject]);
useEffect(() => {
@@ -517,121 +579,6 @@ export function NewTaskDraftScreen(props: {
shareImportAttempt,
]);
- useEffect(() => {
- // Android starts with the collapsed composer pill (like an open thread)
- // and only expands/focuses when tapped.
- if (!selectedProject || Platform.OS === "android") {
- return;
- }
-
- let focusFrame: ReturnType | null = null;
- const interaction = InteractionManager.runAfterInteractions(() => {
- focusFrame = requestAnimationFrame(() => {
- // The delayed focus can land after the settings sheet opened, which
- // would pop the keyboard underneath its modal.
- if (!settingsSheetPresentation.isActiveRef.current) {
- promptInputRef.current?.focus();
- } else {
- settingsSheetPresentation.restoreFocusAfterSave();
- }
- });
- });
-
- return () => {
- interaction.cancel();
- if (focusFrame !== null) {
- cancelAnimationFrame(focusFrame);
- }
- };
- }, [
- selectedProject,
- settingsSheetPresentation.isActiveRef,
- settingsSheetPresentation.restoreFocusAfterSave,
- ]);
-
- const environmentMenuActions = useMemo(
- () =>
- flow.environments.map((environment) => ({
- id: `environment:${environment.environmentId}`,
- title: environment.environmentLabel,
- attributes: isIncomingShareTransferPending ? { disabled: true } : undefined,
- state:
- flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined,
- })),
- [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending],
- );
-
- const providerOptionDescriptors = useMemo(
- () =>
- resolveProviderOptionDescriptors({
- capabilities: flow.selectedModelOption?.capabilities,
- selections: flow.selectedModel?.options,
- }),
- [flow.selectedModel?.options, flow.selectedModelOption?.capabilities],
- );
-
- const workspaceMenuActions = useMemo(() => {
- const branchActions =
- flow.availableBranches.length === 0
- ? [
- {
- id: "workspace:branch:none",
- title: flow.branchesLoading ? "Loading branches…" : "No branches available",
- attributes: { disabled: true },
- },
- ]
- : flow.availableBranches.slice(0, 12).map((branch) => {
- const badge = branchBadgeLabel({
- branch,
- project: flow.selectedProject,
- });
-
- return {
- id: `workspace:branch:${branch.name}`,
- title: branch.name,
- subtitle: badge ? badge.toUpperCase() : undefined,
- state: flow.selectedBranchName === branch.name ? ("on" as const) : undefined,
- };
- });
-
- return [
- {
- id: "workspace:mode",
- title: "Mode",
- subtitle: flow.workspaceMode === "local" ? "Current checkout" : "New worktree",
- subactions: (["local", "worktree"] as const).map((value) => ({
- id: `workspace:mode:${value}`,
- title: value === "local" ? "Current checkout" : "New worktree",
- state: flow.workspaceMode === value ? ("on" as const) : undefined,
- })),
- },
- {
- id: "workspace:branch",
- title: "Branch",
- subtitle: flow.selectedBranchName ?? "Choose branch",
- subactions: branchActions,
- },
- ...(flow.workspaceMode === "worktree"
- ? [
- {
- id: "workspace:start-from-origin",
- title: "Start from origin",
- subtitle: "Base the worktree on the latest origin branch",
- image: "arrow.triangle.pull",
- state: flow.startFromOrigin ? ("on" as const) : undefined,
- },
- ]
- : []),
- ];
- }, [
- flow.availableBranches,
- flow.branchesLoading,
- flow.selectedBranchName,
- flow.selectedProject,
- flow.startFromOrigin,
- flow.workspaceMode,
- ]);
-
const selectedEnvironmentLabel =
flow.environments.find(
(environment) => environment.environmentId === flow.selectedEnvironmentId,
@@ -640,50 +587,17 @@ export function NewTaskDraftScreen(props: {
flow.availableBranches.find((branch) => branch.current)?.name ??
flow.availableBranches.find((branch) => branch.isDefault)?.name ??
null;
- const settingsSummaryLabel = threadSettingsSummaryLabel({
- modelLabel: flow.selectedModelOption?.label ?? "Model",
- optionDescriptors: providerOptionDescriptors,
- runtimeMode: flow.runtimeMode,
- interactionMode: flow.interactionMode,
+ const selectedBranchName = flow.selectedBranchName ?? currentBranchName;
+ const selectedBranchLabel = resolveNewTaskBranchLabel({
+ branchName: selectedBranchName,
+ startFromOrigin: flow.startFromOrigin,
+ workspaceMode: flow.workspaceMode,
});
- const workspaceLabel = useMemo(
- () =>
- formatWorkspaceLabel({
- currentBranchName,
- selectedBranchName: flow.selectedBranchName,
- workspaceMode: flow.workspaceMode,
- }),
- [currentBranchName, flow.selectedBranchName, flow.workspaceMode],
- );
- function handleEnvironmentMenuAction(event: string) {
- if (isIncomingShareTransferPending || !event.startsWith("environment:")) {
- return;
- }
- flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length)));
- }
-
- function handleWorkspaceMenuAction(event: string) {
- if (isIncomingShareTransferPending) {
- return;
- }
- if (event.startsWith("workspace:mode:")) {
- flow.setWorkspaceMode(
- event.slice("workspace:mode:".length) as Parameters[0],
- );
- return;
- }
- if (event === "workspace:start-from-origin") {
- flow.setStartFromOrigin(!flow.startFromOrigin);
- return;
- }
- if (event.startsWith("workspace:branch:")) {
- const branchName = event.slice("workspace:branch:".length);
- const branch = flow.availableBranches.find((candidate) => candidate.name === branchName);
- if (branch) {
- flow.selectBranch(branch);
- }
- }
- }
+ const workspaceLabel = resolveNewTaskWorkspaceLabel({
+ workspaceMode: flow.workspaceMode,
+ worktreePath: flow.selectedWorktreePath,
+ });
+ const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0;
async function handlePickImages(): Promise {
if (isIncomingShareTransferPending) {
@@ -733,7 +647,9 @@ export function NewTaskDraftScreen(props: {
draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
- const interactionMode = draft.interactionMode ?? flow.interactionMode;
+ const interactionMode = flow.planModeEnabled
+ ? (draft.interactionMode ?? flow.interactionMode)
+ : "default";
const initialMessageText = draft.text.trim();
if (
@@ -851,7 +767,7 @@ export function NewTaskDraftScreen(props: {
if (!selectedProject) {
return (
-
+
{Platform.OS === "android" ? (
<>
@@ -866,11 +782,6 @@ export function NewTaskDraftScreen(props: {
const isAndroid = Platform.OS === "android";
const isDarkMode = colorScheme === "dark";
- // Android expansion follows native editor focus so relayout cannot race
- // the touch gesture that opens the keyboard.
- // The settings sheet dismisses the keyboard, so its flag keeps the Android
- // draft composer expanded through the blur (mirrors ThreadComposer).
- const isExpanded = !isAndroid || isComposerFocused || settingsSheetPresentation.isActive;
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
@@ -882,235 +793,279 @@ export function NewTaskDraftScreen(props: {
const promptEditor = (
setIsComposerFocused(true)}
onBlur={() => setIsComposerFocused(false)}
onPasteImages={(uris) => void handleNativePasteImages(uris)}
- placeholder={`Describe a coding task in ${selectedProject.title}`}
- // Same collapsed centering as ThreadComposer: native vertical gravity
- // in a pill-height box.
- singleLineCentered={!isExpanded}
- contentInsetVertical={isAndroid ? 0 : undefined}
- style={
- isAndroid
- ? isExpanded
- ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 }
- : { height: 36 }
- : { flex: 1, minHeight: 0 }
- }
- textStyle={
- isAndroid
- ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }
- : headlineText
- }
+ placeholder="Ask anything…"
+ singleLineCentered={false}
+ contentInsetVertical={0}
+ style={{
+ minHeight: 72,
+ maxHeight: 160,
+ paddingHorizontal: 4,
+ paddingVertical: 4,
+ }}
+ textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }}
/>
);
- const toolbarPills = (
- <>
- void handlePickImages()}
- showChevron={false}
- disabled={isIncomingShareTransferPending}
- />
- {
+ void KeyboardController.dismiss({ animated: true });
+ const parentNavigation = navigation.getParent();
+ if (parentNavigation) {
+ parentNavigation.goBack();
+ return;
+ }
+ navigation.goBack();
+ };
+ const chooseProject = () => {
+ if (isIncomingShareTransferPending) {
+ return;
+ }
+ promptInputRef.current?.blur();
+ void KeyboardController.dismiss({ animated: true });
+ navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId }));
+ };
+ const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => {
+ if (isIncomingShareTransferPending) {
+ return;
+ }
+ promptInputRef.current?.blur();
+ void KeyboardController.dismiss({ animated: true });
+ navigation.dispatch(StackActions.push(routeName));
+ };
+
+ const hero = (
+
+
+
+ What should we build
+
+
+ in
+
+
+ {selectedProject.title}
+
+
+ ?
+
+
+
+ }
- label={settingsSummaryLabel}
- maxWidth={320}
- onPress={settingsSheetPresentation.open}
+ icon="desktopcomputer"
+ label={`on ${selectedEnvironmentLabel}`}
+ maxWidth={260}
+ onPress={
+ flow.environments.length > 1 ? () => openContextPicker("NewTaskEnvironment") : undefined
+ }
+ showChevron={flow.environments.length > 1}
+ static={flow.environments.length <= 1}
/>
- handleEnvironmentMenuAction(nativeEvent.event)}
- >
-
-
- handleWorkspaceMenuAction(nativeEvent.event)}
+
+ );
+ const heroViewport = (
+
+
-
-
- >
+ {hero}
+
+
);
- const settingsSheet = (
- flow.setSelectedModelKey(option.key, option.selection.options)}
- optionDescriptors={providerOptionDescriptors}
- onUpdateOptionSelections={flow.setSelectedModelOptions}
- runtimeMode={flow.runtimeMode}
- onUpdateRuntimeMode={flow.setRuntimeMode}
- />
+ const workspaceControls = (
+
+
+ }
+ label={workspaceLabel}
+ maxWidth={flow.workspaceMode === "local" ? 220 : 148}
+ onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")}
+ showChevron={false}
+ />
+
+ openContextPicker("NewTaskBranch")}
+ />
+
);
- const startButton = (
- void handleStart()}
- variant="primary"
- showChevron={false}
- disabled={!canStart}
- />
+ const composerDock = (
+
+ {workspaceControls}
+
+
+ {flow.attachments.length > 0 ? (
+
+ undefined : flow.removeAttachment}
+ />
+
+ ) : null}
+
+ {promptEditor}
+
+
+
+ void handlePickImages()}
+ showChevron={false}
+ />
+
+ }
+ label={flow.selectedModelOption?.label ?? "Choose model"}
+ maxWidth={152}
+ onPress={settingsSheetPresentation.open}
+ />
+ {flow.planModeEnabled ? (
+
+ flow.setInteractionMode(flow.interactionMode === "plan" ? "default" : "plan")
+ }
+ showChevron={false}
+ />
+ ) : null}
+
+ void handleStart()}
+ showChevron={false}
+ variant="primary"
+ />
+
+
+
);
if (isAndroid) {
- // The draft is a thread that doesn't exist yet, so it mirrors the thread
- // page: in-screen header, empty feed canvas above, and the same floating
- // composer chrome as ThreadComposer (collapsed pill → expanded card).
- //
- // Composer positioning mirrors ThreadDetailScreen's floating overlay
- // (KeyboardStickyView, absolute bottom overlay) rather than
- // KeyboardAvoidingView's automaticOffset+padding: automaticOffset
- // resolves the composer's on-screen frame via a native
- // viewPositionInWindow measurement, which this app's Android
- // edge-to-edge setup (KeyboardProvider's native content-view margin
- // handling neutralizes windowSoftInputMode="adjustResize" while active)
- // makes unreliable — the composer stayed under the keyboard instead of
- // translating above it. KeyboardStickyView sticks directly to the
- // animated keyboard height instead, sidestepping that measurement.
return (
-
+
- navigation.goBack()} />
-
-
+
+ {heroViewport}
-
-
- {isExpanded && flow.attachments.length > 0 ? (
-
- undefined : flow.removeAttachment
- }
- />
-
- ) : null}
- {promptEditor}
- {!isExpanded ? (
- void handleStart()}
- />
- ) : null}
-
-
- {isExpanded ? (
-
-
- {toolbarPills}
-
- {startButton}
-
- ) : null}
-
+ {composerDock}
- {settingsSheet}
);
}
return (
-
-
-
-
- {promptEditor}
+
+
+
+
+
-
- {flow.attachments.length > 0 ? (
-
- undefined : flow.removeAttachment}
- imageSize={88}
- imageBorderRadius={20}
- />
-
- ) : null}
-
-
- {toolbarPills}
-
- {startButton}
-
-
-
- {settingsSheet}
+ {heroViewport}
+
+ {composerDock}
+
);
}
diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
index 7f4a68c08c7d..94304448eaf3 100644
--- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
@@ -1,8 +1,13 @@
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
-import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import {
+ StackActions,
+ useIsFocused,
+ useNavigation,
+ type StaticScreenProps,
+} from "@react-navigation/native";
import { SymbolView } from "../../components/AppSymbol";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef } from "react";
import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
@@ -14,10 +19,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
import { useProjects } from "../../state/entities";
import type { WorkspaceState } from "../../state/workspaceModel";
import { useWorkspaceState } from "../../state/workspace";
-import { scopedProjectKey } from "../../lib/scopedEntities";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { useIncomingShare } from "../sharing/IncomingShareProvider";
import { useNewTaskFlow } from "./new-task-flow-provider";
+import { getProjectScopeSelectionTarget } from "./new-task-project-selection";
type NewTaskRouteParams = {
readonly incomingShareId?: string | string[];
@@ -80,7 +85,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {
export function NewTaskRouteScreen({ route }: StaticScreenProps) {
const projects = useProjects();
- const { projectScopes } = useNewTaskFlow();
+ const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow();
const { state: catalogState } = useWorkspaceState();
const navigation = useNavigation();
const isFocused = useIsFocused();
@@ -88,7 +93,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps>(() => new Set());
const { getShare, releaseShareReservation } = useIncomingShare();
const routeShareId = Array.isArray(route.params?.incomingShareId)
? route.params.incomingShareId[0]
@@ -126,27 +130,22 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {
- const next = new Set(current);
- if (next.has(groupKey)) {
- next.delete(groupKey);
- } else {
- next.add(groupKey);
- }
- return next;
- });
+ }),
+ );
}
useEffect(() => {
@@ -169,15 +168,14 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.navigate("NewTaskSheet", { screen: "AddProject" }),
+ onPress: () => navigation.dispatch(StackActions.push("AddProject")),
},
]
: []
@@ -223,7 +221,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
+ onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
separateBackground
/>
) : null}
@@ -263,7 +261,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps navigation.navigate("NewTaskSheet", { screen: "AddProject" })}
+ onPress={() => navigation.dispatch(StackActions.push("AddProject"))}
>
Add new project
@@ -275,22 +273,15 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps
{projectScopes.map((scope, scopeIndex) => {
const hasMultipleProjects = scope.projects.length > 1;
- const expanded = expandedGroupKeys.has(scope.key);
- const singleProject = hasMultipleProjects ? null : scope.projects[0];
+ const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId);
return (
0 && "border-t border-border-subtle")}
>
{
- if (singleProject) {
- void selectProject(singleProject);
- } else {
- toggleGroup(scope.key);
- }
- }}
+ disabled={reservedDestinationProject !== null}
+ onPress={() => void selectProject(selectionTarget)}
className="flex-row items-center gap-3 bg-card px-4 py-3.5"
>
@@ -311,52 +302,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps
{hasMultipleProjects
? `${scope.projects.length} workspaces`
- : singleProject?.workspaceRoot}
+ : selectionTarget.workspaceRoot}
- {hasMultipleProjects && expanded
- ? scope.projects.map((project) => (
- void selectProject(project)}
- className="flex-row items-center gap-3 border-t border-border-subtle bg-card py-3 pr-4 pl-10"
- >
-
-
-
- {project.title}
-
-
- {project.workspaceRoot}
-
-
-
-
- ))
- : null}
);
})}
diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index 6ce42aeb148d..3fba0a351c2b 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -1,4 +1,3 @@
-import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
import type {
EnvironmentId,
MessageId,
@@ -14,7 +13,7 @@ import {
serializeComposerFileLink,
type ComposerTrigger,
} from "@t3tools/shared/composerTrigger";
-import * as Haptics from "expo-haptics";
+import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
import type { ReactNode } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -41,18 +40,19 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
import { AppText as Text } from "../../components/AppText";
import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
+import { GlassSurface } from "../../components/GlassSurface";
import {
ComposerEditor,
type ComposerEditorHandle,
type ComposerEditorSelection,
} from "../../components/ComposerEditor";
import {
+ ComposerInlineControl,
ComposerToolbarButton,
ComposerToolbarRow,
ComposerToolbarScroller,
- ComposerToolbarTrigger,
-} from "../../components/ComposerToolbarTrigger";
-import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
+} from "../../components/ComposerToolbar";
+import { ControlPill } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
@@ -63,15 +63,17 @@ import {
normalizeSearchQuery,
scoreQueryMatch,
} from "@t3tools/shared/searchRanking";
-import {
- applyProviderOptionSelection,
- resolveProviderOptionDescriptors,
-} from "../../lib/providerOptions";
+import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
-import { buildThreadSettingsMenu } from "./thread-settings-menu";
-import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
-import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";
+import {
+ type ExistingThreadSettingsRouteSession,
+ useExistingThreadSettingsRoutePresentation,
+} from "./ThreadSettingsSheet";
+import {
+ useThreadSettingsSheetPresentation,
+ type NavigationWithFinishTransitioning,
+} from "./use-thread-settings-sheet-presentation";
/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
@@ -83,7 +85,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60;
* Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset).
* Used by the parent to compute the larger feed bottom inset when the composer is focused.
*/
-export const COMPOSER_EXPANDED_CHROME = 174;
+export const COMPOSER_EXPANDED_CHROME = 156;
export interface ThreadComposerProps {
readonly draftMessage: string;
@@ -123,8 +125,8 @@ export interface ThreadComposerProps {
}
/**
- * The pill / card container — renders as LiquidGlassView on supported
- * iOS 26+ devices (progressive blur, native morph), opaque View otherwise.
+ * The pill / card container — renders with Expo's native GlassView on supported
+ * iOS 26+ devices and keeps the existing opaque fallback elsewhere.
* Exported so NewTaskDraftScreen can render the same composer chrome.
*/
// One timing for every piece of the expanded↔compact morph so the surface,
@@ -140,6 +142,8 @@ export function ComposerSurface(props: {
readonly children: ReactNode;
readonly style: ViewStyle;
readonly isDarkMode: boolean;
+ /** Existing thread composers morph between pill and card layouts. */
+ readonly animateLayout?: boolean;
}) {
// Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself
// (needed to clip content to the pill shape) would clip the shadow on iOS.
@@ -152,35 +156,26 @@ export function ComposerSurface(props: {
elevation: 10,
};
- if (isLiquidGlassSupported) {
- return (
-
-
- {props.children}
-
-
- );
- }
-
return (
-
-
+
{props.children}
-
+
);
}
@@ -271,6 +266,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
});
export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
+ const navigation = useNavigation();
const isDarkMode = useColorScheme() === "dark";
const foregroundColor = useThemeColor("--color-foreground");
const bodyText = useScaledTextRole("body");
@@ -281,14 +277,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
editorRef: inputRef,
isEditorFocused: isFocused,
});
+ const settingsRoutePresentation = useExistingThreadSettingsRoutePresentation();
+ const settingsRoutePresentedRef = useRef(false);
const wasExpandedBeforePreviewRef = useRef(false);
const inFlightThreadIdsRef = useRef(new Set());
const { onExpandedChange } = props;
const [previewImageUri, setPreviewImageUri] = useState(null);
const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
- // Opening and closing count as active so the composer stays expanded while
- // focus moves between its native editor and the settings modal.
+ // Opening and presentation count as active so the composer stays expanded
+ // while focus moves between its native editor and the settings picker.
const isExpanded = isFocused || settingsSheetPresentation.isActive;
const canSend = hasContent;
@@ -334,7 +332,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
: "Send";
const currentModelSelection = props.selectedThread.modelSelection;
const currentRuntimeMode = props.selectedThread.runtimeMode;
- const currentInteractionMode = props.selectedThread.interactionMode ?? "default";
const connectionStatus = composerConnectionStatus({
connectionError: props.connectionError,
connectionState: props.connectionState,
@@ -626,67 +623,71 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}),
[currentModelOption?.capabilities, currentModelSelection.options],
);
- const settingsSummaryLabel = threadSettingsSummaryLabel({
- modelLabel: currentModelOption?.label ?? currentModelSelection.model,
- optionDescriptors: providerOptionDescriptors,
- runtimeMode: currentRuntimeMode,
- interactionMode: currentInteractionMode,
- });
-
- // iOS gets a native menu on the trigger pill: the everyday adjustments
- // apply without resigning the keyboard, while "All Settings…" (and the
- // Android trigger) still route through the sheet, which must dismiss it.
- const settingsMenu = useMemo(
- () =>
- Platform.OS === "ios"
- ? buildThreadSettingsMenu({
- providerGroups: threadProviderGroups,
- selectedModel: currentModelSelection,
- optionDescriptors: providerOptionDescriptors,
- runtimeMode: currentRuntimeMode,
- })
- : null,
- [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode],
- );
-
- const onUpdateModelSelection = props.onUpdateModelSelection;
- const onUpdateRuntimeMode = props.onUpdateRuntimeMode;
- const handleSettingsMenuAction = useCallback(
- (eventId: string) => {
- const event = settingsMenu?.events.get(eventId);
- if (!event) {
- return;
- }
- switch (event.type) {
- case "select-model":
- void Haptics.selectionAsync();
- onUpdateModelSelection(event.option.selection);
- return;
- case "set-option": {
- const options = applyProviderOptionSelection(providerOptionDescriptors, {
- id: event.optionId,
- value: event.value,
- });
- if (options) {
- void Haptics.selectionAsync();
- onUpdateModelSelection({ ...currentModelSelection, options });
- }
- return;
- }
- case "set-runtime":
- void Haptics.selectionAsync();
- onUpdateRuntimeMode(event.mode);
- return;
- }
- },
+ const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id);
+ const settingsRouteSession = useMemo(
+ () => ({
+ ownerId: settingsOwnerId,
+ providerGroups: threadProviderGroups,
+ selectedModel: currentModelSelection,
+ onSelectModel: (option) => props.onUpdateModelSelection(option.selection),
+ optionDescriptors: providerOptionDescriptors,
+ onUpdateOptionSelections: (options) =>
+ props.onUpdateModelSelection({ ...currentModelSelection, options }),
+ runtimeMode: currentRuntimeMode,
+ onUpdateRuntimeMode: props.onUpdateRuntimeMode,
+ }),
[
currentModelSelection,
- onUpdateModelSelection,
- onUpdateRuntimeMode,
+ currentRuntimeMode,
+ props.onUpdateModelSelection,
+ props.onUpdateRuntimeMode,
providerOptionDescriptors,
- settingsMenu,
+ settingsOwnerId,
+ threadProviderGroups,
],
);
+ const openSettings = useCallback(() => {
+ settingsRoutePresentation.present(settingsRouteSession);
+ settingsSheetPresentation.open();
+ }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.open]);
+
+ useEffect(() => {
+ if (settingsSheetPresentation.isActive) {
+ settingsRoutePresentation.present(settingsRouteSession);
+ }
+ }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.isActive]);
+
+ useEffect(() => {
+ if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) {
+ return;
+ }
+
+ settingsRoutePresentedRef.current = true;
+ navigation.dispatch(StackActions.push("ThreadSettingsSheet"));
+ }, [navigation, settingsSheetPresentation.isVisible]);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (!settingsRoutePresentedRef.current) {
+ return;
+ }
+
+ settingsRoutePresentedRef.current = false;
+ settingsSheetPresentation.onDismissed();
+ settingsRoutePresentation.clear(settingsOwnerId);
+ }, [settingsOwnerId, settingsRoutePresentation.clear, settingsSheetPresentation.onDismissed]),
+ );
+
+ useEffect(
+ () =>
+ // UIKit's completion callback for the sheet dismissal, surfaced by the
+ // native-stack patch. This is when the queued keyboard restore runs.
+ (navigation as unknown as NavigationWithFinishTransitioning).addListener(
+ "finishTransitioning",
+ settingsSheetPresentation.onStackTransitionsFinished,
+ ),
+ [navigation, settingsSheetPresentation.onStackTransitionsFinished],
+ );
return (
) : null}
-
-
- {isExpanded ? (
- // Toolbar row — matches draft page layout (expanded only)
-
-
+ {isExpanded ? (
+
void props.onPickDraftImages()}
showChevron={false}
/>
- {settingsMenu ? (
- handleSettingsMenuAction(nativeEvent.event)}
- >
-
- }
- label={settingsSummaryLabel}
- maxWidth={320}
- />
-
- ) : (
-
- }
- label={settingsSummaryLabel}
- maxWidth={320}
- onPress={settingsSheetPresentation.open}
- />
- )}
+
+ }
+ label={currentModelOption?.label ?? currentModelSelection.model}
+ maxWidth={152}
+ onPress={openSettings}
+ />
{showStopAction ? (
-
- ) : null}
+ ) : null}
+
{/* Queue count */}
{props.queueCount > 0 ? (
@@ -915,21 +900,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
) : null}
- props.onUpdateModelSelection(option.selection)}
- optionDescriptors={providerOptionDescriptors}
- onUpdateOptionSelections={(options) =>
- props.onUpdateModelSelection({ ...currentModelSelection, options })
- }
- runtimeMode={currentRuntimeMode}
- onUpdateRuntimeMode={props.onUpdateRuntimeMode}
- />
-
= new Set(["claudeAgent", "codex"]);
-
/**
- * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill,
- * covering model, provider options, runtime mode, and plan mode in one label.
+ * Keep measured row changes stable, but let catalog mutations use the list's
+ * native bounds so a filtered catalog that underflows returns to the top.
*/
-export function threadSettingsSummaryLabel(input: {
- readonly modelLabel: string;
- readonly optionDescriptors: ReadonlyArray;
- readonly runtimeMode: RuntimeMode;
- readonly interactionMode: ProviderInteractionMode;
-}): string {
- const runtime = RUNTIME_MODE_CHOICES.find((choice) => choice.mode === input.runtimeMode);
- return [
- input.modelLabel,
- ...providerOptionValueLabels(input.optionDescriptors),
- ...(runtime ? [runtime.shortLabel] : []),
- ...(input.interactionMode === "plan" ? ["Plan"] : []),
- ].join(" · ");
-}
-
+const THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION = {
+ data: false,
+ size: true,
+} as const;
+const THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION = LinearTransition.duration(180);
+const THREAD_SETTINGS_CATALOG_ENTER_TRANSITION = FadeIn.duration(140);
+const THREAD_SETTINGS_CATALOG_EXIT_TRANSITION = FadeOut.duration(120);
+const THREAD_SETTINGS_OPTIONS_LAYOUT_TRANSITION = LinearTransition.duration(180);
+const THREAD_SETTINGS_OPTION_ENTER_TRANSITION = FadeIn.duration(140);
+const THREAD_SETTINGS_OPTION_EXIT_TRANSITION = FadeOut.duration(100);
+const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(
+ Platform.OS,
+ Platform.Version,
+);
function ModelRow(props: {
readonly option: ModelOption;
readonly selected: boolean;
readonly onPress: () => void;
+ readonly isFirst: boolean;
+ readonly isLast: boolean;
}) {
- const primaryFg = useThemeColor("--color-primary-foreground");
+ const checkmarkColor = useThemeColor("--color-icon");
return (
-
+
{props.option.label}
{props.option.isDefault ? (
@@ -101,17 +122,19 @@ function ModelRow(props: {
) : null}
{props.selected ? (
-
+
) : null}
);
}
-/**
- * Provider section header with the harness logo. Secondary providers render
- * as a tappable fold (count + chevron while collapsed); primary providers
- * and the group holding the current selection are static headers.
- */
+/** Provider catalog header with its harness logo and disclosure state. */
function ProviderHeader(props: {
readonly driver: string | undefined;
readonly label: string;
@@ -121,24 +144,10 @@ function ProviderHeader(props: {
readonly onToggle: () => void;
}) {
const iconSubtle = useThemeColor("--color-icon-subtle");
- return (
-
+ const content = (
+ <>
-
- {props.label}
-
+ {props.label}
{props.collapsible ? (
<>
@@ -149,13 +158,33 @@ function ProviderHeader(props: {
) : null}
>
) : null}
-
+ >
+ );
+
+ if (props.collapsible) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
);
}
@@ -163,18 +192,17 @@ function ProviderHeader(props: {
function DisclosureRow(props: {
readonly label: string;
readonly value: string | undefined;
- readonly disabled?: boolean;
readonly onPress: () => void;
+ readonly isLast?: boolean;
}) {
const iconSubtle = useThemeColor("--color-icon-subtle");
return (
{props.label}
@@ -192,31 +220,37 @@ function DisclosureRow(props: {
/** Single option inside a submenu panel. */
function ChoiceRow(props: {
readonly label: string;
+ readonly description?: string;
readonly selected: boolean;
readonly onPress: () => void;
+ readonly isLast: boolean;
}) {
- const primaryFg = useThemeColor("--color-primary-foreground");
+ const checkmarkColor = useThemeColor("--color-icon");
return (
-
- {props.label}
-
-
+
+ {props.label}
+ {props.description ? (
+ {props.description}
+ ) : null}
+
{props.selected ? (
-
+
) : null}
);
@@ -225,60 +259,31 @@ function ChoiceRow(props: {
function SwitchRow(props: {
readonly label: string;
readonly value: boolean;
- readonly disabled?: boolean;
readonly onValueChange: (value: boolean) => void;
+ readonly isLast?: boolean;
}) {
- const activeTrack = String(useThemeColor("--color-switch-active"));
- const track = String(useThemeColor("--color-secondary-border"));
return (
{props.label}
);
}
-type SubmenuPage =
+type ThreadSettingsSubmenuPage =
| { readonly kind: "descriptor"; readonly id: string }
| { readonly kind: "runtime" };
-/**
- * Unified thread settings: the sheet is the provider-grouped model list
- * (primary harnesses expanded, other providers folded, legacy behind the
- * top-right pill) with a Save button, plus compact disclosure rows whose
- * single-choice submenus stack in a small panel over the sheet so it never
- * changes size. Model changes stage until Save — while staged, the settings
- * rows edit the staged model's options and Save applies everything together.
- *
- * Callers control which harnesses are offered via providerGroups: an
- * existing thread must pass only its own provider's group, since a session
- * can't switch harness mid-thread.
- *
- * Rendered through an RN Modal (not the root OverlayPortal) so it also
- * presents above natively-presented form sheets like the new-task draft.
- * Callers must dismiss the keyboard when opening — the iOS keyboard window
- * would otherwise cover the lower half of the sheet.
- */
-export function ThreadSettingsSheet(props: {
- readonly visible: boolean;
- /**
- * "save" = the Save/Done button (the user is finished configuring);
- * "dismiss" = backdrop, grabber, or system back. Hosts only restore the
- * keyboard for "save" so a stray tap outside a control never pops it.
- */
- readonly onClose: (reason: ThreadSettingsSheetCloseReason) => void;
- readonly onDismissed: () => void;
+type ThreadSettingsSessionProps = {
readonly providerGroups: ReadonlyArray;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
@@ -286,367 +291,949 @@ export function ThreadSettingsSheet(props: {
readonly onUpdateOptionSelections: (selections: ReadonlyArray) => void;
readonly runtimeMode: RuntimeMode;
readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
-}) {
- const insets = useSafeAreaInsets();
- const { height: windowHeight } = useWindowDimensions();
+};
+
+export type ExistingThreadSettingsRouteSession = ThreadSettingsSessionProps & {
+ readonly ownerId: string;
+};
+
+type ExistingThreadSettingsRouteContextValue = {
+ readonly session: ExistingThreadSettingsRouteSession | null;
+ readonly present: (session: ExistingThreadSettingsRouteSession) => void;
+ readonly clear: (ownerId: string) => void;
+};
+
+const ExistingThreadSettingsRouteContext =
+ createContext(null);
+
+/** Bridges the active thread's settings state into the root native sheet route. */
+export function ExistingThreadSettingsRouteProvider(props: { readonly children: ReactNode }) {
+ const [session, setSession] = useState(null);
+ const present = useCallback((nextSession: ExistingThreadSettingsRouteSession) => {
+ setSession(nextSession);
+ }, []);
+ const clear = useCallback((ownerId: string) => {
+ setSession((current) => (current?.ownerId === ownerId ? null : current));
+ }, []);
+ const value = useMemo(() => ({ session, present, clear }), [clear, present, session]);
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function useExistingThreadSettingsRoutePresentation() {
+ const value = use(ExistingThreadSettingsRouteContext);
+ if (!value) {
+ throw new Error(
+ "useExistingThreadSettingsRoutePresentation must be used inside ExistingThreadSettingsRouteProvider.",
+ );
+ }
+ return value;
+}
+
+type ThreadSettingsSessionValue = {
+ readonly providerGroups: ReadonlyArray;
+ readonly runtimeMode: RuntimeMode;
+ readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
+ readonly displayedDescriptors: ReadonlyArray;
+ readonly providerExpansionOverrides: ReadonlySet;
+ readonly hasLegacyModels: boolean;
+ readonly pendingModel: ModelOption | null;
+ readonly providerFilter: string | null;
+ readonly searchQuery: string;
+ readonly showLegacy: boolean;
+ readonly applyOptionChange: (id: string, value: string | boolean) => void;
+ readonly commitPendingModel: () => void;
+ readonly isApplied: (option: ModelOption) => boolean;
+ readonly isDisplayed: (option: ModelOption) => boolean;
+ readonly pressModel: (option: ModelOption) => void;
+ readonly setProviderFilter: (providerKey: string | null) => void;
+ readonly setSearchQuery: (query: string) => void;
+ readonly setShowLegacy: (showLegacy: boolean) => void;
+ readonly toggleProvider: (providerKey: string) => void;
+};
+
+const ThreadSettingsSessionContext = createContext(null);
+
+/** Owns the staged model and option state for one picker presentation. */
+function ThreadSettingsSessionProvider(
+ props: ThreadSettingsSessionProps & { readonly children: ReactNode },
+) {
const [showLegacyToggle, setShowLegacyToggle] = useState(false);
- const [expandedProviders, setExpandedProviders] = useState>(() => new Set());
+ const [providerFilter, setProviderFilter] = useState(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [providerExpansionOverrides, setProviderExpansionOverrides] = useState>(
+ () => new Set(),
+ );
const [pendingModel, setPendingModel] = useState(null);
- const [submenu, setSubmenu] = useState(null);
- const wasPresentedRef = useRef(false);
- const notifyDismissed = useCallback(() => {
- if (!wasPresentedRef.current) {
- return;
- }
- wasPresentedRef.current = false;
- props.onDismissed();
- }, [props.onDismissed]);
- // Every open starts fresh: no staged model, no submenu, legacy hidden,
- // secondary providers folded. The sheet stays mounted between opens, so
- // state would otherwise stick around.
- useEffect(() => {
- if (props.visible) {
- wasPresentedRef.current = true;
- setShowLegacyToggle(false);
- setExpandedProviders(new Set());
- setPendingModel(null);
- setSubmenu(null);
- } else if (Platform.OS === "android" && wasPresentedRef.current) {
- // React Native only emits Modal.onDismiss on iOS. Android uses no exit
- // animation below, so the post-commit effect is its dismissal boundary.
- notifyDismissed();
- }
- }, [notifyDismissed, props.visible]);
-
- const isApplied = (option: ModelOption) =>
- option.selection.instanceId === props.selectedModel?.instanceId &&
- option.selection.model === props.selectedModel.model;
+ const isApplied = useCallback(
+ (option: ModelOption) =>
+ option.selection.instanceId === props.selectedModel?.instanceId &&
+ option.selection.model === props.selectedModel.model,
+ [props.selectedModel],
+ );
// The list highlights the staged pick; Save turns it into the applied one.
- const isDisplayed = (option: ModelOption) =>
- pendingModel ? option.key === pendingModel.key : isApplied(option);
+ const isDisplayed = useCallback(
+ (option: ModelOption) => (pendingModel ? option.key === pendingModel.key : isApplied(option)),
+ [isApplied, pendingModel],
+ );
// While a model is staged, the settings rows describe and edit the staged
// model's options (kept on its pending selection); Save applies model and
// options together. Otherwise they edit the applied selection directly.
- const displayedDescriptors = pendingModel
- ? pendingModel.capabilities
- ? getProviderOptionDescriptors({
- caps: pendingModel.capabilities,
- selections: pendingModel.selection.options,
- })
- : []
- : props.optionDescriptors;
-
- const hasLegacyModels = props.providerGroups.some((group) =>
- group.models.some((model) => model.isLegacy),
- );
- // Legacy stays hidden unless the pill is toggled this open; a highlighted
- // legacy model is exempted from the filter instead of forcing the whole
- // legacy list visible.
- const showLegacy = showLegacyToggle;
-
- // Stable settings rows: the union of descriptors across the primary
- // harnesses' current models (plus whatever the displayed model advertises)
- // always renders, with unsupported rows disabled instead of vanishing when
- // the selection changes. Keyed by label, not id — Claude and Codex use
- // different ids for the same "Reasoning" concept.
- const descriptorTemplate = (() => {
- const seen = new Map();
- for (const group of props.providerGroups) {
- const driver = group.models[0]?.providerDriver;
- if (driver === undefined || !PRIMARY_PROVIDER_DRIVERS.has(driver)) {
- continue;
- }
- for (const model of group.models) {
- if (model.isLegacy) {
- continue;
- }
- for (const descriptor of model.capabilities?.optionDescriptors ?? []) {
- if (!seen.has(descriptor.label)) {
- seen.set(descriptor.label, { type: descriptor.type });
- }
- }
- }
- }
- for (const descriptor of displayedDescriptors) {
- if (!seen.has(descriptor.label)) {
- seen.set(descriptor.label, { type: descriptor.type });
- }
- }
- return [...seen.entries()].map(([label, entry]) => ({ label, ...entry }));
- })();
+ const displayedDescriptors = useMemo(
+ () =>
+ pendingModel
+ ? pendingModel.capabilities
+ ? getProviderOptionDescriptors({
+ caps: pendingModel.capabilities,
+ selections: pendingModel.selection.options,
+ })
+ : []
+ : props.optionDescriptors,
+ [pendingModel, props.optionDescriptors],
+ );
- const handleSave = () => {
+ const hasLegacyModels = useMemo(
+ () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)),
+ [props.providerGroups],
+ );
+ const commitPendingModel = useCallback(() => {
if (pendingModel) {
void Haptics.selectionAsync();
props.onSelectModel(pendingModel);
}
- props.onClose("save");
- };
+ }, [pendingModel, props.onSelectModel]);
- const handleOptionChange = (id: string, value: string | boolean) => {
- const next = applyProviderOptionSelection(displayedDescriptors, { id, value });
- if (!next) {
- return;
- }
- if (pendingModel) {
- setPendingModel({
- ...pendingModel,
- selection: { ...pendingModel.selection, options: next },
- });
- } else {
- props.onUpdateOptionSelections(next);
- }
- };
+ const applyOptionChange = useCallback(
+ (id: string, value: string | boolean) => {
+ const next = applyProviderOptionSelection(displayedDescriptors, { id, value });
+ if (!next) {
+ return;
+ }
+ if (pendingModel) {
+ setPendingModel({
+ ...pendingModel,
+ selection: { ...pendingModel.selection, options: next },
+ });
+ } else {
+ props.onUpdateOptionSelections(next);
+ }
+ },
+ [displayedDescriptors, pendingModel, props.onUpdateOptionSelections],
+ );
- const toggleProvider = (providerKey: string) => {
- setExpandedProviders((current) => {
+ const toggleProvider = useCallback((providerKey: string) => {
+ setProviderExpansionOverrides((current) => {
const next = new Set(current);
if (!next.delete(providerKey)) {
next.add(providerKey);
}
return next;
});
- };
+ }, []);
+
+ const pressModel = useCallback(
+ (option: ModelOption) => {
+ void Haptics.selectionAsync();
+ setPendingModel((current) =>
+ pendingModelAfterPress({
+ current,
+ pressed: option,
+ pressedIsApplied: isApplied(option),
+ }),
+ );
+ },
+ [isApplied],
+ );
+
+ const value = useMemo(
+ () => ({
+ providerGroups: props.providerGroups,
+ runtimeMode: props.runtimeMode,
+ onUpdateRuntimeMode: props.onUpdateRuntimeMode,
+ displayedDescriptors,
+ providerExpansionOverrides,
+ hasLegacyModels,
+ pendingModel,
+ providerFilter,
+ searchQuery,
+ showLegacy: showLegacyToggle,
+ applyOptionChange,
+ commitPendingModel,
+ isApplied,
+ isDisplayed,
+ pressModel,
+ setProviderFilter,
+ setSearchQuery,
+ setShowLegacy: setShowLegacyToggle,
+ toggleProvider,
+ }),
+ [
+ applyOptionChange,
+ commitPendingModel,
+ displayedDescriptors,
+ providerExpansionOverrides,
+ hasLegacyModels,
+ isApplied,
+ isDisplayed,
+ pendingModel,
+ pressModel,
+ providerFilter,
+ props.onUpdateRuntimeMode,
+ props.providerGroups,
+ props.runtimeMode,
+ searchQuery,
+ showLegacyToggle,
+ toggleProvider,
+ ],
+ );
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+function useThreadSettingsSession() {
+ const value = use(ThreadSettingsSessionContext);
+ if (!value) {
+ throw new Error("useThreadSettingsSession must be used inside ThreadSettingsSessionProvider.");
+ }
+ return value;
+}
+
+type ThreadSettingsProviderCatalog = {
+ readonly key: string;
+ readonly driver: string | undefined;
+ readonly label: string;
+ readonly collapsible: boolean;
+ readonly collapsed: boolean;
+ readonly modelCount: number;
+ readonly models: ReadonlyArray;
+};
+
+type ThreadSettingsCatalogItem =
+ | {
+ readonly kind: "provider";
+ readonly key: string;
+ readonly provider: ThreadSettingsProviderCatalog;
+ }
+ | {
+ readonly kind: "model";
+ readonly key: string;
+ readonly option: ModelOption;
+ readonly isFirst: boolean;
+ readonly isLast: boolean;
+ }
+ | {
+ readonly kind: "empty";
+ readonly key: "empty";
+ }
+ | {
+ readonly kind: "options";
+ readonly key: "options";
+ };
+
+function ThreadSettingsModelListRow(props: {
+ readonly option: ModelOption;
+ readonly isFirst: boolean;
+ readonly isLast: boolean;
+}) {
+ const session = useThreadSettingsSession();
+ const onPress = useCallback(
+ () => session.pressModel(props.option),
+ [props.option, session.pressModel],
+ );
+
+ return (
+
+ );
+}
+
+function ThreadSettingsProviderListHeader(props: {
+ readonly provider: ThreadSettingsProviderCatalog;
+}) {
+ const session = useThreadSettingsSession();
+ const onToggle = useCallback(
+ () => session.toggleProvider(props.provider.key),
+ [props.provider.key, session.toggleProvider],
+ );
+
+ return (
+
+ );
+}
+
+function useThreadSettingsCatalogItems(
+ session: ThreadSettingsSessionValue,
+): ReadonlyArray {
+ return useMemo(
+ () =>
+ session.providerGroups.flatMap((group) => {
+ if (session.providerFilter !== null && group.providerKey !== session.providerFilter) {
+ return [];
+ }
+ const driver = group.models[0]?.providerDriver;
+ const catalogModels = session.showLegacy
+ ? group.models
+ : group.models.filter((model) => !model.isLegacy || session.isDisplayed(model));
+ const visibleModels = catalogModels.filter((model) =>
+ modelMatchesCatalogQuery({
+ model,
+ providerLabel: group.providerLabel,
+ query: session.searchQuery,
+ }),
+ );
+ if (visibleModels.length === 0) {
+ return [];
+ }
+ const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver);
+ // Staging a model must not change disclosure state. The applied model
+ // stays stable for the lifetime of this picker (Save closes it), so it
+ // is safe to use as the initial selected-provider default.
+ const containsAppliedSelection = group.models.some(session.isApplied);
+ const isNarrowed = session.providerFilter !== null || session.searchQuery.trim().length > 0;
+ const collapsible = !isNarrowed;
+ const collapsed = providerSectionIsCollapsed({
+ defaultExpanded: isPrimary || containsAppliedSelection,
+ hasExpansionOverride: session.providerExpansionOverrides.has(group.providerKey),
+ isNarrowed,
+ });
+ const provider: ThreadSettingsProviderCatalog = {
+ key: group.providerKey,
+ driver,
+ label: group.providerLabel,
+ collapsible,
+ collapsed,
+ modelCount: visibleModels.length,
+ models: collapsed ? [] : visibleModels,
+ };
+ return [
+ {
+ kind: "provider" as const,
+ key: `provider:${group.providerKey}`,
+ provider,
+ },
+ ...provider.models.map((option, index) => ({
+ kind: "model" as const,
+ key: `model:${option.key}`,
+ option,
+ isFirst: index === 0,
+ isLast: index === provider.models.length - 1,
+ })),
+ ];
+ }),
+ [
+ session.isApplied,
+ session.isDisplayed,
+ session.providerExpansionOverrides,
+ session.providerFilter,
+ session.providerGroups,
+ session.searchQuery,
+ session.showLegacy,
+ ],
+ );
+}
+
+function ThreadSettingsOptionsItem(props: {
+ readonly animationsReady: boolean;
+ readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
+}) {
+ const insets = useSafeAreaInsets();
+ const session = useThreadSettingsSession();
+ const bottomToolbarInset =
+ Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
+ ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET
+ : 0;
+
+ return (
+
+ Options
+
+ {session.displayedDescriptors.map((descriptor) => {
+ if (descriptor.type === "select") {
+ return (
+
+ props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })}
+ />
+
+ );
+ }
+ return (
+
+ session.applyOptionChange(descriptor.id, value)}
+ />
+
+ );
+ })}
+
+ choice.mode === session.runtimeMode)?.label
+ }
+ onPress={() => props.onOpenSubmenu({ kind: "runtime" })}
+ />
+
+
+
+ {Platform.OS !== "ios" && session.hasLegacyModels ? (
+ <>
+
+ Catalog
+
+
+
+
+ >
+ ) : null}
+
+ );
+}
+
+/** One native scroll owner for the model catalog and its related settings. */
+function ThreadSettingsMainContent(props: {
+ readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
+}) {
+ const session = useThreadSettingsSession();
+ const catalogItems = useThreadSettingsCatalogItems(session);
+ const [animationsReady, setAnimationsReady] = useState(false);
+ const nativeHeaderHeight = use(HeaderHeightContext) ?? 0;
+ const hasActiveCatalogFilter =
+ session.providerFilter !== null || session.searchQuery.trim().length > 0;
+ const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED;
+ const listItems = useMemo>(
+ () => [
+ ...(catalogItems.length === 0 && hasActiveCatalogFilter
+ ? ([{ kind: "empty", key: "empty" }] as const)
+ : catalogItems),
+ { kind: "options", key: "options" },
+ ],
+ [catalogItems, hasActiveCatalogFilter],
+ );
+ const renderCatalogItem = useCallback(
+ (itemProps: LegendListRenderItemProps) => {
+ const item = itemProps.item;
+ let content: ReactNode;
+
+ if (item.kind === "provider") {
+ content = ;
+ } else if (item.kind === "model") {
+ content = (
+
+ );
+ } else if (item.kind === "empty") {
+ content = (
+
+ No matching models
+
+ );
+ } else {
+ content = (
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ },
+ [animationsReady, props.onOpenSubmenu],
+ );
+
+ return (
+ item.kind}
+ itemLayoutAnimation={THREAD_SETTINGS_CATALOG_LAYOUT_TRANSITION}
+ keyExtractor={(item) => item.key}
+ keyboardDismissMode="on-drag"
+ keyboardShouldPersistTaps="handled"
+ maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION}
+ ListHeaderComponent={
+ <>
+ {usesTransparentNativeHeader ? : null}
+ {Platform.OS === "android" ? (
+
+
+
+ ) : null}
+ >
+ }
+ recycleItems
+ onLoad={() => setAnimationsReady(true)}
+ renderItem={renderCatalogItem}
+ showsVerticalScrollIndicator={false}
+ />
+ );
+}
+
+/** Compact choice page pushed by the picker navigator. */
+function ThreadSettingsChoiceContent(props: {
+ readonly submenu: ThreadSettingsSubmenuPage;
+ readonly onSelected: () => void;
+}) {
+ const insets = useSafeAreaInsets();
+ const session = useThreadSettingsSession();
+ const descriptorId = props.submenu.kind === "descriptor" ? props.submenu.id : null;
const activeDescriptor =
- submenu?.kind === "descriptor"
- ? displayedDescriptors.find(
- (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
+ descriptorId !== null
+ ? session.displayedDescriptors.find(
+ (descriptor) => descriptor.type === "select" && descriptor.id === descriptorId,
)
: undefined;
const submenuContent =
- submenu?.kind === "runtime"
+ props.submenu.kind === "runtime"
? {
- title: "Runtime",
rows: RUNTIME_MODE_CHOICES.map((choice) => ({
id: choice.mode,
label: choice.label,
- selected: choice.mode === props.runtimeMode,
+ description: choice.description,
+ selected: choice.mode === session.runtimeMode,
onPress: () => {
void Haptics.selectionAsync();
- props.onUpdateRuntimeMode(choice.mode);
- setSubmenu(null);
+ session.onUpdateRuntimeMode(choice.mode);
+ props.onSelected();
},
})),
}
: activeDescriptor?.type === "select"
? {
- title: activeDescriptor.label,
rows: selectableChoices(activeDescriptor).map((choice) => ({
id: choice.id,
label: choice.label,
+ description: undefined,
selected: choice.id === getProviderOptionCurrentValue(activeDescriptor),
onPress: () => {
void Haptics.selectionAsync();
- handleOptionChange(activeDescriptor.id, choice.id);
- setSubmenu(null);
+ session.applyOptionChange(activeDescriptor.id, choice.id);
+ props.onSelected();
},
})),
}
: null;
+ if (!submenuContent) {
+ return ;
+ }
+
return (
- setSubmenu(null) : () => props.onClose("dismiss")}
+
-
- props.onClose("dismiss")}
+
+ {submenuContent.rows.map((row, index) => (
+
+ ))}
+
+
+ );
+}
+
+type ThreadSettingsPickerStackParams = {
+ ThreadSettingsModels: undefined;
+ ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string };
+};
+
+type ThreadSettingsPickerPresentation = {
+ readonly onClose: () => void;
+};
+
+const ThreadSettingsPickerStack = createNativeStackNavigator();
+const ThreadSettingsPickerPresentationContext =
+ createContext(null);
+
+function useThreadSettingsPickerPresentation() {
+ const value = use(ThreadSettingsPickerPresentationContext);
+ if (!value) {
+ throw new Error(
+ "useThreadSettingsPickerPresentation must be used inside ThreadSettingsPickerNavigator.",
+ );
+ }
+ return value;
+}
+
+function ThreadSettingsModelsScreen() {
+ const session = useThreadSettingsSession();
+ const presentation = useThreadSettingsPickerPresentation();
+ const navigation = useNavigation>();
+ const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
+ const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;
+ const commitAndClose = useCallback(() => {
+ session.commitPendingModel();
+ presentation.onClose();
+ }, [presentation, session]);
+ const filterMenu = useMemo(
+ () => ({
+ title: "Model filters",
+ items: [
+ {
+ type: "submenu" as const,
+ title: "Provider",
+ items: [
+ {
+ type: "action" as const,
+ title: "All providers",
+ state: session.providerFilter === null ? ("on" as const) : ("off" as const),
+ onPress: () => session.setProviderFilter(null),
+ },
+ ...session.providerGroups.map((group) => ({
+ type: "action" as const,
+ title: group.providerLabel,
+ state:
+ session.providerFilter === group.providerKey ? ("on" as const) : ("off" as const),
+ onPress: () => session.setProviderFilter(group.providerKey),
+ })),
+ ],
+ },
+ ...(session.hasLegacyModels
+ ? [
+ {
+ type: "action" as const,
+ title: "Show legacy models",
+ state: session.showLegacy ? ("on" as const) : ("off" as const),
+ onPress: () => session.setShowLegacy(!session.showLegacy),
+ },
+ ]
+ : []),
+ ],
+ }),
+ [session],
+ );
+
+ return (
+ <>
+ {Platform.OS === "android" ? (
+
-
- {/* The grabber doubles as the accessible close control: the dim
- backdrop above a tall sheet is a sliver, and VoiceOver can't
- reach it at all. */}
- props.onClose("dismiss")}
- className="items-center pb-1 pt-2.5"
- >
-
-
- {hasLegacyModels ? (
-
- {
- void Haptics.selectionAsync();
- setShowLegacyToggle(!showLegacy);
- }}
- className="rounded-full border border-border bg-subtle px-3 py-1.5 active:opacity-70"
- >
-
- {showLegacy ? "Hide legacy models" : "Show legacy models"}
-
-
-
- ) : null}
- {/* Only the model list scrolls. Provider catalogs can run to
- hundreds of models (OpenRouter), so the rows below stay pinned
- and reachable instead of living at the end of that scroll. */}
- group.providerKey),
+ session.showLegacy,
+ ]}
+ options={{
+ unstable_headerToolbarItems: usesNativeMailSearchToolbar
+ ? () => [
+ createNativeMailSearchToolbarItem({
+ filterButtonId: "thread-settings-model-filter",
+ filterMenu,
+ filterSystemImageName: hasCustomCatalogFilter
+ ? "line.3.horizontal.decrease.circle.fill"
+ : "line.3.horizontal.decrease",
+ onSearchTextChange: session.setSearchQuery,
+ placeholder: "Find a model",
+ searchTextChangeId: "thread-settings-model-search-text",
+ showsSearchDismissButton: true,
+ }),
+ ]
+ : undefined,
+ headerShown: Platform.OS !== "android",
+ headerSearchBarOptions:
+ Platform.OS === "ios" && !usesNativeMailSearchToolbar
+ ? {
+ autoCapitalize: "none",
+ hideNavigationBar: false,
+ obscureBackground: false,
+ onCancelButtonPress: () => session.setSearchQuery(""),
+ onChangeText: (event) => session.setSearchQuery(event.nativeEvent.text),
+ placeholder: "Find a model",
+ }
+ : undefined,
+ }}
+ />
+ {
+ const title =
+ submenu.kind === "runtime"
+ ? "Runtime"
+ : (session.displayedDescriptors.find(
+ (descriptor) => descriptor.type === "select" && descriptor.id === submenu.id,
+ )?.label ?? "Option");
+ navigation.navigate("ThreadSettingsChoice", { ...submenu, title });
+ }}
+ />
+
+
+
+
+
+
+ {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? (
+
+
- {props.providerGroups.map((group) => {
- const driver = group.models[0]?.providerDriver;
- const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver);
- const visibleModels = showLegacy
- ? group.models
- : group.models.filter((model) => !model.isLegacy || isDisplayed(model));
- if (visibleModels.length === 0) {
- return null;
- }
- const containsSelection = group.models.some(isDisplayed);
- const collapsible = !isPrimary && !containsSelection;
- const collapsed = collapsible && !expandedProviders.has(group.providerKey);
- return (
-
- toggleProvider(group.providerKey)}
- />
- {collapsed
- ? null
- : visibleModels.map((option) => (
- {
- void Haptics.selectionAsync();
- // Re-tapping the applied model cancels staging.
- setPendingModel((current) =>
- pendingModelAfterPress({
- current,
- pressed: option,
- pressedIsApplied: isApplied(option),
- }),
- );
- }}
- />
- ))}
-
- );
- })}
-
-
-
-
-
- {descriptorTemplate.map((entry) => {
- const live = displayedDescriptors.find(
- (descriptor) => descriptor.label === entry.label,
- );
- if ((live?.type ?? entry.type) === "select") {
- return (
- {
- if (live) {
- setSubmenu({ kind: "descriptor", id: live.id });
- }
- }}
- />
- );
- }
- return (
- {
- if (live) {
- handleOptionChange(live.id, value);
- }
- }}
- />
- );
- })}
- choice.mode === props.runtimeMode)?.label
- }
- onPress={() => setSubmenu({ kind: "runtime" })}
- />
-
-
- {pendingModel ? "Save" : "Done"}
-
-
-
-
-
- {/* Submenus stack over the sheet instead of replacing its content,
- so the main sheet keeps its size while drilling in and out. */}
- {submenuContent ? (
-
- setSubmenu(null)}
- />
-
- setSubmenu(null)}
- className="items-center pb-1 pt-2.5"
+
+ Provider
+ session.setProviderFilter(null)}
>
-
-
-
- {submenuContent.title}
-
-
+ {session.providerGroups.map((group) => (
+ session.setProviderFilter(group.providerKey)}
+ >
+ {group.providerLabel}
+
+ ))}
+
+ {session.hasLegacyModels ? (
+ session.setShowLegacy(!session.showLegacy)}
>
- {submenuContent.rows.map((row) => (
-
- ))}
-
-
-
- ) : null}
-
-
+ Show legacy models
+
+ ) : null}
+
+
+ ) : null}
+ >
+ );
+}
+
+function ThreadSettingsChoiceScreen() {
+ const navigation = useNavigation>();
+ const route = useRoute>();
+
+ return (
+ <>
+
+ {Platform.OS === "android" ? (
+ navigation.goBack()} />
+ ) : null}
+ navigation.goBack()} />
+ >
+ );
+}
+
+function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) {
+ const sheetBackground = String(useThemeColor("--color-sheet"));
+ const foreground = String(useThemeColor("--color-foreground"));
+ const nativeSheetBackground = NATIVE_SHEET_SURFACE_COLOR ?? sheetBackground;
+ const presentation = useMemo(
+ () => ({
+ onClose: props.onClose,
+ }),
+ [props.onClose],
+ );
+
+ return (
+
+
+
+ ({ title: route.params.title })}
+ />
+
+
+ );
+}
+
+/** Existing-thread model picker hosted by the root RNS form-sheet route. */
+export function ExistingThreadSettingsRouteScreen() {
+ const navigation = useNavigation>>();
+ const presentation = useExistingThreadSettingsRoutePresentation();
+ const session = presentation.session;
+
+ useEffect(() => {
+ if (session) {
+ return;
+ }
+
+ navigation.goBack();
+ }, [navigation, session]);
+
+ if (!session) {
+ return ;
+ }
+
+ const { ownerId: _ownerId, ...settings } = session;
+
+ return (
+
+ navigation.goBack()} />
+
+ );
+}
+
+/**
+ * Native stack hosted by the New Task navigator's form-sheet route. Keeping
+ * the sheet presentation in RNS gives UIKit ownership of nested dismissal,
+ * while Reasoning and Runtime remain regular pushes inside this navigator.
+ */
+export function NewTaskThreadSettingsRouteScreen() {
+ const flow = useNewTaskFlow();
+ const navigation = useNavigation>>();
+ const optionDescriptors = useMemo(
+ () =>
+ resolveProviderOptionDescriptors({
+ capabilities: flow.selectedModelOption?.capabilities,
+ selections: flow.selectedModel?.options,
+ }),
+ [flow.selectedModel?.options, flow.selectedModelOption?.capabilities],
+ );
+
+ return (
+ flow.setSelectedModelKey(option.key, option.selection.options)}
+ optionDescriptors={optionDescriptors}
+ onUpdateOptionSelections={flow.setSelectedModelOptions}
+ runtimeMode={flow.runtimeMode}
+ onUpdateRuntimeMode={flow.setRuntimeMode}
+ >
+ navigation.goBack()} />
+
);
}
diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts
new file mode 100644
index 000000000000..e556318855ff
--- /dev/null
+++ b/apps/mobile/src/features/threads/legacy-plan-mode.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";
+
+describe("resolvePendingTaskInteractionMode", () => {
+ it("preserves a queued plan task while the preference is still loading", () => {
+ expect(
+ resolvePendingTaskInteractionMode({
+ preferenceLoaded: false,
+ planModeEnabled: false,
+ draftInteractionMode: "plan",
+ queuedInteractionMode: "plan",
+ }),
+ ).toBe("plan");
+ });
+
+ it("forces build mode once the disabled preference has loaded", () => {
+ expect(
+ resolvePendingTaskInteractionMode({
+ preferenceLoaded: true,
+ planModeEnabled: false,
+ draftInteractionMode: "plan",
+ queuedInteractionMode: "plan",
+ }),
+ ).toBe("default");
+ });
+
+ it("keeps a fresh draft in build mode while the preference is loading", () => {
+ expect(
+ resolvePendingTaskInteractionMode({
+ preferenceLoaded: false,
+ planModeEnabled: false,
+ draftInteractionMode: "plan",
+ queuedInteractionMode: undefined,
+ }),
+ ).toBe("default");
+ });
+
+ it("honors the draft's mode when the plan preference is enabled", () => {
+ expect(
+ resolvePendingTaskInteractionMode({
+ preferenceLoaded: true,
+ planModeEnabled: true,
+ draftInteractionMode: "plan",
+ queuedInteractionMode: undefined,
+ }),
+ ).toBe("plan");
+ expect(
+ resolvePendingTaskInteractionMode({
+ preferenceLoaded: true,
+ planModeEnabled: true,
+ draftInteractionMode: undefined,
+ queuedInteractionMode: "plan",
+ }),
+ ).toBe("default");
+ });
+});
diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/features/threads/legacy-plan-mode.ts
new file mode 100644
index 000000000000..e7122125fb58
--- /dev/null
+++ b/apps/mobile/src/features/threads/legacy-plan-mode.ts
@@ -0,0 +1,29 @@
+import {
+ DEFAULT_PROVIDER_INTERACTION_MODE,
+ type ProviderInteractionMode,
+} from "@t3tools/contracts";
+
+export function resolveLegacyPlanModeEnabled(input: {
+ readonly loaded: boolean;
+ readonly preference: boolean | undefined;
+}): boolean {
+ return input.loaded && input.preference === true;
+}
+
+export function resolvePendingTaskInteractionMode(input: {
+ readonly preferenceLoaded: boolean;
+ readonly planModeEnabled: boolean;
+ readonly draftInteractionMode: ProviderInteractionMode | undefined;
+ readonly queuedInteractionMode: ProviderInteractionMode | undefined;
+}): ProviderInteractionMode {
+ if (input.planModeEnabled) {
+ return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
+ }
+ if (!input.preferenceLoaded) {
+ // Only an existing queued task may retain its previous mode while the
+ // preference is unknown. A fresh draft still defaults to Build so a stale
+ // persisted Plan selection cannot bypass a disabled preference at launch.
+ return input.queuedInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
+ }
+ return DEFAULT_PROVIDER_INTERACTION_MODE;
+}
diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts
new file mode 100644
index 000000000000..3c81d8231216
--- /dev/null
+++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ resolveNewTaskBranchWorktreePath,
+ resolveNewTaskBranchLabel,
+ resolveNewTaskLocalWorkspaceSelection,
+} from "./new-task-context-presentation";
+
+describe("resolveNewTaskLocalWorkspaceSelection", () => {
+ it("waits for refs instead of carrying a worktree base into Current checkout", () => {
+ expect(
+ resolveNewTaskLocalWorkspaceSelection({
+ branches: [],
+ projectCwd: "/repo",
+ }),
+ ).toEqual({
+ branch: null,
+ worktreePath: null,
+ awaitsCurrentBranch: true,
+ });
+ });
+
+ it("adopts the checkout's current branch once refs load", () => {
+ expect(
+ resolveNewTaskLocalWorkspaceSelection({
+ branches: [
+ { name: "feature/worktree-base", current: false, worktreePath: "/worktree" },
+ { name: "main", current: true, worktreePath: "/repo" },
+ ],
+ projectCwd: "/repo",
+ }),
+ ).toEqual({
+ branch: "main",
+ worktreePath: null,
+ awaitsCurrentBranch: false,
+ });
+ });
+
+ it("carries the worktree path when the current branch lives in another worktree", () => {
+ expect(
+ resolveNewTaskLocalWorkspaceSelection({
+ branches: [
+ { name: "feature/split", current: true, worktreePath: "/repo/.t3/worktrees/split" },
+ { name: "main", current: false, worktreePath: "/repo" },
+ ],
+ projectCwd: "/repo",
+ }),
+ ).toEqual({
+ branch: "feature/split",
+ worktreePath: "/repo/.t3/worktrees/split",
+ awaitsCurrentBranch: false,
+ });
+ });
+});
+
+describe("resolveNewTaskBranchWorktreePath", () => {
+ it("moves Current checkout to the selected existing worktree", () => {
+ expect(
+ resolveNewTaskBranchWorktreePath({
+ workspaceMode: "local",
+ projectCwd: "/repo",
+ branchWorktreePath: "/repo/.t3/worktrees/feature",
+ }),
+ ).toBe("/repo/.t3/worktrees/feature");
+ });
+
+ it("keeps the project checkout represented by a null override", () => {
+ expect(
+ resolveNewTaskBranchWorktreePath({
+ workspaceMode: "local",
+ projectCwd: "/repo",
+ branchWorktreePath: "/repo",
+ }),
+ ).toBeNull();
+ });
+
+ it("does not reuse an existing worktree while creating a new one", () => {
+ expect(
+ resolveNewTaskBranchWorktreePath({
+ workspaceMode: "worktree",
+ projectCwd: "/repo",
+ branchWorktreePath: "/repo/.t3/worktrees/feature",
+ }),
+ ).toBeNull();
+ });
+});
+
+describe("resolveNewTaskBranchLabel", () => {
+ it("shows the checked-out branch without a base-ref prefix", () => {
+ expect(
+ resolveNewTaskBranchLabel({
+ branchName: "feature/mobile",
+ startFromOrigin: true,
+ workspaceMode: "local",
+ }),
+ ).toBe("feature/mobile");
+ });
+
+ it("labels a local worktree base with From", () => {
+ expect(
+ resolveNewTaskBranchLabel({
+ branchName: "main",
+ startFromOrigin: false,
+ workspaceMode: "worktree",
+ }),
+ ).toBe("From main");
+ });
+
+ it("labels a remote worktree base with From origin", () => {
+ expect(
+ resolveNewTaskBranchLabel({
+ branchName: "main",
+ startFromOrigin: true,
+ workspaceMode: "worktree",
+ }),
+ ).toBe("From origin/main");
+ });
+
+ it("prompts when no branch is available", () => {
+ expect(
+ resolveNewTaskBranchLabel({
+ branchName: null,
+ startFromOrigin: true,
+ workspaceMode: "worktree",
+ }),
+ ).toBe("Choose branch");
+ });
+});
diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts
new file mode 100644
index 000000000000..99eee3ea48ae
--- /dev/null
+++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts
@@ -0,0 +1,83 @@
+type WorkspaceMode = "local" | "worktree";
+
+export function resolveNewTaskWorkspaceLabel(input: {
+ readonly workspaceMode: WorkspaceMode;
+ readonly worktreePath: string | null;
+}): "Current checkout" | "Current worktree" | "New worktree" {
+ if (input.workspaceMode === "worktree") {
+ return "New worktree";
+ }
+ return input.worktreePath ? "Current worktree" : "Current checkout";
+}
+
+export function resolveNewTaskBranchWorktreePath(input: {
+ readonly workspaceMode: WorkspaceMode;
+ readonly projectCwd: string;
+ readonly branchWorktreePath: string | null | undefined;
+}): string | null {
+ if (
+ input.workspaceMode === "worktree" ||
+ !input.branchWorktreePath ||
+ input.branchWorktreePath === input.projectCwd
+ ) {
+ return null;
+ }
+ return input.branchWorktreePath;
+}
+
+export function resolveNewTaskLocalWorkspaceSelection(input: {
+ readonly branches: ReadonlyArray<{
+ readonly name: string;
+ readonly current: boolean;
+ readonly worktreePath?: string | null;
+ }>;
+ readonly projectCwd: string;
+}): {
+ readonly branch: string | null;
+ readonly worktreePath: string | null;
+ readonly awaitsCurrentBranch: boolean;
+} {
+ const currentBranch = input.branches.find((branch) => branch.current) ?? null;
+ if (!currentBranch) {
+ return {
+ branch: null,
+ worktreePath: null,
+ awaitsCurrentBranch: true,
+ };
+ }
+
+ return {
+ branch: currentBranch.name,
+ worktreePath: resolveNewTaskBranchWorktreePath({
+ workspaceMode: "local",
+ projectCwd: input.projectCwd,
+ branchWorktreePath: currentBranch.worktreePath,
+ }),
+ awaitsCurrentBranch: false,
+ };
+}
+
+export function resolveNewTaskBranchLabel(input: {
+ readonly branchName: string | null;
+ readonly startFromOrigin: boolean;
+ readonly workspaceMode: WorkspaceMode;
+}): string {
+ if (!input.branchName) {
+ return "Choose branch";
+ }
+
+ if (input.workspaceMode === "local") {
+ return input.branchName;
+ }
+
+ const baseRef = input.startFromOrigin ? `origin/${input.branchName}` : input.branchName;
+ return `From ${baseRef}`;
+}
+
+export function shouldCheckoutNewTaskBranch(input: {
+ readonly branchIsCurrent: boolean;
+ readonly branchWorktreePath: string | null | undefined;
+ readonly workspaceMode: WorkspaceMode;
+}): boolean {
+ return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath;
+}
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index 7d79e9ecead9..44056ead3225 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -42,6 +42,7 @@ import { useEnvironmentQuery } from "../../state/query";
import {
appendComposerDraftAttachments,
clearComposerDraft,
+ copyComposerDraftContentIfEmpty,
getComposerDraftSnapshot,
isComposerDraftEmpty,
removeComposerDraftAttachment,
@@ -50,7 +51,7 @@ import {
updateComposerDraftSettings,
useComposerDraft,
} from "../../state/use-composer-drafts";
-import { useBranches } from "../../state/queries";
+import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
import {
flattenQueuedThreadMessages,
threadOutboxManager,
@@ -74,10 +75,16 @@ import {
type HomeProjectScope,
} from "../home/homeThreadList";
import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
+import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";
+import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
+import {
+ resolveNewTaskBranchWorktreePath,
+ resolveNewTaskLocalWorkspaceSelection,
+} from "./new-task-context-presentation";
type WorkspaceMode = "local" | "worktree";
-const EMPTY_BRANCH_REFS: ReadonlyArray = [];
+const BRANCH_SEARCH_DEBOUNCE_MS = 150;
function pendingTaskDraftKey(messageId: string): string {
return `pending-task:${messageId}`;
@@ -96,14 +103,6 @@ function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null {
return message?.creation !== undefined ? message : null;
}
-function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null {
- if (!branch.worktreePath) {
- return null;
- }
-
- return branch.worktreePath === project.workspaceRoot ? null : branch.worktreePath;
-}
-
export function branchBadgeLabel(input: {
readonly branch: VcsRef;
readonly project: EnvironmentProject | null;
@@ -117,9 +116,6 @@ export function branchBadgeLabel(input: {
if (input.branch.isDefault) {
return "default";
}
- if (input.branch.isRemote) {
- return "remote";
- }
return null;
}
@@ -139,9 +135,13 @@ type NewTaskFlowContextValue = {
readonly submitting: boolean;
readonly branchQuery: string;
readonly branchesLoading: boolean;
+ readonly branchesError: string | null;
+ readonly branchesFetchingNextPage: boolean;
+ readonly hasMoreBranches: boolean;
readonly availableBranches: ReadonlyArray;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
+ readonly planModeEnabled: boolean;
readonly expandedProvider: string | null;
readonly environments: ReadonlyArray<{
readonly environmentId: EnvironmentId;
@@ -175,7 +175,8 @@ type NewTaskFlowContextValue = {
readonly clearAttachments: () => void;
readonly setSubmitting: (value: boolean) => void;
readonly setBranchQuery: (value: string) => void;
- readonly loadBranches: () => Promise;
+ readonly loadBranches: () => void;
+ readonly loadMoreBranches: () => void;
readonly setRuntimeMode: (value: RuntimeMode) => void;
readonly setInteractionMode: (value: ProviderInteractionMode) => void;
readonly setSelectedModelOptions: (
@@ -191,6 +192,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const threads = useThreadShells();
const { savedConnectionsById } = useSavedRemoteConnections();
const groupingSettings = useMobileProjectGroupingSettings();
+ const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState();
const projectScopes = useMemo(
() =>
sortHomeProjectScopes({
@@ -219,6 +221,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
const [branchQuery, setBranchQuery] = useState("");
const [expandedProvider, setExpandedProvider] = useState(null);
const [editingPendingTask, setEditingPendingTask] = useState(null);
+ const pendingLocalBranchSyncDraftKeysRef = useRef(new Set());
// Mirrors `editingPendingTask` synchronously so the unmount flush cannot act
// on a task whose editing session already ended this render.
const editingPendingTaskRef = useRef(null);
@@ -229,6 +232,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
setSubmitting(false);
setBranchQuery("");
setExpandedProvider(null);
+ pendingLocalBranchSyncDraftKeysRef.current.clear();
const editing = editingPendingTaskRef.current;
editingPendingTaskRef.current = null;
setEditingPendingTask(null);
@@ -395,7 +399,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
true;
const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
- const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
+ const interactionMode = planModeEnabled
+ ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
+ : DEFAULT_PROVIDER_INTERACTION_MODE;
// Stored selections only count while their provider is usable on the
// server; otherwise the server's default model wins instead of silently
@@ -521,18 +527,24 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
}
replaceComposerDraftAttachments(selectedProjectDraftKey, []);
}, [selectedProjectDraftKey]);
+ const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS);
const branchTarget = useMemo(
() => ({
environmentId: selectedProject?.environmentId ?? null,
// `|| null` also skips the stand-in project's empty workspaceRoot.
cwd: selectedProject?.workspaceRoot || null,
- query: null,
+ query: debouncedBranchQuery,
}),
- [selectedProject?.environmentId, selectedProject?.workspaceRoot],
+ [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot],
);
- const branchState = useBranches(branchTarget);
- const branchesLoading = branchState.isPending;
- const allBranchRefs = branchState.data?.refs ?? EMPTY_BRANCH_REFS;
+ const branchState = usePaginatedBranches(branchTarget);
+ const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim();
+ const branchesLoading =
+ branchSearchIsDebouncing || (branchState.isPending && branchState.data === null);
+ const branchesFetchingNextPage = branchState.isFetchingNextPage;
+ const hasMoreBranches =
+ branchState.data?.nextCursor !== null && branchState.data?.nextCursor !== undefined;
+ const allBranchRefs = branchState.refs;
const availableBranches = useMemo(
() =>
pipe(
@@ -554,11 +566,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
);
}, [availableBranches, branchQuery]);
- const setProject = useCallback((project: EnvironmentProject) => {
- const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
- setSelectedEnvironmentId(project.environmentId);
- setSelectedProjectKey(nextProjectKey);
- }, []);
+ const setProject = useCallback(
+ (project: EnvironmentProject) => {
+ const nextProjectKey = scopedProjectKey(project.environmentId, project.id);
+ const nextDraftKey = `new-task:${nextProjectKey}`;
+ if (
+ selectedProjectDraftKey?.startsWith("new-task:") &&
+ selectedProjectDraftKey !== nextDraftKey
+ ) {
+ void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey);
+ }
+ setSelectedEnvironmentId(project.environmentId);
+ setSelectedProjectKey(nextProjectKey);
+ },
+ [selectedProjectDraftKey],
+ );
const selectEnvironment = useCallback(
(environmentId: EnvironmentId) => {
@@ -596,28 +618,86 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (!selectedProjectDraftKey) {
return;
}
+ if (!selectedProject) {
+ return;
+ }
+ const localSelection = resolveNewTaskLocalWorkspaceSelection({
+ branches: availableBranches,
+ projectCwd: selectedProject.workspaceRoot,
+ });
+ if (mode === "local" && localSelection.awaitsCurrentBranch) {
+ pendingLocalBranchSyncDraftKeysRef.current.add(selectedProjectDraftKey);
+ } else {
+ pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
+ }
updateComposerDraftSettings(selectedProjectDraftKey, {
workspaceSelection: {
mode,
- branch: selectedBranchName,
- worktreePath: selectedWorktreePath,
+ branch: mode === "local" ? localSelection.branch : selectedBranchName,
+ worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath,
...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
},
});
},
- [draftStartFromOrigin, selectedBranchName, selectedProjectDraftKey, selectedWorktreePath],
+ [
+ availableBranches,
+ draftStartFromOrigin,
+ selectedBranchName,
+ selectedProject,
+ selectedProjectDraftKey,
+ selectedWorktreePath,
+ ],
);
+ useEffect(() => {
+ if (
+ workspaceMode !== "local" ||
+ !selectedProject ||
+ !selectedProjectDraftKey ||
+ !pendingLocalBranchSyncDraftKeysRef.current.has(selectedProjectDraftKey)
+ ) {
+ return;
+ }
+ const localSelection = resolveNewTaskLocalWorkspaceSelection({
+ branches: availableBranches,
+ projectCwd: selectedProject.workspaceRoot,
+ });
+ if (localSelection.awaitsCurrentBranch) {
+ return;
+ }
+
+ pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
+ updateComposerDraftSettings(selectedProjectDraftKey, {
+ workspaceSelection: {
+ mode: "local",
+ branch: localSelection.branch,
+ worktreePath: localSelection.worktreePath,
+ ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
+ },
+ });
+ }, [
+ availableBranches,
+ draftStartFromOrigin,
+ selectedProject,
+ selectedProjectDraftKey,
+ workspaceMode,
+ ]);
+
const selectBranch = useCallback(
(branch: VcsRef) => {
if (!selectedProject || !selectedProjectDraftKey) {
return;
}
+ pendingLocalBranchSyncDraftKeysRef.current.delete(selectedProjectDraftKey);
updateComposerDraftSettings(selectedProjectDraftKey, {
workspaceSelection: {
mode: workspaceMode,
branch: branch.name,
- worktreePath: normalizeSelectedWorktreePath(selectedProject, branch),
+ worktreePath: resolveNewTaskBranchWorktreePath({
+ workspaceMode,
+ projectCwd: selectedProject.workspaceRoot,
+ branchWorktreePath: branch.worktreePath,
+ }),
...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
},
});
@@ -643,7 +723,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
);
const refreshBranches = branchState.refresh;
- const loadBranches = useCallback(async () => {
+ const loadMoreBranches = branchState.loadNext;
+ const loadBranches = useCallback(() => {
if (!selectedProject) {
return;
}
@@ -767,7 +848,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
attachments: draft.attachments,
modelSelection: draftModelSelection,
runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,
- interactionMode: draft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
+ interactionMode: resolvePendingTaskInteractionMode({
+ preferenceLoaded: planModePreferenceLoaded,
+ planModeEnabled,
+ draftInteractionMode: draft.interactionMode,
+ queuedInteractionMode: editingPendingTask?.interactionMode,
+ }),
creation: {
projectId: selectedProject.id,
...(projectTitle !== undefined ? { projectTitle } : {}),
@@ -792,6 +878,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedModel,
selectedProject,
selectedProjectDraftKey,
+ planModeEnabled,
+ planModePreferenceLoaded,
startFromOrigin,
workspaceMode,
],
@@ -904,9 +992,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
submitting,
branchQuery,
branchesLoading,
+ branchesError: branchState.error,
+ branchesFetchingNextPage,
+ hasMoreBranches,
availableBranches,
runtimeMode,
interactionMode,
+ planModeEnabled,
expandedProvider,
environments,
selectedProject,
@@ -935,6 +1027,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
setSubmitting,
setBranchQuery,
loadBranches,
+ loadMoreBranches,
setRuntimeMode,
setInteractionMode,
setSelectedModelOptions,
@@ -946,6 +1039,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
beginEditingPendingTask,
branchQuery,
branchesLoading,
+ branchState.error,
+ branchesFetchingNextPage,
buildPendingTaskMessage,
cancelEditingPendingTask,
editingPendingTask,
@@ -954,7 +1049,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
filteredBranches,
finishEditingPendingTask,
interactionMode,
+ planModeEnabled,
loadBranches,
+ loadMoreBranches,
projectScopes,
modelOptions,
prompt,
@@ -963,6 +1060,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
reset,
runtimeMode,
selectedBranchName,
+ hasMoreBranches,
selectedEnvironmentId,
selectedModel,
selectedModelKey,
diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts
index d8ed12bcc73a..7068a95d558a 100644
--- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts
+++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts
@@ -5,12 +5,13 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import type { HomeProjectScope } from "../home/homeThreadList";
import {
getOnlySelectableProject,
+ getProjectScopeSelectionTarget,
resolveDraftProjectSelection,
} from "./new-task-project-selection";
-function makeProject(id: string): EnvironmentProject {
+function makeProject(id: string, environmentId = "environment"): EnvironmentProject {
return {
- environmentId: EnvironmentId.make("environment"),
+ environmentId: EnvironmentId.make(environmentId),
id: ProjectId.make(id),
title: id,
workspaceRoot: `/work/${id}`,
@@ -41,9 +42,25 @@ describe("getOnlySelectableProject", () => {
expect(getOnlySelectableProject([makeScope([project])])).toBe(project);
});
- it("does not auto-select a representative when one group has multiple clones", () => {
+ it("selects the representative when one logical project has multiple workspaces", () => {
const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];
- expect(getOnlySelectableProject([makeScope(projects)])).toBeNull();
+ expect(getOnlySelectableProject([makeScope(projects)])).toBe(projects[0]);
+ });
+});
+
+describe("getProjectScopeSelectionTarget", () => {
+ it("keeps the current environment when it hosts the selected logical project", () => {
+ const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")];
+ expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("server"))).toBe(
+ projects[1],
+ );
+ });
+
+ it("falls back to the representative when the current environment does not host the project", () => {
+ const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")];
+ expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("other"))).toBe(
+ projects[0],
+ );
});
});
@@ -63,10 +80,11 @@ describe("resolveDraftProjectSelection", () => {
});
});
- it("opens the picker for multiple physical projects in one logical group", () => {
+ it("selects one logical project even when it has multiple physical workspaces", () => {
const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];
expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({
- kind: "pick",
+ kind: "select",
+ project: projects[0],
});
});
diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts
index 29ae3cf4f54f..7be899d62a1a 100644
--- a/apps/mobile/src/features/threads/new-task-project-selection.ts
+++ b/apps/mobile/src/features/threads/new-task-project-selection.ts
@@ -1,18 +1,29 @@
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
+import type { EnvironmentId } from "@t3tools/contracts";
import { scopedProjectKey } from "../../lib/scopedEntities";
import type { HomeProjectScope } from "../home/homeThreadList";
-export type DraftProjectSelectionResolution =
+type DraftProjectSelectionResolution =
| { readonly kind: "preserve" }
| { readonly kind: "select"; readonly project: EnvironmentProject }
| { readonly kind: "pick" };
+export function getProjectScopeSelectionTarget(
+ scope: HomeProjectScope,
+ preferredEnvironmentId: EnvironmentId | null,
+): EnvironmentProject {
+ return (
+ scope.projects.find((project) => project.environmentId === preferredEnvironmentId) ??
+ scope.representative
+ );
+}
+
export function getOnlySelectableProject(
projectScopes: ReadonlyArray,
): EnvironmentProject | null {
const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null;
- return onlyScope?.projects.length === 1 ? (onlyScope.projects[0] ?? null) : null;
+ return onlyScope?.representative ?? null;
}
export function resolveDraftProjectSelection(
diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts
deleted file mode 100644
index 078be2df11bd..000000000000
--- a/apps/mobile/src/features/threads/thread-settings-menu.test.ts
+++ /dev/null
@@ -1,284 +0,0 @@
-import { describe, expect, it } from "vite-plus/test";
-
-import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts";
-
-import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
-import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu";
-
-function modelOption(
- model: string,
- overrides: Partial> = {},
-): ModelOption {
- const providerKey = overrides.providerKey ?? "codex";
- return {
- key: `${providerKey}:${model}`,
- label: model,
- subtitle: providerKey,
- providerKey,
- providerLabel: providerKey === "codex" ? "Codex" : "Claude",
- providerDriver: providerKey === "codex" ? "codex" : "claudeAgent",
- isDefault: overrides.isDefault ?? false,
- isLegacy: overrides.isLegacy ?? false,
- capabilities: null,
- selection: {
- instanceId: ProviderInstanceId.make(providerKey),
- model,
- options: [],
- },
- };
-}
-
-function group(models: ReadonlyArray): ProviderGroup {
- const first = models[0];
- if (!first) {
- throw new Error("group requires at least one model");
- }
- return {
- providerKey: first.providerKey,
- providerLabel: first.providerLabel,
- models,
- };
-}
-
-const effortDescriptor: ProviderOptionDescriptor = {
- id: "effort",
- label: "Reasoning",
- type: "select",
- options: [
- { id: "low", label: "Low" },
- { id: "medium", label: "Medium", isDefault: true },
- { id: "high", label: "High" },
- { id: "ultrathink", label: "Ultrathink" },
- { id: "ultracode", label: "Ultracode" },
- ],
- currentValue: "high",
- promptInjectedValues: ["ultrathink"],
-};
-
-const fastModeDescriptor: ProviderOptionDescriptor = {
- id: "fastMode",
- label: "Fast mode",
- type: "boolean",
- currentValue: false,
-};
-
-function baseInput() {
- const models = [
- modelOption("gpt-current", { isDefault: true }),
- modelOption("gpt-next"),
- modelOption("gpt-old", { isLegacy: true }),
- ];
- return {
- providerGroups: [group(models)],
- selectedModel: models[0]?.selection ?? null,
- optionDescriptors: [effortDescriptor, fastModeDescriptor],
- runtimeMode: "auto",
- } as const;
-}
-
-function eventFor(menu: ReturnType, id: string | undefined) {
- return id === undefined ? undefined : menu.events.get(id);
-}
-
-describe("buildThreadSettingsMenu", () => {
- it("orders the top level as model, options, runtime", () => {
- const menu = buildThreadSettingsMenu(baseInput());
-
- expect(menu.actions.map((action) => action.title)).toEqual([
- "Model",
- "Reasoning",
- "Fast mode",
- "Runtime",
- ]);
- });
-
- it("summarizes the current choice on each submenu row", () => {
- const menu = buildThreadSettingsMenu(baseInput());
-
- expect(menu.actions.find((action) => action.title === "Model")?.subtitle).toBe("gpt-current");
- expect(menu.actions.find((action) => action.title === "Reasoning")?.subtitle).toBe("High");
- expect(menu.actions.find((action) => action.title === "Runtime")?.subtitle).toBe("Auto");
- });
-
- it("checkmarks the selected model and resolves selection events", () => {
- const menu = buildThreadSettingsMenu(baseInput());
-
- const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
- const current = modelItems.find((action) => action.title === "gpt-current");
- expect(current?.state).toBe("on");
- expect(current?.subtitle).toBe("Default");
- expect(modelItems.find((action) => action.title === "gpt-next")?.state).toBe("off");
-
- const event = eventFor(menu, modelItems.find((action) => action.title === "gpt-next")?.id);
- expect(event?.type).toBe("select-model");
- expect(event?.type === "select-model" ? event.option.selection.model : null).toBe("gpt-next");
- });
-
- it("folds unselected legacy models behind a nested submenu", () => {
- const menu = buildThreadSettingsMenu(baseInput());
-
- const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
- expect(modelItems.map((action) => action.title)).toEqual([
- "gpt-current",
- "gpt-next",
- "Legacy Models",
- ]);
- expect(
- modelItems
- .find((action) => action.title === "Legacy Models")
- ?.subactions?.map((action) => action.title),
- ).toEqual(["gpt-old"]);
- });
-
- it("keeps a selected legacy model in the main list", () => {
- const input = baseInput();
- const legacy = input.providerGroups[0]?.models.find((model) => model.isLegacy);
- const menu = buildThreadSettingsMenu({
- ...input,
- selectedModel: legacy?.selection ?? null,
- });
-
- const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
- expect(modelItems.map((action) => action.title)).toEqual([
- "gpt-current",
- "gpt-next",
- "gpt-old",
- ]);
- expect(modelItems.find((action) => action.title === "gpt-old")?.state).toBe("on");
- });
-
- it("hides prompt-injected and workflow-trigger efforts but still summarizes them", () => {
- const menu = buildThreadSettingsMenu({
- ...baseInput(),
- optionDescriptors: [{ ...effortDescriptor, currentValue: "ultracode" }],
- });
-
- const reasoning = menu.actions.find((action) => action.title === "Reasoning");
- expect(reasoning?.subactions?.map((action) => action.title)).toEqual(["Low", "Medium", "High"]);
- // The hidden value stays visible as the current summary; it just can't be
- // picked from the phone.
- expect(reasoning?.subtitle).toBe("Ultracode");
- expect(reasoning?.subactions?.every((action) => action.state === "off")).toBe(true);
- });
-
- it("resolves select-option and runtime events with checkmarked current values", () => {
- const menu = buildThreadSettingsMenu(baseInput());
-
- const reasoningItems =
- menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? [];
- expect(reasoningItems.find((action) => action.title === "High")?.state).toBe("on");
- expect(eventFor(menu, reasoningItems.find((action) => action.title === "Low")?.id)).toEqual({
- type: "set-option",
- optionId: "effort",
- value: "low",
- });
-
- const runtimeItems =
- menu.actions.find((action) => action.title === "Runtime")?.subactions ?? [];
- expect(runtimeItems.find((action) => action.title === "Auto")?.state).toBe("on");
- expect(
- eventFor(menu, runtimeItems.find((action) => action.title === "Full access")?.id),
- ).toEqual({ type: "set-runtime", mode: "full-access" });
- });
-
- it("toggles boolean options with the inverted current value", () => {
- const menu = buildThreadSettingsMenu(baseInput());
-
- const fastMode = menu.actions.find((action) => action.title === "Fast mode");
- expect(fastMode?.state).toBe("off");
- expect(fastMode?.subactions).toBeUndefined();
- expect(eventFor(menu, fastMode?.id)).toEqual({
- type: "set-option",
- optionId: "fastMode",
- value: true,
- });
-
- const enabled = buildThreadSettingsMenu({
- ...baseInput(),
- optionDescriptors: [{ ...fastModeDescriptor, currentValue: true }],
- });
- const enabledRow = enabled.actions.find((action) => action.title === "Fast mode");
- expect(enabledRow?.state).toBe("on");
- expect(eventFor(enabled, enabledRow?.id)).toEqual({
- type: "set-option",
- optionId: "fastMode",
- value: false,
- });
- });
-
- it("keeps the menu presented only for top-level toggles", () => {
- const menu = buildThreadSettingsMenu(baseInput());
-
- // Root-level boolean toggles refresh in place with clean chrome, so they
- // keep the menu presented.
- expect(
- menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented,
- ).toBe(true);
-
- // Picks inside nested submenus close the menu: staying presented leaves
- // the submenu on screen with an expanded-submenu header, and the
- // bottom-anchored collapse back out drops by the levels' height delta.
- const expected = undefined;
- const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
- const reasoningItems =
- menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? [];
- const runtimeItems =
- menu.actions.find((action) => action.title === "Runtime")?.subactions ?? [];
- const nestedPicks = [...modelItems, ...reasoningItems, ...runtimeItems].filter(
- (action) => action.subactions === undefined,
- );
- expect(nestedPicks.length).toBeGreaterThan(0);
- expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe(
- true,
- );
- });
-
- it("sections models by provider only when multiple groups are offered", () => {
- const codexModels = [modelOption("gpt-current", { isDefault: true })];
- const claudeModels = [modelOption("fable-5", { providerKey: "claude" })];
- const menu = buildThreadSettingsMenu({
- providerGroups: [group(codexModels), group(claudeModels)],
- selectedModel: codexModels[0]?.selection ?? null,
- optionDescriptors: [],
- runtimeMode: "auto",
- });
-
- const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
- expect(
- modelItems.map((action) => ({ title: action.title, inline: action.displayInline ?? false })),
- ).toEqual([
- { title: "Codex", inline: true },
- { title: "Claude", inline: true },
- ]);
- const claudeSection = modelItems.find((action) => action.title === "Claude");
- expect(claudeSection?.subactions?.map((action) => action.title)).toEqual(["fable-5"]);
- });
-
- const eventTypes = (menu: ReturnType) => {
- const types = new Set();
- for (const event of menu.events.values()) {
- types.add(event.type);
- }
- return types;
- };
-
- it("registers an event for every leaf action id", () => {
- const menu = buildThreadSettingsMenu(baseInput());
- const leafIds: string[] = [];
- const collect = (items: ReadonlyArray<{ id?: string; subactions?: unknown[] }>) => {
- for (const item of items) {
- if (Array.isArray(item.subactions) && item.subactions.length > 0) {
- collect(item.subactions as ReadonlyArray<{ id?: string; subactions?: unknown[] }>);
- } else if (item.id !== undefined) {
- leafIds.push(item.id);
- }
- }
- };
- collect(menu.actions);
-
- for (const id of leafIds) {
- expect(menu.events.get(id), `missing event for ${id}`).toBeDefined();
- }
- expect(eventTypes(menu)).toEqual(new Set(["select-model", "set-option", "set-runtime"]));
- });
-});
diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts
deleted file mode 100644
index 31b1c021c46f..000000000000
--- a/apps/mobile/src/features/threads/thread-settings-menu.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-import type { MenuAction } from "@react-native-menu/menu";
-import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts";
-import {
- getProviderOptionCurrentLabel,
- getProviderOptionCurrentValue,
-} from "@t3tools/shared/model";
-
-import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
-
-/**
- * Desktop-oriented effort keywords that don't belong in the phone picker.
- * Prompt-injected values (ultrathink and friends) are filtered from the
- * descriptor metadata; ultracode is a real option but a workflow trigger, not
- * a reasoning level. A value set elsewhere still displays, it just isn't
- * offered.
- */
-export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]);
-
-export const RUNTIME_MODE_CHOICES: ReadonlyArray<{
- readonly mode: RuntimeMode;
- readonly label: string;
- readonly shortLabel: string;
-}> = [
- { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" },
- { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" },
- { mode: "auto", label: "Auto", shortLabel: "Auto" },
- { mode: "full-access", label: "Full access", shortLabel: "Full" },
-];
-
-export function selectableChoices(
- descriptor: Extract,
-) {
- const injected = new Set(descriptor.promptInjectedValues ?? []);
- return descriptor.options.filter(
- (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id),
- );
-}
-
-export type ThreadSettingsMenuEvent =
- | { readonly type: "select-model"; readonly option: ModelOption }
- | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean }
- | { readonly type: "set-runtime"; readonly mode: RuntimeMode };
-
-export type ThreadSettingsMenu = {
- readonly actions: MenuAction[];
- /** Menu action id → the change it applies, for the onPressAction dispatch. */
- readonly events: ReadonlyMap;
-};
-
-/**
- * Native menu replacement for the thread settings sheet (model, select and
- * boolean provider options, runtime mode). The menu presents from the
- * composer pill without resigning the keyboard, so adjusting settings never
- * bounces focus. A thread is bound to one harness, so the menu covers the
- * sheet's full surface for existing threads; the sheet remains the Android
- * and new-task-draft surface.
- *
- * Selections apply immediately — the sheet's stage-then-Save flow only exists
- * because the sheet batches a model change with its option edits.
- */
-export function buildThreadSettingsMenu(input: {
- readonly providerGroups: ReadonlyArray;
- readonly selectedModel: ModelSelection | null;
- readonly optionDescriptors: ReadonlyArray;
- readonly runtimeMode: RuntimeMode;
-}): ThreadSettingsMenu {
- const events = new Map();
- const actions: MenuAction[] = [];
-
- const isSelected = (option: ModelOption) =>
- option.selection.instanceId === input.selectedModel?.instanceId &&
- option.selection.model === input.selectedModel.model;
-
- // Only top-level leaves (boolean toggles) keep the menu presented (iOS
- // 16+): the root refreshes in place with clean chrome. Picks inside nested
- // submenus close the menu — keeping the submenu presented renders an
- // expanded-submenu header with no way to pop back to the root, and the
- // bottom-anchored collapse back out travels the levels' height difference.
- const keepPresented = { keepsMenuPresented: true } as const;
-
- const modelAction = (option: ModelOption, id: string): MenuAction => {
- events.set(id, { type: "select-model", option });
- return {
- id,
- title: option.label,
- ...(option.isDefault ? { subtitle: "Default" } : {}),
- state: isSelected(option) ? "on" : "off",
- };
- };
-
- const modelItems: MenuAction[] = [];
- const legacyItems: MenuAction[] = [];
- let selectedModelLabel: string | undefined;
- input.providerGroups.forEach((group, groupIndex) => {
- const groupItems: MenuAction[] = [];
- group.models.forEach((option, modelIndex) => {
- if (isSelected(option)) {
- selectedModelLabel = option.label;
- }
- const id = `model:${groupIndex}:${modelIndex}`;
- // A highlighted legacy model stays in the main list (mirroring the
- // sheet) so the checkmark isn't hidden behind the Legacy fold.
- if (option.isLegacy && !isSelected(option)) {
- legacyItems.push(modelAction(option, id));
- } else {
- groupItems.push(modelAction(option, id));
- }
- });
- if (groupItems.length === 0) {
- return;
- }
- // A thread is bound to one harness, so provider sections only appear for
- // multi-group callers (the new-task draft, if it ever adopts the menu).
- if (input.providerGroups.length > 1) {
- modelItems.push({
- id: `model-group:${groupIndex}`,
- title: group.providerLabel,
- displayInline: true,
- subactions: groupItems,
- });
- } else {
- modelItems.push(...groupItems);
- }
- });
- if (legacyItems.length > 0) {
- modelItems.push({
- id: "legacy-models",
- title: "Legacy Models",
- subactions: legacyItems,
- });
- }
- if (modelItems.length > 0) {
- actions.push({
- id: "model",
- title: "Model",
- ...(selectedModelLabel === undefined
- ? input.selectedModel
- ? { subtitle: input.selectedModel.model }
- : {}
- : { subtitle: selectedModelLabel }),
- subactions: modelItems,
- });
- }
-
- for (const descriptor of input.optionDescriptors) {
- if (descriptor.type === "boolean") {
- const id = `option:${descriptor.id}`;
- events.set(id, {
- type: "set-option",
- optionId: descriptor.id,
- value: !(descriptor.currentValue ?? false),
- });
- actions.push({
- id,
- title: descriptor.label,
- state: descriptor.currentValue ? "on" : "off",
- attributes: keepPresented,
- });
- continue;
- }
- const currentValue = getProviderOptionCurrentValue(descriptor);
- const choices = selectableChoices(descriptor).map((choice): MenuAction => {
- const id = `option:${descriptor.id}:${choice.id}`;
- events.set(id, { type: "set-option", optionId: descriptor.id, value: choice.id });
- return {
- id,
- title: choice.label,
- state: choice.id === currentValue ? "on" : "off",
- };
- });
- if (choices.length === 0) {
- continue;
- }
- const currentLabel = getProviderOptionCurrentLabel(descriptor);
- actions.push({
- id: `option:${descriptor.id}`,
- title: descriptor.label,
- ...(currentLabel === undefined ? {} : { subtitle: currentLabel }),
- subactions: choices,
- });
- }
-
- const runtimeLabel = RUNTIME_MODE_CHOICES.find(
- (choice) => choice.mode === input.runtimeMode,
- )?.label;
- actions.push({
- id: "runtime",
- title: "Runtime",
- ...(runtimeLabel === undefined ? {} : { subtitle: runtimeLabel }),
- subactions: RUNTIME_MODE_CHOICES.map((choice): MenuAction => {
- const id = `runtime:${choice.mode}`;
- events.set(id, { type: "set-runtime", mode: choice.mode });
- return {
- id,
- title: choice.label,
- state: choice.mode === input.runtimeMode ? "on" : "off",
- };
- }),
- });
-
- return { actions, events };
-}
diff --git a/apps/mobile/src/features/threads/thread-settings-options.test.ts b/apps/mobile/src/features/threads/thread-settings-options.test.ts
new file mode 100644
index 000000000000..041f8b9de010
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-settings-options.test.ts
@@ -0,0 +1,29 @@
+import type { ProviderOptionDescriptor } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { selectableChoices } from "./thread-settings-options";
+
+const effortDescriptor: Extract = {
+ id: "effort",
+ label: "Reasoning",
+ type: "select",
+ options: [
+ { id: "low", label: "Low" },
+ { id: "medium", label: "Medium", isDefault: true },
+ { id: "high", label: "High" },
+ { id: "ultrathink", label: "Ultrathink" },
+ { id: "ultracode", label: "Ultracode" },
+ ],
+ currentValue: "high",
+ promptInjectedValues: ["ultrathink"],
+};
+
+describe("selectableChoices", () => {
+ it("hides prompt-injected and workflow-trigger choices, keeping declared order", () => {
+ expect(selectableChoices(effortDescriptor).map((choice) => choice.id)).toEqual([
+ "low",
+ "medium",
+ "high",
+ ]);
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts
new file mode 100644
index 000000000000..b678154f83bb
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-settings-options.ts
@@ -0,0 +1,46 @@
+import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts";
+
+/**
+ * Desktop-oriented effort keywords that don't belong in the phone picker.
+ * Prompt-injected values (ultrathink and friends) are filtered from the
+ * descriptor metadata; ultracode is a real option but a workflow trigger, not
+ * a reasoning level. A value set elsewhere still displays, it just isn't
+ * offered.
+ */
+const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]);
+
+export const RUNTIME_MODE_CHOICES: ReadonlyArray<{
+ readonly mode: RuntimeMode;
+ readonly label: string;
+ readonly description: string;
+}> = [
+ {
+ mode: "approval-required",
+ label: "Supervised",
+ description: "Ask before commands and file changes.",
+ },
+ {
+ mode: "auto-accept-edits",
+ label: "Auto-accept edits",
+ description: "Auto-approve edits, ask before other actions.",
+ },
+ {
+ mode: "auto",
+ label: "Auto",
+ description: "Supported providers approve routine actions; others still ask.",
+ },
+ {
+ mode: "full-access",
+ label: "Full access",
+ description: "Allow commands and edits without prompts.",
+ },
+];
+
+export function selectableChoices(
+ descriptor: Extract,
+) {
+ const injected = new Set(descriptor.promptInjectedValues ?? []);
+ return descriptor.options.filter(
+ (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id),
+ );
+}
diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts
index 1264c75cd337..2e8fee98572a 100644
--- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts
+++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";
import type { ModelOption } from "../../lib/modelOptions";
-import { pendingModelAfterPress } from "./thread-settings-sheet-state";
+import { modelMatchesCatalogQuery, pendingModelAfterPress } from "./thread-settings-sheet-state";
function modelOption(
model: string,
@@ -28,6 +28,26 @@ function modelOption(
}
describe("thread settings sheet state", () => {
+ it("matches visible model and provider terms", () => {
+ const model = modelOption("gpt-next");
+
+ expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "NEXT" })).toBe(true);
+ expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "codex" })).toBe(true);
+ expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "claude" })).toBe(
+ false,
+ );
+ });
+
+ it("treats whitespace-only catalog searches as empty", () => {
+ expect(
+ modelMatchesCatalogQuery({
+ model: modelOption("gpt-next"),
+ providerLabel: "Codex",
+ query: " ",
+ }),
+ ).toBe(true);
+ });
+
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts
index f0540dc5a971..1e417b925d9e 100644
--- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts
+++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts
@@ -1,5 +1,24 @@
import type { ModelOption } from "../../lib/modelOptions";
+/** Match the terms a user can actually see or recognize in the model picker. */
+export function modelMatchesCatalogQuery(input: {
+ readonly model: ModelOption;
+ readonly providerLabel: string;
+ readonly query: string;
+}): boolean {
+ const query = input.query.trim().toLocaleLowerCase();
+ if (query.length === 0) {
+ return true;
+ }
+
+ return [
+ input.model.label,
+ input.model.subtitle,
+ input.model.selection.model,
+ input.providerLabel,
+ ].some((value) => value.toLocaleLowerCase().includes(query));
+}
+
/** Preserve staged provider options when the highlighted model is tapped again. */
export function pendingModelAfterPress(input: {
readonly current: ModelOption | null;
@@ -11,3 +30,18 @@ export function pendingModelAfterPress(input: {
}
return input.current?.key === input.pressed.key ? input.current : input.pressed;
}
+
+/**
+ * Primary and selected providers start open; all other catalogs start closed.
+ * A user's disclosure tap inverts that default until the picker is dismissed.
+ */
+export function providerSectionIsCollapsed(input: {
+ readonly defaultExpanded: boolean;
+ readonly hasExpansionOverride: boolean;
+ readonly isNarrowed: boolean;
+}): boolean {
+ if (input.isNarrowed) {
+ return false;
+ }
+ return input.defaultExpanded ? input.hasExpansionOverride : !input.hasExpansionOverride;
+}
diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts
new file mode 100644
index 000000000000..25ec4ff0e7d8
--- /dev/null
+++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts
@@ -0,0 +1,26 @@
+import { useAtomValue } from "@effect/atom-react";
+import { AsyncResult } from "effect/unstable/reactivity";
+
+import { mobilePreferencesAtom } from "../../state/preferences";
+import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode";
+
+/**
+ * Mobile preferences are device-local, matching the desktop client setting.
+ * Keep the legacy composer mode hidden until the preference has loaded and is
+ * explicitly enabled.
+ */
+export function useLegacyPlanModeEnabled(): boolean {
+ return useLegacyPlanModeState().enabled;
+}
+
+export function useLegacyPlanModeState(): { readonly enabled: boolean; readonly loaded: boolean } {
+ const preferences = useAtomValue(mobilePreferencesAtom);
+ const loaded = AsyncResult.isSuccess(preferences);
+ return {
+ enabled: resolveLegacyPlanModeEnabled({
+ loaded,
+ preference: loaded ? preferences.value.planModeEnabled : undefined,
+ }),
+ loaded,
+ };
+}
diff --git a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts
index 3cc2ed184684..b5b4914ad11e 100644
--- a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts
+++ b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts
@@ -3,14 +3,46 @@ import { KeyboardController } from "react-native-keyboard-controller";
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
-export type ThreadSettingsSheetCloseReason = "save" | "dismiss";
+type PresentationPhase = "closed" | "opening" | "visible";
-type PresentationPhase = "closed" | "opening" | "visible" | "closing";
+/**
+ * The navigator-level UIKit completion event added by the repo's
+ * `@react-navigation/native-stack` patch; absent from upstream event maps.
+ */
+export type NavigationWithFinishTransitioning = {
+ readonly addListener: (type: "finishTransitioning", callback: () => void) => () => void;
+};
+
+/**
+ * How long after the dismissal's state change the keyboard starts rising, so
+ * its ~250ms show overlaps the tail of the sheet's ~500ms travel the way
+ * UIKit apps choreograph it. This is aesthetics, not correctness: without
+ * keepFocus-style inputView overrides a show started mid-dismissal completes
+ * cleanly, so a slower device merely gets more overlap — no failure mode.
+ * The navigator's `finishTransitioning` event (UIKit's real completion
+ * callback, surfaced by the repo's native-stack patch) additionally bounds
+ * the restore at the true landing moment should this timer ever lag it.
+ */
+const SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS = 300;
+
+/**
+ * A JS-initiated dismissal pops state before its animation runs; a
+ * gesture-driven one animates natively first and pops afterwards, with the
+ * navigator's completion event landing a few dozen milliseconds before the
+ * pop. A completion this fresh at pop time therefore means the sheet is
+ * already gone and the keyboard should return immediately. The two orderings
+ * are separated by the sheet's full ~500ms travel, so this window is a
+ * classification with wide margin, not an animation race.
+ */
+const NATIVE_DISMISSAL_ECHO_WINDOW_MS = 150;
/**
- * Keeps the custom native composer and the settings modal from owning focus at
- * the same time. Opening waits for the keyboard dismissal to finish, while
- * focus restoration waits for the modal's dismissal callback.
+ * Keeps the custom native composer and the settings sheet from owning focus at
+ * the same time. Opening resigns the editor cleanly; a dismissal re-focuses it
+ * once the sheet has fully landed. A plain blur/focus pair costs one keyboard
+ * animation each way — keepFocus-style inputView overrides are avoided because
+ * removing them forces UIKit to reload input views, replaying the keyboard's
+ * show as a visible collapse/re-open.
*/
export function useThreadSettingsSheetPresentation(input: {
readonly editorRef: RefObject;
@@ -19,18 +51,36 @@ export function useThreadSettingsSheetPresentation(input: {
const [phase, setPhase] = useState("closed");
const isActiveRef = useRef(false);
const isMountedRef = useRef(true);
+ const isEditorFocusedRef = useRef(input.isEditorFocused);
const openingIdRef = useRef(0);
- const restoreFocusOnSaveRef = useRef(false);
- const shouldRestoreAfterDismissRef = useRef(false);
+ const focusRestoreIdRef = useRef(0);
+ const restoreFocusAfterDismissRef = useRef(false);
+ const restorePendingRef = useRef(false);
+ const lastStackTransitionFinishedAtRef = useRef(0);
+ const dismissRestoreTimerRef = useRef | null>(null);
+ const clearDismissRestoreTimer = useCallback(() => {
+ if (dismissRestoreTimerRef.current !== null) {
+ clearTimeout(dismissRestoreTimerRef.current);
+ dismissRestoreTimerRef.current = null;
+ }
+ }, []);
- useEffect(
- () => () => {
+ useEffect(() => {
+ isEditorFocusedRef.current = input.isEditorFocused;
+ }, [input.isEditorFocused]);
+
+ useEffect(() => {
+ // React Strict Mode and Fast Refresh both run an effect cleanup/setup
+ // cycle without recreating refs. Re-arm the mounted guard on every setup.
+ isMountedRef.current = true;
+ return () => {
isMountedRef.current = false;
isActiveRef.current = false;
openingIdRef.current += 1;
- },
- [],
- );
+ focusRestoreIdRef.current += 1;
+ clearDismissRestoreTimer();
+ };
+ }, [clearDismissRestoreTimer]);
const open = useCallback(() => {
if (isActiveRef.current) {
@@ -38,61 +88,107 @@ export function useThreadSettingsSheetPresentation(input: {
}
isActiveRef.current = true;
- restoreFocusOnSaveRef.current = input.isEditorFocused || KeyboardController.isVisible();
- shouldRestoreAfterDismissRef.current = false;
+ focusRestoreIdRef.current += 1;
+ clearDismissRestoreTimer();
+ restorePendingRef.current = false;
+ restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible();
setPhase("opening");
const openingId = openingIdRef.current + 1;
openingIdRef.current = openingId;
- // Keyboard.dismiss() only tracks React Native TextInputs. The composer is
- // a custom native text view, so explicitly resign its first responder too.
+ // Start the keyboard transition before the custom native editor resigns
+ // first responder, then present the sheet on the next frame. The sheet and
+ // keyboard animate together instead of serializing two native transitions.
+ void KeyboardController.dismiss({ animated: true });
input.editorRef.current?.blur();
- void KeyboardController.dismiss().then(() => {
+
+ requestAnimationFrame(() => {
if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) {
return;
}
setPhase("visible");
});
- }, [input.editorRef, input.isEditorFocused]);
+ }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]);
+
+ const restoreEditorFocus = useCallback(() => {
+ const focusRestoreId = focusRestoreIdRef.current + 1;
+ focusRestoreIdRef.current = focusRestoreId;
+ let attemptsRemaining = 20;
+
+ // Restoration runs after the dismissal transition, so the first attempt
+ // normally succeeds; the retries are insurance against UIKit briefly
+ // refusing first-responder status right at the transition boundary.
+ const restoreFocus = () => {
+ if (
+ !isMountedRef.current ||
+ focusRestoreIdRef.current !== focusRestoreId ||
+ isEditorFocusedRef.current ||
+ attemptsRemaining <= 0
+ ) {
+ return;
+ }
- const close = useCallback((reason: ThreadSettingsSheetCloseReason) => {
- if (!isActiveRef.current) {
+ attemptsRemaining -= 1;
+ input.editorRef.current?.focus();
+ setTimeout(restoreFocus, 50);
+ };
+ requestAnimationFrame(restoreFocus);
+ }, [input.editorRef]);
+
+ /** Runs the queued restore once — whichever completion signal arrives first. */
+ const runPendingDismissalRestore = useCallback(() => {
+ if (!restorePendingRef.current) {
return;
}
+ restorePendingRef.current = false;
+ clearDismissRestoreTimer();
+ // A reopened sheet owns focus again; drop the stale restore request.
+ if (!isMountedRef.current || isActiveRef.current) {
+ return;
+ }
+ restoreEditorFocus();
+ }, [clearDismissRestoreTimer, restoreEditorFocus]);
- openingIdRef.current += 1;
- shouldRestoreAfterDismissRef.current = reason === "save" && restoreFocusOnSaveRef.current;
- setPhase("closing");
- }, []);
-
+ /**
+ * Marks the sheet closed and queues the keyboard's return for the moment
+ * the dismissal transition actually completes: the sheet slides away over a
+ * resting composer, then the keyboard lifts it in one continuous motion.
+ */
const onDismissed = useCallback(() => {
- const shouldRestoreFocus = shouldRestoreAfterDismissRef.current;
- shouldRestoreAfterDismissRef.current = false;
- restoreFocusOnSaveRef.current = false;
isActiveRef.current = false;
setPhase("closed");
- if (shouldRestoreFocus) {
- input.editorRef.current?.focus();
+ if (!restoreFocusAfterDismissRef.current) {
+ return;
}
- }, [input.editorRef]);
-
- // The new-task screen can have an autofocus queued before the sheet opens.
- // Preserve that intent for Save without allowing it to focus under the modal.
- const restoreFocusAfterSave = useCallback(() => {
- if (isActiveRef.current) {
- restoreFocusOnSaveRef.current = true;
+ restoreFocusAfterDismissRef.current = false;
+ restorePendingRef.current = true;
+ clearDismissRestoreTimer();
+ if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) {
+ // A stack transition finished just before this pop reached JS: the pop
+ // is the state echo of a gesture-driven dismissal whose animation has
+ // already completed. The sheet is gone — bring the keyboard back now.
+ runPendingDismissalRestore();
+ return;
}
- }, []);
+ dismissRestoreTimerRef.current = setTimeout(() => {
+ dismissRestoreTimerRef.current = null;
+ runPendingDismissalRestore();
+ }, SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS);
+ }, [clearDismissRestoreTimer, runPendingDismissalRestore]);
+
+ /** Wire to the navigator's `finishTransitioning` event. */
+ const onStackTransitionsFinished = useCallback(() => {
+ lastStackTransitionFinishedAtRef.current = Date.now();
+ runPendingDismissalRestore();
+ }, [runPendingDismissalRestore]);
return {
isActive: phase !== "closed",
- isActiveRef,
isVisible: phase === "visible",
open,
- close,
onDismissed,
- restoreFocusAfterSave,
+ onStackTransitionsFinished,
} as const;
}
diff --git a/apps/mobile/src/native/native-glass.ts b/apps/mobile/src/native/native-glass.ts
index 40b28076d360..18f221940a9a 100644
--- a/apps/mobile/src/native/native-glass.ts
+++ b/apps/mobile/src/native/native-glass.ts
@@ -1,9 +1,9 @@
-import { isLiquidGlassSupported } from "@callstack/liquid-glass";
+import { isGlassEffectAPIAvailable } from "expo-glass-effect";
import { Platform } from "react-native";
import { supportsNativeLiquidGlass } from "../lib/native-glass-capability";
export const NATIVE_LIQUID_GLASS_SUPPORTED = supportsNativeLiquidGlass(
Platform.OS,
- isLiquidGlassSupported,
+ isGlassEffectAPIAvailable(),
);
diff --git a/apps/mobile/src/native/sheet-surface.ts b/apps/mobile/src/native/sheet-surface.ts
new file mode 100644
index 000000000000..eb2e8a8d1898
--- /dev/null
+++ b/apps/mobile/src/native/sheet-surface.ts
@@ -0,0 +1,28 @@
+import { DynamicColorIOS, Platform, type ColorValue, type ViewStyle } from "react-native";
+
+/**
+ * One opaque surface for content rendered inside a native form sheet.
+ *
+ * UIKit owns the outer sheet material and rounded corners. The presented route
+ * owns this surface so nested navigators never expose a differently colored
+ * native container while their screens move.
+ */
+export const NATIVE_SHEET_SURFACE_COLOR: ColorValue | undefined =
+ Platform.OS === "ios" ? DynamicColorIOS({ light: "#f2f2f7", dark: "#0e0e0e" }) : undefined;
+
+export const NATIVE_SHEET_SURFACE_CONTENT_STYLE: ViewStyle | undefined =
+ NATIVE_SHEET_SURFACE_COLOR === undefined
+ ? undefined
+ : { backgroundColor: NATIVE_SHEET_SURFACE_COLOR };
+
+/**
+ * Paint the adaptive background on the presented screen itself. Nested stacks
+ * can stay transparent over this single surface, so a push never exposes an
+ * unpainted form-sheet host behind the moving child view controllers.
+ */
+export const FORM_SHEET_PRESENTATION_OPTIONS = {
+ presentation: "formSheet" as const,
+ ...(NATIVE_SHEET_SURFACE_CONTENT_STYLE === undefined
+ ? null
+ : { contentStyle: NATIVE_SHEET_SURFACE_CONTENT_STYLE }),
+};
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index b504fb190c6d..1da9c9f7ac6f 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -35,6 +35,8 @@ export interface Preferences {
* default flat list — see `resolveThreadListV2Enabled`.
*/
readonly legacyThreadListEnabled?: boolean;
+ /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */
+ readonly planModeEnabled?: boolean;
}
export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()(
@@ -88,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingMode?: SidebarProjectGroupingMode;
autoSettleOnMerge?: boolean;
legacyThreadListEnabled?: boolean;
+ planModeEnabled?: boolean;
} = {};
if (typeof parsed.liveActivitiesEnabled === "boolean") {
@@ -130,6 +133,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.legacyThreadListEnabled === "boolean") {
preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
}
+ if (typeof parsed.planModeEnabled === "boolean") {
+ preferences.planModeEnabled = parsed.planModeEnabled;
+ }
return preferences;
}
diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts
index b02b190db259..0c0da1f847d5 100644
--- a/apps/mobile/src/state/queries.ts
+++ b/apps/mobile/src/state/queries.ts
@@ -1,14 +1,23 @@
-import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts";
+import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
+import type {
+ EnvironmentId,
+ OrchestrationThread,
+ ThreadId,
+ VcsListRefsResult,
+ VcsRef,
+} from "@t3tools/contracts";
import {
createThreadSearchResultsAtomFamily,
makeThreadSearchKey,
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
import { useAtomValue } from "@effect/atom-react";
+import * as Cause from "effect/Cause";
import * as Option from "effect/Option";
-import { Atom } from "effect/unstable/reactivity";
-import { useEffect, useMemo, useState } from "react";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { appAtomRegistry } from "./atom-registry";
import { orchestrationEnvironment } from "./orchestration";
import { projectEnvironment } from "./projects";
import { useEnvironmentQuery } from "./query";
@@ -24,6 +33,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;
const COMPOSER_PATH_SEARCH_LIMIT = 20;
const THREAD_SEARCH_DEBOUNCE_MS = 200;
const VCS_REF_LIST_LIMIT = 100;
+const EMPTY_REFS: ReadonlyArray = [];
+const INITIAL_BRANCH_CURSORS = [undefined] as const;
const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]);
const EMPTY_THREAD_SEARCH_ATOM = Atom.make({
matches: EMPTY_THREAD_SEARCH_MATCHES,
@@ -52,7 +63,7 @@ export interface ComposerPathSearchTarget {
readonly query: string | null;
}
-function useDebouncedValue(value: A, delayMs: number): A {
+export function useDebouncedValue(value: A, delayMs: number): A {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
@@ -125,6 +136,113 @@ export function useBranches(input: {
);
}
+export function usePaginatedBranches(target: VcsRefTarget) {
+ const query = target.query?.trim() ?? "";
+ const targetKey =
+ target.environmentId !== null && target.cwd !== null
+ ? JSON.stringify([target.environmentId, target.cwd, query])
+ : null;
+ const [pagination, setPagination] = useState<{
+ readonly targetKey: string | null;
+ readonly cursors: ReadonlyArray;
+ }>({
+ targetKey,
+ cursors: INITIAL_BRANCH_CURSORS,
+ });
+ const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS;
+ const pageAtoms = useMemo(
+ () =>
+ target.environmentId !== null && target.cwd !== null
+ ? cursors.map((cursor) =>
+ vcsEnvironment.listRefs({
+ environmentId: target.environmentId!,
+ input: {
+ cwd: target.cwd!,
+ ...(query.length > 0 ? { query } : {}),
+ ...(cursor === undefined ? {} : { cursor }),
+ limit: VCS_REF_LIST_LIMIT,
+ },
+ }),
+ )
+ : [],
+ [cursors, query, target.cwd, target.environmentId],
+ );
+ const pagesAtom = useMemo(
+ () =>
+ Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe(
+ Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`),
+ ),
+ [pageAtoms, targetKey],
+ );
+ const results = useAtomValue(pagesAtom);
+ const values = results.flatMap((result) => {
+ const value = Option.getOrNull(AsyncResult.value(result));
+ return value === null ? [] : [value];
+ });
+ const refs = new Map();
+ for (const value of values) {
+ for (const ref of value.refs) {
+ refs.set(ref.name, ref);
+ }
+ }
+ const first = values[0] ?? null;
+ const last = values.at(-1) ?? null;
+ const data: VcsListRefsResult | null =
+ first === null || last === null
+ ? null
+ : {
+ refs: [...refs.values()],
+ isRepo: first.isRepo,
+ hasPrimaryRemote: first.hasPrimaryRemote,
+ nextCursor: last.nextCursor,
+ totalCount: Math.max(...values.map((value) => value.totalCount)),
+ };
+ const lastResult = results.at(-1);
+ const isFetchingNextPage =
+ results.length > 1 &&
+ lastResult?.waiting === true &&
+ Option.isNone(AsyncResult.value(lastResult));
+ const failed = results.find((result) => result._tag === "Failure");
+ const error =
+ failed?._tag === "Failure"
+ ? (() => {
+ const cause = Cause.squash(failed.cause);
+ return cause instanceof Error && cause.message.trim().length > 0
+ ? cause.message
+ : "Failed to load refs.";
+ })()
+ : null;
+ const refresh = useCallback(() => {
+ const firstPage = pageAtoms[0];
+ setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS });
+ if (firstPage !== undefined) {
+ appAtomRegistry.refresh(firstPage);
+ }
+ }, [pageAtoms, targetKey]);
+ const loadNext = useCallback(() => {
+ if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) {
+ return;
+ }
+ setPagination((current) => {
+ const currentCursors =
+ current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS;
+ return currentCursors.includes(data.nextCursor!)
+ ? { targetKey, cursors: currentCursors }
+ : { targetKey, cursors: [...currentCursors, data.nextCursor!] };
+ });
+ }, [data?.nextCursor, targetKey]);
+
+ return {
+ data,
+ refs: data?.refs ?? EMPTY_REFS,
+ error,
+ isPending: results.some((result) => result.waiting),
+ isFetchingNextPage,
+ refresh,
+ loadNext,
+ };
+}
+
export function useComposerPathSearch(target: ComposerPathSearchTarget) {
const normalizedTarget = useMemo(
() => ({
diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts
index fed97e81e08f..ae03141a8863 100644
--- a/apps/mobile/src/state/use-composer-drafts.test.ts
+++ b/apps/mobile/src/state/use-composer-drafts.test.ts
@@ -1,10 +1,57 @@
import { afterEach, describe, expect, it } from "@effect/vitest";
import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
+import { vi } from "vite-plus/test";
+
+const composerDraftFileMocks = vi.hoisted(() => {
+ let document = "";
+ let releaseRead: (() => void) | null = null;
+ let readBarrier = Promise.resolve();
+
+ return {
+ blockRead() {
+ readBarrier = new Promise((resolve) => {
+ releaseRead = resolve;
+ });
+ },
+ releaseRead() {
+ releaseRead?.();
+ releaseRead = null;
+ },
+ setDocument(value: unknown) {
+ document = JSON.stringify(value);
+ },
+ Directory: class {
+ create() {}
+ },
+ File: class {
+ exists = true;
+
+ create() {}
+
+ async text() {
+ await readBarrier;
+ return document;
+ }
+
+ write(value: string) {
+ document = value;
+ }
+ },
+ };
+});
+
+vi.mock("expo-file-system", () => ({
+ Directory: composerDraftFileMocks.Directory,
+ File: composerDraftFileMocks.File,
+ Paths: { document: "/documents" },
+}));
import { appAtomRegistry } from "./atom-registry";
import {
clearComposerDraftContentState,
composerDraftsAtom,
+ copyComposerDraftContentIfEmpty,
+ copyComposerDraftContentState,
decodePersistedComposerDrafts,
type ComposerDraft,
getComposerDraftSnapshot,
@@ -165,6 +212,53 @@ describe("mobile composer drafts", () => {
expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft);
});
+ it("carries unfinished content to a newly selected project without overwriting its settings", () => {
+ const sourceKey = "new-task:environment-1:project-1";
+ const targetKey = "new-task:environment-1:project-2";
+ const source: ComposerDraft = {
+ text: "Keep this task",
+ attachments: [],
+ importedShareIds: ["share-1"],
+ workspaceSelection: {
+ mode: "worktree",
+ branch: "feature/source",
+ worktreePath: null,
+ },
+ };
+ const target: ComposerDraft = {
+ text: "",
+ attachments: [],
+ runtimeMode: "approval-required",
+ };
+
+ expect(
+ copyComposerDraftContentState(
+ { [sourceKey]: source, [targetKey]: target },
+ sourceKey,
+ targetKey,
+ ),
+ ).toEqual({
+ [sourceKey]: source,
+ [targetKey]: {
+ ...target,
+ text: source.text,
+ attachments: source.attachments,
+ importedShareIds: source.importedShareIds,
+ },
+ });
+ });
+
+ it("does not overwrite unfinished content already stored for the selected project", () => {
+ const sourceKey = "new-task:environment-1:project-1";
+ const targetKey = "new-task:environment-1:project-2";
+ const drafts: Record = {
+ [sourceKey]: { text: "Source task", attachments: [] },
+ [targetKey]: { text: "Target task", attachments: [] },
+ };
+
+ expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts);
+ });
+
it("merges shared content into a project draft without duplicating retries", () => {
const draftKey = "new-task:environment-1:project-1";
const sharedAttachment = {
@@ -268,4 +362,35 @@ describe("mobile composer drafts", () => {
[`new-task:${retainedEnvironmentId}:project-local`]: DRAFT,
});
});
+
+ it("waits for persisted drafts before copying content between projects", async () => {
+ const sourceKey = "new-task:environment-1:project-1";
+ const targetKey = "new-task:environment-1:project-2";
+ const unrelatedKey = "environment-1:thread-1";
+ const source = { text: "Current task", attachments: [] } satisfies ComposerDraft;
+ const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft;
+ const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft;
+
+ composerDraftFileMocks.setDocument({
+ schemaVersion: 1,
+ drafts: {
+ [targetKey]: target,
+ [unrelatedKey]: unrelated,
+ },
+ });
+ composerDraftFileMocks.blockRead();
+ appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source });
+
+ const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey);
+ expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source });
+
+ composerDraftFileMocks.releaseRead();
+ await copy;
+
+ expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
+ [sourceKey]: source,
+ [targetKey]: target,
+ [unrelatedKey]: unrelated,
+ });
+ });
});
diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts
index 24fa547e2728..e9f8cde3cec2 100644
--- a/apps/mobile/src/state/use-composer-drafts.ts
+++ b/apps/mobile/src/state/use-composer-drafts.ts
@@ -253,7 +253,11 @@ export function ensureComposerDraftsLoaded(): void {
function updateComposerDrafts(
update: (current: Record) => Record,
): void {
- const next = update(appAtomRegistry.get(composerDraftsAtom));
+ const current = appAtomRegistry.get(composerDraftsAtom);
+ const next = update(current);
+ if (next === current) {
+ return;
+ }
appAtomRegistry.set(composerDraftsAtom, next);
schedulePersistComposerDrafts(next);
}
@@ -412,6 +416,51 @@ export function restoreComposerDraftSnapshotState(
return next;
}
+export function copyComposerDraftContentState(
+ current: Record,
+ sourceDraftKey: string,
+ targetDraftKey: string,
+): Record {
+ if (sourceDraftKey === targetDraftKey) {
+ return current;
+ }
+ const source = normalizeDraft(current[sourceDraftKey]);
+ const target = normalizeDraft(current[targetDraftKey]);
+ const sourceHasContent =
+ source.text.length > 0 ||
+ source.attachments.length > 0 ||
+ (source.importedShareIds?.length ?? 0) > 0;
+ const targetHasContent =
+ target.text.length > 0 ||
+ target.attachments.length > 0 ||
+ (target.importedShareIds?.length ?? 0) > 0;
+ if (!sourceHasContent || targetHasContent) {
+ return current;
+ }
+ return {
+ ...current,
+ [targetDraftKey]: {
+ ...target,
+ text: source.text,
+ attachments: source.attachments,
+ ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}),
+ },
+ };
+}
+
+export async function copyComposerDraftContentIfEmpty(
+ sourceDraftKey: string,
+ targetDraftKey: string,
+): Promise {
+ ensureComposerDraftsLoaded();
+ if (loadPromise !== null) {
+ await loadPromise;
+ }
+ updateComposerDrafts((current) =>
+ copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey),
+ );
+}
+
function mergeComposerDraftText(existing: string, incoming: string): string {
if (incoming.length === 0) {
return existing;
diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md
index cb69e45b5d7b..0648bafc8b77 100644
--- a/docs/user/permission-modes.md
+++ b/docs/user/permission-modes.md
@@ -44,5 +44,4 @@ with prompting enabled and a restricted workspace while **Full access** disables
labels above describe what you get; the exact per-provider translation is internal and may
change.
-Mobile offers the same four modes. It labels the first one **Approve actions** rather than
-**Supervised**.
+Mobile offers the same four modes with the same labels and descriptions.
diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch
index 1ec4d978529f..e92ae4975631 100644
--- a/patches/@react-navigation%2Fnative-stack@7.17.6.patch
+++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch
@@ -100,3 +100,25 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
-//# sourceMappingURL=useHeaderConfigProps.js.map
\ No newline at end of file
+//# sourceMappingURL=useHeaderConfigProps.js.map
+diff --git a/lib/module/views/NativeStackView.native.js b/lib/module/views/NativeStackView.native.js
+index c342e90..5d3e440 100644
+--- a/lib/module/views/NativeStackView.native.js
++++ b/lib/module/views/NativeStackView.native.js
+@@ -370,6 +370,17 @@ export function NativeStackView({
+ return /*#__PURE__*/_jsx(SafeAreaProviderCompat, {
+ children: /*#__PURE__*/_jsx(ScreenStack, {
+ style: styles.container,
++ onFinishTransitioning: () => {
++ // Surface UIKit's transition-completion callback to every route of
++ // this navigator. Unlike transitionEnd, this also fires when a modal
++ // finishes dismissing — where the presenting screen below receives no
++ // appearance callbacks — and for a gesture-driven dismissal it fires
++ // before the state pop, while the modal route is still the focused
++ // one, so the event must not be targeted at a single route.
++ navigation.emit({
++ type: 'finishTransitioning'
++ });
++ },
+ children: state.routes.concat(state.preloadedRoutes).map((route, index) => {
+ const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key];
+ const isFocused = state.index === index;
diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch
index 605366ff19a7..dc65d13b91bb 100644
--- a/patches/react-native-screens@4.25.2.patch
+++ b/patches/react-native-screens@4.25.2.patch
@@ -226,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
// appearance does not apply to the tvOS so we need to use lagacy customization
#if TARGET_OS_TV
-@@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc
+@@ -637,10 +675,458 @@ + (void)updateViewController:(UIViewController *)vc
// This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).
// See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)
navitem.title = config.title;
@@ -391,40 +391,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
+ ]];
+ [chromeHostView bringSubviewToFront:toolbarHost];
+
-+ void (^configureKeyboardTracking)(UITextField *) = ^(UITextField *textField) {
-+ BOOL isEditing = textField.isFirstResponder;
-+ keyboardAvoidConstraint.priority =
-+ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow;
-+ restingBottomConstraint.priority =
-+ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh;
-+
-+ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint;
-+ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint;
-+ __weak UIView *weakChromeHostView = chromeHostView;
-+ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin";
-+ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end";
-+ [textField removeActionForIdentifier:beginActionIdentifier forControlEvents:UIControlEventEditingDidBegin];
-+ [textField removeActionForIdentifier:endActionIdentifier forControlEvents:UIControlEventEditingDidEnd];
-+ [textField addAction:[UIAction actionWithTitle:@""
-+ image:nil
-+ identifier:beginActionIdentifier
-+ handler:^(__kindof UIAction *_Nonnull action) {
-+ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultLow;
-+ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh;
-+ [weakChromeHostView setNeedsLayout];
-+ }]
-+ forControlEvents:UIControlEventEditingDidBegin];
-+ [textField addAction:[UIAction actionWithTitle:@""
-+ image:nil
-+ identifier:endActionIdentifier
-+ handler:^(__kindof UIAction *_Nonnull action) {
-+ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow;
-+ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultHigh;
-+ [weakChromeHostView setNeedsLayout];
-+ }]
-+ forControlEvents:UIControlEventEditingDidEnd];
-+ };
-+
+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];
+ glassEffect.interactive = YES;
+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];
@@ -438,9 +404,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
+ mailSearchToolbarConfig[@"composeButtonId"] != nil || mailSearchToolbarConfig[@"composeMenu"] != nil;
+ CGFloat glassLeadingInset = hasFilterButton ? sideButtonReserve : 0.0;
+ CGFloat glassTrailingInset = hasComposeButton ? -sideButtonReserve : 0.0;
++ NSLayoutConstraint *glassLeadingConstraint =
++ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset];
++ NSLayoutConstraint *glassTrailingConstraint =
++ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset];
+ [NSLayoutConstraint activateConstraints:@[
-+ [glassView.leadingAnchor constraintEqualToAnchor:toolbarHost.leadingAnchor constant:glassLeadingInset],
-+ [glassView.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor constant:glassTrailingInset],
++ glassLeadingConstraint,
++ glassTrailingConstraint,
+ [glassView.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
+ [glassView.heightAnchor constraintEqualToConstant:toolbarHeight],
+ ]];
@@ -490,6 +460,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
+ UISearchBar *searchBar =
+ !useFallbackSearchField && navitem.searchController != nil ? navitem.searchController.searchBar : nil;
+ NSString *placeholder = mailSearchToolbarConfig[@"placeholder"];
++ UITextField *resolvedSearchTextField = nil;
+ if (searchBar != nil) {
+ if (placeholder != nil) {
+ searchBar.placeholder = placeholder;
@@ -506,7 +477,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;
+ searchBar.searchTextField.textColor = UIColor.labelColor;
+ searchBar.searchTextField.tintColor = UIColor.labelColor;
-+ configureKeyboardTracking(searchBar.searchTextField);
++ resolvedSearchTextField = searchBar.searchTextField;
+ if (placeholder != nil) {
+ searchBar.searchTextField.attributedPlaceholder =
+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
@@ -539,7 +510,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
+ searchField.adjustsFontForContentSizeCategory = YES;
+ searchField.textColor = UIColor.labelColor;
+ searchField.tintColor = UIColor.labelColor;
-+ configureKeyboardTracking(searchField);
++ resolvedSearchTextField = searchField;
+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
+ [glassView.contentView addSubview:searchField];
+ [NSLayoutConstraint activateConstraints:@[
@@ -550,8 +521,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
+ ]];
+ }
+
++ UIButton *filterButton = nil;
+ if (hasFilterButton) {
-+ UIButton *filterButton = makeGlassButton(
++ filterButton = makeGlassButton(
+ mailSearchToolbarConfig[@"filterSystemImageName"] ?: @"line.3.horizontal.decrease",
+ mailSearchToolbarConfig[@"filterButtonId"],
+ mailSearchToolbarConfig[@"filterMenu"]);
@@ -565,8 +537,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
+ ]];
+ }
+
++ UIButton *composeButton = nil;
+ if (hasComposeButton) {
-+ UIButton *composeButton = makeGlassButton(
++ composeButton = makeGlassButton(
+ mailSearchToolbarConfig[@"composeSystemImageName"] ?: @"square.and.pencil",
+ mailSearchToolbarConfig[@"composeButtonId"],
+ mailSearchToolbarConfig[@"composeMenu"]);
@@ -579,6 +552,107 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
+ [composeButton.heightAnchor constraintEqualToConstant:buttonSize],
+ ]];
+ }
++
++ BOOL showsSearchDismissButton =
++ [mailSearchToolbarConfig[@"showsSearchDismissButton"] boolValue] && resolvedSearchTextField != nil;
++ UIButton *searchDismissButton = nil;
++ if (showsSearchDismissButton) {
++ searchDismissButton = makeGlassButton(@"xmark", nil, nil);
++ searchDismissButton.accessibilityLabel = @"Dismiss search keyboard";
++ searchDismissButton.alpha = 0.0;
++ searchDismissButton.hidden = YES;
++ searchDismissButton.translatesAutoresizingMaskIntoConstraints = NO;
++ [toolbarHost addSubview:searchDismissButton];
++ [NSLayoutConstraint activateConstraints:@[
++ [searchDismissButton.trailingAnchor constraintEqualToAnchor:toolbarHost.trailingAnchor],
++ [searchDismissButton.centerYAnchor constraintEqualToAnchor:toolbarHost.centerYAnchor],
++ [searchDismissButton.widthAnchor constraintEqualToConstant:buttonSize],
++ [searchDismissButton.heightAnchor constraintEqualToConstant:buttonSize],
++ ]];
++ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
++ [searchDismissButton
++ addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) {
++ [weakSearchTextField resignFirstResponder];
++ }]
++ forControlEvents:UIControlEventTouchUpInside];
++ }
++
++ __weak UIButton *weakFilterButton = filterButton;
++ __weak UIButton *weakComposeButton = composeButton;
++ __weak UIButton *weakSearchDismissButton = searchDismissButton;
++ __weak NSLayoutConstraint *weakGlassLeadingConstraint = glassLeadingConstraint;
++ __weak NSLayoutConstraint *weakGlassTrailingConstraint = glassTrailingConstraint;
++ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint;
++ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint;
++ __weak UIView *weakToolbarHost = toolbarHost;
++ __weak UITextField *weakSearchTextField = resolvedSearchTextField;
++ void (^setSearchEditingAppearance)(BOOL, BOOL) = ^(BOOL isEditing, BOOL animated) {
++ weakKeyboardAvoidConstraint.priority =
++ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow;
++ weakRestingBottomConstraint.priority =
++ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh;
++
++ if (showsSearchDismissButton) {
++ if (isEditing) {
++ weakSearchDismissButton.hidden = NO;
++ } else {
++ weakFilterButton.hidden = NO;
++ weakComposeButton.hidden = NO;
++ }
++ weakGlassLeadingConstraint.constant = isEditing ? 0.0 : glassLeadingInset;
++ weakGlassTrailingConstraint.constant = isEditing ? -sideButtonReserve : glassTrailingInset;
++
++ void (^changes)(void) = ^{
++ weakFilterButton.alpha = isEditing ? 0.0 : 1.0;
++ weakComposeButton.alpha = isEditing ? 0.0 : 1.0;
++ weakSearchDismissButton.alpha = isEditing ? 1.0 : 0.0;
++ [weakToolbarHost layoutIfNeeded];
++ };
++ void (^completion)(BOOL) = ^(BOOL finished) {
++ if (!finished || weakSearchTextField.isFirstResponder != isEditing) {
++ return;
++ }
++ weakFilterButton.hidden = isEditing;
++ weakComposeButton.hidden = isEditing;
++ weakSearchDismissButton.hidden = !isEditing;
++ };
++ if (animated) {
++ [UIView animateWithDuration:0.2
++ delay:0.0
++ options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseInOut
++ animations:changes
++ completion:completion];
++ } else {
++ changes();
++ completion(YES);
++ }
++ }
++ [weakToolbarHost setNeedsLayout];
++ };
++
++ setSearchEditingAppearance(resolvedSearchTextField.isFirstResponder, NO);
++ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin";
++ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end";
++ [resolvedSearchTextField removeActionForIdentifier:beginActionIdentifier
++ forControlEvents:UIControlEventEditingDidBegin];
++ [resolvedSearchTextField removeActionForIdentifier:endActionIdentifier
++ forControlEvents:UIControlEventEditingDidEnd];
++ [resolvedSearchTextField
++ addAction:[UIAction actionWithTitle:@""
++ image:nil
++ identifier:beginActionIdentifier
++ handler:^(__kindof UIAction *_Nonnull action) {
++ setSearchEditingAppearance(YES, YES);
++ }]
++ forControlEvents:UIControlEventEditingDidBegin];
++ [resolvedSearchTextField
++ addAction:[UIAction actionWithTitle:@""
++ image:nil
++ identifier:endActionIdentifier
++ handler:^(__kindof UIAction *_Nonnull action) {
++ setSearchEditingAppearance(NO, YES);
++ }]
++ forControlEvents:UIControlEventEditingDidEnd];
+ }
+#endif
+ }
@@ -615,7 +689,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
// Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items
// (setting nav bar visibility should be done after `navitem.*BarButtonItems`).
-@@ -773,6 +1185,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
+@@ -773,6 +1259,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
- (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts
withCurrentItems:(NSArray *)currentItems
@@ -623,7 +697,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
{
if (dicts.count == 0) {
return currentItems;
-@@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
+@@ -781,7 +1268,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
[items addObjectsFromArray:currentItems];
for (NSUInteger i = 0; i < dicts.count; i++) {
NSDictionary *dict = dicts[i];
@@ -822,7 +896,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict
action:^(NSString *buttonId) {
auto eventEmitter = std::static_pointer_cast(
-@@ -803,19 +1406,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
+@@ -803,19 +1480,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
}
imageLoader:_imageLoader];
NSNumber *index = dict[@"index"];
@@ -852,7 +926,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
[items insertObject:item atIndex:index.integerValue];
} else {
[items addObject:item];
-@@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
+@@ -825,6 +1506,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
return items;
}
@@ -900,7 +974,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
RNS_IGNORE_SUPER_CALL_BEGIN
- (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex
{
-@@ -1013,6 +1661,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
+@@ -1013,6 +1735,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
}
_title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);
@@ -909,7 +983,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {
_titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);
}
-@@ -1038,6 +1688,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
+@@ -1038,6 +1762,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
_disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
_backButtonDisplayMode =
[RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];
@@ -917,7 +991,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21
if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {
_userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle];
-@@ -1084,6 +1735,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
+@@ -1084,6 +1809,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
_headerRightBarButtonItems = array;
}
@@ -1313,7 +1387,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db
/**
* The tint color to apply to the item.
*
-@@ -1145,8 +1193,37 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
+@@ -1145,8 +1193,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
export interface HeaderBarButtonItemSpacing {
type: 'spacing';
spacing: number;
@@ -1344,6 +1418,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db
+ onSearchTextChange?: ((text: string) => void) | undefined;
+ placeholder?: string | undefined;
+ searchTextChangeId?: string | undefined;
++ showsSearchDismissButton?: boolean | undefined;
+ useFallbackSearchField?: boolean | undefined;
+ width?: number | undefined;
}
@@ -1653,7 +1728,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13
/**
* The tint color to apply to the item.
*
-@@ -1279,11 +1327,46 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
+@@ -1279,11 +1327,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem {
export interface HeaderBarButtonItemSpacing {
type: 'spacing';
spacing: number;
@@ -1687,6 +1762,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13
+ onSearchTextChange?: ((text: string) => void) | undefined;
+ placeholder?: string | undefined;
+ searchTextChangeId?: string | undefined;
++ showsSearchDismissButton?: boolean | undefined;
+ useFallbackSearchField?: boolean | undefined;
+ width?: number | undefined;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9abd0dc45de5..7eab1715c13e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -80,13 +80,13 @@ patchedDependencies:
'@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
'@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045
'@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784
- '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273
+ '@react-navigation/native-stack@7.17.6': 0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027
effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f
react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008
react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
- react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e
+ react-native-screens@4.25.2: 59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199
importers:
@@ -237,7 +237,7 @@ importers:
version: 7.3.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-navigation/native-stack':
specifier: 7.17.6
- version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)
+ version: 7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be)
'@shikijs/core':
specifier: 4.2.0
version: 4.2.0
@@ -402,7 +402,7 @@ importers:
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(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(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)
+ version: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(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)(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)
@@ -12275,7 +12275,7 @@ snapshots:
ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
zod: 3.25.76
optionalDependencies:
- expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8)
+ expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865)
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'
@@ -12351,7 +12351,7 @@ snapshots:
ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
zod: 3.25.76
optionalDependencies:
- expo-router: 56.2.11(db5c693a26481047569df6781f34db9f)
+ expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797)
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.6)(utf-8-validate@6.0.6)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -12691,7 +12691,7 @@ snapshots:
react: 19.2.3
optionalDependencies:
'@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(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.11(c60e26523d4e8ab19ca3d2f562bb6cb8)
+ expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865)
react-dom: 19.2.3(react@19.2.3)
transitivePeerDependencies:
- supports-color
@@ -12706,7 +12706,7 @@ snapshots:
react: 19.2.6
optionalDependencies:
'@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
- expo-router: 56.2.11(db5c693a26481047569df6781f34db9f)
+ expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797)
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- supports-color
@@ -14291,7 +14291,7 @@ snapshots:
optionalDependencies:
'@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)
- '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)':
+ '@react-navigation/native-stack@7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be)':
dependencies:
'@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)
'@react-navigation/native': 7.3.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)
@@ -14299,7 +14299,7 @@ snapshots:
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-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(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(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(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(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)
sf-symbols-typescript: 2.2.0
warn-once: 0.1.1
transitivePeerDependencies:
@@ -17080,47 +17080,47 @@ snapshots:
- supports-color
- typescript
- expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8):
+ expo-router@56.2.11(80beea6a31a5d2003a696c1401258797):
dependencies:
- '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.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)
- '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(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/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
+ '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
'@expo/schema-utils': 56.0.1
- '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)
- '@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)(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/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5)
+ '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6)
+ '@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.6(react@19.2.6))(react@19.2.6)
+ '@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.6)(utf-8-validate@6.0.6))(react@19.2.6)
'@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.12(8895228379997a2a064f9644cda56ed0)
- expo-constants: 56.0.18(expo@56.0.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))
- expo-glass-effect: 56.0.4(expo@56.0.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)
- expo-linking: 56.0.14(expo@56.0.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)
+ expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
+ expo-constants: 56.0.18(expo@56.0.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.6)(utf-8-validate@6.0.6))
+ expo-glass-effect: 56.0.4(expo@56.0.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
+ expo-linking: 56.0.14(expo@56.0.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
expo-server: 56.0.5
- expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.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)
+ expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.12
query-string: 7.1.3
- react: 19.2.3
+ react: 19.2.6
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)(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(05364bd849de538917a7364cc7dee3f5)
- 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(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(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.6)(utf-8-validate@6.0.6)
+ react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)
+ 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.6)(utf-8-validate@6.0.6))(react@19.2.6)
+ react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(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.6)(utf-8-validate@6.0.6))(react@19.2.6)
server-only: 0.0.1
sf-symbols-typescript: 2.2.0
shallowequal: 1.1.0
standard-navigation: 0.0.5
- 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)
+ vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
optionalDependencies:
- react-dom: 19.2.3(react@19.2.3)
- react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(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-dom: 19.2.6(react@19.2.6)
+ react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(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.6)(utf-8-validate@6.0.6))(react@19.2.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)(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.6)(utf-8-validate@6.0.6))(react@19.2.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
- '@testing-library/dom'
@@ -17131,47 +17131,47 @@ snapshots:
- supports-color
optional: true
- expo-router@56.2.11(db5c693a26481047569df6781f34db9f):
+ expo-router@56.2.11(e1497a99e5bc5be76c1cdb733671f865):
dependencies:
- '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
- '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
+ '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.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)
+ '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(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.18(32843e0c0883df8bccfa0b8323659df5)
- '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6)
- '@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.6(react@19.2.6))(react@19.2.6)
- '@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.6)(utf-8-validate@6.0.6))(react@19.2.6)
+ '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)
+ '@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)(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.12(deb8cbf3e0f411b34ba85a995c9982ba)
- expo-constants: 56.0.18(expo@56.0.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.6)(utf-8-validate@6.0.6))
- expo-glass-effect: 56.0.4(expo@56.0.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
- expo-linking: 56.0.14(expo@56.0.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
+ expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
+ expo-constants: 56.0.18(expo@56.0.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))
+ expo-glass-effect: 56.0.4(expo@56.0.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)
+ expo-linking: 56.0.14(expo@56.0.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)
expo-server: 56.0.5
- expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
+ expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.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)
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.12
query-string: 7.1.3
- react: 19.2.6
+ 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)
- react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)
- 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.6)(utf-8-validate@6.0.6))(react@19.2.6)
- react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(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.6)(utf-8-validate@6.0.6))(react@19.2.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-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5)
+ 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(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(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
standard-navigation: 0.0.5
- vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ 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.6(react@19.2.6)
- react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(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.6)(utf-8-validate@6.0.6))(react@19.2.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)(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.6)(utf-8-validate@6.0.6))(react@19.2.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.6)(utf-8-validate@6.0.6))(react@19.2.6)
+ react-dom: 19.2.3(react@19.2.3)
+ react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(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'
@@ -19977,14 +19977,14 @@ snapshots:
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.6)(utf-8-validate@6.0.6)
optional: true
- react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(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(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(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)(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-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(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.6)(utf-8-validate@6.0.6))(react@19.2.6):
+ react-native-screens@4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(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.6)(utf-8-validate@6.0.6))(react@19.2.6):
dependencies:
react: 19.2.6
react-freeze: 1.0.4(react@19.2.6)
From 5304f3e9d4c912bfa0eb2f5f41fa109b3646236b Mon Sep 17 00:00:00 2001
From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com>
Date: Fri, 14 Aug 2026 00:01:09 +0000
Subject: [PATCH 016/144] chore(mobile): bump app version to 1.0.4
Co-authored-by: codex
---
apps/mobile/app.config.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index 3813a10fa51a..9a51725478e1 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -161,7 +161,7 @@ const config: ExpoConfig = {
slug: "t3-code",
platforms: ["ios", "android"],
scheme: variant.scheme,
- version: "1.0.3",
+ version: "1.0.4",
runtimeVersion: {
// Fingerprint (not appVersion) so an OTA only reaches binaries whose native
// project — native deps, config plugins, AND patches/ — matches the update.
From 59be6f78465d73d2d8bba5ddd7741018205dc675 Mon Sep 17 00:00:00 2001
From: Theo Browne
Date: Thu, 13 Aug 2026 21:45:51 -0400
Subject: [PATCH 017/144] fix(web): simplify the desktop-managed server update
banner copy (#6549)
Co-authored-by: Claude Fable 5
---
apps/web/src/versionSkew.test.ts | 2 +-
apps/web/src/versionSkew.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts
index da4ae1e22419..41a148bacd8c 100644
--- a/apps/web/src/versionSkew.test.ts
+++ b/apps/web/src/versionSkew.test.ts
@@ -101,7 +101,7 @@ describe("versionSkew", () => {
"Update the Remote server so they stay in sync.",
);
expect(serverUpdateGuidance("desktop-managed", "Desktop server")).toBe(
- "The Desktop server is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.",
+ "Update the desktop app that runs the Desktop server.",
);
expect(serverUpdateGuidance(null, "Local server")).toBe(
"Relaunch the Local server with the copied command to sync them.",
diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts
index 6cf2a474269d..f56f03ab7ad3 100644
--- a/apps/web/src/versionSkew.ts
+++ b/apps/web/src/versionSkew.ts
@@ -73,7 +73,7 @@ export function serverUpdateGuidance(
case "respawn":
return `Update the ${serverLabel} so they stay in sync.`;
case "desktop-managed":
- return `The ${serverLabel} is run by the T3 Code desktop app on its machine — update the desktop app there to sync them.`;
+ return `Update the desktop app that runs the ${serverLabel}.`;
default:
return `Relaunch the ${serverLabel} with the copied command to sync them.`;
}
From e15f655ba423e4b0e50a5692cc06eb9421bff7df Mon Sep 17 00:00:00 2001
From: David Hu
Date: Thu, 13 Aug 2026 19:01:11 -0700
Subject: [PATCH 018/144] fix(web): show background policy tooltips sooner
(#6506)
---
apps/web/src/components/settings/settingsLayout.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx
index cf532a772125..0e0ae0a042b0 100644
--- a/apps/web/src/components/settings/settingsLayout.tsx
+++ b/apps/web/src/components/settings/settingsLayout.tsx
@@ -88,6 +88,7 @@ export function PolicyTooltip({ children }: { readonly children: string }) {
return (
Date: Fri, 14 Aug 2026 03:02:09 +0100
Subject: [PATCH 019/144] feat(desktop): add favicons to the Browser panel
(#5644)
---
.../src/preview/FaviconCapture.test.ts | 999 ++++++++++++++++++
apps/desktop/src/preview/FaviconCapture.ts | 679 ++++++++++++
apps/desktop/src/preview/Manager.test.ts | 604 +++++++++++
apps/desktop/src/preview/Manager.ts | 225 +++-
.../src/browser/browserTargetResolver.test.ts | 131 +++
apps/web/src/browser/browserTargetResolver.ts | 134 ++-
apps/web/src/browserFaviconLogic.test.ts | 124 +++
apps/web/src/browserFaviconLogic.ts | 201 ++++
apps/web/src/browserFaviconStore.test.ts | 316 ++++++
apps/web/src/browserFaviconStore.ts | 342 ++++++
apps/web/src/components/ChatView.tsx | 15 +-
.../src/components/RightPanelTabs.test.tsx | 115 ++
apps/web/src/components/RightPanelTabs.tsx | 36 +-
.../preview/PreviewEmptyState.test.tsx | 7 +-
.../components/preview/PreviewEmptyState.tsx | 6 +-
.../preview/PreviewFaviconIcon.test.tsx | 51 +
.../components/preview/PreviewFaviconIcon.tsx | 66 ++
.../preview/PreviewLocalServerCard.tsx | 9 +-
.../preview/PreviewRecentUrlCard.tsx | 8 +-
.../src/components/preview/PreviewView.tsx | 1 +
.../preview/usePreviewBridge.test.ts | 48 +
.../components/preview/usePreviewBridge.ts | 66 +-
apps/web/src/lib/favicon.test.ts | 42 +
apps/web/src/lib/favicon.ts | 3 +
apps/web/src/previewStateStore.test.ts | 4 +
apps/web/src/previewStateStore.ts | 2 +
apps/web/src/routes/_chat.pull-requests.tsx | 2 +
packages/contracts/src/ipc.ts | 24 +
28 files changed, 4189 insertions(+), 71 deletions(-)
create mode 100644 apps/desktop/src/preview/FaviconCapture.test.ts
create mode 100644 apps/desktop/src/preview/FaviconCapture.ts
create mode 100644 apps/web/src/browserFaviconLogic.test.ts
create mode 100644 apps/web/src/browserFaviconLogic.ts
create mode 100644 apps/web/src/browserFaviconStore.test.ts
create mode 100644 apps/web/src/browserFaviconStore.ts
create mode 100644 apps/web/src/components/RightPanelTabs.test.tsx
create mode 100644 apps/web/src/components/preview/PreviewFaviconIcon.test.tsx
create mode 100644 apps/web/src/components/preview/PreviewFaviconIcon.tsx
create mode 100644 apps/web/src/components/preview/usePreviewBridge.test.ts
create mode 100644 apps/web/src/lib/favicon.test.ts
diff --git a/apps/desktop/src/preview/FaviconCapture.test.ts b/apps/desktop/src/preview/FaviconCapture.test.ts
new file mode 100644
index 000000000000..a18c839a712a
--- /dev/null
+++ b/apps/desktop/src/preview/FaviconCapture.test.ts
@@ -0,0 +1,999 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import {
+ MAX_FAVICON_CANDIDATES,
+ MAX_FAVICON_RESPONSE_BYTES,
+ captureFavicon,
+ selectFaviconCandidates,
+} from "./FaviconCapture.ts";
+
+const PNG = "data:image/png;base64,cG5n";
+const SOURCE_PNG = Buffer.alloc(24);
+Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG);
+SOURCE_PNG.writeUInt32BE(1, 16);
+SOURCE_PNG.writeUInt32BE(1, 20);
+const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`;
+
+function sourceGif(
+ width: number,
+ height: number,
+ frameWidth = width,
+ frameHeight = height,
+ additionalFrames: ReadonlyArray<{
+ readonly left?: number;
+ readonly top?: number;
+ readonly width: number;
+ readonly height: number;
+ }> = [],
+): Buffer {
+ const frames = [{ width: frameWidth, height: frameHeight }, ...additionalFrames];
+ const buffer = Buffer.alloc(13 + frames.length * 12 + 1);
+ buffer.write("GIF89a", 0, "ascii");
+ buffer.writeUInt16LE(width, 6);
+ buffer.writeUInt16LE(height, 8);
+ let offset = 13;
+ for (const frame of frames) {
+ buffer[offset] = 0x2c;
+ buffer.writeUInt16LE(frame.left ?? 0, offset + 1);
+ buffer.writeUInt16LE(frame.top ?? 0, offset + 3);
+ buffer.writeUInt16LE(frame.width, offset + 5);
+ buffer.writeUInt16LE(frame.height, offset + 7);
+ offset += 10;
+ buffer[offset] = 2;
+ buffer[offset + 1] = 0;
+ offset += 2;
+ }
+ buffer[offset] = 0x3b;
+ return buffer;
+}
+
+function sourceJpeg(
+ width: number,
+ height: number,
+ orientations: number | ReadonlyArray = [],
+): Buffer {
+ const frame = Buffer.from([
+ 0xff,
+ 0xd8,
+ 0xff,
+ 0xc0,
+ 0x00,
+ 0x07,
+ 0x08,
+ height >>> 8,
+ height & 0xff,
+ width >>> 8,
+ width & 0xff,
+ ]);
+ const app1Segments = (typeof orientations === "number" ? [orientations] : orientations).map(
+ (orientation) => sourceJpegExifSegment([orientation]),
+ );
+ return Buffer.concat([frame.subarray(0, 2), ...app1Segments, frame.subarray(2)]);
+}
+
+function sourceJpegApp1Segment(payload: Buffer): Buffer {
+ const app1 = Buffer.alloc(4 + payload.byteLength);
+ app1[0] = 0xff;
+ app1[1] = 0xe1;
+ app1.writeUInt16BE(payload.byteLength + 2, 2);
+ payload.copy(app1, 4);
+ return app1;
+}
+
+function sourceJpegExifSegment(
+ orientations: ReadonlyArray,
+ options?: {
+ readonly byteOrder?: "II" | "MM";
+ readonly magic?: number;
+ readonly padding?: number;
+ },
+): Buffer {
+ const exif = Buffer.alloc(20 + orientations.length * 12);
+ exif.write("Exif\0\0", 0, "binary");
+ exif[5] = options?.padding ?? 0;
+ const littleEndian = options?.byteOrder !== "MM";
+ exif.write(littleEndian ? "II" : "MM", 6, "ascii");
+ const writeUInt16 = (value: number, offset: number) =>
+ littleEndian ? exif.writeUInt16LE(value, offset) : exif.writeUInt16BE(value, offset);
+ const writeUInt32 = (value: number, offset: number) =>
+ littleEndian ? exif.writeUInt32LE(value, offset) : exif.writeUInt32BE(value, offset);
+ writeUInt16(options?.magic ?? 42, 8);
+ writeUInt32(8, 10);
+ writeUInt16(orientations.length, 14);
+ orientations.forEach((orientation, index) => {
+ const entryOffset = 16 + index * 12;
+ writeUInt16(0x0112, entryOffset);
+ writeUInt16(3, entryOffset + 2);
+ writeUInt32(1, entryOffset + 4);
+ writeUInt16(orientation, entryOffset + 8);
+ });
+ return sourceJpegApp1Segment(exif);
+}
+
+function sourceJpegWithApp1Segments(
+ width: number,
+ height: number,
+ segments: ReadonlyArray,
+): Buffer {
+ const frame = sourceJpeg(width, height);
+ return Buffer.concat([frame.subarray(0, 2), ...segments, frame.subarray(2)]);
+}
+
+function sourceJpegWithOrientationEntries(
+ width: number,
+ height: number,
+ orientations: ReadonlyArray,
+): Buffer {
+ return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]);
+}
+
+function sourceJpegWithEndianAlias(alias: number, byteOrder: "II" | "MM"): Buffer {
+ const exif = sourceJpegExifSegment([6], { byteOrder });
+ exif[10] = alias;
+ exif[11] = alias;
+ return sourceJpegWithApp1Segments(64, 32, [exif]);
+}
+
+function sourceJpegExifWithSubIfd(options: {
+ readonly rootOrientation?: number;
+ readonly subIfdFirst?: boolean;
+ readonly subIfdOrientation: number;
+}): Buffer {
+ const rootEntries = options.rootOrientation === undefined ? 1 : 2;
+ const rootIfdOffset = 14;
+ const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
+ const exif = Buffer.alloc(subIfdOffset + 2 + 12 + 4);
+ exif.write("Exif\0\0", 0, "binary");
+ exif.write("II", 6, "ascii");
+ exif.writeUInt16LE(42, 8);
+ exif.writeUInt32LE(8, 10);
+ exif.writeUInt16LE(rootEntries, rootIfdOffset);
+
+ const writeOrientation = (offset: number, orientation: number) => {
+ exif.writeUInt16LE(0x0112, offset);
+ exif.writeUInt16LE(3, offset + 2);
+ exif.writeUInt32LE(1, offset + 4);
+ exif.writeUInt16LE(orientation, offset + 8);
+ };
+ const writeSubIfdPointer = (offset: number) => {
+ exif.writeUInt16LE(0x8769, offset);
+ exif.writeUInt16LE(4, offset + 2);
+ exif.writeUInt32LE(1, offset + 4);
+ exif.writeUInt32LE(subIfdOffset - 6, offset + 8);
+ };
+
+ const firstRootEntryOffset = rootIfdOffset + 2;
+ if (options.rootOrientation === undefined) {
+ writeSubIfdPointer(firstRootEntryOffset);
+ } else if (options.subIfdFirst) {
+ writeSubIfdPointer(firstRootEntryOffset);
+ writeOrientation(firstRootEntryOffset + 12, options.rootOrientation);
+ } else {
+ writeOrientation(firstRootEntryOffset, options.rootOrientation);
+ writeSubIfdPointer(firstRootEntryOffset + 12);
+ }
+
+ exif.writeUInt16LE(1, subIfdOffset);
+ writeOrientation(subIfdOffset + 2, options.subIfdOrientation);
+ return sourceJpegApp1Segment(exif);
+}
+
+function sourceJpegExifWithSubIfdPointers(options: {
+ readonly pointerCount: number;
+ readonly subIfdEntries: number;
+}): Buffer {
+ const { pointerCount, subIfdEntries } = options;
+ const rootIfdOffset = 14;
+ const rootEntries = pointerCount + 1;
+ const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
+ const exif = Buffer.alloc(subIfdOffset + 2 + subIfdEntries * 12 + 4);
+ exif.write("Exif\0\0", 0, "binary");
+ exif.write("II", 6, "ascii");
+ exif.writeUInt16LE(42, 8);
+ exif.writeUInt32LE(8, 10);
+ exif.writeUInt16LE(rootEntries, rootIfdOffset);
+ for (let index = 0; index < pointerCount; index += 1) {
+ const entryOffset = rootIfdOffset + 2 + index * 12;
+ exif.writeUInt16LE(0x8769, entryOffset);
+ exif.writeUInt16LE(4, entryOffset + 2);
+ exif.writeUInt32LE(1, entryOffset + 4);
+ exif.writeUInt32LE(subIfdOffset - 6, entryOffset + 8);
+ }
+ const orientationOffset = rootIfdOffset + 2 + pointerCount * 12;
+ exif.writeUInt16LE(0x0112, orientationOffset);
+ exif.writeUInt16LE(3, orientationOffset + 2);
+ exif.writeUInt32LE(1, orientationOffset + 4);
+ exif.writeUInt16LE(6, orientationOffset + 8);
+
+ exif.writeUInt16LE(subIfdEntries, subIfdOffset);
+ for (let index = 0; index < subIfdEntries; index += 1) {
+ const entryOffset = subIfdOffset + 2 + index * 12;
+ exif.writeUInt16LE(1, entryOffset);
+ exif.writeUInt16LE(3, entryOffset + 2);
+ exif.writeUInt32LE(1, entryOffset + 4);
+ }
+ return sourceJpegApp1Segment(exif);
+}
+
+function sourceJpegExifWithOverlappingSubIfds(pointerCount: number, subIfdEntries: number): Buffer {
+ const rootIfdOffset = 14;
+ const rootEntries = pointerCount + 1;
+ const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
+ const exif = Buffer.alloc(subIfdOffset + pointerCount * 2 + 2 + subIfdEntries * 12);
+ exif.write("Exif\0\0", 0, "binary");
+ exif.write("II", 6, "ascii");
+ exif.writeUInt32LE(8, 10);
+ exif.writeUInt16LE(rootEntries, rootIfdOffset);
+ for (let index = 0; index < pointerCount; index += 1) {
+ const entryOffset = rootIfdOffset + 2 + index * 12;
+ exif.writeUInt16LE(0x8769, entryOffset);
+ exif.writeUInt16LE(4, entryOffset + 2);
+ exif.writeUInt32LE(1, entryOffset + 4);
+ exif.writeUInt32LE(subIfdOffset + index * 2 - 6, entryOffset + 8);
+ exif.writeUInt16LE(subIfdEntries, subIfdOffset + index * 2);
+ }
+ const orientationOffset = rootIfdOffset + 2 + pointerCount * 12;
+ exif.writeUInt16LE(0x0112, orientationOffset);
+ exif.writeUInt16LE(3, orientationOffset + 2);
+ exif.writeUInt32LE(1, orientationOffset + 4);
+ exif.writeUInt16LE(6, orientationOffset + 8);
+ return sourceJpegApp1Segment(exif);
+}
+
+function sourceWebp(width: number, height: number): Buffer {
+ const buffer = Buffer.alloc(30);
+ buffer.write("RIFF", 0, "ascii");
+ buffer.write("WEBP", 8, "ascii");
+ buffer.write("VP8X", 12, "ascii");
+ buffer.writeUIntLE(width - 1, 24, 3);
+ buffer.writeUIntLE(height - 1, 27, 3);
+ return buffer;
+}
+
+function sourceIco(embedded: Buffer): Buffer {
+ const buffer = Buffer.alloc(22 + embedded.byteLength);
+ buffer.writeUInt16LE(1, 2);
+ buffer.writeUInt16LE(1, 4);
+ buffer.writeUInt32LE(embedded.byteLength, 14);
+ buffer.writeUInt32LE(22, 18);
+ embedded.copy(buffer, 22);
+ return buffer;
+}
+
+function makeUnsafePng(): Buffer {
+ const buffer = Buffer.from(SOURCE_PNG);
+ buffer.writeUInt32BE(4096, 16);
+ buffer.writeUInt32BE(4096, 20);
+ return buffer;
+}
+
+function sourcePng(width: number, height: number): Buffer {
+ const buffer = Buffer.from(SOURCE_PNG);
+ buffer.writeUInt32BE(width, 16);
+ buffer.writeUInt32BE(height, 20);
+ return buffer;
+}
+
+function makeUnsafeDib(): Buffer {
+ const buffer = Buffer.alloc(40);
+ buffer.writeUInt32LE(40, 0);
+ buffer.writeInt32LE(4096, 4);
+ buffer.writeInt32LE(4096, 8);
+ return buffer;
+}
+
+function makeWebContents(options?: {
+ readonly fetch?: (url: string, init?: RequestInit) => Promise;
+ readonly rasterize?: (code: string) => Promise;
+}) {
+ const fetch = vi.fn(
+ options?.fetch ??
+ (async () =>
+ new Response(new Uint8Array(SOURCE_PNG), {
+ headers: { "content-type": "image/png" },
+ })),
+ );
+ const executeJavaScriptInIsolatedWorld = vi.fn(
+ async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) =>
+ options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : PNG,
+ );
+ return {
+ webContents: {
+ session: { fetch },
+ executeJavaScriptInIsolatedWorld,
+ } as never,
+ executeJavaScriptInIsolatedWorld,
+ fetch,
+ };
+}
+
+const JPEG_LANDSCAPE_LAYOUT = {
+ draw: "context.drawImage(bitmap, 0, 8, 32, 16)",
+ resizeHeight: 16,
+ resizeWidth: 32,
+} as const;
+const JPEG_PORTRAIT_LAYOUT = {
+ draw: "context.drawImage(bitmap, 8, 0, 16, 32)",
+ resizeHeight: 32,
+ resizeWidth: 16,
+} as const;
+
+async function expectJpegLayout(
+ source: Buffer,
+ layout: typeof JPEG_LANDSCAPE_LAYOUT | typeof JPEG_PORTRAIT_LAYOUT,
+): Promise {
+ const { webContents } = makeWebContents({
+ rasterize: async (code) => {
+ expect(code).toContain(`resizeWidth: ${layout.resizeWidth}`);
+ expect(code).toContain(`resizeHeight: ${layout.resizeHeight}`);
+ expect(code).toContain(layout.draw);
+ return PNG;
+ },
+ });
+
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [`data:image/jpeg;base64,${source.toString("base64")}`],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "captured", dataUrl: PNG });
+}
+
+describe("selectFaviconCandidates", () => {
+ it("filters and deduplicates before applying the candidate cap", () => {
+ const valid = Array.from(
+ { length: MAX_FAVICON_CANDIDATES + 2 },
+ (_, index) => `https://example.com/favicon-${index}.png`,
+ );
+ expect(
+ selectFaviconCandidates([
+ ...Array.from({ length: 64 }, () => "javascript:alert(1)"),
+ valid[0]!,
+ valid[0]!,
+ ...valid.slice(1),
+ ]),
+ ).toEqual(valid.slice(0, MAX_FAVICON_CANDIDATES));
+ });
+
+ it("bounds raw candidate scanning independently of the usable-candidate cap", () => {
+ const oversizedInvalid = `javascript:${"x".repeat(2_048)}`;
+ expect(
+ selectFaviconCandidates([
+ ...Array.from({ length: 128 }, () => oversizedInvalid),
+ "https://example.com/too-late.png",
+ ]),
+ ).toEqual([]);
+ });
+});
+
+describe("captureFavicon", () => {
+ it.each([
+ {
+ label: "same-origin",
+ pageUrl: "https://example.com/page",
+ faviconUrl: "https://example.com/favicon.png",
+ credentials: "include",
+ },
+ {
+ label: "cross-origin",
+ pageUrl: "https://example.com/page",
+ faviconUrl: "https://cdn.example.net/favicon.png",
+ credentials: "omit",
+ },
+ ])("uses the explicit credential policy for $label requests", async (testCase) => {
+ const { webContents, fetch } = makeWebContents();
+ const result = await captureFavicon({
+ webContents,
+ pageUrl: testCase.pageUrl,
+ candidates: [testCase.faviconUrl],
+ signal: new AbortController().signal,
+ });
+
+ expect(result).toEqual({ kind: "captured", dataUrl: PNG });
+ expect(fetch).toHaveBeenCalledWith(
+ testCase.faviconUrl,
+ expect.objectContaining({ credentials: testCase.credentials, redirect: "error" }),
+ );
+ });
+
+ it("decodes base64 and percent-encoded inline images without fetching", async () => {
+ const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents();
+ const percentEncodedPng = [...SOURCE_PNG]
+ .map((byte) => `%${byte.toString(16).padStart(2, "0")}`)
+ .join("");
+
+ for (const candidate of [SOURCE_PNG_URL, `data:image/png,${percentEncodedPng}`]) {
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [candidate],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "captured", dataUrl: PNG });
+ }
+
+ expect(fetch).not.toHaveBeenCalled();
+ expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2);
+ });
+
+ it("tries the next candidate after an ordinary rejection", async () => {
+ const { webContents, fetch } = makeWebContents({
+ fetch: async (url) =>
+ url.endsWith("first.png")
+ ? new Response(null, { status: 404 })
+ : new Response(new Uint8Array(SOURCE_PNG), {
+ headers: { "content-type": "image/png" },
+ }),
+ });
+
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: ["https://example.com/first.png", "https://example.com/second.png"],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "captured", dataUrl: PNG });
+ expect(fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it("cancels a rejected response body before trying the next candidate", async () => {
+ const cancel = vi.fn();
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array(1));
+ },
+ cancel,
+ });
+ const { webContents, fetch } = makeWebContents({
+ fetch: async (url) =>
+ url.endsWith("first.png")
+ ? new Response(body, { status: 404 })
+ : new Response(new Uint8Array(SOURCE_PNG), {
+ headers: { "content-type": "image/png" },
+ }),
+ });
+
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: ["https://example.com/first.png", "https://example.com/second.png"],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "captured", dataUrl: PNG });
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it("stops a pending fetch when its capture is aborted", async () => {
+ const controller = new AbortController();
+ const { webContents } = makeWebContents({
+ fetch: (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
+ once: true,
+ });
+ }),
+ });
+ const capture = captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: ["https://example.com/favicon.png"],
+ signal: controller.signal,
+ });
+ controller.abort();
+ expect(await capture).toEqual({ kind: "none" });
+ });
+
+ it("ends candidate fallback when the overall capture deadline expires", async () => {
+ const timeoutController = new AbortController();
+ const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
+ const { webContents, fetch } = makeWebContents({
+ fetch: (url, init) => {
+ if (url.endsWith("first.png")) return Promise.resolve(new Response(null, { status: 404 }));
+ return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
+ once: true,
+ });
+ });
+ },
+ });
+ try {
+ const capture = captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [
+ "https://example.com/first.png",
+ "https://example.com/second.png",
+ "https://example.com/third.png",
+ ],
+ signal: new AbortController().signal,
+ });
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
+ timeoutController.abort(new DOMException("capture timed out", "TimeoutError"));
+
+ expect(await capture).toEqual({ kind: "timed-out" });
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(timeout).toHaveBeenCalledOnce();
+ } finally {
+ timeout.mockRestore();
+ }
+ });
+
+ it("does not publish a rasterization that completes after the capture deadline", async () => {
+ const captureTimeoutController = new AbortController();
+ const rasterTimeoutController = new AbortController();
+ const timeout = vi
+ .spyOn(AbortSignal, "timeout")
+ .mockImplementation((milliseconds) =>
+ milliseconds === 5_000 ? captureTimeoutController.signal : rasterTimeoutController.signal,
+ );
+ let resolveRasterization!: (value: unknown) => void;
+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
+ rasterize: () =>
+ new Promise((resolve) => {
+ resolveRasterization = resolve;
+ }),
+ });
+ try {
+ const capture = captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [SOURCE_PNG_URL],
+ signal: new AbortController().signal,
+ });
+ await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce());
+ captureTimeoutController.abort(new DOMException("capture timed out", "TimeoutError"));
+
+ expect(await capture).toEqual({ kind: "timed-out" });
+ resolveRasterization(PNG);
+ } finally {
+ timeout.mockRestore();
+ }
+ });
+
+ it("cancels a stalled response body when the capture deadline expires", async () => {
+ const timeoutController = new AbortController();
+ const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
+ const cancel = vi.fn();
+ const { webContents } = makeWebContents({
+ fetch: async () =>
+ new Response(
+ new ReadableStream({
+ cancel,
+ }),
+ { headers: { "content-type": "image/png" } },
+ ),
+ });
+ try {
+ const capture = captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: ["https://example.com/favicon.png"],
+ signal: new AbortController().signal,
+ });
+ timeoutController.abort(new DOMException("capture timed out", "TimeoutError"));
+
+ expect(await capture).toEqual({ kind: "timed-out" });
+ expect(cancel).toHaveBeenCalledOnce();
+ } finally {
+ timeout.mockRestore();
+ }
+ });
+
+ it("rejects and cancels an oversized streamed response", async () => {
+ const cancel = vi.fn();
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array(MAX_FAVICON_RESPONSE_BYTES));
+ controller.enqueue(new Uint8Array(1));
+ },
+ cancel,
+ });
+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
+ fetch: async () => new Response(body, { headers: { "content-type": "image/png" } }),
+ });
+
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: ["https://example.com/favicon.png"],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "none" });
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
+ });
+
+ it("retains bounded compatibility with common favicon formats", async () => {
+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
+ for (const [mime, buffer] of [
+ ["image/gif", sourceGif(32, 32)],
+ ["image/jpeg", sourceJpeg(32, 32)],
+ ["image/webp", sourceWebp(32, 32)],
+ ["image/x-icon", sourceIco(SOURCE_PNG)],
+ ] as const) {
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [`data:${mime};base64,${buffer.toString("base64")}`],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "captured", dataUrl: PNG });
+ }
+ expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(4);
+ });
+
+ it.each([
+ {
+ label: "landscape",
+ source: sourcePng(64, 32),
+ resizeWidth: 32,
+ resizeHeight: 16,
+ draw: "context.drawImage(bitmap, 0, 8, 32, 16)",
+ },
+ {
+ label: "portrait",
+ source: sourcePng(32, 64),
+ resizeWidth: 16,
+ resizeHeight: 32,
+ draw: "context.drawImage(bitmap, 8, 0, 16, 32)",
+ },
+ ])("preserves $label aspect ratio within the 32x32 output", async (testCase) => {
+ const { webContents } = makeWebContents({
+ rasterize: async (code) => {
+ expect(code).toContain(`resizeWidth: ${testCase.resizeWidth}`);
+ expect(code).toContain(`resizeHeight: ${testCase.resizeHeight}`);
+ expect(code).toContain('resizeQuality: "high"');
+ expect(code).toContain(testCase.draw);
+ return PNG;
+ },
+ });
+
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [`data:image/png;base64,${testCase.source.toString("base64")}`],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "captured", dataUrl: PNG });
+ });
+
+ it.each([
+ ...[1, 2, 3, 4].map((orientation) => ({
+ label: `keeps stored dimensions for orientation ${orientation}`,
+ layout: JPEG_LANDSCAPE_LAYOUT,
+ source: sourceJpeg(64, 32, orientation),
+ })),
+ ...[5, 6, 7, 8].map((orientation) => ({
+ label: `uses display dimensions for orientation ${orientation}`,
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpeg(64, 32, orientation),
+ })),
+ {
+ label: "uses the first separate EXIF segment when it is transposed",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpeg(64, 32, [6, 1]),
+ },
+ {
+ label: "uses the first separate EXIF segment when it is untransposed",
+ layout: JPEG_LANDSCAPE_LAYOUT,
+ source: sourceJpeg(64, 32, [1, 6]),
+ },
+ {
+ label: "does not consult a later EXIF segment after an invalid orientation",
+ layout: JPEG_LANDSCAPE_LAYOUT,
+ source: sourceJpeg(64, 32, [9, 6]),
+ },
+ {
+ label: "uses a later valid orientation in the same IFD",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpegWithOrientationEntries(64, 32, [9, 6]),
+ },
+ {
+ label: "skips a non-EXIF APP1 segment",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [
+ sourceJpegApp1Segment(Buffer.from("not-exif")),
+ sourceJpegExifSegment([6]),
+ ]),
+ },
+ {
+ label: "skips an empty EXIF APP1 segment",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [
+ sourceJpegApp1Segment(Buffer.from("Exif\0\0", "binary")),
+ sourceJpegExifSegment([6]),
+ ]),
+ },
+ {
+ label: "stops after a malformed qualifying EXIF APP1 segment",
+ layout: JPEG_LANDSCAPE_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [
+ sourceJpegApp1Segment(Buffer.from("Exif\0\0broken", "binary")),
+ sourceJpegExifSegment([6]),
+ ]),
+ },
+ {
+ label: "ignores the EXIF padding byte",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { padding: 0xff })]),
+ },
+ {
+ label: "reads big-endian EXIF",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { byteOrder: "MM" })]),
+ },
+ {
+ label: "matches Chromium for a nonstandard TIFF magic field",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { magic: 0 })]),
+ },
+ {
+ label: "rejects a high-bit little-endian alias",
+ layout: JPEG_LANDSCAPE_LAYOUT,
+ source: sourceJpegWithEndianAlias(0xc9, "II"),
+ },
+ {
+ label: "rejects a high-bit big-endian alias",
+ layout: JPEG_LANDSCAPE_LAYOUT,
+ source: sourceJpegWithEndianAlias(0xcd, "MM"),
+ },
+ {
+ label: "reads an orientation from a SubIFD",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [
+ sourceJpegExifWithSubIfd({ subIfdOrientation: 6 }),
+ ]),
+ },
+ {
+ label: "uses a SubIFD orientation before a later root orientation",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [
+ sourceJpegExifWithSubIfd({
+ rootOrientation: 1,
+ subIfdFirst: true,
+ subIfdOrientation: 6,
+ }),
+ ]),
+ },
+ {
+ label: "uses a root orientation before a later SubIFD orientation",
+ layout: JPEG_LANDSCAPE_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [
+ sourceJpegExifWithSubIfd({ rootOrientation: 1, subIfdOrientation: 6 }),
+ ]),
+ },
+ {
+ label: "memoizes repeated aliases to the same SubIFD",
+ layout: JPEG_PORTRAIT_LAYOUT,
+ source: sourceJpegWithApp1Segments(64, 32, [
+ sourceJpegExifWithSubIfdPointers({ pointerCount: 32, subIfdEntries: 32 }),
+ ]),
+ },
+ ])("matches Chromium JPEG layout: $label", async ({ source, layout }) => {
+ await expectJpegLayout(source, layout);
+ });
+
+ it("rejects JPEG metadata when distinct SubIFDs exhaust the linear work budget", async () => {
+ const source = sourceJpegWithApp1Segments(64, 32, [
+ sourceJpegExifWithOverlappingSubIfds(32, 32),
+ ]);
+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
+
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [`data:image/jpeg;base64,${source.toString("base64")}`],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "none" });
+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
+ });
+
+ it("rejects JPEGs with multiple frame headers before rasterization", async () => {
+ const buffer = Buffer.concat([sourceJpeg(4096, 4096), sourceJpeg(1, 1).subarray(2)]);
+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
+
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [`data:image/jpeg;base64,${buffer.toString("base64")}`],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "none" });
+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
+ });
+
+ it("rejects an unsafe PNG size before rasterization", async () => {
+ const buffer = makeUnsafePng();
+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
+ fetch: async () =>
+ new Response(new Uint8Array(buffer), {
+ headers: { "content-type": "image/png" },
+ }),
+ });
+
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: ["https://example.com/favicon.png"],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "none" });
+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["GIF", "image/gif", sourceGif(4096, 4096)],
+ ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)],
+ ["GIF later frame", "image/gif", sourceGif(1, 1, 1, 1, [{ width: 4096, height: 4096 }])],
+ [
+ "GIF cumulative frames",
+ "image/gif",
+ sourceGif(
+ 64,
+ 64,
+ 64,
+ 64,
+ Array.from({ length: 256 }, () => ({ width: 64, height: 64 })),
+ ),
+ ],
+ ["JPEG", "image/jpeg", sourceJpeg(4096, 4096)],
+ ["WebP", "image/webp", sourceWebp(4096, 4096)],
+ ["ICO with PNG", "image/x-icon", sourceIco(makeUnsafePng())],
+ ["ICO with DIB", "image/x-icon", sourceIco(makeUnsafeDib())],
+ ["SVG", "image/svg+xml", Buffer.from('')],
+ [
+ "SVG with embedded bitmap",
+ "image/svg+xml",
+ Buffer.from(
+ ``,
+ ),
+ ],
+ [
+ "ICO invalid payload span",
+ "image/x-icon",
+ (() => {
+ const buffer = Buffer.alloc(22);
+ buffer.writeUInt16LE(1, 2);
+ buffer.writeUInt16LE(1, 4);
+ buffer.writeUInt32LE(100, 14);
+ buffer.writeUInt32LE(22, 18);
+ return buffer;
+ })(),
+ ],
+ ])("rejects unsafe or unsupported %s before rasterization", async (_label, mime, buffer) => {
+ const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
+ const candidate = `data:${mime};base64,${buffer.toString("base64")}`;
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [candidate],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "none" });
+ expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
+ });
+
+ it("ignores output that is not a bounded PNG data URL", async () => {
+ const { webContents } = makeWebContents({
+ rasterize: async () => "data:image/svg+xml;base64,c3Zn",
+ });
+
+ expect(
+ await captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [SOURCE_PNG_URL],
+ signal: new AbortController().signal,
+ }),
+ ).toEqual({ kind: "none" });
+ });
+
+ it("waits for physical rasterization settlement after a logical timeout", async () => {
+ vi.useFakeTimers();
+ try {
+ let resolveOld!: (value: unknown) => void;
+ let executions = 0;
+ const { webContents } = makeWebContents({
+ rasterize: () => {
+ executions += 1;
+ return executions === 1
+ ? new Promise((resolve) => {
+ resolveOld = resolve;
+ })
+ : Promise.resolve(PNG);
+ },
+ });
+ const input = {
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [SOURCE_PNG_URL],
+ signal: new AbortController().signal,
+ };
+ const timedOut = captureFavicon(input);
+ await vi.advanceTimersByTimeAsync(1_001);
+ expect(await timedOut).toEqual({ kind: "timed-out" });
+
+ const newer = captureFavicon(input);
+ await Promise.resolve();
+ expect(executions).toBe(1);
+ resolveOld(PNG);
+ expect(await newer).toEqual({ kind: "captured", dataUrl: PNG });
+ expect(executions).toBe(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("ends candidate fallback after a rasterization timeout", async () => {
+ vi.useFakeTimers();
+ try {
+ let resolveRasterization!: (value: unknown) => void;
+ const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents({
+ rasterize: () =>
+ new Promise((resolve) => {
+ resolveRasterization = resolve;
+ }),
+ });
+ const capture = captureFavicon({
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: ["https://example.com/first.png", "https://example.com/second.png"],
+ signal: new AbortController().signal,
+ });
+
+ await vi.advanceTimersByTimeAsync(1_001);
+
+ expect(await capture).toEqual({ kind: "timed-out" });
+ expect(fetch).toHaveBeenCalledOnce();
+ expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce();
+ resolveRasterization(PNG);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("coalesces queued rasterizations so only the latest pending capture launches", async () => {
+ let resolveFirst!: (value: unknown) => void;
+ let executions = 0;
+ const { webContents } = makeWebContents({
+ rasterize: () => {
+ executions += 1;
+ return executions === 1
+ ? new Promise((resolve) => {
+ resolveFirst = resolve;
+ })
+ : Promise.resolve(PNG);
+ },
+ });
+ const input = {
+ webContents,
+ pageUrl: "https://example.com/page",
+ candidates: [SOURCE_PNG_URL],
+ signal: new AbortController().signal,
+ };
+ const first = captureFavicon(input);
+ const superseded = captureFavicon(input);
+ const newest = captureFavicon(input);
+
+ expect(executions).toBe(1);
+ resolveFirst(PNG);
+ expect(await first).toEqual({ kind: "captured", dataUrl: PNG });
+ expect(await superseded).toEqual({ kind: "none" });
+ expect(await newest).toEqual({ kind: "captured", dataUrl: PNG });
+ expect(executions).toBe(2);
+ });
+});
diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts
new file mode 100644
index 000000000000..c7266282268e
--- /dev/null
+++ b/apps/desktop/src/preview/FaviconCapture.ts
@@ -0,0 +1,679 @@
+import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts";
+
+export const MAX_FAVICON_RESPONSE_BYTES = 100_000;
+export const MAX_FAVICON_CANDIDATES = 8;
+export const MAX_FAVICON_HTTP_URL_LENGTH = 2_048;
+
+const MAX_FAVICON_CANDIDATE_INPUT_UNITS = 262_144;
+const MIN_FAVICON_CANDIDATE_INPUT_UNITS = 256;
+const MAX_FAVICON_SOURCE_PIXELS = 1_048_576;
+const MAX_FAVICON_INLINE_URL_LENGTH = Math.ceil((MAX_FAVICON_RESPONSE_BYTES * 4) / 3) + 128;
+const FAVICON_CAPTURE_TIMEOUT_MS = 5_000;
+const FAVICON_RASTER_WORLD_ID = 1001;
+const FAVICON_RASTER_TIMEOUT_MS = 1_000;
+const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+
+interface RasterizationGate {
+ generation: number;
+ launchAllowed?: Promise;
+}
+
+const rasterizationGates = new WeakMap();
+
+async function waitForRasterLaunch(previous: Promise, signal: AbortSignal): Promise {
+ if (signal.aborted) return;
+ await new Promise((resolve) => {
+ const finish = () => {
+ signal.removeEventListener("abort", finish);
+ resolve();
+ };
+ signal.addEventListener("abort", finish, { once: true });
+ void previous.then(finish);
+ });
+}
+
+export type FaviconCaptureResult =
+ | { readonly kind: "captured"; readonly dataUrl: string }
+ | { readonly kind: "none" }
+ | { readonly kind: "timed-out" };
+
+type RasterizationResult =
+ | { readonly kind: "completed"; readonly value: unknown }
+ | { readonly kind: "timed-out" };
+
+export function safeHttpOrigin(url: string): string | null {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
+ } catch {
+ return null;
+ }
+}
+
+export function selectFaviconCandidates(candidates: ReadonlyArray): ReadonlyArray {
+ const selected: string[] = [];
+ const seen = new Set();
+ let inputUnits = 0;
+ for (const candidate of candidates) {
+ // Charge a minimum per entry so a large array of tiny malformed values is bounded too.
+ inputUnits += Math.max(MIN_FAVICON_CANDIDATE_INPUT_UNITS, candidate.length);
+ if (inputUnits > MAX_FAVICON_CANDIDATE_INPUT_UNITS) break;
+ if (!isSupportedFaviconUrl(candidate) || seen.has(candidate)) continue;
+ seen.add(candidate);
+ selected.push(candidate);
+ if (selected.length === MAX_FAVICON_CANDIDATES) break;
+ }
+ return selected;
+}
+
+export async function captureFavicon(input: {
+ readonly webContents: Electron.WebContents;
+ readonly pageUrl: string;
+ readonly candidates: ReadonlyArray;
+ readonly signal: AbortSignal;
+}): Promise {
+ const pageOrigin = safeHttpOrigin(input.pageUrl);
+ if (!pageOrigin) return { kind: "none" };
+ const captureTimeout = AbortSignal.timeout(FAVICON_CAPTURE_TIMEOUT_MS);
+ const captureSignal = AbortSignal.any([input.signal, captureTimeout]);
+
+ for (const candidate of selectFaviconCandidates(input.candidates)) {
+ if (captureSignal.aborted) {
+ return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" };
+ }
+ const captured = await captureCandidate({
+ webContents: input.webContents,
+ pageOrigin,
+ candidate,
+ signal: captureSignal,
+ });
+ if (captureSignal.aborted) {
+ return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" };
+ }
+ if (captured.kind === "captured" || captured.kind === "timed-out") return captured;
+ }
+
+ return { kind: "none" };
+}
+
+async function captureCandidate(input: {
+ readonly webContents: Electron.WebContents;
+ readonly pageOrigin: string;
+ readonly candidate: string;
+ readonly signal: AbortSignal;
+}): Promise {
+ try {
+ const inline = parseInlineFavicon(input.candidate);
+ if (inline) {
+ return await normalizeFaviconBuffer(
+ input.webContents,
+ inline.mime,
+ inline.buffer,
+ input.signal,
+ );
+ }
+
+ const candidateOrigin = safeHttpOrigin(input.candidate);
+ if (!candidateOrigin) return { kind: "none" };
+ const response = await input.webContents.session.fetch(input.candidate, {
+ credentials: candidateOrigin === input.pageOrigin ? "include" : "omit",
+ redirect: "error",
+ signal: input.signal,
+ });
+ if (!response.ok) {
+ await response.body?.cancel();
+ return { kind: "none" };
+ }
+ const buffer = await readFaviconResponse(response, input.signal);
+ if (!buffer || input.signal.aborted) return { kind: "none" };
+ const mime = response.headers.get("content-type")?.split(";", 1)[0] ?? null;
+ return await normalizeFaviconBuffer(input.webContents, mime, buffer, input.signal);
+ } catch {
+ return { kind: "none" };
+ }
+}
+
+async function readFaviconResponse(
+ response: Response,
+ signal: AbortSignal,
+): Promise {
+ const contentLength = Number(response.headers.get("content-length"));
+ if (Number.isFinite(contentLength) && contentLength > MAX_FAVICON_RESPONSE_BYTES) {
+ await response.body?.cancel();
+ return null;
+ }
+ if (!response.body) {
+ const buffer = Buffer.from(await response.arrayBuffer());
+ return buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES ? buffer : null;
+ }
+
+ const reader = response.body.getReader();
+ const cancelForAbort = () => {
+ void reader.cancel(signal.reason).catch(() => undefined);
+ };
+ signal.addEventListener("abort", cancelForAbort, { once: true });
+ if (signal.aborted) cancelForAbort();
+ const chunks: Buffer[] = [];
+ let byteLength = 0;
+ try {
+ while (true) {
+ const next = await reader.read();
+ if (next.done) return Buffer.concat(chunks, byteLength);
+ byteLength += next.value.byteLength;
+ if (byteLength > MAX_FAVICON_RESPONSE_BYTES) {
+ await reader.cancel();
+ return null;
+ }
+ chunks.push(Buffer.from(next.value));
+ }
+ } finally {
+ signal.removeEventListener("abort", cancelForAbort);
+ reader.releaseLock();
+ }
+}
+
+function isSupportedFaviconUrl(url: string): boolean {
+ if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return false;
+ if (/^data:/i.test(url)) return /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,/i.test(url);
+ try {
+ const protocol = new URL(url).protocol;
+ return (
+ (protocol === "http:" || protocol === "https:") && url.length <= MAX_FAVICON_HTTP_URL_LENGTH
+ );
+ } catch {
+ return false;
+ }
+}
+
+function decodeInlineFaviconPayload(payload: string): Buffer | null {
+ const decoded = Buffer.allocUnsafe(Buffer.byteLength(payload));
+ let inputOffset = 0;
+ let outputOffset = 0;
+ while (inputOffset < payload.length) {
+ const escapeOffset = payload.indexOf("%", inputOffset);
+ const literalEnd = escapeOffset === -1 ? payload.length : escapeOffset;
+ outputOffset += decoded.write(payload.slice(inputOffset, literalEnd), outputOffset, "utf8");
+ if (escapeOffset === -1) break;
+ const hex = payload.slice(escapeOffset + 1, escapeOffset + 3);
+ if (!/^[0-9a-f]{2}$/i.test(hex)) return null;
+ decoded[outputOffset] = Number.parseInt(hex, 16);
+ outputOffset += 1;
+ inputOffset = escapeOffset + 3;
+ }
+ return decoded.subarray(0, outputOffset);
+}
+
+function parseInlineFavicon(
+ url: string,
+): { readonly buffer: Buffer; readonly mime: string } | null {
+ if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return null;
+ const match = /^data:(image\/[a-z0-9.+-]+)((?:;[^,]*)?),(.*)$/is.exec(url);
+ if (!match) return null;
+ const mime = match[1]?.toLowerCase();
+ const parameters = match[2]
+ ?.split(";")
+ .filter(Boolean)
+ .map((parameter) => parameter.toLowerCase());
+ const payload = match[3];
+ if (!mime || !parameters || !payload) return null;
+ const base64 = parameters.at(-1) === "base64";
+ if (parameters.includes("base64") && !base64) return null;
+
+ let buffer: Buffer;
+ try {
+ if (base64) {
+ if (!/^[a-z0-9+/]*={0,2}$/i.test(payload) || payload.length % 4 === 1) return null;
+ buffer = Buffer.from(payload, "base64");
+ if (buffer.toString("base64").replace(/=+$/, "") !== payload.replace(/=+$/, "")) {
+ return null;
+ }
+ } else {
+ const decoded = decodeInlineFaviconPayload(payload);
+ if (!decoded) return null;
+ buffer = decoded;
+ }
+ } catch {
+ return null;
+ }
+
+ return buffer.byteLength > 0 && buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES
+ ? { buffer, mime }
+ : null;
+}
+
+interface ImageDimensions {
+ readonly width: number;
+ readonly height: number;
+}
+
+function safeDimensions(dimensions: ImageDimensions | null): dimensions is ImageDimensions {
+ return (
+ dimensions !== null &&
+ Number.isSafeInteger(dimensions.width) &&
+ Number.isSafeInteger(dimensions.height) &&
+ dimensions.width > 0 &&
+ dimensions.height > 0 &&
+ dimensions.width * dimensions.height <= MAX_FAVICON_SOURCE_PIXELS
+ );
+}
+
+function pngDimensions(buffer: Buffer): ImageDimensions | null {
+ if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || buffer.byteLength < 24) {
+ return null;
+ }
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
+}
+
+function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null {
+ let offset = startOffset;
+ while (offset < buffer.byteLength) {
+ const blockLength = buffer[offset]!;
+ offset += 1;
+ if (blockLength === 0) return offset;
+ if (offset + blockLength > buffer.byteLength) return null;
+ offset += blockLength;
+ }
+ return null;
+}
+
+function gifDimensions(buffer: Buffer): ImageDimensions | null {
+ if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) {
+ return null;
+ }
+ const logicalWidth = buffer.readUInt16LE(6);
+ const logicalHeight = buffer.readUInt16LE(8);
+ if (!safeDimensions({ width: logicalWidth, height: logicalHeight })) return null;
+ const packed = buffer[10]!;
+ let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1));
+ if (offset > buffer.byteLength) return null;
+ let width = logicalWidth;
+ let height = logicalHeight;
+ let frameCount = 0;
+ let framePixels = 0;
+ while (offset < buffer.byteLength) {
+ const marker = buffer[offset];
+ if (marker === 0x3b) return frameCount > 0 ? { width, height } : null;
+ if (marker === 0x2c) {
+ if (offset + 10 > buffer.byteLength) return null;
+ const left = buffer.readUInt16LE(offset + 1);
+ const top = buffer.readUInt16LE(offset + 3);
+ const frameWidth = buffer.readUInt16LE(offset + 5);
+ const frameHeight = buffer.readUInt16LE(offset + 7);
+ if (frameWidth === 0 || frameHeight === 0) return null;
+ framePixels += frameWidth * frameHeight;
+ if (framePixels > MAX_FAVICON_SOURCE_PIXELS) return null;
+ width = Math.max(width, left + frameWidth);
+ height = Math.max(height, top + frameHeight);
+ if (!safeDimensions({ width, height })) return null;
+ const framePacked = buffer[offset + 9]!;
+ offset += 10;
+ if ((framePacked & 0x80) !== 0) {
+ offset += 3 * 2 ** ((framePacked & 0x07) + 1);
+ }
+ if (offset >= buffer.byteLength) return null;
+ const minimumCodeSize = buffer[offset]!;
+ if (minimumCodeSize < 2 || minimumCodeSize > 8) return null;
+ offset += 1;
+ const nextOffset = skipGifSubBlocks(buffer, offset);
+ if (nextOffset === null) return null;
+ offset = nextOffset;
+ frameCount += 1;
+ continue;
+ }
+ if (marker !== 0x21 || offset + 2 > buffer.byteLength) return null;
+ const nextOffset = skipGifSubBlocks(buffer, offset + 2);
+ if (nextOffset === null) return null;
+ offset = nextOffset;
+ }
+ return null;
+}
+
+interface JpegExifMetadata {
+ readonly complete: boolean;
+ readonly orientation: number | null;
+}
+
+function jpegExifMetadata(segment: Buffer): JpegExifMetadata | null {
+ if (segment.byteLength <= 6 || segment.subarray(0, 5).toString("binary") !== "Exif\0") {
+ return null;
+ }
+ const metadataWithoutOrientation = (): JpegExifMetadata => ({
+ complete: true,
+ orientation: null,
+ });
+ if (segment.byteLength < 14) return metadataWithoutOrientation();
+ const tiffOffset = 6;
+ const littleEndian = segment[tiffOffset] === 0x49 && segment[tiffOffset + 1] === 0x49;
+ const bigEndian = segment[tiffOffset] === 0x4d && segment[tiffOffset + 1] === 0x4d;
+ if (!littleEndian && !bigEndian) return metadataWithoutOrientation();
+ const readUInt16 = (offset: number): number | null => {
+ if (offset < 0 || offset + 2 > segment.byteLength) return null;
+ return littleEndian ? segment.readUInt16LE(offset) : segment.readUInt16BE(offset);
+ };
+ const readUInt32 = (offset: number): number | null => {
+ if (offset < 0 || offset + 4 > segment.byteLength) return null;
+ return littleEndian ? segment.readUInt32LE(offset) : segment.readUInt32BE(offset);
+ };
+ const relativeIfdOffset = readUInt32(tiffOffset + 4);
+ if (relativeIfdOffset === null) return metadataWithoutOrientation();
+ // Keep untrusted metadata parsing linear even when IFD pointers overlap.
+ let remainingIfdEntryVisits = Math.ceil(segment.byteLength / 12);
+ const budgetExhausted = Symbol("ifd-entry-budget-exhausted");
+ type IfdOrientation = number | null | typeof budgetExhausted;
+ const subIfdOrientationByOffset = new Map();
+ const readIfdOrientation = (ifdOffset: number, isRoot: boolean): IfdOrientation => {
+ if (!isRoot && subIfdOrientationByOffset.has(ifdOffset)) {
+ return subIfdOrientationByOffset.get(ifdOffset) ?? null;
+ }
+ const entryCount = readUInt16(ifdOffset);
+ if (entryCount === null) return null;
+ let result: IfdOrientation = null;
+ for (let index = 0; index < entryCount; index += 1) {
+ if (remainingIfdEntryVisits === 0) return budgetExhausted;
+ remainingIfdEntryVisits -= 1;
+ const entryOffset = ifdOffset + 2 + index * 12;
+ if (entryOffset + 12 > segment.byteLength) break;
+ const tag = readUInt16(entryOffset);
+ const type = readUInt16(entryOffset + 2);
+ const count = readUInt32(entryOffset + 4);
+ if (tag === 0x0112 && type === 3 && count === 1) {
+ const orientation = readUInt16(entryOffset + 8);
+ if (orientation !== null && orientation >= 1 && orientation <= 8) {
+ result = orientation;
+ break;
+ }
+ } else if (isRoot && tag === 0x8769 && type === 4 && count === 1) {
+ const relativeSubIfdOffset = readUInt32(entryOffset + 8);
+ if (relativeSubIfdOffset !== null) {
+ const orientation = readIfdOrientation(tiffOffset + relativeSubIfdOffset, false);
+ if (orientation === budgetExhausted) return budgetExhausted;
+ if (orientation !== null) {
+ result = orientation;
+ break;
+ }
+ }
+ }
+ }
+ if (!isRoot) subIfdOrientationByOffset.set(ifdOffset, result);
+ return result;
+ };
+ const orientation = readIfdOrientation(tiffOffset + relativeIfdOffset, true);
+ return orientation === budgetExhausted
+ ? { complete: false, orientation: null }
+ : { complete: true, orientation };
+}
+
+function jpegDimensions(buffer: Buffer): ImageDimensions | null {
+ if (buffer.byteLength < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
+ const startOfFrameMarkers = new Set([
+ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
+ ]);
+ let offset = 2;
+ let dimensions: ImageDimensions | null = null;
+ let exifMetadata: JpegExifMetadata | null = null;
+ while (offset + 3 < buffer.byteLength) {
+ if (buffer[offset] !== 0xff) {
+ offset += 1;
+ continue;
+ }
+ while (buffer[offset] === 0xff) offset += 1;
+ const marker = buffer[offset];
+ offset += 1;
+ if (marker === undefined || marker === 0xd9 || marker === 0xda) break;
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
+ if (offset + 1 >= buffer.byteLength) return null;
+ const length = buffer.readUInt16BE(offset);
+ if (length < 2 || offset + length > buffer.byteLength) return null;
+ if (marker === 0xe1 && exifMetadata === null) {
+ exifMetadata = jpegExifMetadata(buffer.subarray(offset + 2, offset + length));
+ }
+ if (startOfFrameMarkers.has(marker)) {
+ if (length < 7) return null;
+ if (dimensions !== null) return null;
+ dimensions = {
+ height: buffer.readUInt16BE(offset + 3),
+ width: buffer.readUInt16BE(offset + 5),
+ };
+ }
+ offset += length;
+ }
+ if (!dimensions) return null;
+ if (exifMetadata?.complete === false) return null;
+ const orientation = exifMetadata?.orientation;
+ return orientation !== undefined && orientation !== null && orientation >= 5 && orientation <= 8
+ ? { width: dimensions.height, height: dimensions.width }
+ : dimensions;
+}
+
+function webpDimensions(buffer: Buffer): ImageDimensions | null {
+ if (
+ buffer.byteLength < 30 ||
+ buffer.subarray(0, 4).toString("ascii") !== "RIFF" ||
+ buffer.subarray(8, 12).toString("ascii") !== "WEBP"
+ ) {
+ return null;
+ }
+ const kind = buffer.subarray(12, 16).toString("ascii");
+ if (kind === "VP8X") {
+ return {
+ width: 1 + buffer.readUIntLE(24, 3),
+ height: 1 + buffer.readUIntLE(27, 3),
+ };
+ }
+ if (kind === "VP8 " && buffer.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) {
+ return {
+ width: buffer.readUInt16LE(26) & 0x3fff,
+ height: buffer.readUInt16LE(28) & 0x3fff,
+ };
+ }
+ if (kind === "VP8L" && buffer[20] === 0x2f) {
+ return {
+ width: 1 + buffer[21]! + ((buffer[22]! & 0x3f) << 8),
+ height: 1 + (buffer[22]! >> 6) + (buffer[23]! << 2) + ((buffer[24]! & 0x0f) << 10),
+ };
+ }
+ return null;
+}
+
+function dibDimensions(buffer: Buffer): ImageDimensions | null {
+ if (buffer.byteLength < 12) return null;
+ const headerSize = buffer.readUInt32LE(0);
+ if (headerSize === 12) {
+ return {
+ width: buffer.readUInt16LE(4),
+ height: buffer.readUInt16LE(6),
+ };
+ }
+ if (headerSize < 40 || buffer.byteLength < 12) return null;
+ return {
+ width: Math.abs(buffer.readInt32LE(4)),
+ height: Math.abs(buffer.readInt32LE(8)),
+ };
+}
+
+function icoDimensions(buffer: Buffer): ImageDimensions | null {
+ if (
+ buffer.byteLength < 22 ||
+ buffer.readUInt16LE(0) !== 0 ||
+ (buffer.readUInt16LE(2) !== 1 && buffer.readUInt16LE(2) !== 2)
+ ) {
+ return null;
+ }
+ const count = buffer.readUInt16LE(4);
+ if (count === 0 || count > 256 || buffer.byteLength < 6 + count * 16) return null;
+ let width = 0;
+ let height = 0;
+ for (let index = 0; index < count; index += 1) {
+ const offset = 6 + index * 16;
+ width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!);
+ height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!);
+ if (!safeDimensions({ width, height })) return null;
+ const byteLength = buffer.readUInt32LE(offset + 8);
+ const imageOffset = buffer.readUInt32LE(offset + 12);
+ if (
+ byteLength === 0 ||
+ imageOffset < 6 + count * 16 ||
+ imageOffset > buffer.byteLength ||
+ byteLength > buffer.byteLength - imageOffset
+ )
+ return null;
+ const embedded = buffer.subarray(imageOffset, imageOffset + byteLength);
+ const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded);
+ if (!safeDimensions(embeddedDimensions)) return null;
+ }
+ return { width, height };
+}
+
+function sourceDimensions(buffer: Buffer): ImageDimensions | null {
+ return (
+ pngDimensions(buffer) ??
+ gifDimensions(buffer) ??
+ jpegDimensions(buffer) ??
+ webpDimensions(buffer) ??
+ icoDimensions(buffer)
+ );
+}
+
+async function normalizeFaviconBuffer(
+ webContents: Electron.WebContents,
+ mime: string | null,
+ buffer: Buffer,
+ signal: AbortSignal,
+): Promise {
+ const declaredMime = mime?.trim().toLowerCase() || null;
+ const normalizedMime =
+ declaredMime === "application/x-icon"
+ ? "image/x-icon"
+ : declaredMime === "application/octet-stream" || declaredMime === "binary/octet-stream"
+ ? null
+ : declaredMime;
+ const dimensions = sourceDimensions(buffer);
+ if (
+ (normalizedMime !== null && !/^image\/[a-z0-9.+-]+$/i.test(normalizedMime)) ||
+ normalizedMime === "image/svg+xml" ||
+ buffer.byteLength > MAX_FAVICON_RESPONSE_BYTES ||
+ !safeDimensions(dimensions)
+ ) {
+ return { kind: "none" };
+ }
+
+ const rasterized = await rasterizeFavicon(
+ webContents,
+ normalizedMime,
+ buffer,
+ dimensions,
+ signal,
+ );
+ if (rasterized.kind === "timed-out") return rasterized;
+ return typeof rasterized.value === "string" &&
+ rasterized.value.startsWith("data:image/png;base64,") &&
+ rasterized.value.length <= FAVICON_DATA_URL_MAX_LENGTH
+ ? { kind: "captured", dataUrl: rasterized.value }
+ : { kind: "none" };
+}
+
+async function rasterizeFavicon(
+ webContents: Electron.WebContents,
+ mime: string | null,
+ buffer: Buffer,
+ dimensions: ImageDimensions,
+ signal: AbortSignal,
+): Promise {
+ const gate = rasterizationGates.get(webContents) ?? { generation: 0 };
+ rasterizationGates.set(webContents, gate);
+ const generation = ++gate.generation;
+ const previousLaunchAllowed = gate.launchAllowed;
+ if (previousLaunchAllowed) {
+ await waitForRasterLaunch(previousLaunchAllowed, signal);
+ }
+ if (signal.aborted || generation !== gate.generation) {
+ return { kind: "completed", value: null };
+ }
+
+ const payload = buffer.toString("base64");
+ const blobType = mime ?? "";
+ const scale = Math.min(32 / dimensions.width, 32 / dimensions.height);
+ const decodeWidth = Math.max(1, Math.round(dimensions.width * scale));
+ const decodeHeight = Math.max(1, Math.round(dimensions.height * scale));
+ const drawX = (32 - decodeWidth) / 2;
+ const drawY = (32 - decodeHeight) / 2;
+ const code = `
+ (() => {
+ const rasterize = async () => {
+ try {
+ const source = Uint8Array.from(atob("${payload}"), (char) => char.charCodeAt(0));
+ const bitmap = await createImageBitmap(new Blob([source], { type: "${blobType}" }), {
+ resizeWidth: ${decodeWidth},
+ resizeHeight: ${decodeHeight},
+ resizeQuality: "high",
+ });
+ try {
+ if (bitmap.width <= 0 || bitmap.height <= 0 || bitmap.width * bitmap.height > ${MAX_FAVICON_SOURCE_PIXELS}) {
+ return null;
+ }
+ const canvas = new OffscreenCanvas(32, 32);
+ const context = canvas.getContext("2d");
+ if (!context) return null;
+ context.drawImage(bitmap, ${drawX}, ${drawY}, ${decodeWidth}, ${decodeHeight});
+ const blob = await canvas.convertToBlob({ type: "image/png" });
+ const output = new Uint8Array(await blob.arrayBuffer());
+ let binary = "";
+ for (const byte of output) binary += String.fromCharCode(byte);
+ return "data:image/png;base64," + btoa(binary);
+ } finally {
+ bitmap.close();
+ }
+ } catch {
+ return null;
+ }
+ };
+ return rasterize();
+ })()
+ `;
+
+ const execution = webContents.executeJavaScriptInIsolatedWorld(FAVICON_RASTER_WORLD_ID, [
+ { code },
+ ]);
+
+ const result = new Promise((resolve, reject) => {
+ // Electron cannot cancel isolated-world execution. This timeout ends only
+ // the logical attempt; renderer work may finish after a newer attempt starts.
+ const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS);
+ let settled = false;
+ const finish = (complete: () => void) => {
+ if (settled) return;
+ settled = true;
+ timeout.removeEventListener("abort", onTimeout);
+ signal.removeEventListener("abort", onAbort);
+ complete();
+ };
+ const onTimeout = () => {
+ finish(() => resolve({ kind: "timed-out" }));
+ };
+ const onAbort = () => {
+ finish(() => resolve({ kind: "completed", value: null }));
+ };
+ timeout.addEventListener("abort", onTimeout, { once: true });
+ signal.addEventListener("abort", onAbort, { once: true });
+ void execution.then(
+ (value) => {
+ finish(() => resolve({ kind: "completed", value }));
+ },
+ (cause: unknown) => {
+ finish(() => reject(cause));
+ },
+ );
+ if (signal.aborted) onAbort();
+ });
+ // The logical timeout does not cancel Electron's renderer work. Keep the
+ // gate closed until that physical execution actually settles.
+ const launchAllowed = execution.then(
+ () => undefined,
+ () => undefined,
+ );
+ gate.launchAllowed = launchAllowed;
+ void launchAllowed.then(() => {
+ if (gate.launchAllowed === launchAllowed) delete gate.launchAllowed;
+ });
+ return await result;
+}
diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index a6ef30c2742a..c24dca802c58 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -186,6 +186,102 @@ const makeTestPreviewWebContents = (
capturePage,
}) as never;
+const TEST_FAVICON = "data:image/png;base64,cG5n";
+
+const makeSourcePng = (width = 1, height = 1): Buffer => {
+ const buffer = Buffer.alloc(24);
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(buffer);
+ buffer.writeUInt32BE(width, 16);
+ buffer.writeUInt32BE(height, 20);
+ return buffer;
+};
+
+const makeFaviconWebContents = (options?: {
+ readonly fetch?: (url: string, init?: RequestInit) => Promise;
+ readonly id?: number;
+ readonly rasterize?: (code: string) => Promise;
+ readonly url?: string;
+}) => {
+ const sourcePng = makeSourcePng();
+ const listeners = new Map void>();
+ let currentUrl = options?.url ?? "http://localhost:3200/";
+ let destroyed = false;
+ let loading = false;
+ const fetch = vi.fn(
+ options?.fetch ??
+ (async () =>
+ new Response(new Uint8Array(sourcePng), {
+ headers: { "content-type": "image/png" },
+ })),
+ );
+ const executeJavaScriptInIsolatedWorld = vi.fn(
+ async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) =>
+ options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON,
+ );
+ const reload = vi.fn();
+ const loadURL = vi.fn(async (url: string) => {
+ currentUrl = url;
+ });
+ const off = vi.fn();
+ const debuggerOff = vi.fn();
+ const webContents = {
+ id: options?.id ?? 42,
+ isDestroyed: () => destroyed,
+ getType: () => "webview",
+ getURL: () => currentUrl,
+ getTitle: () => "Preview",
+ isLoading: () => loading,
+ isDevToolsOpened: () => false,
+ getZoomFactor: () => 1,
+ setZoomFactor: vi.fn(),
+ reload,
+ reloadIgnoringCache: vi.fn(),
+ loadURL,
+ on: vi.fn((event: string, listener: (...args: never[]) => void) => {
+ listeners.set(event, listener);
+ }),
+ off,
+ ipc: { on: vi.fn(), off: vi.fn() },
+ send: webviewSend,
+ session: { fetch },
+ navigationHistory: { canGoBack: () => false, canGoForward: () => false },
+ setWindowOpenHandler: vi.fn(),
+ executeJavaScriptInIsolatedWorld,
+ debugger: {
+ isAttached: () => false,
+ attach: vi.fn(),
+ sendCommand: vi.fn(async () => undefined),
+ on: vi.fn(),
+ off: debuggerOff,
+ },
+ };
+ return {
+ executeJavaScriptInIsolatedWorld,
+ fetch,
+ debuggerOff,
+ listeners,
+ loadURL,
+ off,
+ reload,
+ setDestroyed: (value: boolean) => {
+ destroyed = value;
+ },
+ setLoading: (value: boolean) => {
+ loading = value;
+ },
+ setUrl: (url: string) => {
+ currentUrl = url;
+ },
+ webContents: webContents as never,
+ };
+};
+
+const settle = function* (until: () => boolean) {
+ for (let attempt = 0; attempt < 30 && !until(); attempt++) {
+ yield* Effect.promise(() => Promise.resolve());
+ }
+};
+
const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => {
const listeners = new Map void>();
const send = vi.fn();
@@ -257,6 +353,32 @@ describe("PreviewManager", () => {
),
);
+ effectIt.effect("rejects a destroyed webview during registration", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const getType = vi.fn(() => "webview" as const);
+ fromId.mockReturnValue({
+ id: 42,
+ isDestroyed: () => true,
+ getType,
+ } as never);
+ yield* manager.createTab("tab_destroyed_registration");
+
+ const exit = yield* Effect.exit(manager.registerWebview("tab_destroyed_registration", 42));
+
+ expect(Exit.isFailure(exit)).toBe(true);
+ if (Exit.isFailure(exit)) {
+ expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
+ _tag: "PreviewWebContentsNotFoundError",
+ tabId: "tab_destroyed_registration",
+ webContentsId: 42,
+ });
+ }
+ expect(getType).not.toHaveBeenCalled();
+ }),
+ ),
+ );
+
effectIt.effect("isolates failed state listeners and continues delivery", () => {
const loggedErrors: Array = [];
const logger = Logger.make(({ message }) => {
@@ -375,6 +497,488 @@ describe("PreviewManager", () => {
),
);
+ effectIt.effect("detaches a destroyed webview instead of navigating it", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const preview = makeFaviconWebContents();
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_destroyed_navigation");
+ yield* manager.registerWebview("tab_destroyed_navigation", 42);
+ yield* manager.setColorScheme("tab_destroyed_navigation", "dark");
+ preview.setDestroyed(true);
+
+ yield* manager.navigate("tab_destroyed_navigation", "https://example.com/");
+
+ expect(preview.loadURL).not.toHaveBeenCalled();
+ expect(preview.reload).not.toHaveBeenCalled();
+ expect(preview.off).toHaveBeenCalled();
+ expect(preview.debuggerOff).toHaveBeenCalled();
+ expect(states.at(-1)).toMatchObject({
+ webContentsId: null,
+ navStatus: { kind: "Loading", url: "https://example.com/" },
+ });
+ }),
+ ),
+ );
+
+ effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const previous = makeFaviconWebContents();
+ const replacement = makeFaviconWebContents({ url: "https://example.com/" });
+ let current = previous.webContents;
+ let startReplacementRegistration: () => void = () => void 0;
+ const replacementReady = new Promise((resolve) => {
+ startReplacementRegistration = resolve;
+ });
+ fromId.mockImplementation(() => current);
+ yield* manager.createTab("tab_destroyed_replacement_race");
+ yield* manager.registerWebview("tab_destroyed_replacement_race", 42);
+ yield* manager.setColorScheme("tab_destroyed_replacement_race", "dark");
+ const replacementRegistration = yield* Effect.promise(() => replacementReady).pipe(
+ Effect.flatMap(() => manager.registerWebview("tab_destroyed_replacement_race", 42)),
+ Effect.forkChild({ startImmediately: true }),
+ );
+ previous.setDestroyed(true);
+ previous.debuggerOff.mockImplementationOnce(() => {
+ current = replacement.webContents;
+ startReplacementRegistration();
+ });
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+
+ yield* manager.navigate("tab_destroyed_replacement_race", "https://example.com/");
+ const registrationExit = yield* Fiber.await(replacementRegistration);
+
+ expect(Exit.isSuccess(registrationExit)).toBe(true);
+ expect(previous.off).toHaveBeenCalled();
+ expect(replacement.off).not.toHaveBeenCalled();
+ expect(states.at(-1)).toMatchObject({
+ webContentsId: 42,
+ navStatus: { kind: "Loading", url: "https://example.com/" },
+ });
+ }),
+ ),
+ );
+
+ effectIt.effect("publishes a canonical favicon origin while the page is loading", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const preview = makeFaviconWebContents({
+ url: `http://localhost:3200/${"x".repeat(3_000)}`,
+ });
+ preview.setLoading(true);
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_loading");
+ yield* manager.registerWebview("tab_favicon_loading", 42);
+
+ preview.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+
+ expect(states.at(-1)?.favicon).toMatchObject({
+ dataUrl: TEST_FAVICON,
+ pageUrl: "http://localhost:3200",
+ });
+ expect(states.at(-1)?.favicon?.capturedAt).toEqual(expect.any(Number));
+ }),
+ ),
+ );
+
+ effectIt.effect("shares an identical in-flight event and lets a changed event win", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ let resolveFirst!: (response: Response) => void;
+ const firstResponse = new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+ const preview = makeFaviconWebContents({
+ fetch: (url) =>
+ url.endsWith("first.png")
+ ? firstResponse
+ : Promise.resolve(
+ new Response(new Uint8Array(makeSourcePng()), {
+ headers: { "content-type": "image/png" },
+ }),
+ ),
+ });
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_latest");
+ yield* manager.registerWebview("tab_favicon_latest", 42);
+
+ const faviconUpdated = preview.listeners.get("page-favicon-updated")!;
+ faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never);
+ faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never);
+ yield* settle(() => preview.fetch.mock.calls.length === 1);
+ faviconUpdated({} as never, ["http://localhost:3200/second.png"] as never);
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+ resolveFirst(
+ new Response(new Uint8Array(makeSourcePng()), {
+ headers: { "content-type": "image/png" },
+ }),
+ );
+ yield* settle(() => false);
+
+ expect(preview.fetch).toHaveBeenCalledTimes(2);
+ expect(states.filter((state) => state.favicon !== undefined)).toHaveLength(1);
+ }),
+ ),
+ );
+
+ effectIt.effect("allows an identical retry after an undecodable capture", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ let rasterizations = 0;
+ const preview = makeFaviconWebContents({
+ rasterize: async () => (++rasterizations === 1 ? null : TEST_FAVICON),
+ });
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_retry");
+ yield* manager.registerWebview("tab_favicon_retry", 42);
+ const faviconUpdated = preview.listeners.get("page-favicon-updated")!;
+
+ faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never);
+ yield* settle(() => rasterizations === 1);
+ yield* settle(() => false);
+ faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never);
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+
+ expect(rasterizations).toBe(2);
+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
+ }),
+ ),
+ );
+
+ effectIt.effect("does not publish a capture invalidated by navigation", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ let resolveFetch!: (response: Response) => void;
+ const preview = makeFaviconWebContents({
+ fetch: () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ });
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_navigation");
+ yield* manager.registerWebview("tab_favicon_navigation", 42);
+ preview.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => preview.fetch.mock.calls.length === 1);
+ preview.listeners.get("did-start-navigation")?.({
+ isMainFrame: true,
+ isSameDocument: false,
+ } as never);
+ preview.setUrl("https://example.com/");
+ resolveFetch(
+ new Response(new Uint8Array(makeSourcePng()), {
+ headers: { "content-type": "image/png" },
+ }),
+ );
+ yield* settle(() => false);
+
+ expect(states.some((state) => state.favicon !== undefined)).toBe(false);
+ }),
+ ),
+ );
+
+ effectIt.effect("retains a favicon when reloading the current URL without a new event", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const preview = makeFaviconWebContents();
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_reload");
+ yield* manager.registerWebview("tab_favicon_reload", 42);
+ preview.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+
+ yield* manager.navigate("tab_favicon_reload", "http://localhost:3200/");
+
+ expect(preview.reload).toHaveBeenCalledOnce();
+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
+ }),
+ ),
+ );
+
+ effectIt.effect("clears a published favicon after a confirmed cross-origin navigation", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const preview = makeFaviconWebContents();
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_origin");
+ yield* manager.registerWebview("tab_favicon_origin", 42);
+ preview.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+
+ preview.setUrl("https://example.com/");
+ preview.listeners.get("did-navigate")?.({} as never);
+ yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
+
+ expect(states.at(-1)?.favicon).toBeUndefined();
+ }),
+ ),
+ );
+
+ effectIt.effect(
+ "retains the previous document icon across a failed cross-origin navigation",
+ () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const preview = makeFaviconWebContents();
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_failed_origin");
+ yield* manager.registerWebview("tab_favicon_failed_origin", 42);
+ preview.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+
+ preview.listeners.get("did-fail-load")?.(
+ {} as never,
+ -105 as never,
+ "Name not resolved" as never,
+ "https://unreachable.example/" as never,
+ true as never,
+ );
+ yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed");
+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
+
+ preview.listeners.get("did-navigate")?.({} as never);
+ yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
+ }),
+ ),
+ );
+
+ effectIt.effect("does not resurrect an icon after a confirmed about:blank document", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const preview = makeFaviconWebContents();
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_blank");
+ yield* manager.registerWebview("tab_favicon_blank", 42);
+ preview.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+
+ preview.setUrl("about:blank");
+ preview.listeners.get("did-navigate")?.({} as never);
+ yield* settle(() => states.at(-1)?.navStatus.kind === "Idle");
+ expect(states.at(-1)?.favicon).toBeUndefined();
+
+ preview.setUrl("http://localhost:3200/");
+ preview.listeners.get("did-navigate")?.({} as never);
+ yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
+ expect(states.at(-1)?.favicon).toBeUndefined();
+ }),
+ ),
+ );
+
+ effectIt.effect("clears a published favicon when a replacement webview attaches", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const initial = makeFaviconWebContents({ id: 42 });
+ const replacement = makeFaviconWebContents({ id: 43 });
+ fromId.mockImplementation((id?: number) => {
+ if (id === 42) return initial.webContents;
+ if (id === 43) return replacement.webContents;
+ return null;
+ });
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_replace");
+ yield* manager.registerWebview("tab_favicon_replace", 42);
+ initial.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+
+ yield* manager.registerWebview("tab_favicon_replace", 43);
+
+ expect(states.at(-1)?.webContentsId).toBe(43);
+ expect(states.at(-1)?.favicon).toBeUndefined();
+ }),
+ ),
+ );
+
+ effectIt.effect("ignores an old capture that completes after webview replacement", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ let resolveFetch!: (response: Response) => void;
+ const initial = makeFaviconWebContents({
+ id: 42,
+ fetch: () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ });
+ const replacement = makeFaviconWebContents({ id: 43 });
+ fromId.mockImplementation((id?: number) =>
+ id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null,
+ );
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_late_replace");
+ yield* manager.registerWebview("tab_favicon_late_replace", 42);
+ initial.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => initial.fetch.mock.calls.length === 1);
+
+ yield* manager.registerWebview("tab_favicon_late_replace", 43);
+ resolveFetch(
+ new Response(new Uint8Array(makeSourcePng()), {
+ headers: { "content-type": "image/png" },
+ }),
+ );
+ yield* settle(() => false);
+
+ expect(states.at(-1)?.webContentsId).toBe(43);
+ expect(
+ states.some((state) => state.webContentsId === 43 && state.favicon !== undefined),
+ ).toBe(false);
+ }),
+ ),
+ );
+
+ effectIt.effect("treats a reused WebContents id as a new attachment", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const initial = makeFaviconWebContents({ id: 42 });
+ const replacement = makeFaviconWebContents({ id: 42 });
+ let active = initial.webContents;
+ fromId.mockImplementation(() => active);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_reused_id");
+ yield* manager.registerWebview("tab_favicon_reused_id", 42);
+ initial.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+
+ active = replacement.webContents;
+ yield* manager.registerWebview("tab_favicon_reused_id", 42);
+
+ expect(states.at(-1)?.favicon).toBeUndefined();
+ expect(initial.off).toHaveBeenCalled();
+ expect(replacement.listeners.has("page-favicon-updated")).toBe(true);
+ }),
+ ),
+ );
+
+ effectIt.effect("preserves a favicon when the active attachment registers again", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const preview = makeFaviconWebContents();
+ fromId.mockReturnValue(preview.webContents);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+ yield* manager.createTab("tab_favicon_reregister");
+ yield* manager.registerWebview("tab_favicon_reregister", 42);
+ preview.listeners.get("page-favicon-updated")?.(
+ {} as never,
+ ["http://localhost:3200/favicon.png"] as never,
+ );
+ yield* settle(() => states.at(-1)?.favicon !== undefined);
+
+ yield* manager.registerWebview("tab_favicon_reregister", 42);
+
+ expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
+ }),
+ ),
+ );
+
effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () =>
withManager((manager) =>
Effect.gen(function* () {
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 169fe2992dca..4799a7dfac26 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -8,6 +8,7 @@
import type {
DesktopPreviewAnnotationTheme,
DesktopPreviewColorScheme,
+ DesktopPreviewFavicon,
DesktopPreviewPointerEvent,
PreviewAnnotationPayload,
PreviewAnnotationRect,
@@ -62,6 +63,7 @@ import {
import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";
+import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";
export type PreviewNavStatus =
| { kind: "Idle" }
@@ -85,6 +87,7 @@ export interface PreviewTabState {
pictureInPicture: boolean;
colorScheme: DesktopPreviewColorScheme;
controller: "human" | "agent" | "none";
+ favicon?: DesktopPreviewFavicon;
updatedAt: string;
}
@@ -346,7 +349,10 @@ type PreviewInputSignal =
| { readonly kind: "key"; readonly key: string; readonly code: string };
interface ManagedListeners {
+ readonly attachmentId: symbol;
+ readonly cancelFaviconCapture: () => void;
readonly scope: Scope.Closeable;
+ readonly webContents: Electron.WebContents;
}
type FrameCaptureConsumer = "picture-in-picture" | "recording";
@@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});
+ const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* (
+ tabId: string,
+ state: PreviewTabState,
+ ) {
+ if ((yield* SynchronizedRef.get(tabsRef)).get(tabId) === state) {
+ yield* emit(tabId, state);
+ }
+ });
+
const update = Effect.fn("PreviewManager.update")(function* (
tabId: string,
patch: Partial,
@@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
copy.delete(webContentsId);
}),
]);
- if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
+ if (managed) {
+ managed.cancelFaviconCapture();
+ yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
+ }
});
const isAppShortcut = (input: Electron.Input): boolean =>
@@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc: Electron.WebContents,
) {
const scope = yield* Scope.fork(parentScope, "sequential");
+ const attachmentId = Symbol();
+ let documentId = 0;
+ let nextRequestId = 0;
+ let activeCapture: {
+ readonly controller: AbortController;
+ readonly documentId: number;
+ readonly eventKey: string;
+ readonly requestId: number;
+ } | null = null;
+ const cancelFaviconCapture = () => {
+ documentId += 1;
+ activeCapture?.controller.abort();
+ activeCapture = null;
+ };
const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* (
preserveLoadFailure: boolean,
+ confirmedNavigation = false,
) {
if (wc.isDestroyed()) return;
const zoomFactor = yield* attempt(
@@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const updatedAt = yield* currentIso;
const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
const current = tabs.get(tabId);
- if (!current) return [Option.none(), tabs] as const;
+ if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) {
+ return [Option.none(), tabs] as const;
+ }
// Electron emits did-stop-loading after did-fail-load. At that point the
// failed guest is no longer "loading", but it has not successfully
// navigated anywhere. Keep the failure until a new load actually starts.
@@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
computedNavStatus.kind === "Success"
? current.navStatus
: computedNavStatus;
+ const clearFavicon =
+ confirmedNavigation &&
+ current.favicon !== undefined &&
+ safeHttpOrigin(current.favicon.pageUrl) !==
+ safeHttpOrigin(navStatus.kind === "Idle" ? wc.getURL() : navStatus.url);
+ const { favicon: _favicon, ...currentWithoutFavicon } = current;
const state: PreviewTabState = {
- ...current,
+ ...(clearFavicon ? currentWithoutFavicon : current),
navStatus,
canGoBack,
canGoForward,
@@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
}),
] as const;
});
- if (Option.isSome(next)) yield* emit(tabId, next.value);
+ if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
});
const sync = () => runFork(syncState(true));
- const syncNavigation = () => runFork(syncState(false));
+ const syncNavigation = () => runFork(syncState(false, true));
+ const syncInPageNavigation = () => runFork(syncState(false));
+ const navigationStarted = (
+ event: Electron.Event,
+ ) => {
+ if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture();
+ };
+ const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: {
+ readonly captureDocumentId: number;
+ readonly dataUrl: string;
+ readonly pageUrl: string;
+ readonly requestId: number;
+ }) {
+ const pageOrigin = safeHttpOrigin(input.pageUrl);
+ const managed = (yield* Ref.get(attachedRef)).get(wc.id);
+ if (
+ !pageOrigin ||
+ wc.isDestroyed() ||
+ webContents.fromId(wc.id) !== wc ||
+ managed?.attachmentId !== attachmentId ||
+ activeCapture?.documentId !== input.captureDocumentId ||
+ activeCapture.requestId !== input.requestId ||
+ safeHttpOrigin(wc.getURL()) !== pageOrigin
+ ) {
+ return;
+ }
+ const capturedAt = yield* currentMillis;
+ const updatedAt = yield* currentIso;
+ const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
+ const current = tabs.get(tabId);
+ if (
+ !current ||
+ current.webContentsId !== wc.id ||
+ webContents.fromId(wc.id) !== wc ||
+ activeCapture?.documentId !== input.captureDocumentId ||
+ activeCapture.requestId !== input.requestId
+ ) {
+ return [Option.none(), tabs] as const;
+ }
+ const state: PreviewTabState = {
+ ...current,
+ favicon: { dataUrl: input.dataUrl, pageUrl: pageOrigin, capturedAt },
+ updatedAt,
+ };
+ return [
+ Option.some(state),
+ replaceMap(tabs, (copy) => {
+ copy.set(tabId, state);
+ }),
+ ] as const;
+ });
+ if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
+ });
+ const faviconUpdated = (_event: Event, rawCandidates: ReadonlyArray): void => {
+ const pageUrl = wc.getURL();
+ if (!safeHttpOrigin(pageUrl)) return;
+ const candidates = selectFaviconCandidates(rawCandidates);
+ if (candidates.length === 0) return;
+ const eventKey = JSON.stringify([pageUrl, ...candidates]);
+ if (activeCapture?.eventKey === eventKey) return;
+ activeCapture?.controller.abort();
+ const captureDocumentId = documentId;
+ const requestId = ++nextRequestId;
+ const controller = new AbortController();
+ activeCapture = { controller, documentId: captureDocumentId, eventKey, requestId };
+ runFork(
+ Effect.tryPromise({
+ try: () =>
+ captureFavicon({ webContents: wc, pageUrl, candidates, signal: controller.signal }),
+ catch: (cause) =>
+ new PreviewOperationError({
+ operation: "captureFavicon",
+ tabId,
+ webContentsId: wc.id,
+ cause,
+ }),
+ }).pipe(
+ Effect.flatMap((result) =>
+ result.kind === "captured"
+ ? publishFavicon({
+ captureDocumentId,
+ dataUrl: result.dataUrl,
+ pageUrl,
+ requestId,
+ })
+ : Effect.void,
+ ),
+ Effect.catch((error) =>
+ controller.signal.aborted
+ ? Effect.void
+ : Effect.logDebug("Favicon capture failed.", { error, tabId, webContentsId: wc.id }),
+ ),
+ Effect.ensuring(
+ Effect.sync(() => {
+ if (activeCapture?.requestId === requestId) activeCapture = null;
+ }),
+ ),
+ ),
+ );
+ };
const failed = (
_event: Event,
code: number,
@@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
yield* Scope.addFinalizer(
scope,
attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {
+ cancelFaviconCapture();
+ wc.off("did-start-navigation", navigationStarted);
wc.off("did-navigate", syncNavigation);
- wc.off("did-navigate-in-page", syncNavigation);
+ wc.off("did-navigate-in-page", syncInPageNavigation);
wc.off("page-title-updated", sync);
+ wc.off("page-favicon-updated", faviconUpdated as never);
wc.off("did-start-loading", sync);
wc.off("did-stop-loading", sync);
wc.off("did-fail-load", failed as never);
@@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
+ wc.on("did-start-navigation", navigationStarted);
wc.on("did-navigate", syncNavigation);
- wc.on("did-navigate-in-page", syncNavigation);
+ wc.on("did-navigate-in-page", syncInPageNavigation);
wc.on("page-title-updated", sync);
+ wc.on("page-favicon-updated", faviconUpdated as never);
wc.on("did-start-loading", sync);
wc.on("did-stop-loading", sync);
wc.on("did-fail-load", failed as never);
@@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
});
yield* Ref.update(attachedRef, (attached) =>
replaceMap(attached, (copy) => {
- copy.set(wc.id, { scope });
+ copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc });
}),
);
});
@@ -1561,6 +1706,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const mainWindow = yield* Ref.get(mainWindowRef);
if (
!wc ||
+ wc.isDestroyed() ||
wc.getType() !== "webview" ||
(Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)
) {
@@ -1568,7 +1714,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
}
const attached = yield* Ref.get(attachedRef);
const annotationTheme = yield* Ref.get(annotationThemeRef);
- if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {
+ const currentAttachment = attached.get(webContentsId);
+ if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) {
const zoomFactor = yield* attempt(
{ operation: "registerWebview.getZoomFactor", tabId, webContentsId },
() => wc.getZoomFactor(),
@@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
return;
}
const replacedWebContentsId =
- tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null;
+ tab.webContentsId != null &&
+ (tab.webContentsId !== webContentsId || currentAttachment?.webContents !== wc)
+ ? tab.webContentsId
+ : null;
if (replacedWebContentsId !== null) {
yield* Effect.all(
[
@@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
] as const;
}
const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
+ const { favicon: _favicon, ...currentWithoutFavicon } = current;
const next: PreviewTabState = {
- ...current,
+ ...currentWithoutFavicon,
webContentsId,
navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
canGoBack: wc.navigationHistory.canGoBack(),
@@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
pictureInPicture: current?.pictureInPicture ?? false,
colorScheme: current?.colorScheme ?? "system",
controller: current?.controller ?? "none",
+ ...(current?.favicon ? { favicon: current.favicon } : {}),
updatedAt,
};
return [
@@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
});
yield* emit(tabId, pending);
if (pending.webContentsId == null) return;
- const wc = webContents.fromId(pending.webContentsId);
- if (!wc) {
- const detached = { ...pending, webContentsId: null };
- yield* SynchronizedRef.update(tabsRef, (tabs) =>
- tabs.get(tabId)?.webContentsId !== pending.webContentsId
- ? tabs
- : replaceMap(tabs, (copy) => {
- copy.set(tabId, detached);
- }),
+ const webContentsId = pending.webContentsId;
+ const wc = webContents.fromId(webContentsId);
+ if (!wc || wc.isDestroyed()) {
+ const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
+ yield* withTabLifecycleLock(
+ tabId,
+ Effect.gen(function* () {
+ const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
+ const currentAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
+ const currentWebContents = webContents.fromId(webContentsId);
+ if (
+ currentTab?.webContentsId !== webContentsId ||
+ currentAttachment !== expectedAttachment ||
+ (currentWebContents && !currentWebContents.isDestroyed())
+ ) {
+ return;
+ }
+ yield* Effect.all(
+ [
+ detachControlSession(webContentsId),
+ detachListeners(webContentsId),
+ cancelPickElement(tabId),
+ ],
+ { concurrency: 3, discard: true },
+ );
+ const detached = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
+ const current = tabs.get(tabId);
+ if (current?.webContentsId !== webContentsId) {
+ return [Option.none(), tabs] as const;
+ }
+ const { favicon: _favicon, ...currentWithoutFavicon } = current;
+ const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId: null };
+ return [
+ Option.some(next),
+ replaceMap(tabs, (copy) => {
+ copy.set(tabId, next);
+ }),
+ ] as const;
+ });
+ if (Option.isSome(detached)) yield* emitIfCurrent(tabId, detached.value);
+ }),
);
- yield* emit(tabId, detached);
return;
}
if (wc.getURL() === url) {
diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts
index 6f89d86df88a..558924b63da6 100644
--- a/apps/web/src/browser/browserTargetResolver.test.ts
+++ b/apps/web/src/browser/browserTargetResolver.test.ts
@@ -180,4 +180,135 @@ describe("browser target resolver", () => {
const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" ");
});
+
+ it("classifies exact private IPv4 and IPv6 boundaries", async () => {
+ const { isPrivateNetworkHost } = await import("./browserTargetResolver");
+ const privateHosts = [
+ "0.0.0.0",
+ "10.0.0.0",
+ "10.255.255.255",
+ "100.64.0.0",
+ "100.127.255.255",
+ "127.0.0.0",
+ "127.255.255.255",
+ "169.254.0.0",
+ "169.254.255.255",
+ "172.16.0.0",
+ "172.31.255.255",
+ "192.168.0.0",
+ "192.168.255.255",
+ "198.18.0.0",
+ "198.19.255.255",
+ "fc00::",
+ "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+ "fe80::",
+ "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+ "::ffff:192.168.1.1",
+ "localhost.",
+ "localhost..",
+ "devbox.",
+ "devbox..",
+ "printer.local.",
+ "printer.local..",
+ "printer.home.arpa.",
+ "printer.home.arpa..",
+ "devbox.example.ts.net.",
+ "devbox.example.ts.net..",
+ ];
+ const publicHosts = [
+ "1.0.0.0",
+ "100.63.255.255",
+ "100.128.0.0",
+ "169.253.255.255",
+ "169.255.0.0",
+ "172.15.255.255",
+ "172.32.0.0",
+ "192.167.255.255",
+ "192.169.0.0",
+ "198.17.255.255",
+ "198.20.0.0",
+ "fbff:ffff::",
+ "fec0::",
+ "2001:4860:4860::8888",
+ "::ffff:8.8.8.8",
+ "example.com.",
+ ];
+ expect(privateHosts.filter((host) => !isPrivateNetworkHost(host))).toEqual([]);
+ expect(publicHosts.filter(isPrivateNetworkHost)).toEqual([]);
+ });
+
+ it("allows only globally routable hosts to reach a public favicon provider", async () => {
+ const { isPublicFaviconHost } = await import("./browserTargetResolver");
+ const nonPublic = [
+ "192.0.0.0",
+ "192.0.0.255",
+ "192.0.2.0",
+ "192.0.2.255",
+ "192.88.99.0",
+ "192.88.99.255",
+ "198.51.100.0",
+ "198.51.100.255",
+ "203.0.113.0",
+ "203.0.113.255",
+ "224.0.0.0",
+ "255.255.255.255",
+ "::2",
+ "100::",
+ "100::ffff:ffff:ffff:ffff",
+ "100:0:0:1::",
+ "100:0:0:1:ffff:ffff:ffff:ffff",
+ "64:ff9b:1::1",
+ "64:ff9b::a00:1",
+ "64:ff9b::7f00:1",
+ "64:ff9b::c0a8:101",
+ "64:ff9b::c000:201",
+ "2001:5::1",
+ "2001:2::",
+ "2001:2:0:ffff:ffff:ffff:ffff:ffff",
+ "2001:db8::",
+ "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff",
+ "3fff::",
+ "3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff",
+ "5f00::1",
+ "fec0::",
+ "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+ "::ffff:192.0.2.1",
+ "app.test",
+ "app.test..",
+ "printer.local..",
+ "printer.home.arpa..",
+ "devbox.example.ts.net..",
+ "127.0.0.1..",
+ "127.1..",
+ "10.1..",
+ "172.16.1..",
+ "192.168.1..",
+ "service.internal",
+ "hidden.onion",
+ ];
+ const publicHosts = [
+ "191.255.255.255",
+ "192.0.1.255",
+ "192.0.3.0",
+ "198.51.99.255",
+ "198.51.101.0",
+ "203.0.112.255",
+ "203.0.114.0",
+ "223.255.255.255",
+ "1.1.1.1",
+ "2001:4860:4860::8888",
+ "2606:4700:4700::1111",
+ "64:ff9b::808:808",
+ "2001:1::1",
+ "2001:3::1",
+ "2001:4:112::1",
+ "2001:20::1",
+ "2001:30::1",
+ "::ffff:8.8.8.8",
+ "example.com",
+ "example.com.",
+ ];
+ expect(nonPublic.filter(isPublicFaviconHost)).toEqual([]);
+ expect(publicHosts.filter((host) => !isPublicFaviconHost(host))).toEqual([]);
+ });
});
diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts
index 3c3be59b4578..149248d17609 100644
--- a/apps/web/src/browser/browserTargetResolver.ts
+++ b/apps/web/src/browser/browserTargetResolver.ts
@@ -8,7 +8,10 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
import { readPreparedConnection } from "~/state/session";
export const normalizeHostname = (host: string): string =>
- host.toLowerCase().replace(/^\[|\]$/g, "");
+ host
+ .toLowerCase()
+ .replace(/^\[|\]$/g, "")
+ .replace(/\.+$/u, "");
const parseIpv4Address = (host: string): readonly number[] | null => {
const parts = normalizeHostname(host).split(".").map(Number);
@@ -18,28 +21,91 @@ const parseIpv4Address = (host: string): readonly number[] | null => {
: null;
};
+const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => {
+ const normalized = normalizeHostname(host);
+ if (!normalized.startsWith("::ffff:")) return null;
+ const suffix = normalized.slice("::ffff:".length);
+ const dotted = parseIpv4Address(suffix);
+ if (dotted) return dotted;
+ const hextets = suffix.split(":");
+ if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
+ const high = Number.parseInt(hextets[0]!, 16);
+ const low = Number.parseInt(hextets[1]!, 16);
+ return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
+};
+
+const parseIpv6Address = (host: string): readonly number[] | null => {
+ const normalized = normalizeHostname(host);
+ if (!normalized.includes(":")) return null;
+ const halves = normalized.split("::");
+ if (halves.length > 2) return null;
+ const head = halves[0] ? halves[0].split(":") : [];
+ const tail = halves[1] ? halves[1].split(":") : [];
+ if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
+ const missing = 8 - head.length - tail.length;
+ if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null;
+ return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) =>
+ Number.parseInt(part, 16),
+ );
+};
+
+const ipv6PrefixMatches = (
+ address: readonly number[],
+ prefix: readonly number[],
+ prefixLength: number,
+): boolean => {
+ const fullHextets = Math.floor(prefixLength / 16);
+ if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false;
+ const remainingBits = prefixLength % 16;
+ if (remainingBits === 0) return true;
+ const mask = (0xffff << (16 - remainingBits)) & 0xffff;
+ return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask);
+};
+
+const isPrivateIpv4Address = (parts: readonly number[]): boolean =>
+ parts[0] === 0 ||
+ parts[0] === 10 ||
+ parts[0] === 127 ||
+ (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
+ (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
+ (parts[0] === 192 && parts[1] === 168) ||
+ (parts[0] === 169 && parts[1] === 254) ||
+ (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19);
+
+const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean =>
+ isPrivateIpv4Address(parts) ||
+ parts[0]! >= 224 ||
+ // Deliberately suppress the whole protocol-assignment block. IANA marks
+ // .9 and .10 globally reachable, but privacy-safe false negatives are
+ // preferable to disclosing another special-purpose address by mistake.
+ (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) ||
+ (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) ||
+ (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) ||
+ (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) ||
+ (parts[0] === 203 && parts[1] === 0 && parts[2] === 113);
+
export const isLocalLoopbackHost = (host: string): boolean => {
const normalized = normalizeHostname(host);
if (normalized === "localhost" || normalized === "::1") return true;
return parseIpv4Address(normalized)?.[0] === 127;
};
-const isPrivateNetworkHost = (host: string): boolean => {
+export const isPrivateNetworkHost = (host: string): boolean => {
const normalized = normalizeHostname(host);
- if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) {
+ if (
+ normalized === "::" ||
+ isLocalLoopbackHost(normalized) ||
+ normalized.endsWith(".localhost") ||
+ normalized.endsWith(".local") ||
+ normalized === "home.arpa" ||
+ normalized.endsWith(".home.arpa") ||
+ (!normalized.includes(".") && !normalized.includes(":"))
+ ) {
return true;
}
if (normalized.endsWith(".ts.net")) return true;
- const parts = parseIpv4Address(normalized);
- if (parts) {
- return (
- parts[0] === 10 ||
- (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
- (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
- (parts[0] === 192 && parts[1] === 168) ||
- (parts[0] === 169 && parts[1] === 254)
- );
- }
+ const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
+ if (parts) return isPrivateIpv4Address(parts);
const firstIpv6Token = normalized.split(":", 1)[0] ?? "";
if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;
const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);
@@ -49,6 +115,48 @@ const isPrivateNetworkHost = (host: string): boolean => {
);
};
+/** Whether a hostname is eligible to be disclosed to a public favicon provider. */
+export const isPublicFaviconHost = (host: string): boolean => {
+ // A single trailing dot is a valid absolute DNS name. Repeated trailing
+ // dots are malformed and can conceal legacy numeric forms such as 127.1.
+ if (host.endsWith("..")) return false;
+ const normalized = normalizeHostname(host);
+ if (isPrivateNetworkHost(normalized)) return false;
+ if (
+ [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some(
+ (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix),
+ )
+ ) {
+ return false;
+ }
+ const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
+ if (ipv4) return !isSpecialPurposeIpv4Address(ipv4);
+ if (!normalized.includes(":")) return true;
+ const ipv6 = parseIpv6Address(normalized);
+ if (!ipv6) return false;
+ if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) {
+ const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff];
+ return !isSpecialPurposeIpv4Address(embeddedIpv4);
+ }
+ const first = ipv6[0]!;
+ if ((first & 0xe000) !== 0x2000) return false;
+ if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) {
+ const publicProtocolAssignment =
+ (ipv6[1] === 1 &&
+ ipv6.slice(2, 7).every((part) => part === 0) &&
+ [1, 2, 3].includes(ipv6[7]!)) ||
+ ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) ||
+ ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) ||
+ ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) ||
+ ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28);
+ return publicProtocolAssignment;
+ }
+ if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false;
+ if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false;
+ if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false;
+ return true;
+};
+
const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {
const connection = readPreparedConnection(environmentId);
if (!connection) throw new Error(`Environment ${environmentId} is not connected.`);
diff --git a/apps/web/src/browserFaviconLogic.test.ts b/apps/web/src/browserFaviconLogic.test.ts
new file mode 100644
index 000000000000..39ee76ef1ef3
--- /dev/null
+++ b/apps/web/src/browserFaviconLogic.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ BROWSER_FAVICON_MAX_ENTRIES,
+ type BrowserFaviconEntry,
+ evictExcessFavicons,
+ faviconKey,
+ faviconStorageLocation,
+ isStorableFaviconDataUrl,
+ migratePersistedBrowserFaviconState,
+} from "./browserFaviconLogic";
+
+const PNG = "data:image/png;base64,AAAA";
+
+function entry(capturedAt = 0): BrowserFaviconEntry {
+ return { dataUrl: PNG, capturedAt };
+}
+
+describe("browser favicon logic", () => {
+ it("keys valid origins canonically while keeping distinct scopes separate", () => {
+ expect(faviconKey("env:project", "http://myapp.test:3000/admin?x=1", null)).toBe(
+ "env:project http://myapp.test:3000",
+ );
+ expect(faviconKey("env:project", "http://192.168.64.2:3000/", "192.168.64.2")).toBe(
+ faviconKey("env:project", "http://localhost:3000/", "192.168.64.2"),
+ );
+ expect(faviconKey("env:project", "http://127.0.0.1:3000/", null)).toBe(
+ faviconKey("env:project", "http://0.0.0.0:3000/", null),
+ );
+ const keys = [
+ faviconKey("env:a", "http://localhost:3000/", null),
+ faviconKey("env:b", "http://localhost:3000/", null),
+ faviconKey("env:a", "http://localhost:5173/", null),
+ faviconKey("env:a", "https://localhost:3000/", null),
+ faviconKey("env:a", "http://192.168.1.50:3000/", "192.168.64.2"),
+ ];
+ expect(new Set(keys).size).toBe(keys.length);
+ expect(faviconKey("env:a", "not a url", null)).toBeNull();
+ expect(faviconKey("env:a", "ftp://example.com/", null)).toBeNull();
+ expect(faviconKey("", "http://localhost/", null)).toBeNull();
+ expect(faviconKey("env:a", "http://local:3000/", null)).not.toBe(
+ faviconKey("env:a", "http://localhost:3000/", null),
+ );
+ });
+
+ it("retains an exact environment-host alias for offline lookup", () => {
+ const expected = {
+ aliases: ["192.168.64.2"],
+ key: "env:project http://localhost:3000",
+ };
+ expect(
+ faviconStorageLocation("env:project", "http://localhost:3000/app", "192.168.64.2"),
+ ).toEqual(expected);
+ expect(
+ faviconStorageLocation("env:project", "http://192.168.64.2:3000/app", "192.168.64.2"),
+ ).toEqual(expected);
+ expect(
+ faviconStorageLocation("env:project", "https://example.com/app", "192.168.64.2"),
+ ).toEqual({ aliases: [], key: "env:project https://example.com:443" });
+ expect(faviconStorageLocation("env:project", "http://localhost:3000/app", "fd00::1")).toEqual({
+ aliases: ["fd00::1"],
+ key: "env:project http://localhost:3000",
+ });
+ expect(faviconKey("env:project", "http://[2001:4860:4860::8888]/", null)).toBe(
+ "env:project http://[2001:4860:4860::8888]:80",
+ );
+ });
+
+ it("accepts only bounded base64 PNG data", () => {
+ expect(isStorableFaviconDataUrl(PNG)).toBe(true);
+ expect(isStorableFaviconDataUrl("data:image/svg+xml;base64,AAAA")).toBe(false);
+ expect(isStorableFaviconDataUrl("data:image/png;base64,")).toBe(false);
+ expect(isStorableFaviconDataUrl("data:image/png;base64,%%%%")).toBe(false);
+ expect(isStorableFaviconDataUrl(`data:image/png;base64,${"A".repeat(8192)}`)).toBe(false);
+ });
+
+ it("evicts old entries and sanitizes hydrated state", () => {
+ const byKey = Object.fromEntries(
+ Array.from({ length: BROWSER_FAVICON_MAX_ENTRIES + 2 }, (_, index) => [
+ `key-${index}`,
+ entry(index),
+ ]),
+ );
+ const result = evictExcessFavicons(byKey);
+ expect(Object.keys(result)).toHaveLength(BROWSER_FAVICON_MAX_ENTRIES);
+ expect(result["key-0"]).toBeUndefined();
+ expect(result["key-1"]).toBeUndefined();
+ expect(
+ migratePersistedBrowserFaviconState({
+ byKey: {
+ "env:project http://local:3000": entry(4),
+ "env:project http://localhost:3000": entry(5),
+ "env:project http://localhost:3003": {
+ ...entry(6),
+ aliases: [
+ "192.168.64.2",
+ "192.168.64.2",
+ "192.168.64.3",
+ "192.168.64.4",
+ "192.168.64.5",
+ "192.168.64.6",
+ "Not Normalized",
+ "not a host/",
+ "x".repeat(5_000),
+ ],
+ },
+ "env:project http://localhost:3001": {
+ dataUrl: "https://example.com/icon.png",
+ capturedAt: 6,
+ },
+ "env:project http://localhost:3002": { dataUrl: PNG, capturedAt: Number.NaN },
+ },
+ }),
+ ).toEqual({
+ byKey: {
+ "env:project http://localhost:3000": entry(5),
+ "env:project http://localhost:3003": {
+ ...entry(6),
+ aliases: ["192.168.64.2", "192.168.64.3", "192.168.64.4", "192.168.64.5"],
+ },
+ },
+ });
+ });
+});
diff --git a/apps/web/src/browserFaviconLogic.ts b/apps/web/src/browserFaviconLogic.ts
new file mode 100644
index 000000000000..695bcff20e95
--- /dev/null
+++ b/apps/web/src/browserFaviconLogic.ts
@@ -0,0 +1,201 @@
+import { FAVICON_CAPTURED_AT_MAX, FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts";
+
+import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";
+
+export type BrowserFaviconEntry = {
+ dataUrl: string;
+ capturedAt: number;
+ aliases?: ReadonlyArray;
+};
+
+export const BROWSER_FAVICON_MAX_ENTRIES = 40;
+export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096;
+const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000;
+export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4;
+const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255;
+
+function formatFaviconHost(host: string): string {
+ return host.includes(":") ? `[${host}]` : host;
+}
+
+export function canCanonicalizeFaviconWithoutEnvironment(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ const host = normalizeHostname(parsed.hostname);
+ return (
+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
+ (isLocalLoopbackHost(host) || host === "0.0.0.0")
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function isValidFaviconCapturedAt(value: unknown): value is number {
+ return (
+ typeof value === "number" &&
+ Number.isFinite(value) &&
+ value >= 0 &&
+ value <= FAVICON_CAPTURED_AT_MAX &&
+ value <= Date.now() + BROWSER_FAVICON_MAX_FUTURE_SKEW_MS
+ );
+}
+
+function migratePersistedFaviconKey(key: string): string | null {
+ if (key.length === 0 || key.length > BROWSER_FAVICON_MAX_KEY_LENGTH) return null;
+ const separator = key.indexOf(" ");
+ if (separator <= 0) return null;
+ const scope = key.slice(0, separator);
+ const origin = key.slice(separator + 1);
+ try {
+ const parsed = new URL(origin);
+ if (
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
+ parsed.pathname !== "/" ||
+ parsed.search !== "" ||
+ parsed.hash !== "" ||
+ parsed.username !== "" ||
+ parsed.password !== ""
+ )
+ return null;
+ if (normalizeHostname(parsed.hostname) !== "local") return key;
+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
+ return `${scope} ${parsed.protocol}//localhost:${port}`;
+ } catch {
+ return null;
+ }
+}
+
+function persistedFaviconAlias(value: unknown): string | null {
+ if (typeof value !== "string" || value.length > BROWSER_FAVICON_MAX_ALIAS_LENGTH) return null;
+ const normalized = normalizeHostname(value);
+ if (!normalized || normalized !== value) return null;
+ try {
+ const parsed = new URL(`http://${formatFaviconHost(normalized)}`);
+ return normalizeHostname(parsed.hostname) === normalized ? normalized : null;
+ } catch {
+ return null;
+ }
+}
+
+export function faviconKey(
+ projectRefKey: string,
+ url: string,
+ environmentHostname: string | null,
+): string | null {
+ if (projectRefKey.length === 0) return null;
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
+ const host = normalizeHostname(parsed.hostname);
+ const canonicalHost =
+ isLocalLoopbackHost(host) ||
+ host === "0.0.0.0" ||
+ (environmentHostname !== null && host === normalizeHostname(environmentHostname))
+ ? "localhost"
+ : host;
+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
+ return `${projectRefKey} ${parsed.protocol}//${formatFaviconHost(canonicalHost)}:${port}`;
+ } catch {
+ return null;
+ }
+}
+
+export function faviconStorageLocation(
+ projectRefKey: string,
+ url: string,
+ environmentHostname: string | null,
+): { readonly aliases: ReadonlyArray; readonly key: string } | null {
+ const canonicalKey = faviconKey(projectRefKey, url, environmentHostname);
+ if (!canonicalKey) return null;
+ if (environmentHostname === null) return { aliases: [], key: canonicalKey };
+ try {
+ const parsed = new URL(url);
+ const host = normalizeHostname(parsed.hostname);
+ const normalizedEnvironmentHostname = normalizeHostname(environmentHostname);
+ if (
+ normalizedEnvironmentHostname.length === 0 ||
+ (!isLocalLoopbackHost(host) && host !== "0.0.0.0" && host !== normalizedEnvironmentHostname)
+ ) {
+ return { aliases: [], key: canonicalKey };
+ }
+ return {
+ aliases:
+ isLocalLoopbackHost(normalizedEnvironmentHostname) ||
+ normalizedEnvironmentHostname === "0.0.0.0"
+ ? []
+ : [normalizedEnvironmentHostname],
+ key: canonicalKey,
+ };
+ } catch {
+ return { aliases: [], key: canonicalKey };
+ }
+}
+
+export function isStorableFaviconDataUrl(value: unknown): value is string {
+ if (
+ typeof value !== "string" ||
+ !value.startsWith("data:image/png;base64,") ||
+ value.length > FAVICON_DATA_URL_MAX_LENGTH
+ ) {
+ return false;
+ }
+ const payload = value.slice("data:image/png;base64,".length);
+ return (
+ payload.length > 0 &&
+ payload.length % 4 !== 1 &&
+ !/[^a-z0-9+/=]/i.test(payload) &&
+ /^[a-z0-9+/]*={0,2}$/i.test(payload)
+ );
+}
+
+export function evictExcessFavicons(
+ byKey: Record,
+): Record {
+ const keys = Object.keys(byKey);
+ if (keys.length <= BROWSER_FAVICON_MAX_ENTRIES) return byKey;
+ return Object.fromEntries(
+ keys
+ .toSorted((left, right) => (byKey[right]?.capturedAt ?? 0) - (byKey[left]?.capturedAt ?? 0))
+ .slice(0, BROWSER_FAVICON_MAX_ENTRIES)
+ .map((key) => [key, byKey[key] as BrowserFaviconEntry]),
+ );
+}
+
+export function migratePersistedBrowserFaviconState(persistedState: unknown): {
+ byKey: Record;
+} {
+ if (!persistedState || typeof persistedState !== "object") return { byKey: {} };
+ const raw = "byKey" in persistedState ? (persistedState as { byKey?: unknown }).byKey : null;
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byKey: {} };
+ const byKey: Record = {};
+ for (const [key, value] of Object.entries(raw as Record)) {
+ const migratedKey = migratePersistedFaviconKey(key);
+ if (!migratedKey) continue;
+ if (!value || typeof value !== "object") continue;
+ const { dataUrl, capturedAt } = value as Record;
+ if (!isStorableFaviconDataUrl(dataUrl)) continue;
+ if (!isValidFaviconCapturedAt(capturedAt)) continue;
+ const rawAliases = (value as Record).aliases;
+ const aliases = Array.isArray(rawAliases)
+ ? [...new Set(rawAliases.map(persistedFaviconAlias).filter((alias) => alias !== null))].slice(
+ 0,
+ BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
+ )
+ : [];
+ const existing = byKey[migratedKey];
+ const newest = !existing || capturedAt > existing.capturedAt;
+ const mergedAliases = [
+ ...new Set([
+ ...(newest ? aliases : (existing?.aliases ?? [])),
+ ...(newest ? (existing?.aliases ?? []) : aliases),
+ ]),
+ ].slice(0, BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY);
+ byKey[migratedKey] = {
+ dataUrl: newest ? dataUrl : existing.dataUrl,
+ capturedAt: newest ? capturedAt : existing.capturedAt,
+ ...(mergedAliases.length > 0 ? { aliases: mergedAliases } : {}),
+ };
+ }
+ return { byKey: evictExcessFavicons(byKey) };
+}
diff --git a/apps/web/src/browserFaviconStore.test.ts b/apps/web/src/browserFaviconStore.test.ts
new file mode 100644
index 000000000000..8aef763ddd4a
--- /dev/null
+++ b/apps/web/src/browserFaviconStore.test.ts
@@ -0,0 +1,316 @@
+import { scopeProjectRef } from "@t3tools/client-runtime/environment";
+import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+vi.mock("~/state/entities", () => ({ useThreadShell: () => null }));
+vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" }) }));
+
+import {
+ flushPendingFaviconsForThread,
+ lookupFavicon,
+ mergeBrowserFaviconState,
+ recordFaviconForProject,
+ recordFaviconForThread,
+ registerFaviconProjectForThread,
+ resetBrowserFaviconsForTests,
+ resolveBrowserFaviconStorage,
+ useBrowserFaviconStore,
+} from "./browserFaviconStore";
+import {
+ BROWSER_FAVICON_MAX_ENTRIES,
+ migratePersistedBrowserFaviconState,
+} from "./browserFaviconLogic";
+
+const environmentId = EnvironmentId.make("env-1");
+const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1"));
+const threadRef = { environmentId, threadId: ThreadId.make("thread-1") };
+const PNG = "data:image/png;base64,AAAA";
+const favicon = (pageUrl: string, capturedAt: number, dataUrl = PNG) => ({
+ pageUrl,
+ capturedAt,
+ dataUrl,
+});
+
+describe("browser favicon store", () => {
+ beforeEach(resetBrowserFaviconsForTests);
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("keeps the newest capture for an origin and permits an identical later revisit", () => {
+ const recordFavicon = vi.spyOn(useBrowserFaviconStore.getState(), "recordFavicon");
+ recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 20), null);
+ recordFaviconForProject(
+ projectRef,
+ favicon("http://localhost:3000/old", 10, "data:image/png;base64,QkJCQg=="),
+ null,
+ );
+ recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null);
+ recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null);
+ expect(Object.values(useBrowserFaviconStore.getState().byKey)).toEqual([
+ { dataUrl: PNG, capturedAt: 30 },
+ ]);
+ expect(recordFavicon).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not share localhost icons across environments or physical projects", () => {
+ const otherEnvironment = scopeProjectRef(
+ EnvironmentId.make("env-2"),
+ ProjectId.make("project-1"),
+ );
+ const otherProject = scopeProjectRef(environmentId, ProjectId.make("project-2"));
+ recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 1), null);
+ recordFaviconForProject(otherEnvironment, favicon("http://localhost:3000/", 2), null);
+ recordFaviconForProject(otherProject, favicon("http://localhost:3000/", 3), null);
+ expect(Object.keys(useBrowserFaviconStore.getState().byKey)).toEqual([
+ "env-1:project-1 http://localhost:3000",
+ "env-2:project-1 http://localhost:3000",
+ "env-1:project-2 http://localhost:3000",
+ ]);
+ });
+
+ it("finds a persisted environment icon after shell hydration without a live connection", () => {
+ recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/app", 5), "192.168.64.2");
+ const byKey = useBrowserFaviconStore.getState().byKey;
+ expect(lookupFavicon(byKey, null, "http://localhost:3000/app", null)).toBeNull();
+ expect(lookupFavicon(byKey, projectRef, "http://localhost:3000/app", null)).toBe(PNG);
+ expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3000/app", null)).toBe(PNG);
+ expect(lookupFavicon(byKey, projectRef, "http://192.168.64.3:3000/app", null)).toBeNull();
+ expect(lookupFavicon(byKey, projectRef, "https://192.168.64.2:3000/app", null)).toBeNull();
+ expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3001/app", null)).toBeNull();
+ expect(
+ lookupFavicon(
+ byKey,
+ scopeProjectRef(environmentId, ProjectId.make("project-2")),
+ "http://192.168.64.2:3000/app",
+ null,
+ ),
+ ).toBeNull();
+ });
+
+ it("finds a persisted IPv6 environment icon without a live connection", () => {
+ recordFaviconForProject(projectRef, favicon("http://[fd00::1]:3000/app", 5), "fd00::1");
+ const migrated = migratePersistedBrowserFaviconState({
+ byKey: useBrowserFaviconStore.getState().byKey,
+ }).byKey;
+ expect(lookupFavicon(migrated, projectRef, "http://[fd00::1]:3000/app", null)).toBe(PNG);
+ });
+
+ it("keeps exact host aliases attached to the newest canonical icon", () => {
+ const olderPng = "data:image/png;base64,QkJCQg==";
+ const newerPng = "data:image/png;base64,Q0NDQw==";
+ recordFaviconForProject(
+ projectRef,
+ favicon("http://192.168.64.2:3000/", 10, olderPng),
+ "192.168.64.2",
+ );
+ recordFaviconForProject(
+ projectRef,
+ favicon("http://192.168.64.3:3000/", 20, newerPng),
+ "192.168.64.3",
+ );
+ recordFaviconForProject(
+ projectRef,
+ favicon("http://192.168.64.4:3000/", 15, olderPng),
+ "192.168.64.4",
+ );
+ const byKey = useBrowserFaviconStore.getState().byKey;
+ for (const host of ["192.168.64.2", "192.168.64.3", "192.168.64.4"]) {
+ expect(lookupFavicon(byKey, projectRef, `http://${host}:3000/`, null)).toBe(newerPng);
+ }
+ expect(byKey["env-1:project-1 http://localhost:3000"]?.capturedAt).toBe(20);
+ });
+
+ it("evicts an icon and its exact host aliases atomically", () => {
+ recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/", 100), "192.168.64.2");
+ for (let index = 1; index < BROWSER_FAVICON_MAX_ENTRIES; index += 1) {
+ recordFaviconForProject(
+ projectRef,
+ favicon(`https://example-${index}.com/`, 100 + index),
+ null,
+ );
+ }
+ expect(
+ lookupFavicon(
+ useBrowserFaviconStore.getState().byKey,
+ projectRef,
+ "http://192.168.64.2:3000/",
+ null,
+ ),
+ ).toBe(PNG);
+
+ recordFaviconForProject(projectRef, favicon("https://evicts-oldest.example/", 1_000), null);
+ expect(
+ lookupFavicon(
+ useBrowserFaviconStore.getState().byKey,
+ projectRef,
+ "http://192.168.64.2:3000/",
+ null,
+ ),
+ ).toBeNull();
+ });
+
+ it("retains multiple origins until project and connection metadata hydrate", () => {
+ expect(
+ recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined),
+ ).toBe(false);
+ expect(
+ recordFaviconForThread(threadRef, favicon("http://localhost:5173/", 2), null, undefined),
+ ).toBe(false);
+ expect(
+ Object.keys(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0] ?? {}),
+ ).toHaveLength(2);
+
+ expect(flushPendingFaviconsForThread(threadRef, projectRef, "192.168.64.2")).toBe(true);
+ expect(Object.keys(useBrowserFaviconStore.getState().byKey).toSorted()).toEqual([
+ "env-1:project-1 http://localhost:3000",
+ "env-1:project-1 http://localhost:5173",
+ ]);
+ expect(useBrowserFaviconStore.getState().pendingByThreadKey).toEqual({});
+ });
+
+ it("persists unambiguous loopback captures while the environment is offline", () => {
+ expect(
+ recordFaviconForThread(
+ threadRef,
+ favicon("http://localhost:3000/", 1),
+ projectRef,
+ undefined,
+ ),
+ ).toBe(true);
+ expect(useBrowserFaviconStore.getState().byKey).toEqual({
+ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 1 },
+ });
+
+ recordFaviconForThread(
+ threadRef,
+ favicon("http://192.168.64.2:5173/", 2),
+ projectRef,
+ undefined,
+ );
+ expect(flushPendingFaviconsForThread(threadRef, projectRef, undefined)).toBe(false);
+ expect(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0]).toBeDefined();
+ });
+
+ it("keeps pending captures in store-owned state independent of bridge lifetime", () => {
+ recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 10), null, undefined);
+ const pendingAfterUnmount = useBrowserFaviconStore.getState().pendingByThreadKey;
+ useBrowserFaviconStore.setState({ pendingByThreadKey: pendingAfterUnmount });
+ flushPendingFaviconsForThread(threadRef, projectRef, "localhost");
+ expect(useBrowserFaviconStore.getState().byKey).toEqual({
+ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 10 },
+ });
+ });
+
+ it("flushes and resolves a pending draft-thread favicon after physical project registration", () => {
+ recordFaviconForThread(threadRef, favicon("http://localhost:8025/", 10), null, undefined);
+ registerFaviconProjectForThread(threadRef, projectRef);
+ const registered = useBrowserFaviconStore.getState().projectRefByThreadKey["env-1:thread-1"];
+ expect(registered).toEqual(projectRef);
+ expect(flushPendingFaviconsForThread(threadRef, registered!, undefined)).toBe(true);
+ expect(
+ lookupFavicon(
+ useBrowserFaviconStore.getState().byKey,
+ registered!,
+ "http://localhost:8025/",
+ null,
+ ),
+ ).toBe(PNG);
+ });
+
+ it("bounds pending memory by origin and thread", () => {
+ for (let thread = 0; thread < 22; thread += 1) {
+ for (let port = 3000; port < 3012; port += 1) {
+ recordFaviconForThread(
+ { environmentId, threadId: ThreadId.make(`thread-${thread}`) },
+ favicon(`http://localhost:${port}/`, port),
+ null,
+ undefined,
+ );
+ }
+ }
+ const pending = useBrowserFaviconStore.getState().pendingByThreadKey;
+ expect(Object.keys(pending)).toHaveLength(20);
+ expect(Object.values(pending).every((byOrigin) => Object.keys(byOrigin).length === 10)).toBe(
+ true,
+ );
+ });
+
+ it("sanitizes hydrated state while preserving actions and transient pending data", () => {
+ recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined);
+ const current = useBrowserFaviconStore.getState();
+ const merged = mergeBrowserFaviconState(
+ {
+ byKey: {
+ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 },
+ "env-1:project-1 http://localhost:3001": { dataUrl: "bad", capturedAt: 3 },
+ "env-1:project-1 http://localhost:3002": { dataUrl: PNG, capturedAt: 1e308 },
+ ["x".repeat(5_000)]: { dataUrl: PNG, capturedAt: 4 },
+ },
+ },
+ current,
+ );
+ expect(merged.byKey).toEqual({
+ "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 },
+ });
+ expect(merged.pendingByThreadKey).toEqual(current.pendingByThreadKey);
+ expect(typeof merged.recordFavicon).toBe("function");
+ });
+
+ it("falls back to memory when localStorage access throws", () => {
+ vi.stubGlobal(
+ "window",
+ Object.defineProperty({}, "localStorage", {
+ get: () => {
+ throw new Error("storage blocked");
+ },
+ }),
+ );
+ const storage = resolveBrowserFaviconStorage();
+ storage.setItem("key", "value");
+ expect(storage.getItem("key")).toBe("value");
+ });
+
+ it("falls back to memory when localStorage operations throw", () => {
+ vi.stubGlobal("window", {
+ localStorage: {
+ getItem: vi.fn(() => {
+ throw new Error("read blocked");
+ }),
+ setItem: vi.fn(() => {
+ throw new Error("quota exceeded");
+ }),
+ removeItem: vi.fn(() => {
+ throw new Error("remove blocked");
+ }),
+ },
+ });
+ const storage = resolveBrowserFaviconStorage();
+
+ storage.setItem("key", "value");
+ expect(storage.getItem("key")).toBe("value");
+ storage.removeItem("key");
+ expect(storage.getItem("key")).toBeNull();
+ });
+
+ it("keeps the memory shadow authoritative after an asymmetric primary write failure", () => {
+ vi.stubGlobal("window", {
+ localStorage: {
+ getItem: vi.fn(() => "stale"),
+ setItem: vi.fn(() => {
+ throw new Error("quota exceeded");
+ }),
+ removeItem: vi.fn(() => {
+ throw new Error("remove blocked");
+ }),
+ },
+ });
+ const storage = resolveBrowserFaviconStorage();
+
+ storage.setItem("key", "fresh");
+ expect(storage.getItem("key")).toBe("fresh");
+ storage.removeItem("key");
+ expect(storage.getItem("key")).toBeNull();
+ });
+});
diff --git a/apps/web/src/browserFaviconStore.ts b/apps/web/src/browserFaviconStore.ts
new file mode 100644
index 000000000000..3354cea50ce7
--- /dev/null
+++ b/apps/web/src/browserFaviconStore.ts
@@ -0,0 +1,342 @@
+import {
+ scopedProjectKey,
+ scopedThreadKey,
+ scopeProjectRef,
+} from "@t3tools/client-runtime/environment";
+import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { useMemo } from "react";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+import { normalizeHostname } from "~/browser/browserTargetResolver";
+import { useThreadShell } from "~/state/entities";
+import { usePreparedConnection } from "~/state/session";
+
+import {
+ BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
+ type BrowserFaviconEntry,
+ canCanonicalizeFaviconWithoutEnvironment,
+ evictExcessFavicons,
+ faviconKey,
+ faviconStorageLocation,
+ isStorableFaviconDataUrl,
+ isValidFaviconCapturedAt,
+ migratePersistedBrowserFaviconState,
+} from "./browserFaviconLogic";
+import { createMemoryStorage, type StateStorage } from "./lib/storage";
+
+const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1";
+const MAX_PENDING_ORIGINS_PER_THREAD = 10;
+const MAX_PENDING_THREADS = 20;
+const MAX_REGISTERED_THREADS = 100;
+
+type PendingFavicon = DesktopPreviewFavicon;
+type PendingFaviconsByOrigin = Record;
+
+export interface BrowserFaviconStoreState {
+ byKey: Record;
+ /** Capture buffering only. */
+ pendingByThreadKey: Record;
+ /** Non-persisted fallback for draft/background threads without a hydrated shell. */
+ projectRefByThreadKey: Record;
+ recordFavicon: (
+ key: string,
+ dataUrl: string,
+ capturedAt: number,
+ aliases?: ReadonlyArray,
+ ) => void;
+}
+
+function pendingOriginKey(pageUrl: string): string | null {
+ return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null;
+}
+
+function addPendingFavicon(
+ pendingByThreadKey: Record,
+ threadKey: string,
+ favicon: PendingFavicon,
+): Record {
+ const originKey = pendingOriginKey(favicon.pageUrl);
+ if (!originKey) return pendingByThreadKey;
+ const current = pendingByThreadKey[threadKey] ?? {};
+ const existing = current[originKey];
+ if (existing && existing.capturedAt >= favicon.capturedAt) return pendingByThreadKey;
+ const nextForThread = {
+ ...current,
+ [originKey]: favicon,
+ };
+ const boundedForThread = Object.fromEntries(
+ Object.entries(nextForThread)
+ .toSorted(([, left], [, right]) => right.capturedAt - left.capturedAt)
+ .slice(0, MAX_PENDING_ORIGINS_PER_THREAD),
+ );
+
+ const withoutThread = { ...pendingByThreadKey };
+ delete withoutThread[threadKey];
+ return Object.fromEntries(
+ [...Object.entries(withoutThread), [threadKey, boundedForThread]].slice(-MAX_PENDING_THREADS),
+ );
+}
+
+export function resolveBrowserFaviconStorage(): StateStorage {
+ const fallback = createMemoryStorage();
+ const shadowedNames = new Set();
+ let primary: Storage;
+ try {
+ if (typeof window === "undefined") return fallback;
+ primary = window.localStorage;
+ } catch {
+ return fallback;
+ }
+ return {
+ getItem: (name) => {
+ if (shadowedNames.has(name)) return fallback.getItem(name);
+ try {
+ return primary.getItem(name);
+ } catch {
+ return fallback.getItem(name);
+ }
+ },
+ setItem: (name, value) => {
+ fallback.setItem(name, value);
+ try {
+ primary.setItem(name, value);
+ shadowedNames.delete(name);
+ } catch {
+ shadowedNames.add(name);
+ }
+ },
+ removeItem: (name) => {
+ fallback.removeItem(name);
+ try {
+ primary.removeItem(name);
+ shadowedNames.delete(name);
+ } catch {
+ shadowedNames.add(name);
+ }
+ },
+ };
+}
+
+export const useBrowserFaviconStore = create()(
+ persist(
+ (set) => ({
+ byKey: {},
+ pendingByThreadKey: {},
+ projectRefByThreadKey: {},
+ recordFavicon: (key, dataUrl, capturedAt, aliases = []) =>
+ set((state) => {
+ if (!isStorableFaviconDataUrl(dataUrl)) return state;
+ if (!isValidFaviconCapturedAt(capturedAt)) return state;
+ const existing = state.byKey[key];
+ const aliasCandidates =
+ capturedAt > (existing?.capturedAt ?? -1)
+ ? [...aliases, ...(existing?.aliases ?? [])]
+ : [...(existing?.aliases ?? []), ...aliases];
+ const nextAliases = [...new Set(aliasCandidates)].slice(
+ 0,
+ BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
+ );
+ const aliasesUnchanged =
+ nextAliases.length === (existing?.aliases?.length ?? 0) &&
+ nextAliases.every((alias, index) => alias === existing?.aliases?.[index]);
+ if (existing && capturedAt <= existing.capturedAt && aliasesUnchanged) return state;
+ return {
+ byKey: evictExcessFavicons({
+ ...state.byKey,
+ [key]: {
+ dataUrl: capturedAt > (existing?.capturedAt ?? -1) ? dataUrl : existing!.dataUrl,
+ capturedAt: Math.max(capturedAt, existing?.capturedAt ?? -1),
+ ...(nextAliases.length > 0 ? { aliases: nextAliases } : {}),
+ },
+ }),
+ };
+ }),
+ }),
+ {
+ name: BROWSER_FAVICON_STORAGE_KEY,
+ version: 1,
+ storage: createJSONStorage(resolveBrowserFaviconStorage),
+ partialize: (state) => ({ byKey: state.byKey }),
+ migrate: migratePersistedBrowserFaviconState,
+ merge: mergeBrowserFaviconState,
+ },
+ ),
+);
+
+export function mergeBrowserFaviconState(
+ persistedState: unknown,
+ currentState: BrowserFaviconStoreState,
+): BrowserFaviconStoreState {
+ return {
+ ...currentState,
+ ...migratePersistedBrowserFaviconState(persistedState),
+ };
+}
+
+export function registerFaviconProjectForThread(
+ threadRef: ScopedThreadRef,
+ projectRef: ScopedProjectRef,
+): void {
+ const threadKey = scopedThreadKey(threadRef);
+ const state = useBrowserFaviconStore.getState();
+ const current = state.projectRefByThreadKey[threadKey];
+ if (
+ current?.environmentId === projectRef.environmentId &&
+ current.projectId === projectRef.projectId
+ ) {
+ return;
+ }
+ useBrowserFaviconStore.setState({
+ projectRefByThreadKey: Object.fromEntries(
+ [
+ ...Object.entries(state.projectRefByThreadKey).filter(([key]) => key !== threadKey),
+ [threadKey, projectRef],
+ ].slice(-MAX_REGISTERED_THREADS),
+ ),
+ });
+}
+
+export function useFaviconProjectRefForThread(threadRef: ScopedThreadRef): ScopedProjectRef | null {
+ const shell = useThreadShell(threadRef);
+ const shellProjectId = shell?.projectId ?? null;
+ const shellProjectRef = useMemo(
+ () => (shellProjectId ? scopeProjectRef(threadRef.environmentId, shellProjectId) : null),
+ [shellProjectId, threadRef.environmentId],
+ );
+ const registered = useBrowserFaviconStore(
+ (state) => state.projectRefByThreadKey[scopedThreadKey(threadRef)] ?? null,
+ );
+ return shellProjectRef ?? registered;
+}
+
+export function recordFaviconForProject(
+ projectRef: ScopedProjectRef,
+ favicon: DesktopPreviewFavicon,
+ environmentHostname: string | null,
+): boolean {
+ if (!isStorableFaviconDataUrl(favicon.dataUrl) || !isValidFaviconCapturedAt(favicon.capturedAt)) {
+ return false;
+ }
+ const location = faviconStorageLocation(
+ scopedProjectKey(projectRef),
+ favicon.pageUrl,
+ environmentHostname,
+ );
+ if (!location) return false;
+ const state = useBrowserFaviconStore.getState();
+ const existing = state.byKey[location.key];
+ if (
+ existing &&
+ existing.capturedAt >= favicon.capturedAt &&
+ location.aliases.every((alias) => existing.aliases?.includes(alias))
+ )
+ return true;
+ state.recordFavicon(location.key, favicon.dataUrl, favicon.capturedAt, location.aliases);
+ return true;
+}
+
+export function recordFaviconForThread(
+ threadRef: ScopedThreadRef,
+ favicon: DesktopPreviewFavicon,
+ projectRef: ScopedProjectRef | null,
+ environmentHostname: string | undefined,
+): boolean {
+ if (
+ !isStorableFaviconDataUrl(favicon.dataUrl) ||
+ !isValidFaviconCapturedAt(favicon.capturedAt) ||
+ !pendingOriginKey(favicon.pageUrl)
+ )
+ return false;
+ const hostname =
+ environmentHostname !== undefined
+ ? environmentHostname
+ : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl)
+ ? null
+ : undefined;
+ if (
+ projectRef &&
+ hostname !== undefined &&
+ recordFaviconForProject(projectRef, favicon, hostname)
+ ) {
+ return true;
+ }
+ const threadKey = scopedThreadKey(threadRef);
+ const state = useBrowserFaviconStore.getState();
+ const pendingByThreadKey = addPendingFavicon(state.pendingByThreadKey, threadKey, favicon);
+ if (pendingByThreadKey !== state.pendingByThreadKey) {
+ useBrowserFaviconStore.setState({ pendingByThreadKey });
+ }
+ return false;
+}
+
+export function flushPendingFaviconsForThread(
+ threadRef: ScopedThreadRef,
+ projectRef: ScopedProjectRef,
+ environmentHostname: string | undefined,
+): boolean {
+ const threadKey = scopedThreadKey(threadRef);
+ const pending = useBrowserFaviconStore.getState().pendingByThreadKey[threadKey];
+ if (!pending) return true;
+ const remaining = Object.fromEntries(
+ Object.entries(pending).filter(([, favicon]) => {
+ const hostname =
+ environmentHostname !== undefined
+ ? environmentHostname
+ : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl)
+ ? null
+ : undefined;
+ return hostname === undefined || !recordFaviconForProject(projectRef, favicon, hostname);
+ }),
+ );
+ useBrowserFaviconStore.setState((state) => {
+ const pendingByThreadKey = { ...state.pendingByThreadKey };
+ if (Object.keys(remaining).length === 0) delete pendingByThreadKey[threadKey];
+ else pendingByThreadKey[threadKey] = remaining;
+ return { pendingByThreadKey };
+ });
+ return Object.keys(remaining).length === 0;
+}
+
+export function useFaviconForThreadUrl(threadRef: ScopedThreadRef, url: string): string | null {
+ const projectRef = useFaviconProjectRefForThread(threadRef);
+ const preparedConnection = usePreparedConnection(threadRef.environmentId);
+ const environmentHostname = Option.isSome(preparedConnection)
+ ? new URL(preparedConnection.value.httpBaseUrl).hostname
+ : null;
+ return useBrowserFaviconStore((state) =>
+ lookupFavicon(state.byKey, projectRef, url, environmentHostname),
+ );
+}
+
+export function lookupFavicon(
+ byKey: Record,
+ projectRef: ScopedProjectRef | null,
+ url: string,
+ environmentHostname: string | null,
+): string | null {
+ const key = projectRef
+ ? faviconKey(scopedProjectKey(projectRef), url, environmentHostname)
+ : null;
+ if (!key) return null;
+ const direct = byKey[key];
+ if (direct) return direct.dataUrl;
+ try {
+ const requestedHost = normalizeHostname(new URL(url).hostname);
+ const localKey = faviconKey(scopedProjectKey(projectRef!), url, requestedHost);
+ const localEntry = localKey ? byKey[localKey] : null;
+ return localEntry?.aliases?.includes(requestedHost) ? localEntry.dataUrl : null;
+ } catch {
+ return null;
+ }
+}
+
+export function resetBrowserFaviconsForTests(): void {
+ useBrowserFaviconStore.setState({
+ byKey: {},
+ pendingByThreadKey: {},
+ projectRefByThreadKey: {},
+ });
+ useBrowserFaviconStore.persist.clearStorage();
+}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index fdc7e7dee382..e459d6d09c7b 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -179,6 +179,7 @@ import {
} from "~/projectScripts";
import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
import { useBrowserHistoryStore } from "~/browserHistoryStore";
+import { registerFaviconProjectForThread } from "~/browserFaviconStore";
import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
import {
@@ -1704,9 +1705,11 @@ function ChatViewContent(props: ChatViewProps) {
});
}, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);
const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
- const activeProjectRef = activeThread
- ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
- : null;
+ const activeProjectRef = useMemo(
+ () =>
+ activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null,
+ [activeThread?.environmentId, activeThread?.projectId],
+ );
const activeProject = useProject(activeProjectRef);
const handleNewThreadInActiveProject = useCallback(() => {
startNewThreadForProject(activeProjectRef, handleNewThread);
@@ -1758,6 +1761,10 @@ function ChatViewContent(props: ChatViewProps) {
// drive the environment picker in BranchToolbar.
const allProjects = useProjects();
const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
+ useEffect(() => {
+ if (!activeThreadRef || !activeProjectRef) return;
+ registerFaviconProjectForThread(activeThreadRef, activeProjectRef);
+ }, [activeProjectRef, activeThreadRef]);
useEffect(() => {
if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return;
// Reuse the sidebar's grouping so history follows the project rows the user
@@ -6546,6 +6553,7 @@ function ChatViewContent(props: ChatViewProps) {
activeSurfaceId={activeRightPanelSurface?.id ?? null}
pendingSurfaceIds={pendingFileSurfaceIds}
previewSessions={activePreviewState.sessions}
+ desktopByTabId={activePreviewState.desktopByTabId}
terminalLabelsById={activeTerminalLabelsById}
onActivate={activateRightPanelSurface}
onCloseSurface={closeRightPanelSurface}
@@ -6580,6 +6588,7 @@ function ChatViewContent(props: ChatViewProps) {
activeSurfaceId={activeRightPanelSurface?.id ?? null}
pendingSurfaceIds={pendingFileSurfaceIds}
previewSessions={activePreviewState.sessions}
+ desktopByTabId={activePreviewState.desktopByTabId}
terminalLabelsById={activeTerminalLabelsById}
onActivate={activateRightPanelSurface}
onCloseSurface={closeRightPanelSurface}
diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx
new file mode 100644
index 000000000000..7312f0b8c651
--- /dev/null
+++ b/apps/web/src/components/RightPanelTabs.test.tsx
@@ -0,0 +1,115 @@
+import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vite-plus/test";
+
+import { RightPanelTabs } from "./RightPanelTabs";
+
+const previewSurface = {
+ id: "browser:tab-1" as const,
+ kind: "preview" as const,
+ resourceId: "tab-1",
+};
+const secondSurface = {
+ id: "browser:tab-2" as const,
+ kind: "preview" as const,
+ resourceId: "tab-2",
+};
+const sessions: Readonly> = {
+ "tab-1": {
+ threadId: "thread-1",
+ tabId: "tab-1",
+ navStatus: { _tag: "Success", url: "http://24x.xf.local/", title: "Local site" },
+ canGoBack: false,
+ canGoForward: false,
+ updatedAt: "2026-08-09T00:00:00.000Z",
+ },
+ "tab-2": {
+ threadId: "thread-1",
+ tabId: "tab-2",
+ navStatus: { _tag: "Success", url: "http://24x.xf.local/admin", title: "Admin" },
+ canGoBack: false,
+ canGoForward: false,
+ updatedAt: "2026-08-09T00:00:00.000Z",
+ },
+};
+
+const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({
+ dataUrl,
+ pageUrl,
+ capturedAt: 1,
+});
+
+function overlay(icon: DesktopPreviewFavicon | null) {
+ return {
+ hasWebContents: true,
+ canGoBack: false,
+ canGoForward: false,
+ loading: false,
+ zoomFactor: 1,
+ pictureInPicture: false,
+ colorScheme: "system" as const,
+ controller: "none" as const,
+ favicon: icon,
+ };
+}
+
+function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon) {
+ return renderToStaticMarkup(
+ undefined}
+ onCloseSurface={() => undefined}
+ onCloseOtherSurfaces={() => undefined}
+ onCloseSurfacesToRight={() => undefined}
+ onCloseAllSurfaces={() => undefined}
+ onCopyFilePath={() => undefined}
+ onAddBrowser={() => undefined}
+ onAddTerminal={() => undefined}
+ onAddPullRequest={() => undefined}
+ onAddDiff={() => undefined}
+ onAddFiles={() => undefined}
+ onAddAgents={() => undefined}
+ liveAgentCount={0}
+ browserAvailable
+ terminalAvailable={false}
+ diffAvailable={false}
+ filesAvailable={false}
+ pullRequestAvailable={false}
+ agentsAvailable={false}
+ >
+ content
+ ,
+ );
+}
+
+describe("RightPanelTabs preview favicon", () => {
+ it("prefers a live capture and never asks Google about a private hostname", () => {
+ const captured = renderTabs(favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"));
+ expect(captured).toContain("data:image/png;base64,AAAA");
+ expect(captured).not.toContain("s2/favicons");
+ expect(renderTabs(null)).not.toContain("s2/favicons");
+ });
+
+ it("keeps route-specific captures isolated between live tabs on one origin", () => {
+ const html = renderTabs(
+ favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"),
+ favicon("data:image/png;base64,BBBB", "http://24x.xf.local/admin"),
+ );
+ expect(html).toContain("data:image/png;base64,AAAA");
+ expect(html).toContain("data:image/png;base64,BBBB");
+ });
+
+ it("hides a capture while the server session still describes another origin", () => {
+ const html = renderTabs(favicon("data:image/png;base64,AAAA", "https://example.com/"));
+ expect(html).not.toContain("data:image/png;base64,AAAA");
+ });
+});
diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx
index 32c66dca6aee..21abd873cb3a 100644
--- a/apps/web/src/components/RightPanelTabs.tsx
+++ b/apps/web/src/components/RightPanelTabs.tsx
@@ -22,6 +22,7 @@ import {
} from "react";
import { isElectron } from "~/env";
+import type { DesktopPreviewOverlay } from "~/previewStateStore";
import type { RightPanelSurface } from "~/rightPanelStore";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
@@ -34,6 +35,7 @@ import { useTheme } from "~/hooks/useTheme";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";
+import { FaviconImage } from "./preview/PreviewFaviconIcon";
import { PierreEntryIcon } from "./chat/PierreEntryIcon";
interface RightPanelTabsProps {
@@ -48,6 +50,7 @@ interface RightPanelTabsProps {
activeSurfaceId: string | null;
pendingSurfaceIds: ReadonlySet;
previewSessions: Readonly>;
+ desktopByTabId: Readonly>;
terminalLabelsById: ReadonlyMap;
onActivate: (surface: RightPanelSurface) => void;
onCloseSurface: (surface: RightPanelSurface) => void;
@@ -435,30 +438,35 @@ function surfaceTitle(
}
}
-function PreviewFavicon({ url }: { url: string | null }) {
- const faviconUrl = faviconUrlForOrigin(url, 32);
- const [failedUrl, setFailedUrl] = useState(null);
- if (!faviconUrl || failedUrl === faviconUrl) return ;
+function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) {
+ const publicProviderUrl = faviconUrlForOrigin(url, 32);
return (
-
setFailedUrl(faviconUrl)}
+ }
+ className="size-3 shrink-0 rounded-sm object-contain"
/>
);
}
+function sameOrigin(left: string, right: string): boolean {
+ try {
+ return new URL(left).origin === new URL(right).origin;
+ } catch {
+ return false;
+ }
+}
+
function SurfaceIcon({
surface,
sessions,
+ desktopByTabId,
theme,
pullRequestStatuses,
}: {
surface: RightPanelSurface;
sessions: Readonly>;
+ desktopByTabId: Readonly>;
theme: "light" | "dark";
pullRequestStatuses: Readonly> | undefined;
}) {
@@ -466,7 +474,10 @@ function SurfaceIcon({
case "preview": {
const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
- return ;
+ const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null;
+ const capturedUrl =
+ favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null;
+ return ;
}
case "diff":
return ;
@@ -636,6 +647,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
diff --git a/apps/web/src/components/preview/PreviewEmptyState.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.test.tsx
index 86cab6dbe2b8..95e21c0266a4 100644
--- a/apps/web/src/components/preview/PreviewEmptyState.test.tsx
+++ b/apps/web/src/components/preview/PreviewEmptyState.test.tsx
@@ -1,4 +1,4 @@
-import { EnvironmentId } from "@t3tools/contracts";
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vite-plus/test";
@@ -19,10 +19,14 @@ const mocks = vi.hoisted(() => ({
vi.mock("./useDiscoveredLocalServers", () => ({
useDiscoveredLocalServers: () => mocks.servers,
}));
+vi.mock("./PreviewFaviconIcon", () => ({
+ PreviewFaviconIcon: () => ,
+}));
import { PreviewEmptyState } from "./PreviewEmptyState";
const environmentId = EnvironmentId.make("env-1");
+const threadRef = { environmentId, threadId: ThreadId.make("thread-1") };
function server(port: number) {
return {
@@ -41,6 +45,7 @@ function server(port: number) {
function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) {
return renderToStaticMarkup(
undefined}
diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx
index 3b9aacf4dfd6..4e74f44cb2aa 100644
--- a/apps/web/src/components/preview/PreviewEmptyState.tsx
+++ b/apps/web/src/components/preview/PreviewEmptyState.tsx
@@ -1,4 +1,4 @@
-import type { EnvironmentId } from "@t3tools/contracts";
+import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import { Globe, History, RadioTower } from "lucide-react";
import type { BrowserHistoryEntry } from "~/browserHistoryStore";
@@ -9,6 +9,7 @@ import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard";
import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers";
interface Props {
+ threadRef: ScopedThreadRef;
environmentId: EnvironmentId;
configuredUrls?: ReadonlyArray | undefined;
recentlySeenUrls?: ReadonlyArray | undefined;
@@ -18,6 +19,7 @@ interface Props {
}
export function PreviewEmptyState({
+ threadRef,
environmentId,
configuredUrls,
recentlySeenUrls,
@@ -60,6 +62,7 @@ export function PreviewEmptyState({
{recents.map((entry) => (
onOpenUrl(entry.url)}
onRemove={() => onRemoveRecent(entry.url)}
@@ -78,6 +81,7 @@ export function PreviewEmptyState({
{servers.map((server) => (
onOpenUrl(server.requestedUrl)}
/>
diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx
new file mode 100644
index 000000000000..d950a99b59fc
--- /dev/null
+++ b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx
@@ -0,0 +1,51 @@
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vite-plus/test";
+
+const mocks = vi.hoisted(() => ({ favicon: null as string | null }));
+
+vi.mock("~/browserFaviconStore", () => ({
+ useFaviconForThreadUrl: () => mocks.favicon,
+}));
+
+import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon";
+
+const threadRef = {
+ environmentId: EnvironmentId.make("env-1"),
+ threadId: ThreadId.make("thread-1"),
+};
+
+describe("preview favicon image", () => {
+ it("renders a captured source before later fallback sources", () => {
+ expect(
+ renderToStaticMarkup(
+ fallback}
+ />,
+ ),
+ ).toContain('src="data:image/png;base64,AAAA"');
+ const captured = "data:image/png;base64,AAAA";
+ const google = "https://public.example/icon";
+ expect(selectFaviconSource([captured, google], new Set())).toBe(captured);
+ expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google);
+ expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull();
+ expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe(
+ "data:image/png;base64,BBBB",
+ );
+ });
+
+ it("uses a stored project icon or falls back to the browser mockup", () => {
+ mocks.favicon = null;
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain("
,
+ );
+ expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"');
+ });
+});
diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.tsx
new file mode 100644
index 000000000000..111facfd82dd
--- /dev/null
+++ b/apps/web/src/components/preview/PreviewFaviconIcon.tsx
@@ -0,0 +1,66 @@
+import type { ScopedThreadRef } from "@t3tools/contracts";
+import { type ReactNode, useState } from "react";
+
+import { useFaviconForThreadUrl } from "~/browserFaviconStore";
+import { cn } from "~/lib/utils";
+
+import { BrowserMockup } from "./BrowserMockup";
+
+export function selectFaviconSource(
+ sources: ReadonlyArray,
+ failed: ReadonlySet,
+): string | null {
+ return sources.find((candidate) => !failed.has(candidate)) ?? null;
+}
+
+export function FaviconImage(props: {
+ sources: ReadonlyArray;
+ fallback: ReactNode;
+ className?: string | undefined;
+}) {
+ const sources = props.sources.filter((source): source is string => Boolean(source));
+ return (
+
+ );
+}
+
+function FaviconImageAttempt(props: {
+ sources: ReadonlyArray;
+ fallback: ReactNode;
+ className?: string | undefined;
+}) {
+ const [failed, setFailed] = useState>(() => new Set());
+ const source = selectFaviconSource(props.sources, failed);
+ if (!source) return props.fallback;
+ return (
+
setFailed((current) => new Set(current).add(source))}
+ />
+ );
+}
+
+export function PreviewFaviconIcon(props: {
+ threadRef: ScopedThreadRef;
+ url: string;
+ className?: string | undefined;
+}) {
+ const source = useFaviconForThreadUrl(props.threadRef, props.url);
+ const fallback = ;
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx
index c7b08ad2893d..1e0f01324424 100644
--- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx
+++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx
@@ -1,12 +1,15 @@
-import { BrowserMockup } from "./BrowserMockup";
+import type { ScopedThreadRef } from "@t3tools/contracts";
+
+import { PreviewFaviconIcon } from "./PreviewFaviconIcon";
import type { PreviewableServer } from "./useDiscoveredLocalServers";
interface Props {
+ threadRef: ScopedThreadRef;
server: PreviewableServer;
onOpen: () => void;
}
-export function PreviewLocalServerCard({ server, onOpen }: Props) {
+export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
const subtitle = describeServer(server);
return (