History

Merge branch 'main' into feat/open-source-licenses

Julius Marminge committed 2 months ago

Commit ca7a2542 parents b56190e 85b656f43 files · +1280 −245Browse files

Unified · Split

43 files
  1. .coderabbit.yaml +2 −0
  2. apps/mobile/app.config.ts +1 −0
  3. apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +34 −2
  4. apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift +165 −0
  5. apps/mobile/src/components/ComposerAttachmentStrip.tsx +67 −36
  6. apps/mobile/src/components/FilePreview.ios.tsx +43 −0
  7. apps/mobile/src/components/FilePreview.tsx +52 −0
  8. apps/mobile/src/components/FilePreviewModal.tsx +93 −0
  9. apps/mobile/src/components/VideoPreviewModal.ios.tsx +2 −2
  10. apps/mobile/src/components/VideoPreviewModal.tsx +2 −2
  11. apps/mobile/src/components/VideoThumbnailImage.tsx +2 −2
  12. apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +30 −1
  13. apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +26 −23
  14. apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +4 −11
  15. apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +21 −5
  16. apps/mobile/src/features/threads/ThreadComposer.tsx +10 −17
  17. apps/mobile/src/features/threads/ThreadFeed.tsx +152 −79
  18. apps/mobile/src/lib/composerFiles.test.ts +112 −0
  19. apps/mobile/src/lib/composerImages.ts +37 −11
  20. apps/mobile/src/lib/filePreview.test.ts +17 −0
  21. apps/mobile/src/lib/filePreview.ts +6 −0
  22. apps/mobile/src/lib/localAttachmentPreview.test.ts +23 −10
  23. apps/mobile/src/lib/localAttachmentPreview.ts +3 −3
  24. apps/server/src/server.test.ts +37 −1
  25. apps/server/src/server.ts +7 −0
  26. apps/server/src/usage/usageScanCache.test.ts +14 −0
  27. apps/server/src/usage/usageScanCache.ts +12 −1
  28. apps/web/src/components/BranchToolbar.tsx +7 −5
  29. apps/web/src/components/BranchToolbarEnvModeSelector.tsx +14 −9
  30. apps/web/src/components/chat/ChatHeader.tsx +7 −4
  31. apps/web/src/components/files/FileBrowserPanel.tsx +39 −2
  32. apps/web/src/components/files/fileTreeExpansion.test.ts +60 −0
  33. apps/web/src/components/files/fileTreeExpansion.ts +55 −0
  34. apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +20 −0
  35. apps/web/src/components/settings/ConnectionsSettings.logic.ts +8 −0
  36. apps/web/src/components/settings/ConnectionsSettings.tsx +11 −5
  37. apps/web/src/components/settings/SettingsSidebarNav.tsx +22 −1
  38. apps/web/src/components/settings/settingsSearch.test.ts +11 −0
  39. apps/web/src/components/settings/settingsSearch.ts +13 −0
  40. docs/internals/mobile-navigation.md +22 −5
  41. docs/user/composer.md +9 −2
  42. packages/shared/src/shell.test.ts +5 −5
  43. packages/shared/src/shell.ts +3 −1
