diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index a5c03e0b9336..0d204fb3ad42 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -226,6 +226,7 @@ describe("ElectronProtocol", () => { "https:", ]); assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); + assert.deepEqual(directives["frame-src"], ["'self'", "blob:", "http:", "https:"]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index da8fcd3a2e4b..af0366f93f47 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -91,7 +91,9 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat "style-src 'self' 'unsafe-inline'", `font-src 'self' ${input.scheme}: data:`, "worker-src 'self' blob:", - "frame-src 'self' https://challenges.cloudflare.com", + // Document viewers use local Blob URLs and signed assets from runtime environments. + // HTML viewers retain their own sandbox; the renderer's script policy stays unchanged. + "frame-src 'self' blob: http: https:", "form-action 'self'", ].join("; "); } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 6aa3810db3f9..e409c791a5e5 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -96,7 +96,8 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( val offset = editor.getOffsetForPosition(event.x, event.y) val token = tokens.firstOrNull { - (it.type == "context" || it.type == "mention") && offset >= it.start && + (it.type == "context" || it.type == "mention" || it.type == "skill") && + offset >= it.start && offset < it.end } ?: return false diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index a84a8766e7f7..11c62f36f226 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -426,7 +426,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro var elements: [Any] = [textView] let layout = textView.layoutManager textView.textStorage.enumerateAttribute(.attachment, in: NSRange(location: 0, length: textView.textStorage.length)) { value, range, _ in - guard let attachment = value as? ComposerTextAttachment, !attachment.source.hasPrefix("$") else { return } + guard let attachment = value as? ComposerTextAttachment else { return } let glyphRange = layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil) let rect = layout.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer) .offsetBy(dx: textView.textContainerInset.left, dy: textView.textContainerInset.top) @@ -466,8 +466,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro let layout = textView.layoutManager let index = layout.characterIndex(for: containerPoint, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil) guard index < textView.textStorage.length, - let attachment = textView.textStorage.attribute(.attachment, at: index, effectiveRange: nil) as? ComposerTextAttachment, - !attachment.source.hasPrefix("$") else { return nil } + let attachment = textView.textStorage.attribute(.attachment, at: index, effectiveRange: nil) as? ComposerTextAttachment else { return nil } let glyphRange = layout.glyphRange(forCharacterRange: NSRange(location: index, length: 1), actualCharacterRange: nil) guard layout.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer).contains(containerPoint) else { return nil } return (index, attachment) @@ -854,11 +853,25 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro ) ) } - let textSize = attributedLabel.size() let iconWidth: CGFloat = icon == nil ? 0 : chipFontSize * 1.17 let iconGap: CGFloat = icon == nil ? 0 : chipFontSize * 0.33 let padding = chipFontSize * 0.5 let height = ceil(chipFontSize * 1.41) + // A long path would otherwise draw a chip wider than the composer and clip. Cap the label + // to the text the editor can actually show and truncate inside it, as Android's + // `maximumWidth` does, so the chip always fits the line it sits on. + let availableWidth = textView.textContainer.size.width > 0 + ? textView.textContainer.size.width - textView.textContainer.lineFragmentPadding * 2 + : UIScreen.main.bounds.width + let maximumLabelWidth = max(chipFontSize * 3, availableWidth - padding * 2 - iconWidth - iconGap) + paragraph.lineBreakMode = .byTruncatingMiddle + attributedLabel.addAttribute( + .paragraphStyle, + value: paragraph, + range: NSRange(location: 0, length: attributedLabel.length) + ) + let measured = attributedLabel.size() + let textSize = CGSize(width: min(measured.width, maximumLabelWidth), height: measured.height) let width = ceil(padding * 2 + iconWidth + iconGap + textSize.width) let format = UIGraphicsImageRendererFormat.preferred() format.opaque = false @@ -882,8 +895,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro ) x += iconWidth + iconGap } + // Exactly the measured width: a spare pixel here would let a capped label draw past + // the cap instead of truncating inside it. attributedLabel.draw( - in: CGRect(x: x, y: (height - textSize.height) / 2, width: textSize.width + 1, height: textSize.height) + in: CGRect(x: x, y: (height - textSize.height) / 2, width: textSize.width, height: textSize.height) ) context.cgContext.setAllowsAntialiasing(true) } diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index 3ed4c0d09804..435d0ec8d309 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -194,8 +194,11 @@ class T3MarkdownTextSelectionModule : Module() { label = payload.optString("label").take(4096), symbol = payload.optString("symbol", "doc") ), + // The line box around the chip is measured with `fontScale` below, so the chip has to + // carry it too, or it shrinks against the words beside it at a larger text size. fontSize = - payload.optDouble("fontSize", 12.0).toFloat().coerceIn(10f, 40f) * metrics.density, + payload.optDouble("fontSize", 12.0).toFloat().coerceIn(10f, 40f) * + metrics.density * fontScale, colors = T3ContextChip.Colors( accent = T3ContextChip.color(payload.optString("accent"), Color.GRAY), foreground = T3ContextChip.color(payload.optString("foreground"), Color.BLACK), diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h index b7b17e37dc7b..09ddd9c0fe86 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h @@ -8,7 +8,11 @@ static NSDictionary *T3ContextChipPayload(NSString *uri) if (![uri hasPrefix:@"chip:"]) return nil; NSData *data = [[uri substringFromIndex:5] dataUsingEncoding:NSUTF8StringEncoding]; id payload = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - return [payload isKindOfClass:NSDictionary.class] ? payload : nil; + if (![payload isKindOfClass:NSDictionary.class]) return nil; + // Every consumer draws or substitutes `label`; a missing or non-string one would raise inside + // `replaceCharactersInRange:withString:` while a message is rendering. + if (![payload[@"label"] isKindOfClass:NSString.class]) return nil; + return payload; } static UIColor *T3ContextChipColor(NSString *hex) diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 95614639516c..ec8cf74fee2b 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -465,12 +465,18 @@ function appendNode( case "link": { const reference = parseComposerContextHref(node.href ?? ""); if (reference) { - return appendChildren(runs, node, { + // Build the link in isolation: adjacent links with the same href and + // style would otherwise merge into one run, collapsing two chips and + // their copy ranges into a single reference with a combined label. + const referenceRuns: NativeMarkdownTextRun[] = []; + appendChildren(referenceRuns, node, { ...context, href: node.href, fileIcon: reference.kind === "image" ? "image" : reference.kind === "terminal" ? "bash" : "text", }); + runs.push(...referenceRuns); + return runs; } const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); if (presentation.kind === "file") { diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index 6aca0cec234c..baea4a9590e7 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -1,12 +1,50 @@ package expo.modules.t3nativecontrols +import android.content.Intent +import androidx.core.content.FileProvider +import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import java.io.File +import java.net.URI class T3NativeControlsModule : Module() { + private var filePreviewPromise: Promise? = null + + @Suppress("TooGenericExceptionCaught") // Clear the pending promise before rethrowing. override fun definition() = ModuleDefinition { Name("T3NativeControls") + AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise -> + check(filePreviewPromise == null) { "A document viewer is already open." } + val activity = appContext.currentActivity ?: error("The app is not active.") + val file = File(URI(uri)).canonicalFile + require(file.isFile) { "The file is no longer available." } + val contentUri = FileProvider.getUriForFile( + activity, + "${activity.packageName}.FileSystemFileProvider", + file + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(contentUri, mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + filePreviewPromise = promise + try { + activity.startActivityForResult(intent, 7343) + } catch (error: Exception) { + filePreviewPromise = null + throw error + } + } + + OnActivityResult { _, (requestCode) -> + if (requestCode == 7343) { + filePreviewPromise?.resolve(null) + filePreviewPromise = null + } + } + Function("getShowcasePairingUrl") { appContext.currentActivity?.intent?.getStringExtra("showcasePairingUrl") } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift index 45954053125b..6eb8f7703581 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift @@ -46,6 +46,16 @@ final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) return } + // Quick Look knows which formats it renders; refuse before presenting so the caller + // can fall back instead of showing an "unsupported format" page. + guard QLPreviewController.canPreview(file as NSURL) else { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + throw NSError( + domain: "T3NativePresentation", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "This file type cannot be previewed on this device."] + ) + } item.previewItemURL = file item.previewItemTitle = title let preview = FilePreviewController() @@ -137,26 +147,34 @@ final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, try FileManager.default.moveItem(at: temporaryFile, to: download) } try Task.checkCancellation() - let type: UTType + // Pictures and PDFs are identified by content so a misnamed file still opens correctly. + // Anything else keeps its own extension: Quick Look decides from that whether it can + // render the document (Office, iWork, RTF and more) and refuses before we present. + let filename = URL(fileURLWithPath: title).lastPathComponent as NSString + let originalExtension = filename.pathExtension + var type: UTType? if let image = CGImageSourceCreateWithURL(download as CFURL, nil), CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image), let detectedType = UTType(imageType as String) { type = detectedType } else if CGPDFDocument(download as CFURL) != nil { type = .pdf - } else if URL(fileURLWithPath: title).pathExtension.lowercased() == "svg" { + } else if originalExtension.lowercased() == "svg" { type = .svg + } + let fileExtension: String + if let type { + fileExtension = UTType(filenameExtension: originalExtension) == type + ? originalExtension : type.preferredFilenameExtension ?? originalExtension } else { - throw URLError(.cannotDecodeContentData) + fileExtension = originalExtension } - let filename = URL(fileURLWithPath: title).lastPathComponent as NSString - let originalExtension = filename.pathExtension - let fileExtension = UTType(filenameExtension: originalExtension) == type - ? originalExtension : type.preferredFilenameExtension ?? "png" let stem = filename.deletingPathExtension var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_") while name.utf8.count > 200 { name.removeLast() } - let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)") + let baseName = name.isEmpty ? "Preview" : name + let file = directory.appendingPathComponent( + fileExtension.isEmpty ? baseName : "\(baseName).\(fileExtension)") try FileManager.default.moveItem(at: download, to: file) return file } catch { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index e205a0f31990..262cc8a8a74d 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -228,7 +228,17 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { guard ghostty_surface_read_text(surface, selection, &captured) else { onCapture(["text": ""]); return } defer { ghostty_surface_free_text(surface, &captured) } let text = captured.text.flatMap { String(bytes: UnsafeBufferPointer(start: UnsafeRawPointer($0).assumingMemoryBound(to: UInt8.self), count: Int(captured.text_len)), encoding: .utf8) } ?? "" - onCapture(["text": text]) + // Android joins snapshot rows with "\n" and trims each row's trailing whitespace, so do + // the same here: identical terminal content must capture identically on both platforms. + let normalized = text.split(separator: "\n", omittingEmptySubsequences: false) + .map { row -> String in + var line = String(row) + // Kotlin's trimEnd only strips ASCII whitespace; match it exactly. + while let last = line.last, last.isASCII && last.isWhitespace { line.removeLast() } + return line + } + .joined(separator: "\n") + onCapture(["text": normalized]) } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 2a2aa9d69b49..dabc41cb045a 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -20,6 +20,7 @@ import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRo import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; +import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; @@ -293,6 +294,11 @@ const NewTaskSheetStack = createNativeStackNavigator({ linking: "draft/files/:path*", options: SOLID_HEADER_OPTIONS, }), + NewTaskAttachment: createNativeStackScreen({ + screen: AttachmentFileScreen, + linking: "draft/attachments/:attachmentId", + options: SOLID_HEADER_OPTIONS, + }), ThreadSettings: createNativeStackScreen({ screen: NewTaskThreadSettingsRouteScreen, linking: "draft/settings", @@ -515,6 +521,11 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files/:path*`, options: SOLID_HEADER_OPTIONS, }), + ThreadAttachment: createNativeStackScreen({ + screen: AttachmentFileScreen, + linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`, + options: SOLID_HEADER_OPTIONS, + }), ThreadSettingsSheet: createNativeStackScreen({ screen: ExistingThreadSettingsRouteScreen, options: { diff --git a/apps/mobile/src/components/AudioFilePreview.tsx b/apps/mobile/src/components/AudioFilePreview.tsx new file mode 100644 index 000000000000..cea4acfb4614 --- /dev/null +++ b/apps/mobile/src/components/AudioFilePreview.tsx @@ -0,0 +1,75 @@ +import { useAudioPlayer, useAudioPlayerStatus } from "expo-audio"; +import { useState } from "react"; +import { Pressable, View } from "react-native"; +import { AppText as Text } from "./AppText"; + +function timestamp(seconds: number) { + const value = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0; + return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, "0")}`; +} + +export function AudioFilePreview(props: { uri: string; onRetry: () => void }) { + const player = useAudioPlayer({ uri: props.uri }, { updateInterval: 500 }); + const status = useAudioPlayerStatus(player); + const [seekError, setSeekError] = useState(false); + const seek = (seconds: number, play = false) => { + setSeekError(false); + void player + .seekTo(seconds) + .then(() => { + if (play) player.play(); + }) + .catch(() => setSeekError(true)); + }; + return ( + + + {timestamp(status.currentTime)} / {timestamp(status.duration)} + + + seek(Math.max(0, status.currentTime - 15))} + className="p-4" + > + −15s + + { + if (status.playing) player.pause(); + else if (status.didJustFinish || status.currentTime >= status.duration) seek(0, true); + else player.play(); + }} + className="rounded-xl bg-subtle px-6 py-4" + > + + {!status.isLoaded ? "Loading…" : status.playing ? "Pause" : "Play"} + + + seek(Math.min(status.duration, status.currentTime + 15))} + className="p-4" + > + +15s + + + {status.error || seekError ? ( + + + This audio could not be played. Try again or save it to open in another app. + + + Try again + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index f22786158166..caf8f676f07b 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -14,7 +14,7 @@ import { } from "../lib/composerImages"; import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles"; import { VideoAttachmentTile } from "./VideoAttachmentTile"; -import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; +import { type MediaActionsSource } from "../lib/mediaActions"; import { PresentationSource } from "./NativePresentation"; import type { FilePreviewSource } from "./FilePreviewModal"; import { isPdfFile } from "../lib/filePreview"; @@ -36,6 +36,8 @@ export interface ComposerAttachmentStripProps { attachment: DraftComposerFileAttachment, sourceIdentifier: string, ) => void; + /** Called when the user taps a document that is not a picture, video or PDF. */ + readonly onPressDocument?: (attachment: DraftComposerFileAttachment) => void; /** Image thumbnail size in points. Defaults to 72. */ readonly imageSize?: number; /** Border radius of each image thumbnail. Defaults to 16. */ @@ -55,6 +57,7 @@ type ComposerAttachmentThumbnailProps = { attachment: DraftComposerFileAttachment, sourceIdentifier: string, ) => void; + readonly onPressDocument?: (attachment: DraftComposerFileAttachment) => void; }; export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) { @@ -101,9 +104,40 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr * persisted URI renders meanwhile, which is correct everywhere but after a * container move. */ -function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string { - const { fileUri, previewUri } = attachment; +const PREVIEW_CACHE_DIRECTORY = "t3-composer-previews"; + +/** + * Fabric re-parses an image source URL on every layout pass of the node, and a + * multi-megabyte data URL makes each Fabric commit slow enough that concurrent + * UI-thread commits (the question card's coverage animation) win the race every + * time until the renderer aborts. Inline bytes are written to the cache once and + * the thumbnail renders from that file instead. + */ +/** Roughly 192KB of base64: small enough that re-parsing it per layout stays imperceptible. */ +const INLINE_PREVIEW_FALLBACK_MAX_CHARS = 256_000; + +async function materializeDataUrlPreview(id: string, dataUrl: string): Promise { + const comma = dataUrl.indexOf(","); + if (comma < 0) return null; + const { Directory, File, Paths } = await import("expo-file-system"); + const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/jpeg"; + const extension = (mimeType.split("/")[1] ?? "jpg").replace("jpeg", "jpg"); + const directory = new Directory(Paths.cache, PREVIEW_CACHE_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const file = new File(directory, `${id}.${extension}`); + if (!file.exists) { + file.create(); + file.write(dataUrl.slice(comma + 1), { encoding: "base64" }); + } + return file.uri; +} + +/** The thumbnail source for a draft image: an owned file when there is one, never a data URL. */ +function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string | null { + const { id, fileUri, previewUri } = attachment; const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null); + const [materialized, setMaterialized] = useState<{ id: string; uri: string | null } | null>(null); + const inlinePreview = fileUri === undefined && previewUri.startsWith("data:"); useEffect(() => { if (fileUri === undefined) return; let cancelled = false; @@ -117,7 +151,35 @@ function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): s cancelled = true; }; }, [fileUri, previewUri]); - return fileUri !== undefined && rebased?.fileUri === fileUri ? rebased.uri : previewUri; + useEffect(() => { + if (!inlinePreview) return; + let cancelled = false; + void materializeDataUrlPreview(id, previewUri) + .then((uri) => { + if (!cancelled && uri !== null) setMaterialized({ id, uri }); + }) + .catch((error: unknown) => { + console.warn("[composer-attachments] could not cache an image preview", error); + // Record the failure so the thumbnail stops waiting on a file that will never arrive. + if (!cancelled) setMaterialized({ id, uri: null }); + }); + return () => { + cancelled = true; + }; + }, [id, inlinePreview, previewUri]); + if (fileUri !== undefined && rebased?.fileUri === fileUri) return rebased.uri; + if (fileUri !== undefined) return previewUri.startsWith("data:") ? fileUri : previewUri; + if (inlinePreview) { + if (materialized?.id !== id) return null; + // Falling back to the data URL is a last resort: a large one re-parses on every layout and + // starves the Fabric commit, which is what the cache file exists to avoid. Small ones are + // cheap enough to render directly rather than leaving the thumbnail blank forever. + return ( + materialized.uri ?? + (previewUri.length <= INLINE_PREVIEW_FALLBACK_MAX_CHARS ? previewUri : null) + ); + } + return previewUri; } function ComposerImageAttachment( @@ -149,7 +211,7 @@ function ComposerImageAttachment( } > - - canPreview - ? props.onPressPreview?.({ - kind: "pdf", - name: attachment.name, - attachment, - sourceIdentifier, - }) - : share() - } - className={ - props.compact - ? "items-center justify-center bg-subtle" - : "items-center justify-center gap-1 bg-subtle px-2" - } - style={style} - > - - {!props.compact ? ( - - {attachment.name} - - ) : null} - - + <> + + + canPreview + ? props.onPressPreview?.({ + kind: "pdf", + name: attachment.name, + attachment, + sourceIdentifier, + }) + : onPressDocument?.(attachment) + } + className={ + props.compact + ? "items-center justify-center bg-subtle" + : "items-center justify-center gap-1 bg-subtle px-2" + } + style={style} + > + + {!props.compact ? ( + + {attachment.name} + + ) : null} + + + ); } @@ -304,6 +363,7 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { borderRadius={radius} onPressPreview={props.onPressPreview} onPressVideo={props.onPressVideo} + onPressDocument={props.onPressDocument} /> {props.label} - - {props.value} - + {props.code && (props.label === "HTML" || props.label === "Styles") ? ( + + + + ) : ( + + {props.value} + + )} ); } @@ -72,6 +86,8 @@ export function ComposerContextSheet(props: { readonly onRemove?: () => void; readonly onOpenAttachment?: () => void; readonly onOpenPullRequest?: () => void; + readonly skillDescription?: string; + readonly onOpenSkill?: () => void; readonly environmentId?: EnvironmentId; readonly records?: ReadonlyArray; readonly attachments?: ReadonlyArray; @@ -95,6 +111,7 @@ export function ComposerContextSheet(props: { : undefined; if (record && !("payload" in record) && (record.kind === "image" || record.kind === "file")) { const mimeType = videoMimeType(record) ?? record.mimeType; + const previewKind = filePreviewKind(record); const resource = { _tag: "attachment" as const, attachmentId: record.attachmentId, @@ -127,7 +144,7 @@ export function ComposerContextSheet(props: { /> ); } - } else if (record.kind === "image" || mimeType === "application/pdf") { + } else if (record.kind === "image" || previewKind === "image" || previewKind === "pdf") { const inlineUri = composerAttachmentInlineUri(localAttachment); const source = localFile ? { attachment: localFile } @@ -138,7 +155,7 @@ export function ComposerContextSheet(props: { return ( @@ -324,7 +341,7 @@ export function ComposerContextSheet(props: { ) : null} - {record.kind === "image" || record.kind === "file" ? ( + {record.kind === "image" ? ( ) : null} {record.kind === "skill" ? ( - + + + + {props.onOpenSkill ? ( + + View instructions + + ) : null} + ) : null} )} diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 1429d73246e6..4a5cb017c236 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -1,3 +1,4 @@ +import { ComposerContextId } from "@t3tools/contracts"; import { useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -21,18 +22,25 @@ import { } from "../lib/composerContextClipboard"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./AppText"; -import { composerMentionPath } from "../lib/composerContext"; +import { + composerDocumentAttachment, + composerMentionPath, + type ComposerDocumentAttachment, +} from "../lib/composerContext"; export type ComposerEditorProps = NativeComposerEditorProps & { readonly draftKey?: string | null; readonly environmentId?: EnvironmentId; readonly onOpenMention?: (path: string) => void; + /** Documents open in the file screen; pictures, video and PDF keep their native viewers. */ + readonly onOpenAttachment?: (attachment: ComposerDocumentAttachment) => void; }; export function ComposerEditor({ draftKey, environmentId, onOpenMention, + onOpenAttachment, ...props }: ComposerEditorProps) { const draft = useComposerDraft(draftKey ?? null); @@ -136,6 +144,9 @@ export function ComposerEditor({ const selectedReference = selected ? collectComposerContextReferences(selected.source)[0] : undefined; + const selectedSkill = selected?.source.startsWith("$") + ? props.skills?.find((skill) => skill.name === selected.source.slice(1)) + : undefined; const record = draft.context?.records.find( (entry) => entry.contextId === selectedReference?.contextId, ); @@ -155,6 +166,11 @@ export function ComposerEditor({ onOpenMention(path); return; } + const document = composerDocumentAttachment(selection.source, draft.context); + if (document && onOpenAttachment) { + onOpenAttachment(document); + return; + } setSelected(selection); }} onSelectionChange={(selection) => { @@ -165,10 +181,32 @@ export function ComposerEditor({ {importing ? ( Copying context… ) : null} - {selected && selectedReference ? ( + {selected && (selectedReference || selectedSkill) ? ( { + setSelected(null); + onOpenMention(selectedSkill.path!); + }, + } + : {})} environmentId={environmentId} records={draft.context?.records} attachments={draft.attachments} diff --git a/apps/mobile/src/components/CopyTextButton.tsx b/apps/mobile/src/components/CopyTextButton.tsx index 1937ec8b4a18..a728cc1c18b5 100644 --- a/apps/mobile/src/components/CopyTextButton.tsx +++ b/apps/mobile/src/components/CopyTextButton.tsx @@ -10,7 +10,7 @@ export const CopyTextButton = memo(function CopyTextButton(props: { readonly accessibilityLabel: string; readonly text: string; readonly onCopy?: () => Promise; - readonly tintColor: ColorValue; + readonly tintColor?: ColorValue; readonly copiedTintColor?: ColorValue; readonly backgroundColor?: ColorValue; readonly borderColor?: ColorValue; @@ -38,7 +38,11 @@ export const CopyTextButton = memo(function CopyTextButton(props: { onPress={async () => { try { if (props.onCopy) await props.onCopy(); - else if (!(await tryCopyTextWithHaptic(props.text))) return; + else if (!(await tryCopyTextWithHaptic(props.text))) { + // A refused clipboard write is the common failure, and silence reads as success. + Alert.alert("Could not copy", "Try again."); + return; + } } catch { Alert.alert("Could not copy", "Try again."); return; @@ -72,6 +76,7 @@ export const CopyTextButton = memo(function CopyTextButton(props: { } size={props.iconSize ?? 13} tintColor={copied ? (props.copiedTintColor ?? props.tintColor) : props.tintColor} + tintColorClassName={props.tintColor ? undefined : "accent-foreground"} type="monochrome" /> diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index af943b8be9a2..e2bae6101b60 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -17,18 +17,21 @@ const NativeControls = requireNativeModule<{ function NativeFilePreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; + readonly onOpenError?: (error: unknown) => void; }) { const { uri, name, sourceIdentifier } = props.source; const identifier = useId(); const onRequestClose = useEffectEvent(props.onRequestClose); + const onOpenError = useEffectEvent((error: unknown) => { + if (props.onOpenError) props.onOpenError(error); + else Alert.alert("Could not open preview", "The file could not be loaded. Please try again."); + }); useEffect(() => { let canceled = false; void NativeControls.presentFile(uri, name ?? "Preview", sourceIdentifier ?? "", identifier) - .catch(() => { - if (!canceled) { - Alert.alert("Could not open preview", "The file could not be loaded. Please try again."); - } + .catch((error: unknown) => { + if (!canceled) onOpenError(error); }) .finally(() => { if (!canceled) onRequestClose(); @@ -45,6 +48,7 @@ function NativeFilePreview(props: { export function FilePreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; + readonly onOpenError?: (error: unknown) => void; }) { return ; } diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index 8240c4a6ad38..a255f50ccd21 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -1,45 +1,76 @@ import { useEffect, useEffectEvent } from "react"; -import { Alert } from "react-native"; +import { Alert, Modal, Pressable, View } from "react-native"; import ImageViewing from "react-native-image-viewing"; -import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; +import { openAttachmentInViewer } from "../lib/attachmentDownload"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; import { MediaImagePreview } from "./MediaImagePreview"; +import { AppText as Text } from "./AppText"; -function PdfPreview(props: { +function DocumentPreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; + readonly onOpenError?: (error: unknown) => void; }) { const { uri, name } = props.source; const onRequestClose = useEffectEvent(props.onRequestClose); + const onOpenError = useEffectEvent((error: unknown) => { + if (props.onOpenError) props.onOpenError(error); + else + Alert.alert( + "Could not open document", + "A compatible viewer must be installed. Check your connection and try again.", + ); + }); useEffect(() => { const controller = new AbortController(); const input = { - attachment: { name: name ?? "Document.pdf", mimeType: "application/pdf" }, + uri, + attachment: { + name: name ?? "Document", + mimeType: + props.source.mimeType ?? + (props.source.kind === "pdf" ? "application/pdf" : "application/octet-stream"), + }, signal: controller.signal, }; - // Android's system chooser supplies the installed PDF apps. - const opened = - uri.startsWith("file:") || uri.startsWith("content:") - ? shareLocalAttachment({ ...input, uri }) - : downloadAndShareAttachment({ ...input, url: uri }); + const opened = openAttachmentInViewer(input); void opened - .catch(() => { - if (!controller.signal.aborted) Alert.alert("Could not open PDF", "Please try again."); + .catch((error: unknown) => { + if (!controller.signal.aborted) onOpenError(error); }) .finally(() => { if (!controller.signal.aborted) onRequestClose(); }); return () => controller.abort(); - }, [uri, name]); - return null; + }, [uri, name, props.source.mimeType, props.source.kind]); + return ( + + + + Opening document… + + {name ?? "Document"} + + + Cancel + + + + + ); } export function FilePreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; + readonly onOpenError?: (error: unknown) => void; }) { - if (props.source.kind === "pdf") return ; + if (props.source.kind !== "image") return ; if (props.source.actionsSource) return ; return ( & function ResolvedFilePreview(props: { readonly source: FilePreviewSource; readonly onRequestClose: () => void; + readonly onOpenError?: (error: unknown) => void; }) { const { source } = props; const environmentId = "environmentId" in source ? source.environmentId : null; - const connection = usePreparedConnection(environmentId); - const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); - // Keep the original URL through dismissal; a refreshed signature must not reopen the viewer. + const refreshAssetUrl = useRefreshAssetUrl( + environmentId, + "resource" in source ? source.resource : null, + ); + // Resolve once per presentation; background URL refreshes must not reopen the native viewer. const [uri, setUri] = useState("uri" in source ? source.uri : null); const onRequestClose = useEffectEvent(props.onRequestClose); - const failed = - environmentId !== null && - uri === null && - (connection._tag === "None" || asset._tag === "Failure"); + const onResolutionError = useEffectEvent((error: unknown, fallbackMessage: string) => { + if (props.onOpenError) props.onOpenError(error); + else Alert.alert("Could not open preview", fallbackMessage); + onRequestClose(); + }); useEffect(() => Keyboard.dismiss(), []); useEffect(() => { - if (uri === null && asset._tag === "Success") setUri(asset.url + (source.srcFragment ?? "")); - }, [uri, asset, source.srcFragment]); - useEffect(() => { - if (!failed) return; - Alert.alert( - "Could not open preview", - connection._tag === "None" - ? "Reconnect to this environment and try again." - : "The file could not be loaded. It may have been moved or deleted.", - ); - onRequestClose(); - }, [failed, connection._tag]); + if (environmentId === null || uri !== null) return; + let cancelled = false; + // A cached URL may have expired while the app was suspended. Await reauthorization + // before handing a URL to Quick Look or ACTION_VIEW, which retain that URL. + void refreshAssetUrl() + .then((url) => { + if (cancelled) return; + if (!url) throw new Error("Reconnect to this environment and try again."); + setUri(url + (source.srcFragment ?? "")); + }) + .catch((error: unknown) => { + if (cancelled) return; + onResolutionError( + error, + "Reconnect to this environment and try again. The file may have been moved or deleted.", + ); + }); + return () => { + cancelled = true; + }; + }, [environmentId, uri, refreshAssetUrl, source.srcFragment]); useEffect(() => { if (!("attachment" in source)) return; const controller = new AbortController(); @@ -69,10 +82,9 @@ function ResolvedFilePreview(props: { release = file.dispose; setUri(file.uri); }) - .catch(() => { + .catch((error: unknown) => { if (controller.signal.aborted) return; - Alert.alert("Could not open preview", "Attach the file again and retry."); - onRequestClose(); + onResolutionError(error, "Attach the file again and retry."); }); return () => { controller.abort(); @@ -81,13 +93,19 @@ function ResolvedFilePreview(props: { }, [source]); return uri === null ? null : ( - + ); } export function FilePreviewModal(props: { readonly source: FilePreviewSource | null; readonly onRequestClose: () => void; + /** Replaces the default alert when the platform cannot open the document. */ + readonly onOpenError?: (error: unknown) => void; }) { const isFocused = useIsFocused(); const hasSource = props.source !== null; @@ -97,5 +115,11 @@ export function FilePreviewModal(props: { }, [isFocused, hasSource]); if (!props.source || !isFocused) return null; - return ; + return ( + + ); } diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx new file mode 100644 index 000000000000..46365630b4d2 --- /dev/null +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -0,0 +1,417 @@ +/* oxlint-disable react/no-array-index-key -- Captured table rows and columns have stable positions and may contain identical values. */ +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { MenuAction } from "@react-native-menu/menu"; +import { EnvironmentId } from "@t3tools/contracts"; +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Platform, ScrollView, View } from "react-native"; + +import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text } from "../../components/AppText"; +import { AudioFilePreview } from "../../components/AudioFilePreview"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { EmptyState } from "../../components/EmptyState"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { useAttachmentDocument } from "../../lib/attachmentDocument"; +import { nativeViewerErrorMessage } from "../../lib/attachmentDownload"; +import { isFileBackedComposerAttachment } from "../../lib/composerImages"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { removeComposerDraftAttachment, useComposerDraft } from "../../state/use-composer-drafts"; +import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { SourceFileSurface } from "./SourceFileSurface"; +import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; + +/** + * Both the thread stack and the new-task sheet stack register this screen, so a chip in a + * sent message and a chip in a draft open the same view a workspace file does. + */ +export type AttachmentFileRouteParams = { + readonly environmentId?: string; + readonly threadId?: string; + readonly attachmentId: string; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: string; + /** Present for a draft attachment, which may still live only on this device. */ + readonly draftKey?: string; +}; + +type AttachmentFileScreenProps = StaticScreenProps; + +/** Kinds this screen cannot render itself; the platform viewer is the primary presentation. */ +function nativeViewerKind(kind: ReturnType["kind"]) { + if (kind === "image") return "image" as const; + if (kind === "pdf") return "pdf" as const; + if (kind === "video" || kind === "unsupported") return "document" as const; + return null; +} + +function AttachmentDocumentBody(props: { + readonly document: ReturnType; + readonly name: string; + readonly environmentId: EnvironmentId | null; + readonly nativeViewer: "pending" | "open" | "unavailable" | null; + readonly nativeError: string | null; + readonly onOpenNative: () => void; +}) { + const { document } = props; + if (document.error) { + return ( + + + + ); + } + if (props.nativeViewer !== null && props.nativeViewer !== "unavailable") { + return ( + + + Opening in file viewer... + + ); + } + if (!document.uri || (document.needsText && !document.content)) { + return ( + + + Loading file... + + ); + } + if (document.needsText && document.content) { + const { content, table } = document; + return ( + + {content.truncated ? ( + + + Partial file + + + Preview limited to the first 1 MB. Save or share the file to read it in full. + + + ) : null} + {table && document.rendered ? ( + + {table.truncated ? ( + + + Table limited to the first 100 rows and 30 columns. Source shows the rest. + + + ) : null} + + + {table.rows.map((row, rowIndex) => ( + + {row.map((cell, columnIndex) => ( + + + {cell} + + + ))} + + ))} + + + + ) : document.kind === "markdown" && document.rendered && props.environmentId ? ( + + ) : ( + + )} + + ); + } + if (document.kind === "audio") { + return ; + } + if (document.kind === "html") { + return ; + } + return ( + + + + ); +} + +export function AttachmentFileScreen(props: AttachmentFileScreenProps) { + const navigation = useNavigation(); + const iconColor = useUniwindTheme()["--color-icon"]; + const isAndroid = Platform.OS === "android"; + const params = props.route.params; + const environmentId = params.environmentId ? EnvironmentId.make(params.environmentId) : null; + const sizeBytes = Number.parseInt(params.sizeBytes, 10) || 0; + const draftKey = params.draftKey ?? null; + const draft = useComposerDraft(draftKey); + const draftAttachment = draftKey + ? draft.attachments.find((entry) => entry.id === params.attachmentId) + : undefined; + const localAttachment = + draftAttachment && isFileBackedComposerAttachment(draftAttachment) ? draftAttachment : null; + const document = useAttachmentDocument({ + name: params.name, + mimeType: params.mimeType, + sizeBytes, + attachmentId: params.attachmentId, + environmentId, + attachment: localAttachment, + }); + const [nativeOpen, setNativeOpen] = useState(false); + // A format we cannot render goes straight to the system viewer; this screen is only the + // launch pad and, when no viewer can show it, the honest fallback. + const nativeKind = nativeViewerKind(document.kind); + const [nativeViewer, setNativeViewer] = useState<"pending" | "open" | "unavailable" | null>( + nativeKind ? "pending" : null, + ); + const [nativeError, setNativeError] = useState(null); + const pendingNativeError = useRef(null); + const handleBack = useCallback(() => { + if (navigation.canGoBack()) navigation.goBack(); + }, [navigation]); + const { uri, resource } = document; + useEffect(() => { + if (nativeViewer !== "pending" || !uri) return; + // oxlint-disable-next-line react/set-state-in-effect -- Presenting the viewer waits on the resolved file. + setNativeViewer("open"); + setNativeOpen(true); + }, [nativeViewer, uri]); + // The viewer mints its own fresh URL from the resource, so a link that expired while this + // screen sat in the background is never handed to Quick Look or ACTION_VIEW. + const nativeSource = useMemo(() => { + const kind = nativeKind ?? "document"; + const base = { kind, name: params.name, mimeType: params.mimeType }; + if (localAttachment) return { ...base, attachment: localAttachment }; + if (environmentId) return { ...base, environmentId, resource }; + return uri ? { ...base, uri } : null; + }, [environmentId, localAttachment, nativeKind, params.mimeType, params.name, resource, uri]); + const handleNativeOpenError = useCallback((error: unknown) => { + pendingNativeError.current = nativeViewerErrorMessage(error); + }, []); + const handleNativeClose = useCallback(() => { + setNativeOpen(false); + const message = pendingNativeError.current; + pendingNativeError.current = null; + if (nativeViewer === null) { + // A file this screen renders itself: an explicit viewer failure is worth a word. + if (message) Alert.alert("Could not open document", message); + return; + } + if (message) { + setNativeError(message); + setNativeViewer("unavailable"); + return; + } + // The viewer was the whole visit: return to the conversation rather than a blank screen. + handleBack(); + }, [handleBack, nativeViewer]); + const removeFromDraft = useCallback(() => { + if (!draftKey) return; + removeComposerDraftAttachment(draftKey, params.attachmentId); + handleBack(); + }, [draftKey, handleBack, params.attachmentId]); + + const { content, renderedMode, rendered, setRendered, share, sharing } = document; + const menuActions = useMemo( + () => + [ + renderedMode + ? ({ + id: "preview", + title: renderedMode === "table" ? "Table" : "Preview", + icon: renderedMode === "table" ? "tablecells" : "eye", + inline: true, + onPress: () => setRendered(true), + } as const) + : null, + renderedMode + ? ({ + id: "source", + title: "Source", + icon: "doc.text", + inline: true, + onPress: () => setRendered(false), + } as const) + : null, + content + ? ({ + id: "copy", + title: content.truncated ? "Copy preview" : "Copy contents", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(content.text), + } as const) + : null, + uri + ? ({ + id: "share", + title: sharing ? "Opening share sheet…" : "Save or share", + icon: "square.and.arrow.up", + inline: false, + onPress: () => void share(), + } as const) + : null, + uri + ? ({ + id: "open-viewer", + title: "Open in file viewer", + icon: "arrow.up.left.and.arrow.down.right", + inline: false, + onPress: () => { + setNativeViewer((current) => (current === null ? null : "open")); + setNativeOpen(true); + }, + } as const) + : null, + draftKey + ? ({ + id: "remove", + title: "Remove from draft", + icon: "trash", + inline: false, + destructive: true, + onPress: removeFromDraft, + } as const) + : null, + ].filter((action) => action !== null), + [content, draftKey, removeFromDraft, renderedMode, setRendered, share, sharing, uri], + ); + const activeMode = rendered ? "preview" : "source"; + const androidMenuActions = useMemo( + () => + menuActions.map((action) => ({ + id: action.id, + title: action.title, + image: action.icon, + state: action.inline ? (action.id === activeMode ? "on" : "off") : undefined, + ...("destructive" in action ? { attributes: { destructive: true } } : {}), + })), + [activeMode, menuActions], + ); + const handleAndroidMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + menuActions.find(({ id }) => id === event.nativeEvent.event)?.onPress(); + }, + [menuActions], + ); + const subtitle = `${draftKey ? "Draft attachment" : "Attachment"} · ${formatAttachmentSize(sizeBytes)}`; + + return ( + + + {isAndroid ? ( + + + + } + /> + ) : null} + + + {renderedMode ? ( + + {menuActions + .filter(({ inline }) => inline) + .map((action) => ( + + {action.title} + + ))} + + ) : null} + {menuActions + .filter(({ inline }) => !inline) + .map((action) => ( + + {action.title} + + ))} + + + { + setNativeViewer((current) => (current === null ? null : "open")); + setNativeOpen(true); + }} + /> + {nativeOpen && nativeSource ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 660a5431e28a..dcd29d71d5c9 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -191,6 +191,7 @@ function useMarkdownPreviewStyles(renderImage?: MarkdownImageRenderer): Markdown export function FileMarkdownPreview(props: { readonly cwd: string; + readonly captured?: boolean; readonly environmentId: EnvironmentId; readonly markdown: string; readonly relativePath: string; @@ -224,7 +225,12 @@ export function FileMarkdownPreview(props: { if (media?.access === "direct") { return null; } - if (media === null || media.kind !== "image" || media.access === "unavailable") { + if ( + props.captured || + media === null || + media.kind !== "image" || + media.access === "unavailable" + ) { return ; } return ( @@ -237,7 +243,7 @@ export function FileMarkdownPreview(props: { /> ); }, - [markdownDirectory, props.environmentId, props.threadId], + [markdownDirectory, props.environmentId, props.threadId, props.captured], ); const styles = useMarkdownPreviewStyles(renderImage); const onLinkPress = useCallback((href: string) => { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 2d8b3320727a..0e58b26bdaf5 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -22,6 +22,7 @@ import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { AudioFilePreview } from "../../components/AudioFilePreview"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; @@ -62,6 +63,7 @@ import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, fileHeaderSubtitle, + isAudioPreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, isVideoPreviewFile, @@ -98,7 +100,8 @@ function defaultViewMode(path: string | null): FileViewMode { return path !== null && (isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path) || - isVideoPreviewFile(path)) + isVideoPreviewFile(path) || + isAudioPreviewFile(path)) ? "preview" : "source"; } @@ -127,9 +130,12 @@ function FileContent(props: { const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); const isVideoFile = isVideoPreviewFile(props.relativePath); + const isAudioFile = isAudioPreviewFile(props.relativePath); // Only the surfaces that wait on a signed asset URL can be blocked by one. const needsAssetUrl = - isVideoFile || (props.activeMode === "preview" && (isImageFile || isBrowserFile)); + isVideoFile || + isAudioFile || + (props.activeMode === "preview" && (isImageFile || isBrowserFile)); if (needsAssetUrl && props.previewFailure !== null) { return ( @@ -153,6 +159,17 @@ function FileContent(props: { ); } + if (isAudioFile) { + return props.previewUri === null ? ( + + + Loading file... + + ) : ( + + ); + } + if (props.activeMode === "preview" && isImageFile) { if (isSvgImagePreviewFile(props.relativePath)) { return ; @@ -582,30 +599,38 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); const [fullScreenPreview, setFullScreenPreview] = useState(null); const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath); + const isAudioFile = relativePath !== null && !isVideoFile && isAudioPreviewFile(relativePath); const isBrowserFile = relativePath !== null && !isVideoFile && isWorkspaceBrowserPreviewPath(relativePath); const isImageFile = relativePath !== null && !isVideoFile && isWorkspaceImagePreviewPath(relativePath); const canPreview = relativePath !== null && - (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile || isVideoFile); + (isMarkdownPreviewFile(relativePath) || + isBrowserFile || + isImageFile || + isVideoFile || + isAudioFile); const activeMode = relativePath !== null && modeOverride?.path === relativePath ? modeOverride.mode : defaultViewMode(relativePath); - const resolvedActiveMode = isVideoFile ? "preview" : canPreview ? activeMode : "source"; - const assetPreviewPath = isBrowserFile || isImageFile || isVideoFile ? relativePath : null; + const resolvedActiveMode = + isVideoFile || isAudioFile ? "preview" : canPreview ? activeMode : "source"; + const assetPreviewPath = + isBrowserFile || isImageFile || isVideoFile || isAudioFile ? relativePath : null; const assetPreview = useWorkspaceFileAssetUrlState({ cwd, environmentId, relativePath: assetPreviewPath, threadId, + // A project draft names its workspace root explicitly: there is no thread to resolve one. + draftCwd: threadId === null ? cwd : null, }); const assetPreviewUri = assetPreview._tag === "Success" ? assetPreview.url : null; const mediaSource = useMemo( () => environmentId !== null && - threadId !== null && relativePath !== null && assetPreview.resource !== null && "path" in assetPreview.resource && @@ -618,7 +643,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ?? "application/octet-stream", environmentId, - threadId, + ...(threadId === null ? {} : { threadId }), resource: assetPreview.resource, } : undefined, @@ -629,7 +654,8 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { () => environmentId !== null && relativePath !== null && - assetPreview.resource?._tag === "media-file" + (assetPreview.resource?._tag === "media-file" || + assetPreview.resource?._tag === "draft-workspace-file") ? { type: "media", environmentId, @@ -652,6 +678,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const needsFileContents = relativePath !== null && !isVideoFile && + !isAudioFile && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); const fileQuery = useEnvironmentQuery( environmentId !== null && cwd !== null && relativePath !== null && needsFileContents @@ -665,13 +692,27 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const handleSelectFile = useCallback( (path: string) => { + const segments = path.split("/").filter(Boolean); + // A draft has no thread. `ThreadFile` would stringify null and then wait forever for a + // thread to resolve, so a draft stays on its own route and carries its workspace along. + if (threadId === null) { + navigation.dispatch( + StackActions.push("NewTaskFile", { + environmentId: String(environmentId), + ...(cwd === null ? {} : { cwd }), + projectName, + path: segments, + }), + ); + return; + } navigation.navigate("ThreadFile", { environmentId: String(environmentId), threadId: String(threadId), - path: path.split("/").filter(Boolean), + path: segments, }); }, - [environmentId, navigation, threadId], + [cwd, environmentId, navigation, projectName, threadId], ); const renderInspector = useCallback( (headerInset: number) => @@ -702,7 +743,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const fileMenuActions = useMemo(() => { if (relativePath === null) return []; - const canToggleMode = canPreview && !isImageFile && !isVideoFile; + const canToggleMode = canPreview && !isImageFile && !isVideoFile && !isAudioFile; return [ canToggleMode ? ({ @@ -765,14 +806,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"), } as const) : null, - resolvedActiveMode === "preview" && (isBrowserFile || isImageFile || isVideoFile) + resolvedActiveMode === "preview" && + (isBrowserFile || isImageFile || isVideoFile || isAudioFile) ? ({ id: "refresh", title: "Refresh", icon: "arrow.clockwise", inline: false, onPress: async () => { - if (isVideoFile) await assetPreview.refresh(); + if (isVideoFile || isAudioFile) await assetPreview.refresh(); setPreviewRevision((current) => current + 1); }, } as const) @@ -783,6 +825,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { assetPreview.refresh, previewUri, canPreview, + isAudioFile, isBrowserFile, isImageFile, isVideoFile, diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 51ff7831dd13..24bfaabd708e 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -1,4 +1,7 @@ -import { isWorkspaceVideoPreviewPath } from "@t3tools/shared/filePreview"; +import { + isWorkspaceAudioPreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; export interface FileBreadcrumb { readonly label: string; @@ -100,6 +103,10 @@ export function isVideoPreviewFile(path: string): boolean { return isWorkspaceVideoPreviewPath(path); } +export function isAudioPreviewFile(path: string): boolean { + return isWorkspaceAudioPreviewPath(path.split(/[?#]/, 1)[0] ?? ""); +} + export function isSvgImagePreviewFile(path: string): boolean { return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? ""); } diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index 8ea903f831ba..6d59caf2a287 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -2,13 +2,20 @@ import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts" import { useMemo } from "react"; import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; -import { isAbsolutePath, isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; +import { + isAbsolutePath, + isAudioPreviewFile, + isVideoPreviewFile, + resolveWorkspaceFilePath, +} from "./filePath"; export function useWorkspaceFileAssetUrlState(props: { readonly cwd: string | null; readonly environmentId: EnvironmentId | null; readonly relativePath: string | null; readonly threadId: ThreadId | null; + /** A draft's workspace root, used only when there is no thread to resolve one from. */ + readonly draftCwd?: string | null; }) { const absolutePath = useMemo( () => @@ -18,23 +25,28 @@ export function useWorkspaceFileAssetUrlState(props: { [props.cwd, props.relativePath], ); - // Videos stream from an exact-file URL, and so does anything outside the - // workspace, where no workspace-scoped URL can exist. + // Video and audio stream from an exact-file URL, and so does anything outside + // the workspace, where no workspace-scoped URL can exist. const relativePath = props.relativePath; - const resource = useMemo( - () => - absolutePath !== null && relativePath !== null && props.threadId !== null - ? { - _tag: - isVideoPreviewFile(absolutePath) || isAbsolutePath(relativePath) - ? "media-file" - : "workspace-file", - threadId: props.threadId, - path: absolutePath, - } - : null, - [absolutePath, relativePath, props.threadId], - ); + const draftCwd = props.draftCwd ?? null; + const resource = useMemo(() => { + if (absolutePath === null || relativePath === null) return null; + if (props.threadId !== null) { + return { + _tag: + isVideoPreviewFile(absolutePath) || + isAudioPreviewFile(absolutePath) || + isAbsolutePath(relativePath) + ? "media-file" + : "workspace-file", + threadId: props.threadId, + path: absolutePath, + }; + } + // A project draft has no thread, so it names its workspace root explicitly. + if (draftCwd === null) return null; + return { _tag: "draft-workspace-file", cwd: draftCwd, path: relativePath }; + }, [absolutePath, relativePath, props.threadId, draftCwd]); const state = useAssetUrlState(props.environmentId, resource); const refresh = useRefreshAssetUrl(props.environmentId, resource); return { ...state, resource, refresh }; diff --git a/apps/mobile/src/features/review/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts index c2553ccafba0..d4a03538b241 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.test.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts @@ -53,6 +53,15 @@ describe("review comment serialization", () => { ]); }); + it("keeps a closing tag inside a chip label out of the inline comment body", () => { + const body = "Before [](t3-context://v1/mention/context-1) after"; + const serialized = `${body}`; + + expect(parseReviewInlineComments(serialized)).toEqual([ + expect.objectContaining({ text: body }), + ]); + }); + it("treats legacy markup inside a context label as opaque text", () => { const text = '[Review this](t3-context://v1/mention/context-1)'; diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 7b223efcc66a..f5c93076f13a 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -270,8 +270,23 @@ export function countReviewCommentContexts(value: string): number { export function parseReviewInlineComments(value: string): ReadonlyArray { const comments: ReviewInlineComment[] = []; - for (const [index, match] of Array.from(value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)).entries()) { - const comment = parseReviewInlineComment(match[1] ?? "", match[2] ?? "", index); + // Match on masked delimiters, as `parseReviewCommentMessageSegments` does: a chip label may + // contain ``, which would otherwise end the block early and truncate it. + const masked = replaceComposerContextReferences(value, (reference) => + " ".repeat(reference.source.length), + ); + for (const [index, match] of Array.from( + masked.matchAll(REVIEW_COMMENT_BLOCK_PATTERN), + ).entries()) { + const matchIndex = match.index; + const raw = value.slice(matchIndex, matchIndex + match[0].length); + const attributeStart = "".length), + index, + ); if (!comment) { continue; } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ba0521496ed1..8b03a2983bfa 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -28,7 +28,10 @@ import { import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { composerContextImportsAtom } from "../../state/use-composer-drafts"; -import { composerContextSendBlockReason } from "../../lib/composerContext"; +import { + composerContextSendBlockReason, + type ComposerDocumentAttachment, +} from "../../lib/composerContext"; import { ComposerActionButton, ComposerInlineControl, @@ -1138,11 +1141,30 @@ export function NewTaskDraftScreen(props: { !flow.submitting && !voiceInput.blocksSubmission && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); + const openDraftDocument = (attachment: ComposerDocumentAttachment) => { + // A draft attachment lives only in the draft. Without its key the screen would fall through + // to a remote lookup for bytes the server has never seen. + const draftKey = flow.draftKey; + if (!draftKey) return; + promptInputRef.current?.blur(); + void KeyboardController.dismiss({ animated: true }); + navigation.dispatch( + StackActions.push("NewTaskAttachment", { + environmentId: String(selectedProject.environmentId), + attachmentId: attachment.attachmentId, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: String(attachment.sizeBytes), + draftKey, + }), + ); + }; const promptEditor = ( <> { if (!composerWorkspaceCwd) return; promptInputRef.current?.blur(); @@ -1363,6 +1385,17 @@ export function NewTaskDraftScreen(props: { onPressVideo={ isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview } + onPressDocument={ + isComposerInteractionLocked || voiceInput.isBusy + ? undefined + : (attachment) => + openDraftDocument({ + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }) + } /> ) : null} diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index ca4daf1978fc..c2a903917bd5 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -9,11 +9,14 @@ import { } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { Alert, View } from "react-native"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages"; import { useThreadSelection } from "../../state/use-thread-selection"; +import { useNavigation } from "@react-navigation/native"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { useServerConfigs } from "../../state/entities"; import { appAtomRegistry } from "../../state/atom-registry"; import { @@ -38,6 +41,9 @@ export function QuestionAttachments(props: { onInputFocusChange?: ((focused: boolean) => void) | undefined; }) { const { selectedThread } = useThreadSelection(); + const navigation = useNavigation(); + const [previewFile, setPreviewFile] = useState(null); + const [previewVideo, setPreviewVideo] = useState(null); const configs = useServerConfigs(); const drafts = useAtomValue(composerDraftsAtom); const scopeKey = JSON.stringify([ @@ -166,7 +172,24 @@ export function QuestionAttachments(props: { onRemove={(id) => { if (!props.disabled) removeComposerDraftAttachment(key, id); }} + onPressPreview={setPreviewFile} + onPressVideo={(attachment, sourceIdentifier) => + setPreviewVideo({ type: "local", attachment, sourceIdentifier }) + } + onPressDocument={(attachment) => + navigation.navigate("ThreadAttachment", { + environmentId: String(environmentId), + threadId: String(threadId), + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: String(attachment.sizeBytes), + draftKey: key, + }) + } /> + setPreviewFile(null)} /> + setPreviewVideo(null)} /> { + Keyboard.dismiss(); + navigation.navigate("ThreadAttachment", { + environmentId: String(props.environmentId), + threadId: String(props.selectedThread.id), + attachmentId: attachment.attachmentId, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: String(attachment.sizeBytes), + draftKey: composerOwnerKey, + }); + }; const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; // T3 owns /usage-limits only where Limits has data for the selected provider; // elsewhere the name stays the provider's own and is sent through untouched. @@ -693,6 +706,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onRemove={voiceInput.isBusy ? () => undefined : props.onRemoveDraftImage} onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} + onPressDocument={ + voiceInput.isBusy + ? undefined + : (attachment) => + openDraftDocument({ + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }) + } /> ) : null} @@ -711,6 +735,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer path: fileRoutePathSegments(path), }); }} + onOpenAttachment={openDraftDocument} ref={inputRef} multiline value={props.draftMessage} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 14aa4592e213..66c4359b8190 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -175,6 +175,8 @@ import { } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; import { basename, @@ -286,6 +288,8 @@ function MessageAttachmentImage(props: { [props.attachmentId, props.name, props.mimeType], ); const uri = useAssetUrl(props.environmentId, resource); + const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource); + const retriedImage = useRef(false); if (uri === null) { return ( @@ -317,7 +321,19 @@ function MessageAttachmentImage(props: { }) } > - + { + retriedImage.current = false; + }} + onError={() => { + if (retriedImage.current) return; + retriedImage.current = true; + void refreshAssetUrl(); + }} + /> ); @@ -343,6 +359,8 @@ function MessageAttachmentFile(props: { readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; }) { const sourceIdentifier = useId(); + const navigation = useNavigation(); + const { selectedThread } = useThreadSelection(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { refresh: true, reportFailure: false, @@ -450,58 +468,68 @@ function MessageAttachmentFile(props: { } return ( - - - isPdf - ? props.onPressPreview({ - kind: "pdf", - name: attachment.name, - environmentId: props.environmentId, - resource: { - _tag: "attachment", - attachmentId: attachment.id, - fileName: attachment.name, - mimeType: "application/pdf", - }, - sourceIdentifier, - }) - : shareFile(sourceIdentifier) - } + <> + - - {opening ? ( - - ) : ( - - )} - - - - {attachment.name} - - - {fileTypeLabel} · {sizeLabel} - - - - - + shareFile(sourceIdentifier)} + onPress={() => + isPdf + ? props.onPressPreview({ + kind: "pdf", + name: attachment.name, + environmentId: props.environmentId, + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: "application/pdf", + }, + sourceIdentifier, + }) + : navigation.navigate("ThreadAttachment", { + environmentId: String(props.environmentId), + ...(selectedThread ? { threadId: String(selectedThread.id) } : {}), + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: String(attachment.sizeBytes), + }) + } + > + + {opening ? ( + + ) : ( + + )} + + + + {attachment.name} + + + {fileTypeLabel} · {sizeLabel} + + + + + + ); } @@ -1706,6 +1734,8 @@ type UserMessageContentProps = { function UserMessageContent(props: UserMessageContentProps) { const [selected, setSelected] = useState<{ contextId: string; label: string } | null>(null); + const navigation = useNavigation(); + const { selectedThread } = useThreadSelection(); const text = replaceComposerContextReferences(props.text, (ref) => { const available = props.context?.records.some((record) => record.contextId === ref.contextId); return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`; @@ -1720,6 +1750,19 @@ function UserMessageContent(props: UserMessageContentProps) { props.linkHandlers.onLinkPress?.(record.path); return; } + // Documents open in the file screen; pictures, video and PDF keep their native viewers. + const document = composerDocumentAttachmentRecord(record); + if (document) { + navigation.navigate("ThreadAttachment", { + environmentId: String(props.environmentId), + ...(selectedThread ? { threadId: String(selectedThread.id) } : {}), + attachmentId: document.attachmentId, + name: document.name, + mimeType: document.mimeType, + sizeBytes: String(document.sizeBytes), + }); + return; + } setSelected({ contextId: reference.contextId, label: record?.label ?? "Context unavailable" }); }; return ( @@ -1746,20 +1789,21 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { const text = props.text; const segments = parseReviewCommentMessageSegments(text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); + // A message can hold both a review comment and context chips. The fragment travels with every + // text run, so copying from the segmented branch carries the same context as the plain one. + const contextClipboardFragment = props.context + ? (encodeComposerContextFragment({ + version: 1, + source: { environmentId: props.environmentId }, + records: props.context.records, + }) ?? undefined) + : undefined; if (!hasReviewComment) { if (hasNativeSelectableMarkdownText()) { return ( navigation.goBack()} + onBack={ + layout.usesSplitView + ? undefined + : () => { + // A deep link or cold start has no previous route; Home is the way out. + // Read the history at press time: it changes without re-rendering this screen. + if (navigation.canGoBack()) navigation.goBack(); + else navigation.dispatch(StackActions.replace("Home")); + } + } actions={androidHeaderActions} hideBottomBorder={materialYouStyleLayoutActive} /> diff --git a/apps/mobile/src/lib/attachmentDocument.ts b/apps/mobile/src/lib/attachmentDocument.ts new file mode 100644 index 000000000000..b7b8397cc6ec --- /dev/null +++ b/apps/mobile/src/lib/attachmentDocument.ts @@ -0,0 +1,203 @@ +import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview"; +import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview"; +import { fetch } from "expo/fetch"; +import { File } from "expo-file-system"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Alert } from "react-native"; + +import type { FileBackedComposerAttachment } from "./composerImages"; +import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; +import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; +import { useRefreshAssetUrl } from "../state/assets"; + +const isLocalUri = (uri: string) => /^(file|content):/.test(uri); + +/** Signed asset URLs live for an hour; treat anything older than this as worth re-minting. */ +const STALE_URL_MS = 5 * 60_000; + +/** + * Loads a captured attachment for viewing: a fresh signed URL for a sent or uploaded file, + * a leased local file for a draft. Text kinds read a bounded prefix; documents hand their + * URL to a native or web renderer. Captured bytes never resolve against the workspace. + */ +export function useAttachmentDocument(input: { + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; + readonly attachmentId: string; + readonly environmentId: EnvironmentId | null; + readonly attachment: FileBackedComposerAttachment | null; +}) { + const kind = filePreviewKind(input); + const delimiter = filePreviewDelimiter(input); + const renderedMode = + kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null; + const shareController = useRef(null); + useEffect(() => () => shareController.current?.abort(), []); + const resource = useMemo( + () => ({ + _tag: "attachment" as const, + attachmentId: input.attachmentId, + fileName: input.name, + mimeType: input.mimeType, + disposition: "inline" as const, + }), + [input.attachmentId, input.name, input.mimeType], + ); + const environmentId = input.attachment ? null : input.environmentId; + const refresh = useRefreshAssetUrl(environmentId, resource); + const [localUri, setLocalUri] = useState(null); + const [remoteUri, setRemoteUri] = useState(null); + const [content, setContent] = useState<{ text: string; truncated: boolean } | null>(null); + const table = useMemo( + () => (content && delimiter ? parseDelimitedPreview(content.text, delimiter) : null), + [content, delimiter], + ); + const [error, setError] = useState(null); + // Reading source is a separate failure from loading the file: a rendered HTML page can be + // fine while its bytes are not UTF-8, and switching back to the page must not stay stuck. + const [contentError, setContentError] = useState(null); + const textReadUrl = useRef<{ uri: string; authorizedAt: number } | null>(null); + const [rendered, setRendered] = useState(true); + const [revision, setRevision] = useState(0); + const [sharing, setSharing] = useState(false); + const uri = input.attachment ? localUri : remoteUri; + const attachment = input.attachment; + useEffect(() => { + if (attachment) return; + let cancelled = false; + // Await a fresh signed URL: cached links can expire while the client is suspended. + // oxlint-disable-next-line react/set-state-in-effect -- A new preview request clears its previous URL and error. + setRemoteUri(null); + setError(null); + textReadUrl.current = null; + void refresh() + .then((url) => { + if (cancelled) return; + if (!url) throw new Error("Reconnect to this environment and try again."); + textReadUrl.current = { uri: url, authorizedAt: Date.now() }; + setRemoteUri(url); + }) + .catch((cause: unknown) => { + if (!cancelled) + setError(cause instanceof Error ? cause.message : "The attachment is unavailable."); + }); + return () => { + cancelled = true; + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reauthorize the remote file. + }, [attachment, refresh, revision]); + useEffect(() => { + if (!attachment) return; + // A new attachment must not keep the previous file behind it: `share()` would otherwise + // send the old bytes under the new name if this load fails. + // oxlint-disable-next-line react/set-state-in-effect -- A new attachment invalidates the last one. + setLocalUri(null); + setContent(null); + setContentError(null); + const controller = new AbortController(); + let release: (() => void) | undefined; + void loadLocalAttachmentPreview(attachment, controller.signal) + .then((file) => { + if (!file) return; + if (controller.signal.aborted) return file.dispose(); + release = file.dispose; + setLocalUri(file.uri); + setError(null); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) + setError(cause instanceof Error ? cause.message : "The local file is unavailable."); + }); + return () => { + controller.abort(); + release?.(); + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reacquire a local file lease after a failed load. + }, [attachment, revision]); + const needsText = kind === "text" || kind === "markdown" || (kind === "html" && !rendered); + const sizeBytes = input.sizeBytes; + useEffect(() => { + if (!uri || !needsText) return; + const controller = new AbortController(); + // oxlint-disable-next-line react/set-state-in-effect -- A new external resource must clear the previous response before loading. + setContent(null); + setContentError(null); + const response = isLocalUri(uri) + ? Promise.resolve().then(() => ({ ok: true, body: new File(uri).readableStream() })) + : (async () => { + // A signed URL minted when the file opened may have expired by the time the user + // switches to source; reauthorize before fetching instead of reading a stale link. + const authorized = textReadUrl.current; + let target = authorized?.uri ?? uri; + if (!authorized || Date.now() - authorized.authorizedAt > STALE_URL_MS) { + const refreshed = await refresh(); + if (!refreshed) throw new Error("Reconnect to this environment and try again."); + target = refreshed; + if (!controller.signal.aborted) { + // Keep the source-read URL and its age together without restarting active media. + textReadUrl.current = { uri: refreshed, authorizedAt: Date.now() }; + } + } + return fetch(target, { + signal: controller.signal, + headers: { + ...(sizeBytes > 0 ? { Range: `bytes=0-${FILE_TEXT_PREVIEW_MAX_BYTES}` } : {}), + ...(revision > 0 ? { "Cache-Control": "no-cache" } : {}), + }, + }); + })(); + void response + .then((value) => readFilePreviewResponse(value, controller.signal)) + .then((value) => { + if (!controller.signal.aborted) setContent(value); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) + setContentError(cause instanceof Error ? cause.message : "Could not read this file."); + }); + return () => controller.abort(); + }, [uri, needsText, revision, sizeBytes, refresh]); + const share = async () => { + if (!uri || sharing) return; + setSharing(true); + const controller = new AbortController(); + shareController.current = controller; + const request = { + attachment: { name: input.name, mimeType: input.mimeType }, + signal: controller.signal, + }; + try { + if (isLocalUri(uri)) await shareLocalAttachment({ ...request, uri }); + else await downloadAndShareAttachment({ ...request, url: (await refresh()) ?? uri }); + } catch (cause) { + if (controller.signal.aborted) return; + Alert.alert( + "Could not share file", + cause instanceof Error ? cause.message : "Please try again.", + ); + } finally { + if (shareController.current === controller) shareController.current = null; + if (!controller.signal.aborted) setSharing(false); + } + }; + return { + kind, + renderedMode, + uri, + /** Native viewers resolve their own fresh URL from this instead of reusing `uri`. */ + resource, + content, + table, + error: error ?? (needsText ? contentError : null), + needsText, + rendered, + setRendered, + revision, + retry: () => setRevision((value) => value + 1), + share, + sharing, + }; +} diff --git a/apps/mobile/src/lib/attachmentDownload.test.ts b/apps/mobile/src/lib/attachmentDownload.test.ts index 78e182e74f66..1832794872d4 100644 --- a/apps/mobile/src/lib/attachmentDownload.test.ts +++ b/apps/mobile/src/lib/attachmentDownload.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ share: vi.fn(), shareFromSource: vi.fn(), available: vi.fn(), + open: vi.fn(), uuid: vi.fn(), })); @@ -60,6 +61,8 @@ vi.mock("expo-file-system", () => { return { Directory, File, Paths: { cache: "file:///cache" } }; }); +vi.mock("expo", () => ({ requireNativeModule: () => ({ openFile: mocks.open }) })); + vi.mock("expo-sharing", () => ({ isAvailableAsync: mocks.available, shareAsync: mocks.share, @@ -69,6 +72,7 @@ vi.mock("./uuid", () => ({ uuidv4: mocks.uuid })); vi.mock("./shareFileFromSource", () => ({ shareFileFromSource: mocks.shareFromSource })); import { + openAttachmentInViewer, downloadAndShareAttachment, downloadAttachmentForPreview, shareLocalAttachment, @@ -84,6 +88,8 @@ const input = { }; beforeEach(() => { + mocks.open.mockReset(); + mocks.open.mockResolvedValue(undefined); mocks.directories.clear(); mocks.deleted.mockReset(); mocks.download.mockReset(); @@ -398,3 +404,29 @@ describe("attachment preview files", () => { expect(mocks.deleted).toHaveBeenCalledTimes(1); }); }); + +describe("document viewer handoff", () => { + it("opens a cache copy with its MIME type and retains it for the viewer", async () => { + await openAttachmentInViewer({ + uri: "file:///documents/report.pdf", + attachment: input.attachment, + signal: new AbortController().signal, + }); + expect(mocks.open).toHaveBeenCalledWith(mocks.copy.mock.calls[0]![1], "application/pdf"); + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.deleted).not.toHaveBeenCalled(); + }); + it("cleans up when no viewer handles the document", async () => { + mocks.open.mockRejectedValue(new Error("No viewer")); + await expect( + openAttachmentInViewer({ + uri: input.url, + attachment: input.attachment, + signal: new AbortController().signal, + }), + ).rejects.toThrow("No viewer"); + expect(mocks.download).toHaveBeenCalledTimes(1); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + expect(isForegroundHandoffActive()).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/attachmentDownload.ts b/apps/mobile/src/lib/attachmentDownload.ts index 2ae0c729c190..232f2a389306 100644 --- a/apps/mobile/src/lib/attachmentDownload.ts +++ b/apps/mobile/src/lib/attachmentDownload.ts @@ -151,7 +151,55 @@ async function createCachedAttachmentFile(attachment: AttachmentFileMetadata) { } }, }; - return { file, preview }; + return { + file, + preview, + retainAfterHandoff: () => { + shared = true; + }, + }; +} + +/** A readable reason for a viewer refusing a file; native rejections carry stack text. */ +export function nativeViewerErrorMessage(cause: unknown): string { + const text = cause instanceof Error ? cause.message : String(cause); + return /ActivityNotFound|cannot be previewed/i.test(text) + ? "No app on this device can show this format. Save or share it to open it elsewhere." + : "The file could not be opened. Check the connection and try again."; +} + +/** Open an Android document in a viewer, retaining the cache while another app reads it. */ +export async function openAttachmentInViewer(input: { + readonly uri: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; +}): Promise { + const { File } = await import("expo-file-system"); + const { requireNativeModule } = await import("expo"); + if (input.signal.aborted) return; + const cached = await createCachedAttachmentFile(input.attachment); + try { + if (/^(file|content):/.test(input.uri)) { + await new File(input.uri).copy(cached.file); + } else { + await File.downloadFileAsync(input.uri, cached.file, { signal: input.signal }); + } + if (input.signal.aborted) return; + const endHandoff = beginForegroundHandoff(); + try { + await requireNativeModule<{ openFile(uri: string, mimeType: string): Promise }>( + "T3NativeControls", + ).openFile( + cached.file.uri, + input.attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", + ); + cached.retainAfterHandoff(); + } finally { + endHandoff(); + } + } finally { + cached.preview.dispose(); + } } /** The caller owns this cached file until disposal, unless it has been shared with another app. */ diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index cf4d7d941aea..81e87b506027 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -1,4 +1,6 @@ import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; +import { filePreviewKind } from "@t3tools/shared/filePreview"; +import { videoMimeType } from "@t3tools/shared/video"; import { COMPOSER_CONTEXT_MAX_RECORDS, ComposerContextId, @@ -45,6 +47,38 @@ export function composerMentionPath(source: string, context?: OrchestrationMessa return token?.type === "mention" && token.source === source ? token.value : null; } +export interface ComposerDocumentAttachment { + readonly attachmentId: string; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; +} + +/** + * The attachment behind a chip when it is a document rather than a picture, video or PDF. + * Those three open in native viewers; documents open in the file screen. + */ +export function composerDocumentAttachment( + source: string, + context?: OrchestrationMessageContext, +): ComposerDocumentAttachment | null { + const reference = collectComposerContextReferences(source)[0]; + const record = reference + ? context?.records.find((entry) => entry.contextId === reference.contextId) + : undefined; + return composerDocumentAttachmentRecord(record); +} + +export function composerDocumentAttachmentRecord( + record: ComposerContextRecord | undefined, +): ComposerDocumentAttachment | null { + if (!record || "payload" in record || record.kind !== "file") return null; + if (videoMimeType(record) !== null) return null; + const kind = filePreviewKind(record); + if (kind === "image" || kind === "pdf" || kind === "video") return null; + return record; +} + /** Retain a bounded native undo history without persisting removed payloads in the draft. */ export function createComposerContextHistory() { const records = new Map(); diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 57b548df34a5..dfe19c231c4a 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -28,6 +28,15 @@ describe("nativeMarkdownTextRuns", () => { ); }); + it("renders a video-named file with a declared document MIME type as a file chip", () => { + expect( + contextChipPresentation("file", { + name: "recording.mp4", + mimeType: "application/pdf", + }), + ).toEqual(contextChipPresentation("file")); + }); + it("maps rendered selection offsets back to canonical references without losing repeated chips", () => { const href = "t3-context://v1/image/screenshot"; expect( @@ -566,6 +575,26 @@ describe("nativeMarkdownDocumentRuns", () => { .join(""), ).toBe("BASH\npnpm install"); }); + + it("keeps adjacent context links with the same href in separate runs", () => { + const href = "t3-context://v1/terminal/ctx-1"; + const link = (content: string): MarkdownNode => ({ + type: "link", + href, + children: [{ type: "text", content }], + }); + const runs = nativeMarkdownDocumentRuns({ + type: "document", + children: [{ type: "paragraph", children: [link("First"), link("Second")] }], + }); + + // Merging these would render one chip and emit one copy range with a + // combined label for two distinct references. + expect(runs).toEqual([ + { text: "First", role: "body", href, fileIcon: "bash" }, + { text: "Second", role: "body", href, fileIcon: "bash" }, + ]); + }); }); describe("nativeMarkdownListItemBlocks", () => { diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts index 90db612d002f..3b5837a1861b 100644 --- a/apps/mobile/src/lib/videoPreviewSource.ts +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -15,7 +15,10 @@ export type MediaVideoPreviewSource = { | { readonly uri: string } | { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract< + AssetResource, + { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" } + >; } ); @@ -35,13 +38,23 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string ? ["media-video", source.uri] : source.resource._tag === "attachment" ? ["media-video", source.environmentId, "attachment", source.resource.attachmentId] - : [ - "media-video", - source.environmentId, - source.resource.threadId, - source.resource.path, - source.srcFragment ?? "", - ], + : source.resource._tag === "media-file" + ? [ + "media-video", + "media-file", + source.environmentId, + source.resource.threadId, + source.resource.path, + source.srcFragment ?? "", + ] + : [ + "media-video", + "draft-workspace-file", + source.environmentId, + source.resource.cwd, + source.resource.path, + source.srcFragment ?? "", + ], ); } diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index 8ebfd81010cc..dc1448e87d76 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -24,7 +24,8 @@ export interface ComposerEditorProps { readonly html: string; }) => void; readonly skills?: ReadonlyArray< - Pick + Pick & + Partial> >; readonly selection?: ComposerEditorSelection; readonly placeholder?: string; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 7271e158b11a..a97d84d256c4 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -173,6 +173,7 @@ import { migrateLegacyNewTaskDraft, releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, + replaceComposerDraftAttachments, resetComposerDraftsLoadState, retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, @@ -302,6 +303,40 @@ describe("mobile composer drafts", () => { expect(reloaded?.context?.records[0]?.label.length).toBeLessThanOrEqual(200); }); + it("drops chips and records for attachments a replace no longer keeps", () => { + const key = "new-task:draft-1"; + const kept = { + type: "file" as const, + id: "kept-file", + name: "kept.txt", + mimeType: "text/plain", + sizeBytes: 1, + fileUri: "file:///kept.txt", + }; + const dropped = { + type: "file" as const, + id: "dropped-file", + name: "dropped.txt", + mimeType: "text/plain", + sizeBytes: 1, + fileUri: "file:///dropped.txt", + }; + appendComposerDraftAttachments(key, [kept, dropped], { appendReference: true }); + + const before = getComposerDraftSnapshot(key); + expect(before.text).toContain("dropped.txt"); + expect(before.context?.records).toHaveLength(2); + + replaceComposerDraftAttachments(key, [kept]); + + const after = getComposerDraftSnapshot(key); + expect(after.attachments.map((attachment) => attachment.id)).toEqual([kept.id]); + expect(after.text).not.toContain("dropped.txt"); + expect(after.context?.records.map((record) => record.contextId)).toEqual([ + before.context?.records[0]?.contextId, + ]); + }); + it.each(["new-task:draft-1", "pending-task:queued-1"])( "finds draft-only local clipboard files in %s", (key) => { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index cb47c44f8a3f..b8ec4276b3cf 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1205,14 +1205,27 @@ export function replaceComposerDraftAttachments( attachments: ReadonlyArray, ): void { const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; + const retainedIds = new Set(attachments.map((attachment) => attachment.id)); updateComposerDrafts((current) => { + const existing = normalizeDraft(current[draftKey]); + // An attachment that is no longer here must take its chip and context record with it, or + // the draft keeps a reference pointing at a file it no longer holds. + const droppedContextIds = new Set( + existing.context?.records + .filter((record) => "attachmentId" in record && !retainedIds.has(record.attachmentId)) + .map((record) => record.contextId), + ); + const text = replaceComposerContextReferences(existing.text, (ref) => + droppedContextIds.has(ref.contextId) ? "" : ref.source, + ); const draft = { - ...normalizeDraft(current[draftKey]), + ...existing, + text, + context: referencedComposerContext(text, existing.context), attachments, }; return withComposerDraft(current, draftKey, draft); }); - const retainedIds = new Set(attachments.map((attachment) => attachment.id)); scheduleUnusedComposerAttachmentCleanup( previousAttachments.filter((attachment) => !retainedIds.has(attachment.id)), ); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index b83b8684432c..225910e38426 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -495,6 +495,96 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues draft workspace URLs without a thread", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-draft-", + }); + const htmlPath = path.join(root, "report.html"); + const cssPath = path.join(root, "report.css"); + yield* fileSystem.writeFileString(htmlPath, ''); + yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); + const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); + const canonicalCssPath = yield* fileSystem.realPath(cssPath); + + const result = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: "report.html" }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "report.html")).toEqual({ + kind: "file", + path: canonicalHtmlPath, + }); + expect(yield* resolveAsset(token, "report.css")).toEqual({ + kind: "file", + path: canonicalCssPath, + }); + expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("serves absolute draft media files exactly, wherever they live", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-draft-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-draft-outside-", + }); + const clipPath = path.join(outside, "clip.mp4"); + yield* fileSystem.writeFileString(clipPath, "video"); + const canonicalClipPath = yield* fileSystem.realPath(clipPath); + + const result = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: clipPath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "clip.mp4")).toMatchObject({ + kind: "file", + path: canonicalClipPath, + mimeType: "video/mp4", + }); + expect(yield* resolveAsset(token, "other.mp4")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("falls back to the resource cwd for relative draft paths", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-draft-fallback-", + }); + const htmlPath = path.join(root, "report.html"); + yield* fileSystem.writeFileString(htmlPath, "

draft

"); + const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); + + const result = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: "report.html" }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); + + expect(yield* resolveAsset(token, "report.html")).toEqual({ + kind: "file", + path: canonicalHtmlPath, + }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("preserves non-missing canonical path failures when issuing asset URLs", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -677,6 +767,40 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("serves audio previews with their stored format and keeps saving explicit", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000003-wav"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.wav`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + for (const disposition of ["inline", "attachment"] as const) { + const result = yield* issueAssetUrl({ + resource: { + _tag: "attachment", + attachmentId, + fileName: "recording.wav", + mimeType: "application/octet-stream", + disposition, + }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), + ).toEqual({ + kind: "file", + path: attachmentPath, + fileName: "recording.wav", + mimeType: disposition === "inline" ? "audio/wav" : "application/octet-stream", + ...(disposition === "attachment" ? { download: true } : {}), + }); + } + }).pipe(Effect.provide(testLayer)), + ); + it.effect("keeps inline requests for other attachment types as downloads", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 956c4ac44211..700aeacb19a5 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -15,6 +15,7 @@ import { ToolActivityNativeAppReference, } from "@t3tools/contracts"; import { + audioMimeTypeFromExtension, hostPreviewMimeTypeFromExtension, isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, @@ -58,14 +59,15 @@ const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; const PROJECT_FAVICON_VERSION_PREFIX = "v"; const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i; -// Extensions a document viewer may request inline. The extension comes from +// Extensions a document viewer or audio player may request inline. The extension comes from // the attachment id the server assigned, never from the client's mime type. -const INLINE_DOCUMENT_EXTENSIONS = new Set(["pdf", "html", "htm"]); -const INLINE_DOCUMENT_MIME_TYPES: Record = { +const INLINE_PREVIEW_MIME_TYPES: Record = { pdf: "application/pdf", html: "text/html", htm: "text/html", }; +const inlinePreviewMimeTypeForExtension = (extension: string) => + INLINE_PREVIEW_MIME_TYPES[extension] ?? audioMimeTypeFromExtension(`.${extension}`) ?? undefined; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -257,6 +259,143 @@ const readImageDimensionsFromHeader = (filePath: string) => Effect.orElseSucceed((): ImageDimensions | null => null), ); +const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMediaFileAsset")( + function* (input: { + readonly requestedPath: string; + readonly resource: AssetResource; + readonly expiresAt: number; + }) { + const path = yield* Path.Path; + const canonicalFile = yield* resolveCanonicalFile(input.requestedPath).pipe( + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!canonicalFile) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase()); + const opened = yield* openMediaFile(canonicalFile).pipe( + Effect.flatMap((file) => + file === null + ? Effect.succeed(null) + : Effect.map( + wantsDimensions + ? readImageDimensionsFromOpenFile(canonicalFile, file) + : Effect.succeed(null), + (dimensions) => ({ + identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() }, + dimensions, + }), + ), + ), + Effect.scoped, + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!opened) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + return { + claims: { + version: 1 as const, + kind: "media-file-exact" as const, + filePath: canonicalFile, + ...opened.identity, + expiresAt: input.expiresAt, + }, + fileName: path.basename(canonicalFile), + imageDimensions: opened.dimensions, + }; + }, +); + +const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileAsset")( + function* (input: { + readonly workspaceRoot: string; + readonly requestedPath: string; + readonly resource: AssetResource; + readonly expiresAt: number; + }) { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const relativePath = path.isAbsolute(input.requestedPath) + ? path.relative(input.workspaceRoot, input.requestedPath) + : input.requestedPath; + const resolved = yield* workspacePaths + .resolveRelativePathWithinRoot({ workspaceRoot: input.workspaceRoot, relativePath }) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspacePathValidationError({ + resource: input.resource, + cause, + }), + ), + ); + if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { + return yield* new AssetPreviewTypeValidationError({ + resource: input.resource, + }); + } + const canonicalFile = yield* resolveCanonicalWorkspaceFile({ + workspaceRoot: input.workspaceRoot, + relativePath: resolved.relativePath, + }).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceAssetInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + if (!canonicalFile) { + return yield* new AssetWorkspaceAssetNotFoundError({ + resource: input.resource, + }); + } + const canonicalWorkspaceRoot = yield* fileSystem.realPath(input.workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + const imageDimensions = HEADER_IMAGE_EXTENSIONS.has( + path.extname(resolved.relativePath).toLowerCase(), + ) + ? yield* readImageDimensionsFromHeader(canonicalFile) + : null; + return { + claims: isWorkspaceImagePreviewPath(resolved.relativePath) + ? { + version: 1 as const, + kind: "workspace-file-exact" as const, + workspaceRoot: canonicalWorkspaceRoot, + relativePath: resolved.relativePath, + expiresAt: input.expiresAt, + } + : { + version: 1 as const, + kind: "workspace-file" as const, + workspaceRoot: canonicalWorkspaceRoot, + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt: input.expiresAt, + }, + fileName: path.basename(resolved.relativePath), + imageDimensions, + }; + }, +); + export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; @@ -288,52 +427,14 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ); requestedPath = path.resolve(workspaceRoot, requestedPath); } - const canonicalFile = yield* resolveCanonicalFile(requestedPath).pipe( - Effect.mapError( - (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), - ), - ); - if (!canonicalFile) { - return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); - } - if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { - return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); - } - const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has( - path.extname(canonicalFile).toLowerCase(), - ); - const opened = yield* openMediaFile(canonicalFile).pipe( - Effect.flatMap((file) => - file === null - ? Effect.succeed(null) - : Effect.map( - wantsDimensions - ? readImageDimensionsFromOpenFile(canonicalFile, file) - : Effect.succeed(null), - (dimensions) => ({ - identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() }, - dimensions, - }), - ), - ), - Effect.scoped, - Effect.mapError( - (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), - ), - ); - if (!opened) { - return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); - } - const identity = opened.identity; - imageDimensions = opened.dimensions; - claims = { - version: 1, - kind: "media-file-exact", - filePath: canonicalFile, - ...identity, + const finalized = yield* finalizeAbsoluteMediaFileAsset({ + requestedPath, + resource: input.resource, expiresAt, - }; - fileName = path.basename(canonicalFile); + }); + claims = finalized.claims; + fileName = finalized.fileName; + imageDimensions = finalized.imageDimensions; break; } case "workspace-file": { @@ -351,70 +452,51 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - const relativePath = path.isAbsolute(input.resource.path) - ? path.relative(workspaceRoot, input.resource.path) - : input.resource.path; - const resolved = yield* workspacePaths - .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) - .pipe( - Effect.mapError( - (cause) => - new AssetWorkspacePathValidationError({ - resource: input.resource, - cause, - }), - ), - ); - if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { - return yield* new AssetPreviewTypeValidationError({ - resource: input.resource, - }); - } - const canonicalFile = yield* resolveCanonicalWorkspaceFile({ + const finalized = yield* finalizeWorkspaceFileAsset({ workspaceRoot, - relativePath: resolved.relativePath, - }).pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceAssetInspectionError({ - resource: input.resource, - cause, - }), - ), - ); - if (!canonicalFile) { - return yield* new AssetWorkspaceAssetNotFoundError({ + requestedPath: input.resource.path, + resource: input.resource, + expiresAt, + }); + claims = finalized.claims; + fileName = finalized.fileName; + imageDimensions = finalized.imageDimensions; + break; + } + case "draft-workspace-file": { + // The draft names its workspace root in the resource itself; an explicit + // root only overrides it. + const draftWorkspaceRoot = input.workspaceRoot ?? input.resource.cwd; + if (path.isAbsolute(input.resource.path)) { + // An absolute draft path serves exactly like an absolute media path. + const finalized = yield* finalizeAbsoluteMediaFileAsset({ + requestedPath: input.resource.path, resource: input.resource, + expiresAt, }); + claims = finalized.claims; + fileName = finalized.fileName; + imageDimensions = finalized.imageDimensions; + break; } - const canonicalWorkspaceRoot = yield* fileSystem.realPath(workspaceRoot).pipe( + const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(draftWorkspaceRoot).pipe( Effect.mapError( (cause) => - new AssetWorkspaceResolutionError({ + new AssetWorkspaceRootNormalizationError({ resource: input.resource, cause, }), ), ); - if (HEADER_IMAGE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { - imageDimensions = yield* readImageDimensionsFromHeader(canonicalFile); - } - claims = isWorkspaceImagePreviewPath(resolved.relativePath) - ? { - version: 1, - kind: "workspace-file-exact", - workspaceRoot: canonicalWorkspaceRoot, - relativePath: resolved.relativePath, - expiresAt, - } - : { - version: 1, - kind: "workspace-file", - workspaceRoot: canonicalWorkspaceRoot, - baseRelativePath: path.dirname(resolved.relativePath), - expiresAt, - }; - fileName = path.basename(resolved.relativePath); + const finalized = yield* finalizeWorkspaceFileAsset({ + workspaceRoot, + requestedPath: input.resource.path, + resource: input.resource, + expiresAt, + }); + claims = finalized.claims; + fileName = finalized.fileName; + imageDimensions = finalized.imageDimensions; break; } case "attachment": { @@ -430,17 +512,15 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } // Generic files carry their extension inside the attachment id (that // shape resolves the on-disk path); images do not. Videos and images - // render inline. Other generic files download, unless a document viewer - // asked for inline and the stored extension is one a browser can show. + // render inline. Other generic files download unless a viewer requests + // a supported document or audio format inline. const extension = parseAttachmentFileExtension(input.resource.attachmentId); const isGenericFile = extension !== null; const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? ""; const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType); - const inlineDocumentMimeType = - input.resource.disposition === "inline" && - extension !== null && - INLINE_DOCUMENT_EXTENSIONS.has(extension) - ? INLINE_DOCUMENT_MIME_TYPES[extension] + const inlinePreviewMimeType = + input.resource.disposition === "inline" && extension !== null + ? inlinePreviewMimeTypeForExtension(extension) : undefined; if (!isGenericFile) { imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath); @@ -449,12 +529,12 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i version: 1, kind: "attachment", attachmentId: input.resource.attachmentId, - ...(isGenericFile && !isVideo && inlineDocumentMimeType === undefined + ...(isGenericFile && !isVideo && inlinePreviewMimeType === undefined ? { download: true } : {}), ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}), - ...(inlineDocumentMimeType !== undefined - ? { mimeType: inlineDocumentMimeType } + ...(inlinePreviewMimeType !== undefined + ? { mimeType: inlinePreviewMimeType } : input.resource.mimeType !== undefined ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType } : {}), diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 0c253033ac52..ef75d9446158 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -146,6 +146,24 @@ describe("video asset byte ranges", () => { }).pipe(Effect.provide(fileResponseLayer)), ); + it.effect("keeps attachment media out of the cache once its signed URL expires", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-attachment-media-" }); + const filePath = path.join(directory, "audio.wav"); + yield* fs.writeFileString(filePath, "RIFF"); + const canonicalPath = yield* fs.realPath(filePath); + // An attachment is read straight from disk, so it carries no opened host file. Its URL is + // signed and short-lived; a cached copy would outlive the grant that served it. + const response = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: canonicalPath, mimeType: "audio/wav" }), + ); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + }).pipe(Effect.provide(fileResponseLayer)), + ); + it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -246,6 +264,36 @@ describe("video asset byte ranges", () => { }).pipe(Effect.provide(fileResponseLayer)), ); + it.effect( + "supports native audio header probes and seeking without changing explicit downloads", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-audio-range-" }); + const file = path.join(directory, "recording.wav"); + yield* fs.writeFileString(file, "0123456789"); + const asset = { path: file, mimeType: "audio/wav" }; + for (const [header, expected] of [ + ["bytes=0-1", "01"], + ["bytes=5-", "56789"], + ] as const) { + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header)); + expect(response.status).toBe(206); + expect(response.headers.get("content-type")).toBe("audio/wav"); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + const download = HttpServerResponse.toWeb( + yield* assetFileResponse({ ...asset, download: true, fileName: "recording.wav" }), + ); + expect(download.status).toBe(200); + expect(download.headers.get("content-disposition")).toContain("attachment;"); + expect(yield* Effect.promise(() => download.text())).toBe("0123456789"); + }).pipe(Effect.provide(fileResponseLayer)), + ); + it.effect("rejects ranges outside the file, including empty files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 290b73b48514..4d2dac735425 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -63,8 +63,8 @@ const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; const isSafeDownloadMimeType = (mimeType: string): boolean => DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase()); -const isSafeInlineVideoMimeType = (mimeType: string): boolean => - DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/"); +const isSafeInlineMediaMimeType = (mimeType: string): boolean => + DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && /^(?:audio|video)\//i.test(mimeType); const isSafeInlineDocumentMimeType = (mimeType: string): boolean => mimeType.toLowerCase() === "application/pdf" || mimeType.toLowerCase() === "text/html"; @@ -108,7 +108,7 @@ export function assetResponseHeaders( ? options.mimeType : "application/octet-stream", } - : inlineMimeType !== undefined && isSafeInlineVideoMimeType(inlineMimeType) + : inlineMimeType !== undefined && isSafeInlineMediaMimeType(inlineMimeType) ? { "Content-Type": inlineMimeType } : inlineMimeType !== undefined && isSafeInlineDocumentMimeType(inlineMimeType) ? { @@ -132,7 +132,7 @@ export function assetResponseHeaders( }; } -/** A single byte range for native video readers; unsupported range syntax uses the full file. */ +/** A single byte range for native media readers; unsupported range syntax uses the full file. */ function assetByteRange(header: string, size: bigint) { const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim()); if (!match || (!match[1] && !match[2])) return null; @@ -170,16 +170,17 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( const headers = assetResponseHeaders(asset.path, asset); const mediaFile = asset.file; const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; - const isVideo = headers["Content-Type"]?.toLowerCase().startsWith("video/") === true; - if (mediaFile && isVideo) { - // Host videos can change in place. Do not invite conditional range requests - // with validators that cannot establish byte-for-byte identity. + const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? ""); + if (isMedia) { + // Host media can change in place. Do not invite conditional range requests + // with validators that cannot establish byte-for-byte identity. Attachment media + // carries no `file`, and must not outlive the signed URL that granted it either. headers["Cache-Control"] = "private, no-store"; } let status = 200; let offset = 0n; let bytesToRead: bigint | undefined; - if (isVideo) { + if (isMedia) { headers["Accept-Ranges"] = "bytes"; // If-Range requires a matching validator. A full response is safe when we cannot validate it. if (method === "GET" && rangeHeader && ifRangeHeader === undefined) { @@ -204,7 +205,7 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( const size = bytesToRead ?? mediaInfo.size; headers["Content-Type"] ??= Mime.getType(asset.path) ?? "application/octet-stream"; headers["Content-Length"] = String(size); - if (!isVideo) { + if (!isMedia) { headers["Last-Modified"] = mediaInfo.mtime.toUTCString(); headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`; } diff --git a/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts index 13e4c1e08519..c3cdfa1b9dcd 100644 --- a/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts +++ b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts @@ -8,18 +8,18 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); -layer("050_ProjectionThreadMessageContext", (it) => { +layer("051_ProjectionThreadMessageContext", (it) => { it.effect("accepts context added by an earlier development migration", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - yield* runMigrations({ toMigrationInclusive: 49 }); + yield* runMigrations({ toMigrationInclusive: 50 }); yield* sql` ALTER TABLE projection_thread_messages ADD COLUMN context_json TEXT `; - yield* runMigrations({ toMigrationInclusive: 50 }); + yield* runMigrations({ toMigrationInclusive: 51 }); const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` PRAGMA table_info(projection_thread_messages) @@ -28,7 +28,7 @@ layer("050_ProjectionThreadMessageContext", (it) => { const migrations = yield* sql<{ readonly migration_id: number }>` SELECT migration_id FROM effect_sql_migrations - WHERE migration_id = 50 + WHERE migration_id = 51 `; assert.equal(context?.name, "context_json"); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index afb4adfef79c..81399ce61d6f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5537,6 +5537,31 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves draft workspace files without a thread", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const wsUrl = yield* getWsServerUrl("/ws"); + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-draft-media-" }); + yield* fileSystem.writeFileString(path.join(directory, "note.html"), "

draft

"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const issued = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "draft-workspace-file", cwd: directory, path: "note.html" }, + }); + const response = yield* HttpClient.get(issued.relativeUrl); + assert.equal(response.status, 200); + assert.equal(response.headers["content-type"], "text/html; charset=utf-8"); + assert.equal(yield* response.text, "

draft

"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("uploads image bytes through a signed URL issued by websocket rpc", () => Effect.gen(function* () { const config = yield* buildAppUnderTest(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f59a753d9c72..e91fadd3155e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2464,6 +2464,14 @@ const makeWsRpcLayer = ( ) { return yield* issueAssetUrl({ resource: input.resource }); } + if (input.resource._tag === "draft-workspace-file") { + // A project draft names its workspace directly; there is no + // thread to resolve one from. + return yield* issueAssetUrl({ + resource: input.resource, + workspaceRoot: input.resource.cwd, + }); + } if (input.resource._tag === "project-favicon") { const project = yield* projectionSnapshotQuery .getActiveProjectByWorkspaceRoot(input.resource.cwd) diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 5a9738c9fbfa..89a864f76d41 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -35,16 +35,19 @@ export function useAssetUrlState( export function useAssetUrlRefresh( environmentId: EnvironmentId | null, resource: AssetResource | null, -): () => Promise { +): () => Promise { + const connection = usePreparedConnection(environmentId); + const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); return useCallback(async () => { - if (environmentId === null || resource === null) return; + if (environmentId === null || resource === null || httpBaseUrl === null) return null; const result = await refresh({ environmentId, input: { resource } }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); - }, [environmentId, resource, refresh]); + return resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + }, [environmentId, resource, refresh, httpBaseUrl]); } export function useAssetUrls( diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index d076756e468a..ce2b0f8efa53 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -112,6 +112,32 @@ describe("ChatMarkdown context references", () => { vi.unstubAllGlobals(); } }); + + it("reads formatted context labels through nested markup instead of the context id", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const seen: Array = []; + try { + await act(async () => { + renderer = create( + { + seen.push(`${kind}: ${label}`); + return ; + }} + />, + ); + }); + expect(seen).toEqual(["terminal: Bold code"]); + } finally { + await act(async () => { + renderer?.unmount(); + }); + vi.unstubAllGlobals(); + } + }); }); describe("ChatMarkdown favicon privacy", () => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 906b5887545d..4f113f6ed4be 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1534,7 +1534,7 @@ function ChatMarkdownVideo(props: { readonly style?: CSSProperties | undefined; readonly mediaIdentity?: string | undefined; readonly actionsSource?: MediaActionSource | undefined; - readonly onRetry?: (() => Promise) | undefined; + readonly onRetry?: (() => Promise) | undefined; }) { return ( part !== null) ? parts.join("") : null; } +/** + * The anchor's words, gathered through any nesting. A context label that picked up emphasis or a + * code span still has to read as its label; `plainHastText` gives up on the first non-text child, + * which would leave the raw context id showing in its place. + */ +function hastPlainTextDeep(node: unknown): string { + if (!node || typeof node !== "object") return ""; + if ("type" in node && node.type === "text" && "value" in node && typeof node.value === "string") { + return node.value; + } + if (!("children" in node) || !Array.isArray(node.children)) return ""; + return node.children.map(hastPlainTextDeep).join(""); +} + /** * Whether the link carries any words of its own. An anchor that is only an image — a badge, a * "Fix in Cursor" button — already shows its identity, and a favicon bolted on in front of it @@ -2771,7 +2785,7 @@ const CHAT_MARKDOWN_COMPONENTS = { if (citation) return ; const contextReference = href ? parseComposerContextHref(href) : null; if (contextReference) { - const label = plainHastText(node) ?? contextReference.contextId; + const label = hastPlainTextDeep(node) || contextReference.contextId; return renderContextReference ? ( renderContextReference({ ...contextReference, label }) ) : ( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9b660e870563..f8939aad30e9 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -51,6 +51,7 @@ import { rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, + restorePlanFollowUpComposer, resolveComposerProviderSelection, resolveDraftPromotionNavigationTarget, observeProactivePanelUserChoice, @@ -2242,3 +2243,66 @@ describe("threadShellHasStarted", () => { expect(threadShellHasStarted(null)).toBe(false); }); }); + +describe("restorePlanFollowUpComposer", () => { + it("writes back every field a cleared plan follow-up composer held", () => { + const snapshot = { + prompt: "Follow up on the plan", + terminalContexts: [ + { + id: "terminal-1", + threadId: ThreadId.make("thread-1"), + createdAt: "2026-09-11T00:00:00.000Z", + terminalId: "main", + terminalLabel: "Main", + lineStart: 1, + lineEnd: 2, + text: "output", + }, + ], + reviewComments: [ + { + id: "review-1", + sectionId: "file:a.ts", + sectionTitle: "File comment", + filePath: "a.ts", + startIndex: 0, + endIndex: 0, + rangeLabel: "L1", + text: "look here", + diff: "", + }, + ], + previewAnnotations: [], + }; + const writePrompt = vi.fn(); + const writeTerminalContexts = vi.fn(); + const writeReviewComments = vi.fn(); + const writePreviewAnnotations = vi.fn(); + const resetCursor = vi.fn(); + + restorePlanFollowUpComposer({ + snapshot, + writePrompt, + writeTerminalContexts, + writeReviewComments, + writePreviewAnnotations, + resetCursor, + }); + + expect(writePrompt).toHaveBeenCalledTimes(1); + expect(writePrompt).toHaveBeenCalledWith("Follow up on the plan"); + expect(writeTerminalContexts).toHaveBeenCalledTimes(1); + expect(writeTerminalContexts).toHaveBeenCalledWith(snapshot.terminalContexts); + expect(writeReviewComments).toHaveBeenCalledTimes(1); + expect(writeReviewComments).toHaveBeenCalledWith(snapshot.reviewComments); + expect(writePreviewAnnotations).toHaveBeenCalledTimes(1); + expect(writePreviewAnnotations).toHaveBeenCalledWith(snapshot.previewAnnotations); + expect(resetCursor).toHaveBeenCalledTimes(1); + expect(resetCursor).toHaveBeenCalledWith({ + cursor: expect.any(Number), + prompt: "Follow up on the plan", + detectTrigger: true, + }); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c6dbf6a5cb1a..7fd1e6489ac6 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -8,6 +8,7 @@ import { ProjectId, type MessageId, type ModelSelection, + type PreviewAnnotationPayload, type ProviderInteractionMode, ProviderDriverKind, type ProviderInstanceId, @@ -45,7 +46,8 @@ import { environmentThreadDetails } from "../state/threads"; import { stripInlineContextReferences } from "~/lib/composerContextReferences"; import { filterTerminalContextsWithText, type TerminalContextDraft } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; -import type { ComposerSubmissionIntent } from "../composer-logic"; +import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; +import type { ReviewCommentContext } from "../reviewCommentContext"; import type { TimelineEntry } from "../session-logic"; import type { PreviewMiniPlayerSource } from "../previewMiniPlayerStore"; import type { DesktopPreviewOverlay } from "../previewStateStore"; @@ -1252,3 +1254,38 @@ export function shouldRefocusComposerOnWindowFocus( ) === null ); } + +export interface PlanFollowUpComposerSnapshot { + readonly prompt: string; + readonly terminalContexts: ReadonlyArray; + readonly reviewComments: ReadonlyArray; + readonly previewAnnotations: ReadonlyArray; +} + +/** + * Puts back everything a plan follow-up send cleared when the send fails. The + * caller clears the composer before awaiting the send, so every field it held + * has to be written back here: a dropped field silently discards user context. + */ +export function restorePlanFollowUpComposer(input: { + readonly snapshot: PlanFollowUpComposerSnapshot; + readonly writePrompt: (prompt: string) => void; + readonly writeTerminalContexts: (contexts: ReadonlyArray) => void; + readonly writeReviewComments: (comments: ReadonlyArray) => void; + readonly writePreviewAnnotations: (annotations: ReadonlyArray) => void; + readonly resetCursor: (options: { + cursor: number; + prompt: string; + detectTrigger: boolean; + }) => void; +}): void { + input.writePrompt(input.snapshot.prompt); + input.writeTerminalContexts(input.snapshot.terminalContexts); + input.writeReviewComments(input.snapshot.reviewComments); + input.writePreviewAnnotations(input.snapshot.previewAnnotations); + input.resetCursor({ + cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length), + prompt: input.snapshot.prompt, + detectTrigger: true, + }); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c77c3aba970b..17cd67a077d1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -157,7 +157,6 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, - isBrowserPreviewAttachment, isImageAttachment, type SessionPhase, type Thread, @@ -423,6 +422,7 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, + restorePlanFollowUpComposer, isPaintOnlyThreadTimeline, peekHeldThreadTimeline, peekRememberedThreadTimeline, @@ -3076,13 +3076,12 @@ export default function ChatView(props: ChatViewProps) { ); const openFileAttachment = useCallback( (attachment: ChatFileAttachment) => { - if (isBrowserPreviewAttachment(attachment) && activeThreadRef) { + if (activeThreadRef) { useRightPanelStore.getState().openAttachment(activeThreadRef, attachment); return; } - void downloadFileAttachment(attachment); }, - [activeThreadRef, downloadFileAttachment], + [activeThreadRef], ); const serverAttachmentResources = useMemo( () => selectHandoffImageResources(serverMessages, attachmentPreviewHandoffByMessageId), @@ -6898,10 +6897,17 @@ export default function ChatView(props: ChatViewProps) { if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { return; } + // The composer is cleared before the send resolves, so hold everything it carried: a + // transient failure must give the prose and its context back, as the ordinary send does. + // Snapshot exactly what was sent, copied, so later mutations cannot alias the backup. + const followUpPromptSnapshot = promptRef.current; + const followUpTerminalContexts = [...sendableComposerTerminalContexts]; + const followUpReviewComments = [...composerReviewComments]; + const followUpPreviewAnnotations = [...composerPreviewAnnotations]; promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); - await onSubmitPlanFollowUp({ + const followUpSent = await onSubmitPlanFollowUp({ text: followUp.text, context: buildMessageContext({ terminalContexts: sendableComposerTerminalContexts, @@ -6910,6 +6916,26 @@ export default function ChatView(props: ChatViewProps) { }), interactionMode: followUp.interactionMode, }); + if (!followUpSent) { + promptRef.current = followUpPromptSnapshot; + composerTerminalContextsRef.current = [...followUpTerminalContexts]; + restorePlanFollowUpComposer({ + snapshot: { + prompt: followUpPromptSnapshot, + terminalContexts: followUpTerminalContexts, + reviewComments: followUpReviewComments, + previewAnnotations: followUpPreviewAnnotations, + }, + writePrompt: (prompt) => setComposerDraftPrompt(composerDraftTarget, prompt), + writeTerminalContexts: (contexts) => + setComposerDraftTerminalContexts(composerDraftTarget, [...contexts]), + writeReviewComments: (comments) => + setComposerDraftReviewComments(composerDraftTarget, [...comments]), + writePreviewAnnotations: (annotations) => + setComposerDraftPreviewAnnotations(composerDraftTarget, [...annotations]), + resetCursor: (options) => composerRef.current?.resetCursorState(options), + }); + } return; } // Providers without the legacy toggle receive their native commands unchanged. @@ -7002,11 +7028,6 @@ export default function ChatView(props: ChatViewProps) { const outgoingMessageContext = buildOutgoingMessageContext( composerAttachmentsSnapshot.map((attachment) => attachment.id), ); - // Servers from before inline context drop the records and forward the links as literal - // text, so their turns carry the payload the legacy way instead. - const supportsInlineMessageContext = - appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities - .inlineMessageContext === true; const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -7332,6 +7353,13 @@ export default function ChatView(props: ChatViewProps) { ), ); if (context === undefined) return {}; + // Read the capability at dispatch time: the upload and persistence + // awaits above can span a server reconnect that changes it. Servers + // from before inline context drop the records and forward the links + // as literal text, so their turns carry the payload the legacy way. + const supportsInlineMessageContext = + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment + .capabilities.inlineMessageContext === true; if (!supportsInlineMessageContext) { return { text: serializeLegacyContextMessage({ @@ -7736,7 +7764,9 @@ export default function ChatView(props: ChatViewProps) { text: string; context?: ReturnType; interactionMode: "default" | "plan"; - }) => { + // Whether the message actually went out. A `false` return tells the caller to put the + // composer back, because it cleared it before awaiting this. + }): Promise => { if ( !activeThread || !isServerThread || @@ -7744,17 +7774,17 @@ export default function ChatView(props: ChatViewProps) { isConnecting || sendInFlightRef.current ) { - return; + return false; } const trimmed = text.trim(); if (!trimmed) { - return; + return false; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) { - return; + return false; } const { selectedProvider: ctxSelectedProvider, @@ -7856,7 +7886,7 @@ export default function ChatView(props: ChatViewProps) { clearUsageLimitsFor(routeThreadKey); acknowledgeActiveThreadWoke(); sendInFlightRef.current = false; - return; + return true; } setOptimisticUserMessages((existing) => @@ -7871,6 +7901,7 @@ export default function ChatView(props: ChatViewProps) { } sendInFlightRef.current = false; resetLocalDispatch(); + return false; }, [ activeThread, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index d8a69611f573..fbd839629182 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1,3 +1,5 @@ +import { ContextChipPopover } from "./contextChipParts"; +import { Button } from "./ui/button"; import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; @@ -55,6 +57,8 @@ import { type Spread, } from "lexical"; import { + createContext, + use, useCallback, useEffect, useEffectEvent, @@ -92,6 +96,7 @@ import { ComposerContextReferenceNode, } from "./ComposerContextReferenceNode"; import { + ComposerContextActionsContext, ComposerContextRecordsContext, type ComposerDraftContextRecords, } from "./composerContextPresentation"; @@ -147,16 +152,20 @@ type SerializedComposerSkillNode = Spread< >; function ComposerMentionDecorator(props: { path: string }) { + const actions = use(ComposerContextActionsContext); const theme = resolvedThemeFromDocument(); const chip = ( - actions.openMention(props.path)} + aria-label={`Preview ${props.path}`} + className={`${FILE_TAG_CHIP_CLASS_NAME} cursor-pointer focus-visible:outline-2`} contentEditable={false} spellCheck={false} data-composer-mention-chip="true" > - + ); return ( @@ -260,34 +269,44 @@ function skillMetadataByName( ); } -function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: string | null }) { - const chip = ( - - - ); - - if (!props.skillDescription) { - return chip; - } +const ComposerSkillsContext = createContext>([]); +function ComposerSkillDecorator(props: { + skillName: string; + skillLabel: string; + skillDescription: string | null; +}) { + const actions = use(ComposerContextActionsContext); + const skill = use(ComposerSkillsContext).find((candidate) => candidate.name === props.skillName); return ( - - - - {props.skillDescription} - - + + ); } @@ -362,6 +381,7 @@ class ComposerSkillNode extends DecoratorNode { override decorate(): React.ReactElement { return ( @@ -2082,29 +2102,31 @@ export function ComposerPromptEditor({ ); return ( - - - + + + + + ); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2ec6495d4ade..7a9c78798ff3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,3 +1,6 @@ +import { useRightPanelStore } from "~/rightPanelStore"; +import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; +import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; import { importPastedComposerText } from "../composerInlineTokenPaste"; import { elementContextToPreviewAnnotation } from "../../lib/elementContext"; @@ -1564,14 +1567,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const prompt = composerDraft.prompt; const composerImages = attachmentDraft.images; const composerFiles = attachmentDraft.files; + // A question answer has no chips: its files live in the question draft and show in the + // strip. Only the thread prompt's references decide which files leave the strip. const inlineFileIdSet = useMemo(() => { + if (questionAttachmentTarget) return new Set(); const contextIds = new Set(collectInlineContextIds(prompt)); return new Set( composerFiles .filter((file) => contextIds.has(toKindScopedComposerContextId("file", file.id))) .map((file) => file.id), ); - }, [composerFiles, prompt]); + }, [composerFiles, prompt, questionAttachmentTarget]); const composerVideos = composerFiles.filter((file) => isPreviewableComposerVideo(file, environmentId), ); @@ -1605,12 +1611,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const nonPersistedComposerImageIds = attachmentDraft.nonPersistedImageIds; const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); const openPrLink = useOpenPrLink(routeThreadRef); + const [previewFileId, setPreviewFileId] = useState(null); + const previewFile = composerFiles.find((file) => file.id === previewFileId); const composerContextActions = useMemo( () => ({ expandImage: (imageId: string) => { const preview = buildExpandedImagePreview(composerImages, imageId); if (preview) onExpandImage(preview); }, + openFile: setPreviewFileId, + openMention: (path: string) => useRightPanelStore.getState().openFile(routeThreadRef, path), expandVideo: (fileId: string) => { const file = composerFiles.find((candidate) => candidate.id === fileId); if (!file || !isVideoAttachment(file)) return; @@ -1635,7 +1645,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) openPrLink(event, url); }, }), - [composerFiles, composerImages, environmentId, onExpandImage, openPrLink], + [composerFiles, composerImages, environmentId, onExpandImage, openPrLink, routeThreadRef], ); const composerContextRecords = useMemo( () => @@ -2601,8 +2611,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerFilesToDraft = useCallback( - (files: ComposerFileAttachment[]) => addComposerDraftFiles(attachmentDraftTarget, files), - [addComposerDraftFiles, attachmentDraftTarget], + (files: ComposerFileAttachment[]) => + addComposerDraftFiles(attachmentDraftTarget, files, { + appendReference: questionAttachmentTarget === null, + }), + [addComposerDraftFiles, attachmentDraftTarget, questionAttachmentTarget], ); const removeComposerImageFromDraft = useCallback( @@ -5169,8 +5182,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const storedIds = new Set(addComposerFilesToDraft(acceptedFiles)); const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id)); if (storedFiles.length > 0) { - insertAttachmentReferences(storedFiles.map(fileContextReference)); - insertedAny = true; + insertedAny = insertAttachmentReferences(storedFiles.map(fileContextReference)); } } if (acceptedImages.length === 0) return insertedAny; @@ -5226,8 +5238,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const storedImages = nextImages.filter((image) => storedImageIds.has(image.id)); if (storedImages.length > 0) { - insertAttachmentReferences(storedImages.map(imageContextReference)); - insertedAny = true; + insertedAny = + insertAttachmentReferences(storedImages.map(imageContextReference)) || insertedAny; } // Only failures are reported here. Success must not pass `null`: by // now other work (a failed send, an overlapping paste) may have set a @@ -5254,13 +5266,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) * Chips for freshly attached files land at the caret; when the editor cannot take * input (approval, pending questions) they are appended so the file is never invisible. */ - const insertAttachmentReferences = (references: ReadonlyArray) => { - if (references.length === 0) return; + const insertAttachmentReferences = ( + references: ReadonlyArray, + ): boolean => { + if (references.length === 0) return false; + // Question answers carry attachments beside the answer, never as chips. Falling back to + // the thread prompt here would hide the file behind a reference the question never shows. + if (questionAttachmentTarget) return false; const text = references.map(formatInlineContextReference).join(" "); const inserted = insertComposerText(`${text} `, "cursor", { ensureLeadingBoundary: true }); if (!inserted) { setPrompt(ensureInlineContextReferences(promptRef.current, references)); } + return true; }; const removeComposerImage = (imageId: string) => { @@ -6312,7 +6330,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) kind="file" theme={resolvedTheme} /> - {file.name} + {needsReattach ? canReattachFile @@ -6377,6 +6402,43 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : "pr-12"), )} > + {previewFile ? ( + { + if (!open) setPreviewFileId(null); + }} + > + + {previewFile.name} + { + removeComposerFileFromDraft(previewFile.id); + setPreviewFileId(null); + }} + onClose={() => setPreviewFileId(null)} + /> + + + ) : null} (null); - const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; + // The offset accumulates without bound, so wrap it into range in both directions: + // JavaScript `%` keeps the sign of the dividend, and a negative index blanks the dialog. + const imageCount = preview.images.length; + const index = + imageCount > 0 ? (((preview.index + imageOffset) % imageCount) + imageCount) % imageCount : 0; const item = preview.images[index]; const source: MediaActionSource = item?.actionsSource ?? { kind: item?.type === "video" ? "video" : "image", @@ -181,8 +185,8 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ ); })} {unknownAttachments.map((attachment) => ( @@ -2520,6 +2501,44 @@ function AssistantChangedFilesSectionInner({ // Leaf components // --------------------------------------------------------------------------- +function UserMessageMentionChip(props: { + record: Extract; + copyMarkdown: string; +}) { + const ctx = use(TimelineRowCtx); + return ( + + { + if (ctx.threadRef) + useRightPanelStore.getState().openFile(ctx.threadRef, props.record.path); + }} + > + + {props.record.label} + + } + /> + {props.record.path} + + ); +} + function UserMessageContextChip(props: { icon: ReactNode; label: string; @@ -2571,7 +2590,7 @@ function UserMessagePreviewAnnotationDetails(props: { image: ChatImageAttachment | null; }) { const ctx = use(TimelineRowCtx); - const visibleElements = props.record.elements?.slice(0, 3) ?? []; + const visibleElements = props.record.elements ?? []; return (
{props.image?.previewUrl ? ( @@ -2683,14 +2702,14 @@ function UserMessageElementDetails({ ) : null}
{record.htmlPreview?.trim() ? ( -
-            {record.htmlPreview.trim()}
-          
+
+ +
) : null} {record.styles?.trim() ? ( -
-            {record.styles.trim()}
-          
+
+ +
) : null} @@ -2733,21 +2752,7 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry canRender: (record) => record.kind === "mention", render: (record, context) => record.kind === "mention" ? ( - - } - label={record.label} - kindLabel="File mention" - tooltip={record.path} - copyMarkdown={context.copyMarkdown} - toneClassName={CONTEXT_INLINE_CHIP_TONE_CLASS_NAMES.mention} - /> + ) : ( ), @@ -2805,15 +2810,15 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry }, { kind: "file", + // A file chip names its attachment by id, so it renders whatever came back under that id. + // `isFileAttachment` excludes pictures, which a legacy `file` attachment may still be. canRender: (record, context) => - record.kind === "file" && - context.attachment !== null && - isFileAttachment(context.attachment), + record.kind === "file" && context.attachment !== null && context.attachment.type === "file", render: (record, context) => { if ( record.kind !== "file" || context.attachment === null || - !isFileAttachment(context.attachment) + context.attachment.type !== "file" ) { return ; } diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx index 18dbbb1d4bc7..f583a0ee923f 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -1,3 +1,5 @@ +import ChatMarkdown from "./ChatMarkdown"; +import { ReadOnlySourcePreview } from "./files/AttachmentFilePreview"; import type { PreviewAnnotationPayload } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { videoMimeType } from "@t3tools/shared/video"; @@ -65,12 +67,16 @@ export type ComposerDraftContextRecord = export interface ComposerContextActions { expandImage: (imageId: string) => void; expandVideo: (fileId: string) => void; + openFile: (fileId: string) => void; + openMention: (path: string) => void; openPullRequest: (event: MouseEvent, url: string) => void; } export const ComposerContextActionsContext = createContext({ expandImage: () => {}, expandVideo: () => {}, + openFile: () => {}, + openMention: () => {}, openPullRequest: () => {}, }); @@ -218,7 +224,12 @@ function FileContextChip(props: { unresolved={needsReattach} suffix={suffix} accessibleLabel={`${isVideo && !needsReattach ? "Preview video" : "File"} attachment, ${props.record.name}, ${size}`} - onOpen={isVideo && !needsReattach ? () => actions.expandVideo(props.record.id) : undefined} + onOpen={ + !needsReattach + ? () => + isVideo ? actions.expandVideo(props.record.id) : actions.openFile(props.record.id) + : undefined + } tooltip={ needsReattach ? `${props.record.name} was not saved with this draft. Attach it again to send it.` @@ -268,15 +279,11 @@ function ComposerReviewCommentDetails({ comment }: { comment: ReviewCommentConte {comment.sectionTitle} · {comment.rangeLabel} - {comment.text.trim() ? ( -
- {comment.text.trim()} -
- ) : null} + {comment.text.trim() ? : null} {comment.diff.trim() ? ( -
-          {comment.diff.trim()}
-        
+
+ +
) : null} ); diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index 7b27c938ba37..55d66063b13e 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -113,35 +113,29 @@ export function PullRequestChip(props: { onOpen: (event: MouseEvent, url: string) => void; }) { return ( - - props.onOpen(event, props.metadata.url)} - > - - {props.label} - - } - /> - + + + {props.label} + + } + > +
- - +

Captured pull request context

+ +
+
); } diff --git a/apps/web/src/components/files/AttachmentFilePreview.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.test.tsx new file mode 100644 index 000000000000..6bd25ce313e3 --- /dev/null +++ b/apps/web/src/components/files/AttachmentFilePreview.test.tsx @@ -0,0 +1,133 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { AttachmentFilePreview } from "./AttachmentFilePreview"; + +const { refresh } = vi.hoisted(() => ({ refresh: vi.fn<() => Promise>() })); + +vi.mock("~/assets/assetUrls", () => ({ useAssetUrlRefresh: () => refresh })); +vi.mock("~/hooks/useCopyToClipboard", () => ({ + useCopyToClipboard: () => ({ copyToClipboard: vi.fn(), isCopied: false }), +})); +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); +vi.mock("~/components/ChatMarkdown", () => ({ default: () => null })); +vi.mock("~/components/ui/scroll-area", () => ({ + ScrollArea: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("./ReadOnlySourcePreview", () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})); +vi.mock("./fileSurfaceChrome", () => ({ + FILE_SURFACE_SUBHEADER_CLASS: "", + FileSurfaceAction: ({ label, onPress }: { label: string; onPress: () => void }) => ( +