From d1d15c67f4a5fb82fd8d5e01e5e3b288296789c3 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 12 Sep 2026 12:50:34 -0300 Subject: [PATCH 1/6] feat(sidebar): fold the project scope into the search row (#11315) --- apps/web/src/components/Sidebar.logic.test.ts | 20 +- apps/web/src/components/Sidebar.logic.ts | 8 +- apps/web/src/components/Sidebar.tsx | 191 ++++------------ .../sidebar/SidebarThreadHeader.tsx | 208 ++++++++++++++++++ 4 files changed, 266 insertions(+), 161 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarThreadHeader.tsx diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 33157e7b4b4b..f9a62c40a511 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -828,30 +828,26 @@ describe("filterSidebarProjectScopeItems", () => { { value: "alpha", label: "Alpha workspace" }, { value: "beta", label: "Beta tools" }, ] as const; - const filter = (activeScopeKey: string | null, query: string) => + const filter = (query: string) => filterSidebarProjectScopeItems({ items, - activeScopeKey, query, matches: (item, candidate) => item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()), }); - it("omits the reset row when the sidebar is already unscoped", () => { - expect(filter(null, "")).toEqual(items.slice(1)); + it("shows the default row first while the query is empty", () => { + expect(filter("")).toEqual(items); + expect(filter(" ")).toEqual(items); }); - it("shows the reset row first while a project scope is active", () => { - expect(filter("alpha", "")).toEqual(items); - }); - - it("hides the reset row while filtering an active scope", () => { - expect(filter("alpha", "all")).toEqual([]); + it("hides the default row while filtering", () => { + expect(filter("all")).toEqual([]); }); it("returns matching projects in source order and supports no-match results", () => { - expect(filter(null, "WORK")).toEqual([items[1]]); - expect(filter(null, "missing")).toEqual([]); + expect(filter("WORK")).toEqual([items[1]]); + expect(filter("missing")).toEqual([]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 0150eb473d51..50650ed389dc 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -894,16 +894,12 @@ export function searchSidebarThreads< export function filterSidebarProjectScopeItems(input: { items: readonly TItem[]; - activeScopeKey: string | null; query: string; matches: (item: TItem, query: string) => boolean; }): readonly TItem[] { - const projectItems = input.items.filter((item) => item.value !== "all"); const query = input.query.trim(); - if (query.length > 0) { - return projectItems.filter((item) => input.matches(item, query)); - } - return input.activeScopeKey === null ? projectItems : input.items; + if (query.length === 0) return input.items; + return input.items.filter((item) => item.value !== "all" && input.matches(item, query)); } export interface SidebarProjectScopeMenuState { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c951689230e9..445ae533c323 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -46,12 +46,10 @@ import { CircleDashedIcon, ClockIcon, FolderIcon, - FolderPlusIcon, GitBranchIcon, PinIcon, PinOffIcon, PlusIcon, - SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -218,7 +216,6 @@ import { import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; -import { Input } from "./ui/input"; import { Combobox, ComboboxEmpty, @@ -229,8 +226,9 @@ import { ComboboxTrigger, useComboboxFilter, } from "./ui/combobox"; -import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; +import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { @@ -2370,21 +2368,19 @@ export default function Sidebar() { const projectScopeFilter = useComboboxFilter(); // Filtering derives from the same React state that controls the input, so // the visible query and the visible list can never desync — the peer wiring - // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope - // reset, not a searchable entry: it only shows while a project scope is - // active (there is something to reset) and the query is empty, so it can't - // outrank a project match under autoHighlight and no-hit queries reach the - // empty state. + // in DiffPanel and BranchToolbarBranchSelector. "All projects" is the default + // row, not a searchable entry: it heads the list while the query is empty and + // drops out while filtering, so it can't outrank a project match under + // autoHighlight and no-hit queries reach the empty state. const filteredProjectScopeItems = useMemo( () => filterSidebarProjectScopeItems({ items: projectScopeItems, - activeScopeKey: projectScopeKey, query: projectScopeMenuState.query, matches: (item, query) => projectScopeFilter.contains(item, query, (candidate) => candidate.label), }), - [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query], + [projectScopeFilter, projectScopeItems, projectScopeMenuState.query], ); const scopedProjectGroup = useMemo( () => @@ -2457,6 +2453,8 @@ export default function Sidebar() { }, [isMobile, router, setOpenMobile], ); + // Anchor for the scope popup: the header search field, not its icon trigger. + const headerSearchRef = useRef(null); // Safari can send a click after Ctrl+click opens settings. Ignore that one // selection, then clear the guard when the picker opens again. const suppressNextScopeChangeRef = useRef(false); @@ -4325,100 +4323,11 @@ export default function Sidebar() { fixedHeader={ // Lifted above the stage backdrop, whose fade bleeds below the // header and would otherwise paint across the search row's outline. - -
-
- - { - setThreadSearchQuery(event.currentTarget.value); - setActiveSearchResultIndex(0); - }} - onKeyDown={handleThreadSearchKeyDown} - placeholder="Search threads or PRs" - aria-label="Search threads" - role="combobox" - aria-autocomplete="list" - aria-expanded={isSearchingThreads && threadSearchResults.length > 0} - aria-controls={ - isSearchingThreads && threadSearchResults.length > 0 - ? "sidebar-thread-search-results" - : undefined - } - aria-activedescendant={ - isSearchingThreads && threadSearchResults[activeSearchResultIndex] - ? `sidebar-thread-search-result-${activeSearchResultIndex}` - : undefined - } - className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" - /> - {isSearchingThreads ? ( - - ) : null} -
-
- - - } - > - - - - {projectGroups.length > 1 ? ( - - - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} - - - New thread in current project: Shift+click - {newThreadInProjectShortcutLabel - ? ` (${newThreadInProjectShortcutLabel})` - : ""} - - - ) : newThreadShortcutLabel ? ( - `New thread (${newThreadShortcutLabel})` - ) : ( - "New thread" - )} - - -
-
- {projectGroups.length > 0 ? ( -
+ + 0} + projectScope={ } > {scopedProjectGroup ? ( + // Wrapped so the button's direct-child svg color rule cannot override + // a project's own icon color. ) : ( - + )} - - {scopedProjectGroup?.displayName ?? "All projects"} - - {scopedProjectGroup && showProjectEnvironments ? ( - - ) : null} - - - - } - > - - - New project - -
- ) : null} + } + onNewProject={openAddProjectCommandPalette} + onNewThread={handleNewThreadClick} + newThreadDisabled={projects.length === 0} + newThreadShortcutLabel={newThreadShortcutLabel} + newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} + showNewThreadInProjectHint={projectGroups.length > 1} + searchInputRef={threadSearchInputRef} + searchQuery={threadSearchQuery} + onSearchQueryChange={(value) => { + setThreadSearchQuery(value); + setActiveSearchResultIndex(0); + }} + onSearchKeyDown={handleThreadSearchKeyDown} + isSearching={isSearchingThreads} + searchResultCount={threadSearchResults.length} + activeSearchResultIndex={activeSearchResultIndex} + onClearSearch={clearThreadSearch} + />
} > diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx new file mode 100644 index 000000000000..878235615b39 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -0,0 +1,208 @@ +/** + * The sidebar header: one row holding search, project scope and new thread. + * + * Search owns the row's text and spans it. Project scope collapses to an icon + * that sits with new-project and new-thread as a segmented group at the end. + * The scope icon swaps to the project favicon while a project is selected, + * so the header still names the scope after the row that showed it is gone. + * + * The scope picker itself is passed in: its combobox state lives with the rest + * of the sidebar's scope logic. `searchFieldRef` lands on the search field so + * the picker's popup can anchor to that width rather than to its 28px trigger. + */ +import { FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react"; +import { + type ComponentProps, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, + type RefObject, +} from "react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { SidebarMenuButton } from "../ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export interface SidebarThreadHeaderProps { + /** Lands on the search field so a popup can anchor to its width. */ + searchFieldRef?: RefObject; + /** Without projects there is nothing to scope, so those controls stay out. */ + hasProjects: boolean; + /** The project scope combobox, rendered as the first icon of the group. */ + projectScope: ReactNode; + onNewProject: () => void; + /** Receives the click so Shift+click can skip the project picker. */ + onNewThread: (event: ReactMouseEvent) => void; + newThreadDisabled: boolean; + newThreadShortcutLabel: string | null | undefined; + newThreadInProjectShortcutLabel: string | null | undefined; + /** Shift+click only matters once there is more than one project to pick. */ + showNewThreadInProjectHint: boolean; + searchInputRef: RefObject; + searchQuery: string; + onSearchQueryChange: (value: string) => void; + onSearchKeyDown: (event: ReactKeyboardEvent) => void; + isSearching: boolean; + searchResultCount: number; + activeSearchResultIndex: number; + onClearSearch: () => void; +} + +export function SidebarThreadHeader({ + searchFieldRef, + hasProjects, + projectScope, + onNewProject, + onNewThread, + newThreadDisabled, + newThreadShortcutLabel, + newThreadInProjectShortcutLabel, + showNewThreadInProjectHint, + searchInputRef, + searchQuery, + onSearchQueryChange, + onSearchKeyDown, + isSearching, + searchResultCount, + activeSearchResultIndex, + onClearSearch, +}: SidebarThreadHeaderProps) { + const resultsVisible = isSearching && searchResultCount > 0; + // Results shrink as the query narrows, so the active index can outrun the + // list; pointing aria-activedescendant at a removed option strands the + // screen reader on nothing. + const activeResultExists = resultsVisible && activeSearchResultIndex < searchResultCount; + const newThreadLabel = newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"; + + return ( +
+
+ + onSearchQueryChange(event.currentTarget.value)} + onKeyDown={onSearchKeyDown} + placeholder="Search" + aria-label="Search threads" + role="combobox" + aria-autocomplete="list" + aria-expanded={resultsVisible} + aria-controls={resultsVisible ? "sidebar-thread-search-results" : undefined} + aria-activedescendant={ + activeResultExists + ? `sidebar-thread-search-result-${activeSearchResultIndex}` + : undefined + } + className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-[var(--sidebar-icon-color)]" + /> + {isSearching ? ( + + ) : null} +
+ {/* Segmented well: the icons read as one control instead of three loose + buttons competing with the search field beside them. */} +
+ {hasProjects ? ( + <> + {projectScope} + + + + + ) : null} + + {newThreadLabel} + + New thread in current project: Shift+click + {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""} + + + ) : ( + newThreadLabel + ) + } + disabled={newThreadDisabled} + onClick={onNewThread} + > + + +
+
+ ); +} + +/** + * Icon button with a tooltip, sized for the header's segmented pair. Spreads + * unknown props through so it can serve as a popup trigger's render target, + * which injects its own handlers, ref and aria state. + */ +export function SidebarHeaderIconButton({ + label, + tooltip = label, + className, + children, + ...rest +}: { + /** Accessible name; also the tooltip unless `tooltip` says more. */ + label: string; + tooltip?: ReactNode; + className?: string | undefined; + children?: ReactNode; +} & Omit< + ComponentProps, + "children" | "className" | "tooltip" | "isActive" | "aria-label" +>) { + return ( + + + } + > + {children} + {/* Coarse-pointer hit area, matching the rest of the sidebar chrome. */} + + + {tooltip} + + ); +} From 18d8cbfd920d0a53e5b5206456585aea767e852c Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Sat, 12 Sep 2026 19:29:08 +0200 Subject: [PATCH 2/6] fix(mobile): pin expo-audio so the release smoke patch stays in use (#11426) --- apps/mobile/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a5d2f40b4a93..461e7edf56cf 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -75,7 +75,7 @@ "effect": "catalog:", "expo": "~57.0.18", "expo-asset": "~57.0.15", - "expo-audio": "~57.0.4", + "expo-audio": "57.0.4", "expo-auth-session": "~57.0.10", "expo-blur": "~57.0.2", "expo-build-properties": "~57.0.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f175605d8bf..1e33a5d03964 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,7 +321,7 @@ importers: specifier: ~57.0.15 version: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2) expo-audio: - specifier: ~57.0.4 + specifier: 57.0.4 version: 57.0.4(patch_hash=fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a)(expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.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-auth-session: specifier: ~57.0.10 From 5349522108bec896bb6a69ae76c6e1b8d419ae16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 12 Sep 2026 11:00:58 -0700 Subject: [PATCH 3/6] Delete .pnpm-store/v11 directory --- .pnpm-store/v11/index.db | Bin 8192 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .pnpm-store/v11/index.db diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db deleted file mode 100644 index 7babead3f031028576d0d7b852595e72b103b599..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeIuzpBD86bA4#2p0s=&GDX11#$5OY&BppTCFMSqD0LV@%|C%po4=C;amAox0O=t zfB*y_009U<00Izz00bZafgB3lKCO>xt!CY>pipIV>wEYDQ#G?7q-|A44BRz*ko}y78W!h}e%vF6aP~>|v kx0l=(WA#c7>G359KmY;|fB*y_009U<00Izz00dHje?_k|*#H0l From a43f9b45ae85caf37e0be8270ad3d27365ece2bd Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:02:13 +0300 Subject: [PATCH 4/6] fix(web): preserve snapshot preview size in sent messages (#11429) Co-authored-by: Illia Panasenko Co-authored-by: Julius Marminge --- apps/web/src/components/chat/MessagesTimeline.test.tsx | 1 - apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b94b73219506..d9240b21a133 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -700,7 +700,6 @@ describe("MessagesTimeline", () => { expect(markup).toContain("t3code — Tests"); expect(markup).toContain('src="data:image/png;base64,aWNvbg=="'); expect(markup).toContain("h-28 w-52 max-w-full"); - expect(markup).not.toContain("col-span-2"); expect(onAnchorReady).toHaveBeenCalledOnce(); expect(onAnchorReady).toHaveBeenCalledWith(firstEntry.message.id, 0); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 14cfadac3a8a..da0fc52059e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1569,7 +1569,7 @@ function UserTimelineRow({ row }: { row: Extract From bbedad0278bbf753503184c00e0c09a0eab6679c Mon Sep 17 00:00:00 2001 From: Nelglor Date: Sat, 12 Sep 2026 14:40:48 -0400 Subject: [PATCH 5/6] fix(mobile): render photo library picks to a bounded JPEG off the JS thread (#11440) Co-authored-by: Claude Fable 5.1 --- apps/mobile/package.json | 1 + apps/mobile/src/lib/composerFiles.test.ts | 161 ++++++++++++++++++---- apps/mobile/src/lib/composerImages.ts | 130 ++++++++++++----- docs/user/composer.md | 6 +- pnpm-lock.yaml | 13 ++ 5 files changed, 246 insertions(+), 65 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 461e7edf56cf..0fd35faf7528 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -91,6 +91,7 @@ "expo-glass-effect": "~57.0.1", "expo-haptics": "~57.0.2", "expo-image": "~57.0.3", + "expo-image-manipulator": "~57.0.17", "expo-image-picker": "~57.0.14", "expo-linking": "~57.0.8", "expo-network": "~57.0.1", diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index b38c0813c6a1..962be35013f2 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ open: vi.fn(), size: vi.fn(), readBase64: vi.fn(), + manipulate: vi.fn(), + release: vi.fn(), })); vi.mock("expo-file-system", () => { @@ -80,6 +82,10 @@ vi.mock("expo-file-system", () => { vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile })); +vi.mock("expo-image-manipulator", () => ({ + SaveFormat: { JPEG: "jpeg", PNG: "png", WEBP: "webp" }, + ImageManipulator: { manipulate: mocks.manipulate }, +})); vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); import { @@ -106,20 +112,50 @@ describe("composer file attachments", () => { }); describe("photo library image conversion", () => { - const jpeg = "/9j/2Q=="; + const rendered = { uri: "file:///cache/ImageManipulator/photo.jpg", base64: "/9j/2Q==" }; const photo: ImagePickerAsset = { uri: "file:///picker/photo.heic", type: "image", fileName: "photo.HEIC", mimeType: "image/heic", - fileSize: 20 * 1024 * 1024, - base64: jpeg, - width: 1, - height: 1, + fileSize: 4 * 1024 * 1024, + width: 4032, + height: 3024, + }; + /** + * Stands in for the native manipulator: `size` is what decoding the source yields, + * `resizes` records every requested resize, and `saved` is what saving returns. + */ + const native = { + size: { width: 1, height: 1 }, + resizes: [] as Array<{ width?: number | null; height?: number | null }>, + saved: rendered as { uri: string; base64?: string }, }; + beforeEach(() => { + native.size = { width: 1, height: 1 }; + native.resizes = []; + native.saved = rendered; + mocks.manipulate.mockReset(); + mocks.release.mockReset(); + mocks.manipulate.mockImplementation(() => { + const context = { + resize(size: { width?: number | null; height?: number | null }) { + native.resizes.push(size); + return context; + }, + renderAsync: async () => ({ + ...native.size, + release: mocks.release, + saveAsync: async () => native.saved, + }), + }; + return context; + }); + }); + it.each(["image/heic", "image/heif", undefined])( - "attaches the native JPEG conversion with matching metadata when the source MIME is %s", + "renders a %s photo to JPEG natively and previews the rendered file", async (mimeType) => { mocks.pickMedia.mockResolvedValue({ canceled: false, @@ -128,6 +164,9 @@ describe("composer file attachments", () => { const result = await pickComposerImages({ existingCount: 0 }); + expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ base64: false })); + expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); + expect(mocks.readBase64).not.toHaveBeenCalled(); expect(result).toEqual({ images: [ { @@ -136,8 +175,8 @@ describe("composer file attachments", () => { name: "photo.jpg", mimeType: "image/jpeg", sizeBytes: 4, - dataUrl: `data:image/jpeg;base64,${jpeg}`, - previewUri: `data:image/jpeg;base64,${jpeg}`, + dataUrl: `data:image/jpeg;base64,${rendered.base64}`, + previewUri: rendered.uri, }, ], error: null, @@ -145,11 +184,26 @@ describe("composer file attachments", () => { }, ); + it.each([ + { size: { width: 4032, height: 3024 }, resizes: [{ width: 2048 }] }, + { size: { width: 3024, height: 4032 }, resizes: [{ height: 2048 }] }, + { size: { width: 2048, height: 1536 }, resizes: [] }, + ])("bounds a $size.width x $size.height photo to a 2048 px longest edge", async (input) => { + native.size = input.size; + mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); + + await pickComposerImages({ existingCount: 0 }); + + expect(native.resizes).toEqual(input.resizes); + // Every decoded bitmap is released, including the full-size one a resize replaces. + expect(mocks.release).toHaveBeenCalledTimes(input.resizes.length + 1); + }); + it.each([ { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" }, - ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => { + ])("keeps original $extension bytes from the picker file", async (original) => { const name = `photo.${original.extension}`; mocks.pickMedia.mockResolvedValue({ canceled: false, @@ -159,6 +213,8 @@ describe("composer file attachments", () => { const result = await pickComposerImages({ existingCount: 0 }); + expect(mocks.manipulate).not.toHaveBeenCalled(); + expect(mocks.readBase64).toHaveBeenCalledWith(photo.uri); expect(result.error).toBeNull(); expect(result.images).toEqual([ expect.objectContaining({ @@ -166,37 +222,74 @@ describe("composer file attachments", () => { mimeType: original.mimeType, dataUrl: `data:${original.mimeType};base64,${original.base64}`, sizeBytes: Buffer.from(original.base64, "base64").byteLength, + previewUri: photo.uri, }), ]); }); - it("checks the converted JPEG size even when the HEIC source was smaller", async () => { - const oversized = - jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4); + it("renders a supported original that exceeds the image limit instead of rejecting it", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, - assets: [{ ...photo, fileSize: 42, base64: oversized }], + assets: [{ ...photo, fileName: "photo.jpg", mimeType: "image/jpeg" }], }); + mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); - await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ - images: [], - error: "'photo.HEIC' exceeds the 10 MB attachment limit.", + const result = await pickComposerImages({ existingCount: 0 }); + + expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); + expect(result.error).toBeNull(); + expect(result.images).toEqual([ + expect.objectContaining({ name: "photo.jpg", mimeType: "image/jpeg", sizeBytes: 4 }), + ]); + }); + + it("measures the picker file instead of trusting the reported size", async () => { + // A content stream can deliver more bytes than the picker advertises; a supported + // original only skips rendering when the file itself measures within the limit. + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: "photo.png", mimeType: "image/png", fileSize: 42 }], }); + mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(mocks.readBase64).not.toHaveBeenCalled(); + expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); + expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]); }); - it("does not relabel unconverted HEIC bytes as JPEG", async () => { + it("renders a supported original whose size cannot be measured", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, - assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }], + assets: [ + { ...photo, uri: "content://media/1", fileName: "photo.png", mimeType: "image/png" }, + ], }); const result = await pickComposerImages({ existingCount: 0 }); - expect(result.images).toEqual([]); - expect(result.error).toContain("not a supported image type"); + expect(mocks.readBase64).not.toHaveBeenCalled(); + expect(mocks.manipulate).toHaveBeenCalledWith("content://media/1"); + expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]); }); - it("retains a converted photo when another original cannot be read", async () => { + it("checks the rendered JPEG against the image limit", async () => { + native.saved = { + uri: rendered.uri, + base64: + rendered.base64.slice(0, 4) + + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4), + }; + mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); + + await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ + images: [], + error: "'photo.HEIC' exceeds the 10 MB attachment limit.", + }); + }); + + it("retains a rendered photo when another original cannot be read", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo], @@ -208,6 +301,21 @@ describe("composer file attachments", () => { expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]); expect(result.error).toBe("Failed to read 'missing.gif'."); }); + + it("reports a photo the native renderer cannot decode", async () => { + mocks.manipulate.mockImplementation(() => ({ + resize: () => { + throw new Error("unreachable"); + }, + renderAsync: () => Promise.reject(new Error("corrupt")), + })); + mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); + + await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ + images: [], + error: "Failed to read 'photo.HEIC'.", + }); + }); }); describe("photo library videos", () => { @@ -217,10 +325,13 @@ describe("composer file attachments", () => { fileName: "photo.png", mimeType: "image/png", fileSize: 3, - base64: "YWJj", width: 1, height: 1, }; + + beforeEach(() => { + mocks.readBase64.mockResolvedValue("YWJj"); + }); const video: ImagePickerAsset = { uri: "file:///picker/clip.mov", type: "video", @@ -234,7 +345,9 @@ describe("composer file attachments", () => { it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); - mocks.size.mockReturnValue(video.fileSize); + mocks.size.mockImplementation((uri: string) => + uri.endsWith("clip.mov") ? video.fileSize : 3, + ); const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 }); @@ -345,7 +458,7 @@ describe("composer file attachments", () => { canceled: false, assets: [{ ...video, fileSize: reported }, image], }); - mocks.size.mockReturnValue(stored); + mocks.size.mockImplementation((uri: string) => (uri.endsWith("clip.mov") ? stored : 3)); const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 5a45825c3a43..86658db8dc3b 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -307,6 +307,46 @@ export async function pickComposerFiles(input: { return { files: attachments, error }; } +/** + * Longest edge kept when a photo has to be re-encoded. Matches the web composer's + * MAX_DIMENSION so every client hands providers the same resolution. + */ +const PHOTO_MAX_EDGE = 2048; +const PHOTO_JPEG_QUALITY = 0.85; + +/** + * Renders a photo-library pick to a provider-readable JPEG. Decode, downscale, and encode run + * natively; only the bounded result crosses the bridge. Camera photos are 12-48 MP HEIC files, + * so a full-size conversion is both slow to transfer and far more than a model can use. + */ +async function renderPhotoAsJpeg(uri: string): Promise<{ base64: string; uri: string }> { + const { ImageManipulator, SaveFormat } = await import("expo-image-manipulator"); + let image = await ImageManipulator.manipulate(uri).renderAsync(); + try { + const longestEdge = Math.max(image.width, image.height); + if (longestEdge > PHOTO_MAX_EDGE) { + const resized = await ImageManipulator.manipulate(image) + .resize( + image.width >= image.height ? { width: PHOTO_MAX_EDGE } : { height: PHOTO_MAX_EDGE }, + ) + .renderAsync(); + image.release(); + image = resized; + } + const saved = await image.saveAsync({ + format: SaveFormat.JPEG, + compress: PHOTO_JPEG_QUALITY, + base64: true, + }); + if (!saved.base64) { + throw new Error("The rendered photo has no bytes."); + } + return { base64: saved.base64, uri: saved.uri }; + } finally { + image.release(); + } +} + async function loadImagePicker() { try { return await import("expo-image-picker"); @@ -369,7 +409,10 @@ export async function pickComposerMedia(input: { mediaTypes: input.maxVideoBytes === undefined ? ["images"] : ["images", "videos"], allowsMultipleSelection: true, selectionLimit: remainingSlots, - base64: true, + // Bytes stay in the picker's file until we know how much of them we need. Asking for + // base64 here made iOS decode and re-encode every camera photo at full resolution and + // hand JS a 10 MB+ string, which stalled the composer for seconds. + base64: false, quality: 1, shouldDownloadFromNetwork: true, }); @@ -397,7 +440,7 @@ export async function pickComposerMedia(input: { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; break; } - let mimeType = asset.mimeType?.toLowerCase(); + const mimeType = asset.mimeType?.toLowerCase(); if (asset.type === "video" || mimeType?.startsWith("video/")) { if (input.maxVideoBytes === undefined) { error = "Video attachments are unavailable here."; @@ -426,56 +469,67 @@ export async function pickComposerMedia(input: { continue; } - let base64 = asset.base64; - if (!base64) { - error = `Failed to read '${asset.fileName ?? "image"}'.`; - continue; + const name = asset.fileName?.trim() || "image"; + // The picker's reported size is a hint, not a measurement: Android content streams can + // deliver more bytes than they advertise. Only a size read from the file itself decides + // whether the original bytes are safe to load into JS. + let sourceBytes: number | null = null; + try { + const { File } = await import("expo-file-system"); + sourceBytes = new File(asset.uri).size; + } catch { + sourceBytes = null; } - - let name = asset.fileName?.trim() || "image"; - // The iOS picker returns JPEG base64 even when its metadata describes HEIC, - // PNG, or GIF. Keep supported originals so transparency and animation survive; - // use the native JPEG conversion for formats providers cannot accept. - if (base64.startsWith("/9j/")) { - if ( - mimeType && - mimeType !== "image/jpeg" && - isProviderSendTurnSupportedImageMimeType(mimeType) - ) { - try { - const { File } = await import("expo-file-system"); - base64 = await new File(asset.uri).base64(); - } catch { - error = `Failed to read '${name}'.`; - continue; - } + // Originals the provider can read and that fit the cap pass through byte for byte so + // transparency and animation survive. Everything else (HEIC/HEIF, oversized JPEGs, + // unmeasurable sources) is rendered to a bounded JPEG off the JS thread. + const originalMimeType = + mimeType !== undefined && + isProviderSendTurnSupportedImageMimeType(mimeType) && + sourceBytes !== null && + sourceBytes > 0 && + sourceBytes <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + ? mimeType + : null; + + let image: { base64: string; mimeType: string; name: string; previewUri: string }; + try { + if (originalMimeType !== null) { + const { File } = await import("expo-file-system"); + image = { + base64: await new File(asset.uri).base64(), + mimeType: originalMimeType, + name, + previewUri: asset.uri, + }; } else { - mimeType = "image/jpeg"; - if (!/\.jpe?g$/i.test(name)) { - name = `${name.replace(/\.[^.]+$/, "")}.jpg`; - } + const rendered = await renderPhotoAsJpeg(asset.uri); + image = { + base64: rendered.base64, + mimeType: "image/jpeg", + name: /\.jpe?g$/i.test(name) ? name : `${name.replace(/\.[^.]+$/, "")}.jpg`, + previewUri: rendered.uri, + }; } - } - if (!mimeType || !isProviderSendTurnSupportedImageMimeType(mimeType)) { - error = `'${name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; + } catch { + error = `Failed to read '${name}'.`; continue; } - const sizeBytes = estimateBase64ByteSize(base64); + const sizeBytes = estimateBase64ByteSize(image.base64); if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; + error = `'${name}' exceeds the 10 MB attachment limit.`; continue; } - const dataUrl = `data:${mimeType};base64,${base64}`; attachments.push({ id: uuidv4(), type: "image", - name, - mimeType, + name: image.name, + mimeType: image.mimeType, sizeBytes, - dataUrl, - previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl, + dataUrl: `data:${image.mimeType};base64,${image.base64}`, + previewUri: image.previewUri, }); } diff --git a/docs/user/composer.md b/docs/user/composer.md index a49b147a30af..186bbf034aa0 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -17,9 +17,9 @@ message can send. Retry or remove a failed upload. On web and desktop, reloading before an upload finishes requires you to attach that file again. You can drag or paste images into the web or desktop composer. HEIC and HEIF -photos are converted to JPEG there and when selected from the iOS photo library; -the image limit applies after conversion. On mobile, you can also send files to -T3 Code through another app's system share sheet. +photos are converted to JPEG there and when selected from the mobile photo +library; photos over the image limit are also resized to fit. On mobile, you can +also send files to T3 Code through another app's system share sheet. See [images and videos](#images-and-videos-in-messages) for previewing and saving media. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e33a5d03964..a8dbaf396924 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,6 +368,9 @@ importers: expo-image: specifier: ~57.0.3 version: 57.0.3(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.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-image-manipulator: + specifier: ~57.0.17 + version: 57.0.17(expo@57.0.18) expo-image-picker: specifier: ~57.0.14 version: 57.0.14(expo@57.0.18) @@ -7443,6 +7446,11 @@ packages: peerDependencies: expo: '*' + expo-image-manipulator@57.0.17: + resolution: {integrity: sha512-VpM8qAotTeSIobIAL8RAIDrZKL0jQBK/6O41NnwmdT96sd2wT263DlTbkvQ6RkcFrR9+NZiC5Um7FDBK+jFneg==} + peerDependencies: + expo: '*' + expo-image-picker@57.0.14: resolution: {integrity: sha512-NK9XBQqOtscbB/uRts1Gm7Oki6odMN3FQPWD6fSKmNUfKM69O6kcgU25lEXFTSLZ7sLl+AopHa2X0uILd8rjaQ==} peerDependencies: @@ -17957,6 +17965,11 @@ snapshots: dependencies: expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo-image-manipulator@57.0.17(expo@57.0.18): + dependencies: + expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) + expo-image-loader: 57.0.1(expo@57.0.18) + expo-image-picker@57.0.14(expo@57.0.18): dependencies: expo: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) From c542b781c6c4c766e66dcb764b2142d95600e890 Mon Sep 17 00:00:00 2001 From: akiraueno Date: Sun, 13 Sep 2026 04:07:33 +0900 Subject: [PATCH 6/6] fix(desktop): keep the native preview User-Agent so Turnstile passes (#7110) --- .../src/preview/BrowserSession.test.ts | 40 ++++++++++++++++++- apps/desktop/src/preview/BrowserSession.ts | 11 ++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index aaf34c3578f9..45000736c558 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -13,7 +13,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ { readonly clearCache: ReturnType; readonly clearStorageData: ReturnType; - readonly getUserAgent: ReturnType; + readonly getUserAgent: ReturnType string>>; readonly setPermissionRequestHandler: ReturnType; readonly setPermissionCheckHandler: ReturnType; readonly setUserAgent: ReturnType; @@ -102,6 +102,44 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + // A rewritten session UA — any variant, even ones that keep the Electron + // token — makes Cloudflare Turnstile loop with error 600010 (#5002), so + // the guest must end up with Electron's native User-Agent. The mock applies + // setUserAgent calls, so this fails on any reintroduced rewrite while still + // permitting a harmless re-set of the unchanged native string. + it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => + Effect.gen(function* () { + // Electron's real UA shape: app token, then Chrome, then Electron, then + // Safari — the token order and casing matter to any strip regex. + const nativeUserAgent = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36"; + fromPartition.mockReset(); + fromPartition.mockImplementation((partition: string) => { + let userAgent = nativeUserAgent; + const browserSession = { + clearCache: vi.fn(() => Promise.resolve()), + clearStorageData: vi.fn(() => Promise.resolve()), + getUserAgent: vi.fn(() => userAgent), + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setUserAgent: vi.fn((next: string) => { + userAgent = next; + }), + }; + sessions.set(partition, browserSession); + return browserSession; + }); + + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + assert.strictEqual(browserSession.getUserAgent(), nativeUserAgent); + }).pipe(Effect.provide(layer)), + ); + it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 13ec5682e582..930c13990d51 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -197,11 +197,12 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); - const userAgent = browserSession - .getUserAgent() - .replace(/Electron\/[\d.]+ /, "") - .replace(/\s*t3code\/[\d.]+/, ""); - browserSession.setUserAgent(userAgent); + // The guest keeps Electron's native User-Agent. Rewriting it in any + // form — even variants that keep the Electron token — makes Cloudflare + // Turnstile fail its integrity check with error 600010 and recreate + // the challenge every few seconds, so logins behind it never complete + // (#5002). Re-setting the unchanged native string is harmless, so it + // is the rewritten string itself that trips the check. browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); });