.coderabbit.yaml+2−0
@@ -1,2 +1,4 @@
11 reviews:
22 review_status: false
3+ auto_review:
4+ enabled: false
apps/mobile/app.config.ts+1−0
@@ -206,6 +206,7 @@ const config: ExpoConfig = { · 205 unmodified lines
206206 },
207207 NSLocalNetworkUsageDescription:
208208 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",
209+ NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.",
209210 ITSAppUsesNonExemptEncryption: false,
210211 // The App Store screenshot harness rotates the iPad interface from
211212 // inside the app (CI denies osascript the Accessibility access that
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+34−2
@@ -5,6 +5,7 @@ import UIKit · 4 unmodified lines
55 public final class T3NativeControlsModule: Module {
66 private let presentationSources = T3PresentationSources()
77 private var videoPresentation: T3NativeVideoPresentation?
8+ private var filePresentation: T3NativeFilePresentation?
89
910 public func definition() -> ModuleDefinition {
1011 Name("T3NativeControls")
@@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module { · 12 unmodified lines
2324 self.dismissVideo(identifier: identifier)
2425 }.runOnQueue(.main)
2526
27+ AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
28+ try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier,
29+ identifier: identifier, promise: promise)
30+ }.runOnQueue(.main)
31+
32+ AsyncFunction("dismissFile") { (identifier: String) in
33+ self.dismissFile(identifier: identifier)
34+ }.runOnQueue(.main)
35+
2636 OnDestroy {
2737 let presentation = self.videoPresentation
28− DispatchQueue.main.async { presentation?.dismiss() }
38+ let file = self.filePresentation
39+ DispatchQueue.main.async {
40+ presentation?.dismiss()
41+ file?.dismiss()
42+ }
2943 }
3044
3145 View(T3PresentationSourceView.self) {
@@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module { · 108 unmodified lines
140154 let isPlayableURL = url.isFileURL
141155 ? FileManager.default.isReadableFile(atPath: url.path)
142156 : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil)
143− guard videoPresentation == nil,
157+ guard videoPresentation == nil, filePresentation == nil,
144158 let presenter = appContext?.utilities?.currentViewController(),
145159 isPlayableURL
146160 else {
@@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module { · 15 unmodified lines
162176 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }
163177 }
164178
179+ private func presentFile(url: URL, title: String, sourceIdentifier: String,
180+ identifier: String, promise: Promise) throws {
181+ guard filePresentation == nil, videoPresentation == nil,
182+ let presenter = appContext?.utilities?.currentViewController()
183+ else { throw URLError(.cannotLoadFromNetwork) }
184+ let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources,
185+ sourceIdentifier: sourceIdentifier) { [weak self] error in
186+ self?.filePresentation = nil
187+ if let error { promise.reject(error) } else { promise.resolve(nil) }
188+ }
189+ filePresentation = file
190+ file.present(url: url, title: title, from: presenter)
191+ }
192+
193+ private func dismissFile(identifier: String) {
194+ if filePresentation?.identifier == identifier { filePresentation?.dismiss() }
195+ }
196+
165197 private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws {
166198 guard let presenter = appContext?.utilities?.currentViewController() else {
167199 throw NSError(
apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift+165−0
@@ -0,0 +1,165 @@
1+import ImageIO
2+import QuickLook
3+import UIKit
4+import UniformTypeIdentifiers
5+
6+private final class FilePreviewItem: NSObject, QLPreviewItem {
7+ var previewItemURL: URL?
8+ var previewItemTitle: String?
9+}
10+
11+private final class FilePreviewController: QLPreviewController {
12+ var onAppear: (() -> Void)?
13+
14+ override func viewDidAppear(_ animated: Bool) {
15+ super.viewDidAppear(animated)
16+ onAppear?()
17+ }
18+}
19+
20+/// Quick Look owns image and document controls, zooming, and source-view transitions.
21+final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource,
22+ QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate {
23+ let identifier: String
24+ private var controller: UIViewController?
25+ private let completion: (Error?) -> Void
26+ private weak var sources: T3PresentationSources?
27+ private let sourceIdentifier: String
28+ private let item = FilePreviewItem()
29+ private var loading: Task<Void, Never>?
30+ private var dismissRequested = false
31+ private var finished = false
32+
33+ init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) {
34+ self.identifier = identifier
35+ self.sources = sources
36+ self.sourceIdentifier = sourceIdentifier
37+ self.completion = completion
38+ super.init()
39+ }
40+
41+ func present(url: URL, title: String, from presenter: UIViewController) {
42+ loading = Task { @MainActor [self] in
43+ do {
44+ let file = try await Self.prepareFile(url: url, title: title)
45+ guard !finished, !Task.isCancelled else {
46+ try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
47+ return
48+ }
49+ item.previewItemURL = file
50+ item.previewItemTitle = title
51+ let preview = FilePreviewController()
52+ preview.delegate = self
53+ preview.dataSource = self
54+ preview.onAppear = { [weak self] in self?.resumePendingDismissal() }
55+ controller = preview
56+ presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in
57+ resumePendingDismissal()
58+ }
59+ preview.presentationController?.delegate = self
60+ } catch {
61+ finish(error: error)
62+ }
63+ }
64+ }
65+
66+ func dismiss() {
67+ dismissRequested = true
68+ loading?.cancel()
69+ guard !finished else { return }
70+ guard let controller else { finish(); return }
71+ // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal.
72+ // Starting a second modal transition while UIKit is settling the first can strand it.
73+ guard !controller.isBeingPresented, !controller.isBeingDismissed else { return }
74+ controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() }
75+ }
76+
77+ private func resumePendingDismissal() {
78+ // Appearance callbacks run before UIKit has cleared the current transition.
79+ DispatchQueue.main.async { [weak self] in
80+ if self?.dismissRequested == true { self?.dismiss() }
81+ }
82+ }
83+
84+ func numberOfPreviewItems(in controller: QLPreviewController) -> Int { item.previewItemURL == nil ? 0 : 1 }
85+
86+ func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
87+ item
88+ }
89+
90+ func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? {
91+ guard !UIAccessibility.isReduceMotionEnabled else { return nil }
92+ return sources?.view(for: sourceIdentifier)
93+ }
94+
95+ func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem,
96+ inSourceView view: AutoreleasingUnsafeMutablePointer<UIView?>) -> CGRect {
97+ guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero }
98+ view.pointee = source
99+ return source.bounds
100+ }
101+
102+ func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() }
103+
104+ func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() }
105+
106+ private func finish(error: Error? = nil) {
107+ guard !finished else { return }
108+ finished = true
109+ loading?.cancel()
110+ loading = nil
111+ if let file = item.previewItemURL {
112+ try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
113+ }
114+ item.previewItemURL = nil
115+ DispatchQueue.main.async { [completion] in completion(error) }
116+ }
117+
118+ /// Copy original bytes so preview and sharing do not mutate a draft or workspace file.
119+ nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL {
120+ try Task.checkCancellation()
121+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)")
122+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
123+ do {
124+ let download = directory.appendingPathComponent("original")
125+ if url.isFileURL {
126+ try FileManager.default.copyItem(at: url, to: download)
127+ } else if url.scheme == "data" {
128+ try Data(contentsOf: url).write(to: download, options: .atomic)
129+ } else {
130+ guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else {
131+ throw URLError(.unsupportedURL)
132+ }
133+ let (temporaryFile, response) = try await URLSession.shared.download(from: url)
134+ guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else {
135+ throw URLError(.badServerResponse)
136+ }
137+ try FileManager.default.moveItem(at: temporaryFile, to: download)
138+ }
139+ try Task.checkCancellation()
140+ let type: UTType
141+ if let image = CGImageSourceCreateWithURL(download as CFURL, nil),
142+ CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image),
143+ let detectedType = UTType(imageType as String) {
144+ type = detectedType
145+ } else if CGPDFDocument(download as CFURL) != nil {
146+ type = .pdf
147+ } else {
148+ throw URLError(.cannotDecodeContentData)
149+ }
150+ let filename = URL(fileURLWithPath: title).lastPathComponent as NSString
151+ let originalExtension = filename.pathExtension
152+ let fileExtension = UTType(filenameExtension: originalExtension) == type
153+ ? originalExtension : type.preferredFilenameExtension ?? "png"
154+ let stem = filename.deletingPathExtension
155+ var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_")
156+ while name.utf8.count > 200 { name.removeLast() }
157+ let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)")
158+ try FileManager.default.moveItem(at: download, to: file)
159+ return file
160+ } catch {
161+ try? FileManager.default.removeItem(at: directory)
162+ throw error
163+ }
164+ }
165+}
apps/mobile/src/components/ComposerAttachmentStrip.tsx+67−36
@@ -6,15 +6,18 @@ import { Alert, Image, Pressable, ScrollView, View } from "react-native"; · 5 unmodified lines
66 import { AppText as Text } from "./AppText";
77 import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
88 import { VideoAttachmentTile } from "./VideoAttachmentTile";
9−import { loadLocalVideoPreview } from "../lib/localVideoPreview";
9+import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
10+import { PresentationSource } from "./NativePresentation";
11+import type { FilePreviewSource } from "./FilePreviewModal";
12+import { isPdfFile } from "../lib/filePreview";
1013
1114 export interface ComposerAttachmentStripProps {
1215 /** Attachments to display. */
1316 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
1417 /** Called when the user removes an attachment. */
1518 readonly onRemove: (imageId: string) => void;
16− /** Called when the user taps on an image thumbnail to preview it. */
17− readonly onPressImage?: (previewUri: string) => void;
19+ /** Called when the user taps an image or PDF to preview it. */
20+ readonly onPressPreview?: (source: FilePreviewSource) => void;
1821 readonly onPressVideo?: (
1922 attachment: DraftComposerFileAttachment,
2023 sourceIdentifier: string,
@@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: { · 11 unmodified lines
3235 readonly size: number;
3336 readonly borderRadius: number;
3437 readonly compact?: boolean;
35− readonly onPressImage?: (previewUri: string) => void;
38+ readonly onPressPreview?: (source: FilePreviewSource) => void;
3639 readonly onPressVideo?: (
3740 attachment: DraftComposerFileAttachment,
3841 sourceIdentifier: string,
@@ -41,17 +44,30 @@ export function ComposerAttachmentThumbnail(props: { · 2 unmodified lines
4144 const { attachment } = props;
4245 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
4346 if (attachment.type === "image") {
47+ const sourceIdentifier = `draft-image:${attachment.id}`;
4448 return (
45− <Pressable
46− onPress={props.onPressImage ? () => props.onPressImage?.(attachment.previewUri) : undefined}
47− >
48− <Image
49− source={{ uri: attachment.previewUri }}
50− style={style}
51− className="bg-subtle"
52− resizeMode="cover"
53− />
54− </Pressable>
49+ <PresentationSource identifier={sourceIdentifier}>
50+ <Pressable
51+ accessibilityRole="imagebutton"
52+ accessibilityLabel={`Open ${attachment.name}`}
53+ disabled={!props.onPressPreview}
54+ onPress={() =>
55+ props.onPressPreview?.({
56+ kind: "image",
57+ uri: attachment.dataUrl,
58+ name: attachment.name,
59+ sourceIdentifier,
60+ })
61+ }
62+ >
63+ <Image
64+ source={{ uri: attachment.previewUri }}
65+ style={style}
66+ className="bg-subtle"
67+ resizeMode="cover"
68+ />
69+ </Pressable>
70+ </PresentationSource>
5571 );
5672 }
5773 const onPressVideo = props.onPressVideo;
@@ -60,27 +76,42 @@ export function ComposerAttachmentThumbnail(props: { · 2 unmodified lines
6076 <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} />
6177 );
6278 }
79+ const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined;
80+ const sourceIdentifier = `draft-file:${attachment.id}`;
6381 return (
64− <View
65− className={
66− props.compact
67− ? "items-center justify-center bg-subtle"
68− : "items-center justify-center gap-1 bg-subtle px-2"
69− }
70− style={style}
71− >
72− <SymbolView
73− name="doc.text"
74− size={props.compact ? 15 : 22}
75− tintColor="#a3a3a3"
76− type="monochrome"
77− />
78− {!props.compact ? (
79− <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
80− {attachment.name}
81− </Text>
82− ) : null}
83− </View>
82+ <PresentationSource identifier={sourceIdentifier}>
83+ <Pressable
84+ accessibilityRole="button"
85+ accessibilityLabel={`Open ${attachment.name}`}
86+ disabled={!canPreview}
87+ onPress={() =>
88+ props.onPressPreview?.({
89+ kind: "pdf",
90+ name: attachment.name,
91+ attachment,
92+ sourceIdentifier,
93+ })
94+ }
95+ className={
96+ props.compact
97+ ? "items-center justify-center bg-subtle"
98+ : "items-center justify-center gap-1 bg-subtle px-2"
99+ }
100+ style={style}
101+ >
102+ <SymbolView
103+ name="doc.text"
104+ size={props.compact ? 15 : 22}
105+ tintColor="#a3a3a3"
106+ type="monochrome"
107+ />
108+ {!props.compact ? (
109+ <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>
110+ {attachment.name}
111+ </Text>
112+ ) : null}
113+ </Pressable>
114+ </PresentationSource>
84115 );
85116 }
86117
@@ -113,7 +144,7 @@ function ComposerVideoAttachment(props: { · 26 unmodified lines
113144 shareRef.current = controller;
114145 setSharing(true);
115146 void (async () => {
116− const preview = await loadLocalVideoPreview(attachment, controller.signal);
147+ const preview = await loadLocalAttachmentPreview(attachment, controller.signal);
117148 if (!preview) return;
118149 try {
119150 await preview.share(controller.signal, sourceIdentifier);
@@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 65 unmodified lines
185216 attachment={attachment}
186217 size={size}
187218 borderRadius={radius}
188− onPressImage={props.onPressImage}
219+ onPressPreview={props.onPressPreview}
189220 onPressVideo={props.onPressVideo}
190221 />
191222 <Pressable
apps/mobile/src/components/FilePreview.ios.tsx+43−0
@@ -0,0 +1,43 @@
1+import { requireNativeModule } from "expo";
2+import { useEffect, useEffectEvent, useId } from "react";
3+import { Alert } from "react-native";
4+
5+import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
6+
7+const NativeControls = requireNativeModule<{
8+ presentFile(
9+ uri: string,
10+ name: string,
11+ sourceIdentifier: string,
12+ identifier: string,
13+ ): Promise<void>;
14+ dismissFile(identifier: string): Promise<void>;
15+}>("T3NativeControls");
16+
17+export function FilePreview(props: {
18+ readonly source: ResolvedFilePreviewSource;
19+ readonly onRequestClose: () => void;
20+}) {
21+ const { uri, name, sourceIdentifier } = props.source;
22+ const identifier = useId();
23+ const onRequestClose = useEffectEvent(props.onRequestClose);
24+
25+ useEffect(() => {
26+ let canceled = false;
27+ void NativeControls.presentFile(uri, name ?? "Preview", sourceIdentifier ?? "", identifier)
28+ .catch(() => {
29+ if (!canceled) {
30+ Alert.alert("Could not open preview", "The file could not be loaded. Please try again.");
31+ }
32+ })
33+ .finally(() => {
34+ if (!canceled) onRequestClose();
35+ });
36+ return () => {
37+ canceled = true;
38+ void NativeControls.dismissFile(identifier).catch(() => undefined);
39+ };
40+ }, [uri, name, sourceIdentifier, identifier]);
41+
42+ return null;
43+}
apps/mobile/src/components/FilePreview.tsx+52−0
@@ -0,0 +1,52 @@
1+import { useEffect, useEffectEvent } from "react";
2+import { Alert } from "react-native";
3+import ImageViewing from "react-native-image-viewing";
4+
5+import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";
6+import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
7+
8+function PdfPreview(props: {
9+ readonly source: ResolvedFilePreviewSource;
10+ readonly onRequestClose: () => void;
11+}) {
12+ const { uri, name } = props.source;
13+ const onRequestClose = useEffectEvent(props.onRequestClose);
14+ useEffect(() => {
15+ const controller = new AbortController();
16+ const input = {
17+ attachment: { name: name ?? "Document.pdf", mimeType: "application/pdf" },
18+ signal: controller.signal,
19+ };
20+ // Android's system chooser supplies the installed PDF apps.
21+ const opened =
22+ uri.startsWith("file:") || uri.startsWith("content:")
23+ ? shareLocalAttachment({ ...input, uri })
24+ : downloadAndShareAttachment({ ...input, url: uri });
25+ void opened
26+ .catch(() => {
27+ if (!controller.signal.aborted) Alert.alert("Could not open PDF", "Please try again.");
28+ })
29+ .finally(() => {
30+ if (!controller.signal.aborted) onRequestClose();
31+ });
32+ return () => controller.abort();
33+ }, [uri, name]);
34+ return null;
35+}
36+
37+export function FilePreview(props: {
38+ readonly source: ResolvedFilePreviewSource;
39+ readonly onRequestClose: () => void;
40+}) {
41+ if (props.source.kind === "pdf") return <PdfPreview {...props} />;
42+ return (
43+ <ImageViewing
44+ images={[{ uri: props.source.uri }]}
45+ imageIndex={0}
46+ visible
47+ onRequestClose={props.onRequestClose}
48+ swipeToCloseEnabled
49+ doubleTapToZoomEnabled
50+ />
51+ );
52+}
apps/mobile/src/components/FilePreviewModal.tsx+93−0
@@ -0,0 +1,93 @@
1+import { useIsFocused } from "@react-navigation/native";
2+import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
3+import { useEffect, useEffectEvent, useState } from "react";
4+import { Alert, Keyboard } from "react-native";
5+
6+import type { DraftComposerFileAttachment } from "../lib/composerImages";
7+import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8+import { useAssetUrlState } from "../state/assets";
9+import { usePreparedConnection } from "../state/session";
10+import { FilePreview } from "./FilePreview";
11+
12+export interface ResolvedFilePreviewSource {
13+ readonly kind: "image" | "pdf";
14+ readonly uri: string;
15+ readonly name?: string;
16+ readonly sourceIdentifier?: string;
17+}
18+
19+export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
20+ (
21+ | { readonly uri: string }
22+ | { readonly attachment: DraftComposerFileAttachment }
23+ | { readonly environmentId: EnvironmentId; readonly resource: AssetResource }
24+ );
25+
26+function ResolvedFilePreview(props: {
27+ readonly source: FilePreviewSource;
28+ readonly onRequestClose: () => void;
29+}) {
30+ const { source } = props;
31+ const environmentId = "environmentId" in source ? source.environmentId : null;
32+ const connection = usePreparedConnection(environmentId);
33+ const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null);
34+ // Keep the original URL through dismissal; a refreshed signature must not reopen the viewer.
35+ const [uri, setUri] = useState<string | null>("uri" in source ? source.uri : null);
36+ const onRequestClose = useEffectEvent(props.onRequestClose);
37+ const failed =
38+ environmentId !== null &&
39+ uri === null &&
40+ (connection._tag === "None" || asset._tag === "Failure");
41+ useEffect(() => Keyboard.dismiss(), []);
42+ useEffect(() => {
43+ if (uri === null && asset._tag === "Success") setUri(asset.url);
44+ }, [uri, asset]);
45+ useEffect(() => {
46+ if (!failed) return;
47+ Alert.alert("Could not open preview", "Reconnect to this environment and try again.");
48+ onRequestClose();
49+ }, [failed]);
50+ useEffect(() => {
51+ if (!("attachment" in source)) return;
52+ const controller = new AbortController();
53+ let release: (() => void) | undefined;
54+ void loadLocalAttachmentPreview(source.attachment, controller.signal)
55+ .then((file) => {
56+ if (!file) return;
57+ if (controller.signal.aborted) {
58+ file.dispose();
59+ return;
60+ }
61+ release = file.dispose;
62+ setUri(file.uri);
63+ })
64+ .catch(() => {
65+ if (controller.signal.aborted) return;
66+ Alert.alert("Could not open preview", "Attach the file again and retry.");
67+ onRequestClose();
68+ });
69+ return () => {
70+ controller.abort();
71+ release?.();
72+ };
73+ }, [source]);
74+
75+ return uri === null ? null : (
76+ <FilePreview source={{ ...source, uri }} onRequestClose={props.onRequestClose} />
77+ );
78+}
79+
80+export function FilePreviewModal(props: {
81+ readonly source: FilePreviewSource | null;
82+ readonly onRequestClose: () => void;
83+}) {
84+ const isFocused = useIsFocused();
85+ const hasSource = props.source !== null;
86+ const onRequestClose = useEffectEvent(props.onRequestClose);
87+ useEffect(() => {
88+ if (!isFocused && hasSource) onRequestClose();
89+ }, [isFocused, hasSource]);
90+
91+ if (!props.source || !isFocused) return null;
92+ return <ResolvedFilePreview source={props.source} onRequestClose={props.onRequestClose} />;
93+}
apps/mobile/src/components/VideoPreviewModal.ios.tsx+2−2
@@ -4,7 +4,7 @@ import { requireNativeModule } from "expo"; · 3 unmodified lines
44 import { useEffect, useEffectEvent, useId, useState } from "react";
55 import { Alert, Keyboard } from "react-native";
66
7−import { loadLocalVideoPreview } from "../lib/localVideoPreview";
7+import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
88 import { useAssetUrlState } from "../state/assets";
99 import { usePreparedConnection } from "../state/session";
1010 import type { VideoPreviewSource } from "./VideoPreviewModal";
@@ -67,7 +67,7 @@ function NativeVideoPreview(props: { · 56 unmodified lines
6767 void (async () => {
6868 const file =
6969 source.type === "local"
70− ? await loadLocalVideoPreview(source.attachment, controller.signal)
70+ ? await loadLocalAttachmentPreview(source.attachment, controller.signal)
7171 : null;
7272 if (source.type === "local" && !file) return;
7373 try {
apps/mobile/src/components/VideoPreviewModal.tsx+2−2
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
2020 type AttachmentPreviewFile,
2121 } from "../lib/attachmentDownload";
2222 import type { DraftComposerFileAttachment } from "../lib/composerImages";
23−import { loadLocalVideoPreview } from "../lib/localVideoPreview";
23+import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
2424 import { useAssetUrlState } from "../state/assets";
2525 import { usePreparedConnection } from "../state/session";
2626 import { SymbolView } from "./AppSymbol";
@@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: { · 128 unmodified lines
155155 setFailure(null);
156156 const loading =
157157 source.type === "local"
158− ? loadLocalVideoPreview(source.attachment, controller.signal)
158+ ? loadLocalAttachmentPreview(source.attachment, controller.signal)
159159 : downloadAttachmentForPreview({
160160 url: downloadUrl!,
161161 attachment: { name: attachment.name, mimeType },
apps/mobile/src/components/VideoThumbnailImage.tsx+2−2
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; · 4 unmodified lines
55 import { StyleSheet } from "react-native";
66
77 import type { DraftComposerFileAttachment } from "../lib/composerImages";
8−import { loadLocalVideoPreview } from "../lib/localVideoPreview";
8+import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
99 import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails";
1010
1111 export function VideoThumbnailImage(props: {
@@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: { · 13 unmodified lines
2525 async (signal) =>
2626 typeof source === "string"
2727 ? { uri: source, dispose: () => undefined }
28− : loadLocalVideoPreview(source, signal),
28+ : loadLocalAttachmentPreview(source, signal),
2929 controller.signal,
3030 ).then((thumbnail) => {
3131 if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail });
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+30−1
@@ -17,9 +17,11 @@ import { SymbolView } from "../../components/AppSymbol"; · 16 unmodified lines
1717 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
1818 import { ControlPillMenu } from "../../components/ControlPill";
1919 import { EmptyState } from "../../components/EmptyState";
20+import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
2021 import { LoadingScreen } from "../../components/LoadingScreen";
2122 import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
2223 import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
24+import { isPdfFile } from "../../lib/filePreview";
2325 import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
2426 import { useUniwindTheme } from "../../lib/useUniwindTheme";
2527 import { useThreadSelection } from "../../state/use-thread-selection";
@@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 461 unmodified lines
487489 readonly mode: FileViewMode;
488490 } | null>(null);
489491 const [previewRevision, setPreviewRevision] = useState(0);
492+ const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null);
490493 const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);
491494 const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);
492495 const canPreview =
@@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 93 unmodified lines
586589 inline: false,
587590 onPress: () => copyTextWithHaptic(relativePath),
588591 } as const,
592+ isPdfFile({ name: relativePath }) && previewUri !== null
593+ ? ({
594+ id: "open-pdf",
595+ title: "Open PDF",
596+ icon: "arrow.up.left.and.arrow.down.right",
597+ inline: false,
598+ onPress: () =>
599+ setFullScreenPreview({
600+ kind: "pdf",
601+ uri: previewUri,
602+ name: basename(relativePath),
603+ }),
604+ } as const)
605+ : null,
589606 isBrowserFile && typeof assetPreviewUri === "string"
590607 ? ({
591608 id: "open-browser",
@@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 13 unmodified lines
605622 } as const)
606623 : null,
607624 ].filter((action) => action !== null);
608− }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]);
625+ }, [
626+ assetPreviewUri,
627+ previewUri,
628+ canPreview,
629+ isBrowserFile,
630+ isImageFile,
631+ relativePath,
632+ resolvedActiveMode,
633+ ]);
609634
610635 const androidFileMenuActions = useMemo<MenuAction[]>(
611636 () =>
@@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 154 unmodified lines
766791 truncated={fileData?.truncated ?? false}
767792 onRefresh={() => fileQuery.refresh()}
768793 />
794+ <FilePreviewModal
795+ source={fullScreenPreview}
796+ onRequestClose={() => setFullScreenPreview(null)}
797+ />
769798 </View>
770799 </ReviewHighlighterProvider>
771800 );
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+26−23
@@ -1,24 +1,25 @@
11 import { useAtomValue } from "@effect/atom-react";
2−import { useMemo, useState } from "react";
2+import { useId, useMemo, useState } from "react";
33 import { ActivityIndicator, Image, Pressable, View } from "react-native";
4−import ImageViewing from "react-native-image-viewing";
54 import { AsyncResult } from "effect/unstable/reactivity";
65
76 import { AppText as Text } from "../../components/AppText";
87 import { EmptyState } from "../../components/EmptyState";
98 import { workspaceFileImageAtom } from "./workspace-file-image-cache";
9+import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
10+import { PresentationSource } from "../../components/NativePresentation";
1011
1112 function ResolvedWorkspaceFileImagePreview(props: {
1213 readonly accessibilityLabel: string;
1314 readonly uri: string;
1415 }) {
1516 const [loadError, setLoadError] = useState<string | null>(null);
16− const [fullScreenVisible, setFullScreenVisible] = useState(false);
17+ const [preview, setPreview] = useState<FilePreviewSource | null>(null);
18+ const sourceIdentifier = useId();
1719 const imageSource = useMemo(
1820 () => ({ uri: props.uri, cache: "force-cache" as const }),
1921 [props.uri],
2022 );
21− const fullScreenImages = useMemo(() => [imageSource], [imageSource]);
2223
2324 return (
2425 <View className="relative flex-1 bg-subtle">
@@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: { · 2 unmodified lines
2728 accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`}
2829 disabled={loadError !== null}
2930 className="flex-1 p-4 active:bg-subtle-strong"
30− onPress={() => setFullScreenVisible(true)}
31+ onPress={() =>
32+ setPreview({
33+ kind: "image",
34+ uri: props.uri,
35+ name: props.accessibilityLabel,
36+ sourceIdentifier,
37+ })
38+ }
3139 >
32− <Image
33− accessible={false}
34− source={imageSource}
35− className="h-full w-full"
36− resizeMode="contain"
37− onLoadStart={() => setLoadError(null)}
38− onError={(event) => {
39− setLoadError(event.nativeEvent.error || "The image could not be rendered.");
40− }}
41− />
40+ <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}>
41+ <Image
42+ accessible={false}
43+ source={imageSource}
44+ className="h-full w-full"
45+ resizeMode="contain"
46+ onLoadStart={() => setLoadError(null)}
47+ onError={(event) => {
48+ setLoadError(event.nativeEvent.error || "The image could not be rendered.");
49+ }}
50+ />
51+ </PresentationSource>
4252 </Pressable>
4353
4454 {loadError !== null ? (
@@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: { · 2 unmodified lines
4757 </View>
4858 ) : null}
4959
50− <ImageViewing
51− images={fullScreenImages}
52− imageIndex={0}
53− visible={fullScreenVisible}
54− onRequestClose={() => setFullScreenVisible(false)}
55− swipeToCloseEnabled
56− doubleTapToZoomEnabled
57− />
60+ <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
5861 </View>
5962 );
6063 }
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+4−11
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; · 4 unmodified lines
55 import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
66 import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
77 import { useSafeAreaInsets } from "react-native-safe-area-context";
8−import ImageViewing from "react-native-image-viewing";
8+import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
99
1010 import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
1111 import { SymbolView } from "../../components/AppSymbol";
@@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 41 unmodified lines
5353 Record<string, ReadonlyArray<ReviewHighlightedToken>>
5454 >({});
5555 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
56− const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
56+ const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
5757
5858 const selectedLines = useMemo(
5959 () => (target ? getSelectedReviewCommentLines(target) : []),
@@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 212 unmodified lines
272272 attachments={attachments}
273273 imageBorderRadius={16}
274274 imageSize={60}
275− onPressImage={setPreviewImageUri}
275+ onPressPreview={setPreviewFile}
276276 removeButtonPlacement="gutter"
277277 onRemove={(imageId) => {
278278 setAttachments((current) =>
@@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 53 unmodified lines
332332 </View>
333333 </KeyboardStickyView>
334334 ) : null}
335− <ImageViewing
336− images={previewImageUri ? [{ uri: previewImageUri }] : []}
337− imageIndex={0}
338− visible={previewImageUri !== null}
339− onRequestClose={() => setPreviewImageUri(null)}
340− swipeToCloseEnabled
341− doubleTapToZoomEnabled
342− />
335+ <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} />
343336 </View>
344337 );
345338 }
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+21−5
@@ -35,6 +35,7 @@ import { · 34 unmodified lines
3535 import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
3636 import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
3737 import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
38+import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
3839 import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
3940 import { ProviderIcon } from "../../components/ProviderIcon";
4041 import { SymbolView } from "../../components/AppSymbol";
@@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: { · 126 unmodified lines
167168 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
168169 const [isComposerFocused, setIsComposerFocused] = useState(false);
169170 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
170− const wasFocusedBeforeVideoRef = useRef(false);
171+ const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
172+ const wasFocusedBeforePreviewRef = useRef(false);
171173 const openVideoPreview = useCallback(
172174 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
173− wasFocusedBeforeVideoRef.current = isComposerFocused;
175+ wasFocusedBeforePreviewRef.current = isComposerFocused;
176+ setPreviewFile(null);
174177 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
175178 },
176179 [isComposerFocused],
177180 );
178− const closeVideoPreview = useCallback(() => {
181+ const openFilePreview = useCallback(
182+ (source: FilePreviewSource) => {
183+ wasFocusedBeforePreviewRef.current = isComposerFocused;
184+ setPreviewVideo(null);
185+ setPreviewFile((current) => current ?? source);
186+ },
187+ [isComposerFocused],
188+ );
189+ const closeMediaPreview = useCallback(() => {
179190 setPreviewVideo(null);
180− if (wasFocusedBeforeVideoRef.current) {
191+ setPreviewFile(null);
192+ if (wasFocusedBeforePreviewRef.current) {
181193 setTimeout(() => {
182194 if (navigation.isFocused()) promptInputRef.current?.focus();
183195 }, 100);
@@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: { · 1028 unmodified lines
12121224 ? () => undefined
12131225 : flow.removeAttachment
12141226 }
1227+ onPressPreview={
1228+ isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview
1229+ }
12151230 onPressVideo={
12161231 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview
12171232 }
@@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: { · 100 unmodified lines
13181333 </ComposerDictationToolbar>
13191334 </Animated.View>
13201335 </ComposerSurface>
1321− <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} />
1336+ <VideoPreviewModal source={previewVideo} onRequestClose={closeMediaPreview} />
1337+ <FilePreviewModal source={previewFile} onRequestClose={closeMediaPreview} />
13221338 </View>
13231339 );
13241340
apps/mobile/src/features/threads/ThreadComposer.tsx+10−17
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
2020 type RefObject,
2121 } from "react";
2222 import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";
23−import ImageViewing from "react-native-image-viewing";
23+import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
2424 import Animated, {
2525 FadeIn,
2626 FadeInDown,
@@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 284 unmodified lines
311311 const inFlightThreadIdsRef = useRef(new Set<string>());
312312 const { onExpandedChange } = props;
313313
314− const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
314+ const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
315315 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
316316 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
317317 const showStopAction =
@@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 54 unmodified lines
372372 onExpandedChange?.(isExpanded);
373373 }, [isExpanded, onExpandedChange]);
374374
375− const onPressImage = useCallback(
376− (uri: string) => {
375+ const onPressPreview = useCallback(
376+ (source: FilePreviewSource) => {
377377 wasExpandedBeforePreviewRef.current = isFocused;
378378 setPreviewVideo(null);
379− setPreviewImageUri(uri);
379+ setPreviewFile((current) => current ?? source);
380380 },
381381 [isFocused],
382382 );
383383
384384 const closePreview = useCallback(() => {
385− setPreviewImageUri(null);
385+ setPreviewFile(null);
386386 setPreviewVideo(null);
387387 if (wasExpandedBeforePreviewRef.current) {
388388 setTimeout(() => {
@@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 5 unmodified lines
394394 const onPressVideo = useCallback(
395395 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
396396 wasExpandedBeforePreviewRef.current = isFocused;
397− setPreviewImageUri(null);
397+ setPreviewFile(null);
398398 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
399399 },
400400 [isFocused],
@@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 218 unmodified lines
619619 <ComposerAttachmentStrip
620620 attachments={props.draftAttachments}
621621 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}
622− onPressImage={voiceInput.isBusy ? undefined : onPressImage}
622+ onPressPreview={voiceInput.isBusy ? undefined : onPressPreview}
623623 onPressVideo={voiceInput.isBusy ? undefined : onPressVideo}
624624 />
625625 </Animated.View>
@@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 47 unmodified lines
673673 size={30}
674674 borderRadius={8}
675675 compact
676− onPressImage={onPressImage}
676+ onPressPreview={onPressPreview}
677677 onPressVideo={onPressVideo}
678678 />
679679 ))}
@@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 139 unmodified lines
819819 </Animated.View>
820820
821821 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
822− <ImageViewing
823− images={previewImageUri ? [{ uri: previewImageUri }] : []}
824− imageIndex={0}
825− visible={previewImageUri !== null}
826− onRequestClose={closePreview}
827− swipeToCloseEnabled
828− doubleTapToZoomEnabled
829− />
822+ <FilePreviewModal source={previewFile} onRequestClose={closePreview} />
830823 </Animated.View>
831824 );
832825 });
apps/mobile/src/features/threads/ThreadFeed.tsx+152−79
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
3636 useMemo,
3737 useRef,
3838 useState,
39+ useId,
3940 type ReactNode,
4041 type RefObject,
4142 } from "react";
@@ -62,8 +63,9 @@ import { · 20 unmodified lines
6263 View,
6364 type ViewStyle,
6465 } from "react-native";
65−import { TouchableOpacity } from "react-native-gesture-handler";
66−import ImageViewing from "react-native-image-viewing";
66+import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
67+import { isPdfFile } from "../../lib/filePreview";
68+import { PresentationSource } from "../../components/NativePresentation";
6769 import { useSafeAreaInsets } from "react-native-safe-area-context";
6870 import Animated, {
6971 FadeIn,
@@ -212,9 +214,11 @@ export interface ThreadFeedProps { · 142 unmodified lines
212214 function MessageAttachmentImage(props: {
213215 readonly environmentId: EnvironmentId;
214216 readonly attachmentId: string;
217+ readonly name: string;
215218 readonly className: string;
216− readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
219+ readonly onPressPreview: (source: FilePreviewSource) => void;
217220 }) {
221+ const sourceIdentifier = useId();
218222 const uri = useAssetUrl(props.environmentId, {
219223 _tag: "attachment",
220224 attachmentId: props.attachmentId,
@@ -229,9 +233,17 @@ function MessageAttachmentImage(props: { · 8 unmodified lines
229233 }
230234
231235 return (
232− <TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}>
233− <Image source={{ uri }} className={props.className} resizeMode="cover" />
234− </TouchableOpacity>
236+ <PresentationSource identifier={sourceIdentifier}>
237+ <Pressable
238+ accessibilityRole="imagebutton"
239+ accessibilityLabel={`Open ${props.name}`}
240+ onPress={() =>
241+ props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier })
242+ }
243+ >
244+ <Image source={{ uri }} className={props.className} resizeMode="cover" />
245+ </Pressable>
246+ </PresentationSource>
235247 );
236248 }
237249
@@ -249,14 +261,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt · 11 unmodified lines
249261 function MessageAttachmentFile(props: {
250262 readonly environmentId: EnvironmentId;
251263 readonly attachment: ChatFileAttachment;
264+ readonly onPressPreview: (source: FilePreviewSource) => void;
252265 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
253266 }) {
267+ const sourceIdentifier = useId();
254268 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
255269 reportFailure: false,
256270 });
257271 const preparedConnection = usePreparedConnection(props.environmentId);
258272 const { attachment } = props;
259273 const videoType = videoMimeType(attachment);
274+ const isPdf = isPdfFile(attachment);
275+ const fileTypeLabel = isPdf
276+ ? "PDF"
277+ : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File");
278+ const sizeLabel = formatAttachmentSize(attachment.sizeBytes);
260279 const thumbnailUrl = useAssetUrl(
261280 props.environmentId,
262281 videoType === null
@@ -348,26 +367,63 @@ function MessageAttachmentFile(props: { · 85 unmodified lines
348367 }
349368
350369 return (
351− <Pressable
352− accessibilityRole="button"
353− accessibilityLabel={`Open ${attachment.name}`}
354− accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}
355− disabled={opening || httpBaseUrl === null}
356− className="flex-row items-center gap-2 py-1"
357− onPress={() => shareFile()}
370+ <PresentationSource
371+ identifier={sourceIdentifier}
372+ className="my-1"
373+ style={{ width: 280, maxWidth: "100%" }}
358374 >
359− {opening ? (
360− <ActivityIndicator size="small" />
361− ) : (
362− <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />
363− )}
364− <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>
365− {attachment.name}
366− </Text>
367− <Text className="text-xs text-foreground-muted">
368− {formatAttachmentSize(attachment.sizeBytes)}
369− </Text>
370− </Pressable>
375+ <Pressable
376+ accessibilityRole="button"
377+ accessibilityLabel={`Open ${attachment.name}`}
378+ accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }}
379+ accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}
380+ disabled={opening || httpBaseUrl === null}
381+ className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle"
382+ onPress={() =>
383+ isPdf
384+ ? props.onPressPreview({
385+ kind: "pdf",
386+ name: attachment.name,
387+ environmentId: props.environmentId,
388+ resource: {
389+ _tag: "attachment",
390+ attachmentId: attachment.id,
391+ fileName: attachment.name,
392+ mimeType: "application/pdf",
393+ },
394+ sourceIdentifier,
395+ })
396+ : shareFile(sourceIdentifier)
397+ }
398+ >
399+ <View className="h-12 w-10 shrink-0 items-center justify-center rounded-lg bg-subtle">
400+ {opening ? (
401+ <ActivityIndicator size="small" />
402+ ) : (
403+ <SymbolView
404+ name="doc.text"
405+ size={26}
406+ tintColorClassName={isPdf ? "accent-red-500" : "accent-foreground-muted"}
407+ type="monochrome"
408+ />
409+ )}
410+ </View>
411+ <View className="min-w-0 flex-1 gap-1">
412+ <Text className="font-t3-medium text-sm text-foreground" numberOfLines={2}>
413+ {attachment.name}
414+ </Text>
415+ <Text className="text-xs text-foreground-muted" numberOfLines={1}>
416+ {fileTypeLabel} · {sizeLabel}
417+ </Text>
418+ </View>
419+ <SymbolView
420+ name="chevron.right"
421+ size={12}
422+ tintColorClassName="accent-foreground-muted"
423+ type="monochrome"
424+ />
425+ </Pressable>
426+ </PresentationSource>
371427 );
372428 }
373429
@@ -391,8 +447,9 @@ function ThreadMarkdownImageView(props: { · 17 unmodified lines
391447 readonly sourceKey: string;
392448 readonly unavailable: boolean;
393449 readonly alt: string | null;
394− readonly onPressImage: (uri: string) => void;
450+ readonly onPressPreview: (source: FilePreviewSource) => void;
395451 }) {
452+ const sourceIdentifier = useId();
396453 const [availableWidth, setAvailableWidth] = useState(0);
397454 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
398455 const [failedUri, setFailedUri] = useState<string | null>(null);
@@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: { · 38 unmodified lines
437494 )}
438495 </View>
439496 ) : (
440− <TouchableOpacity
441− accessibilityRole="imagebutton"
442− accessibilityLabel={props.alt ?? "Markdown image"}
443− activeOpacity={0.7}
444− onPress={() => props.onPressImage(props.uri!)}
445− style={{ alignSelf: "flex-start" }}
446− >
447− <View
448− className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
449− style={{
450− ...frameStyle,
451− }}
497+ <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
498+ <Pressable
499+ accessibilityRole="imagebutton"
500+ accessibilityLabel={props.alt ?? "Markdown image"}
501+ onPress={() =>
502+ props.onPressPreview({
503+ kind: "image",
504+ uri: props.uri!,
505+ name: props.alt ?? "Image",
506+ sourceIdentifier,
507+ })
508+ }
509+ style={{ alignSelf: "flex-start" }}
452510 >
453− <ThreadMarkdownImageRequest
454− key={props.uri}
455− uri={props.uri}
456− onLoad={setSourceSize}
457− onError={() => setFailedUri(props.uri)}
458− />
459− </View>
460− </TouchableOpacity>
511+ <View
512+ className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
513+ style={{
514+ ...frameStyle,
515+ }}
516+ >
517+ <ThreadMarkdownImageRequest
518+ key={props.uri}
519+ uri={props.uri}
520+ onLoad={setSourceSize}
521+ onError={() => setFailedUri(props.uri)}
522+ />
523+ </View>
524+ </Pressable>
525+ </PresentationSource>
461526 )}
462527 {props.alt ? (
463528 <Text selectable className="text-xs text-foreground-muted">
@@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: { · 42 unmodified lines
506571 readonly threadId: ThreadId;
507572 readonly path: string;
508573 readonly alt: string | null;
509− readonly onPressImage: (uri: string) => void;
574+ readonly onPressPreview: (source: FilePreviewSource) => void;
510575 }) {
511576 const assetUrl = useAssetUrlState(props.environmentId, {
512577 _tag: "workspace-file",
@@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: { · 7 unmodified lines
520585 sourceKey={props.path}
521586 unavailable={assetUrl._tag === "Failure"}
522587 alt={props.alt}
523− onPressImage={props.onPressImage}
588+ onPressPreview={props.onPressPreview}
524589 />
525590 );
526591 }
@@ -532,7 +597,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) · 5 unmodified lines
532597 sourceKey="unavailable"
533598 unavailable
534599 alt={props.alt}
535− onPressImage={() => undefined}
600+ onPressPreview={() => undefined}
536601 />
537602 );
538603 }
@@ -1264,7 +1329,7 @@ function renderFeedEntry( · 725 unmodified lines
12641329 readonly onToggleWorkGroup: (groupId: string) => void;
12651330 readonly onToggleWorkRow: (rowId: string) => void;
12661331 readonly onToggleTurnFold: (turnId: TurnId) => void;
1267− readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
1332+ readonly onPressPreview: (source: FilePreviewSource) => void;
12681333 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
12691334 readonly onMarkdownLinkPress: (href: string) => void;
12701335 readonly renderMarkdownImage: MarkdownImageRenderer;
@@ -1375,14 +1440,16 @@ function renderFeedEntry( · 104 unmodified lines
13751440 key={attachment.id}
13761441 environmentId={props.environmentId}
13771442 attachmentId={attachment.id}
1443+ name={attachment.name}
13781444 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
1379− onPressImage={props.onPressImage}
1445+ onPressPreview={props.onPressPreview}
13801446 />
13811447 ) : isFileAttachment(attachment) ? (
13821448 <MessageAttachmentFile
13831449 key={attachment.id}
13841450 environmentId={props.environmentId}
13851451 attachment={attachment}
1452+ onPressPreview={props.onPressPreview}
13861453 onPressVideo={props.onPressVideo}
13871454 />
13881455 ) : (
@@ -1436,14 +1503,16 @@ function renderFeedEntry( · 47 unmodified lines
14361503 key={attachment.id}
14371504 environmentId={props.environmentId}
14381505 attachmentId={attachment.id}
1506+ name={attachment.name}
14391507 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
1440− onPressImage={props.onPressImage}
1508+ onPressPreview={props.onPressPreview}
14411509 />
14421510 ) : isFileAttachment(attachment) ? (
14431511 <MessageAttachmentFile
14441512 key={attachment.id}
14451513 environmentId={props.environmentId}
14461514 attachment={attachment}
1515+ onPressPreview={props.onPressPreview}
14471516 onPressVideo={props.onPressVideo}
14481517 />
14491518 ) : (
@@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 356 unmodified lines
18061875 expandedTurnIds: new Set(),
18071876 });
18081877 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
1809− const [expandedImage, setExpandedImage] = useState<{
1810− uri: string;
1811− headers?: Record<string, string>;
1812− } | null>(null);
1878+ const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
18131879 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
18141880 useEffect(() => {
18151881 setExpandedVideo(null);
1882+ setExpandedFile(null);
18161883 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);
18171884 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
18181885 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
@@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 39 unmodified lines
18581925 );
18591926 if (relativePath) {
18601927 void Haptics.selectionAsync();
1928+ if (isPdfFile({ name: relativePath })) {
1929+ setExpandedFile(
1930+ (current) =>
1931+ current ?? {
1932+ kind: "pdf",
1933+ name: relativePath.split("/").at(-1),
1934+ environmentId: props.environmentId,
1935+ resource: {
1936+ _tag: "workspace-file",
1937+ threadId: props.threadId,
1938+ path: relativePath,
1939+ },
1940+ },
1941+ );
1942+ return;
1943+ }
18611944 navigation.navigate("ThreadFile", {
18621945 environmentId: String(props.environmentId),
18631946 threadId: String(props.threadId),
@@ -1869,6 +1952,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 5 unmodified lines
18691952 }
18701953
18711954 if (presentation.href) {
1955+ if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) {
1956+ setExpandedFile(
1957+ (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" },
1958+ );
1959+ return;
1960+ }
18721961 void tryOpenExternalUrl(presentation.href, "markdown-link");
18731962 }
18741963 },
@@ -1884,7 +1973,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 9 unmodified lines
18841973 sourceKey={imageSource.uri}
18851974 unavailable={false}
18861975 alt={image.alt}
1887− onPressImage={(uri) => setExpandedImage({ uri })}
1976+ onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
18881977 />
18891978 );
18901979 }
@@ -1897,7 +1986,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 6 unmodified lines
18971986 threadId={props.threadId}
18981987 path={imageSource.path}
18991988 alt={image.alt}
1900− onPressImage={(uri) => setExpandedImage({ uri })}
1989+ onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
19011990 />
19021991 );
19031992 },
@@ -2285,8 +2374,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 381 unmodified lines
22852374 [suspendEndScrollMaintenanceForDisclosure],
22862375 );
22872376
2288− const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {
2289− setExpandedImage({ uri, headers });
2377+ const onPressPreview = useCallback((source: FilePreviewSource) => {
2378+ setExpandedFile((current) => current ?? source);
22902379 }, []);
22912380 const onPressVideo = useCallback(
22922381 (attachment: ChatFileAttachment, sourceIdentifier: string) => {
@@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 57 unmodified lines
23502439 onToggleWorkGroup,
23512440 onToggleWorkRow,
23522441 onToggleTurnFold,
2353− onPressImage,
2442+ onPressPreview,
23542443 onPressVideo,
23552444 onMarkdownLinkPress,
23562445 renderMarkdownImage,
@@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 22 unmodified lines
23792468 userBubbleMaxWidth,
23802469 onCopyWorkRow,
23812470 onMarkdownLinkPress,
2382− onPressImage,
2471+ onPressPreview,
23832472 onPressVideo,
23842473 onToggleTurnFold,
23852474 onToggleWorkGroup,
@@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 179 unmodified lines
25652654 </View>
25662655
25672656 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
2568− <ImageViewing
2569− images={
2570− expandedImage
2571− ? [
2572− {
2573− uri: expandedImage.uri,
2574− headers: expandedImage.headers,
2575− },
2576− ]
2577− : []
2578− }
2579− imageIndex={0}
2580− visible={expandedImage !== null}
2581− onRequestClose={() => setExpandedImage(null)}
2582− swipeToCloseEnabled
2583− doubleTapToZoomEnabled
2584− />
2657+ <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
25852658 </>
25862659 );
25872660 });
apps/mobile/src/lib/composerFiles.test.ts+112−0
@@ -1,4 +1,5 @@
11 import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts";
23 import type { ImagePickerAsset } from "expo-image-picker";
34
45 const mocks = vi.hoisted(() => ({
@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ · 4 unmodified lines
910 delete: vi.fn(),
1011 open: vi.fn(),
1112 size: vi.fn(),
13+ readBase64: vi.fn(),
1214 }));
1315
1416 vi.mock("expo-file-system", () => {
@@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => { · 40 unmodified lines
5557 mocks.copy(this.uri, destination.uri);
5658 }
5759
60+ async base64(): Promise<string> {
61+ return mocks.readBase64(this.uri);
62+ }
63+
5864 delete(): void {
5965 mocks.delete(this.uri);
6066 }
@@ -95,9 +101,115 @@ describe("composer file attachments", () => { · 34 unmodified lines
95101 mocks.delete.mockReset();
96102 mocks.open.mockReset();
97103 mocks.size.mockReset();
104+ mocks.readBase64.mockReset();
98105 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));
99106 });
100107
108+ describe("photo library image conversion", () => {
109+ const jpeg = "/9j/2Q==";
110+ const photo: ImagePickerAsset = {
111+ uri: "file:///picker/photo.heic",
112+ type: "image",
113+ fileName: "photo.HEIC",
114+ mimeType: "image/heic",
115+ fileSize: 20 * 1024 * 1024,
116+ base64: jpeg,
117+ width: 1,
118+ height: 1,
119+ };
120+
121+ it.each(["image/heic", "image/heif", undefined])(
122+ "attaches the native JPEG conversion with matching metadata when the source MIME is %s",
123+ async (mimeType) => {
124+ mocks.pickMedia.mockResolvedValue({
125+ canceled: false,
126+ assets: [{ ...photo, mimeType }],
127+ });
128+
129+ const result = await pickComposerImages({ existingCount: 0 });
130+
131+ expect(result).toEqual({
132+ images: [
133+ {
134+ id: "attachment-id",
135+ type: "image",
136+ name: "photo.jpg",
137+ mimeType: "image/jpeg",
138+ sizeBytes: 4,
139+ dataUrl: `data:image/jpeg;base64,${jpeg}`,
140+ previewUri: `data:image/jpeg;base64,${jpeg}`,
141+ },
142+ ],
143+ error: null,
144+ });
145+ },
146+ );
147+
148+ it.each([
149+ { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" },
150+ { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" },
151+ { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" },
152+ ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => {
153+ const name = `photo.${original.extension}`;
154+ mocks.pickMedia.mockResolvedValue({
155+ canceled: false,
156+ assets: [{ ...photo, fileName: name, mimeType: original.mimeType }],
157+ });
158+ mocks.readBase64.mockResolvedValue(original.base64);
159+
160+ const result = await pickComposerImages({ existingCount: 0 });
161+
162+ expect(result.error).toBeNull();
163+ expect(result.images).toEqual([
164+ expect.objectContaining({
165+ name,
166+ mimeType: original.mimeType,
167+ dataUrl: `data:${original.mimeType};base64,${original.base64}`,
168+ sizeBytes: Buffer.from(original.base64, "base64").byteLength,
169+ }),
170+ ]);
171+ });
172+
173+ it("checks the converted JPEG size even when the HEIC source was smaller", async () => {
174+ const oversized =
175+ jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4);
176+ mocks.pickMedia.mockResolvedValue({
177+ canceled: false,
178+ assets: [{ ...photo, fileSize: 42, base64: oversized }],
179+ });
180+
181+ await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
182+ images: [],
183+ error: "'photo.HEIC' exceeds the 10 MB attachment limit.",
184+ });
185+ });
186+
187+ it("does not relabel unconverted HEIC bytes as JPEG", async () => {
188+ mocks.pickMedia.mockResolvedValue({
189+ canceled: false,
190+ assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }],
191+ });
192+
193+ const result = await pickComposerImages({ existingCount: 0 });
194+
195+ expect(result.images).toEqual([]);
196+ expect(result.error).toContain("not a supported image type");
197+ });
198+
199+ it("retains a converted photo when another original cannot be read", async () => {
200+ mocks.pickMedia.mockResolvedValue({
201+ canceled: false,
202+ assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo],
203+ });
204+ mocks.readBase64.mockRejectedValue(new Error("missing file"));
205+
206+ const result = await pickComposerImages({ existingCount: 0 });
207+
208+ expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]);
209+ expect(result.error).toBe("Failed to read 'missing.gif'.");
210+ });
211+ });
212+
101213 describe("photo library videos", () => {
102214 const image: ImagePickerAsset = {
103215 uri: "file:///picker/photo.png",
apps/mobile/src/lib/composerImages.ts+37−11
@@ -351,7 +351,7 @@ export async function pickComposerMedia(input: { · 350 unmodified lines
351351 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;
352352 break;
353353 }
354− const mimeType = asset.mimeType?.toLowerCase();
354+ let mimeType = asset.mimeType?.toLowerCase();
355355 if (asset.type === "video" || mimeType?.startsWith("video/")) {
356356 if (input.maxVideoBytes === undefined) {
357357 error = "Video attachments are unavailable here.";
@@ -375,35 +375,61 @@ export async function pickComposerMedia(input: { · 17 unmodified lines
375375 }
376376 continue;
377377 }
378− if (!mimeType?.startsWith("image/")) {
378+ if (asset.type !== "image" && !mimeType?.startsWith("image/")) {
379379 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
380380 continue;
381381 }
382− if (!isProviderSendTurnSupportedImageMimeType(mimeType)) {
383− error = `'${asset.fileName ?? "image"}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`;
384− continue;
385− }
386382
387− const base64 = asset.base64;
383+ let base64 = asset.base64;
388384 if (!base64) {
389385 error = `Failed to read '${asset.fileName ?? "image"}'.`;
390386 continue;
391387 }
392388
393− const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64);
389+ let name = asset.fileName?.trim() || "image";
390+ // The iOS picker returns JPEG base64 even when its metadata describes HEIC,
391+ // PNG, or GIF. Keep supported originals so transparency and animation survive;
392+ // use the native JPEG conversion for formats providers cannot accept.
393+ if (base64.startsWith("/9j/")) {
394+ if (
395+ mimeType &&
396+ mimeType !== "image/jpeg" &&
397+ isProviderSendTurnSupportedImageMimeType(mimeType)
398+ ) {
399+ try {
400+ const { File } = await import("expo-file-system");
401+ base64 = await new File(asset.uri).base64();
402+ } catch {
403+ error = `Failed to read '${name}'.`;
404+ continue;
405+ }
406+ } else {
407+ mimeType = "image/jpeg";
408+ if (!/\.jpe?g$/i.test(name)) {
409+ name = `${name.replace(/\.[^.]+$/, "")}.jpg`;
410+ }
411+ }
412+ }
413+ if (!mimeType || !isProviderSendTurnSupportedImageMimeType(mimeType)) {
414+ error = `'${name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`;
415+ continue;
416+ }
417+
418+ const sizeBytes = estimateBase64ByteSize(base64);
394419 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
395420 error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`;
396421 continue;
397422 }
398423
424+ const dataUrl = `data:${mimeType};base64,${base64}`;
399425 attachments.push({
400426 id: uuidv4(),
401427 type: "image",
402− name: asset.fileName ?? "image",
428+ name,
403429 mimeType,
404430 sizeBytes,
405− dataUrl: `data:${mimeType};base64,${base64}`,
406− previewUri: asset.uri,
431+ dataUrl,
432+ previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl,
407433 });
408434 }
409435
apps/mobile/src/lib/filePreview.test.ts+17−0
@@ -0,0 +1,17 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { isPdfFile } from "./filePreview";
4+
5+describe("PDF preview detection", () => {
6+ it.each([
7+ [{ name: "download", mimeType: "application/pdf" }, true],
8+ [{ name: "download", mimeType: "APPLICATION/PDF; charset=binary" }, true],
9+ [{ name: "Report.PDF", mimeType: "application/octet-stream" }, true],
10+ [{ name: "https://example.com/report.pdf?signature=abc#page=2" }, true],
11+ [{ name: "report.pdf", mimeType: "text/plain" }, false],
12+ [{ name: "report.pdf.exe" }, false],
13+ [{ name: "https://example.com/page?download=report.pdf" }, false],
14+ ])("classifies %j as %s", (file, expected) => {
15+ expect(isPdfFile(file)).toBe(expected);
16+ });
17+});
apps/mobile/src/lib/filePreview.ts+6−0
@@ -0,0 +1,6 @@
1+/** MIME metadata wins; use the extension for files reported without a specific type. */
2+export function isPdfFile(file: { readonly name: string; readonly mimeType?: string }): boolean {
3+ const mimeType = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase();
4+ if (mimeType && mimeType !== "application/octet-stream") return mimeType === "application/pdf";
5+ return /\.pdf$/i.test(file.name.split(/[?#]/, 1)[0] ?? "");
6+}
apps/mobile/src/lib/localAttachmentPreview.test.tsrenamed from apps/mobile/src/lib/localVideoPreview.test.ts+23−10
@@ -24,7 +24,7 @@ vi.mock("expo-file-system", () => ({ · 23 unmodified lines
2424 },
2525 }));
2626
27−import { loadLocalVideoPreview } from "./localVideoPreview";
27+import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
2828
2929 const attachment = {
3030 type: "file" as const,
@@ -45,9 +45,22 @@ beforeEach(() => { · 14 unmodified lines
4545 mocks.share.mockResolvedValue(undefined);
4646 });
4747
48−describe("loadLocalVideoPreview", () => {
48+describe("loadLocalAttachmentPreview", () => {
49+ it("retains and shares a PDF with its original filename and type", async () => {
50+ const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" };
51+ const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal);
52+ await preview!.share(new AbortController().signal);
53+ expect(mocks.share).toHaveBeenCalledWith(
54+ expect.objectContaining({
55+ attachment: { name: "report.pdf", mimeType: "application/pdf" },
56+ }),
57+ );
58+ expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled();
59+ preview!.dispose();
60+ expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
61+ });
4962 it("resolves the current iOS container and releases its playback lease once", async () => {
50− const preview = await loadLocalVideoPreview(attachment, new AbortController().signal);
63+ const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
5164 expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/");
5265 expect(mocks.retain).toHaveBeenCalledWith(attachment);
5366 const release = mocks.retain.mock.results[0]!.value;
@@ -62,7 +75,7 @@ describe("loadLocalVideoPreview", () => { · 8 unmodified lines
6275 async (sourceIdentifier) => {
6376 const shared = Promise.withResolvers<void>();
6477 mocks.share.mockReturnValue(shared.promise);
65− const preview = await loadLocalVideoPreview(attachment, new AbortController().signal);
78+ const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
6679 const share = preview!.share(new AbortController().signal, sourceIdentifier);
6780 expect(mocks.retain).toHaveBeenCalledTimes(2);
6881 const releasePlayback = mocks.retain.mock.results[0]!.value;
@@ -78,7 +91,7 @@ describe("loadLocalVideoPreview", () => { · 9 unmodified lines
7891
7992 it("releases a failed share while keeping playback retained", async () => {
8093 mocks.share.mockRejectedValue(new Error("Sharing unavailable"));
81− const preview = await loadLocalVideoPreview(attachment, new AbortController().signal);
94+ const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
8295 await expect(preview!.share(new AbortController().signal)).rejects.toThrow(
8396 "Sharing unavailable",
8497 );
@@ -89,7 +102,7 @@ describe("loadLocalVideoPreview", () => { · 4 unmodified lines
89102
90103 it("releases a load canceled during native module loading", async () => {
91104 const controller = new AbortController();
92− const loading = loadLocalVideoPreview(attachment, controller.signal);
105+ const loading = loadLocalAttachmentPreview(attachment, controller.signal);
93106 controller.abort();
94107 await expect(loading).resolves.toBeNull();
95108 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
@@ -98,14 +111,14 @@ describe("loadLocalVideoPreview", () => { · 2 unmodified lines
98111
99112 it("reports missing files and releases their lease", async () => {
100113 mocks.exists.mockReturnValue(false);
101− await expect(loadLocalVideoPreview(attachment, new AbortController().signal)).rejects.toThrow(
102− "This video is no longer available. Attach the file again.",
103− );
114+ await expect(
115+ loadLocalAttachmentPreview(attachment, new AbortController().signal),
116+ ).rejects.toThrow("This attachment is no longer available. Attach the file again.");
104117 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
105118 });
106119
107120 it("does not start sharing a disposed preview", async () => {
108− const preview = await loadLocalVideoPreview(attachment, new AbortController().signal);
121+ const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
109122 preview!.dispose();
110123 await preview!.share(new AbortController().signal);
111124 expect(mocks.share).not.toHaveBeenCalled();
apps/mobile/src/lib/localAttachmentPreview.tsrenamed from apps/mobile/src/lib/localVideoPreview.ts+3−3
@@ -5,8 +5,8 @@ import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles · 4 unmodified lines
55 import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
66 import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
77
8−/** Retains the draft original for playback and gives each outgoing share its own lease. */
9−export async function loadLocalVideoPreview(
8+/** Retains the draft original for preview and gives each outgoing share its own lease. */
9+export async function loadLocalAttachmentPreview(
1010 attachment: DraftComposerFileAttachment,
1111 signal: AbortSignal,
1212 ): Promise<AttachmentPreviewFile | null> {
@@ -54,6 +54,6 @@ export async function loadLocalVideoPreview( · 41 unmodified lines
5454 } catch (cause) {
5555 release();
5656 if (signal.aborted) return null;
57− throw new Error("This video is no longer available. Attach the file again.", { cause });
57+ throw new Error("This attachment is no longer available. Attach the file again.", { cause });
5858 }
5959 }
apps/server/src/server.test.ts+37−1
@@ -103,7 +103,7 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function · 102 unmodified lines
103103
104104 import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
105105 import * as ServerConfig from "./config.ts";
106−import { makeRoutesLayer } from "./server.ts";
106+import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts";
107107 import {
108108 isThreadDetailEvent,
109109 resolveAvailableEditorsForConfig,
@@ -629,6 +629,7 @@ const buildAppUnderTest = (options?: { · 519 unmodified lines
629629 {
630630 disableListenLog: true,
631631 disableLogger: true,
632+ routerConfig: HTTP_ROUTER_CONFIG,
632633 },
633634 ).pipe(
634635 Layer.provide(
@@ -1527,6 +1528,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 892 unmodified lines
15271528 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
15281529 );
15291530
1531+ it.effect("serves snapshots for MCP handoff thread IDs above the router default", () =>
1532+ Effect.gen(function* () {
1533+ const threadId = ThreadId.make(
1534+ "thread:mcp:abfba0d2-b591-4b7e-aad1-e943d89811fa:handoff%3A0ae5edf4-2ea3-4ee3-ba7c-48de3ac92896%3A2026-08-24T17%3A08%3A52.138Z:0",
1535+ );
1536+ const thread = {
1537+ ...makeDefaultOrchestrationReadModel().threads[0]!,
1538+ id: threadId,
1539+ };
1540+ yield* buildAppUnderTest({
1541+ layers: {
1542+ projectionSnapshotQuery: {
1543+ getThreadDetailSnapshot: (requestedThreadId) =>
1544+ Effect.succeed(
1545+ requestedThreadId === threadId
1546+ ? Option.some({ snapshotSequence: 1, thread })
1547+ : Option.none(),
1548+ ),
1549+ },
1550+ },
1551+ });
1552+
1553+ const response = yield* fetchEffect(
1554+ yield* getHttpServerUrl(`/api/orchestration/threads/${encodeURIComponent(threadId)}`),
1555+ { headers: { cookie: yield* getAuthenticatedSessionCookieHeader() } },
1556+ );
1557+ const snapshot = yield* responseJsonEffect<{
1558+ readonly thread: { readonly id: ThreadId };
1559+ }>(response);
1560+
1561+ assert.equal(response.status, 200);
1562+ assert.equal(snapshot.thread.id, threadId);
1563+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1564+ );
1565+
15301566 it.effect("compresses large JSON responses through the composed routes", () =>
15311567 Effect.gen(function* () {
15321568 const descriptor = {
apps/server/src/server.ts+7−0
@@ -123,6 +123,12 @@ import * as RelayClient from "@t3tools/shared/relayClient"; · 122 unmodified lines
123123 import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
124124 import { forkParked, ServerActivation } from "./serverActivation.ts";
125125
126+// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's
127+// 100-character default for one path segment.
128+export const HTTP_ROUTER_CONFIG = {
129+ maxParamLength: 512,
130+} as const;
131+
126132 // Effect's default preemptive shutdown waits 20s before finalizing request scopes.
127133 // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer
128134 // already closes the websocket gracefully. Do not add an artificial drain before
@@ -675,6 +681,7 @@ export const makeServerLayer = Layer.unwrap( · 546 unmodified lines
675681
676682 const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), {
677683 disableLogger: !config.logWebSocketEvents,
684+ routerConfig: HTTP_ROUTER_CONFIG,
678685 }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie)));
679686 const serverApplicationLayer = Layer.mergeAll(
680687 routesLayer,
apps/server/src/usage/usageScanCache.test.ts+14−0
@@ -193,6 +193,20 @@ describe("pruneScanCache with an unwalked root", () => { · 192 unmodified lines
193193 expect(removed).toBe(0);
194194 expect(cache.size).toBe(1);
195195 });
196+
197+ it("keeps entries under a sibling path that only shares the walked root prefix", () => {
198+ const cache = cacheWith([["/claude/projects-copy/a.jsonl", 5000, [record()]]]);
199+
200+ const removed = pruneScanCache(cache, {
201+ livePaths: new Set(),
202+ walkedRoots: ["/claude/projects"],
203+ windowStartMs: 4000,
204+ retentionCutoffMs: 1000,
205+ });
206+
207+ expect(removed).toBe(0);
208+ expect(cache.size).toBe(1);
209+ });
196210 });
197211
198212 describe("dedupeWithinFile", () => {
apps/server/src/usage/usageScanCache.ts+12−1
@@ -14,6 +14,9 @@ · 13 unmodified lines
1414 *
1515 * @module usageScanCache
1616 */
17+// @effect-diagnostics nodeBuiltinImport:off
18+import * as NodePath from "node:path";
19+
1720 import type { UsageProviderKind } from "@t3tools/contracts";
1821
1922 import type { UsageRecord } from "./usageTranscripts.ts";
@@ -229,7 +232,15 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number · 209 unmodified lines
229232 let removed = 0;
230233 for (const [path, entry] of cache) {
231234 const agedOut = entry.mtimeMs < options.retentionCutoffMs;
232− const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root));
235+ const underWalkedRoot = options.walkedRoots.some((root) => {
236+ const relative = NodePath.relative(root, path);
237+ return (
238+ relative === "" ||
239+ (relative !== ".." &&
240+ !relative.startsWith(`..${NodePath.sep}`) &&
241+ !NodePath.isAbsolute(relative))
242+ );
243+ });
233244 const deleted =
234245 underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path);
235246 if (agedOut || deleted) {
apps/web/src/components/BranchToolbar.tsx+7−5
@@ -265,8 +265,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { · 264 unmodified lines
265265 let needed = 0;
266266 let groups = 0;
267267 for (const child of current.children) {
268− if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue;
269− needed += contentWidth(child);
268+ if (!(child instanceof HTMLElement)) continue;
269+ const width = contentWidth(child);
270+ if (width <= 1) continue;
271+ needed += width;
270272 groups += 1;
271273 }
272274 needed += stripGap * Math.max(0, groups - 1);
@@ -356,7 +358,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { · 83 unmodified lines
356358 // Label widths can change without the strip box moving (font family or
357359 // size preferences), so re-measure on every render as well as on resize
358360 // and font loads.
359− useEffect(() => {
361+ useLayoutEffect(() => {
360362 measure();
361363 });
362364
@@ -487,7 +489,7 @@ export const BranchToolbar = memo(function BranchToolbar({ · 124 unmodified lines
487489 onUsePreviousWorktree={onUsePreviousWorktree}
488490 />
489491 ) : (
490− <div className="flex min-w-0 flex-1 items-center gap-1">
492+ <div className="flex min-w-10 flex-1 items-center gap-1">
491493 {showEnvironmentIndicator && availableEnvironments && (
492494 <>
493495 <BranchToolbarEnvironmentSelector
@@ -520,7 +522,7 @@ export const BranchToolbar = memo(function BranchToolbar({ · 26 unmodified lines
520522
521523 {showGitControls ? (
522524 <BranchToolbarBranchSelector
523− className="min-w-0 flex-1 justify-end md:ml-auto md:flex-none"
525+ className="min-w-0 flex-1 justify-end md:ml-auto md:flex-initial"
524526 environmentId={environmentId}
525527 threadId={threadId}
526528 {...(draftId ? { draftId } : {})}
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+14−9
@@ -51,20 +51,25 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe · 50 unmodified lines
5151 if (envLocked) {
5252 return (
5353 <span
54− className="inline-flex h-7 shrink-0 items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs"
54+ className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:h-6 sm:text-xs"
5555 data-composer-context-control
5656 >
5757 {activeWorktreePath ? (
58− <>
59− <FolderGitIcon className="size-3" />
60− {resolveLockedWorkspaceLabel(activeWorktreePath)}
61− </>
58+ <FolderGitIcon className="size-3 shrink-0" />
6259 ) : (
63− <>
64− <FolderIcon className="size-3" />
65− {resolveLockedWorkspaceLabel(activeWorktreePath)}
66− </>
60+ <FolderIcon className="size-3 shrink-0" />
6761 )}
62+ <span
63+ data-composer-label
64+ className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0"
65+ >
66+ <span
67+ data-composer-label-motion
68+ className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"
69+ >
70+ {resolveLockedWorkspaceLabel(activeWorktreePath)}
71+ </span>
72+ </span>
6873 </span>
6974 );
7075 }
apps/web/src/components/chat/ChatHeader.tsx+7−4
@@ -288,13 +288,16 @@ export const ChatHeader = memo(function ChatHeader({ · 287 unmodified lines
288288 className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
289289 onContextMenu={handleHeaderContextMenu}
290290 >
291− <WorkspaceBreadcrumb ariaLabel="Thread breadcrumb" className="flex-1">
291+ <WorkspaceBreadcrumb
292+ ariaLabel="Thread breadcrumb"
293+ className="flex-1 overflow-clip [overflow-clip-margin:2px]"
294+ >
292295 {/* The project always leads the header: knowing which project a
293296 thread lives in is priority zero, and the thread title alone
294297 doesn't answer it. */}
295298 {activeProjectName ? (
296299 <>
297− <WorkspaceBreadcrumbItem>
300+ <WorkspaceBreadcrumbItem className="shrink">
298301 <Tooltip>
299302 <TooltipTrigger
300303 render={
@@ -302,7 +305,7 @@ export const ChatHeader = memo(function ChatHeader({ · 1 unmodified line
302305 type="button"
303306 aria-label={`New thread in ${activeProjectName}`}
304307 onClick={onNewThreadInProject}
305− className="inline-flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
308+ className="inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
306309 />
307310 }
308311 >
@@ -320,7 +323,7 @@ export const ChatHeader = memo(function ChatHeader({ · 11 unmodified lines
320323 <WorkspaceBreadcrumbSeparator />
321324 </>
322325 ) : null}
323− <WorkspaceBreadcrumbItem current className="flex-1">
326+ <WorkspaceBreadcrumbItem current className="min-w-10 flex-1">
324327 {renamingTitle !== null ? (
325328 <input
326329 autoFocus
apps/web/src/components/files/FileBrowserPanel.tsx+39−2
@@ -3,9 +3,9 @@ import type { · 2 unmodified lines
33 ContextMenuOpenContext as TreeContextMenuOpenContext,
44 } from "@pierre/trees";
55 import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
6−import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";
6+import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
77 import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
8−import { RotateCw } from "lucide-react";
8+import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react";
99 import { useEffect, useMemo, useRef } from "react";
1010
1111 import { Button } from "~/components/ui/button";
@@ -21,6 +21,7 @@ import { readLocalApi } from "~/localApi"; · 9 unmodified lines
2121 import { T3_PIERRE_ICONS } from "~/pierre-icons";
2222
2323 import { createFileTreeDragMentionController } from "./fileTreeDragMention";
24+import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
2425 import { useProjectEntriesQuery } from "./projectFilesQueryState";
2526
2627 interface FileBrowserPanelProps {
@@ -121,6 +122,10 @@ export default function FileBrowserPanel({ · 94 unmodified lines
121122 );
122123 const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
123124 const treePaths = useMemo(() => entries.map(treePath), [entries]);
125+ const directoryPaths = useMemo(
126+ () => entries.filter((entry) => entry.kind === "directory").map(treePath),
127+ [entries],
128+ );
124129 const previousTreePathsRef = useRef<readonly string[]>([]);
125130 const syncingSelectionRef = useRef(false);
126131 const treeSelectionPathRef = useRef<string | null>(null);
@@ -252,6 +257,12 @@ export default function FileBrowserPanel({ · 125 unmodified lines
252257 unsafeCSS: TREE_UNSAFE_CSS,
253258 });
254259 const search = useFileTreeSearch(model);
260+ const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
261+ areAllDirectoriesExpanded(currentModel, directoryPaths),
262+ );
263+ const toggleAllDirectories = () => {
264+ setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded);
265+ };
255266 const handleSearchValueChange = (value: string) => {
256267 if (value.trim().length === 0) {
257268 search.close();
@@ -376,6 +387,32 @@ export default function FileBrowserPanel({ · 118 unmodified lines
376387 onValueChange={handleSearchValueChange}
377388 onClose={search.close}
378389 />
390+ {directoryPaths.length > 0 ? (
391+ <Tooltip>
392+ <TooltipTrigger
393+ render={
394+ <Button
395+ type="button"
396+ size="icon-xs"
397+ variant="ghost"
398+ aria-label={
399+ allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
400+ }
401+ onClick={toggleAllDirectories}
402+ />
403+ }
404+ >
405+ {allDirectoriesExpanded ? (
406+ <ChevronsDownUpIcon className="size-3.5" />
407+ ) : (
408+ <ChevronsUpDownIcon className="size-3.5" />
409+ )}
410+ </TooltipTrigger>
411+ <TooltipPopup>
412+ {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
413+ </TooltipPopup>
414+ </Tooltip>
415+ ) : null}
379416 </div>
380417 {entriesQuery.error && entriesQuery.data === null ? (
381418 <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>
apps/web/src/components/files/fileTreeExpansion.test.ts+60−0
@@ -0,0 +1,60 @@
1+import { describe, expect, it, vi } from "@effect/vitest";
2+
3+import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
4+
5+type FakeDirectoryItem = {
6+ isDirectory: () => true;
7+ isExpanded: () => boolean;
8+ expand: () => void;
9+ collapse: () => void;
10+};
11+
12+function makeModel(expanded: Record<string, boolean>) {
13+ const items = new Map<string, FakeDirectoryItem>();
14+ return {
15+ getItem: (path: string) => {
16+ const existing = items.get(path);
17+ if (existing !== undefined) return existing;
18+ const item: FakeDirectoryItem = {
19+ isDirectory: () => true,
20+ isExpanded: () => expanded[path] ?? false,
21+ expand: () => {
22+ expanded[path] = true;
23+ },
24+ collapse: () => {
25+ expanded[path] = false;
26+ },
27+ };
28+ items.set(path, item);
29+ return item;
30+ },
31+ };
32+}
33+
34+describe("file tree expansion", () => {
35+ it("requires at least one directory and detects whether all are expanded", () => {
36+ const model = makeModel({ "src/": true, "test/": true });
37+ expect(areAllDirectoriesExpanded(model, [])).toBe(false);
38+ expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true);
39+ expect(
40+ areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]),
41+ ).toBe(false);
42+ });
43+
44+ it("expands and collapses every directory", () => {
45+ const expanded = { "src/": true, "test/": false };
46+ const model = makeModel(expanded);
47+ setAllDirectoriesExpanded(model, ["src/", "test/"], true);
48+ expect(expanded).toEqual({ "src/": true, "test/": true });
49+ setAllDirectoriesExpanded(model, ["src/", "test/"], false);
50+ expect(expanded).toEqual({ "src/": false, "test/": false });
51+ });
52+
53+ it("skips directories already at the requested state", () => {
54+ const model = makeModel({ "src/": true });
55+ const item = model.getItem("src/");
56+ const collapse = vi.spyOn(item, "collapse");
57+ setAllDirectoriesExpanded(model, ["src/"], true);
58+ expect(collapse).not.toHaveBeenCalled();
59+ });
60+});
apps/web/src/components/files/fileTreeExpansion.ts+55−0
@@ -0,0 +1,55 @@
1+export interface FileTreeExpansionModel {
2+ getItem(path: string): unknown;
3+}
4+
5+type DirectoryHandle = {
6+ isDirectory(): boolean;
7+ isExpanded(): boolean;
8+ expand(): void;
9+ collapse(): void;
10+};
11+
12+function asDirectoryHandle(item: unknown): DirectoryHandle | null {
13+ if (
14+ typeof item !== "object" ||
15+ item === null ||
16+ !("isDirectory" in item) ||
17+ typeof item.isDirectory !== "function" ||
18+ !item.isDirectory() ||
19+ !("isExpanded" in item) ||
20+ typeof item.isExpanded !== "function" ||
21+ !("expand" in item) ||
22+ typeof item.expand !== "function" ||
23+ !("collapse" in item) ||
24+ typeof item.collapse !== "function"
25+ ) {
26+ return null;
27+ }
28+ return item as DirectoryHandle;
29+}
30+
31+export function areAllDirectoriesExpanded(
32+ model: FileTreeExpansionModel,
33+ directoryPaths: readonly string[],
34+): boolean {
35+ return (
36+ directoryPaths.length > 0 &&
37+ directoryPaths.every((path) => {
38+ const item = asDirectoryHandle(model.getItem(path));
39+ return item !== null && item.isExpanded();
40+ })
41+ );
42+}
43+
44+export function setAllDirectoriesExpanded(
45+ model: FileTreeExpansionModel,
46+ directoryPaths: readonly string[],
47+ expanded: boolean,
48+): void {
49+ for (const path of directoryPaths) {
50+ const item = asDirectoryHandle(model.getItem(path));
51+ if (item === null || item.isExpanded() === expanded) continue;
52+ if (expanded) item.expand();
53+ else item.collapse();
54+ }
55+}
apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+20−0
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; · 2 unmodified lines
33 import {
44 applyWslEnableSelection,
55 isQrShareableEndpoint,
6+ isWslSettingsRowVisible,
67 selectQrEndpointOption,
78 } from "./ConnectionsSettings.logic";
89
@@ -15,6 +16,25 @@ const baseWslState: DesktopWslState = { · 6 unmodified lines
1516 preflightError: null,
1617 };
1718
19+describe("isWslSettingsRowVisible", () => {
20+ it("shows the retry row when the WSL state failed to load", () => {
21+ expect(isWslSettingsRowVisible({ state: null, error: "load failed" })).toBe(true);
22+ });
23+
24+ it("hides an unavailable and unused WSL snapshot", () => {
25+ expect(
26+ isWslSettingsRowVisible({
27+ state: { ...baseWslState, available: false, wslOnly: false },
28+ error: null,
29+ }),
30+ ).toBe(false);
31+ });
32+
33+ it("shows an available WSL snapshot", () => {
34+ expect(isWslSettingsRowVisible({ state: baseWslState, error: null })).toBe(true);
35+ });
36+});
37+
1838 describe("applyWslEnableSelection", () => {
1939 it("clears WSL-only and updates the distro before enabling both backends", async () => {
2040 const calls: Array<string> = [];
apps/web/src/components/settings/ConnectionsSettings.logic.ts+8−0
@@ -11,6 +11,14 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { · 10 unmodified lines
1111 return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback";
1212 }
1313
14+export function isWslSettingsRowVisible(input: {
15+ readonly state: DesktopWslState | null;
16+ readonly error: string | null;
17+}): boolean {
18+ const { state, error } = input;
19+ return state ? state.available || state.enabled || state.wslOnly : error !== null;
20+}
21+
1422 export type QrEndpointOption = {
1523 /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */
1624 readonly id: string;
apps/web/src/components/settings/ConnectionsSettings.tsx+11−5
@@ -43,6 +43,7 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls · 42 unmodified lines
4343 import {
4444 applyWslEnableSelection,
4545 isQrShareableEndpoint,
46+ isWslSettingsRowVisible,
4647 selectQrEndpointOption,
4748 } from "./ConnectionsSettings.logic";
4849 import {
@@ -2766,10 +2767,13 @@ export function ConnectionsSettings() { · 2717 unmodified lines
27662767 // retry so the row doesn't flicker away, and the button reflects the
27672768 // loading state. With no error we simply haven't loaded yet (or WSL
27682769 // management isn't available), so render nothing.
2769− if (desktopWslError && canManageLocalBackend) {
2770+ if (
2771+ isWslSettingsRowVisible({ state: null, error: desktopWslError }) &&
2772+ canManageLocalBackend
2773+ ) {
27702774 return (
27712775 <SettingsRow
2772− title="WSL backend"
2776+ {...searchableSetting("wsl-backend")}
27732777 description="Couldn't load the WSL backend state."
27742778 status={<span className="block text-destructive">{desktopWslError}</span>}
27752779 control={
@@ -2794,11 +2798,13 @@ export function ConnectionsSettings() { · 18 unmodified lines
27942798 // be stranded on a WSL preference they can't clear, so render a recovery
27952799 // row that switches back to Windows. When WSL is unavailable AND unused,
27962800 // there's nothing to recover — keep the section hidden as before.
2801+ if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) {
2802+ return null;
2803+ }
27972804 if (!desktopWslState.available) {
2798− if (!desktopWslState.enabled && !desktopWslState.wslOnly) return null;
27992805 return (
28002806 <SettingsRow
2801− title="WSL backend"
2807+ {...searchableSetting("wsl-backend")}
28022808 description="WSL is no longer available, so the Windows backend is running instead. Switch off the WSL backend to clear this preference."
28032809 status={
28042810 desktopWslError ? (
@@ -2835,7 +2841,7 @@ export function ConnectionsSettings() { · 30 unmodified lines
28352841 return (
28362842 <>
28372843 <SettingsRow
2838− title="WSL backend"
2844+ {...searchableSetting("wsl-backend")}
28392845 description="Run a second backend inside a WSL distro alongside the Windows one. Pick a distro to start it; pick Off to stop it. Projects opened against the WSL backend live on the Linux side; Windows projects stay where they are."
28402846 status={
28412847 desktopWslError ? (
apps/web/src/components/settings/SettingsSidebarNav.tsx+22−1
@@ -7,6 +7,8 @@ import { · 6 unmodified lines
77 type ComponentType,
88 type KeyboardEvent,
99 } from "react";
10+import { useEnvironmentQuery } from "~/state/query";
11+import { desktopWslStateAtom } from "~/state/desktopWslState";
1012 import {
1113 ArchiveIcon,
1214 BlocksIcon,
@@ -21,6 +23,8 @@ import { · 8 unmodified lines
2123 } from "lucide-react";
2224 import { useLocation, useNavigate } from "@tanstack/react-router";
2325
26+import { isElectron } from "~/env";
27+import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
2428 import { Button } from "../ui/button";
2529 import { Input } from "../ui/input";
2630 import { Kbd } from "../ui/kbd";
@@ -38,6 +42,7 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; · 11 unmodified lines
3842 import { scrollToSettingsTarget } from "./settingsLayout";
3943 import {
4044 searchSettings,
45+ SETTINGS_SEARCH_ITEMS,
4146 SETTINGS_SECTION_LABELS,
4247 type SettingsPath,
4348 type SettingsSearchItem,
@@ -78,10 +83,26 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { · 34 unmodified lines
7883 const searchInputRef = useRef<HTMLInputElement>(null);
7984 const [query, setQuery] = useState("");
8085 const [activeResultIndex, setActiveResultIndex] = useState(0);
81− const results = useMemo(() => searchSettings(query), [query]);
86+ const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
87+ const searchableItems = useMemo(() => {
88+ const wslState = desktopWsl.data;
89+ const rowRenders = isWslSettingsRowVisible({
90+ state: wslState,
91+ error: desktopWsl.error,
92+ });
93+ if (rowRenders) {
94+ return SETTINGS_SEARCH_ITEMS;
95+ }
96+ return SETTINGS_SEARCH_ITEMS.filter((item) => item.id !== "wsl-backend");
97+ }, [desktopWsl.data, desktopWsl.error]);
98+ const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);
8299 const isSearching = query.trim().length > 0;
83100 const hasResults = results.length > 0;
84101
102+ useEffect(() => {
103+ setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0)));
104+ }, [results.length]);
105+
85106 useEffect(() => {
86107 const result = results[activeResultIndex];
87108 if (!result) return;
apps/web/src/components/settings/settingsSearch.test.ts+11−0
@@ -71,6 +71,17 @@ describe("searchSettings", () => { · 70 unmodified lines
7171 it("hides desktop-only settings from browser search", () => {
7272 expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true);
7373 expect(searchSettings("quit confirmation")).toEqual([]);
74+ expect(searchSettings("wsl")).toEqual([]);
75+ });
76+
77+ it("registers the WSL backend as a desktop-only setting", () => {
78+ expect(SETTINGS_SEARCH_ITEMS).toContainEqual({
79+ id: "wsl-backend",
80+ title: "WSL backend",
81+ to: "/settings/connections",
82+ desktopOnly: true,
83+ windowsOnly: true,
84+ });
7485 });
7586
7687 it("keeps catalog result ids unique", () => {
apps/web/src/components/settings/settingsSearch.ts+13−0
@@ -1,4 +1,5 @@
11 import { isElectron } from "~/env";
2+import { isWindowsPlatform } from "~/lib/utils";
23
34 export type SettingsPath =
45 | "/settings/general"
@@ -18,6 +19,9 @@ export interface SettingsSearchItem { · 13 unmodified lines
1819 // Its row only renders in the desktop app, so a browser result would land on
1920 // an anchor that isn't there.
2021 readonly desktopOnly?: boolean;
22+ // Its row only renders on Windows desktop, so other desktop platforms must
23+ // not expose a result that points to a missing anchor.
24+ readonly windowsOnly?: boolean;
2125 }
2226
2327 /**
@@ -264,6 +268,13 @@ export const SETTINGS_SEARCH_ITEMS = [ · 240 unmodified lines
264268 title: "Remote environments",
265269 to: "/settings/connections",
266270 },
271+ {
272+ id: "wsl-backend",
273+ title: "WSL backend",
274+ to: "/settings/connections",
275+ desktopOnly: true,
276+ windowsOnly: true,
277+ },
267278 {
268279 id: "archive",
269280 title: "Archived threads",
@@ -309,6 +320,8 @@ export function searchSettings( · 39 unmodified lines
309320 return items.filter(
310321 (item) =>
311322 (isElectron || item.desktopOnly !== true) &&
323+ (!item.windowsOnly ||
324+ isWindowsPlatform(typeof navigator === "undefined" ? "" : navigator.platform)) &&
312325 normalizeSearchText(item.title).includes(normalizedQuery),
313326 );
314327 }
docs/internals/mobile-navigation.md+22−5
@@ -51,13 +51,12 @@ use a separate implementation and do not need this workaround. · 50 unmodified lines
5151
5252 ## Native media presentations
5353
54−`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's
55−full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child
54+`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit,
55+image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child
5656 and pass the stable identifier to the presentation. The registry keeps weak
5757 references to source views; recycled or compact composer thumbnails can register
5858 the same identifier. Identifiers must distinguish simultaneously visible attachments.
59−This source registration does not own playback and can be reused by a future image
60−viewer. Android uses a regular view.
59+The source registration does not own the preview. Android uses a regular view.
6160
6261 On iOS, video previews mount `AVPlayerViewController` temporarily inside the
6362 registered source and enter full screen through AVKit. AVKit
@@ -67,7 +66,25 @@ simulator, that leaves native Close unable to exit full screen. When the source · 3 unmodified lines
6766 the player uses a standard modal presentation. Programmatic entry uses the same
6867 guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video;
6968 if that selector is unavailable, the player also falls back to a standard modal.
70−Images retain their existing viewer.
69+
70+`FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset,
71+or a retained composer file. On iOS, Quick Look owns image and document layout, controls,
72+zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail
73+and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or
74+another image scroll view over that presentation: Quick Look coordinates its image gestures
75+with the return to the thumbnail. Missing sources use the standard transition, and Reduce
76+Motion disables animation. A pending programmatic Close waits until the current presentation
77+or cancelled dismissal has settled before starting another transition.
78+
79+The shared native presenter copies original bytes into its own temporary directory and
80+removes that copy after dismissal. Network downloads write to disk, and sharing never edits
81+the source attachment. Draft images use their stored upload data rather than a potentially
82+expired picker URI. No React Navigation route or custom transition animator is needed.
83+
84+The same viewer handles message images, markdown images, PDF attachments and links,
85+composer thumbnails, and workspace image previews. The workspace PDF web preview has an
86+Open PDF action for the native viewer. Android retains its image viewer and uses the
87+system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission.
7188
7289 Received videos open directly from their signed asset URL. AVKit handles buffering;
7390 the client does not download the entire file or show a separate opening overlay before
docs/user/composer.md+9−2
@@ -20,7 +20,13 @@ file uploads, **+** opens a menu beside the button with **Photo Library** and ** · 19 unmodified lines
2020 Videos use the server's file upload limit. You can also share photos, videos, and files into
2121 T3 Code from other apps through the system share sheet. Mobile uploads happen when the message
2222 sends, so queued messages keep their files until they deliver. Select a received file on mobile
23−to save it or open it in another app through the system share sheet.
23+to preview it or open the system share options.
24+
25+Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail
26+into the native viewer. Pinch or double-tap to zoom, and swipe down or tap Close to return.
27+Use Share to save a copy or send it to another app. PDFs support page navigation and search.
28+PDF links in assistant responses open the same preview. On Android, images open in the image
29+viewer and PDFs open the system chooser.
2430
2531 Select a video attachment before or after sending to play it. Web and desktop use the browser's
2632 built-in controls. On mobile, videos open in a full-screen player with native playback controls.
@@ -36,7 +42,8 @@ On web and desktop, if you reload before a file finishes uploading, the draft ke · 9 unmodified lines
3642 and shows **Attach again** next to it. Attach the file again or remove it, then send.
3743
3844 On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into
39−the composer or paste them into a message.
45+the composer or paste them into a message. On iOS, selecting them from **Photo Library** also
46+converts them to JPEG. The 10 MB image limit applies to the converted photo.
4047
4148 On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic,
4249 GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
packages/shared/src/shell.test.ts+5−5
@@ -460,7 +460,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { · 459 unmodified lines
460460 });
461461
462462 effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => {
463− it.effect("returns the baseline no-profile PATH patch when node is already available", () =>
463+ it.effect("uses known CLI directories as a fallback without changing shell PATH priority", () =>
464464 Effect.gen(function* () {
465465 const readEnvironment = vi.fn(
466466 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
@@ -483,6 +483,8 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 16 unmodified lines
483483 ),
484484 ).toEqual({
485485 PATH: [
486+ "C:\\Shell\\Bin",
487+ "C:\\Windows\\System32",
486488 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
487489 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
488490 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
@@ -490,8 +492,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 1 unmodified line
490492 "C:\\Users\\testuser\\.local\\bin",
491493 "C:\\Users\\testuser\\.bun\\bin",
492494 "C:\\Users\\testuser\\scoop\\shims",
493− "C:\\Shell\\Bin",
494− "C:\\Windows\\System32",
495495 ].join(";"),
496496 });
497497 expect(readEnvironment).toHaveBeenCalledTimes(1);
@@ -532,6 +532,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 34 unmodified lines
532532 PATH: [
533533 "C:\\Profile\\Node",
534534 "C:\\Windows\\System32",
535+ "C:\\Shell\\Bin",
535536 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
536537 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
537538 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
@@ -539,7 +540,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 1 unmodified line
539540 "C:\\Users\\testuser\\.local\\bin",
540541 "C:\\Users\\testuser\\.bun\\bin",
541542 "C:\\Users\\testuser\\scoop\\shims",
542− "C:\\Shell\\Bin",
543543 ].join(";"),
544544 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
545545 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
@@ -576,11 +576,11 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 30 unmodified lines
576576 ),
577577 ).toEqual({
578578 PATH: [
579+ "C:\\Windows\\System32",
579580 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
580581 "C:\\Users\\testuser\\.local\\bin",
581582 "C:\\Users\\testuser\\.bun\\bin",
582583 "C:\\Users\\testuser\\scoop\\shims",
583− "C:\\Windows\\System32",
584584 ].join(";"),
585585 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
586586 });
packages/shared/src/shell.ts+3−1
@@ -728,7 +728,9 @@ export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironm · 727 unmodified lines
728728 }).PATH;
729729 const mergedPath = mergePathValues(shellPath, inheritedPath, "win32");
730730 const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER);
731− const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32");
731+ // Preserve the order a user's shell uses. These directories fill gaps when
732+ // desktop apps launch without the full interactive-shell PATH.
733+ const baselinePath = mergePathValues(mergedPath, knownCliPath, "win32");
732734 const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {};
733735 const baselineEnv = mergeWindowsEnv(env, baselinePatch);
734736

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t