diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db deleted file mode 100644 index 7babead3f031..000000000000 Binary files a/.pnpm-store/v11/index.db and /dev/null differ 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)); }); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a5d2f40b4a93..0fd35faf7528 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", @@ -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/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/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 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} + + ); +} 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 9f175605d8bf..a8dbaf396924 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 @@ -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)