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 @@
1reviews:1reviews:
2 review_status: false2 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
206 },206 },
207 NSLocalNetworkUsageDescription:207 NSLocalNetworkUsageDescription:
208 "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.",208 "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.",
209 ITSAppUsesNonExemptEncryption: false,210 ITSAppUsesNonExemptEncryption: false,
210 // The App Store screenshot harness rotates the iPad interface from211 // The App Store screenshot harness rotates the iPad interface from
211 // inside the app (CI denies osascript the Accessibility access that212 // 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
5public final class T3NativeControlsModule: Module {5public final class T3NativeControlsModule: Module {
6 private let presentationSources = T3PresentationSources()6 private let presentationSources = T3PresentationSources()
7 private var videoPresentation: T3NativeVideoPresentation?7 private var videoPresentation: T3NativeVideoPresentation?
8 private var filePresentation: T3NativeFilePresentation?
8 9
9 public func definition() -> ModuleDefinition {10 public func definition() -> ModuleDefinition {
10 Name("T3NativeControls")11 Name("T3NativeControls")
@@ -23,9 +24,22 @@ public final class T3NativeControlsModule: Module { · 12 unmodified lines
23 self.dismissVideo(identifier: identifier)24 self.dismissVideo(identifier: identifier)
24 }.runOnQueue(.main)25 }.runOnQueue(.main)
25 26
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
26 OnDestroy {36 OnDestroy {
27 let presentation = self.videoPresentation37 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 }
29 }43 }
30 44
31 View(T3PresentationSourceView.self) {45 View(T3PresentationSourceView.self) {
@@ -140,7 +154,7 @@ public final class T3NativeControlsModule: Module { · 108 unmodified lines
140 let isPlayableURL = url.isFileURL154 let isPlayableURL = url.isFileURL
141 ? FileManager.default.isReadableFile(atPath: url.path)155 ? FileManager.default.isReadableFile(atPath: url.path)
142 : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil)156 : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil)
143 guard videoPresentation == nil,157 guard videoPresentation == nil, filePresentation == nil,
144 let presenter = appContext?.utilities?.currentViewController(),158 let presenter = appContext?.utilities?.currentViewController(),
145 isPlayableURL159 isPlayableURL
146 else {160 else {
@@ -162,6 +176,24 @@ public final class T3NativeControlsModule: Module { · 15 unmodified lines
162 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }176 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }
163 }177 }
164 178
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
165 private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws {197 private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws {
166 guard let presenter = appContext?.utilities?.currentViewController() else {198 guard let presenter = appContext?.utilities?.currentViewController() else {
167 throw NSError(199 throw NSError(
apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift+165−0
@@ -0,0 +1,165 @@
1import ImageIO
2import QuickLook
3import UIKit
4import UniformTypeIdentifiers
5
6private final class FilePreviewItem: NSObject, QLPreviewItem {
7 var previewItemURL: URL?
8 var previewItemTitle: String?
9}
10
11private 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.
21final 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
6import { AppText as Text } from "./AppText";6import { AppText as Text } from "./AppText";
7import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";7import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
8import { VideoAttachmentTile } from "./VideoAttachmentTile";8import { VideoAttachmentTile } from "./VideoAttachmentTile";
9import { loadLocalVideoPreview } from "../lib/localVideoPreview";9import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
10import { PresentationSource } from "./NativePresentation";
11import type { FilePreviewSource } from "./FilePreviewModal";
12import { isPdfFile } from "../lib/filePreview";
10 13
11export interface ComposerAttachmentStripProps {14export interface ComposerAttachmentStripProps {
12 /** Attachments to display. */15 /** Attachments to display. */
13 readonly attachments: ReadonlyArray<DraftComposerAttachment>;16 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
14 /** Called when the user removes an attachment. */17 /** Called when the user removes an attachment. */
15 readonly onRemove: (imageId: string) => void;18 readonly onRemove: (imageId: string) => void;
16 /** Called when the user taps on an image thumbnail to preview it. */19 /** Called when the user taps an image or PDF to preview it. */
17 readonly onPressImage?: (previewUri: string) => void;20 readonly onPressPreview?: (source: FilePreviewSource) => void;
18 readonly onPressVideo?: (21 readonly onPressVideo?: (
19 attachment: DraftComposerFileAttachment,22 attachment: DraftComposerFileAttachment,
20 sourceIdentifier: string,23 sourceIdentifier: string,
@@ -32,7 +35,7 @@ export function ComposerAttachmentThumbnail(props: { · 11 unmodified lines
32 readonly size: number;35 readonly size: number;
33 readonly borderRadius: number;36 readonly borderRadius: number;
34 readonly compact?: boolean;37 readonly compact?: boolean;
35 readonly onPressImage?: (previewUri: string) => void;38 readonly onPressPreview?: (source: FilePreviewSource) => void;
36 readonly onPressVideo?: (39 readonly onPressVideo?: (
37 attachment: DraftComposerFileAttachment,40 attachment: DraftComposerFileAttachment,
38 sourceIdentifier: string,41 sourceIdentifier: string,
@@ -41,17 +44,30 @@ export function ComposerAttachmentThumbnail(props: { · 2 unmodified lines
41 const { attachment } = props;44 const { attachment } = props;
42 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };45 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
43 if (attachment.type === "image") {46 if (attachment.type === "image") {
47 const sourceIdentifier = `draft-image:${attachment.id}`;
44 return (48 return (
45 <Pressable49 <PresentationSource identifier={sourceIdentifier}>
46 onPress={props.onPressImage ? () => props.onPressImage?.(attachment.previewUri) : undefined}50 <Pressable
47 >51 accessibilityRole="imagebutton"
48 <Image52 accessibilityLabel={`Open ${attachment.name}`}
49 source={{ uri: attachment.previewUri }}53 disabled={!props.onPressPreview}
50 style={style}54 onPress={() =>
51 className="bg-subtle"55 props.onPressPreview?.({
52 resizeMode="cover"56 kind: "image",
53 />57 uri: attachment.dataUrl,
54 </Pressable>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>
55 );71 );
56 }72 }
57 const onPressVideo = props.onPressVideo;73 const onPressVideo = props.onPressVideo;
@@ -60,27 +76,42 @@ export function ComposerAttachmentThumbnail(props: { · 2 unmodified lines
60 <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} />76 <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} />
61 );77 );
62 }78 }
79 const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined;
80 const sourceIdentifier = `draft-file:${attachment.id}`;
63 return (81 return (
64 <View82 <PresentationSource identifier={sourceIdentifier}>
65 className={83 <Pressable
66 props.compact84 accessibilityRole="button"
67 ? "items-center justify-center bg-subtle"85 accessibilityLabel={`Open ${attachment.name}`}
68 : "items-center justify-center gap-1 bg-subtle px-2"86 disabled={!canPreview}
69 }87 onPress={() =>
70 style={style}88 props.onPressPreview?.({
71 >89 kind: "pdf",
72 <SymbolView90 name: attachment.name,
73 name="doc.text"91 attachment,
74 size={props.compact ? 15 : 22}92 sourceIdentifier,
75 tintColor="#a3a3a3"93 })
76 type="monochrome"94 }
77 />95 className={
78 {!props.compact ? (96 props.compact
79 <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>97 ? "items-center justify-center bg-subtle"
80 {attachment.name}98 : "items-center justify-center gap-1 bg-subtle px-2"
81 </Text>99 }
82 ) : null}100 style={style}
83 </View>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>
84 );115 );
85}116}
86 117
@@ -113,7 +144,7 @@ function ComposerVideoAttachment(props: { · 26 unmodified lines
113 shareRef.current = controller;144 shareRef.current = controller;
114 setSharing(true);145 setSharing(true);
115 void (async () => {146 void (async () => {
116 const preview = await loadLocalVideoPreview(attachment, controller.signal);147 const preview = await loadLocalAttachmentPreview(attachment, controller.signal);
117 if (!preview) return;148 if (!preview) return;
118 try {149 try {
119 await preview.share(controller.signal, sourceIdentifier);150 await preview.share(controller.signal, sourceIdentifier);
@@ -185,7 +216,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { · 65 unmodified lines
185 attachment={attachment}216 attachment={attachment}
186 size={size}217 size={size}
187 borderRadius={radius}218 borderRadius={radius}
188 onPressImage={props.onPressImage}219 onPressPreview={props.onPressPreview}
189 onPressVideo={props.onPressVideo}220 onPressVideo={props.onPressVideo}
190 />221 />
191 <Pressable222 <Pressable
apps/mobile/src/components/FilePreview.ios.tsx+43−0
@@ -0,0 +1,43 @@
1import { requireNativeModule } from "expo";
2import { useEffect, useEffectEvent, useId } from "react";
3import { Alert } from "react-native";
4
5import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
6
7const 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
17export 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 @@
1import { useEffect, useEffectEvent } from "react";
2import { Alert } from "react-native";
3import ImageViewing from "react-native-image-viewing";
4
5import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload";
6import type { ResolvedFilePreviewSource } from "./FilePreviewModal";
7
8function 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
37export 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 @@
1import { useIsFocused } from "@react-navigation/native";
2import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
3import { useEffect, useEffectEvent, useState } from "react";
4import { Alert, Keyboard } from "react-native";
5
6import type { DraftComposerFileAttachment } from "../lib/composerImages";
7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { useAssetUrlState } from "../state/assets";
9import { usePreparedConnection } from "../state/session";
10import { FilePreview } from "./FilePreview";
11
12export interface ResolvedFilePreviewSource {
13 readonly kind: "image" | "pdf";
14 readonly uri: string;
15 readonly name?: string;
16 readonly sourceIdentifier?: string;
17}
18
19export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
20 (
21 | { readonly uri: string }
22 | { readonly attachment: DraftComposerFileAttachment }
23 | { readonly environmentId: EnvironmentId; readonly resource: AssetResource }
24 );
25
26function 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
80export 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
4import { useEffect, useEffectEvent, useId, useState } from "react";4import { useEffect, useEffectEvent, useId, useState } from "react";
5import { Alert, Keyboard } from "react-native";5import { Alert, Keyboard } from "react-native";
6 6
7import { loadLocalVideoPreview } from "../lib/localVideoPreview";7import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
8import { useAssetUrlState } from "../state/assets";8import { useAssetUrlState } from "../state/assets";
9import { usePreparedConnection } from "../state/session";9import { usePreparedConnection } from "../state/session";
10import type { VideoPreviewSource } from "./VideoPreviewModal";10import type { VideoPreviewSource } from "./VideoPreviewModal";
@@ -67,7 +67,7 @@ function NativeVideoPreview(props: { · 56 unmodified lines
67 void (async () => {67 void (async () => {
68 const file =68 const file =
69 source.type === "local"69 source.type === "local"
70 ? await loadLocalVideoPreview(source.attachment, controller.signal)70 ? await loadLocalAttachmentPreview(source.attachment, controller.signal)
71 : null;71 : null;
72 if (source.type === "local" && !file) return;72 if (source.type === "local" && !file) return;
73 try {73 try {
apps/mobile/src/components/VideoPreviewModal.tsx+2−2
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
20 type AttachmentPreviewFile,20 type AttachmentPreviewFile,
21} from "../lib/attachmentDownload";21} from "../lib/attachmentDownload";
22import type { DraftComposerFileAttachment } from "../lib/composerImages";22import type { DraftComposerFileAttachment } from "../lib/composerImages";
23import { loadLocalVideoPreview } from "../lib/localVideoPreview";23import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
24import { useAssetUrlState } from "../state/assets";24import { useAssetUrlState } from "../state/assets";
25import { usePreparedConnection } from "../state/session";25import { usePreparedConnection } from "../state/session";
26import { SymbolView } from "./AppSymbol";26import { SymbolView } from "./AppSymbol";
@@ -155,7 +155,7 @@ function OpenVideoPreviewModal(props: { · 128 unmodified lines
155 setFailure(null);155 setFailure(null);
156 const loading =156 const loading =
157 source.type === "local"157 source.type === "local"
158 ? loadLocalVideoPreview(source.attachment, controller.signal)158 ? loadLocalAttachmentPreview(source.attachment, controller.signal)
159 : downloadAttachmentForPreview({159 : downloadAttachmentForPreview({
160 url: downloadUrl!,160 url: downloadUrl!,
161 attachment: { name: attachment.name, mimeType },161 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
5import { StyleSheet } from "react-native";5import { StyleSheet } from "react-native";
6 6
7import type { DraftComposerFileAttachment } from "../lib/composerImages";7import type { DraftComposerFileAttachment } from "../lib/composerImages";
8import { loadLocalVideoPreview } from "../lib/localVideoPreview";8import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
9import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails";9import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails";
10 10
11export function VideoThumbnailImage(props: {11export function VideoThumbnailImage(props: {
@@ -25,7 +25,7 @@ export function VideoThumbnailImage(props: { · 13 unmodified lines
25 async (signal) =>25 async (signal) =>
26 typeof source === "string"26 typeof source === "string"
27 ? { uri: source, dispose: () => undefined }27 ? { uri: source, dispose: () => undefined }
28 : loadLocalVideoPreview(source, signal),28 : loadLocalAttachmentPreview(source, signal),
29 controller.signal,29 controller.signal,
30 ).then((thumbnail) => {30 ).then((thumbnail) => {
31 if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail });31 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
17import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";17import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
18import { ControlPillMenu } from "../../components/ControlPill";18import { ControlPillMenu } from "../../components/ControlPill";
19import { EmptyState } from "../../components/EmptyState";19import { EmptyState } from "../../components/EmptyState";
20import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
20import { LoadingScreen } from "../../components/LoadingScreen";21import { LoadingScreen } from "../../components/LoadingScreen";
21import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";22import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
22import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";23import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
24import { isPdfFile } from "../../lib/filePreview";
23import { tryOpenExternalUrl } from "../../lib/openExternalUrl";25import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
24import { useUniwindTheme } from "../../lib/useUniwindTheme";26import { useUniwindTheme } from "../../lib/useUniwindTheme";
25import { useThreadSelection } from "../../state/use-thread-selection";27import { useThreadSelection } from "../../state/use-thread-selection";
@@ -487,6 +489,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 461 unmodified lines
487 readonly mode: FileViewMode;489 readonly mode: FileViewMode;
488 } | null>(null);490 } | null>(null);
489 const [previewRevision, setPreviewRevision] = useState(0);491 const [previewRevision, setPreviewRevision] = useState(0);
492 const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null);
490 const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);493 const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);
491 const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);494 const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);
492 const canPreview =495 const canPreview =
@@ -586,6 +589,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 93 unmodified lines
586 inline: false,589 inline: false,
587 onPress: () => copyTextWithHaptic(relativePath),590 onPress: () => copyTextWithHaptic(relativePath),
588 } as const,591 } 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,
589 isBrowserFile && typeof assetPreviewUri === "string"606 isBrowserFile && typeof assetPreviewUri === "string"
590 ? ({607 ? ({
591 id: "open-browser",608 id: "open-browser",
@@ -605,7 +622,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 13 unmodified lines
605 } as const)622 } as const)
606 : null,623 : null,
607 ].filter((action) => action !== null);624 ].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 ]);
609 634
610 const androidFileMenuActions = useMemo<MenuAction[]>(635 const androidFileMenuActions = useMemo<MenuAction[]>(
611 () =>636 () =>
@@ -766,6 +791,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { · 154 unmodified lines
766 truncated={fileData?.truncated ?? false}791 truncated={fileData?.truncated ?? false}
767 onRefresh={() => fileQuery.refresh()}792 onRefresh={() => fileQuery.refresh()}
768 />793 />
794 <FilePreviewModal
795 source={fullScreenPreview}
796 onRequestClose={() => setFullScreenPreview(null)}
797 />
769 </View>798 </View>
770 </ReviewHighlighterProvider>799 </ReviewHighlighterProvider>
771 );800 );
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+26−23
@@ -1,24 +1,25 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useMemo, useState } from "react";2import { useId, useMemo, useState } from "react";
3import { ActivityIndicator, Image, Pressable, View } from "react-native";3import { ActivityIndicator, Image, Pressable, View } from "react-native";
4import ImageViewing from "react-native-image-viewing";
5import { AsyncResult } from "effect/unstable/reactivity";4import { AsyncResult } from "effect/unstable/reactivity";
6 5
7import { AppText as Text } from "../../components/AppText";6import { AppText as Text } from "../../components/AppText";
8import { EmptyState } from "../../components/EmptyState";7import { EmptyState } from "../../components/EmptyState";
9import { workspaceFileImageAtom } from "./workspace-file-image-cache";8import { workspaceFileImageAtom } from "./workspace-file-image-cache";
9import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
10import { PresentationSource } from "../../components/NativePresentation";
10 11
11function ResolvedWorkspaceFileImagePreview(props: {12function ResolvedWorkspaceFileImagePreview(props: {
12 readonly accessibilityLabel: string;13 readonly accessibilityLabel: string;
13 readonly uri: string;14 readonly uri: string;
14}) {15}) {
15 const [loadError, setLoadError] = useState<string | null>(null);16 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();
17 const imageSource = useMemo(19 const imageSource = useMemo(
18 () => ({ uri: props.uri, cache: "force-cache" as const }),20 () => ({ uri: props.uri, cache: "force-cache" as const }),
19 [props.uri],21 [props.uri],
20 );22 );
21 const fullScreenImages = useMemo(() => [imageSource], [imageSource]);
22 23
23 return (24 return (
24 <View className="relative flex-1 bg-subtle">25 <View className="relative flex-1 bg-subtle">
@@ -27,18 +28,27 @@ function ResolvedWorkspaceFileImagePreview(props: { · 2 unmodified lines
27 accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`}28 accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`}
28 disabled={loadError !== null}29 disabled={loadError !== null}
29 className="flex-1 p-4 active:bg-subtle-strong"30 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 }
31 >39 >
32 <Image40 <PresentationSource identifier={sourceIdentifier} style={{ flex: 1 }}>
33 accessible={false}41 <Image
34 source={imageSource}42 accessible={false}
35 className="h-full w-full"43 source={imageSource}
36 resizeMode="contain"44 className="h-full w-full"
37 onLoadStart={() => setLoadError(null)}45 resizeMode="contain"
38 onError={(event) => {46 onLoadStart={() => setLoadError(null)}
39 setLoadError(event.nativeEvent.error || "The image could not be rendered.");47 onError={(event) => {
40 }}48 setLoadError(event.nativeEvent.error || "The image could not be rendered.");
41 />49 }}
50 />
51 </PresentationSource>
42 </Pressable>52 </Pressable>
43 53
44 {loadError !== null ? (54 {loadError !== null ? (
@@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: { · 2 unmodified lines
47 </View>57 </View>
48 ) : null}58 ) : null}
49 59
50 <ImageViewing60 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
51 images={fullScreenImages}
52 imageIndex={0}
53 visible={fullScreenVisible}
54 onRequestClose={() => setFullScreenVisible(false)}
55 swipeToCloseEnabled
56 doubleTapToZoomEnabled
57 />
58 </View>61 </View>
59 );62 );
60}63}
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+4−11
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; · 4 unmodified lines
5import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";5import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native";
6import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";6import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller";
7import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
8import ImageViewing from "react-native-image-viewing";8import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
9 9
10import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";10import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
11import { SymbolView } from "../../components/AppSymbol";11import { SymbolView } from "../../components/AppSymbol";
@@ -53,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 41 unmodified lines
53 Record<string, ReadonlyArray<ReviewHighlightedToken>>53 Record<string, ReadonlyArray<ReviewHighlightedToken>>
54 >({});54 >({});
55 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);55 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
56 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);56 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
57 57
58 const selectedLines = useMemo(58 const selectedLines = useMemo(
59 () => (target ? getSelectedReviewCommentLines(target) : []),59 () => (target ? getSelectedReviewCommentLines(target) : []),
@@ -272,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 212 unmodified lines
272 attachments={attachments}272 attachments={attachments}
273 imageBorderRadius={16}273 imageBorderRadius={16}
274 imageSize={60}274 imageSize={60}
275 onPressImage={setPreviewImageUri}275 onPressPreview={setPreviewFile}
276 removeButtonPlacement="gutter"276 removeButtonPlacement="gutter"
277 onRemove={(imageId) => {277 onRemove={(imageId) => {
278 setAttachments((current) =>278 setAttachments((current) =>
@@ -332,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp · 53 unmodified lines
332 </View>332 </View>
333 </KeyboardStickyView>333 </KeyboardStickyView>
334 ) : null}334 ) : null}
335 <ImageViewing335 <FilePreviewModal source={previewFile} onRequestClose={() => setPreviewFile(null)} />
336 images={previewImageUri ? [{ uri: previewImageUri }] : []}
337 imageIndex={0}
338 visible={previewImageUri !== null}
339 onRequestClose={() => setPreviewImageUri(null)}
340 swipeToCloseEnabled
341 doubleTapToZoomEnabled
342 />
343 </View>336 </View>
344 );337 );
345}338}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+21−5
@@ -35,6 +35,7 @@ import { · 34 unmodified lines
35import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";35import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
36import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";36import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton";
37import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";37import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
38import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
38import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";39import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
39import { ProviderIcon } from "../../components/ProviderIcon";40import { ProviderIcon } from "../../components/ProviderIcon";
40import { SymbolView } from "../../components/AppSymbol";41import { SymbolView } from "../../components/AppSymbol";
@@ -167,17 +168,28 @@ export function NewTaskDraftScreen(props: { · 126 unmodified lines
167 const loadedBranchesProjectKeyRef = useRef<string | null>(null);168 const loadedBranchesProjectKeyRef = useRef<string | null>(null);
168 const [isComposerFocused, setIsComposerFocused] = useState(false);169 const [isComposerFocused, setIsComposerFocused] = useState(false);
169 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);170 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);
171 const openVideoPreview = useCallback(173 const openVideoPreview = useCallback(
172 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {174 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
173 wasFocusedBeforeVideoRef.current = isComposerFocused;175 wasFocusedBeforePreviewRef.current = isComposerFocused;
176 setPreviewFile(null);
174 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });177 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
175 },178 },
176 [isComposerFocused],179 [isComposerFocused],
177 );180 );
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(() => {
179 setPreviewVideo(null);190 setPreviewVideo(null);
180 if (wasFocusedBeforeVideoRef.current) {191 setPreviewFile(null);
192 if (wasFocusedBeforePreviewRef.current) {
181 setTimeout(() => {193 setTimeout(() => {
182 if (navigation.isFocused()) promptInputRef.current?.focus();194 if (navigation.isFocused()) promptInputRef.current?.focus();
183 }, 100);195 }, 100);
@@ -1212,6 +1224,9 @@ export function NewTaskDraftScreen(props: { · 1028 unmodified lines
1212 ? () => undefined1224 ? () => undefined
1213 : flow.removeAttachment1225 : flow.removeAttachment
1214 }1226 }
1227 onPressPreview={
1228 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview
1229 }
1215 onPressVideo={1230 onPressVideo={
1216 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview1231 isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview
1217 }1232 }
@@ -1318,7 +1333,8 @@ export function NewTaskDraftScreen(props: { · 100 unmodified lines
1318 </ComposerDictationToolbar>1333 </ComposerDictationToolbar>
1319 </Animated.View>1334 </Animated.View>
1320 </ComposerSurface>1335 </ComposerSurface>
1321 <VideoPreviewModal source={previewVideo} onRequestClose={closeVideoPreview} />1336 <VideoPreviewModal source={previewVideo} onRequestClose={closeMediaPreview} />
1337 <FilePreviewModal source={previewFile} onRequestClose={closeMediaPreview} />
1322 </View>1338 </View>
1323 );1339 );
1324 1340
apps/mobile/src/features/threads/ThreadComposer.tsx+10−17
@@ -20,7 +20,7 @@ import { · 19 unmodified lines
20 type RefObject,20 type RefObject,
21} from "react";21} from "react";
22import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";22import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native";
23import ImageViewing from "react-native-image-viewing";23import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
24import Animated, {24import Animated, {
25 FadeIn,25 FadeIn,
26 FadeInDown,26 FadeInDown,
@@ -311,7 +311,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 284 unmodified lines
311 const inFlightThreadIdsRef = useRef(new Set<string>());311 const inFlightThreadIdsRef = useRef(new Set<string>());
312 const { onExpandedChange } = props;312 const { onExpandedChange } = props;
313 313
314 const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);314 const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
315 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);315 const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
316 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;316 const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0;
317 const showStopAction =317 const showStopAction =
@@ -372,17 +372,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 54 unmodified lines
372 onExpandedChange?.(isExpanded);372 onExpandedChange?.(isExpanded);
373 }, [isExpanded, onExpandedChange]);373 }, [isExpanded, onExpandedChange]);
374 374
375 const onPressImage = useCallback(375 const onPressPreview = useCallback(
376 (uri: string) => {376 (source: FilePreviewSource) => {
377 wasExpandedBeforePreviewRef.current = isFocused;377 wasExpandedBeforePreviewRef.current = isFocused;
378 setPreviewVideo(null);378 setPreviewVideo(null);
379 setPreviewImageUri(uri);379 setPreviewFile((current) => current ?? source);
380 },380 },
381 [isFocused],381 [isFocused],
382 );382 );
383 383
384 const closePreview = useCallback(() => {384 const closePreview = useCallback(() => {
385 setPreviewImageUri(null);385 setPreviewFile(null);
386 setPreviewVideo(null);386 setPreviewVideo(null);
387 if (wasExpandedBeforePreviewRef.current) {387 if (wasExpandedBeforePreviewRef.current) {
388 setTimeout(() => {388 setTimeout(() => {
@@ -394,7 +394,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 5 unmodified lines
394 const onPressVideo = useCallback(394 const onPressVideo = useCallback(
395 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {395 (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => {
396 wasExpandedBeforePreviewRef.current = isFocused;396 wasExpandedBeforePreviewRef.current = isFocused;
397 setPreviewImageUri(null);397 setPreviewFile(null);
398 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });398 setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier });
399 },399 },
400 [isFocused],400 [isFocused],
@@ -619,7 +619,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 218 unmodified lines
619 <ComposerAttachmentStrip619 <ComposerAttachmentStrip
620 attachments={props.draftAttachments}620 attachments={props.draftAttachments}
621 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}621 onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage}
622 onPressImage={voiceInput.isBusy ? undefined : onPressImage}622 onPressPreview={voiceInput.isBusy ? undefined : onPressPreview}
623 onPressVideo={voiceInput.isBusy ? undefined : onPressVideo}623 onPressVideo={voiceInput.isBusy ? undefined : onPressVideo}
624 />624 />
625 </Animated.View>625 </Animated.View>
@@ -673,7 +673,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 47 unmodified lines
673 size={30}673 size={30}
674 borderRadius={8}674 borderRadius={8}
675 compact675 compact
676 onPressImage={onPressImage}676 onPressPreview={onPressPreview}
677 onPressVideo={onPressVideo}677 onPressVideo={onPressVideo}
678 />678 />
679 ))}679 ))}
@@ -819,14 +819,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 139 unmodified lines
819 </Animated.View>819 </Animated.View>
820 820
821 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />821 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
822 <ImageViewing822 <FilePreviewModal source={previewFile} onRequestClose={closePreview} />
823 images={previewImageUri ? [{ uri: previewImageUri }] : []}
824 imageIndex={0}
825 visible={previewImageUri !== null}
826 onRequestClose={closePreview}
827 swipeToCloseEnabled
828 doubleTapToZoomEnabled
829 />
830 </Animated.View>823 </Animated.View>
831 );824 );
832});825});
apps/mobile/src/features/threads/ThreadFeed.tsx+152−79
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
36 useMemo,36 useMemo,
37 useRef,37 useRef,
38 useState,38 useState,
39 useId,
39 type ReactNode,40 type ReactNode,
40 type RefObject,41 type RefObject,
41} from "react";42} from "react";
@@ -62,8 +63,9 @@ import { · 20 unmodified lines
62 View,63 View,
63 type ViewStyle,64 type ViewStyle,
64} from "react-native";65} from "react-native";
65import { TouchableOpacity } from "react-native-gesture-handler";66import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
66import ImageViewing from "react-native-image-viewing";67import { isPdfFile } from "../../lib/filePreview";
68import { PresentationSource } from "../../components/NativePresentation";
67import { useSafeAreaInsets } from "react-native-safe-area-context";69import { useSafeAreaInsets } from "react-native-safe-area-context";
68import Animated, {70import Animated, {
69 FadeIn,71 FadeIn,
@@ -212,9 +214,11 @@ export interface ThreadFeedProps { · 142 unmodified lines
212function MessageAttachmentImage(props: {214function MessageAttachmentImage(props: {
213 readonly environmentId: EnvironmentId;215 readonly environmentId: EnvironmentId;
214 readonly attachmentId: string;216 readonly attachmentId: string;
217 readonly name: string;
215 readonly className: string;218 readonly className: string;
216 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;219 readonly onPressPreview: (source: FilePreviewSource) => void;
217}) {220}) {
221 const sourceIdentifier = useId();
218 const uri = useAssetUrl(props.environmentId, {222 const uri = useAssetUrl(props.environmentId, {
219 _tag: "attachment",223 _tag: "attachment",
220 attachmentId: props.attachmentId,224 attachmentId: props.attachmentId,
@@ -229,9 +233,17 @@ function MessageAttachmentImage(props: { · 8 unmodified lines
229 }233 }
230 234
231 return (235 return (
232 <TouchableOpacity activeOpacity={0.7} onPress={() => props.onPressImage(uri)}>236 <PresentationSource identifier={sourceIdentifier}>
233 <Image source={{ uri }} className={props.className} resizeMode="cover" />237 <Pressable
234 </TouchableOpacity>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>
235 );247 );
236}248}
237 249
@@ -249,14 +261,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt · 11 unmodified lines
249function MessageAttachmentFile(props: {261function MessageAttachmentFile(props: {
250 readonly environmentId: EnvironmentId;262 readonly environmentId: EnvironmentId;
251 readonly attachment: ChatFileAttachment;263 readonly attachment: ChatFileAttachment;
264 readonly onPressPreview: (source: FilePreviewSource) => void;
252 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;265 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
253}) {266}) {
267 const sourceIdentifier = useId();
254 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {268 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
255 reportFailure: false,269 reportFailure: false,
256 });270 });
257 const preparedConnection = usePreparedConnection(props.environmentId);271 const preparedConnection = usePreparedConnection(props.environmentId);
258 const { attachment } = props;272 const { attachment } = props;
259 const videoType = videoMimeType(attachment);273 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);
260 const thumbnailUrl = useAssetUrl(279 const thumbnailUrl = useAssetUrl(
261 props.environmentId,280 props.environmentId,
262 videoType === null281 videoType === null
@@ -348,26 +367,63 @@ function MessageAttachmentFile(props: { · 85 unmodified lines
348 }367 }
349 368
350 return (369 return (
351 <Pressable370 <PresentationSource
352 accessibilityRole="button"371 identifier={sourceIdentifier}
353 accessibilityLabel={`Open ${attachment.name}`}372 className="my-1"
354 accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}373 style={{ width: 280, maxWidth: "100%" }}
355 disabled={opening || httpBaseUrl === null}
356 className="flex-row items-center gap-2 py-1"
357 onPress={() => shareFile()}
358 >374 >
359 {opening ? (375 <Pressable
360 <ActivityIndicator size="small" />376 accessibilityRole="button"
361 ) : (377 accessibilityLabel={`Open ${attachment.name}`}
362 <SymbolView name="doc.text" size={16} tintColor="#a3a3a3" type="monochrome" />378 accessibilityValue={{ text: `${fileTypeLabel}, ${sizeLabel}` }}
363 )}379 accessibilityState={{ disabled: opening || httpBaseUrl === null, busy: opening }}
364 <Text className="min-w-0 flex-1 text-sm text-foreground" numberOfLines={1}>380 disabled={opening || httpBaseUrl === null}
365 {attachment.name}381 className="min-w-0 flex-row items-center gap-3 rounded-xl border border-border bg-card p-3 active:bg-subtle"
366 </Text>382 onPress={() =>
367 <Text className="text-xs text-foreground-muted">383 isPdf
368 {formatAttachmentSize(attachment.sizeBytes)}384 ? props.onPressPreview({
369 </Text>385 kind: "pdf",
370 </Pressable>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>
371 );427 );
372}428}
373 429
@@ -391,8 +447,9 @@ function ThreadMarkdownImageView(props: { · 17 unmodified lines
391 readonly sourceKey: string;447 readonly sourceKey: string;
392 readonly unavailable: boolean;448 readonly unavailable: boolean;
393 readonly alt: string | null;449 readonly alt: string | null;
394 readonly onPressImage: (uri: string) => void;450 readonly onPressPreview: (source: FilePreviewSource) => void;
395}) {451}) {
452 const sourceIdentifier = useId();
396 const [availableWidth, setAvailableWidth] = useState(0);453 const [availableWidth, setAvailableWidth] = useState(0);
397 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);454 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
398 const [failedUri, setFailedUri] = useState<string | null>(null);455 const [failedUri, setFailedUri] = useState<string | null>(null);
@@ -437,27 +494,35 @@ function ThreadMarkdownImageView(props: { · 38 unmodified lines
437 )}494 )}
438 </View>495 </View>
439 ) : (496 ) : (
440 <TouchableOpacity497 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
441 accessibilityRole="imagebutton"498 <Pressable
442 accessibilityLabel={props.alt ?? "Markdown image"}499 accessibilityRole="imagebutton"
443 activeOpacity={0.7}500 accessibilityLabel={props.alt ?? "Markdown image"}
444 onPress={() => props.onPressImage(props.uri!)}501 onPress={() =>
445 style={{ alignSelf: "flex-start" }}502 props.onPressPreview({
446 >503 kind: "image",
447 <View504 uri: props.uri!,
448 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"505 name: props.alt ?? "Image",
449 style={{506 sourceIdentifier,
450 ...frameStyle,507 })
451 }}508 }
509 style={{ alignSelf: "flex-start" }}
452 >510 >
453 <ThreadMarkdownImageRequest511 <View
454 key={props.uri}512 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
455 uri={props.uri}513 style={{
456 onLoad={setSourceSize}514 ...frameStyle,
457 onError={() => setFailedUri(props.uri)}515 }}
458 />516 >
459 </View>517 <ThreadMarkdownImageRequest
460 </TouchableOpacity>518 key={props.uri}
519 uri={props.uri}
520 onLoad={setSourceSize}
521 onError={() => setFailedUri(props.uri)}
522 />
523 </View>
524 </Pressable>
525 </PresentationSource>
461 )}526 )}
462 {props.alt ? (527 {props.alt ? (
463 <Text selectable className="text-xs text-foreground-muted">528 <Text selectable className="text-xs text-foreground-muted">
@@ -506,7 +571,7 @@ function ThreadMarkdownImage(props: { · 42 unmodified lines
506 readonly threadId: ThreadId;571 readonly threadId: ThreadId;
507 readonly path: string;572 readonly path: string;
508 readonly alt: string | null;573 readonly alt: string | null;
509 readonly onPressImage: (uri: string) => void;574 readonly onPressPreview: (source: FilePreviewSource) => void;
510}) {575}) {
511 const assetUrl = useAssetUrlState(props.environmentId, {576 const assetUrl = useAssetUrlState(props.environmentId, {
512 _tag: "workspace-file",577 _tag: "workspace-file",
@@ -520,7 +585,7 @@ function ThreadMarkdownImage(props: { · 7 unmodified lines
520 sourceKey={props.path}585 sourceKey={props.path}
521 unavailable={assetUrl._tag === "Failure"}586 unavailable={assetUrl._tag === "Failure"}
522 alt={props.alt}587 alt={props.alt}
523 onPressImage={props.onPressImage}588 onPressPreview={props.onPressPreview}
524 />589 />
525 );590 );
526}591}
@@ -532,7 +597,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) · 5 unmodified lines
532 sourceKey="unavailable"597 sourceKey="unavailable"
533 unavailable598 unavailable
534 alt={props.alt}599 alt={props.alt}
535 onPressImage={() => undefined}600 onPressPreview={() => undefined}
536 />601 />
537 );602 );
538}603}
@@ -1264,7 +1329,7 @@ function renderFeedEntry( · 725 unmodified lines
1264 readonly onToggleWorkGroup: (groupId: string) => void;1329 readonly onToggleWorkGroup: (groupId: string) => void;
1265 readonly onToggleWorkRow: (rowId: string) => void;1330 readonly onToggleWorkRow: (rowId: string) => void;
1266 readonly onToggleTurnFold: (turnId: TurnId) => void;1331 readonly onToggleTurnFold: (turnId: TurnId) => void;
1267 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;1332 readonly onPressPreview: (source: FilePreviewSource) => void;
1268 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;1333 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
1269 readonly onMarkdownLinkPress: (href: string) => void;1334 readonly onMarkdownLinkPress: (href: string) => void;
1270 readonly renderMarkdownImage: MarkdownImageRenderer;1335 readonly renderMarkdownImage: MarkdownImageRenderer;
@@ -1375,14 +1440,16 @@ function renderFeedEntry( · 104 unmodified lines
1375 key={attachment.id}1440 key={attachment.id}
1376 environmentId={props.environmentId}1441 environmentId={props.environmentId}
1377 attachmentId={attachment.id}1442 attachmentId={attachment.id}
1443 name={attachment.name}
1378 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"1444 className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
1379 onPressImage={props.onPressImage}1445 onPressPreview={props.onPressPreview}
1380 />1446 />
1381 ) : isFileAttachment(attachment) ? (1447 ) : isFileAttachment(attachment) ? (
1382 <MessageAttachmentFile1448 <MessageAttachmentFile
1383 key={attachment.id}1449 key={attachment.id}
1384 environmentId={props.environmentId}1450 environmentId={props.environmentId}
1385 attachment={attachment}1451 attachment={attachment}
1452 onPressPreview={props.onPressPreview}
1386 onPressVideo={props.onPressVideo}1453 onPressVideo={props.onPressVideo}
1387 />1454 />
1388 ) : (1455 ) : (
@@ -1436,14 +1503,16 @@ function renderFeedEntry( · 47 unmodified lines
1436 key={attachment.id}1503 key={attachment.id}
1437 environmentId={props.environmentId}1504 environmentId={props.environmentId}
1438 attachmentId={attachment.id}1505 attachmentId={attachment.id}
1506 name={attachment.name}
1439 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"1507 className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
1440 onPressImage={props.onPressImage}1508 onPressPreview={props.onPressPreview}
1441 />1509 />
1442 ) : isFileAttachment(attachment) ? (1510 ) : isFileAttachment(attachment) ? (
1443 <MessageAttachmentFile1511 <MessageAttachmentFile
1444 key={attachment.id}1512 key={attachment.id}
1445 environmentId={props.environmentId}1513 environmentId={props.environmentId}
1446 attachment={attachment}1514 attachment={attachment}
1515 onPressPreview={props.onPressPreview}
1447 onPressVideo={props.onPressVideo}1516 onPressVideo={props.onPressVideo}
1448 />1517 />
1449 ) : (1518 ) : (
@@ -1806,13 +1875,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 356 unmodified lines
1806 expandedTurnIds: new Set(),1875 expandedTurnIds: new Set(),
1807 });1876 });
1808 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;1877 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
1809 const [expandedImage, setExpandedImage] = useState<{1878 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
1810 uri: string;
1811 headers?: Record<string, string>;
1812 } | null>(null);
1813 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);1879 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
1814 useEffect(() => {1880 useEffect(() => {
1815 setExpandedVideo(null);1881 setExpandedVideo(null);
1882 setExpandedFile(null);
1816 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);1883 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);
1817 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;1884 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
1818 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({1885 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
@@ -1858,6 +1925,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 39 unmodified lines
1858 );1925 );
1859 if (relativePath) {1926 if (relativePath) {
1860 void Haptics.selectionAsync();1927 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 }
1861 navigation.navigate("ThreadFile", {1944 navigation.navigate("ThreadFile", {
1862 environmentId: String(props.environmentId),1945 environmentId: String(props.environmentId),
1863 threadId: String(props.threadId),1946 threadId: String(props.threadId),
@@ -1869,6 +1952,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 5 unmodified lines
1869 }1952 }
1870 1953
1871 if (presentation.href) {1954 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 }
1872 void tryOpenExternalUrl(presentation.href, "markdown-link");1961 void tryOpenExternalUrl(presentation.href, "markdown-link");
1873 }1962 }
1874 },1963 },
@@ -1884,7 +1973,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 9 unmodified lines
1884 sourceKey={imageSource.uri}1973 sourceKey={imageSource.uri}
1885 unavailable={false}1974 unavailable={false}
1886 alt={image.alt}1975 alt={image.alt}
1887 onPressImage={(uri) => setExpandedImage({ uri })}1976 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
1888 />1977 />
1889 );1978 );
1890 }1979 }
@@ -1897,7 +1986,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 6 unmodified lines
1897 threadId={props.threadId}1986 threadId={props.threadId}
1898 path={imageSource.path}1987 path={imageSource.path}
1899 alt={image.alt}1988 alt={image.alt}
1900 onPressImage={(uri) => setExpandedImage({ uri })}1989 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
1901 />1990 />
1902 );1991 );
1903 },1992 },
@@ -2285,8 +2374,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 381 unmodified lines
2285 [suspendEndScrollMaintenanceForDisclosure],2374 [suspendEndScrollMaintenanceForDisclosure],
2286 );2375 );
2287 2376
2288 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {2377 const onPressPreview = useCallback((source: FilePreviewSource) => {
2289 setExpandedImage({ uri, headers });2378 setExpandedFile((current) => current ?? source);
2290 }, []);2379 }, []);
2291 const onPressVideo = useCallback(2380 const onPressVideo = useCallback(
2292 (attachment: ChatFileAttachment, sourceIdentifier: string) => {2381 (attachment: ChatFileAttachment, sourceIdentifier: string) => {
@@ -2350,7 +2439,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 57 unmodified lines
2350 onToggleWorkGroup,2439 onToggleWorkGroup,
2351 onToggleWorkRow,2440 onToggleWorkRow,
2352 onToggleTurnFold,2441 onToggleTurnFold,
2353 onPressImage,2442 onPressPreview,
2354 onPressVideo,2443 onPressVideo,
2355 onMarkdownLinkPress,2444 onMarkdownLinkPress,
2356 renderMarkdownImage,2445 renderMarkdownImage,
@@ -2379,7 +2468,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 22 unmodified lines
2379 userBubbleMaxWidth,2468 userBubbleMaxWidth,
2380 onCopyWorkRow,2469 onCopyWorkRow,
2381 onMarkdownLinkPress,2470 onMarkdownLinkPress,
2382 onPressImage,2471 onPressPreview,
2383 onPressVideo,2472 onPressVideo,
2384 onToggleTurnFold,2473 onToggleTurnFold,
2385 onToggleWorkGroup,2474 onToggleWorkGroup,
@@ -2565,23 +2654,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 179 unmodified lines
2565 </View>2654 </View>
2566 2655
2567 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />2656 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
2568 <ImageViewing2657 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
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 />
2585 </>2658 </>
2586 );2659 );
2587});2660});
apps/mobile/src/lib/composerFiles.test.ts+112−0
@@ -1,4 +1,5 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts";
2import type { ImagePickerAsset } from "expo-image-picker";3import type { ImagePickerAsset } from "expo-image-picker";
3 4
4const mocks = vi.hoisted(() => ({5const mocks = vi.hoisted(() => ({
@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ · 4 unmodified lines
9 delete: vi.fn(),10 delete: vi.fn(),
10 open: vi.fn(),11 open: vi.fn(),
11 size: vi.fn(),12 size: vi.fn(),
13 readBase64: vi.fn(),
12}));14}));
13 15
14vi.mock("expo-file-system", () => {16vi.mock("expo-file-system", () => {
@@ -55,6 +57,10 @@ vi.mock("expo-file-system", () => { · 40 unmodified lines
55 mocks.copy(this.uri, destination.uri);57 mocks.copy(this.uri, destination.uri);
56 }58 }
57 59
60 async base64(): Promise<string> {
61 return mocks.readBase64(this.uri);
62 }
63
58 delete(): void {64 delete(): void {
59 mocks.delete(this.uri);65 mocks.delete(this.uri);
60 }66 }
@@ -95,9 +101,115 @@ describe("composer file attachments", () => { · 34 unmodified lines
95 mocks.delete.mockReset();101 mocks.delete.mockReset();
96 mocks.open.mockReset();102 mocks.open.mockReset();
97 mocks.size.mockReset();103 mocks.size.mockReset();
104 mocks.readBase64.mockReset();
98 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));105 mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42));
99 });106 });
100 107
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
101 describe("photo library videos", () => {213 describe("photo library videos", () => {
102 const image: ImagePickerAsset = {214 const image: ImagePickerAsset = {
103 uri: "file:///picker/photo.png",215 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
351 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;351 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;
352 break;352 break;
353 }353 }
354 const mimeType = asset.mimeType?.toLowerCase();354 let mimeType = asset.mimeType?.toLowerCase();
355 if (asset.type === "video" || mimeType?.startsWith("video/")) {355 if (asset.type === "video" || mimeType?.startsWith("video/")) {
356 if (input.maxVideoBytes === undefined) {356 if (input.maxVideoBytes === undefined) {
357 error = "Video attachments are unavailable here.";357 error = "Video attachments are unavailable here.";
@@ -375,35 +375,61 @@ export async function pickComposerMedia(input: { · 17 unmodified lines
375 }375 }
376 continue;376 continue;
377 }377 }
378 if (!mimeType?.startsWith("image/")) {378 if (asset.type !== "image" && !mimeType?.startsWith("image/")) {
379 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;379 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
380 continue;380 continue;
381 }381 }
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 }
386 382
387 const base64 = asset.base64;383 let base64 = asset.base64;
388 if (!base64) {384 if (!base64) {
389 error = `Failed to read '${asset.fileName ?? "image"}'.`;385 error = `Failed to read '${asset.fileName ?? "image"}'.`;
390 continue;386 continue;
391 }387 }
392 388
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);
394 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {419 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
395 error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`;420 error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`;
396 continue;421 continue;
397 }422 }
398 423
424 const dataUrl = `data:${mimeType};base64,${base64}`;
399 attachments.push({425 attachments.push({
400 id: uuidv4(),426 id: uuidv4(),
401 type: "image",427 type: "image",
402 name: asset.fileName ?? "image",428 name,
403 mimeType,429 mimeType,
404 sizeBytes,430 sizeBytes,
405 dataUrl: `data:${mimeType};base64,${base64}`,431 dataUrl,
406 previewUri: asset.uri,432 previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl,
407 });433 });
408 }434 }
409 435
apps/mobile/src/lib/filePreview.test.ts+17−0
@@ -0,0 +1,17 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isPdfFile } from "./filePreview";
4
5describe("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. */
2export 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
24 },24 },
25}));25}));
26 26
27import { loadLocalVideoPreview } from "./localVideoPreview";27import { loadLocalAttachmentPreview } from "./localAttachmentPreview";
28 28
29const attachment = {29const attachment = {
30 type: "file" as const,30 type: "file" as const,
@@ -45,9 +45,22 @@ beforeEach(() => { · 14 unmodified lines
45 mocks.share.mockResolvedValue(undefined);45 mocks.share.mockResolvedValue(undefined);
46});46});
47 47
48describe("loadLocalVideoPreview", () => {48describe("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 });
49 it("resolves the current iOS container and releases its playback lease once", async () => {62 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);
51 expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/");64 expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/");
52 expect(mocks.retain).toHaveBeenCalledWith(attachment);65 expect(mocks.retain).toHaveBeenCalledWith(attachment);
53 const release = mocks.retain.mock.results[0]!.value;66 const release = mocks.retain.mock.results[0]!.value;
@@ -62,7 +75,7 @@ describe("loadLocalVideoPreview", () => { · 8 unmodified lines
62 async (sourceIdentifier) => {75 async (sourceIdentifier) => {
63 const shared = Promise.withResolvers<void>();76 const shared = Promise.withResolvers<void>();
64 mocks.share.mockReturnValue(shared.promise);77 mocks.share.mockReturnValue(shared.promise);
65 const preview = await loadLocalVideoPreview(attachment, new AbortController().signal);78 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
66 const share = preview!.share(new AbortController().signal, sourceIdentifier);79 const share = preview!.share(new AbortController().signal, sourceIdentifier);
67 expect(mocks.retain).toHaveBeenCalledTimes(2);80 expect(mocks.retain).toHaveBeenCalledTimes(2);
68 const releasePlayback = mocks.retain.mock.results[0]!.value;81 const releasePlayback = mocks.retain.mock.results[0]!.value;
@@ -78,7 +91,7 @@ describe("loadLocalVideoPreview", () => { · 9 unmodified lines
78 91
79 it("releases a failed share while keeping playback retained", async () => {92 it("releases a failed share while keeping playback retained", async () => {
80 mocks.share.mockRejectedValue(new Error("Sharing unavailable"));93 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);
82 await expect(preview!.share(new AbortController().signal)).rejects.toThrow(95 await expect(preview!.share(new AbortController().signal)).rejects.toThrow(
83 "Sharing unavailable",96 "Sharing unavailable",
84 );97 );
@@ -89,7 +102,7 @@ describe("loadLocalVideoPreview", () => { · 4 unmodified lines
89 102
90 it("releases a load canceled during native module loading", async () => {103 it("releases a load canceled during native module loading", async () => {
91 const controller = new AbortController();104 const controller = new AbortController();
92 const loading = loadLocalVideoPreview(attachment, controller.signal);105 const loading = loadLocalAttachmentPreview(attachment, controller.signal);
93 controller.abort();106 controller.abort();
94 await expect(loading).resolves.toBeNull();107 await expect(loading).resolves.toBeNull();
95 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);108 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
@@ -98,14 +111,14 @@ describe("loadLocalVideoPreview", () => { · 2 unmodified lines
98 111
99 it("reports missing files and releases their lease", async () => {112 it("reports missing files and releases their lease", async () => {
100 mocks.exists.mockReturnValue(false);113 mocks.exists.mockReturnValue(false);
101 await expect(loadLocalVideoPreview(attachment, new AbortController().signal)).rejects.toThrow(114 await expect(
102 "This video is no longer available. Attach the file again.",115 loadLocalAttachmentPreview(attachment, new AbortController().signal),
103 );116 ).rejects.toThrow("This attachment is no longer available. Attach the file again.");
104 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);117 expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1);
105 });118 });
106 119
107 it("does not start sharing a disposed preview", async () => {120 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);
109 preview!.dispose();122 preview!.dispose();
110 await preview!.share(new AbortController().signal);123 await preview!.share(new AbortController().signal);
111 expect(mocks.share).not.toHaveBeenCalled();124 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
5import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";5import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
6import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";6import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
7 7
8/** Retains the draft original for playback and gives each outgoing share its own lease. */8/** Retains the draft original for preview and gives each outgoing share its own lease. */
9export async function loadLocalVideoPreview(9export async function loadLocalAttachmentPreview(
10 attachment: DraftComposerFileAttachment,10 attachment: DraftComposerFileAttachment,
11 signal: AbortSignal,11 signal: AbortSignal,
12): Promise<AttachmentPreviewFile | null> {12): Promise<AttachmentPreviewFile | null> {
@@ -54,6 +54,6 @@ export async function loadLocalVideoPreview( · 41 unmodified lines
54 } catch (cause) {54 } catch (cause) {
55 release();55 release();
56 if (signal.aborted) return null;56 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 });
58 }58 }
59}59}
apps/server/src/server.test.ts+37−1
@@ -103,7 +103,7 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function · 102 unmodified lines
103 103
104import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";104import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
105import * as ServerConfig from "./config.ts";105import * as ServerConfig from "./config.ts";
106import { makeRoutesLayer } from "./server.ts";106import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts";
107import {107import {
108 isThreadDetailEvent,108 isThreadDetailEvent,
109 resolveAvailableEditorsForConfig,109 resolveAvailableEditorsForConfig,
@@ -629,6 +629,7 @@ const buildAppUnderTest = (options?: { · 519 unmodified lines
629 {629 {
630 disableListenLog: true,630 disableListenLog: true,
631 disableLogger: true,631 disableLogger: true,
632 routerConfig: HTTP_ROUTER_CONFIG,
632 },633 },
633 ).pipe(634 ).pipe(
634 Layer.provide(635 Layer.provide(
@@ -1527,6 +1528,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 892 unmodified lines
1527 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1528 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1528 );1529 );
1529 1530
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
1530 it.effect("compresses large JSON responses through the composed routes", () =>1566 it.effect("compresses large JSON responses through the composed routes", () =>
1531 Effect.gen(function* () {1567 Effect.gen(function* () {
1532 const descriptor = {1568 const descriptor = {
apps/server/src/server.ts+7−0
@@ -123,6 +123,12 @@ import * as RelayClient from "@t3tools/shared/relayClient"; · 122 unmodified lines
123import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";123import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
124import { forkParked, ServerActivation } from "./serverActivation.ts";124import { forkParked, ServerActivation } from "./serverActivation.ts";
125 125
126// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's
127// 100-character default for one path segment.
128export const HTTP_ROUTER_CONFIG = {
129 maxParamLength: 512,
130} as const;
131
126// Effect's default preemptive shutdown waits 20s before finalizing request scopes.132// Effect's default preemptive shutdown waits 20s before finalizing request scopes.
127// T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer133// T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer
128// already closes the websocket gracefully. Do not add an artificial drain before134// already closes the websocket gracefully. Do not add an artificial drain before
@@ -675,6 +681,7 @@ export const makeServerLayer = Layer.unwrap( · 546 unmodified lines
675 681
676 const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), {682 const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), {
677 disableLogger: !config.logWebSocketEvents,683 disableLogger: !config.logWebSocketEvents,
684 routerConfig: HTTP_ROUTER_CONFIG,
678 }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie)));685 }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie)));
679 const serverApplicationLayer = Layer.mergeAll(686 const serverApplicationLayer = Layer.mergeAll(
680 routesLayer,687 routesLayer,
apps/server/src/usage/usageScanCache.test.ts+14−0
@@ -193,6 +193,20 @@ describe("pruneScanCache with an unwalked root", () => { · 192 unmodified lines
193 expect(removed).toBe(0);193 expect(removed).toBe(0);
194 expect(cache.size).toBe(1);194 expect(cache.size).toBe(1);
195 });195 });
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 });
196});210});
197 211
198describe("dedupeWithinFile", () => {212describe("dedupeWithinFile", () => {
apps/server/src/usage/usageScanCache.ts+12−1
@@ -14,6 +14,9 @@ · 13 unmodified lines
14 *14 *
15 * @module usageScanCache15 * @module usageScanCache
16 */16 */
17// @effect-diagnostics nodeBuiltinImport:off
18import * as NodePath from "node:path";
19
17import type { UsageProviderKind } from "@t3tools/contracts";20import type { UsageProviderKind } from "@t3tools/contracts";
18 21
19import type { UsageRecord } from "./usageTranscripts.ts";22import type { UsageRecord } from "./usageTranscripts.ts";
@@ -229,7 +232,15 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number · 209 unmodified lines
229 let removed = 0;232 let removed = 0;
230 for (const [path, entry] of cache) {233 for (const [path, entry] of cache) {
231 const agedOut = entry.mtimeMs < options.retentionCutoffMs;234 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 });
233 const deleted =244 const deleted =
234 underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path);245 underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path);
235 if (agedOut || deleted) {246 if (agedOut || deleted) {
apps/web/src/components/BranchToolbar.tsx+7−5
@@ -265,8 +265,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { · 264 unmodified lines
265 let needed = 0;265 let needed = 0;
266 let groups = 0;266 let groups = 0;
267 for (const child of current.children) {267 for (const child of current.children) {
268 if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue;268 if (!(child instanceof HTMLElement)) continue;
269 needed += contentWidth(child);269 const width = contentWidth(child);
270 if (width <= 1) continue;
271 needed += width;
270 groups += 1;272 groups += 1;
271 }273 }
272 needed += stripGap * Math.max(0, groups - 1);274 needed += stripGap * Math.max(0, groups - 1);
@@ -356,7 +358,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { · 83 unmodified lines
356 // Label widths can change without the strip box moving (font family or358 // Label widths can change without the strip box moving (font family or
357 // size preferences), so re-measure on every render as well as on resize359 // size preferences), so re-measure on every render as well as on resize
358 // and font loads.360 // and font loads.
359 useEffect(() => {361 useLayoutEffect(() => {
360 measure();362 measure();
361 });363 });
362 364
@@ -487,7 +489,7 @@ export const BranchToolbar = memo(function BranchToolbar({ · 124 unmodified lines
487 onUsePreviousWorktree={onUsePreviousWorktree}489 onUsePreviousWorktree={onUsePreviousWorktree}
488 />490 />
489 ) : (491 ) : (
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">
491 {showEnvironmentIndicator && availableEnvironments && (493 {showEnvironmentIndicator && availableEnvironments && (
492 <>494 <>
493 <BranchToolbarEnvironmentSelector495 <BranchToolbarEnvironmentSelector
@@ -520,7 +522,7 @@ export const BranchToolbar = memo(function BranchToolbar({ · 26 unmodified lines
520 522
521 {showGitControls ? (523 {showGitControls ? (
522 <BranchToolbarBranchSelector524 <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"
524 environmentId={environmentId}526 environmentId={environmentId}
525 threadId={threadId}527 threadId={threadId}
526 {...(draftId ? { draftId } : {})}528 {...(draftId ? { draftId } : {})}
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+14−9
@@ -51,20 +51,25 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe · 50 unmodified lines
51 if (envLocked) {51 if (envLocked) {
52 return (52 return (
53 <span53 <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"
55 data-composer-context-control55 data-composer-context-control
56 >56 >
57 {activeWorktreePath ? (57 {activeWorktreePath ? (
58 <>58 <FolderGitIcon className="size-3 shrink-0" />
59 <FolderGitIcon className="size-3" />
60 {resolveLockedWorkspaceLabel(activeWorktreePath)}
61 </>
62 ) : (59 ) : (
63 <>60 <FolderIcon className="size-3 shrink-0" />
64 <FolderIcon className="size-3" />
65 {resolveLockedWorkspaceLabel(activeWorktreePath)}
66 </>
67 )}61 )}
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>
68 </span>73 </span>
69 );74 );
70 }75 }
apps/web/src/components/chat/ChatHeader.tsx+7−4
@@ -288,13 +288,16 @@ export const ChatHeader = memo(function ChatHeader({ · 287 unmodified lines
288 className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"288 className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
289 onContextMenu={handleHeaderContextMenu}289 onContextMenu={handleHeaderContextMenu}
290 >290 >
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 >
292 {/* The project always leads the header: knowing which project a295 {/* The project always leads the header: knowing which project a
293 thread lives in is priority zero, and the thread title alone296 thread lives in is priority zero, and the thread title alone
294 doesn't answer it. */}297 doesn't answer it. */}
295 {activeProjectName ? (298 {activeProjectName ? (
296 <>299 <>
297 <WorkspaceBreadcrumbItem>300 <WorkspaceBreadcrumbItem className="shrink">
298 <Tooltip>301 <Tooltip>
299 <TooltipTrigger302 <TooltipTrigger
300 render={303 render={
@@ -302,7 +305,7 @@ export const ChatHeader = memo(function ChatHeader({ · 1 unmodified line
302 type="button"305 type="button"
303 aria-label={`New thread in ${activeProjectName}`}306 aria-label={`New thread in ${activeProjectName}`}
304 onClick={onNewThreadInProject}307 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"
306 />309 />
307 }310 }
308 >311 >
@@ -320,7 +323,7 @@ export const ChatHeader = memo(function ChatHeader({ · 11 unmodified lines
320 <WorkspaceBreadcrumbSeparator />323 <WorkspaceBreadcrumbSeparator />
321 </>324 </>
322 ) : null}325 ) : null}
323 <WorkspaceBreadcrumbItem current className="flex-1">326 <WorkspaceBreadcrumbItem current className="min-w-10 flex-1">
324 {renamingTitle !== null ? (327 {renamingTitle !== null ? (
325 <input328 <input
326 autoFocus329 autoFocus
apps/web/src/components/files/FileBrowserPanel.tsx+39−2
@@ -3,9 +3,9 @@ import type { · 2 unmodified lines
3 ContextMenuOpenContext as TreeContextMenuOpenContext,3 ContextMenuOpenContext as TreeContextMenuOpenContext,
4} from "@pierre/trees";4} from "@pierre/trees";
5import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";5import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
6import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";6import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
8import { RotateCw } from "lucide-react";8import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react";
9import { useEffect, useMemo, useRef } from "react";9import { useEffect, useMemo, useRef } from "react";
10 10
11import { Button } from "~/components/ui/button";11import { Button } from "~/components/ui/button";
@@ -21,6 +21,7 @@ import { readLocalApi } from "~/localApi"; · 9 unmodified lines
21import { T3_PIERRE_ICONS } from "~/pierre-icons";21import { T3_PIERRE_ICONS } from "~/pierre-icons";
22 22
23import { createFileTreeDragMentionController } from "./fileTreeDragMention";23import { createFileTreeDragMentionController } from "./fileTreeDragMention";
24import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
24import { useProjectEntriesQuery } from "./projectFilesQueryState";25import { useProjectEntriesQuery } from "./projectFilesQueryState";
25 26
26interface FileBrowserPanelProps {27interface FileBrowserPanelProps {
@@ -121,6 +122,10 @@ export default function FileBrowserPanel({ · 94 unmodified lines
121 );122 );
122 const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);123 const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
123 const treePaths = useMemo(() => entries.map(treePath), [entries]);124 const treePaths = useMemo(() => entries.map(treePath), [entries]);
125 const directoryPaths = useMemo(
126 () => entries.filter((entry) => entry.kind === "directory").map(treePath),
127 [entries],
128 );
124 const previousTreePathsRef = useRef<readonly string[]>([]);129 const previousTreePathsRef = useRef<readonly string[]>([]);
125 const syncingSelectionRef = useRef(false);130 const syncingSelectionRef = useRef(false);
126 const treeSelectionPathRef = useRef<string | null>(null);131 const treeSelectionPathRef = useRef<string | null>(null);
@@ -252,6 +257,12 @@ export default function FileBrowserPanel({ · 125 unmodified lines
252 unsafeCSS: TREE_UNSAFE_CSS,257 unsafeCSS: TREE_UNSAFE_CSS,
253 });258 });
254 const search = useFileTreeSearch(model);259 const search = useFileTreeSearch(model);
260 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
261 areAllDirectoriesExpanded(currentModel, directoryPaths),
262 );
263 const toggleAllDirectories = () => {
264 setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded);
265 };
255 const handleSearchValueChange = (value: string) => {266 const handleSearchValueChange = (value: string) => {
256 if (value.trim().length === 0) {267 if (value.trim().length === 0) {
257 search.close();268 search.close();
@@ -376,6 +387,32 @@ export default function FileBrowserPanel({ · 118 unmodified lines
376 onValueChange={handleSearchValueChange}387 onValueChange={handleSearchValueChange}
377 onClose={search.close}388 onClose={search.close}
378 />389 />
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}
379 </div>416 </div>
380 {entriesQuery.error && entriesQuery.data === null ? (417 {entriesQuery.error && entriesQuery.data === null ? (
381 <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>418 <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 @@
1import { describe, expect, it, vi } from "@effect/vitest";
2
3import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
4
5type FakeDirectoryItem = {
6 isDirectory: () => true;
7 isExpanded: () => boolean;
8 expand: () => void;
9 collapse: () => void;
10};
11
12function 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
34describe("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 @@
1export interface FileTreeExpansionModel {
2 getItem(path: string): unknown;
3}
4
5type DirectoryHandle = {
6 isDirectory(): boolean;
7 isExpanded(): boolean;
8 expand(): void;
9 collapse(): void;
10};
11
12function 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
31export 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
44export 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
3import {3import {
4 applyWslEnableSelection,4 applyWslEnableSelection,
5 isQrShareableEndpoint,5 isQrShareableEndpoint,
6 isWslSettingsRowVisible,
6 selectQrEndpointOption,7 selectQrEndpointOption,
7} from "./ConnectionsSettings.logic";8} from "./ConnectionsSettings.logic";
8 9
@@ -15,6 +16,25 @@ const baseWslState: DesktopWslState = { · 6 unmodified lines
15 preflightError: null,16 preflightError: null,
16};17};
17 18
19describe("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
18describe("applyWslEnableSelection", () => {38describe("applyWslEnableSelection", () => {
19 it("clears WSL-only and updates the distro before enabling both backends", async () => {39 it("clears WSL-only and updates the distro before enabling both backends", async () => {
20 const calls: Array<string> = [];40 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
11 return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback";11 return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback";
12}12}
13 13
14export 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
14export type QrEndpointOption = {22export type QrEndpointOption = {
15 /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */23 /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */
16 readonly id: string;24 readonly id: string;
apps/web/src/components/settings/ConnectionsSettings.tsx+11−5
@@ -43,6 +43,7 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls · 42 unmodified lines
43import {43import {
44 applyWslEnableSelection,44 applyWslEnableSelection,
45 isQrShareableEndpoint,45 isQrShareableEndpoint,
46 isWslSettingsRowVisible,
46 selectQrEndpointOption,47 selectQrEndpointOption,
47} from "./ConnectionsSettings.logic";48} from "./ConnectionsSettings.logic";
48import {49import {
@@ -2766,10 +2767,13 @@ export function ConnectionsSettings() { · 2717 unmodified lines
2766 // retry so the row doesn't flicker away, and the button reflects the2767 // retry so the row doesn't flicker away, and the button reflects the
2767 // loading state. With no error we simply haven't loaded yet (or WSL2768 // loading state. With no error we simply haven't loaded yet (or WSL
2768 // management isn't available), so render nothing.2769 // management isn't available), so render nothing.
2769 if (desktopWslError && canManageLocalBackend) {2770 if (
2771 isWslSettingsRowVisible({ state: null, error: desktopWslError }) &&
2772 canManageLocalBackend
2773 ) {
2770 return (2774 return (
2771 <SettingsRow2775 <SettingsRow
2772 title="WSL backend"2776 {...searchableSetting("wsl-backend")}
2773 description="Couldn't load the WSL backend state."2777 description="Couldn't load the WSL backend state."
2774 status={<span className="block text-destructive">{desktopWslError}</span>}2778 status={<span className="block text-destructive">{desktopWslError}</span>}
2775 control={2779 control={
@@ -2794,11 +2798,13 @@ export function ConnectionsSettings() { · 18 unmodified lines
2794 // be stranded on a WSL preference they can't clear, so render a recovery2798 // be stranded on a WSL preference they can't clear, so render a recovery
2795 // row that switches back to Windows. When WSL is unavailable AND unused,2799 // row that switches back to Windows. When WSL is unavailable AND unused,
2796 // there's nothing to recover — keep the section hidden as before.2800 // there's nothing to recover — keep the section hidden as before.
2801 if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) {
2802 return null;
2803 }
2797 if (!desktopWslState.available) {2804 if (!desktopWslState.available) {
2798 if (!desktopWslState.enabled && !desktopWslState.wslOnly) return null;
2799 return (2805 return (
2800 <SettingsRow2806 <SettingsRow
2801 title="WSL backend"2807 {...searchableSetting("wsl-backend")}
2802 description="WSL is no longer available, so the Windows backend is running instead. Switch off the WSL backend to clear this preference."2808 description="WSL is no longer available, so the Windows backend is running instead. Switch off the WSL backend to clear this preference."
2803 status={2809 status={
2804 desktopWslError ? (2810 desktopWslError ? (
@@ -2835,7 +2841,7 @@ export function ConnectionsSettings() { · 30 unmodified lines
2835 return (2841 return (
2836 <>2842 <>
2837 <SettingsRow2843 <SettingsRow
2838 title="WSL backend"2844 {...searchableSetting("wsl-backend")}
2839 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."2845 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."
2840 status={2846 status={
2841 desktopWslError ? (2847 desktopWslError ? (
apps/web/src/components/settings/SettingsSidebarNav.tsx+22−1
@@ -7,6 +7,8 @@ import { · 6 unmodified lines
7 type ComponentType,7 type ComponentType,
8 type KeyboardEvent,8 type KeyboardEvent,
9} from "react";9} from "react";
10import { useEnvironmentQuery } from "~/state/query";
11import { desktopWslStateAtom } from "~/state/desktopWslState";
10import {12import {
11 ArchiveIcon,13 ArchiveIcon,
12 BlocksIcon,14 BlocksIcon,
@@ -21,6 +23,8 @@ import { · 8 unmodified lines
21} from "lucide-react";23} from "lucide-react";
22import { useLocation, useNavigate } from "@tanstack/react-router";24import { useLocation, useNavigate } from "@tanstack/react-router";
23 25
26import { isElectron } from "~/env";
27import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
24import { Button } from "../ui/button";28import { Button } from "../ui/button";
25import { Input } from "../ui/input";29import { Input } from "../ui/input";
26import { Kbd } from "../ui/kbd";30import { Kbd } from "../ui/kbd";
@@ -38,6 +42,7 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; · 11 unmodified lines
38import { scrollToSettingsTarget } from "./settingsLayout";42import { scrollToSettingsTarget } from "./settingsLayout";
39import {43import {
40 searchSettings,44 searchSettings,
45 SETTINGS_SEARCH_ITEMS,
41 SETTINGS_SECTION_LABELS,46 SETTINGS_SECTION_LABELS,
42 type SettingsPath,47 type SettingsPath,
43 type SettingsSearchItem,48 type SettingsSearchItem,
@@ -78,10 +83,26 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { · 34 unmodified lines
78 const searchInputRef = useRef<HTMLInputElement>(null);83 const searchInputRef = useRef<HTMLInputElement>(null);
79 const [query, setQuery] = useState("");84 const [query, setQuery] = useState("");
80 const [activeResultIndex, setActiveResultIndex] = useState(0);85 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]);
82 const isSearching = query.trim().length > 0;99 const isSearching = query.trim().length > 0;
83 const hasResults = results.length > 0;100 const hasResults = results.length > 0;
84 101
102 useEffect(() => {
103 setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0)));
104 }, [results.length]);
105
85 useEffect(() => {106 useEffect(() => {
86 const result = results[activeResultIndex];107 const result = results[activeResultIndex];
87 if (!result) return;108 if (!result) return;
apps/web/src/components/settings/settingsSearch.test.ts+11−0
@@ -71,6 +71,17 @@ describe("searchSettings", () => { · 70 unmodified lines
71 it("hides desktop-only settings from browser search", () => {71 it("hides desktop-only settings from browser search", () => {
72 expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true);72 expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true);
73 expect(searchSettings("quit confirmation")).toEqual([]);73 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 });
74 });85 });
75 86
76 it("keeps catalog result ids unique", () => {87 it("keeps catalog result ids unique", () => {
apps/web/src/components/settings/settingsSearch.ts+13−0
@@ -1,4 +1,5 @@
1import { isElectron } from "~/env";1import { isElectron } from "~/env";
2import { isWindowsPlatform } from "~/lib/utils";
2 3
3export type SettingsPath =4export type SettingsPath =
4 | "/settings/general"5 | "/settings/general"
@@ -18,6 +19,9 @@ export interface SettingsSearchItem { · 13 unmodified lines
18 // Its row only renders in the desktop app, so a browser result would land on19 // Its row only renders in the desktop app, so a browser result would land on
19 // an anchor that isn't there.20 // an anchor that isn't there.
20 readonly desktopOnly?: boolean;21 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;
21}25}
22 26
23/**27/**
@@ -264,6 +268,13 @@ export const SETTINGS_SEARCH_ITEMS = [ · 240 unmodified lines
264 title: "Remote environments",268 title: "Remote environments",
265 to: "/settings/connections",269 to: "/settings/connections",
266 },270 },
271 {
272 id: "wsl-backend",
273 title: "WSL backend",
274 to: "/settings/connections",
275 desktopOnly: true,
276 windowsOnly: true,
277 },
267 {278 {
268 id: "archive",279 id: "archive",
269 title: "Archived threads",280 title: "Archived threads",
@@ -309,6 +320,8 @@ export function searchSettings( · 39 unmodified lines
309 return items.filter(320 return items.filter(
310 (item) =>321 (item) =>
311 (isElectron || item.desktopOnly !== true) &&322 (isElectron || item.desktopOnly !== true) &&
323 (!item.windowsOnly ||
324 isWindowsPlatform(typeof navigator === "undefined" ? "" : navigator.platform)) &&
312 normalizeSearchText(item.title).includes(normalizedQuery),325 normalizeSearchText(item.title).includes(normalizedQuery),
313 );326 );
314}327}
docs/internals/mobile-navigation.md+22−5
@@ -51,13 +51,12 @@ use a separate implementation and do not need this workaround. · 50 unmodified lines
51 51
52## Native media presentations52## Native media presentations
53 53
54`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit's54`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit,
55full-screen entry and UIKit's share sheet. Wrap the thumbnail as its single child55image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child
56and pass the stable identifier to the presentation. The registry keeps weak56and pass the stable identifier to the presentation. The registry keeps weak
57references to source views; recycled or compact composer thumbnails can register57references to source views; recycled or compact composer thumbnails can register
58the same identifier. Identifiers must distinguish simultaneously visible attachments.58the same identifier. Identifiers must distinguish simultaneously visible attachments.
59This source registration does not own playback and can be reused by a future image59The source registration does not own the preview. Android uses a regular view.
60viewer. Android uses a regular view.
61 60
62On iOS, video previews mount `AVPlayerViewController` temporarily inside the61On iOS, video previews mount `AVPlayerViewController` temporarily inside the
63registered source and enter full screen through AVKit. AVKit62registered 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
67the player uses a standard modal presentation. Programmatic entry uses the same66the player uses a standard modal presentation. Programmatic entry uses the same
68guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video;67guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video;
69if that selector is unavailable, the player also falls back to a standard modal.68if that selector is unavailable, the player also falls back to a standard modal.
70Images retain their existing viewer.69
70`FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset,
71or a retained composer file. On iOS, Quick Look owns image and document layout, controls,
72zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail
73and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or
74another image scroll view over that presentation: Quick Look coordinates its image gestures
75with the return to the thumbnail. Missing sources use the standard transition, and Reduce
76Motion disables animation. A pending programmatic Close waits until the current presentation
77or cancelled dismissal has settled before starting another transition.
78
79The shared native presenter copies original bytes into its own temporary directory and
80removes that copy after dismissal. Network downloads write to disk, and sharing never edits
81the source attachment. Draft images use their stored upload data rather than a potentially
82expired picker URI. No React Navigation route or custom transition animator is needed.
83
84The same viewer handles message images, markdown images, PDF attachments and links,
85composer thumbnails, and workspace image previews. The workspace PDF web preview has an
86Open PDF action for the native viewer. Android retains its image viewer and uses the
87system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission.
71 88
72Received videos open directly from their signed asset URL. AVKit handles buffering;89Received videos open directly from their signed asset URL. AVKit handles buffering;
73the client does not download the entire file or show a separate opening overlay before90the 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
20Videos use the server's file upload limit. You can also share photos, videos, and files into20Videos use the server's file upload limit. You can also share photos, videos, and files into
21T3 Code from other apps through the system share sheet. Mobile uploads happen when the message21T3 Code from other apps through the system share sheet. Mobile uploads happen when the message
22sends, so queued messages keep their files until they deliver. Select a received file on mobile22sends, so queued messages keep their files until they deliver. Select a received file on mobile
23to save it or open it in another app through the system share sheet.23to preview it or open the system share options.
24
25Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail
26into the native viewer. Pinch or double-tap to zoom, and swipe down or tap Close to return.
27Use Share to save a copy or send it to another app. PDFs support page navigation and search.
28PDF links in assistant responses open the same preview. On Android, images open in the image
29viewer and PDFs open the system chooser.
24 30
25Select a video attachment before or after sending to play it. Web and desktop use the browser's31Select a video attachment before or after sending to play it. Web and desktop use the browser's
26built-in controls. On mobile, videos open in a full-screen player with native playback controls.32built-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
36and shows **Attach again** next to it. Attach the file again or remove it, then send.42and shows **Attach again** next to it. Attach the file again or remove it, then send.
37 43
38On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into44On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into
39the composer or paste them into a message.45the composer or paste them into a message. On iOS, selecting them from **Photo Library** also
46converts them to JPEG. The 10 MB image limit applies to the converted photo.
40 47
41On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic,48On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic,
42GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list49GitHub 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
460});460});
461 461
462effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => {462effectIt.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", () =>
464 Effect.gen(function* () {464 Effect.gen(function* () {
465 const readEnvironment = vi.fn(465 const readEnvironment = vi.fn(
466 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>466 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
@@ -483,6 +483,8 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 16 unmodified lines
483 ),483 ),
484 ).toEqual({484 ).toEqual({
485 PATH: [485 PATH: [
486 "C:\\Shell\\Bin",
487 "C:\\Windows\\System32",
486 "C:\\Users\\testuser\\AppData\\Roaming\\npm",488 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
487 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",489 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
488 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",490 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
@@ -490,8 +492,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 1 unmodified line
490 "C:\\Users\\testuser\\.local\\bin",492 "C:\\Users\\testuser\\.local\\bin",
491 "C:\\Users\\testuser\\.bun\\bin",493 "C:\\Users\\testuser\\.bun\\bin",
492 "C:\\Users\\testuser\\scoop\\shims",494 "C:\\Users\\testuser\\scoop\\shims",
493 "C:\\Shell\\Bin",
494 "C:\\Windows\\System32",
495 ].join(";"),495 ].join(";"),
496 });496 });
497 expect(readEnvironment).toHaveBeenCalledTimes(1);497 expect(readEnvironment).toHaveBeenCalledTimes(1);
@@ -532,6 +532,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 34 unmodified lines
532 PATH: [532 PATH: [
533 "C:\\Profile\\Node",533 "C:\\Profile\\Node",
534 "C:\\Windows\\System32",534 "C:\\Windows\\System32",
535 "C:\\Shell\\Bin",
535 "C:\\Users\\testuser\\AppData\\Roaming\\npm",536 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
536 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",537 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
537 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",538 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
@@ -539,7 +540,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 1 unmodified line
539 "C:\\Users\\testuser\\.local\\bin",540 "C:\\Users\\testuser\\.local\\bin",
540 "C:\\Users\\testuser\\.bun\\bin",541 "C:\\Users\\testuser\\.bun\\bin",
541 "C:\\Users\\testuser\\scoop\\shims",542 "C:\\Users\\testuser\\scoop\\shims",
542 "C:\\Shell\\Bin",
543 ].join(";"),543 ].join(";"),
544 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",544 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
545 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",545 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
@@ -576,11 +576,11 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { · 30 unmodified lines
576 ),576 ),
577 ).toEqual({577 ).toEqual({
578 PATH: [578 PATH: [
579 "C:\\Windows\\System32",
579 "C:\\Users\\testuser\\AppData\\Roaming\\npm",580 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
580 "C:\\Users\\testuser\\.local\\bin",581 "C:\\Users\\testuser\\.local\\bin",
581 "C:\\Users\\testuser\\.bun\\bin",582 "C:\\Users\\testuser\\.bun\\bin",
582 "C:\\Users\\testuser\\scoop\\shims",583 "C:\\Users\\testuser\\scoop\\shims",
583 "C:\\Windows\\System32",
584 ].join(";"),584 ].join(";"),
585 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",585 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
586 });586 });
packages/shared/src/shell.ts+3−1
@@ -728,7 +728,9 @@ export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironm · 727 unmodified lines
728 }).PATH;728 }).PATH;
729 const mergedPath = mergePathValues(shellPath, inheritedPath, "win32");729 const mergedPath = mergePathValues(shellPath, inheritedPath, "win32");
730 const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER);730 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");
732 const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {};734 const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {};
733 const baselineEnv = mergeWindowsEnv(env, baselinePatch);735 const baselineEnv = mergeWindowsEnv(env, baselinePatch);
734 736

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