diff --git a/apps/mobile/clerk-theme.json b/apps/mobile/clerk-theme.json index 52941785f3e..119927a04d6 100644 --- a/apps/mobile/clerk-theme.json +++ b/apps/mobile/clerk-theme.json @@ -13,7 +13,7 @@ "neutral": "#F5F5F5", "border": "#E5E5EA", "ring": "#A3A3A3", - "muted": "#F5F5F5", + "muted": "#F2F2F7", "shadow": "#000000" }, "darkColors": { @@ -30,7 +30,7 @@ "neutral": "#1C1C1C", "border": "#2A2A2A", "ring": "#525252", - "muted": "#1C1C1C", + "muted": "#0E0E0E", "shadow": "#000000" }, "design": { diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 4642879451a..0fbf4fb3c9d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -18,7 +18,7 @@ --color-foreground: #262626; --color-foreground-secondary: #525252; --color-foreground-muted: #737373; - --color-foreground-tertiary: #a3a3a3; + --color-foreground-tertiary: #8e8e93; /* Borders & separators */ --color-border: rgba(0, 0, 0, 0.08); @@ -28,6 +28,9 @@ /* Subtle backgrounds (badges, pills, overlays) */ --color-subtle: rgba(0, 0, 0, 0.04); --color-subtle-strong: rgba(0, 0, 0, 0.08); + --color-inline-skill-background: rgba(217, 70, 239, 0.12); + --color-inline-skill-border: rgba(217, 70, 239, 0.25); + --color-inline-skill-foreground: #a21caf; /* Primary action */ --color-primary: #262626; @@ -58,6 +61,8 @@ /* Header / glass chrome */ --color-header: rgba(255, 255, 255, 0.97); --color-header-border: rgba(0, 0, 0, 0.06); + --color-glass-surface: rgba(255, 255, 255, 0.72); + --color-glass-tint: rgba(255, 255, 255, 0.18); /* StatusBar */ --color-status-bar: #f2f2f7; @@ -105,8 +110,8 @@ /* Text */ --color-foreground: #f5f5f5; --color-foreground-secondary: #a3a3a3; - --color-foreground-muted: #737373; - --color-foreground-tertiary: #525252; + --color-foreground-muted: #8e8e93; + --color-foreground-tertiary: #636366; /* Borders & separators */ --color-border: rgba(255, 255, 255, 0.06); @@ -116,6 +121,9 @@ /* Subtle backgrounds (badges, pills, overlays) */ --color-subtle: rgba(255, 255, 255, 0.04); --color-subtle-strong: rgba(255, 255, 255, 0.08); + --color-inline-skill-background: rgba(217, 70, 239, 0.12); + --color-inline-skill-border: rgba(217, 70, 239, 0.25); + --color-inline-skill-foreground: #f0abfc; /* Primary action */ --color-primary: #f5f5f5; @@ -136,16 +144,18 @@ /* Inputs */ --color-input: #141414; --color-input-border: rgba(255, 255, 255, 0.08); - --color-placeholder: #737373; + --color-placeholder: #8e8e93; /* Icons */ --color-icon: #f5f5f5; --color-icon-muted: #a3a3a3; - --color-icon-subtle: #737373; + --color-icon-subtle: #8e8e93; /* Header / glass chrome */ --color-header: rgba(10, 10, 10, 0.97); --color-header-border: rgba(255, 255, 255, 0.06); + --color-glass-surface: rgba(23, 23, 23, 0.78); + --color-glass-tint: rgba(23, 23, 23, 0.24); /* StatusBar */ --color-status-bar: #0a0a0a; diff --git a/apps/mobile/modules/t3-composer-editor/LICENSE b/apps/mobile/modules/t3-composer-editor/LICENSE new file mode 100644 index 00000000000..30b20e3b5f0 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-composer-editor/expo-module.config.json b/apps/mobile/modules/t3-composer-editor/expo-module.config.json new file mode 100644 index 00000000000..0d6384cd91a --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["T3ComposerEditorModule"] + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec new file mode 100644 index 00000000000..57c09fa9535 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'T3ComposerEditor' + s.version = '1.0.0' + s.summary = 'Native attributed composer editor for T3 Code mobile.' + s.description = 'UIKit-backed rich text composer with atomic skill and file tokens.' + s.author = 'T3 Tools' + s.homepage = 'https://t3tools.com' + s.platforms = { + :ios => '16.4', + } + s.source = { :path => '.' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift new file mode 100644 index 00000000000..5d3b33094cb --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -0,0 +1,71 @@ +import ExpoModulesCore + +public class T3ComposerEditorModule: Module { + public func definition() -> ModuleDefinition { + Name("T3ComposerEditor") + + View(T3ComposerEditorView.self) { + Prop("value") { (view: T3ComposerEditorView, value: String) in + view.setValue(value) + } + Prop("tokensJson") { (view: T3ComposerEditorView, tokensJson: String) in + view.setTokensJson(tokensJson) + } + Prop("selectionJson") { (view: T3ComposerEditorView, selectionJson: String) in + view.setSelectionJson(selectionJson) + } + Prop("themeJson") { (view: T3ComposerEditorView, themeJson: String) in + view.setThemeJson(themeJson) + } + Prop("placeholder") { (view: T3ComposerEditorView, placeholder: String) in + view.setPlaceholder(placeholder) + } + Prop("fontFamily") { (view: T3ComposerEditorView, fontFamily: String) in + view.setFontFamily(fontFamily) + } + Prop("fontSize") { (view: T3ComposerEditorView, fontSize: Double) in + view.setFontSize(CGFloat(fontSize)) + } + Prop("lineHeight") { (view: T3ComposerEditorView, lineHeight: Double) in + view.setLineHeight(CGFloat(lineHeight)) + } + Prop("contentInsetVertical") { (view: T3ComposerEditorView, contentInsetVertical: Double) in + view.setContentInsetVertical(CGFloat(contentInsetVertical)) + } + Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in + view.setEditable(editable) + } + Prop("scrollEnabled") { (view: T3ComposerEditorView, scrollEnabled: Bool) in + view.setScrollEnabled(scrollEnabled) + } + Prop("autoFocus") { (view: T3ComposerEditorView, autoFocus: Bool) in + view.setAutoFocus(autoFocus) + } + Prop("autoCorrect") { (view: T3ComposerEditorView, autoCorrect: Bool) in + view.setAutoCorrect(autoCorrect) + } + Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in + view.setSpellCheck(spellCheck) + } + + Events( + "onComposerChange", + "onComposerSelectionChange", + "onComposerFocus", + "onComposerBlur", + "onComposerPasteImages", + "onComposerContentSizeChange" + ) + + AsyncFunction("focus") { (view: T3ComposerEditorView) in + view.focusEditor() + } + AsyncFunction("blur") { (view: T3ComposerEditorView) in + view.blurEditor() + } + AsyncFunction("setSelection") { (view: T3ComposerEditorView, start: Int, end: Int) in + view.setSelection(start: start, end: end) + } + } + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift new file mode 100644 index 00000000000..a88acbc31f7 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -0,0 +1,819 @@ +import ExpoModulesCore +import UIKit + +private struct ComposerTokenPayload: Decodable { + let type: String + let source: String + let label: String + let iconUri: String? + let start: Int + let end: Int +} + +private struct ComposerSelectionPayload: Decodable { + let start: Int + let end: Int +} + +private struct ComposerThemePayload: Decodable { + let text: String + let placeholder: String + let chipBackground: String + let chipBorder: String + let chipText: String + let skillBackground: String + let skillBorder: String + let skillText: String + let fileTint: String +} + +private struct ComposerChipStyle { + let tint: UIColor + let backgroundColor: UIColor + let borderColor: UIColor + let textColor: UIColor +} + +private final class ComposerTextAttachment: NSTextAttachment { + let source: String + + init(source: String, image: UIImage, size: CGSize, baselineOffset: CGFloat) { + self.source = source + super.init(data: nil, ofType: nil) + self.image = image + bounds = CGRect(x: 0, y: baselineOffset, width: size.width, height: size.height) + } + + required init?(coder: NSCoder) { + nil + } +} + +private final class ComposerTextView: UITextView { + private static let pastedImageDirectoryName = "t3-composer-paste" + private static let stalePastedImageAge: TimeInterval = 60 * 60 + + var onPasteImages: (([String]) -> Void)? + var onAttributedMutation: (() -> Void)? + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(paste(_:)) { + let pasteboard = UIPasteboard.general + if pasteboard.hasImages || + pasteboard.itemProviders.contains(where: { + $0.canLoadObject(ofClass: UIImage.self) + }) { + return true + } + } + return super.canPerformAction(action, withSender: sender) + } + + override func paste(_ sender: Any?) { + let pasteboard = UIPasteboard.general + let imageProviders = pasteboard.itemProviders.filter { + $0.canLoadObject(ofClass: UIImage.self) + } + if !imageProviders.isEmpty { + loadImages(from: imageProviders) + return + } + + let images = pasteboard.images ?? [] + if !images.isEmpty { + let urls = images.compactMap(Self.writeTemporaryImage) + if !urls.isEmpty { + onPasteImages?(urls) + return + } + } + super.paste(sender) + } + + override func deleteBackward() { + guard selectedRange.length == 0, selectedRange.location > 0 else { + super.deleteBackward() + return + } + + let previousOffset = selectedRange.location - 1 + if textStorage.attribute(.attachment, at: previousOffset, effectiveRange: nil) + is ComposerTextAttachment { + replaceDisplayRange(NSRange(location: previousOffset, length: 1)) + return + } + + super.deleteBackward() + } + + private func replaceDisplayRange(_ range: NSRange) { + guard let start = position(from: beginningOfDocument, offset: range.location), + let end = position(from: start, offset: range.length), + let textRange = textRange(from: start, to: end) else { + return + } + replace(textRange, withText: "") + } + + private func loadImages(from providers: [NSItemProvider]) { + let group = DispatchGroup() + let lock = NSLock() + var images = [UIImage?](repeating: nil, count: providers.count) + + for (index, provider) in providers.enumerated() { + group.enter() + provider.loadObject(ofClass: UIImage.self) { object, _ in + defer { group.leave() } + guard let image = object as? UIImage else { + return + } + lock.lock() + images[index] = image + lock.unlock() + } + } + + group.notify(queue: .main) { [weak self] in + let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage) + if !urls.isEmpty { + self?.onPasteImages?(urls) + } + } + } + + override func copy(_ sender: Any?) { + guard selectedRange.length > 0 else { + return super.copy(sender) + } + UIPasteboard.general.string = serializedText(in: selectedRange) + } + + override func cut(_ sender: Any?) { + guard isEditable, selectedRange.length > 0 else { + return super.cut(sender) + } + copy(sender) + textStorage.replaceCharacters(in: selectedRange, with: "") + selectedRange = NSRange(location: selectedRange.location, length: 0) + onAttributedMutation?() + } + + func serializedText() -> String { + serializedText(in: NSRange(location: 0, length: attributedText.length)) + } + + func serializedText(in range: NSRange) -> String { + guard range.length > 0 else { + return "" + } + + let source = NSMutableString() + let nsString = attributedText.string as NSString + var cursor = range.location + let end = NSMaxRange(range) + attributedText.enumerateAttribute(.attachment, in: range) { value, attachmentRange, _ in + if attachmentRange.location > cursor { + source.append( + nsString.substring( + with: NSRange(location: cursor, length: attachmentRange.location - cursor) + ) + ) + } + if let attachment = value as? ComposerTextAttachment { + source.append(attachment.source) + } else { + source.append(nsString.substring(with: attachmentRange)) + } + cursor = NSMaxRange(attachmentRange) + } + if cursor < end { + source.append(nsString.substring(with: NSRange(location: cursor, length: end - cursor))) + } + return source as String + } + + func sourceOffset(forDisplayOffset displayOffset: Int) -> Int { + let boundedOffset = max(0, min(attributedText.length, displayOffset)) + if boundedOffset == 0 { + return 0 + } + + var sourceOffset = 0 + let range = NSRange(location: 0, length: boundedOffset) + attributedText.enumerateAttribute(.attachment, in: range) { value, attributeRange, _ in + if let attachment = value as? ComposerTextAttachment { + sourceOffset += (attachment.source as NSString).length + } else { + sourceOffset += attributeRange.length + } + } + return sourceOffset + } + + private static func writeTemporaryImage(_ image: UIImage) -> String? { + guard let data = image.pngData() else { + return nil + } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(pastedImageDirectoryName, isDirectory: true) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + removeStaleTemporaryImages(in: directory) + let url = directory.appendingPathComponent("\(UUID().uuidString).png") + try data.write(to: url, options: .atomic) + return url.absoluteString + } catch { + return nil + } + } + + private static func removeStaleTemporaryImages(in directory: URL) { + let cutoff = Date().addingTimeInterval(-stalePastedImageAge) + guard let urls = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + for url in urls { + guard + let values = try? url.resourceValues( + forKeys: [.contentModificationDateKey, .isRegularFileKey] + ), + values.isRegularFile == true, + let modifiedAt = values.contentModificationDate, + modifiedAt < cutoff + else { + continue + } + try? FileManager.default.removeItem(at: url) + } + } +} + +public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { + private let textView = ComposerTextView() + private let placeholderLabel = UILabel() + private var value = "" + private var tokensJson = "[]" + private var tokens: [ComposerTokenPayload] = [] + private var requestedSelection: ComposerSelectionPayload? + private var theme = ComposerThemePayload( + text: "#262626", + placeholder: "#8e8e93", + chipBackground: "#f2f2f7", + chipBorder: "#dedee3", + chipText: "#262626", + skillBackground: "#f9e8fb", + skillBorder: "#e5a6eb", + skillText: "#a21caf", + fileTint: "#737373" + ) + private var fontFamily = "DMSans_400Regular" + private var fontSize: CGFloat = 15 + private var lineHeight: CGFloat = 22 + private var contentInsetVertical: CGFloat = 0 + private var shouldAutoFocus = false + private var didAutoFocus = false + private var isApplyingControlledValue = false + private var lastContentSize = CGSize.zero + private var iconImages: [String: UIImage] = [:] + private var pendingIconUris = Set() + private var tokensNeedRebuild = false + + let onComposerChange = EventDispatcher() + let onComposerSelectionChange = EventDispatcher() + let onComposerFocus = EventDispatcher() + let onComposerBlur = EventDispatcher() + let onComposerPasteImages = EventDispatcher() + let onComposerContentSizeChange = EventDispatcher() + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + clipsToBounds = false + textView.delegate = self + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.keyboardDismissMode = .interactive + textView.alwaysBounceVertical = false + textView.showsVerticalScrollIndicator = true + textView.adjustsFontForContentSizeCategory = true + textView.onPasteImages = { [weak self] urls in + self?.onComposerPasteImages(["uris": urls]) + } + textView.onAttributedMutation = { [weak self] in + self?.emitTextChange() + } + addSubview(textView) + + placeholderLabel.numberOfLines = 0 + placeholderLabel.adjustsFontForContentSizeCategory = true + addSubview(placeholderLabel) + applyTypography() + applyTheme() + } + + public override func layoutSubviews() { + super.layoutSubviews() + textView.frame = bounds + let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding + let placeholderY = textView.textContainerInset.top + let placeholderWidth = max( + 0, + bounds.width - placeholderX - textView.textContainerInset.right - + textView.textContainer.lineFragmentPadding + ) + placeholderLabel.frame = CGRect( + x: placeholderX, + y: placeholderY, + width: placeholderWidth, + height: max(lineHeight, placeholderLabel.font.lineHeight) + ) + emitContentSizeIfNeeded() + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil, shouldAutoFocus, !didAutoFocus else { + return + } + didAutoFocus = true + DispatchQueue.main.async { [weak self] in + self?.textView.becomeFirstResponder() + } + } + + func setValue(_ value: String) { + self.value = value + applyControlledDocument(force: tokensNeedRebuild) + if tokensMatchCurrentValue() { + tokensNeedRebuild = false + } + } + + func setTokensJson(_ tokensJson: String) { + guard self.tokensJson != tokensJson else { + return + } + self.tokensJson = tokensJson + tokens = decode([ComposerTokenPayload].self, from: tokensJson) ?? [] + tokensNeedRebuild = true + applyControlledDocument(force: true) + if tokensMatchCurrentValue() { + tokensNeedRebuild = false + } + } + + func setSelectionJson(_ selectionJson: String) { + requestedSelection = decode(ComposerSelectionPayload.self, from: selectionJson) + applyRequestedSelection() + } + + func setThemeJson(_ themeJson: String) { + guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else { + return + } + theme = nextTheme + applyTheme() + applyControlledDocument(force: true) + } + + func setPlaceholder(_ placeholder: String) { + placeholderLabel.text = placeholder + setNeedsLayout() + } + + func setFontFamily(_ fontFamily: String) { + self.fontFamily = fontFamily + applyTypography() + applyControlledDocument(force: true) + } + + func setFontSize(_ fontSize: CGFloat) { + self.fontSize = fontSize + applyTypography() + applyControlledDocument(force: true) + } + + func setLineHeight(_ lineHeight: CGFloat) { + self.lineHeight = lineHeight + applyTypography() + applyControlledDocument(force: true) + } + + func setContentInsetVertical(_ contentInsetVertical: CGFloat) { + self.contentInsetVertical = contentInsetVertical + textView.textContainerInset = UIEdgeInsets( + top: contentInsetVertical, + left: 0, + bottom: contentInsetVertical, + right: 0 + ) + setNeedsLayout() + } + + func setEditable(_ editable: Bool) { + textView.isEditable = editable + } + + func setScrollEnabled(_ scrollEnabled: Bool) { + textView.isScrollEnabled = scrollEnabled + } + + func setAutoFocus(_ autoFocus: Bool) { + shouldAutoFocus = autoFocus + } + + func setAutoCorrect(_ autoCorrect: Bool) { + textView.autocorrectionType = autoCorrect ? .yes : .no + } + + func setSpellCheck(_ spellCheck: Bool) { + textView.spellCheckingType = spellCheck ? .yes : .no + } + + func focusEditor() { + textView.becomeFirstResponder() + } + + func blurEditor() { + textView.resignFirstResponder() + } + + func setSelection(start: Int, end: Int) { + requestedSelection = ComposerSelectionPayload(start: start, end: end) + applyRequestedSelection() + } + + public func textViewDidChange(_ textView: UITextView) { + emitTextChange() + } + + public func textViewDidChangeSelection(_ textView: UITextView) { + guard !isApplyingControlledValue else { + return + } + emitSelection() + } + + public func textViewDidBeginEditing(_ textView: UITextView) { + onComposerFocus() + } + + public func textViewDidEndEditing(_ textView: UITextView) { + onComposerBlur() + } + + private func applyControlledDocument(force: Bool = false) { + let currentSource = textView.serializedText() + guard force || currentSource != value || !documentMatchesExpectedTokens() else { + updatePlaceholderVisibility() + return + } + + let previousSelection = sourceSelection() + isApplyingControlledValue = true + textView.attributedText = makeAttributedDocument() + let targetSelection = requestedSelection ?? previousSelection + requestedSelection = nil + textView.selectedRange = displayRange(for: targetSelection) + isApplyingControlledValue = false + updatePlaceholderVisibility() + emitContentSizeIfNeeded() + } + + private func makeAttributedDocument() -> NSAttributedString { + let result = NSMutableAttributedString() + let source = value as NSString + var cursor = 0 + let validTokens = tokens.filter { + $0.start >= cursor && + $0.end > $0.start && + $0.end <= source.length && + source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source + } + + for token in validTokens { + if token.start < cursor { + continue + } + if token.start > cursor { + appendPlainText( + source.substring(with: NSRange(location: cursor, length: token.start - cursor)), + to: result + ) + } + result.append(makeAttachmentString(token)) + cursor = token.end + } + if cursor < source.length { + appendPlainText( + source.substring(with: NSRange(location: cursor, length: source.length - cursor)), + to: result + ) + } + return result + } + + private func appendPlainText(_ text: String, to result: NSMutableAttributedString) { + result.append(NSAttributedString(string: text, attributes: baseAttributes())) + } + + private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString { + let isSkill = token.type == "skill" + let tint = UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel + let iconName = isSkill ? "cube" : "doc" + let iconImage = token.iconUri.flatMap(iconImage(for:)) + let style = ComposerChipStyle( + tint: tint, + backgroundColor: UIColor( + composerHex: isSkill ? theme.skillBackground : theme.chipBackground + ) ?? .secondarySystemFill, + borderColor: UIColor( + composerHex: isSkill ? theme.skillBorder : theme.chipBorder + ) ?? .separator, + textColor: UIColor(composerHex: isSkill ? theme.skillText : theme.chipText) ?? .label + ) + let image = renderChip( + label: token.label, + iconName: iconName, + iconImage: iconImage, + style: style + ) + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + let baselineOffset = floor((font.capHeight - image.size.height) / 2) + let attachment = ComposerTextAttachment( + source: token.source, + image: image, + size: image.size, + baselineOffset: baselineOffset + ) + return NSAttributedString(attachment: attachment) + } + + private func renderChip( + label: String, + iconName: String, + iconImage: UIImage?, + style: ComposerChipStyle + ) -> UIImage { + let font = UIFont(name: "DMSans_500Medium", size: max(12, fontSize - 2)) + ?? UIFont.systemFont(ofSize: max(12, fontSize - 2), weight: .medium) + let fallbackIcon = UIImage( + systemName: iconName, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) + ) + let icon = iconImage ?? fallbackIcon + let textSize = (label as NSString).size(withAttributes: [.font: font]) + let iconWidth = icon == nil ? 0 : 14 + let iconGap = icon == nil ? 0 : 5 + let height: CGFloat = 24 + let width = ceil(9 + CGFloat(iconWidth + iconGap) + textSize.width + 9) + let format = UIGraphicsImageRendererFormat.preferred() + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) + return renderer.image { context in + let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height)) + let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 7) + style.backgroundColor.setFill() + path.fill() + style.borderColor.setStroke() + path.lineWidth = 1 + path.stroke() + + var x: CGFloat = 9 + if let icon { + let renderedIcon = iconImage == nil + ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal) + : icon + renderedIcon.draw( + in: CGRect(x: x, y: 5, width: 14, height: 14) + ) + x += 19 + } + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .left + (label as NSString).draw( + in: CGRect(x: x, y: 3, width: textSize.width + 1, height: 18), + withAttributes: [ + .font: font, + .foregroundColor: style.textColor, + .paragraphStyle: paragraph, + ] + ) + context.cgContext.setAllowsAntialiasing(true) + } + } + + private func iconImage(for uri: String) -> UIImage? { + if let image = iconImages[uri] { + return image + } + guard !pendingIconUris.contains(uri), let url = URL(string: uri) else { + return nil + } + + if url.isFileURL, let image = UIImage(contentsOfFile: url.path) { + iconImages[uri] = image + return image + } + + pendingIconUris.insert(uri) + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let self, let data, let image = UIImage(data: data) else { + DispatchQueue.main.async { + self?.pendingIconUris.remove(uri) + } + return + } + DispatchQueue.main.async { + self.pendingIconUris.remove(uri) + self.iconImages[uri] = image + self.applyControlledDocument(force: true) + } + }.resume() + return nil + } + + private func baseAttributes() -> [NSAttributedString.Key: Any] { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + return [ + .font: font, + .foregroundColor: UIColor(composerHex: theme.text) ?? .label, + .paragraphStyle: paragraph, + ] + } + + private func applyTypography() { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + textView.font = font + textView.typingAttributes = baseAttributes() + placeholderLabel.font = font + setNeedsLayout() + } + + private func applyTheme() { + textView.textColor = UIColor(composerHex: theme.text) ?? .label + placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText + tintColor = UIColor.systemBlue + } + + private func emitTextChange() { + guard !isApplyingControlledValue else { + return + } + value = textView.serializedText() + let selection = sourceSelection() + onComposerChange([ + "value": value, + "selection": ["start": selection.start, "end": selection.end], + ]) + updatePlaceholderVisibility() + emitContentSizeIfNeeded() + } + + private func emitSelection() { + let selection = sourceSelection() + onComposerSelectionChange([ + "selection": ["start": selection.start, "end": selection.end], + ]) + } + + private func sourceSelection() -> ComposerSelectionPayload { + ComposerSelectionPayload( + start: textView.sourceOffset(forDisplayOffset: textView.selectedRange.location), + end: textView.sourceOffset(forDisplayOffset: NSMaxRange(textView.selectedRange)) + ) + } + + private func displayRange(for selection: ComposerSelectionPayload) -> NSRange { + let start = displayOffset(forSourceOffset: selection.start) + let end = displayOffset(forSourceOffset: selection.end) + return NSRange(location: start, length: max(0, end - start)) + } + + private func displayOffset(forSourceOffset sourceOffset: Int) -> Int { + let boundedOffset = max(0, min((value as NSString).length, sourceOffset)) + var collapsedLength = 0 + for token in tokens where token.end <= boundedOffset { + collapsedLength += max(0, token.end - token.start - 1) + } + if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) { + return token.start - collapsedLength + 1 + } + return boundedOffset - collapsedLength + } + + private func applyRequestedSelection() { + guard let requestedSelection else { + return + } + let nextRange = displayRange(for: requestedSelection) + guard nextRange.location <= textView.attributedText.length, + NSMaxRange(nextRange) <= textView.attributedText.length else { + return + } + isApplyingControlledValue = true + textView.selectedRange = nextRange + isApplyingControlledValue = false + } + + private func updatePlaceholderVisibility() { + placeholderLabel.isHidden = !value.isEmpty + } + + private func emitContentSizeIfNeeded() { + let nextSize = textView.contentSize + guard abs(nextSize.width - lastContentSize.width) > 0.5 || + abs(nextSize.height - lastContentSize.height) > 0.5 else { + return + } + lastContentSize = nextSize + onComposerContentSizeChange(["width": nextSize.width, "height": nextSize.height]) + } + + private func decode(_ type: T.Type, from json: String) -> T? { + guard let data = json.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(type, from: data) + } + + private func tokensMatchCurrentValue() -> Bool { + let source = value as NSString + return tokens.allSatisfy { + $0.start >= 0 && + $0.end > $0.start && + $0.end <= source.length && + source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source + } + } + + private func documentMatchesExpectedTokens() -> Bool { + let source = value as NSString + let expectedSources = tokens.compactMap { token -> String? in + guard token.start >= 0, + token.end > token.start, + token.end <= source.length, + source.substring( + with: NSRange(location: token.start, length: token.end - token.start) + ) == token.source else { + return nil + } + return token.source + } + var renderedSources: [String] = [] + textView.attributedText.enumerateAttribute( + .attachment, + in: NSRange(location: 0, length: textView.attributedText.length) + ) { value, _, _ in + if let attachment = value as? ComposerTextAttachment { + renderedSources.append(attachment.source) + } + } + return renderedSources == expectedSources + } +} + +private extension UIColor { + convenience init?(composerHex hex: String?) { + guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if value.hasPrefix("#") { + value.removeFirst() + } + guard value.count == 6 || value.count == 8, + let raw = UInt64(value, radix: 16) else { + return nil + } + if value.count == 8 { + self.init( + red: CGFloat((raw >> 24) & 0xff) / 255, + green: CGFloat((raw >> 16) & 0xff) / 255, + blue: CGFloat((raw >> 8) & 0xff) / 255, + alpha: CGFloat(raw & 0xff) / 255 + ) + } else { + self.init( + red: CGFloat((raw >> 16) & 0xff) / 255, + green: CGFloat((raw >> 8) & 0xff) / 255, + blue: CGFloat(raw & 0xff) / 255, + alpha: 1 + ) + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/LICENSE b/apps/mobile/modules/t3-markdown-text/LICENSE new file mode 100644 index 00000000000..9aa27cb649d --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2024-25 Bluesky PBC +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec b/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec new file mode 100644 index 00000000000..0ac471faf24 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec @@ -0,0 +1,25 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" + +Pod::Spec.new do |s| + s.name = "T3MarkdownText" + s.version = package["version"] + s.summary = "Native selectable markdown renderer for T3 Code mobile." + s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code." + s.homepage = "https://t3tools.com" + s.license = { :type => "MIT", :file => "LICENSE" } + s.author = { "T3 Tools" => "hello@t3tools.com" } + s.platforms = { :ios => min_ios_version_supported } + s.source = { :path => "." } + s.source_files = "ios/**/*.{h,m,mm,cpp}" + + install_modules_dependencies(s) + + if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled + add_dependency(s, "React-FabricComponents", :additional_framework_paths => [ + "react/renderer/textlayoutmanager/platform/ios", + ]) + end +end diff --git a/apps/mobile/modules/t3-markdown-text/UPSTREAM.md b/apps/mobile/modules/t3-markdown-text/UPSTREAM.md new file mode 100644 index 00000000000..0ddc7775a9e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/UPSTREAM.md @@ -0,0 +1,12 @@ +# Upstream Attribution + +The Fabric attributed-text component in this module originated from +[`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview), +version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`. + +The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under +the MIT License included in this directory. + +T3 Code has substantially modified and renamed the implementation, integrated +its markdown renderer, and owns the resulting module going forward. This is not +an upstream package dependency or a compatibility fork. diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png new file mode 100644 index 00000000000..4696e3fd37d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png new file mode 100644 index 00000000000..0d348cb0a2e Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png new file mode 100644 index 00000000000..7481353a259 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png new file mode 100644 index 00000000000..da0441b96a4 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png new file mode 100644 index 00000000000..ba07d10d8fa Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png new file mode 100644 index 00000000000..32a8b227598 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png new file mode 100644 index 00000000000..4b15e51c463 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png new file mode 100644 index 00000000000..7369c907148 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png new file mode 100644 index 00000000000..adc17802ef1 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png new file mode 100644 index 00000000000..90117bf0e5e Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png new file mode 100644 index 00000000000..adc17802ef1 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png new file mode 100644 index 00000000000..0e15d2f96af Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png new file mode 100644 index 00000000000..19b31f09a1f Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png new file mode 100644 index 00000000000..06bc23496a1 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png new file mode 100644 index 00000000000..66e899f28eb Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png new file mode 100644 index 00000000000..e6a14ca533c Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png new file mode 100644 index 00000000000..4bd7f4a45d5 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png new file mode 100644 index 00000000000..1efb11a3703 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png new file mode 100644 index 00000000000..98fc3adcb9b Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png new file mode 100644 index 00000000000..3f2c97ea1ac Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png new file mode 100644 index 00000000000..2e63e9c485d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png new file mode 100644 index 00000000000..9f6be84d09d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png new file mode 100644 index 00000000000..827d1feca36 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png new file mode 100644 index 00000000000..b2c4b3dcd89 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png new file mode 100644 index 00000000000..8742ea19308 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png new file mode 100644 index 00000000000..b31a9473235 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png new file mode 100644 index 00000000000..2fb339d4f97 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png new file mode 100644 index 00000000000..070802b308a Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png new file mode 100644 index 00000000000..8353d6b7e4b Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png new file mode 100644 index 00000000000..5150250a1b6 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png new file mode 100644 index 00000000000..f5bde9929ff Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png new file mode 100644 index 00000000000..856e70ac441 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png new file mode 100644 index 00000000000..cf805c1602c Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png new file mode 100644 index 00000000000..ae577548b71 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png new file mode 100644 index 00000000000..76e085bf60f Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png new file mode 100644 index 00000000000..bfbd4298b01 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png new file mode 100644 index 00000000000..93160389d9e Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png new file mode 100644 index 00000000000..b3de20632f2 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png new file mode 100644 index 00000000000..194bd8e456a Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png new file mode 100644 index 00000000000..e0951fb7ad2 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png new file mode 100644 index 00000000000..08381b8325b Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png new file mode 100644 index 00000000000..5c00f0e1887 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png new file mode 100644 index 00000000000..77cbe960e55 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png new file mode 100644 index 00000000000..2bbd936a2d5 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png new file mode 100644 index 00000000000..089b2091c98 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png new file mode 100644 index 00000000000..3a817538888 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png new file mode 100644 index 00000000000..52f29bc3a62 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png new file mode 100644 index 00000000000..adc79f280a4 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png new file mode 100644 index 00000000000..e8bf751f434 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png new file mode 100644 index 00000000000..006ac67149c Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png new file mode 100644 index 00000000000..7f3ac301545 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png new file mode 100644 index 00000000000..cce8c108a6e Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png new file mode 100644 index 00000000000..252d2278496 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png new file mode 100644 index 00000000000..167002a94db Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png new file mode 100644 index 00000000000..838d008ff80 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png new file mode 100644 index 00000000000..09f01e445c7 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png new file mode 100644 index 00000000000..64b10efb9aa Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png new file mode 100644 index 00000000000..4f668ea490f Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png differ diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts new file mode 100644 index 00000000000..89bce5395c8 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/index.ts @@ -0,0 +1,27 @@ +export { markdownFileIconSource } from "./src/markdownFileIcons"; +export { + resolveMarkdownFileIcon, + resolveMarkdownLinkPresentation, + type MarkdownFileIcon, + type MarkdownLinkPresentation, +} from "./src/markdownLinks"; +export { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, + type NativeMarkdownDocumentChunk, + type NativeMarkdownTextRun, +} from "./src/nativeMarkdownText"; +export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive"; +export { + SelectableMarkdownText, + type MarkdownCodeHighlighter, + type MarkdownHighlightedToken, +} from "./src/SelectableMarkdownText"; +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./src/SelectableMarkdownText.types"; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h new file mode 100644 index 00000000000..f9c05a19819 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h @@ -0,0 +1,13 @@ +#import +#import + +#ifndef T3MarkdownTextNativeComponent_h +#define T3MarkdownTextNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN +@interface T3MarkdownText : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END + +#endif /* UitextviewViewNativeComponent_h */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm new file mode 100644 index 00000000000..3ebfdb7a11e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -0,0 +1,688 @@ +#import "T3MarkdownText.h" +#import "T3MarkdownTextShadowNode.h" +#import "T3MarkdownTextComponentDescriptor.h" +#import "T3MarkdownTextRun.h" +#import +#import + +#import +#import +#import +#import +#import "RCTFabricComponentsPlugins.h" + +using namespace facebook::react; + +static void T3MarkdownTextApplyParagraphStyles( + NSMutableAttributedString *attributedString, + const std::vector &styleRanges) +{ + for (const auto &styleRange : styleRanges) { + if (styleRange.length == 0 || styleRange.location >= attributedString.length) { + continue; + } + + const NSRange markerRange = NSMakeRange( + styleRange.location, + MIN(styleRange.length, attributedString.length - styleRange.location)); + const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; + const NSParagraphStyle *existingStyle = + [attributedString attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:nil]; + NSMutableParagraphStyle *paragraphStyle = + existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; + paragraphStyle.headIndent = styleRange.headIndent; + paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + paragraphStyle.tabStops = @[ + [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + location:styleRange.headIndent + options:@{}] + ]; + paragraphStyle.defaultTabInterval = styleRange.headIndent; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:paragraphStyle + range:paragraphRange]; + } +} + +static void T3MarkdownTextApplyAttachments( + NSMutableAttributedString *attributedString, + const std::vector &attachmentRanges, + NSDictionary *images) +{ + for (const auto &attachmentRange : attachmentRanges) { + if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { + continue; + } + + NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + UIImage *image = images[imageUri]; + if ([imageUri hasPrefix:@"sf:"]) { + NSString *symbolName = [imageUri substringFromIndex:3]; + UIColor *foregroundColor = + [attributedString attribute:NSForegroundColorAttributeName + atIndex:attachmentRange.location + effectiveRange:nil] ?: UIColor.labelColor; + image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor + renderingMode:UIImageRenderingModeAlwaysOriginal]; + } + attachment.image = image ?: [[UIImage alloc] init]; + attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const NSRange range = NSMakeRange( + attachmentRange.location, + MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); + NSAttributedString *attachmentString = + [NSAttributedString attributedStringWithAttachment:attachment]; + [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; + } +} + +static NSArray *> *T3MarkdownTextExtractChipBackgrounds( + NSMutableAttributedString *attributedString, + const std::vector &chipRanges) +{ + NSMutableArray *> *backgrounds = [NSMutableArray array]; + for (const auto &chipRange : chipRanges) { + if (chipRange.length == 0 || chipRange.location >= attributedString.length) { + continue; + } + + const NSRange range = NSMakeRange( + chipRange.location, + MIN(chipRange.length, attributedString.length - chipRange.location)); + UIColor *color = [attributedString attribute:NSBackgroundColorAttributeName + atIndex:range.location + effectiveRange:nil]; + UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName + atIndex:range.location + effectiveRange:nil]; + if (color == nil) { + continue; + } + [backgrounds addObject:@{ + @"range": [NSValue valueWithRange:range], + @"color": color, + @"strokeColor": [foregroundColor + colorWithAlphaComponent:chipRange.isSkill ? 0.25 : 0.1] ?: UIColor.clearColor, + }]; + [attributedString removeAttribute:NSBackgroundColorAttributeName range:range]; + } + return backgrounds; +} + +@interface T3MarkdownTextBackingView : UITextView +@property(nonatomic, copy) NSArray *> *chipBackgrounds; +@end + +@implementation T3MarkdownTextBackingView + +- (void)drawRect:(CGRect)rect +{ + [self.layoutManager ensureLayoutForTextContainer:self.textContainer]; + CGContextRef context = UIGraphicsGetCurrentContext(); + if (context != nil) { + CGContextSaveGState(context); + CGContextResetClip(context); + CGContextClipToRect(context, self.bounds); + } + for (NSDictionary *background in self.chipBackgrounds) { + const NSRange characterRange = [background[@"range"] rangeValue]; + UIColor *color = background[@"color"]; + UIColor *strokeColor = background[@"strokeColor"]; + if (characterRange.length == 0 || NSMaxRange(characterRange) > self.textStorage.length) { + continue; + } + + const NSRange glyphRange = + [self.layoutManager glyphRangeForCharacterRange:characterRange actualCharacterRange:nil]; + [color setFill]; + [self.layoutManager + enumerateEnclosingRectsForGlyphRange:glyphRange + withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0) + inTextContainer:self.textContainer + usingBlock:^(CGRect glyphRect, BOOL *stop) { + const CGFloat chipHeight = 22; + CGRect chipRect = CGRectMake( + glyphRect.origin.x - 4, + CGRectGetMidY(glyphRect) - chipHeight / 2, + glyphRect.size.width + 8, + chipHeight); + chipRect.origin.x += self.textContainerInset.left; + chipRect.origin.y += self.textContainerInset.top; + const CGFloat minimumX = self.textContainerInset.left + 0.5; + const CGFloat maximumX = + CGRectGetWidth(self.bounds) - self.textContainerInset.right - 0.5; + if (chipRect.origin.x < minimumX) { + chipRect.size.width -= minimumX - chipRect.origin.x; + chipRect.origin.x = minimumX; + } + if (CGRectGetMaxX(chipRect) > maximumX) { + chipRect.size.width = MAX(0, maximumX - chipRect.origin.x); + } + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect cornerRadius:6]; + [path fill]; + [strokeColor setStroke]; + path.lineWidth = 1; + [path stroke]; + }]; + } + if (context != nil) { + CGContextRestoreGState(context); + } + + [super drawRect:rect]; +} + +@end + +@protocol T3MarkdownOutsideTapTarget +- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; +@end + +@interface T3MarkdownOutsideTapCoordinator : NSObject + +- (instancetype)initWithWindow:(UIWindow *)window; +- (void)addTarget:(id)target; +- (void)removeTarget:(id)target; + +@end + +static const void *T3MarkdownOutsideTapCoordinatorKey = + &T3MarkdownOutsideTapCoordinatorKey; + +@implementation T3MarkdownOutsideTapCoordinator { + __weak UIWindow *_window; + UITapGestureRecognizer *_recognizer; + NSHashTable> *_targets; +} + +- (instancetype)initWithWindow:(UIWindow *)window +{ + if (self = [super init]) { + _window = window; + _targets = [NSHashTable weakObjectsHashTable]; + _recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handleTap:)]; + _recognizer.cancelsTouchesInView = NO; + _recognizer.delegate = self; + [window addGestureRecognizer:_recognizer]; + } + return self; +} + +- (void)addTarget:(id)target +{ + [_targets addObject:target]; +} + +- (void)removeTarget:(id)target +{ + [_targets removeObject:target]; + if (_targets.count > 0) { + return; + } + + UIWindow *window = _window; + [window removeGestureRecognizer:_recognizer]; + if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) { + objc_setAssociatedObject( + window, + T3MarkdownOutsideTapCoordinatorKey, + nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } +} + +- (void)handleTap:(UITapGestureRecognizer *)sender +{ + UIWindow *window = _window; + if (window == nil) { + return; + } + + UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil]; + if (hitView == nil) { + return; + } + for (id target in _targets.allObjects) { + [target clearSelectionForOutsideTapWithHitView:hitView]; + } +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer + shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer +{ + return YES; +} + +@end + +static T3MarkdownOutsideTapCoordinator * +T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) +{ + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey); + if (coordinator == nil) { + coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window]; + objc_setAssociatedObject( + window, + T3MarkdownOutsideTapCoordinatorKey, + coordinator, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + return coordinator; +} + +@interface T3MarkdownText () + +@end + +@interface T3MarkdownText () +@end + +@implementation T3MarkdownText { + UIView * _view; + T3MarkdownTextBackingView * _textView; + T3MarkdownTextShadowNode::ConcreteState::Shared _state; + __weak UIWindow * _outsideTapWindow; + BOOL _suppressSelectionChange; + NSMutableDictionary * _attachmentImages; + NSMutableSet * _pendingAttachmentUris; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _view = [[UIView alloc] init]; + self.contentView = _view; + self.clipsToBounds = true; + + _textView = [[T3MarkdownTextBackingView alloc] init]; + _attachmentImages = [[NSMutableDictionary alloc] init]; + _pendingAttachmentUris = [[NSMutableSet alloc] init]; + _textView.scrollEnabled = false; + _textView.editable = false; + _textView.textContainerInset = UIEdgeInsetsZero; + _textView.textContainer.lineFragmentPadding = 0; + _textView.delegate = self; + // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. + _textView.layoutManager.usesFontLeading = NO; + [self addSubview:_textView]; + + const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(handleLongPressIfNecessary:)]; + longPressGestureRecognizer.delegate = self; + + const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handlePressIfNecessary:)]; + pressGestureRecognizer.delegate = self; + [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer]; + + [_textView addGestureRecognizer:pressGestureRecognizer]; + [_textView addGestureRecognizer:longPressGestureRecognizer]; + } + + return self; +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if (_outsideTapWindow == self.window) { + return; + } + if (_outsideTapWindow != nil) { + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; + } + _outsideTapWindow = self.window; + if (_outsideTapWindow != nil) { + [T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self]; + } +} + +- (void)dealloc +{ + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; +} + +// See RCTParagraphComponentView +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; + _outsideTapWindow = nil; + _state.reset(); + + // Reset the frame to zero so that when it properly lays out on the next use + _textView.frame = CGRectZero; + _textView.attributedText = nil; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + // _textView's frame is assigned inside drawRect, which only fires when + // state changes. Trigger a redraw whenever the host frame moves out from + // under it (rotation, parent relayout) so the text view resizes and + // onTextLayout re-fires with the new line wrapping. + if (!CGRectEqualToRect(_textView.frame, _view.frame)) { + [self setNeedsDisplay]; + } +} + +- (void)drawRect:(CGRect)rect +{ + if (!_state) { + return; + } + + const auto &props = *std::static_pointer_cast(_props); + + const auto attrString = _state->getData().attributedString; + NSMutableAttributedString *convertedAttrString = + [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy]; + T3MarkdownTextApplyParagraphStyles( + convertedAttrString, + _state->getData().paragraphStyleRanges); + T3MarkdownTextApplyAttachments( + convertedAttrString, + _state->getData().attachmentRanges, + _attachmentImages); + _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds( + convertedAttrString, + _state->getData().chipRanges); + [self loadAttachmentImages:_state->getData().attachmentRanges]; + + // Setting attributedText clears any active text selection, and re-assigning + // the frame triggers a layout flush that has the same effect. Bail out + // entirely when nothing actually changed so a JS-side state update made in + // response to onSelectionChange doesn't deselect what the user is selecting. + const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; + const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); + if (!textChanged && !frameChanged) { + return; + } + if (textChanged) { + // Reassigning attributedText clears any active selection. Save it and + // restore after, while suppressing the synthetic textViewDidChangeSelection + // events the clear-then-restore would otherwise produce — those would + // round-trip to JS and re-trigger this same path, causing a loop. + const NSRange savedRange = _textView.selectedRange; + _suppressSelectionChange = YES; + _textView.attributedText = convertedAttrString; + if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) { + _textView.selectedRange = savedRange; + } + _suppressSelectionChange = NO; + } + if (frameChanged) { + _textView.frame = _view.frame; + } + + __block std::vector lines; + const int maxLines = props.numberOfLines; + [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect, + CGRect usedRect, + NSTextContainer * _Nonnull textContainer, + NSRange glyphRange, + BOOL * _Nonnull stop) { + const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil]; + const auto line = [self->_textView.text substringWithRange:charRange]; + lines.push_back(line.UTF8String); + // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS + // 18, so cap explicitly. + if (maxLines > 0 && lines.size() >= (size_t)maxLines) { + *stop = YES; + } + }]; + + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast(self.tag), lines}); + }; +} + +- (void)loadAttachmentImages:(const std::vector &)attachmentRanges +{ + for (const auto &attachmentRange : attachmentRanges) { + NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; + if ([imageUri hasPrefix:@"sf:"]) { + continue; + } + if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) { + continue; + } + + NSURL *url = [NSURL URLWithString:imageUri]; + if (url == nil) { + continue; + } + if (url.isFileURL) { + UIImage *image = [UIImage imageWithContentsOfFile:url.path]; + if (image != nil) { + _attachmentImages[imageUri] = image; + dispatch_async(dispatch_get_main_queue(), ^{ + [self refreshDisplayedAttachments]; + }); + } + continue; + } + + [_pendingAttachmentUris addObject:imageUri]; + [[[NSURLSession sharedSession] dataTaskWithURL:url + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + UIImage *image = data == nil ? nil : [UIImage imageWithData:data]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_pendingAttachmentUris removeObject:imageUri]; + if (image != nil) { + self->_attachmentImages[imageUri] = image; + [self refreshDisplayedAttachments]; + } + }); + }] resume]; + } +} + +- (void)refreshDisplayedAttachments +{ + if (!_state || _textView.attributedText == nil) { + return; + } + + NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy]; + T3MarkdownTextApplyAttachments( + attributedText, + _state->getData().attachmentRanges, + _attachmentImages); + + const NSRange savedRange = _textView.selectedRange; + _suppressSelectionChange = YES; + _textView.attributedText = attributedText; + if (savedRange.location != NSNotFound && + NSMaxRange(savedRange) <= _textView.attributedText.length) { + _textView.selectedRange = savedRange; + } + _suppressSelectionChange = NO; + [_textView setNeedsDisplay]; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &oldViewProps = *std::static_pointer_cast(_props); + const auto &newViewProps = *std::static_pointer_cast(props); + + if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { + _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; + } + + if (oldViewProps.selectable != newViewProps.selectable) { + _textView.selectable = newViewProps.selectable; + } + + if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { + if (@available(iOS 11.0, *)) { + _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; + } + } + + if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { + if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping; + } + } + + + // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text + // is in will have this background color applied. To apply it just to a particular part of a string, you'd need + // to do Hello. + // This is how the base component works though, so we'll go with it for now. Can change later if we want. + if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { + _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor); + } + + [super updateProps:props oldProps:oldProps]; +} + +// See RCTParagraphComponentView +- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState +{ + _state = std::static_pointer_cast(state); + [self setNeedsDisplay]; +} + +// MARK: - UIGestureRecognizerDelegate + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer +{ + return YES; +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch +{ + return YES; +} + +- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView +{ + if ([hitView isDescendantOfView:self]) { + return; + } + // Defer past the current event loop turn so any in-flight edit-menu action + // (Copy / Define / Look Up / …) reads the live selection before we clear it. + UITextView *textView = _textView; + dispatch_async(dispatch_get_main_queue(), ^{ + UITextRange *range = textView.selectedTextRange; + if (range != nil && !range.isEmpty) { + textView.selectedTextRange = nil; + } + }); +} + +// MARK: - Touch handling + +- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender +{ + return [sender locationInView:_textView]; +} + +- (T3MarkdownTextRun*)getTouchChild:(CGPoint)location +{ + const auto charIndex = [_textView.layoutManager characterIndexForPoint:location + inTextContainer:_textView.textContainer + fractionOfDistanceBetweenInsertionPoints:nil + ]; + + int currIndex = -1; + for (UIView* child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child; + + // This is UTF16 code units!! + currIndex += textChild.text.length; + + if (charIndex <= currIndex) { + return textChild; + } + } + + return nil; +} + +- (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender +{ + const auto location = [self getLocationOfPress:sender]; + const auto child = [self getTouchChild:location]; + + if (child) { + [child onPress]; + } +} + +- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender +{ + const auto location = [self getLocationOfPress:sender]; + const auto child = [self getTouchChild:location]; + + if (child) { + [child onLongPress]; + } +} + +// MARK: - UITextViewDelegate + +- (void)textViewDidChangeSelection:(UITextView *)textView +{ + if (_suppressSelectionChange) { + return; + } + if (_eventEmitter == nullptr) { + return; + } + + const NSRange selectedRange = textView.selectedRange; + if (selectedRange.location == NSNotFound) { + return; + } + + // Fires on programmatic selection changes too (e.g. the outside-tap clear + // in handleOutsideTap:), so JS will see a synthetic empty-range event then. + std::dynamic_pointer_cast(_eventEmitter) + ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{ + static_cast(self.tag), + static_cast(selectedRange.location), + static_cast(selectedRange.location + selectedRange.length), + }); +} + +Class T3MarkdownTextCls(void) +{ + return T3MarkdownText.class; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h new file mode 100644 index 00000000000..77e21d58510 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "T3MarkdownTextShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor; + +void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm new file mode 100644 index 00000000000..3ca2b1eee5b --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm @@ -0,0 +1,36 @@ +#import +#import +#import "RCTBridge.h" +#import "Utils.h" + +@interface T3MarkdownTextManager : RCTViewManager +@end + +@implementation T3MarkdownTextManager + +RCT_EXPORT_MODULE(T3MarkdownText) + +- (UIView *)view +{ + return [[UIView alloc] init]; +} + +RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView) +{ +} + +@end + +@interface T3MarkdownTextRunManager : RCTViewManager +@end + +@implementation T3MarkdownTextRunManager + +RCT_EXPORT_MODULE(T3MarkdownTextRun) + +- (UIView *)view +{ + return nil; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h new file mode 100644 index 00000000000..b8b40657110 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h @@ -0,0 +1,24 @@ +// This guard prevent this file to be compiled in the old architecture. +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import +#import + +#ifndef T3MarkdownTextRunNativeComponent_h +#define T3MarkdownTextRunNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN + +@interface T3MarkdownTextRun : RCTViewComponentView + +@property (nonatomic, copy, nullable) NSString *text; + +- (void)onPress; +- (void)onLongPress; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* UitextviewViewNativeComponent_h */ +#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm new file mode 100644 index 00000000000..4549084f03f --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm @@ -0,0 +1,72 @@ +#import "T3MarkdownTextRun.h" +#import "T3MarkdownText.h" +#import "T3MarkdownTextRunComponentDescriptor.h" +#import +#import +#import +#import "RCTFabricComponentsPlugins.h" +#import "Utils.h" + +using namespace facebook::react; + +@interface T3MarkdownTextRun () + +@end + +@implementation T3MarkdownTextRun { + NSString * _text; + RCTBubblingEventBlock _onPress; + RCTBubblingEventBlock _onLongPress; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + } + return self; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &oldViewProps = *std::static_pointer_cast(_props); + const auto &newViewProps = *std::static_pointer_cast(props); + + if (newViewProps.text != oldViewProps.text) { + NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; + _text = text; + } + + [super updateProps:props oldProps:oldProps]; +} + +- (void)onPress { + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{}); + } +} + +- (void)onLongPress { + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{}); + } +} + ++ (BOOL)shouldBeRecycled { + return NO; +} + +Class T3MarkdownTextRunCls(void) +{ + return T3MarkdownTextRun.class; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h new file mode 100644 index 00000000000..61f9e1a129e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "T3MarkdownTextRunShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor; + +void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp new file mode 100644 index 00000000000..a1af619205d --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp @@ -0,0 +1,6 @@ +#include "T3MarkdownTextRunShadowNode.h" + +namespace facebook::react { + +extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun"; +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h new file mode 100644 index 00000000000..c00bd1f2407 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include + +namespace facebook::react { +extern const char T3MarkdownTextRunComponentName[]; + +using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode< + T3MarkdownTextRunComponentName, + T3MarkdownTextRunProps, + T3MarkdownTextRunEventEmitter, + T3MarkdownTextRunState>; +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h new file mode 100644 index 00000000000..afc276aedda --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace facebook::react { + +extern const char T3MarkdownTextComponentName[]; + +struct T3MarkdownTextParagraphStyleRange { + size_t location; + size_t length; + Float firstLineHeadIndent; + Float headIndent; + Float paragraphSpacing; +}; + +struct T3MarkdownTextAttachmentRange { + size_t location; + size_t length; + std::string imageUri; +}; + +struct T3MarkdownTextChipRange { + size_t location; + size_t length; + bool isSkill; +}; + +class T3MarkdownTextStateReal final { + public: + AttributedString attributedString; + std::vector paragraphStyleRanges; + std::vector attachmentRanges; + std::vector chipRanges; +}; + +class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< +T3MarkdownTextComponentName, +T3MarkdownTextProps, +T3MarkdownTextEventEmitter, +T3MarkdownTextStateReal> { +public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + T3MarkdownTextShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment + ); + + static ShadowNodeTraits BaseTraits() { + auto traits = ConcreteViewShadowNode::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; + } + + void layout(LayoutContext layoutContext) override; + + Size measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const override; + +private: + mutable AttributedString _attributedString; + mutable std::vector _paragraphStyleRanges; + mutable std::vector _attachmentRanges; + mutable std::vector _chipRanges; +}; +} // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm new file mode 100644 index 00000000000..00fda742284 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -0,0 +1,269 @@ +#include "T3MarkdownTextShadowNode.h" +#include "T3MarkdownTextRunShadowNode.h" +#include +#import + +#include +#include + +namespace facebook::react { + +static constexpr Float ParagraphStyleEncodingOffset = 1000; +static constexpr auto ChipNativeIdPrefix = "t3-chip-"; +static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:"; +static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:"; + +static void applyParagraphStyles( + NSMutableAttributedString *attributedString, + const std::vector &styleRanges) +{ + for (const auto &styleRange : styleRanges) { + if (styleRange.length == 0 || styleRange.location >= attributedString.length) { + continue; + } + + const NSRange markerRange = NSMakeRange( + styleRange.location, + MIN(styleRange.length, attributedString.length - styleRange.location)); + const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; + const NSParagraphStyle *existingStyle = + [attributedString attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:nil]; + NSMutableParagraphStyle *paragraphStyle = + existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; + paragraphStyle.headIndent = styleRange.headIndent; + paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + paragraphStyle.tabStops = @[ + [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + location:styleRange.headIndent + options:@{}] + ]; + paragraphStyle.defaultTabInterval = styleRange.headIndent; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:paragraphStyle + range:paragraphRange]; + } +} + +static void applyAttachments( + NSMutableAttributedString *attributedString, + const std::vector &attachmentRanges) +{ + for (const auto &attachmentRange : attachmentRanges) { + if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { + continue; + } + + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + attachment.image = [[UIImage alloc] init]; + attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const NSRange range = NSMakeRange( + attachmentRange.location, + MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); + NSAttributedString *attachmentString = + [NSAttributedString attributedStringWithAttachment:attachment]; + [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; + } +} + +T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment +) : ConcreteViewShadowNode(sourceShadowNode, fragment) { +}; + +Size T3MarkdownTextShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + + auto baseTextAttributes = TextAttributes::defaultTextAttributes(); + baseTextAttributes.backgroundColor = baseProps.backgroundColor; + baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; + + Float fontSizeMultiplier = 1.0; + if (baseTextAttributes.allowFontScaling) { + fontSizeMultiplier = layoutContext.fontSizeMultiplier; + } + + auto baseAttributedString = AttributedString{}; + auto paragraphStyleRanges = std::vector{}; + auto attachmentRanges = std::vector{}; + auto chipRanges = std::vector{}; + size_t utf16Offset = 0; + const auto &children = getChildren(); + for (size_t i = 0; i < children.size(); i++) { + const auto child = children[i].get(); + if (auto textViewChild = dynamic_cast(child)) { + auto &props = textViewChild->getConcreteProps(); + auto fragment = AttributedString::Fragment{}; + auto textAttributes = TextAttributes::defaultTextAttributes(); + + textAttributes.allowFontScaling = baseProps.allowFontScaling; + textAttributes.backgroundColor = props.backgroundColor; + textAttributes.fontSize = props.fontSize * fontSizeMultiplier; + textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; + textAttributes.foregroundColor = props.color; + const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset; + if (!hasParagraphStyle) { + textAttributes.textShadowColor = props.shadowColor; + textAttributes.textShadowOffset = props.shadowOffset; + textAttributes.textShadowRadius = props.shadowRadius; + } + textAttributes.letterSpacing = props.letterSpacing; + textAttributes.textDecorationColor = props.textDecorationColor; + textAttributes.fontFamily = props.fontFamily; + + if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) { + textAttributes.fontStyle = FontStyle::Italic; + } else { + textAttributes.fontStyle = FontStyle::Normal; + } + + if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) { + textAttributes.fontWeight = FontWeight::Bold; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) { + textAttributes.fontWeight = FontWeight::UltraLight; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) { + textAttributes.fontWeight = FontWeight::Light; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) { + textAttributes.fontWeight = FontWeight::Medium; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) { + textAttributes.fontWeight = FontWeight::Semibold; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) { + textAttributes.fontWeight = FontWeight::Heavy; + } else { + textAttributes.fontWeight = FontWeight::Regular; + } + + if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) { + textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough; + } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) { + textAttributes.textDecorationLineType = TextDecorationLineType::Underline; + } else { + textAttributes.textDecorationLineType = TextDecorationLineType::None; + } + + if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) { + textAttributes.textDecorationStyle = TextDecorationStyle::Solid; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) { + textAttributes.textDecorationStyle = TextDecorationStyle::Double; + } + + if (props.textAlign == T3MarkdownTextRunTextAlign::Left) { + textAttributes.alignment = TextAlignment::Left; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) { + textAttributes.alignment = TextAlignment::Right; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) { + textAttributes.alignment = TextAlignment::Center; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) { + textAttributes.alignment = TextAlignment::Justified; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) { + textAttributes.alignment = TextAlignment::Natural; + } + + textAttributes.backgroundColor = props.backgroundColor; + + fragment.string = props.text; + fragment.textAttributes = textAttributes; + + NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()]; + const size_t fragmentLength = fragmentText.length; + if (hasParagraphStyle) { + paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{ + utf16Offset, + fragmentLength, + props.shadowOffset.width, + props.shadowOffset.height, + props.shadowRadius - ParagraphStyleEncodingOffset, + }); + } + if (props.nativeId.rfind(ChipNativeIdPrefix, 0) == 0 && fragmentLength > 0) { + chipRanges.push_back(T3MarkdownTextChipRange{ + utf16Offset, + fragmentLength, + props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0, + }); + } + if (props.nativeId.rfind(FileChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset + 1, + 1, + props.nativeId.substr(std::char_traits::length(FileChipNativeIdPrefix)), + }); + } else if ( + props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset + 1, + 1, + props.nativeId.substr(std::char_traits::length(SkillChipNativeIdPrefix)), + }); + } + utf16Offset += fragmentLength; + baseAttributedString.appendFragment(std::move(fragment)); + } + } + + _attributedString = baseAttributedString; + _paragraphStyleRanges = paragraphStyleRanges; + _attachmentRanges = attachmentRanges; + _chipRanges = chipRanges; + + NSMutableAttributedString *convertedAttributedString = + [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; + applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); + applyAttachments(convertedAttributedString, attachmentRanges); + + const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) + ? layoutConstraints.maximumSize.width + : CGFLOAT_MAX; + NSTextStorage *textStorage = + [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString]; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + layoutManager.usesFontLeading = NO; + NSTextContainer *textContainer = + [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; + textContainer.lineFragmentPadding = 0; + textContainer.maximumNumberOfLines = baseProps.numberOfLines; + if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { + textContainer.lineBreakMode = NSLineBreakByTruncatingHead; + } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { + textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; + } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { + textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + } else { + textContainer.lineBreakMode = NSLineBreakByClipping; + } + [layoutManager addTextContainer:textContainer]; + [textStorage addLayoutManager:layoutManager]; + [layoutManager ensureLayoutForTextContainer:textContainer]; + const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; + + return { + std::clamp( + static_cast(std::ceil(usedRect.size.width)), + layoutConstraints.minimumSize.width, + layoutConstraints.maximumSize.width), + std::clamp( + static_cast(std::ceil(usedRect.size.height)), + layoutConstraints.minimumSize.height, + layoutConstraints.maximumSize.height), + }; +} + +void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { + ensureUnsealed(); + setStateData(T3MarkdownTextStateReal{ + _attributedString, + _paragraphStyleRanges, + _attachmentRanges, + _chipRanges, + }); +} +} diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json new file mode 100644 index 00000000000..d51b6c5d9ff --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -0,0 +1,51 @@ +{ + "name": "@t3tools/mobile-markdown-text", + "version": "0.0.0", + "private": true, + "source": "./index.ts", + "files": [ + "assets", + "ios", + "src", + "index.ts", + "LICENSE", + "UPSTREAM.md", + "T3MarkdownText.podspec", + "react-native.config.js" + ], + "main": "./index.ts", + "types": "./index.ts", + "react-native": "./index.ts", + "exports": { + ".": "./index.ts", + "./file-icons": "./src/markdownFileIcons.ts", + "./links": "./src/markdownLinks.ts", + "./markdown": "./src/nativeMarkdownText.ts", + "./primitive": "./src/MarkdownTextPrimitive.tsx", + "./renderer": "./src/SelectableMarkdownText.ios.tsx", + "./types": "./src/SelectableMarkdownText.types.ts" + }, + "peerDependencies": { + "expo-asset": "*", + "expo-clipboard": "*", + "expo-haptics": "*", + "expo-symbols": "*", + "react": "*", + "react-native": "*", + "react-native-nitro-markdown": "*" + }, + "codegenConfig": { + "name": "T3MarkdownTextSpec", + "type": "all", + "jsSrcsDir": "src", + "ios": { + "componentProvider": { + "T3MarkdownText": "T3MarkdownText", + "T3MarkdownTextRun": "T3MarkdownTextRun" + } + }, + "outputDir": { + "ios": "ios/generated" + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/react-native.config.js b/apps/mobile/modules/t3-markdown-text/react-native.config.js new file mode 100644 index 00000000000..6b10ea26eec --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/react-native.config.js @@ -0,0 +1,10 @@ +module.exports = { + dependency: { + platforms: { + ios: { + podspecPath: "T3MarkdownText.podspec", + }, + android: null, + }, + }, +}; diff --git a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs new file mode 100644 index 00000000000..87f17c28e0f --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs @@ -0,0 +1,133 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { getBuiltInSpriteSheet } from "@pierre/trees"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const moduleDirectory = resolve(scriptDirectory, ".."); +const repositoryRoot = resolve(moduleDirectory, "../../../.."); +const outputDirectory = join(moduleDirectory, "assets/file-icons"); +const generatedModulePath = join(moduleDirectory, "src/markdownFileIcons.generated.ts"); +const webIconSource = readFileSync(join(repositoryRoot, "apps/web/src/pierre-icons.ts"), "utf8"); +const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1]; + +if (!customSprite) { + throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts"); +} + +const colors = { + astro: "#a631be", + babel: "#d5a910", + bash: "#199f43", + biome: "#1a85d4", + bootstrap: "#693acf", + browserslist: "#d5a910", + bun: "#594c5b", + c: "#1a85d4", + claude: "#d47628", + cpp: "#1a85d4", + css: "#693acf", + database: "#a631be", + default: "#84848a", + docker: "#1a85d4", + eslint: "#693acf", + font: "#84848a", + git: "#ff8c5b", + go: "#1ca1c7", + graphql: "#d32a61", + html: "#d47628", + image: "#d32a61", + javascript: "#d5a910", + json: "#d47628", + markdown: "#199f43", + mcp: "#17a5af", + nextjs: "#84848a", + npm: "#d52c36", + oxc: "#1ca1c7", + postcss: "#d52c36", + prettier: "#17a5af", + python: "#1a85d4", + react: "#1ca1c7", + ruby: "#d52c36", + rust: "#d47628", + sass: "#d32a61", + stylelint: "#84848a", + svelte: "#d52c36", + svg: "#d47628", + svgo: "#199f43", + swift: "#d47628", + table: "#17a5af", + tailwind: "#1ca1c7", + terraform: "#693acf", + text: "#84848a", + typescript: "#1a85d4", + vite: "#a631be", + vscode: "#1a85d4", + vue: "#199f43", + wasm: "#693acf", + webpack: "#1a85d4", + yml: "#d52c36", + zig: "#d47628", + zip: "#d47628", +}; + +const customIcons = { + agents: "t3-file-icon-agents", + claude: "t3-file-icon-claude", + package: "t3-file-icon-package-json", + pnpm: "t3-file-icon-pnpm", + readme: "t3-file-icon-readme", + tsconfig: "t3-file-icon-tsconfig", +}; + +function symbolFromSprite(sprite, id) { + const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = sprite.match( + new RegExp(`]*)>([\\s\\S]*?)<\\/symbol>`), + ); + if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`); + return { + body: match[2], + viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16", + }; +} + +function renderIcon(token, symbol, color) { + const svgPath = join(outputDirectory, `.pierre-${token}.svg`); + const pngPath = join(outputDirectory, `pierre_${token}.png`); + writeFileSync( + svgPath, + `${symbol.body}`, + ); + execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { + stdio: "ignore", + }); + rmSync(svgPath); +} + +rmSync(outputDirectory, { recursive: true, force: true }); +mkdirSync(outputDirectory, { recursive: true }); + +const builtInSprite = getBuiltInSpriteSheet("complete"); +const builtInTokens = [...builtInSprite.matchAll(/ match[1]) + .sort(); + +for (const token of builtInTokens) { + renderIcon( + token, + symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`), + colors[token] ?? colors.default, + ); +} +for (const [token, symbolId] of Object.entries(customIcons)) { + renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default); +} + +const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort(); +const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens + .map((token) => ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`) + .join("\n")}\n} as const satisfies Readonly>;\n`; +writeFileSync(generatedModulePath, generatedSource); diff --git a/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx new file mode 100644 index 00000000000..ffbc0e2fcb6 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx @@ -0,0 +1,73 @@ +import { SymbolView } from "expo-symbols"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import { memo, useEffect, useRef, useState } from "react"; +import { Pressable, type ColorValue } from "react-native"; + +const COPY_FEEDBACK_DURATION_MS = 1200; + +function copyTextWithHaptic(value: string): void { + void Clipboard.setStringAsync(value); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +} + +export const CopyTextButton = memo(function CopyTextButton(props: { + readonly accessibilityLabel: string; + readonly text: string; + readonly tintColor: ColorValue; + readonly copiedTintColor?: ColorValue; + readonly backgroundColor?: ColorValue; + readonly borderColor?: ColorValue; + readonly iconSize?: number; + readonly buttonSize?: number; +}) { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }, + [], + ); + + return ( + { + copyTextWithHaptic(props.text); + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_FEEDBACK_DURATION_MS); + }} + style={({ pressed }) => ({ + width: props.buttonSize ?? 30, + height: props.buttonSize ?? 30, + alignItems: "center", + justifyContent: "center", + borderRadius: 9, + borderWidth: props.borderColor ? 1 : 0, + borderColor: props.borderColor, + backgroundColor: props.backgroundColor, + opacity: pressed ? 0.52 : 1, + })} + > + + + ); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx new file mode 100644 index 00000000000..6ed7fecd2d3 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; +import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; +import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; +import { flattenStyles } from "./util"; + +const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ + false, + StyleSheet.create({}), +]); + +const textDefaults: TextProps = { + allowFontScaling: true, + selectable: true, +}; + +const useTextAncestorContext = () => React.useContext(TextAncestorContext); + +/** + * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices + * into the rendered string. `start === end` means the selection was cleared. + */ +export type SelectionChangeEvent = { + nativeEvent: { target: number; start: number; end: number }; +}; + +export type MarkdownTextPrimitiveProps = TextProps & { + uiTextView?: boolean; + /** + * Fired when the native text selection changes. Only fires on iOS when + * `uiTextView` is true. Note: fires on every selection-edge adjustment + * (e.g. dragging a selection handle), so consumers driving expensive work + * off this event should debounce. + */ + onSelectionChange?: (event: SelectionChangeEvent) => void; +}; + +function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) { + const [isAncestor, rootStyle] = useTextAncestorContext(); + + // Flatten the styles, and apply the root styles when needed + const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); + const contextValue = React.useMemo<[boolean, ViewStyle]>( + () => [true, flattenedStyle], + [flattenedStyle], + ); + let childPosition = 0; + const nativeChildren = React.Children.toArray(children).map((child) => { + const position = childPosition; + childPosition += 1; + + if (React.isValidElement(child)) { + return child; + } + if (typeof child !== "string" && typeof child !== "number") { + return null; + } + + const text = child.toString(); + return ( + // @ts-expect-error The generated run props do not include inherited Text props. + + ); + }); + + if (!isAncestor) { + return ( + + + {nativeChildren} + + + ); + } + + return <>{nativeChildren}; +} + +function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) { + const [isAncestor] = useTextAncestorContext(); + + // Even if the uiTextView prop is set, we can still default to using + // normal selection (i.e. base RN text) if the text doesn't need to be + // selectable + if ((!props.selectable || !props.uiTextView) && !isAncestor) { + return ; + } + return ; +} + +export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { + if (Platform.OS !== "ios") { + return ; + } + return ; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx new file mode 100644 index 00000000000..757b6c66011 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -0,0 +1,647 @@ +import { useEffect, useState } from "react"; +import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { CopyTextButton } from "./CopyTextButton"; +import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; +import { + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, +} from "./nativeMarkdownText"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, +} from "./SelectableMarkdownText.types"; + +type HighlightedCode = ReadonlyArray>; + +const highlightedCodeCache = new Map(); +const highlightedCodePromiseCache = new Map>(); +const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; + +function nodeKey(node: MarkdownNode, index: number): string { + return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; +} + +function nodeText(node: MarkdownNode): string { + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(nodeText).join(""); +} + +function documentFor(node: MarkdownNode): MarkdownNode { + return node.type === "document" ? node : { type: "document", children: [node] }; +} + +function SelectableNode(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + return ( + + ); +} + +function codeHighlightCacheKey( + code: string, + language: string | undefined, + theme: "light" | "dark", +): string { + return `${theme}:${language ?? "text"}:${code}`; +} + +function cacheHighlightedCode(key: string, tokens: HighlightedCode): void { + highlightedCodeCache.delete(key); + highlightedCodeCache.set(key, tokens); + + while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) { + const oldestKey = highlightedCodeCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + highlightedCodeCache.delete(oldestKey); + } +} + +function loadHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): Promise { + const key = codeHighlightCacheKey(code, language, theme); + const cached = highlightedCodeCache.get(key); + if (cached) { + return Promise.resolve(cached); + } + + const pending = highlightedCodePromiseCache.get(key); + if (pending) { + return pending; + } + + const promise = highlightCode({ code, language, theme }) + .then((tokens) => { + cacheHighlightedCode(key, tokens); + highlightedCodePromiseCache.delete(key); + return tokens; + }) + .catch((error) => { + highlightedCodePromiseCache.delete(key); + throw error; + }); + highlightedCodePromiseCache.set(key, promise); + return promise; +} + +function useHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): HighlightedCode | null { + const key = codeHighlightCacheKey(code, language, theme); + const [highlighted, setHighlighted] = useState<{ + readonly key: string; + readonly tokens: HighlightedCode | null; + }>(() => ({ + key, + tokens: highlightedCodeCache.get(key) ?? null, + })); + + useEffect(() => { + let active = true; + const cached = highlightedCodeCache.get(key); + if (cached) { + cacheHighlightedCode(key, cached); + setHighlighted({ key, tokens: cached }); + return () => { + active = false; + }; + } + + void loadHighlightedCode(code, language, theme, highlightCode) + .then((tokens) => { + if (active) { + setHighlighted({ key, tokens }); + } + }) + .catch(() => { + if (active) { + setHighlighted({ key, tokens: null }); + } + }); + return () => { + active = false; + }; + }, [code, highlightCode, key, language, theme]); + + return highlighted.key === key ? highlighted.tokens : null; +} + +function HighlightedCodeText(props: { + readonly content: string; + readonly highlighted: HighlightedCode | null; + readonly textStyle: NativeMarkdownTextStyle; +}) { + if (!props.highlighted) { + return ( + + {props.content} + + ); + } + const highlighted = props.highlighted; + let sourceOffset = 0; + const keyOccurrences = new Map(); + const keyedLines = highlighted.map((line) => { + const lineStart = sourceOffset; + const tokens = line.map((token) => { + const start = sourceOffset; + sourceOffset += token.content.length; + const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`; + const occurrence = keyOccurrences.get(signature) ?? 0; + keyOccurrences.set(signature, occurrence + 1); + return { key: `${signature}:${occurrence}`, token }; + }); + sourceOffset += 1; + return { + key: `line:${lineStart}:${line.map((token) => token.content).join("")}`, + tokens, + }; + }); + + return ( + + {keyedLines.map((line, lineIndex) => ( + + {line.tokens.map(({ key, token }) => ( + + {token.content} + + ))} + {lineIndex + 1 < keyedLines.length ? "\n" : ""} + + ))} + + ); +} + +function NativeCodeBlock(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly compact?: boolean; +}) { + const content = nodeText(props.node).replace(/\n$/, ""); + const colorScheme = useColorScheme(); + const theme = colorScheme === "dark" ? "dark" : "light"; + const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); + const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; + return ( + + + + {languageLabel} + + + + + + + + ); +} + +function collectTableRows(node: MarkdownNode): MarkdownNode[] { + const rows: MarkdownNode[] = []; + const visit = (child: MarkdownNode) => { + if (child.type === "table_row") { + rows.push(child); + return; + } + for (const nested of child.children ?? []) { + visit(nested); + } + }; + visit(node); + return rows; +} + +function NativeTable(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const rows = collectTableRows(props.node); + return ( + + + {rows.map((row, rowIndex) => ( + + {(row.children ?? []).map((cell, cellIndex) => ( + + + rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, + )} + textStyle={props.textStyle} + /> + + ))} + + ))} + + + ); +} + +function NativeMarkdownImage(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const href = props.node.href; + if (!href) { + return ; + } + + return ( + + + {props.node.alt ? ( + + {props.node.alt} + + ) : null} + + ); +} + +function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { + const groups: MarkdownNode[] = []; + let inline: MarkdownNode[] = []; + const flush = () => { + if (inline.length === 0) { + return; + } + groups.push({ type: "paragraph", children: inline }); + inline = []; + }; + + for (const node of nodes) { + if (node.type === "image") { + flush(); + groups.push(node); + } else { + inline.push(node); + } + } + flush(); + return groups; +} + +function NativeMixedParagraph(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + return ( + + {inlineGroups(props.node.children ?? []).map((child, index) => + child.type === "image" ? ( + + ) : ( + + ), + )} + + ); +} + +function NativeList(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly depth: number; +}) { + const ordered = props.node.ordered ?? false; + const start = props.node.start ?? 1; + const nested = props.depth > 0; + return ( + + {(props.node.children ?? []).map((item, index) => { + const taskMarker = item.type === "task_list_item"; + const marker = taskMarker + ? item.checked + ? "☑︎" + : "☐︎" + : ordered + ? `${start + index}.` + : props.depth % 3 === 1 + ? "◦" + : props.depth % 3 === 2 + ? "▪︎" + : "•"; + const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; + const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; + return ( + + + + {marker} + + + + {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( + + ))} + + + ); + })} + + ); +} + +export function NativeMarkdownBlock(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly depth?: number; + readonly compact?: boolean; +}) { + const depth = props.depth ?? 0; + switch (props.node.type) { + case "document": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "code_block": + return ( + + ); + case "table": + return ; + case "image": + return ; + case "horizontal_rule": + return ( + + ); + case "blockquote": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "list": + return ( + + ); + case "paragraph": + return (props.node.children ?? []).some((child) => child.type === "image") ? ( + + ) : ( + + ); + case "html_block": + case "math_block": + return ( + + + + ); + case "table_head": + case "table_body": + case "table_row": + case "table_cell": + case "list_item": + case "task_list_item": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + default: + return ; + } +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx new file mode 100644 index 00000000000..c6495eed860 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -0,0 +1,257 @@ +import { useEffect, useMemo, useState } from "react"; +import { Asset } from "expo-asset"; +import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; + +import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; +import { markdownFileIconSource } from "./markdownFileIcons"; +import type { MarkdownFileIcon } from "./markdownLinks"; +import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +const EXTERNAL_LINK_PREFIX = "◉ "; +const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0"; +const CHIP_SUFFIX = "\u00A0"; +const SKILL_ICON_PLACEHOLDER = "\uFFFC"; +const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; + +function useFileIconUris(runs: ReadonlyArray) { + const iconSignature = JSON.stringify( + [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(), + ); + const icons = useMemo( + () => JSON.parse(iconSignature) as ReadonlyArray, + [iconSignature], + ); + const [uris, setUris] = useState>(() => new Map()); + + useEffect(() => { + let cancelled = false; + + void Promise.all( + icons.map(async (icon) => { + const source = markdownFileIconSource(icon); + const fallbackUri = Image.resolveAssetSource(source).uri; + if (typeof source !== "number" && typeof source !== "string") { + return [icon, fallbackUri] as const; + } + try { + const asset = Asset.fromModule(source); + await asset.downloadAsync(); + return [icon, asset.localUri ?? fallbackUri] as const; + } catch { + return [icon, fallbackUri] as const; + } + }), + ).then((entries) => { + if (!cancelled) { + setUris(new Map(entries)); + } + }); + + return () => { + cancelled = true; + }; + }, [icons]); + + return uris; +} + +function runKeySignature(run: NativeMarkdownTextRun): string { + return [ + run.text, + run.bold, + run.italic, + run.strikethrough, + run.code, + run.href, + run.externalHost, + run.fileIcon, + run.skillName, + run.skillLabel, + run.role, + run.headingLevel, + run.depth, + run.spacing, + run.firstLineHeadIndent, + run.headIndent, + run.paragraphSpacing, + ].join(":"); +} + +function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { + const isFile = run.fileIcon != null; + const isSkill = run.skillName != null; + const isChip = isFile || isSkill; + const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); + const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; + const isHeading = run.role === "heading"; + const isCodeBlock = run.role === "code-block" || run.role === "code-language"; + const hasParagraphStyle = run.headIndent !== undefined; + const textDecorationLine = run.strikethrough ? "line-through" : run.href ? "underline" : "none"; + + return { + color: isFile + ? textStyle.fileTextColor + : isSkill + ? textStyle.skillTextColor + : run.href + ? textStyle.linkColor + : isHeading + ? textStyle.strongColor + : run.role === "quote-marker" + ? textStyle.quoteMarkerColor + : run.role === "divider" + ? textStyle.dividerColor + : run.role === "code-language" + ? textStyle.mutedColor + : run.role === "list-marker" + ? textStyle.mutedColor + : run.code || isFile + ? textStyle.codeColor + : run.bold + ? textStyle.strongColor + : textStyle.color, + fontFamily: isChip + ? "DMSans_500Medium" + : run.code || isCodeBlock + ? "ui-monospace" + : isHeading + ? textStyle.headingFontFamily + : run.bold + ? textStyle.boldFontFamily + : textStyle.fontFamily, + fontSize: + run.role === "spacer" + ? (run.spacing ?? 10) + : run.role === "list-break" + ? textStyle.fontSize + : isHeading + ? headingFontSize + : run.role === "code-language" + ? 11 + : run.code || isChip || isCodeBlock + ? Math.max(12, textStyle.fontSize - 2) + : textStyle.fontSize, + lineHeight: + run.role === "spacer" + ? (run.spacing ?? 10) + : run.role === "list-break" + ? textStyle.lineHeight + (run.spacing ?? 0) + : isHeading + ? Math.max(headingFontSize + 6, 20) + : isCodeBlock + ? 18 + : textStyle.lineHeight, + fontStyle: run.italic ? "italic" : "normal", + fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400", + textDecorationLine, + backgroundColor: isCodeBlock + ? textStyle.codeBlockBackgroundColor + : isSkill + ? textStyle.skillBackgroundColor + : run.code + ? textStyle.codeBackgroundColor + : isFile + ? textStyle.fileBackgroundColor + : undefined, + ...(hasParagraphStyle + ? { + shadowColor: "transparent", + shadowOffset: { + width: run.firstLineHeadIndent ?? 0, + height: run.headIndent, + }, + shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), + } + : {}), + }; +} + +export function NativeMarkdownSelectableText(props: { + readonly runs: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const colorScheme = useColorScheme(); + const fileIconUris = useFileIconUris(props.runs); + const occurrences = new Map(); + const prefixedExternalLinks = new Set(); + const keyedRuns = props.runs.map((run) => { + const signature = runKeySignature(run); + const occurrence = occurrences.get(signature) ?? 0; + occurrences.set(signature, occurrence + 1); + + let text = run.text; + if (run.fileIcon) { + text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`; + } else if (run.skillName && run.skillLabel) { + text = `\u00A0${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}${CHIP_SUFFIX}`; + } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { + prefixedExternalLinks.add(run.href); + text = `${EXTERNAL_LINK_PREFIX}${text}`; + } + + return { key: `${signature}:${occurrence}`, run, text }; + }); + // T3MarkdownText only rebuilds its attributed string during native layout. A + // color-only child update can otherwise leave the previous appearance cached. + const appearanceKey = [ + colorScheme ?? "unspecified", + props.textStyle.color, + props.textStyle.strongColor, + props.textStyle.mutedColor, + props.textStyle.linkColor, + props.textStyle.codeColor, + props.textStyle.codeBackgroundColor, + props.textStyle.codeBlockBackgroundColor, + props.textStyle.fileBackgroundColor, + props.textStyle.fileTextColor, + props.textStyle.skillBackgroundColor, + props.textStyle.skillTextColor, + props.textStyle.quoteMarkerColor, + props.textStyle.dividerColor, + ].join(":"); + + return ( + + {keyedRuns.map(({ key, run, text }) => { + const href = run.href; + return ( + { + void Linking.openURL(href); + } + : undefined + } + > + {text} + + ); + })} + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx new file mode 100644 index 00000000000..7c8f8d1bd55 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -0,0 +1,80 @@ +import { useMemo } from "react"; +import { View } from "react-native"; +import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, +} from "./nativeMarkdownText"; +import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import type { + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +const EMPTY_SKILLS: ReadonlyArray = []; + +export type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText({ + markdown, + skills = EMPTY_SKILLS, + textStyle, + highlightCode, + marginTop = 0, + marginBottom = 0, +}: SelectableMarkdownTextProps) { + const chunks = useMemo(() => { + const document = parseMarkdownWithOptions(markdown, { + gfm: true, + html: true, + math: false, + }); + return nativeMarkdownDocumentChunks(document).map((chunk) => + chunk.kind === "selectable" + ? { + ...chunk, + runs: nativeMarkdownDocumentRuns(chunk.node, skills), + } + : chunk, + ); + }, [markdown, skills]); + + return ( + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); + + return ( + + {content} + + ); + })} + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx new file mode 100644 index 00000000000..fcb2472f648 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -0,0 +1,13 @@ +import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; + +export type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +export function SelectableMarkdownText(_props: SelectableMarkdownTextProps) { + return null; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts new file mode 100644 index 00000000000..bd67d9110e5 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -0,0 +1,46 @@ +export interface NativeMarkdownTextStyle { + readonly color: string; + readonly strongColor: string; + readonly mutedColor: string; + readonly linkColor: string; + readonly codeColor: string; + readonly codeBackgroundColor: string; + readonly codeBlockBackgroundColor: string; + readonly fileBackgroundColor: string; + readonly fileTextColor: string; + readonly skillBackgroundColor: string; + readonly skillTextColor: string; + readonly quoteMarkerColor: string; + readonly dividerColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly fontFamily: string; + readonly headingFontFamily: string; + readonly boldFontFamily: string; +} + +export interface MarkdownHighlightedToken { + readonly content: string; + readonly color: string | null; + readonly fontStyle: number | null; +} + +export type MarkdownCodeHighlighter = (input: { + readonly code: string; + readonly language?: string | null; + readonly theme: "light" | "dark"; +}) => Promise>>; + +export interface SelectableMarkdownSkill { + readonly name: string; + readonly displayName?: string | null; +} + +export interface SelectableMarkdownTextProps { + readonly markdown: string; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly skills?: ReadonlyArray; + readonly marginTop?: number; + readonly marginBottom?: number; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts new file mode 100644 index 00000000000..656ad47d252 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts @@ -0,0 +1,55 @@ +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; +import type { ViewProps } from "react-native"; +import type { + BubblingEventHandler, + Int32, + WithDefault, +} from "react-native/Libraries/Types/CodegenTypes"; + +interface TargetedEvent { + target: Int32; +} + +interface TextLayoutEvent extends TargetedEvent { + lines: string[]; +} + +/** + * Event fired when text selection changes in the MarkdownTextPrimitive. + * @property target - The view tag identifier + * @property start - The start index of the selected range (0-based) + * @property end - The end index of the selected range (0-based, exclusive) + */ +interface SelectionChangeEvent extends TargetedEvent { + start: Int32; + end: Int32; +} + +type EllipsizeMode = "head" | "middle" | "tail" | "clip"; + +interface NativeProps extends ViewProps { + numberOfLines?: Int32; + allowFontScaling?: WithDefault; + ellipsizeMode?: WithDefault; + selectable?: boolean; + onTextLayout?: BubblingEventHandler; + /** + * Callback fired when the text selection changes. + * + * @example + * ```tsx + * { + * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end); + * }} + * > + * Selectable text + * + * ``` + */ + onSelectionChange?: BubblingEventHandler; +} + +export default codegenNativeComponent("T3MarkdownText", { + excludedPlatforms: ["android"], +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts new file mode 100644 index 00000000000..7f8fab8d844 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts @@ -0,0 +1,51 @@ +import type { ColorValue, ViewProps } from "react-native"; +import type { + BubblingEventHandler, + Float, + Int32, + WithDefault, +} from "react-native/Libraries/Types/CodegenTypes"; +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; + +interface TargetedEvent { + target: Int32; +} + +type TextDecorationLine = "none" | "underline" | "line-through"; + +type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; + +export type NativeFontWeight = + | "normal" + | "bold" + | "ultraLight" + | "light" + | "medium" + | "semibold" + | "heavy"; + +type FontStyle = "normal" | "italic"; + +type TextAlign = "auto" | "left" | "right" | "center" | "justify"; + +interface NativeProps extends ViewProps { + text: string; + color?: ColorValue; + fontSize?: Float; + fontStyle?: WithDefault; + fontWeight?: WithDefault; + fontFamily?: string; + letterSpacing?: Float; + lineHeight?: Float; + textDecorationLine?: WithDefault; + textDecorationStyle?: WithDefault; + textDecorationColor?: ColorValue; + textAlign?: WithDefault; + shadowRadius?: WithDefault; + onPress?: BubblingEventHandler; + onLongPress?: BubblingEventHandler; +} + +export default codegenNativeComponent("T3MarkdownTextRun", { + excludedPlatforms: ["android"], +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts new file mode 100644 index 00000000000..608fa08c486 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts @@ -0,0 +1,62 @@ +import type { ImageSourcePropType } from "react-native"; + +export const MARKDOWN_FILE_ICON_SOURCES = { + agents: require("../assets/file-icons/pierre_agents.png"), + astro: require("../assets/file-icons/pierre_astro.png"), + babel: require("../assets/file-icons/pierre_babel.png"), + bash: require("../assets/file-icons/pierre_bash.png"), + biome: require("../assets/file-icons/pierre_biome.png"), + bootstrap: require("../assets/file-icons/pierre_bootstrap.png"), + browserslist: require("../assets/file-icons/pierre_browserslist.png"), + bun: require("../assets/file-icons/pierre_bun.png"), + c: require("../assets/file-icons/pierre_c.png"), + claude: require("../assets/file-icons/pierre_claude.png"), + cpp: require("../assets/file-icons/pierre_cpp.png"), + css: require("../assets/file-icons/pierre_css.png"), + database: require("../assets/file-icons/pierre_database.png"), + default: require("../assets/file-icons/pierre_default.png"), + docker: require("../assets/file-icons/pierre_docker.png"), + eslint: require("../assets/file-icons/pierre_eslint.png"), + font: require("../assets/file-icons/pierre_font.png"), + git: require("../assets/file-icons/pierre_git.png"), + go: require("../assets/file-icons/pierre_go.png"), + graphql: require("../assets/file-icons/pierre_graphql.png"), + html: require("../assets/file-icons/pierre_html.png"), + image: require("../assets/file-icons/pierre_image.png"), + javascript: require("../assets/file-icons/pierre_javascript.png"), + json: require("../assets/file-icons/pierre_json.png"), + markdown: require("../assets/file-icons/pierre_markdown.png"), + mcp: require("../assets/file-icons/pierre_mcp.png"), + nextjs: require("../assets/file-icons/pierre_nextjs.png"), + npm: require("../assets/file-icons/pierre_npm.png"), + oxc: require("../assets/file-icons/pierre_oxc.png"), + package: require("../assets/file-icons/pierre_package.png"), + pnpm: require("../assets/file-icons/pierre_pnpm.png"), + postcss: require("../assets/file-icons/pierre_postcss.png"), + prettier: require("../assets/file-icons/pierre_prettier.png"), + python: require("../assets/file-icons/pierre_python.png"), + react: require("../assets/file-icons/pierre_react.png"), + readme: require("../assets/file-icons/pierre_readme.png"), + ruby: require("../assets/file-icons/pierre_ruby.png"), + rust: require("../assets/file-icons/pierre_rust.png"), + sass: require("../assets/file-icons/pierre_sass.png"), + stylelint: require("../assets/file-icons/pierre_stylelint.png"), + svelte: require("../assets/file-icons/pierre_svelte.png"), + svg: require("../assets/file-icons/pierre_svg.png"), + svgo: require("../assets/file-icons/pierre_svgo.png"), + swift: require("../assets/file-icons/pierre_swift.png"), + table: require("../assets/file-icons/pierre_table.png"), + tailwind: require("../assets/file-icons/pierre_tailwind.png"), + terraform: require("../assets/file-icons/pierre_terraform.png"), + text: require("../assets/file-icons/pierre_text.png"), + tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), + typescript: require("../assets/file-icons/pierre_typescript.png"), + vite: require("../assets/file-icons/pierre_vite.png"), + vscode: require("../assets/file-icons/pierre_vscode.png"), + vue: require("../assets/file-icons/pierre_vue.png"), + wasm: require("../assets/file-icons/pierre_wasm.png"), + webpack: require("../assets/file-icons/pierre_webpack.png"), + yml: require("../assets/file-icons/pierre_yml.png"), + zig: require("../assets/file-icons/pierre_zig.png"), + zip: require("../assets/file-icons/pierre_zip.png"), +} as const satisfies Readonly>; diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts new file mode 100644 index 00000000000..94b08c1de7e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts @@ -0,0 +1,8 @@ +import type { ImageSourcePropType } from "react-native"; + +import type { MarkdownFileIcon } from "./markdownLinks"; +import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; + +export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType { + return MARKDOWN_FILE_ICON_SOURCES[icon]; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts new file mode 100644 index 00000000000..affd7515b25 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -0,0 +1,349 @@ +import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; + +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; +const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; +const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const POSIX_FILE_ROOT_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/etc/", + "/opt/", + "/mnt/", + "/Volumes/", + "/private/", + "/root/", +] as const; + +export type MarkdownLinkPresentation = + | { + readonly kind: "external"; + readonly href: string; + readonly host: string; + } + | { + readonly kind: "file"; + readonly icon: MarkdownFileIcon; + readonly label: string; + } + | { + readonly kind: "link"; + readonly href: string | null; + }; + +export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; + +const FILE_ICON_BY_NAME: Readonly> = { + ".babelrc": "babel", + ".babelrc.json": "babel", + ".bash_profile": "bash", + ".bashrc": "bash", + ".browserslistrc": "browserslist", + ".dockerignore": "docker", + ".eslintignore": "eslint", + ".eslintrc": "eslint", + ".eslintrc.cjs": "eslint", + ".eslintrc.js": "eslint", + ".eslintrc.json": "eslint", + ".eslintrc.yaml": "eslint", + ".eslintrc.yml": "eslint", + ".gitattributes": "git", + ".gitignore": "git", + ".gitkeep": "git", + ".gitmodules": "git", + ".oxlintrc.json": "oxc", + ".postcssrc": "postcss", + ".postcssrc.json": "postcss", + ".postcssrc.yaml": "postcss", + ".postcssrc.yml": "postcss", + ".prettierignore": "prettier", + ".prettierrc": "prettier", + ".prettierrc.json": "prettier", + ".prettierrc.cjs": "prettier", + ".prettierrc.js": "prettier", + ".prettierrc.mjs": "prettier", + ".prettierrc.toml": "prettier", + ".prettierrc.yaml": "prettier", + ".prettierrc.yml": "prettier", + ".stylelintignore": "stylelint", + ".stylelintrc": "stylelint", + ".stylelintrc.cjs": "stylelint", + ".stylelintrc.js": "stylelint", + ".stylelintrc.json": "stylelint", + ".stylelintrc.mjs": "stylelint", + ".stylelintrc.yaml": "stylelint", + ".stylelintrc.yml": "stylelint", + ".terraform.lock.hcl": "terraform", + ".zprofile": "bash", + ".zshenv": "bash", + ".zshrc": "bash", + "agents.md": "agents", + "babel.config.js": "babel", + "babel.config.cjs": "babel", + "babel.config.json": "babel", + "babel.config.mjs": "babel", + "biome.json": "biome", + "biome.jsonc": "biome", + "bun.lock": "bun", + "bun.lockb": "bun", + "bunfig.toml": "bun", + "claude.md": "claude", + "compose.yaml": "docker", + "compose.yml": "docker", + "docker-compose.yaml": "docker", + "docker-compose.yml": "docker", + "docker-compose.override.yml": "docker", + dockerfile: "docker", + "eslint.config.js": "eslint", + "eslint.config.cjs": "eslint", + "eslint.config.mjs": "eslint", + "eslint.config.mts": "eslint", + "eslint.config.ts": "eslint", + gemfile: "ruby", + "next.config.js": "nextjs", + "next.config.mjs": "nextjs", + "next.config.mts": "nextjs", + "next.config.ts": "nextjs", + "package.json": "package", + "pnpm-lock.yaml": "pnpm", + "pnpm-workspace.yaml": "pnpm", + "postcss.config.js": "postcss", + "postcss.config.cjs": "postcss", + "postcss.config.mjs": "postcss", + "postcss.config.ts": "postcss", + "prettier.config.js": "prettier", + "prettier.config.cjs": "prettier", + "prettier.config.mjs": "prettier", + rakefile: "ruby", + "readme.md": "readme", + "stylelint.config.js": "stylelint", + "stylelint.config.cjs": "stylelint", + "stylelint.config.mjs": "stylelint", + "svgo.config.js": "svgo", + "svgo.config.cjs": "svgo", + "svgo.config.mjs": "svgo", + "svgo.config.ts": "svgo", + "tailwind.config.js": "tailwind", + "tailwind.config.cjs": "tailwind", + "tailwind.config.mjs": "tailwind", + "tailwind.config.ts": "tailwind", + "tsconfig.json": "tsconfig", + "vite.config.js": "vite", + "vite.config.mjs": "vite", + "vite.config.mts": "vite", + "vite.config.ts": "vite", + "webpack.config.js": "webpack", + "webpack.config.babel.js": "webpack", + "webpack.config.cjs": "webpack", + "webpack.config.mjs": "webpack", + "webpack.config.ts": "webpack", +}; + +const FILE_ICON_BY_EXTENSION: Readonly> = { + "7z": "zip", + astro: "astro", + avif: "image", + "code-workspace": "vscode", + bash: "bash", + bmp: "image", + bz2: "zip", + c: "c", + cc: "cpp", + cpp: "cpp", + cxx: "cpp", + css: "css", + csv: "table", + cts: "typescript", + db: "database", + env: "text", + "env.development": "text", + "env.local": "text", + "env.production": "text", + eot: "font", + erb: "ruby", + fish: "bash", + gif: "image", + go: "go", + gql: "graphql", + graphql: "graphql", + gz: "zip", + h: "c", + hh: "cpp", + hpp: "cpp", + hxx: "cpp", + htm: "html", + html: "html", + ico: "image", + icns: "image", + ini: "text", + inl: "cpp", + jar: "zip", + jpeg: "image", + jpg: "image", + js: "javascript", + jsx: "react", + json: "json", + jsonc: "json", + less: "css", + md: "markdown", + mdx: "markdown", + "mdx.tsx": "markdown", + mjs: "javascript", + mts: "typescript", + png: "image", + postcss: "css", + py: "python", + pyi: "python", + pyw: "python", + pyx: "python", + rake: "ruby", + rar: "zip", + rb: "ruby", + rs: "rust", + sass: "sass", + scss: "sass", + sh: "bash", + sql: "database", + sqlite: "database", + sqlite3: "database", + svelte: "svelte", + svg: "svg", + swift: "swift", + tar: "zip", + tf: "terraform", + tfstate: "terraform", + tfvars: "terraform", + tgz: "zip", + ts: "typescript", + tsv: "table", + tsx: "react", + txt: "text", + woff: "font", + woff2: "font", + vue: "vue", + wasm: "wasm", + webp: "image", + yml: "yml", + yaml: "yml", + zig: "zig", + zip: "zip", + zsh: "bash", +}; + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function normalizeDestination(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; +} + +function fileUrlPath(href: string): string | null { + try { + const parsed = new URL(href); + if (parsed.protocol.toLowerCase() !== "file:") { + return null; + } + const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) + ? parsed.pathname.slice(1) + : parsed.pathname; + const lineMatch = parsed.hash.match(/^#L(\d+)(?:C(\d+))?$/i); + return `${safeDecode(path)}${ + lineMatch?.[1] ? `:${lineMatch[1]}${lineMatch[2] ? `:${lineMatch[2]}` : ""}` : "" + }`; + } catch { + return null; + } +} + +function looksLikePosixFilesystemPath(path: string): boolean { + if (!path.startsWith("/")) { + return false; + } + if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) { + return true; + } + if (POSITION_SUFFIX_PATTERN.test(path)) { + return true; + } + const basename = path.slice(path.lastIndexOf("/") + 1); + return /\.[A-Za-z0-9_-]+$/.test(basename); +} + +function looksLikeFilePath(value: string): boolean { + if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) { + return true; + } + if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) { + return true; + } + if (value.startsWith("/")) { + return looksLikePosixFilesystemPath(value); + } + if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { + return true; + } + return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); +} + +function fileLabel(value: string): string { + const normalized = value.replaceAll("\\", "/"); + const basename = normalized.slice(normalized.lastIndexOf("/") + 1); + return basename || normalized; +} + +export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { + const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + const exactIcon = FILE_ICON_BY_NAME[basename]; + if (exactIcon) return exactIcon; + if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { + return "tsconfig"; + } + const segments = basename.split("."); + for (let index = 1; index < segments.length; index += 1) { + const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")]; + if (icon) return icon; + } + return "default"; +} + +export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { + const normalized = normalizeDestination(href); + try { + const parsed = new URL(normalized); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return { + kind: "external", + href: parsed.toString(), + host: parsed.hostname, + }; + } + } catch { + // Relative paths and non-URL link destinations are handled below. + } + + const fileTarget = normalized.toLowerCase().startsWith("file:") + ? fileUrlPath(normalized) + : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized); + if (fileTarget && looksLikeFilePath(fileTarget)) { + return { + kind: "file", + icon: resolveMarkdownFileIcon(fileTarget), + label: fileLabel(fileTarget), + }; + } + + return { + kind: "link", + href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, + }; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts new file mode 100644 index 00000000000..6751e165f1c --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -0,0 +1,751 @@ +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; +import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; + +export interface NativeMarkdownTextRun { + readonly text: string; + readonly bold?: boolean; + readonly italic?: boolean; + readonly strikethrough?: boolean; + readonly code?: boolean; + readonly href?: string; + readonly externalHost?: string; + readonly fileIcon?: MarkdownFileIcon; + readonly skillName?: string; + readonly skillLabel?: string; + readonly role?: + | "body" + | "heading" + | "list-marker" + | "list-break" + | "quote-marker" + | "code-block" + | "code-language" + | "divider" + | "spacer"; + readonly headingLevel?: number; + readonly depth?: number; + readonly spacing?: number; + readonly firstLineHeadIndent?: number; + readonly headIndent?: number; + readonly paragraphSpacing?: number; +} + +export type NativeMarkdownDocumentChunk = + | { + readonly kind: "selectable"; + readonly key: string; + readonly node: MarkdownNode; + } + | { + readonly kind: "rich"; + readonly key: string; + readonly node: MarkdownNode; + }; + +interface RunContext { + readonly bold: boolean; + readonly italic: boolean; + readonly strikethrough: boolean; + readonly code: boolean; + readonly href?: string; + readonly externalHost?: string; + readonly fileIcon?: MarkdownFileIcon; + readonly role?: NativeMarkdownTextRun["role"]; + readonly headingLevel?: number; + readonly depth?: number; + readonly spacing?: number; + readonly firstLineHeadIndent?: number; + readonly headIndent?: number; + readonly paragraphSpacing?: number; +} + +const EMPTY_CONTEXT: RunContext = { + bold: false, + italic: false, + strikethrough: false, + code: false, +}; + +const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; + +function decodeHtmlEntitiesOnce(value: string): string { + return value.replace( + /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, + (entity, decimal: string | undefined, hexadecimal: string | undefined) => { + if (decimal) { + return String.fromCodePoint(Number.parseInt(decimal, 10)); + } + if (hexadecimal) { + return String.fromCodePoint(Number.parseInt(hexadecimal, 16)); + } + switch (entity.toLowerCase()) { + case "&": + return "&"; + case "'": + return "'"; + case ">": + return ">"; + case "<": + return "<"; + case " ": + return "\u00a0"; + case """: + return '"'; + default: + return entity; + } + }, + ); +} + +function decodeHtmlEntities(value: string): string { + let decoded = value; + for (let pass = 0; pass < 2; pass += 1) { + const next = decodeHtmlEntitiesOnce(decoded); + if (next === decoded) { + break; + } + decoded = next; + } + return decoded; +} + +function textNodeContent(value: string): string { + return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); +} + +function inlineHtmlText(value: string): string { + if (/^$/i.test(value.trim())) { + return "\n"; + } + return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); +} + +function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { + return ( + left.bold === right.bold && + left.italic === right.italic && + left.strikethrough === right.strikethrough && + left.code === right.code && + left.href === right.href && + left.externalHost === right.externalHost && + left.fileIcon === right.fileIcon && + left.skillName === right.skillName && + left.skillLabel === right.skillLabel && + left.role === right.role && + left.headingLevel === right.headingLevel && + left.depth === right.depth && + left.spacing === right.spacing && + left.firstLineHeadIndent === right.firstLineHeadIndent && + left.headIndent === right.headIndent && + left.paragraphSpacing === right.paragraphSpacing + ); +} + +function appendRun( + runs: NativeMarkdownTextRun[], + text: string, + context: RunContext, +): NativeMarkdownTextRun[] { + if (text.length === 0) { + return runs; + } + + const run: NativeMarkdownTextRun = { + text, + ...(context.bold ? { bold: true } : {}), + ...(context.italic ? { italic: true } : {}), + ...(context.strikethrough ? { strikethrough: true } : {}), + ...(context.code ? { code: true } : {}), + ...(context.href ? { href: context.href } : {}), + ...(context.externalHost ? { externalHost: context.externalHost } : {}), + ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), + ...(context.role ? { role: context.role } : {}), + ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), + ...(context.depth ? { depth: context.depth } : {}), + ...(context.spacing ? { spacing: context.spacing } : {}), + ...(context.firstLineHeadIndent !== undefined + ? { firstLineHeadIndent: context.firstLineHeadIndent } + : {}), + ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}), + ...(context.paragraphSpacing !== undefined + ? { paragraphSpacing: context.paragraphSpacing } + : {}), + }; + const previous = runs.at(-1); + if (previous && sameRunStyle(previous, run)) { + runs[runs.length - 1] = { ...previous, text: previous.text + run.text }; + return runs; + } + + runs.push(run); + return runs; +} + +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +function formatSkillLabel(skill: SelectableMarkdownSkill): string { + const displayName = skill.displayName?.trim(); + if (displayName) { + return displayName; + } + return skill.name + .split(/[\s:_-]+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function decorateSkillRuns( + runs: ReadonlyArray, + skills: ReadonlyArray, +): ReadonlyArray { + if (skills.length === 0) { + return runs; + } + const skillByName = new Map(skills.map((skill) => [skill.name, skill])); + const decorated: NativeMarkdownTextRun[] = []; + + for (const run of runs) { + if (run.code || run.href || run.fileIcon || run.role === "code-block") { + decorated.push(run); + continue; + } + + let cursor = 0; + let matched = false; + for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { + const prefix = match[1] ?? ""; + const name = match[2] ?? ""; + const skill = skillByName.get(name); + if (!skill) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + name.length + 1; + if (start > cursor) { + decorated.push({ ...run, text: run.text.slice(cursor, start) }); + } + decorated.push({ + ...run, + text: run.text.slice(start, end), + skillName: name, + skillLabel: formatSkillLabel(skill), + }); + cursor = end; + matched = true; + } + if (!matched) { + decorated.push(run); + } else if (cursor < run.text.length) { + decorated.push({ ...run, text: run.text.slice(cursor) }); + } + } + + return decorated; +} + +function appendChildren( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + for (const child of node.children ?? []) { + appendNode(runs, child, context); + } + return runs; +} + +function nodeTextContent(node: MarkdownNode): string { + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(nodeTextContent).join(""); +} + +function appendNode( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + switch (node.type) { + case "text": + case "math_inline": + return appendRun(runs, textNodeContent(nodeTextContent(node)), context); + case "html_inline": + return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); + case "code_inline": + return appendRun(runs, nodeTextContent(node), { ...context, code: true }); + case "soft_break": + return appendRun(runs, " ", context); + case "line_break": + return appendRun(runs, "\n", context); + case "bold": + return appendChildren(runs, node, { ...context, bold: true }); + case "italic": + return appendChildren(runs, node, { ...context, italic: true }); + case "strikethrough": + return appendChildren(runs, node, { ...context, strikethrough: true }); + case "link": { + const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); + if (presentation.kind === "file") { + return appendRun(runs, presentation.label, { + ...context, + fileIcon: presentation.icon, + }); + } + if (presentation.kind === "external") { + return appendChildren(runs, node, { + ...context, + href: presentation.href, + externalHost: presentation.host, + }); + } + return appendChildren(runs, node, { + ...context, + ...(presentation.href ? { href: presentation.href } : {}), + }); + } + case "image": + return appendRun(runs, node.alt ?? node.title ?? "", context); + default: + return appendChildren(runs, node, context); + } +} + +export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray { + return appendChildren([], node, EMPTY_CONTEXT); +} + +function appendBlockTerminator( + runs: NativeMarkdownTextRun[], + context: RunContext, +): NativeMarkdownTextRun[] { + return appendRun(runs, "\n", context); +} + +function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] { + return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing }); +} + +function appendInlineChildren( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + for (const child of node.children ?? []) { + appendNode(runs, child, context); + } + return runs; +} + +function isInlineNode(node: MarkdownNode): boolean { + return ( + node.type === "text" || + node.type === "bold" || + node.type === "italic" || + node.type === "strikethrough" || + node.type === "link" || + node.type === "image" || + node.type === "code_inline" || + node.type === "math_inline" || + node.type === "html_inline" || + node.type === "soft_break" || + node.type === "line_break" + ); +} + +export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray { + const blocks: MarkdownNode[] = []; + let inlineNodes: MarkdownNode[] = []; + const flushInlineNodes = () => { + if (inlineNodes.length === 0) { + return; + } + blocks.push({ type: "paragraph", children: inlineNodes }); + inlineNodes = []; + }; + + for (const child of node.children ?? []) { + if (isInlineNode(child)) { + inlineNodes.push(child); + continue; + } + + flushInlineNodes(); + blocks.push(child); + } + flushInlineNodes(); + return blocks; +} + +function appendListItem( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + marker: string, + depth: number, + markerColumnWidth: number, +): NativeMarkdownTextRun[] { + const firstLineHeadIndent = Math.max(0, depth - 1) * 20; + appendRun(runs, `${marker}\t`, { + ...EMPTY_CONTEXT, + role: "list-marker", + depth, + firstLineHeadIndent, + headIndent: firstLineHeadIndent + markerColumnWidth, + paragraphSpacing: 2, + }); + + const children = node.children ?? []; + let wroteInlineContent = false; + for (const child of children) { + if (child.type === "paragraph") { + appendInlineChildren(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + wroteInlineContent = true; + continue; + } + if (child.type === "list") { + if (wroteInlineContent) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "list-break", + depth, + spacing: 1, + }); + } + appendList(runs, child, depth + 1); + wroteInlineContent = false; + continue; + } + if (isInlineNode(child)) { + appendNode(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + wroteInlineContent = true; + continue; + } + appendDocumentBlock(runs, child, depth); + wroteInlineContent = true; + } + + if (wroteInlineContent) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "list-break", + depth, + spacing: depth === 1 ? 4 : 2, + }); + } + return runs; +} + +function appendList( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const ordered = node.ordered ?? false; + const start = node.start ?? 1; + const children = node.children ?? []; + const markers = children.map((child, index) => + child.type === "task_list_item" + ? child.checked + ? "☑︎" + : "☐︎" + : ordered + ? `${start + index}.` + : depth % 3 === 2 + ? "◦" + : depth % 3 === 0 + ? "▪︎" + : "•", + ); + const markerWidth = ordered + ? Math.max(0, ...markers.map((marker) => Array.from(marker).length)) + : 0; + + for (const [index, child] of children.entries()) { + const marker = markers[index] ?? "•"; + const alignedMarker = + child.type === "task_list_item" + ? marker + : ordered + ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}` + : marker; + const markerColumnWidth = + child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; + appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); + } + return runs; +} + +function appendQuoteBlock( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + for (const [index, child] of (node.children ?? []).entries()) { + if (index > 0) { + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + } + appendRun(runs, "│\u00a0", { + ...EMPTY_CONTEXT, + role: "quote-marker", + depth, + }); + if (child.type === "paragraph") { + appendInlineChildren(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + } else { + appendDocumentBlock(runs, child, depth); + } + } + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + return runs; +} + +function appendTableRow( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const cells = node.children ?? []; + for (const [index, cell] of cells.entries()) { + if (index > 0) { + appendRun(runs, "\u00a0│\u00a0", { + ...EMPTY_CONTEXT, + role: "divider", + depth, + }); + } + appendInlineChildren(runs, cell, { + ...EMPTY_CONTEXT, + role: "body", + bold: cell.isHeader ?? false, + depth, + }); + } + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + return runs; +} + +function appendTable( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const visit = (child: MarkdownNode) => { + if (child.type === "table_row") { + appendTableRow(runs, child, depth); + return; + } + for (const nested of child.children ?? []) { + visit(nested); + } + }; + visit(node); + return runs; +} + +function appendDocumentBlock( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth = 0, +): NativeMarkdownTextRun[] { + switch (node.type) { + case "document": { + const children = node.children ?? []; + for (const [index, child] of children.entries()) { + if (index > 0) { + const previous = children[index - 1]; + appendSpacer( + runs, + child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, + ); + } + appendDocumentBlock(runs, child, depth); + } + return runs; + } + case "heading": { + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "heading", + headingLevel: node.level ?? 1, + depth, + }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } + case "paragraph": { + const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } + case "list": + return appendList(runs, node, depth + 1); + case "blockquote": + return appendQuoteBlock(runs, node, depth); + case "code_block": { + if (node.language) { + appendRun(runs, `${node.language.toUpperCase()}\n`, { + ...EMPTY_CONTEXT, + role: "code-language", + code: true, + depth, + }); + } + const content = nodeTextContent(node); + appendRun(runs, content, { + ...EMPTY_CONTEXT, + role: "code-block", + code: true, + depth, + }); + if (!content.endsWith("\n")) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "code-block", + code: true, + depth, + }); + } + return runs; + } + case "horizontal_rule": + appendRun(runs, "────────────────────────\n", { + ...EMPTY_CONTEXT, + role: "divider", + depth, + }); + return runs; + case "table": + return appendTable(runs, node, depth); + case "html_block": + appendRun(runs, inlineHtmlText(nodeTextContent(node)), { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + case "math_block": + appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + default: + appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + } +} + +function containsRichBlock(node: MarkdownNode): boolean { + if ( + node.type === "code_block" || + node.type === "table" || + node.type === "image" || + node.type === "horizontal_rule" || + node.type === "html_block" || + node.type === "math_block" + ) { + return true; + } + return (node.children ?? []).some(containsRichBlock); +} + +export function nativeMarkdownDocumentChunks( + document: MarkdownNode, +): ReadonlyArray { + const chunks: NativeMarkdownDocumentChunk[] = []; + let selectableNodes: MarkdownNode[] = []; + + const flushSelectable = () => { + if (selectableNodes.length === 0) { + return; + } + const first = selectableNodes[0]; + const last = selectableNodes.at(-1); + chunks.push({ + kind: "selectable", + key: `selectable:${first?.beg ?? "start"}:${last?.end ?? "end"}`, + node: { + type: "document", + children: selectableNodes, + }, + }); + selectableNodes = []; + }; + + for (const [index, child] of (document.children ?? []).entries()) { + if (!containsRichBlock(child)) { + selectableNodes.push(child); + continue; + } + + flushSelectable(); + chunks.push({ + kind: "rich", + key: `rich:${child.type}:${child.beg ?? index}:${child.end ?? index}`, + node: child, + }); + } + flushSelectable(); + return chunks; +} + +function topLevelNodes(node: MarkdownNode): ReadonlyArray { + return node.type === "document" ? (node.children ?? []) : [node]; +} + +export function nativeMarkdownChunkSpacing( + previous: NativeMarkdownDocumentChunk | undefined, + current: NativeMarkdownDocumentChunk, +): number { + if (!previous) { + return 0; + } + + const previousLast = topLevelNodes(previous.node).at(-1); + const currentFirst = topLevelNodes(current.node)[0]; + + if (currentFirst?.type === "heading") { + return 20; + } + if (previousLast?.type === "heading") { + return 10; + } + if (previousLast?.type === "list" && currentFirst?.type === "list") { + return 12; + } + return 14; +} + +export function nativeMarkdownDocumentRuns( + node: MarkdownNode, + skills: ReadonlyArray = [], +): ReadonlyArray { + const runs = appendDocumentBlock([], node); + while (runs.length > 0) { + const lastIndex = runs.length - 1; + const last = runs[lastIndex]; + if (!last?.text.endsWith("\n")) { + break; + } + const text = last.text.slice(0, -1); + if (text.length === 0) { + runs.pop(); + } else { + runs[lastIndex] = { ...last, text }; + } + } + return decorateSkillRuns(runs, skills); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/util.ts b/apps/mobile/modules/t3-markdown-text/src/util.ts new file mode 100644 index 00000000000..d9f33d3a2ef --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/util.ts @@ -0,0 +1,62 @@ +import { type StyleProp, StyleSheet, type TextStyle } from "react-native"; +import type { NativeFontWeight } from "./T3MarkdownTextRunNativeComponent"; + +export function flattenStyles(rootStyle: TextStyle, style: StyleProp) { + const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle; + return { + ...flattenedStyle, + fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"), + backgroundColor: flattenedStyle.backgroundColor + ? flattenedStyle.backgroundColor + : "transparent", + shadowOffset: flattenedStyle.shadowOffset + ? flattenedStyle.shadowOffset + : { width: 0, height: 0 }, + }; +} + +// Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop +// value before returning flattened styles. +function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight { + switch (fontWeight) { + case "normal": + return "normal"; + case "bold": + return "bold"; + case 100: + case "100": + case "ultralight": + return "ultraLight"; + case 200: + case "200": + return "ultraLight"; + case 300: + case "300": + case "light": + return "light"; + case 400: + case "400": + case "regular": + return "normal"; + case 500: + case "500": + case "medium": + return "medium"; + case 600: + case "600": + case "semibold": + return "semibold"; + case 700: + case "700": + return "semibold"; + case 800: + case "800": + return "bold"; + case 900: + case "900": + case "heavy": + return "heavy"; + default: + return "normal"; + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4b08a338e12..47efc95adb1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -34,12 +34,13 @@ "config:preview": "APP_VARIANT=preview expo config", "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", + "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs", "test": "vp test run", "typecheck": "tsc --noEmit" }, "dependencies": { "@callstack/liquid-glass": "^0.7.1", - "@clerk/expo": "^3.3.0", + "@clerk/expo": "^3.4.1", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", @@ -54,6 +55,7 @@ "@shikijs/themes": "3.23.0", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/shared": "workspace:*", @@ -61,6 +63,7 @@ "diff": "8.0.3", "effect": "catalog:", "expo": "^56.0.0", + "expo-asset": "~56.0.15", "expo-auth-session": "~56.0.12", "expo-build-properties": "~56.0.15", "expo-camera": "~56.0.7", @@ -104,6 +107,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@pierre/trees": "1.0.0-beta.4", "@types/react": "~19.2.0", "babel-preset-expo": "~56.0.0", "tailwindcss": "^4.0.0", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 136e141fdcf..1583fdbb2d7 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -5,7 +5,9 @@ import { DMSans_700Bold, useFonts, } from "@expo-google-fonts/dm-sans"; +import { usePathname } from "expo-router"; import Stack from "expo-router/stack"; +import { useCallback } from "react"; import { StatusBar, useColorScheme } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; @@ -21,15 +23,41 @@ import { import { RegistryContext } from "@effect/atom-react"; import { appAtomRegistry } from "../state/atom-registry"; import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; +import { + ClerkSettingsSheetDetentProvider, + useClerkSettingsSheetDetent, +} from "../features/cloud/ClerkSettingsSheetDetent"; import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; +import { useThemeColor } from "../lib/useThemeColor"; function AppNavigator() { + const pathname = usePathname(); + const clerkRouteIsActive = pathname === "/settings/auth"; + + return ( + + + + ); +} + +function AppNavigatorContent() { const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const { collapse, isExpanded } = useClerkSettingsSheetDetent(); const colorScheme = useColorScheme(); - const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7"; + const statusBarBg = useThemeColor("--color-status-bar"); const sheetStyle = useResolveClassNames("bg-sheet"); useAgentNotificationNavigation(); + const handleSettingsTransitionEnd = useCallback( + (event: { data: { closing: boolean } }) => { + if (event.data.closing) { + collapse(); + } + }, + [collapse], + ); + const newTaskScreenOptions = { contentStyle: sheetStyle, gestureEnabled: true, @@ -50,7 +78,7 @@ function AppNavigator() { const settingsSheetScreenOptions = { ...connectionSheetScreenOptions, - sheetAllowedDetents: [0.7], + sheetAllowedDetents: isExpanded ? [0.92] : [0.7], }; if (isLoadingSavedConnection) { @@ -61,7 +89,7 @@ function AppNavigator() { <> @@ -74,7 +102,11 @@ function AppNavigator() { headerShadowVisible: false, }} /> - + @@ -54,7 +56,7 @@ export default function HomeRouteScreen() { diff --git a/apps/mobile/src/app/settings/_layout.tsx b/apps/mobile/src/app/settings/_layout.tsx index 087e07ba5fb..86831d885f1 100644 --- a/apps/mobile/src/app/settings/_layout.tsx +++ b/apps/mobile/src/app/settings/_layout.tsx @@ -1,16 +1,27 @@ import Stack from "expo-router/stack"; -import { useColorScheme } from "react-native"; +import { useCallback } from "react"; import { useResolveClassNames } from "uniwind"; +import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; +import { useThemeColor } from "../../lib/useThemeColor"; + export const unstable_settings = { anchor: "index", }; export default function SettingsLayout() { + const { collapse } = useClerkSettingsSheetDetent(); const contentStyle = useResolveClassNames("bg-sheet"); - const isDark = useColorScheme() === "dark"; - const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; - const headerTint = isDark ? "#f5f5f5" : "#262626"; + const sheetBg = useThemeColor("--color-sheet"); + const headerTint = useThemeColor("--color-foreground"); + const handleClerkRouteTransitionEnd = useCallback( + (event: { data: { closing: boolean } }) => { + if (event.data.closing) { + collapse(); + } + }, + [collapse], + ); return ( + ); } diff --git a/apps/mobile/src/app/settings/auth.tsx b/apps/mobile/src/app/settings/auth.tsx new file mode 100644 index 00000000000..de33207ccda --- /dev/null +++ b/apps/mobile/src/app/settings/auth.tsx @@ -0,0 +1,33 @@ +import { useAuth } from "@clerk/expo"; +import { AuthView, UserProfileView } from "@clerk/expo/native"; +import { Redirect, Stack } from "expo-router"; +import { View } from "react-native"; + +import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; + +export default function SettingsAuthRouteScreen() { + return hasCloudPublicConfig() ? ( + + ) : ( + + ); +} + +function ConfiguredSettingsAuthRouteScreen() { + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + + return ( + <> + + + {isLoaded ? ( + isSignedIn ? ( + + ) : ( + + ) + ) : null} + + + ); +} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 85d2699c76f..c8b4cd40995 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -13,6 +13,7 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; +import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions, @@ -66,6 +67,7 @@ function LocalSettingsRouteScreen() { function ConfiguredSettingsRouteScreen() { const insets = useSafeAreaInsets(); const { push } = useRouter(); + const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); const { savedConnectionsById } = useRemoteEnvironmentState(); @@ -265,11 +267,9 @@ function ConfiguredSettingsRouteScreen() { push("/settings/waitlist"); return; } - Alert.alert( - "T3 Cloud unavailable", - "Native T3 Cloud account management is not available in this build.", - ); - }, [isLoaded, isSignedIn, push]); + expandClerkSheet(); + push("/settings/auth"); + }, [expandClerkSheet, isLoaded, isSignedIn, push]); return ( @@ -335,7 +335,7 @@ type SymbolName = ComponentProps["name"]; function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { return ( - {props.title} + {props.title} { + if (isLoaded && isSignedIn) { + router.replace("/settings"); + } + }, [isLoaded, isSignedIn, router]), + ); return ( <> @@ -31,7 +43,12 @@ function ConfiguredSettingsWaitlistRouteScreen() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - void presentAuth()} /> + { + expand(); + router.push("/settings/auth"); + }} + /> ); diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx new file mode 100644 index 00000000000..0c596e29232 --- /dev/null +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -0,0 +1,6 @@ +export { ComposerEditor } from "../native/T3ComposerEditor"; +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "../native/T3ComposerEditor"; diff --git a/apps/mobile/src/components/CopyTextButton.tsx b/apps/mobile/src/components/CopyTextButton.tsx new file mode 100644 index 00000000000..712b272a909 --- /dev/null +++ b/apps/mobile/src/components/CopyTextButton.tsx @@ -0,0 +1,68 @@ +import { SymbolView } from "expo-symbols"; +import { memo, useEffect, useRef, useState } from "react"; +import { Pressable, type ColorValue } from "react-native"; + +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; + +const COPY_FEEDBACK_DURATION_MS = 1200; + +export const CopyTextButton = memo(function CopyTextButton(props: { + readonly accessibilityLabel: string; + readonly text: string; + readonly tintColor: ColorValue; + readonly copiedTintColor?: ColorValue; + readonly backgroundColor?: ColorValue; + readonly borderColor?: ColorValue; + readonly iconSize?: number; + readonly buttonSize?: number; +}) { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }, + [], + ); + + return ( + { + copyTextWithHaptic(props.text); + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_FEEDBACK_DURATION_MS); + }} + style={({ pressed }) => ({ + width: props.buttonSize ?? 30, + height: props.buttonSize ?? 30, + alignItems: "center", + justifyContent: "center", + borderRadius: 9, + borderWidth: props.borderColor ? 1 : 0, + borderColor: props.borderColor, + backgroundColor: props.backgroundColor, + opacity: pressed ? 0.52 : 1, + })} + > + + + ); +}); diff --git a/apps/mobile/src/components/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx index f7cc49c368e..836a7cffbd7 100644 --- a/apps/mobile/src/components/GlassSafeAreaView.tsx +++ b/apps/mobile/src/components/GlassSafeAreaView.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; -import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native"; +import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColor } from "../lib/useThemeColor"; import { GlassSurface } from "./GlassSurface"; @@ -17,14 +18,16 @@ export function GlassSafeAreaView({ rightSlot, style, }: GlassSafeAreaViewProps) { - const isDarkMode = useColorScheme() === "dark"; const insets = useSafeAreaInsets(); + const headerColor = useThemeColor("--color-header"); + const headerBorderColor = useThemeColor("--color-header-border"); + const glassTint = useThemeColor("--color-glass-tint"); const headerPaddingTop = insets.top + 16; const surfaceStyle = { borderRadius: 0, - backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)", + backgroundColor: headerColor, borderBottomWidth: 1, - borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)", + borderBottomColor: headerBorderColor, } as const; return ( @@ -32,7 +35,7 @@ export function GlassSafeAreaView({ diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx new file mode 100644 index 00000000000..15ea24331b0 --- /dev/null +++ b/apps/mobile/src/components/PierreEntryIcon.tsx @@ -0,0 +1,25 @@ +import { SymbolView } from "expo-symbols"; +import { Image, type ImageStyle, type StyleProp } from "react-native"; + +import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; + +export function PierreEntryIcon(props: { + readonly path: string; + readonly kind: "file" | "directory"; + readonly size?: number; + readonly style?: StyleProp; +}) { + const size = props.size ?? 16; + if (props.kind === "directory") { + return ; + } + + return ( + + ); +} diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index d62f8d9a4bf..6c1b1038698 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -24,7 +24,7 @@ export function ProviderIcon(props: ProviderIconProps) { return ( diff --git a/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx new file mode 100644 index 00000000000..8bd51b8518d --- /dev/null +++ b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx @@ -0,0 +1,44 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +interface ClerkSettingsSheetDetentValue { + collapse: () => void; + expand: () => void; + isExpanded: boolean; +} + +const ClerkSettingsSheetDetentContext = createContext(null); + +interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren { + initiallyExpanded: boolean; +} + +export function ClerkSettingsSheetDetentProvider({ + children, + initiallyExpanded, +}: ClerkSettingsSheetDetentProviderProps) { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded); + const collapse = useCallback(() => setIsExpanded(false), []); + const expand = useCallback(() => setIsExpanded(true), []); + const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]); + + return ( + {children} + ); +} + +export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue { + const value = useContext(ClerkSettingsSheetDetentContext); + if (!value) { + throw new Error( + "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider", + ); + } + return value; +} diff --git a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts deleted file mode 100644 index 3356642776a..00000000000 --- a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { getClerkInstance } from "@clerk/expo"; -import { tokenCache } from "@clerk/expo/token-cache"; -import * as Data from "effect/Data"; -import { useCallback, useRef } from "react"; -import type { TurboModule } from "react-native"; -import { TurboModuleRegistry } from "react-native"; - -const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt"; - -interface NativeClerkModule extends TurboModule { - readonly getClientToken?: () => Promise; - readonly presentAuth?: (options: { - readonly dismissable: boolean; - readonly mode: "signInOrUp"; - }) => Promise; -} - -interface NativeAuthResult { - readonly cancelled?: boolean; - readonly session?: { - readonly id?: string; - }; - readonly sessionId?: string; -} - -interface ClerkWithNativeSync { - readonly __internal_reloadInitialResources?: () => Promise; - readonly setActive?: (params: { readonly session: string }) => Promise; -} - -const NativeClerk = TurboModuleRegistry.get("ClerkExpo"); - -class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -async function syncNativeSession(sessionId: string): Promise { - const getClientToken = NativeClerk?.getClientToken; - let nativeClientToken: string | null = null; - if (getClientToken) { - try { - nativeClientToken = await getClientToken(); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not read native Clerk client token.", - cause, - }); - } - } - if (nativeClientToken) { - const saveToken = tokenCache?.saveToken; - if (saveToken) { - try { - await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not save native Clerk client token.", - cause, - }); - } - } - } - - const clerk = getClerkInstance(); - const clerkWithNativeSync = clerk as ClerkWithNativeSync; - const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources; - if (reloadInitialResources) { - try { - await reloadInitialResources(); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not reload Clerk resources after native auth.", - cause, - }); - } - } - const setActive = clerkWithNativeSync.setActive; - if (setActive) { - try { - await setActive({ session: sessionId }); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not activate native Clerk session.", - cause, - }); - } - } -} - -export function useNativeClerkAuthModal() { - const presentingRef = useRef(false); - - const presentAuth = useCallback(async (): Promise => { - if (presentingRef.current || !NativeClerk?.presentAuth) { - return; - } - - presentingRef.current = true; - const presentNativeAuth = NativeClerk.presentAuth; - try { - // Clerk's iOS AuthView is not inline. It presents this same native modal - // internally; call the presenter directly so Expo Router does not render - // an empty formSheet behind it. - let result: NativeAuthResult | null; - try { - result = await presentNativeAuth({ - dismissable: true, - mode: "signInOrUp", - }); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Native Clerk auth presentation failed.", - cause, - }); - } - const sessionId = result?.sessionId ?? result?.session?.id ?? null; - if (sessionId && !result?.cancelled) { - await syncNativeSession(sessionId); - } - } catch (error) { - if (__DEV__) { - console.error("[useNativeClerkAuthModal] presentAuth failed:", error); - } - } finally { - presentingRef.current = false; - } - }, []); - - return { - isAvailable: !!NativeClerk?.presentAuth, - presentAuth, - }; -} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 00e4582957c..cdae41668a0 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -146,8 +146,8 @@ function ProjectGroupLabel(props: { bearerToken={props.bearerToken} /> {props.project.title} @@ -156,8 +156,8 @@ function ProjectGroupLabel(props: { {hiddenCount > 0 ? ( {props.isExpanded ? "Show less" : `${hiddenCount} more`} @@ -191,6 +191,7 @@ function ThreadRow(props: { readonly isLast: boolean; }) { const separatorColor = useThemeColor("--color-separator"); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); const { bg, fg } = statusColors(props.thread); const tone = threadStatusTone(props.thread); const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); @@ -267,7 +268,7 @@ function ThreadRow(props: { & Pick, @@ -119,3 +119,22 @@ describe("highlightReviewFile", () => { ]); }); }); + +describe("highlightCodeSnippet", () => { + it("resolves language aliases and returns syntax-colored tokens", async () => { + const source = "const answer: number = 42;"; + const highlighted = await highlightCodeSnippet({ + code: source, + language: "ts", + theme: "dark", + }); + + expect( + highlighted + .flat() + .map((token) => token.content) + .join(""), + ).toBe(source); + expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 8e254fbb0b3..d6d09221dac 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -685,6 +685,16 @@ async function highlightLines( return highlightedLines; } +export async function highlightCodeSnippet(input: { + readonly code: string; + readonly language?: string | null; + readonly theme: ReviewDiffTheme; +}): Promise>> { + const languageHint = input.language?.trim() || "text"; + const language = await resolveLanguageFromPath(`snippet.${languageHint}`, languageHint); + return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); +} + async function highlightPatchLinesInChunks(input: { readonly lines: ReadonlyArray; readonly language: string; diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 08746eb74e7..1b652a139cf 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -6,6 +6,7 @@ import { memo } from "react"; import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; export type ComposerCommandItem = | { @@ -88,13 +89,13 @@ function PopoverSurface(props: { function itemIcon(item: ComposerCommandItem) { switch (item.type) { - case "path": - return item.kind === "directory" ? ("folder" as const) : ("doc" as const); case "slash-command": case "provider-slash-command": return "terminal" as const; case "skill": return "cube" as const; + case "path": + return null; } } @@ -149,7 +150,11 @@ const CommandRow = memo(function CommandRow(props: { borderBottomColor: "rgba(255,255,255,0.1)", })} > - + {props.item.type === "path" ? ( + + ) : iconName ? ( + + ) : null} state.isVisible); const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; - const promptInputRef = useRef(null); + const promptInputRef = useRef(null); const borderColor = useThemeColor("--color-border"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; @@ -410,10 +403,6 @@ export function NewTaskDraftScreen(props: { [flow], ); - const handleNativePaste = useNativePaste((uris) => { - void handleNativePasteImages(uris); - }); - async function handleStart(): Promise { if ( !flow.selectedProject || @@ -478,23 +467,19 @@ export function NewTaskDraftScreen(props: { - void handleNativePaste(payload)} + void handleNativePasteImages(uris)} + placeholder={`Describe a coding task in ${selectedProject.title}`} style={{ flex: 1, minHeight: 0 }} - > - - + textStyle={{ fontSize: 18, lineHeight: 28 }} + /> ; @@ -87,6 +74,7 @@ export interface ThreadComposerProps { readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; + readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; @@ -156,10 +144,9 @@ function formatTitleCase(value: string): string { export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const isDarkMode = useColorScheme() === "dark"; - const themePlaceholderColor = useThemeColor("--color-placeholder"); - const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor; const foregroundColor = useThemeColor("--color-foreground"); - const inputRef = useRef(null); + const fallbackInputRef = useRef(null); + const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const wasExpandedBeforePreviewRef = useRef(false); const { onExpandedChange } = props; @@ -182,11 +169,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (wasExpandedBeforePreviewRef.current) { setTimeout(() => inputRef.current?.focus(), 100); } - }, []); + }, [inputRef]); - useEffect(() => { - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); + const handleFocus = useCallback(() => { + setIsFocused(true); + onExpandedChange?.(true); + }, [onExpandedChange]); + + const handleBlur = useCallback(() => { + setIsFocused(false); + onExpandedChange?.(false); + }, [onExpandedChange]); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting" || @@ -219,26 +212,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderDriver === "claudeAgent" ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") : "1M"; - - const handleNativePaste = useNativePaste((uris) => { - void props.onNativePasteImages(uris); - }); - // ── Trigger detection ──────────────────────────────────── - const [cursorPosition, setCursorPosition] = useState(0); + const [composerSelection, setComposerSelection] = useState(() => ({ + start: props.draftMessage.length, + end: props.draftMessage.length, + })); - const handleSelectionChange = useCallback( - (event: NativeSyntheticEvent) => { - const { start } = event.nativeEvent.selection; - setCursorPosition(start); - }, - [], - ); + const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { + setComposerSelection(selection); + }, []); + useEffect(() => { + const end = props.draftMessage.length; + setComposerSelection((selection) => { + const start = Math.min(selection.start, end); + const selectionEnd = Math.min(selection.end, end); + if (start === selection.start && selectionEnd === selection.end) { + return selection; + } + return { start, end: selectionEnd }; + }); + }, [props.draftMessage.length]); - const composerTrigger = useMemo( - () => detectComposerTrigger(props.draftMessage, cursorPosition), - [cursorPosition, props.draftMessage], - ); + const composerTrigger = useMemo(() => { + if (composerSelection.start !== composerSelection.end) { + return null; + } + return detectComposerTrigger(props.draftMessage, composerSelection.end); + }, [composerSelection, props.draftMessage]); const pathSearch = useComposerPathSearch({ environmentId: props.environmentId, cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, @@ -411,7 +411,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer composerTrigger.rangeEnd, "", ); - setCursorPosition(result.cursor); + setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); void onUpdateInteractionMode(item.command); return; @@ -434,7 +434,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer composerTrigger.rangeEnd, replacement, ); - setCursorPosition(result.cursor); + setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); }, [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], @@ -624,8 +624,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer - - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - textAlignVertical={isExpanded ? "top" : "center"} - style={ - isExpanded - ? { - minHeight: 80, - maxHeight: 160, - paddingHorizontal: 4, - paddingVertical: 4, - fontSize: 15, - lineHeight: 22, - color: foregroundColor, - fontFamily: "DMSans_400Regular", - } - : { - maxHeight: 36, - paddingVertical: 6, - fontSize: 15, - lineHeight: 20, - color: foregroundColor, - fontFamily: "DMSans_400Regular", - } - } - /> - + void props.onNativePasteImages(uris)} + placeholder={props.placeholder} + onFocus={handleFocus} + onBlur={handleBlur} + scrollEnabled={isExpanded} + contentInsetVertical={isExpanded ? 0 : 6} + style={ + isExpanded + ? { + minHeight: 80, + maxHeight: 160, + paddingHorizontal: 4, + paddingVertical: 4, + } + : { + height: 36, + } + } + textStyle={{ + fontSize: 15, + lineHeight: isExpanded ? 22 : 20, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + }} + /> {!isExpanded && props.draftAttachments.length > 0 ? ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 8e6050418fd..d035f6eb909 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -11,17 +11,20 @@ import type { } from "@t3tools/contracts"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; +import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, type LayoutChangeEvent } from "react-native"; +import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { runOnJS } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { MobileLayoutVariant } from "../../lib/mobileLayout"; +import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; import type { PendingApproval, PendingUserInput, @@ -33,7 +36,6 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, - COMPOSER_EXPANDED_TOOLBAR_CHROME, ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; @@ -200,7 +202,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const { onOpenDrawer } = props; const insets = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; + const composerRef = useRef(null); + const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const [composerExpanded, setComposerExpanded] = useState(false); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; @@ -211,10 +216,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; + const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0; - const feedBottomInset = - Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8; + const feedBottomInset = resolveThreadFeedBottomInset({ + estimatedOverlayHeight, + measuredOverlayHeight, + gap: 8, + }); + const selectedProviderSkills = useMemo( + () => + props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) + ?.skills ?? [], + [props.serverConfig, selectedInstanceId], + ); const completeDrawerGesture = useCallback(() => { void Haptics.selectionAsync(); @@ -245,20 +259,66 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ); }, []); + const collapseComposer = useCallback(() => { + composerRef.current?.blur(); + }, []); + + const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { + feedTouchStartRef.current = { + pageX: event.nativeEvent.pageX, + pageY: event.nativeEvent.pageY, + }; + }, []); + + const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => { + const start = feedTouchStartRef.current; + if (!start) { + return; + } + const deltaX = event.nativeEvent.pageX - start.pageX; + const deltaY = event.nativeEvent.pageY - start.pageY; + if (Math.hypot(deltaX, deltaY) > 8) { + feedTouchStartRef.current = null; + } + }, []); + + const handleFeedTouchEnd = useCallback(() => { + if (feedTouchStartRef.current) { + collapseComposer(); + } + feedTouchStartRef.current = null; + }, [collapseComposer]); + + const handleFeedTouchCancel = useCallback(() => { + feedTouchStartRef.current = null; + }, []); + return ( {showContent ? ( - + + + ) : ( )} @@ -298,6 +358,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} ; readonly httpBaseUrl: string | null; readonly bearerToken: string | null; readonly agentLabel: string; + readonly latestTurn: ThreadFeedLatestTurn | null; + readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly layoutVariant?: MobileLayoutVariant; readonly composerExpanded?: boolean; + readonly skills?: ReadonlyArray; } function stripShellWrapper(value: string): string { @@ -75,34 +109,48 @@ function compactActivityDetail(detail: string | null): string | null { } function buildActivityRows( - activities: ReadonlyArray<{ - readonly id: string; - readonly createdAt: string; - readonly summary: string; - readonly detail: string | null; - readonly status: string | null; - }>, + activities: Extract["activities"], ) { - return activities.map<{ - id: string; - createdAt: string; - summary: string; - detail: string | null; - status: string | null; - }>((activity) => ({ - id: activity.id, - createdAt: activity.createdAt, - summary: activity.summary, + return activities.map((activity) => ({ + ...activity, detail: compactActivityDetail(activity.detail), - status: activity.status, })); } -const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; +const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; -function toMarkdownThemeColor(value: ColorValue): string { - return value as string; -} +const MARKDOWN_COLORS = { + light: { + body: "#111111", + strong: "#000000", + link: "#2563eb", + blockquoteBorder: "rgba(0, 0, 0, 0.08)", + blockquoteBackground: "rgba(0, 0, 0, 0.02)", + codeBackground: "rgba(0, 0, 0, 0.04)", + codeText: "#262626", + horizontalRule: "rgba(0, 0, 0, 0.08)", + userBody: "#ffffff", + userCodeBackground: "rgba(255, 255, 255, 0.22)", + userCodeText: "#ffffff", + userFenceBackground: "rgba(0, 0, 0, 0.16)", + userFenceText: "#ffffff", + }, + dark: { + body: "#e5e5e5", + strong: "#f5f5f5", + link: "#60a5fa", + blockquoteBorder: "rgba(255, 255, 255, 0.1)", + blockquoteBackground: "rgba(255, 255, 255, 0.03)", + codeBackground: "rgba(255, 255, 255, 0.06)", + codeText: "#e5e5e5", + horizontalRule: "rgba(255, 255, 255, 0.08)", + userBody: "#ffffff", + userCodeBackground: "rgba(255, 255, 255, 0.18)", + userCodeText: "#ffffff", + userFenceBackground: "rgba(0, 0, 0, 0.28)", + userFenceText: "#ffffff", + }, +} as const; interface MarkdownStyleSets { readonly user: MarkdownStyleSet; @@ -113,6 +161,7 @@ interface MarkdownStyleSet { readonly theme: PartialMarkdownTheme; readonly styles: NodeStyleOverrides; readonly renderers: CustomRenderers; + readonly nativeTextStyle: NativeMarkdownTextStyle; } interface ReviewCommentColors { @@ -124,6 +173,70 @@ interface ReviewCommentColors { readonly codeBackground: ColorValue; } +const failedMarkdownFaviconHosts = new Set(); +const markdownLinkStyles = StyleSheet.create({ + favicon: { + width: 14, + height: 14, + borderRadius: 3, + marginHorizontal: 3, + transform: [{ translateY: 2 }], + }, + file: { + borderRadius: 5, + borderWidth: StyleSheet.hairlineWidth, + fontFamily: "DMSans_500Medium", + fontSize: 13, + lineHeight: 20, + paddingHorizontal: 6, + paddingVertical: 2, + }, + fileIcon: { + width: 15, + height: 15, + marginRight: 4, + transform: [{ translateY: 2 }], + }, +}); + +const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { + readonly children: ReactNode; + readonly color: string; + readonly host: string; + readonly href: string; +}) { + const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); + + return ( + { + void Linking.openURL(props.href); + }} + style={{ + color: props.color, + fontFamily: "DMSans_400Regular", + textDecorationLine: "none", + }} + > + {!failed ? ( + { + failedMarkdownFaviconHosts.add(props.host); + setFailed(true); + }} + /> + ) : ( + {" ◉ "} + )} + {props.children} + + ); +}); + function useReviewCommentColors(): ReviewCommentColors { const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; @@ -148,34 +261,26 @@ function useReviewCommentColors(): ReviewCommentColors { } function useMarkdownStyles(): MarkdownStyleSets { - const bodyColor = useThemeColor("--color-md-body"); - const strongColor = useThemeColor("--color-md-strong"); - const linkColor = useThemeColor("--color-md-link"); - const blockquoteBg = useThemeColor("--color-md-blockquote-bg"); - const blockquoteBorder = useThemeColor("--color-md-blockquote-border"); - const codeBg = useThemeColor("--color-md-code-bg"); - const codeText = useThemeColor("--color-md-code-text"); - const hrColor = useThemeColor("--color-md-hr"); - const userBodyColor = useThemeColor("--color-user-bubble-foreground"); - const userCodeBg = useThemeColor("--color-md-user-code-bg"); - const userCodeText = useThemeColor("--color-md-user-code-text"); - const userFenceBg = useThemeColor("--color-md-user-fence-bg"); - const userFenceText = useThemeColor("--color-md-user-fence-text"); + const colorScheme = useColorScheme(); + const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; + const inlineChipBackground = String(useThemeColor("--color-subtle")); + const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background")); + const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); return useMemo(() => { - const markdownBodyColor = toMarkdownThemeColor(bodyColor); - const markdownStrongColor = toMarkdownThemeColor(strongColor); - const markdownLinkColor = toMarkdownThemeColor(linkColor); - const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg); - const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder); - const markdownCodeBg = toMarkdownThemeColor(codeBg); - const markdownCodeText = toMarkdownThemeColor(codeText); - const markdownHrColor = toMarkdownThemeColor(hrColor); - const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor); - const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg); - const markdownUserCodeText = toMarkdownThemeColor(userCodeText); - const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg); - const markdownUserFenceText = toMarkdownThemeColor(userFenceText); + const markdownBodyColor = colors.body; + const markdownStrongColor = colors.strong; + const markdownLinkColor = colors.link; + const markdownBlockquoteBg = colors.blockquoteBackground; + const markdownBlockquoteBorder = colors.blockquoteBorder; + const markdownCodeBg = colors.codeBackground; + const markdownCodeText = colors.codeText; + const markdownHrColor = colors.horizontalRule; + const markdownUserBodyColor = colors.userBody; + const markdownUserCodeBg = colors.userCodeBackground; + const markdownUserCodeText = colors.userCodeText; + const markdownUserFenceBg = colors.userFenceBackground; + const markdownUserFenceText = colors.userFenceText; const baseTheme: PartialMarkdownTheme = { colors: { @@ -202,12 +307,12 @@ function useMarkdownStyles(): MarkdownStyleSets { fontSizes: { s: 13, m: 15, - h1: 22, - h2: 19, - h3: 17, - h4: 15, - h5: 15, - h6: 15, + h1: 20, + h2: 18, + h3: 16, + h4: 14, + h5: 14, + h6: 14, }, fontFamilies: { regular: "DMSans_400Regular", @@ -225,8 +330,8 @@ function useMarkdownStyles(): MarkdownStyleSets { const baseStyles: NodeStyleOverrides = { document: { flexShrink: 1 }, - paragraph: { marginTop: 0, marginBottom: 8 }, - list: { marginTop: 4, marginBottom: 4 }, + paragraph: { marginTop: 0, marginBottom: 10 }, + list: { marginTop: 4, marginBottom: 8 }, list_item: { marginTop: 0, marginBottom: 4 }, task_list_item: { marginTop: 0, marginBottom: 4 }, text: { lineHeight: 22 }, @@ -241,20 +346,18 @@ function useMarkdownStyles(): MarkdownStyleSets { textDecorationLine: "underline" as const, }, blockquote: { - borderLeftWidth: 3, + borderLeftWidth: 2, borderLeftColor: markdownBlockquoteBorder, - backgroundColor: markdownBlockquoteBg, - paddingLeft: 12, - paddingVertical: 6, + paddingLeft: 11, + paddingVertical: 2, marginLeft: 0, - marginVertical: 4, - borderRadius: 4, + marginVertical: 10, }, heading: { fontFamily: "DMSans_700Bold", color: markdownStrongColor, - marginTop: 12, - marginBottom: 6, + marginTop: 18, + marginBottom: 8, }, horizontal_rule: { backgroundColor: markdownHrColor, @@ -263,44 +366,173 @@ function useMarkdownStyles(): MarkdownStyleSets { }, }; - const createCodeRenderers = ( + const createMarkdownRenderers = ( inlineBackgroundColor: string, inlineTextColor: string, blockBackgroundColor: string, blockTextColor: string, ): CustomRenderers => ({ - code_inline: ({ content }) => ( - - {content} - + link: ({ children, href = "" }) => { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind === "file") { + return ( + + + {presentation.label} + + ); + } + if (presentation.kind === "external") { + return ( + + {children} + + ); + } + const linkHref = presentation.href; + return ( + { + void Linking.openURL(linkHref); + } + : undefined + } + style={{ + color: markdownLinkColor, + textDecorationLine: "underline", + }} + > + {children} + + ); + }, + list: ({ node, Renderer, ordered = false, start = 1 }) => ( + + {node.children?.map((child, index) => { + const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; + if (child.type === "task_list_item") { + return ( + + ); + } + return ( + + + {ordered ? `${start + index}.` : "•"} + + + + + + ); + })} + ), - code_block: ({ content }) => ( + code_inline: ({ content }) => { + const value = content ?? ""; + const wrapsPoorly = + value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":"); + return ( + + {value} + + ); + }, + code_block: ({ content, language }) => ( - + {language ? ( + + + {language} + + + ) : null} + {content} @@ -333,6 +565,8 @@ function useMarkdownStyles(): MarkdownStyleSets { heading: { ...baseStyles.heading, color: markdownUserBodyColor, + marginTop: 8, + marginBottom: 4, }, link: { color: markdownUserBodyColor, @@ -357,48 +591,79 @@ function useMarkdownStyles(): MarkdownStyleSets { user: { theme: userTheme, styles: userStyles, - renderers: createCodeRenderers( + renderers: createMarkdownRenderers( markdownUserCodeBg, markdownUserCodeText, markdownUserFenceBg, markdownUserFenceText, ), + nativeTextStyle: { + color: markdownUserBodyColor, + strongColor: markdownUserBodyColor, + mutedColor: markdownUserBodyColor, + linkColor: markdownUserBodyColor, + codeColor: markdownUserCodeText, + codeBackgroundColor: markdownUserCodeBg, + codeBlockBackgroundColor: markdownUserFenceBg, + fileBackgroundColor: "rgba(255, 255, 255, 0.12)", + fileTextColor: "#ffffff", + skillBackgroundColor: "rgba(217, 70, 239, 0.24)", + skillTextColor: "#ffffff", + quoteMarkerColor: markdownUserBodyColor, + dividerColor: markdownUserBodyColor, + fontSize: 15, + lineHeight: 22, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, }, assistant: { theme: assistantTheme, styles: assistantStyles, - renderers: createCodeRenderers( + renderers: createMarkdownRenderers( markdownCodeBg, markdownCodeText, markdownCodeBg, markdownCodeText, ), + nativeTextStyle: { + color: markdownBodyColor, + strongColor: markdownStrongColor, + mutedColor: markdownBodyColor, + linkColor: markdownLinkColor, + codeColor: markdownCodeText, + codeBackgroundColor: markdownCodeBg, + codeBlockBackgroundColor: markdownCodeBg, + fileBackgroundColor: inlineChipBackground, + fileTextColor: markdownCodeText, + skillBackgroundColor: inlineSkillBackground, + skillTextColor: inlineSkillForeground, + quoteMarkerColor: markdownBlockquoteBorder, + dividerColor: markdownHrColor, + fontSize: 15, + lineHeight: 22, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, }, }; - }, [ - blockquoteBg, - blockquoteBorder, - bodyColor, - codeBg, - codeText, - hrColor, - linkColor, - strongColor, - userBodyColor, - userCodeBg, - userCodeText, - userFenceBg, - userFenceText, - ]); + }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]); } function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; + readonly expandedWorkRows: Record; + readonly terminalAssistantMessageIds: ReadonlySet; + readonly unsettledTurnId: TurnId | null; readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string) => void; + readonly onToggleWorkRow: (rowId: string) => void; + readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; @@ -410,19 +675,50 @@ function renderFeedEntry( const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "turn-fold") { + return ( + props.onToggleTurnFold(entry.turnId)} + hitSlop={4} + className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" + > + + {entry.label} + + + + ); + } + if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; - const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`; + const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = message.text.includes(" ) : null} {attachments.map((attachment) => { @@ -459,9 +756,20 @@ function renderFeedEntry( ); })} - - {timestampLabel} - + + + {timestampLabel} + + {message.text.trim().length > 0 ? ( + + ) : null} + ); } @@ -473,16 +781,24 @@ function renderFeedEntry( } return ( - + {message.text.trim().length > 0 ? ( - - {message.text} - + hasNativeSelectableMarkdownText() ? ( + + ) : ( + + {message.text} + + ) ) : null} {attachments.map((attachment) => { const uri = messageImageUrl(props.httpBaseUrl, attachment.id); @@ -508,9 +824,20 @@ function renderFeedEntry( ); })} - - {timestampLabel} - + {showAssistantMeta ? ( + + + + {timestampLabel} + + + ) : null} ); } @@ -539,67 +866,121 @@ function renderFeedEntry( ); } - const rows = buildActivityRows(entry.activities); + const rows = buildActivityRows(entry.activities).filter( + (activity) => !(activity.toolLike && activity.status === "neutral"), + ); + if (rows.length === 0) { + return null; + } const isExpanded = props.expandedWorkGroups[entry.id] ?? false; const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; const hiddenCount = rows.length - visibleRows.length; - const showHeader = hasOverflow; + const onlyToolRows = rows.every((row) => row.toolLike); + const headerTitle = onlyToolRows + ? rows.length === 1 + ? "1 tool call" + : `${rows.length} tool calls` + : "Work log"; return ( - - {showHeader ? ( - - - Tool calls ({rows.length}) - - props.onToggleWorkGroup(entry.id)}> - + + + {headerTitle} + {hasOverflow ? ( + props.onToggleWorkGroup(entry.id)} + className="flex-row items-center gap-1" + > + {isExpanded ? "Show less" : `Show ${hiddenCount} more`} + - - ) : null} + ) : null} + {visibleRows.map((row, index) => ( - { + if (row.fullDetail) { + props.onToggleWorkRow(row.id); + } + }} + onLongPress={() => props.onCopyWorkRow(row.id, row.copyText)} className={cn( - "flex-row items-center gap-2 rounded-lg px-1 py-1", + "rounded-lg px-2 py-1.5", index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", )} > - - - - + + + + { - const copyValue = row.detail ?? row.summary; - props.onCopyWorkRow(row.id, copyValue); - }} - style={{ - fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", - }} + className="min-w-0 flex-1 text-[12px] leading-[18px] text-neutral-700 dark:text-neutral-300" + numberOfLines={props.expandedWorkRows[row.id] ? undefined : 1} > {row.detail ? `${row.summary} - ${row.detail}` : row.summary} - - {props.copiedRowId === row.id ? ( - - Copied - + {row.fullDetail ? ( + + ) : null} + {props.copiedRowId === row.id ? ( + + Copied + + ) : null} + + {row.fullDetail && props.expandedWorkRows[row.id] ? ( + + + {row.fullDetail} + + ) : null} - + ))} ); @@ -609,10 +990,20 @@ function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; readonly reviewCommentColors: ReviewCommentColors; + readonly skills?: ReadonlyArray; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); if (!hasReviewComment) { + if (hasNativeSelectableMarkdownText()) { + return ( + + ); + } return ( + ) : ( = 0 ? normalized.slice(lastSlashIndex + 1) : normalized; } -const IOS_NAV_BAR_HEIGHT = 44; - export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const listRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>(null); + const scrollFrameRef = useRef(null); + const foldSettleFrameRef = useRef(null); + const foldSettleSecondFrameRef = useRef(null); + const suppressAutoFollowRef = useRef(false); + const previousLatestTurnRef = useRef(props.latestTurn); + const isNearEndRef = useRef(true); + const initialScrollReadyRef = useRef(false); + const lastContentHeightRef = useRef(0); const { width: viewportWidth } = useWindowDimensions(); - const [copiedRowId, setCopiedRowId] = useState(null); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [interactionState, setInteractionState] = useState<{ + readonly copiedRowId: string | null; + readonly expandedWorkGroups: Record; + readonly expandedWorkRows: Record; + readonly expandedTurnIds: ReadonlySet; + }>({ + copiedRowId: null, + expandedWorkGroups: {}, + expandedWorkRows: {}, + expandedTurnIds: new Set(), + }); + const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedImage, setExpandedImage] = useState<{ uri: string; headers?: Record; @@ -835,47 +1249,193 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); - const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; + const topContentInset = props.contentTopInset ?? insets.top + 44; const bottomContentInset = props.contentBottomInset ?? 18; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); const markdownStyles = useMarkdownStyles(); const reviewCommentColors = useReviewCommentColors(); + const listAppearanceData = useMemo( + () => ({ + iconSubtleColor, + markdownStyles, + reviewCommentColors, + userBubbleColor, + }), + [iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor], + ); + const presentedFeed = useMemo( + () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), + [expandedTurnIds, props.feed, props.latestTurn], + ); + const terminalAssistantMessageIds = useMemo(() => { + const terminalIdsByTurn = new Map(); + for (const entry of props.feed) { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + terminalIdsByTurn.set(entry.message.turnId, entry.message.id); + } + } + return new Set(terminalIdsByTurn.values()); + }, [props.feed]); + const unsettledTurnId = + props.latestTurn && + (props.latestTurn.completedAt === null || props.latestTurn.state === "running") + ? props.latestTurn.turnId + : null; + + const scrollToEnd = useCallback(() => { + if (scrollFrameRef.current !== null) { + return; + } + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = null; + listRef.current?.scrollToEnd({ animated: false }); + }); + }, []); + + const onListScroll = useCallback( + (event: NativeSyntheticEvent | NativeScrollEvent) => { + const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; + const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; + isNearEndRef.current = isThreadFeedNearEnd( + { + contentHeight: contentSize.height, + viewportHeight: layoutMeasurement.height, + offsetY: contentOffset.y, + bottomInset: contentInset.bottom, + }, + THREAD_FEED_END_THRESHOLD, + ); + }, + [], + ); + + const onListContentSizeChange = useCallback( + (_width: number, height: number) => { + const contentGrew = height > lastContentHeightRef.current + 0.5; + lastContentHeightRef.current = height; + + if ( + initialScrollReadyRef.current && + contentGrew && + isNearEndRef.current && + !suppressAutoFollowRef.current + ) { + scrollToEnd(); + } + }, + [scrollToEnd], + ); + + const onListLoad = useCallback(() => { + initialScrollReadyRef.current = true; + }, []); useEffect(() => { - setCopiedRowId(null); - setExpandedWorkGroups({}); - }, [props.threadId]); + const previous = previousLatestTurnRef.current; + previousLatestTurnRef.current = props.latestTurn; + if (!props.latestTurn || !previous) { + return; + } + if (props.latestTurn.turnId === previous.turnId) { + if (previous.state === "running" && props.latestTurn.state === "interrupted") { + const interruptedTurnId = props.latestTurn.turnId; + setInteractionState((current) => ({ + ...current, + expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), + })); + } + return; + } + setInteractionState((current) => { + if (!current.expandedTurnIds.has(previous.turnId)) { + return current; + } + const next = new Set(current.expandedTurnIds); + next.delete(previous.turnId); + return { ...current, expandedTurnIds: next }; + }); + }, [props.latestTurn]); useEffect(() => { return () => { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } + if (scrollFrameRef.current !== null) { + cancelAnimationFrame(scrollFrameRef.current); + } + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); + } + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); + } }; }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { void Clipboard.setStringAsync(value); void Haptics.selectionAsync(); - setCopiedRowId(rowId); + setInteractionState((current) => ({ ...current, copiedRowId: rowId })); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } copyFeedbackTimeoutRef.current = setTimeout(() => { - setCopiedRowId((current) => (current === rowId ? null : current)); + setInteractionState((current) => + current.copiedRowId === rowId ? { ...current, copiedRowId: null } : current, + ); copyFeedbackTimeoutRef.current = null; }, 1200); }, []); const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((current) => ({ + setInteractionState((current) => ({ ...current, - [groupId]: !(current[groupId] ?? false), + expandedWorkGroups: { + ...current.expandedWorkGroups, + [groupId]: !(current.expandedWorkGroups[groupId] ?? false), + }, })); }, []); + const onToggleWorkRow = useCallback((rowId: string) => { + setInteractionState((current) => ({ + ...current, + expandedWorkRows: { + ...current.expandedWorkRows, + [rowId]: !(current.expandedWorkRows[rowId] ?? false), + }, + })); + }, []); + + const onToggleTurnFold = useCallback((turnId: TurnId) => { + suppressAutoFollowRef.current = true; + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); + } + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); + } + setInteractionState((current) => { + const next = new Set(current.expandedTurnIds); + if (next.has(turnId)) { + next.delete(turnId); + } else { + next.add(turnId); + } + return { ...current, expandedTurnIds: next }; + }); + foldSettleFrameRef.current = requestAnimationFrame(() => { + foldSettleSecondFrameRef.current = requestAnimationFrame(() => { + suppressAutoFollowRef.current = false; + foldSettleFrameRef.current = null; + foldSettleSecondFrameRef.current = null; + }); + }); + }, []); + const onPressImage = useCallback((uri: string, headers?: Record) => { setExpandedImage({ uri, headers }); }, []); @@ -887,18 +1447,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { copiedRowId, httpBaseUrl: props.httpBaseUrl, expandedWorkGroups, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, onCopyWorkRow, onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, onPressImage, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + skills: props.skills, }), [ copiedRowId, expandedWorkGroups, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, iconSubtleColor, userBubbleColor, markdownStyles, @@ -906,9 +1475,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentBubbleWidth, onCopyWorkRow, onPressImage, + onToggleTurnFold, onToggleWorkGroup, + onToggleWorkRow, props.bearerToken, props.httpBaseUrl, + props.skills, ], ); @@ -935,33 +1507,46 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( <> - `${entry.type}:${entry.id}`} - getItemType={(entry) => - entry.type === "message" ? `message:${entry.message.role}` : entry.type - } - keyboardShouldPersistTaps="handled" - estimatedItemSize={180} - initialScrollAtEnd - maintainScrollAtEnd={{ - on: { layout: true, itemLayout: true, dataChange: true }, - }} - maintainScrollAtEndThreshold={0.1} - safeAreaInsetBottom={insets.bottom} - contentContainerStyle={{ - paddingTop: 12, - paddingHorizontal: horizontalPadding, - }} - /> + + `${entry.type}:${entry.id}`} + getItemType={(entry) => + entry.type === "message" ? `message:${entry.message.role}` : entry.type + } + keyboardShouldPersistTaps="always" + keyboardDismissMode="none" + estimatedItemSize={180} + initialScrollAtEnd + onContentSizeChange={onListContentSizeChange} + onLoad={onListLoad} + onScroll={onListScroll} + scrollEventThrottle={16} + ListHeaderComponent={} + contentContainerStyle={{ + paddingTop: 12, + paddingBottom: bottomContentInset, + paddingHorizontal: horizontalPadding, + }} + /> + ; readonly selectedModel: ModelSelection | null; readonly selectedModelOption: ModelOption | null; + readonly selectedProviderSkills: ReadonlyArray; readonly providerGroups: ReadonlyArray; readonly filteredBranches: ReadonlyArray; readonly reset: () => void; @@ -283,6 +285,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; + const selectedProviderSkills = + (selectedProject + ? serverConfigByEnvironmentId[selectedProject.environmentId] + : null + )?.providers.find((provider) => provider.instanceId === selectedModel?.instanceId)?.skills ?? + []; const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); const setPrompt = useCallback( @@ -450,6 +458,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { modelOptions, selectedModel, selectedModelOption, + selectedProviderSkills, providerGroups, filteredBranches, reset, @@ -498,6 +507,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedModelKey, selectedModelOption, + selectedProviderSkills, selectedProject, selectedProjectKey, selectedWorktreePath, diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts new file mode 100644 index 00000000000..40e00a271f7 --- /dev/null +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; + +const files = new Map(); + +vi.mock("expo-file-system", () => ({ + File: class { + readonly uri: string; + + constructor(uri: string) { + this.uri = uri; + } + + get exists(): boolean { + return files.has(this.uri) && files.get(this.uri)?.deleted === false; + } + + async base64(): Promise { + const entry = files.get(this.uri); + if (!entry || entry.deleted) { + throw new Error("missing file"); + } + return entry.base64; + } + + delete(): void { + const entry = files.get(this.uri); + if (entry) { + entry.deleted = true; + } + } + }, +})); + +vi.mock("./uuid", () => ({ + uuidv4: () => "attachment-id", +})); + +import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; + +describe("native pasted image cleanup", () => { + beforeEach(() => { + files.clear(); + }); + + it("recognizes only files created in the native composer paste directory", () => { + expect( + isOwnedPastedImageUri( + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png", + ), + ).toBe(true); + expect(isOwnedPastedImageUri("file:///private/var/mobile/photos/id.png")).toBe(false); + expect(isOwnedPastedImageUri("https://example.com/t3-composer-paste/id.png")).toBe(false); + }); + + it("converts owned files to data-backed previews and deletes the source", async () => { + const uri = + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png"; + files.set(uri, { base64: "aGVsbG8=", deleted: false }); + + const attachments = await convertPastedImagesToAttachments({ + uris: [uri], + existingCount: 0, + }); + + expect(attachments).toEqual([ + expect.objectContaining({ + dataUrl: "data:image/png;base64,aGVsbG8=", + previewUri: "data:image/png;base64,aGVsbG8=", + }), + ]); + expect(files.get(uri)?.deleted).toBe(true); + }); + + it("deletes rejected and overflow owned files without deleting user-owned files", async () => { + const rejected = + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/bad.png"; + const overflow = + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/overflow.png"; + const userOwned = "file:///private/var/mobile/photos/library.png"; + files.set(rejected, { base64: "", deleted: false }); + files.set(overflow, { base64: "aGVsbG8=", deleted: false }); + files.set(userOwned, { base64: "aGVsbG8=", deleted: false }); + + await convertPastedImagesToAttachments({ + uris: [rejected, overflow, userOwned], + existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1, + }); + + expect(files.get(rejected)?.deleted).toBe(true); + expect(files.get(overflow)?.deleted).toBe(true); + expect(files.get(userOwned)?.deleted).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 871982442e6..13b53af724e 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -10,6 +10,8 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } +const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; + function estimateBase64ByteSize(base64: string): number { const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; return Math.floor((base64.length * 3) / 4) - padding; @@ -213,17 +215,35 @@ function mimeTypeFromUri(uri: string): string { } } +export function isOwnedPastedImageUri(uri: string): boolean { + try { + const url = new URL(uri); + if (url.protocol !== "file:") { + return false; + } + const segments = url.pathname.split("/").filter(Boolean); + return ( + segments.at(-2) === OWNED_PASTED_IMAGE_DIRECTORY && segments.at(-1)?.endsWith(".png") === true + ); + } catch { + return false; + } +} + export async function convertPastedImagesToAttachments(input: { readonly uris: ReadonlyArray; readonly existingCount: number; }): Promise> { const { File } = await import("expo-file-system"); const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; - const uris = input.uris.slice(0, Math.max(0, remainingSlots)); const results: DraftComposerImageAttachment[] = []; - for (const uri of uris) { + for (const [index, uri] of input.uris.entries()) { + const ownedTemporaryFile = isOwnedPastedImageUri(uri); try { + if (index >= Math.max(0, remainingSlots)) { + continue; + } const file = new File(uri); const base64 = await file.base64(); const sizeBytes = estimateBase64ByteSize(base64); @@ -238,10 +258,21 @@ export async function convertPastedImagesToAttachments(input: { mimeType, sizeBytes, dataUrl: `data:${mimeType};base64,${base64}`, - previewUri: uri, + previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri, }); } catch (error) { console.warn("Failed to read pasted image", uri, error); + } finally { + if (ownedTemporaryFile) { + try { + const file = new File(uri); + if (file.exists) { + file.delete(); + } + } catch (error) { + console.warn("Failed to remove temporary pasted image", uri, error); + } + } } } diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts new file mode 100644 index 00000000000..d15a3a1a59b --- /dev/null +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + impactAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +vi.mock("expo-clipboard", () => ({ + setStringAsync: mocks.setStringAsync, +})); + +vi.mock("expo-haptics", () => ({ + ImpactFeedbackStyle: { + Light: "light", + }, + impactAsync: mocks.impactAsync, +})); + +import { copyTextWithHaptic } from "./copyTextWithHaptic"; + +describe("copyTextWithHaptic", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.setStringAsync.mockReturnValue(new Promise(() => undefined)); + mocks.impactAsync.mockResolvedValue(undefined); + }); + + it("triggers haptic feedback without waiting for the clipboard promise", () => { + copyTextWithHaptic("trace-123"); + + expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); + expect(mocks.impactAsync).toHaveBeenCalledWith("light"); + }); +}); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts new file mode 100644 index 00000000000..80f725f5b00 --- /dev/null +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -0,0 +1,7 @@ +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; + +export function copyTextWithHaptic(value: string): void { + void Clipboard.setStringAsync(value); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +} diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts new file mode 100644 index 00000000000..90153d0afa0 --- /dev/null +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; + +describe("resolveMarkdownLinkPresentation", () => { + it("extracts external link hosts", () => { + expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ + kind: "external", + href: "https://example.com/docs?q=1", + host: "example.com", + }); + }); + + it("renders file URLs as basename pills with positions", () => { + expect( + resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), + ).toEqual({ + kind: "file", + icon: "typescript", + label: "main.ts:42:7", + }); + }); + + it("recognizes relative source paths and bare filenames", () => { + expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({ + kind: "file", + icon: "typescript", + label: "index.ts:10", + }); + expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({ + kind: "file", + icon: "agents", + label: "AGENTS.md", + }); + expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ + kind: "file", + icon: "package", + label: "package.json", + }); + }); + + it("uses the Pierre complete icon mappings", () => { + expect(resolveMarkdownLinkPresentation("src/Button.tsx")).toMatchObject({ + kind: "file", + icon: "react", + }); + expect(resolveMarkdownLinkPresentation("vite.config.ts")).toMatchObject({ + kind: "file", + icon: "vite", + }); + expect(resolveMarkdownLinkPresentation("Dockerfile")).toMatchObject({ + kind: "file", + icon: "docker", + }); + expect(resolveMarkdownLinkPresentation("pnpm-lock.yaml")).toMatchObject({ + kind: "file", + icon: "pnpm", + }); + }); + + it("does not style app routes as file links", () => { + expect(resolveMarkdownLinkPresentation("/chat/settings")).toEqual({ + kind: "link", + href: null, + }); + }); +}); diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts new file mode 100644 index 00000000000..9d5c55686ec --- /dev/null +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -0,0 +1,734 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, +} from "@t3tools/mobile-markdown-text/markdown"; + +describe("nativeMarkdownTextRuns", () => { + it("preserves inline emphasis and code styles", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "plain " }, + { type: "bold", children: [{ type: "text", content: "bold" }] }, + { type: "text", content: " " }, + { type: "code_inline", content: "const value = 1" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "plain " }, + { text: "bold", bold: true }, + { text: " " }, + { text: "const value = 1", code: true }, + ]); + }); + + it("normalizes external and file links for native presentation", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "link", + href: "https://example.com/docs", + children: [{ type: "text", content: "Docs" }], + }, + { type: "text", content: " " }, + { + type: "link", + href: "file:///repo/README.md#L12", + children: [{ type: "text", content: "ignored label" }], + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { + text: "Docs", + href: "https://example.com/docs", + externalHost: "example.com", + }, + { text: " " }, + { text: "README.md:12", fileIcon: "readme" }, + ]); + }); + + it("keeps hard breaks and collapses soft breaks", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "first" }, + { type: "soft_break" }, + { type: "text", content: "second" }, + { type: "line_break" }, + { type: "text", content: "third" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]); + }); + + it("normalizes common inline HTML and entities", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "Less than: < " }, + { type: "html_inline", content: "" }, + { type: "text", content: "⌘" }, + { type: "html_inline", content: "" }, + { type: "html_inline", content: "
" }, + { type: "html_inline", content: "highlighted" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "Less than: < ⌘\nhighlighted" }]); + }); + + it("normalizes double-encoded entities and inline tags emitted as text", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "text", + content: + "Keyboard: + K; Less than: &lt;; Greater than: &gt;", + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "Keyboard: ⌘ + K; Less than: <; Greater than: >" }, + ]); + }); + + it("reads inline content from nested text nodes", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "text", + children: [{ type: "text", content: "Plain text" }], + }, + { type: "text", content: " and " }, + { + type: "code_inline", + children: [{ type: "text", content: "inline code" }], + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "Plain text and " }, + { text: "inline code", code: true }, + ]); + }); +}); + +describe("nativeMarkdownDocumentRuns", () => { + it("decorates known skill references as selectable skill chips", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $ui for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ + { text: "Use ", role: "body" }, + { + text: "$ui", + role: "body", + skillName: "ui", + skillLabel: "UI", + }, + { text: " for this.", role: "body" }, + ]); + }); + + it("leaves unknown skill-like text unchanged", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $unknown for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [])).toEqual([ + { text: "Use $unknown for this.", role: "body" }, + ]); + }); + + it("keeps headings, paragraphs, and lists in one continuous document", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + children: [{ type: "text", content: "Header One" }], + }, + { + type: "paragraph", + children: [ + { type: "text", content: "A paragraph with " }, + { type: "bold", children: [{ type: "text", content: "bold text" }] }, + { type: "text", content: "." }, + ], + }, + { + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "First item" }], + }, + ], + }, + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Second item" }], + }, + ], + }, + ], + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node); + expect(runs.map((run) => run.text).join("")).toBe( + "Header One\n\nA paragraph with bold text.\n\n•\tFirst item\n•\tSecond item", + ); + expect(runs).toContainEqual({ + text: "Header One\n", + role: "heading", + headingLevel: 1, + }); + expect(runs).toContainEqual({ + text: "bold text", + bold: true, + role: "body", + }); + expect(runs).toContainEqual({ + text: "•\t", + role: "list-marker", + depth: 1, + firstLineHeadIndent: 0, + headIndent: 24, + paragraphSpacing: 2, + }); + }); + + it("uses distinct section, heading-content, and body spacing", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Intro" }], + }, + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Section" }], + }, + { + type: "paragraph", + children: [{ type: "text", content: "First paragraph" }], + }, + { + type: "paragraph", + children: [{ type: "text", content: "Second paragraph" }], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node) + .filter((run) => run.role === "spacer") + .map((run) => run.spacing), + ).toEqual([20, 10, 12]); + }); + + it("renders tight list items whose inline nodes are direct children", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { + type: "bold", + children: [{ type: "text", content: "Finding:" }], + }, + { type: "text", content: " details with " }, + { type: "code_inline", content: "inline code" }, + { type: "text", content: "." }, + ], + }, + ], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node)).toEqual([ + { + text: "•\t", + role: "list-marker", + depth: 1, + firstLineHeadIndent: 0, + headIndent: 24, + paragraphSpacing: 2, + }, + { text: "Finding:", bold: true, role: "body", depth: 1 }, + { text: " details with ", role: "body", depth: 1 }, + { text: "inline code", code: true, role: "body", depth: 1 }, + { text: ".", role: "body", depth: 1 }, + ]); + }); + + it("includes quotes and fenced code in the same selectable string", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Read this" }], + }, + ], + }, + { + type: "code_block", + language: "ts", + content: "const answer = 42;", + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node); + expect(runs.map((run) => run.text).join("")).toBe("│\u00a0Read this\n\nTS\nconst answer = 42;"); + expect(runs).toContainEqual({ + text: "const answer = 42;", + code: true, + role: "code-block", + }); + }); + + it("reads fenced code content from child text nodes", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node) + .map((run) => run.text) + .join(""), + ).toBe("BASH\npnpm install"); + }); +}); + +describe("nativeMarkdownListItemBlocks", () => { + it("groups consecutive inline nodes into one paragraph block", () => { + const item: MarkdownNode = { + type: "list_item", + children: [ + { type: "text", content: "Finding: " }, + { type: "bold", children: [{ type: "text", content: "important" }] }, + { type: "text", content: " details." }, + { + type: "list", + children: [ + { + type: "list_item", + children: [{ type: "text", content: "Nested" }], + }, + ], + }, + { type: "text", content: "Trailing prose." }, + ], + }; + + expect(nativeMarkdownListItemBlocks(item)).toEqual([ + { + type: "paragraph", + children: item.children?.slice(0, 3), + }, + item.children?.[3], + { + type: "paragraph", + children: [item.children?.[4]], + }, + ]); + }); +}); + +describe("nativeMarkdownDocumentChunks", () => { + it("keeps headings and plain lists in one selectable document", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Tasks" }], + }, + { + type: "list", + children: [ + { + type: "task_list_item", + checked: true, + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Completed" }], + }, + ], + }, + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Parent" }], + }, + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Nested" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect( + nativeMarkdownDocumentRuns(chunks[0]?.node ?? document) + .map((run) => run.text) + .join(""), + ).toBe("Tasks\n\n☑︎\tCompleted\n•\tParent\n◦\tNested"); + }); + + it("aligns ordered markers while keeping the list in one selectable string", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + ordered: true, + start: 9, + children: [ + { + type: "list_item", + children: [{ type: "text", content: "Ninth" }], + }, + { + type: "list_item", + children: [{ type: "text", content: "Tenth" }], + }, + ], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(document) + .map((run) => run.text) + .join(""), + ).toBe("\u20079.\tNinth\n10.\tTenth"); + }); + + it("keeps prose selectable while exposing rich AST blocks", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + beg: 0, + end: 9, + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + beg: 11, + end: 35, + children: [{ type: "text", content: "pnpm install\n" }], + }, + { + type: "paragraph", + beg: 37, + end: 42, + children: [{ type: "text", content: "Done." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:code_block:11:35", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("keeps a list containing fenced code as one rich AST container", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + beg: 0, + end: 45, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }, + ], + }, + ], + }; + + expect(nativeMarkdownDocumentChunks(document)).toEqual([ + { + kind: "rich", + key: "rich:list:0:45", + node: document.children?.[0], + }, + ]); + }); + + it("keeps surrounding prose selectable when rich nodes have no source offsets", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + children: [{ type: "text", content: "Before" }], + }, + { type: "horizontal_rule" }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:horizontal_rule:1:1", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("keeps offset-free structural lists isolated without promoting the whole document", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Before." }], + }, + { + type: "list", + ordered: true, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }, + ], + }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:list:1:1", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("never collapses a rich subtree into a second markdown parsing pass", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Before." }], + }, + { + type: "blockquote", + children: [ + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { type: "text", content: "Run this" }, + { + type: "code_block", + language: "sh", + children: [{ type: "text", content: "vp check\n" }], + }, + ], + }, + ], + }, + ], + }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich", "selectable"]); + expect(chunks[1]).toMatchObject({ + kind: "rich", + node: { type: "blockquote" }, + }); + }); + + it("keeps a plain list in one selectable native text container", () => { + const list: MarkdownNode = { + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [{ type: "text", content: "First" }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks({ + type: "document", + children: [list], + }); + + expect(chunks).toHaveLength(1); + expect(chunks[0]).toMatchObject({ + kind: "selectable", + node: { type: "document", children: [list] }, + }); + }); + + it("separates sections more than related rich blocks", () => { + const headingChunk = { + kind: "selectable" as const, + key: "heading", + node: { + type: "document", + children: [ + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Section" }], + }, + ], + } satisfies MarkdownNode, + }; + const firstList = { + kind: "rich" as const, + key: "list-1", + node: { type: "list", children: [] } satisfies MarkdownNode, + }; + const secondList = { + kind: "rich" as const, + key: "list-2", + node: { type: "list", children: [] } satisfies MarkdownNode, + }; + + expect(nativeMarkdownChunkSpacing(undefined, headingChunk)).toBe(0); + expect(nativeMarkdownChunkSpacing(headingChunk, firstList)).toBe(10); + expect(nativeMarkdownChunkSpacing(firstList, secondList)).toBe(12); + expect(nativeMarkdownChunkSpacing(firstList, headingChunk)).toBe(20); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 89e21b347d5..307afb9e3d2 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EventId, + MessageId, ProjectId, ProviderInstanceId, ThreadId, @@ -10,7 +11,7 @@ import { type OrchestrationThreadActivity, } from "@t3tools/contracts"; -import { buildThreadFeed } from "./threadActivity"; +import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity"; function makeActivity( input: Partial & @@ -49,7 +50,7 @@ function makeThread( } describe("buildThreadFeed", () => { - it("includes runtime warnings from the latest turn", () => { + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), @@ -87,22 +88,16 @@ describe("buildThreadFeed", () => { }); const feed = buildThreadFeed(thread, [], null); - const group = feed[0]; - - expect(group).toMatchObject({ - type: "activity-group", - }); - if (!group || group.type !== "activity-group") { - return; - } - - expect(group.activities).toEqual([ + expect(feed).toMatchObject([ + { + type: "activity-group", + turnId: "turn-old", + activities: [{ id: "activity-old", turnId: "turn-old" }], + }, { - id: "activity-latest", - createdAt: "2026-04-01T00:00:03.000Z", - summary: "Runtime warning", - detail: null, - status: null, + type: "activity-group", + turnId: "turn-latest", + activities: [{ id: "activity-latest", turnId: "turn-latest" }], }, ]); }); @@ -164,10 +159,201 @@ describe("buildThreadFeed", () => { { id: "tool-completed", createdAt: "2026-04-01T00:00:02.000Z", + turnId: "turn-1", summary: "Run tests", detail: "bun run test", - status: null, + fullDetail: null, + copyText: "Run tests\nbun run test", + toolLike: true, + status: "success", }, ]); }); + + it("folds settled turn work while leaving the terminal answer visible", () => { + const turnId = TurnId.make("turn-1"); + const thread = makeThread({ + id: ThreadId.make("thread-3"), + projectId: ProjectId.make("project-1"), + title: "Folded work", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:18.000Z", + assistantMessageId: MessageId.make("assistant-final"), + }, + messages: [ + { + id: MessageId.make("assistant-commentary"), + role: "assistant", + text: "I am checking.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:03.000Z", + }, + { + id: MessageId.make("assistant-final"), + role: "assistant", + text: "Done.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:17.000Z", + updatedAt: "2026-04-01T00:00:18.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("tool-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Read files", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + title: "Read files", + itemType: "file_read", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); + expect(collapsed[0]).toMatchObject({ + type: "turn-fold", + label: "Worked for 17s", + expanded: false, + }); + + const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); + expect(expanded.map((entry) => entry.id)).toEqual([ + "turn-fold:turn-1", + "assistant-commentary", + "tool-completed", + "assistant-final", + ]); + }); + + it("measures a steer-superseded turn from its user boundary through trailing work", () => { + const firstTurnId = TurnId.make("turn-1"); + const secondTurnId = TurnId.make("turn-2"); + const thread = makeThread({ + id: ThreadId.make("thread-steered"), + projectId: ProjectId.make("project-1"), + title: "Steered work", + latestTurn: { + turnId: secondTurnId, + state: "running", + requestedAt: "2026-04-01T00:00:14.000Z", + startedAt: "2026-04-01T00:00:14.000Z", + completedAt: null, + assistantMessageId: MessageId.make("assistant-next"), + }, + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "Do it once more.", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + { + id: MessageId.make("assistant-commentary"), + role: "assistant", + text: "Kicking off call 1.", + turnId: firstTurnId, + streaming: false, + createdAt: "2026-04-01T00:00:09.000Z", + updatedAt: "2026-04-01T00:00:09.000Z", + }, + { + id: MessageId.make("user-2"), + role: "user", + text: "Actually do 15.", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:14.000Z", + updatedAt: "2026-04-01T00:00:14.000Z", + }, + { + id: MessageId.make("assistant-next"), + role: "assistant", + text: "One down - adjusting.", + turnId: secondTurnId, + streaming: true, + createdAt: "2026-04-01T00:00:17.000Z", + updatedAt: "2026-04-01T00:00:17.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("work-1"), + kind: "tool.completed", + tone: "tool", + summary: "Ran command", + createdAt: "2026-04-01T00:00:12.000Z", + turnId: firstTurnId, + payload: { + title: "Ran command", + itemType: "command_execution", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ + turnId: firstTurnId, + label: "Worked for 12s", + }); + }); + + it("keeps an active turn expanded and classifies error-shaped tool output", () => { + const turnId = TurnId.make("turn-running"); + const thread = makeThread({ + id: ThreadId.make("thread-4"), + projectId: ProjectId.make("project-1"), + title: "Running work", + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("tool-failed"), + kind: "tool.completed", + tone: "tool", + summary: "Run command", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + title: "Run command", + itemType: "command_execution", + detail: "zsh: command not found: nope", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); + expect(feed[0]).toMatchObject({ + type: "activity-group", + activities: [{ status: "failure" }], + }); + }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 6ff27cadfee..e5fdb439954 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -3,13 +3,15 @@ import type { CommandId, EnvironmentId, MessageId, + OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, - TurnId, ToolLifecycleItemType, ThreadId, + TurnId, UserInputQuestion, } from "@t3tools/contracts"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import type { DraftComposerImageAttachment } from "./composerImages"; import * as Arr from "effect/Array"; @@ -46,14 +48,21 @@ export interface QueuedThreadMessage { export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly summary: string; readonly detail: string | null; - readonly status: string | null; + readonly fullDetail: string | null; + readonly copyText: string; + readonly toolLike: boolean; + readonly status: "success" | "failure" | "neutral" | null; } +type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; + interface WorkLogEntry { id: string; createdAt: string; + turnId: TurnId | null; label: string; detail?: string; command?: string; @@ -63,6 +72,7 @@ interface WorkLogEntry { toolTitle?: string; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; + toolLifecycleStatus?: WorkLogToolLifecycleStatus; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -88,6 +98,7 @@ type RawThreadFeedEntry = readonly type: "activity"; readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly activity: ThreadFeedActivity; }; @@ -97,9 +108,23 @@ export type ThreadFeedEntry = readonly type: "activity-group"; readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly activities: ReadonlyArray; + } + | { + readonly type: "turn-fold"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId; + readonly label: string; + readonly expanded: boolean; }; +export type ThreadFeedLatestTurn = Pick< + OrchestrationLatestTurn, + "turnId" | "state" | "startedAt" | "completedAt" +>; + function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { switch (requestType) { case "command_execution_approval": @@ -202,14 +227,12 @@ function resolvePendingUserInputAnswer( function deriveWorkLogEntries( activities: ReadonlyArray, - latestTurnId: TurnId | undefined, ): WorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { - if (latestTurnId && activity.turnId !== latestTurnId) continue; if (activity.kind === "tool.started") continue; - if (activity.kind === "task.started" || activity.kind === "task.completed") continue; + if (activity.kind === "task.started") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; @@ -240,16 +263,40 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); + const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; + const taskSummary = + isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 + ? payload.summary + : null; + const taskDetailAsLabel = + isTaskActivity && + !taskSummary && + typeof payload?.detail === "string" && + payload.detail.length > 0 + ? payload.detail + : null; + const taskLabel = taskSummary || taskDetailAsLabel; const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, - label: activity.summary, - tone: activity.tone === "approval" ? "info" : activity.tone, + turnId: activity.turnId, + label: taskLabel || activity.summary, + tone: + activity.kind === "task.progress" + ? "thinking" + : activity.tone === "approval" + ? "info" + : activity.tone, activityKind: activity.kind, }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); - if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { + if ( + !taskDetailAsLabel && + payload && + typeof payload.detail === "string" && + payload.detail.length > 0 + ) { const detail = stripTrailingExitCode(payload.detail).output; if (detail) { entry.detail = detail; @@ -273,6 +320,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } + let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); + if (!toolLifecycleStatus && activity.kind === "tool.completed") { + toolLifecycleStatus = "completed"; + } + if (toolLifecycleStatus) { + entry.toolLifecycleStatus = toolLifecycleStatus; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -323,6 +377,7 @@ function mergeDerivedWorkLogEntries( const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; + const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; return { ...previous, ...next, @@ -334,6 +389,7 @@ function mergeDerivedWorkLogEntries( ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), + ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), }; } @@ -365,6 +421,78 @@ function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { + if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { + return true; + } + if (entry.command !== undefined && entry.command.trim().length > 0) { + return true; + } + if (entry.requestKind !== undefined) { + return true; + } + return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); +} + +function toolDetailTextLooksLikeFailure(text: string): boolean { + const normalized = text.toLowerCase(); + return ( + normalized.includes("file not found") || + normalized.includes("no files found") || + normalized.includes("enoent") || + normalized.includes("no such file or directory") || + normalized.includes("no such file") || + normalized.includes("commandnotfoundexception") || + normalized.includes("command not found") || + (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || + (normalized.includes("is not recognized") && normalized.includes("the term '")) || + //i.test(text) || + /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || + /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) + ); +} + +function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { + if (entry.tone === "error") { + return true; + } + if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") { + return true; + } + if (!workLogEntryIsToolLike(entry)) { + return false; + } + return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n")); +} + +function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { + if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { + return false; + } + if (entry.tone === "thinking") { + return false; + } + return ( + entry.toolLifecycleStatus !== "inProgress" && + entry.toolLifecycleStatus !== "stopped" && + entry.toolLifecycleStatus !== "failed" && + entry.toolLifecycleStatus !== "declined" + ); +} + +function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { + if (!workLogEntryIsToolLike(entry)) { + return null; + } + if (workEntryIndicatesToolFailure(entry)) { + return "failure"; + } + if (workEntryIndicatesToolSuccess(entry)) { + return "success"; + } + return "neutral"; +} + function workEntryPreview( workEntry: Pick, ): string | null { @@ -592,6 +720,22 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } +function extractWorkLogToolLifecycleStatus( + payload: Record | null, +): WorkLogToolLifecycleStatus | undefined { + const status = payload?.status; + if ( + status === "inProgress" || + status === "completed" || + status === "failed" || + status === "declined" || + status === "stopped" + ) { + return status; + } + return undefined; +} + function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; @@ -743,7 +887,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th } const previous = grouped.at(-1); - if (previous?.type === "activity-group") { + if (previous?.type === "activity-group" && previous.turnId === entry.turnId) { grouped[grouped.length - 1] = { ...previous, activities: [...previous.activities, entry.activity], @@ -755,6 +899,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th type: "activity-group", id: entry.id, createdAt: entry.createdAt, + turnId: entry.turnId, activities: [entry.activity], }); } @@ -762,6 +907,179 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th return grouped; } +function computeElapsedMs(startIso: string, endIso: string): number | null { + const start = Date.parse(startIso); + const end = Date.parse(endIso); + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + return Math.max(0, end - start); +} + +function maxIsoTimestamp(a: string | null, b: string | null): string | null { + if (a === null) return b; + if (b === null) return a; + const aMs = Date.parse(a); + const bMs = Date.parse(b); + if (!Number.isFinite(aMs)) return b; + if (!Number.isFinite(bMs)) return a; + return bMs > aMs ? b : a; +} + +function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null { + if (!latestTurn) { + return null; + } + const settled = latestTurn.completedAt !== null && latestTurn.state !== "running"; + return settled ? null : latestTurn.turnId; +} + +interface ThreadFeedTurnFold { + readonly turnId: TurnId; + readonly createdAt: string; + readonly hiddenEntryIds: ReadonlySet; + readonly label: string; +} + +function deriveThreadFeedTurnFolds( + feed: ReadonlyArray, + latestTurn: ThreadFeedLatestTurn | null, +): ReadonlyMap { + const terminalAssistantMessageIdByTurn = new Map(); + for (const entry of feed) { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); + } + } + + interface TurnGroup { + readonly entries: ThreadFeedEntry[]; + readonly startBoundary: string | null; + } + const groupsByTurnId = new Map(); + let pendingUserBoundary: string | null = null; + for (const entry of feed) { + if (entry.type === "message" && entry.message.role === "user") { + pendingUserBoundary = entry.message.createdAt; + continue; + } + const turnId = + entry.type === "message" && entry.message.role === "assistant" + ? entry.message.turnId + : entry.type === "activity-group" + ? entry.turnId + : null; + if (!turnId) { + continue; + } + let group = groupsByTurnId.get(turnId); + if (!group) { + group = { + entries: [], + startBoundary: pendingUserBoundary, + }; + pendingUserBoundary = null; + groupsByTurnId.set(turnId, group); + } + group.entries.push(entry); + } + + const unsettledTurnId = deriveUnsettledTurnId(latestTurn); + const foldsByAnchorId = new Map(); + for (const [turnId, group] of groupsByTurnId) { + const { entries } = group; + if (turnId === unsettledTurnId) { + continue; + } + if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) { + continue; + } + + const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); + const hiddenEntryIds = new Set( + entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), + ); + if (hiddenEntryIds.size === 0) { + continue; + } + + const firstEntry = entries[0]; + const lastEntry = entries.at(-1); + if (!firstEntry || !lastEntry) { + continue; + } + const terminalEntry = terminalAssistantMessageId + ? entries.find((entry) => entry.id === terminalAssistantMessageId) + : null; + const latestTurnMatches = latestTurn?.turnId === turnId; + const lastEntryEnd = + lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; + const elapsedMs = + latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt + ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt) + : computeElapsedMs( + group.startBoundary ?? firstEntry.createdAt, + maxIsoTimestamp( + terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null, + lastEntryEnd, + ) ?? lastEntryEnd, + ); + const duration = elapsedMs === null ? null : formatDuration(elapsedMs); + const interrupted = latestTurnMatches && latestTurn.state === "interrupted"; + const label = interrupted + ? duration + ? `You stopped after ${duration}` + : "You stopped this response" + : duration + ? `Worked for ${duration}` + : "Worked"; + + foldsByAnchorId.set(firstEntry.id, { + turnId, + createdAt: firstEntry.createdAt, + hiddenEntryIds, + label, + }); + } + return foldsByAnchorId; +} + +export function deriveThreadFeedPresentation( + feed: ReadonlyArray, + latestTurn: ThreadFeedLatestTurn | null, + expandedTurnIds: ReadonlySet, +): ThreadFeedEntry[] { + const sourceFeed = feed.filter((entry) => entry.type !== "turn-fold"); + const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); + const collapsedEntryIds = new Set(); + for (const fold of foldsByAnchorId.values()) { + if (!expandedTurnIds.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); + } + } + } + + const result: ThreadFeedEntry[] = []; + for (const entry of sourceFeed) { + const fold = foldsByAnchorId.get(entry.id); + if (fold) { + result.push({ + type: "turn-fold", + id: `turn-fold:${fold.turnId}`, + createdAt: fold.createdAt, + turnId: fold.turnId, + label: fold.label, + expanded: expandedTurnIds.has(fold.turnId), + }); + } + if (!collapsedEntryIds.has(entry.id)) { + result.push(entry); + } + } + return result; +} + export function derivePendingApprovals( activities: ReadonlyArray, ): PendingApproval[] { @@ -893,10 +1211,7 @@ export function buildThreadFeed( const loadedMessages = options?.loadedMessages ?? thread.messages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; - const workLogEntries = deriveWorkLogEntries( - thread.activities, - thread.latestTurn?.turnId ?? undefined, - ); + const workLogEntries = deriveWorkLogEntries(thread.activities); const entries = Arr.sortWith( [ ...loadedMessages.map((message) => ({ @@ -921,18 +1236,36 @@ export function buildThreadFeed( oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt ); }) - .map((entry) => ({ - type: "activity", - id: entry.id, - createdAt: entry.createdAt, - activity: { + .map((entry) => { + const summary = workEntryHeading(entry); + const detail = workEntryPreview(entry); + const normalizedFullDetail = entry.detail + ? unwrapKnownShellCommandWrapper(entry.detail) + : null; + const fullDetail = + normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null; + return { + type: "activity", id: entry.id, createdAt: entry.createdAt, - summary: workEntryHeading(entry), - detail: workEntryPreview(entry), - status: null, - }, - })), + turnId: entry.turnId, + activity: { + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + summary, + detail, + fullDetail, + copyText: [summary, detail, fullDetail] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"), + toolLike: workLogEntryIsToolLike(entry), + status: workEntryStatus(entry), + }, + }; + }), ], (s) => new Date(s.createdAt), Order.Date, diff --git a/apps/mobile/src/lib/threadFeedLayout.test.ts b/apps/mobile/src/lib/threadFeedLayout.test.ts new file mode 100644 index 00000000000..73f113eac38 --- /dev/null +++ b/apps/mobile/src/lib/threadFeedLayout.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isThreadFeedNearEnd, + resolveThreadFeedBottomInset, + threadFeedDistanceFromEnd, +} from "./threadFeedLayout"; + +describe("thread feed layout", () => { + it("accounts for the bottom inset when measuring distance from the end", () => { + const metrics = { + contentHeight: 900, + viewportHeight: 600, + offsetY: 380, + bottomInset: 100, + }; + + expect(threadFeedDistanceFromEnd(metrics)).toBe(20); + expect(isThreadFeedNearEnd(metrics, 50)).toBe(true); + expect(isThreadFeedNearEnd(metrics, 10)).toBe(false); + }); + + it("does not double count chrome already included in the measured composer overlay", () => { + expect( + resolveThreadFeedBottomInset({ + estimatedOverlayHeight: 162, + measuredOverlayHeight: 182, + gap: 8, + }), + ).toBe(190); + }); +}); diff --git a/apps/mobile/src/lib/threadFeedLayout.ts b/apps/mobile/src/lib/threadFeedLayout.ts new file mode 100644 index 00000000000..de7946f866d --- /dev/null +++ b/apps/mobile/src/lib/threadFeedLayout.ts @@ -0,0 +1,22 @@ +export interface ThreadFeedScrollMetrics { + readonly contentHeight: number; + readonly viewportHeight: number; + readonly offsetY: number; + readonly bottomInset: number; +} + +export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number { + return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY; +} + +export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean { + return threadFeedDistanceFromEnd(metrics) <= threshold; +} + +export function resolveThreadFeedBottomInset(input: { + readonly estimatedOverlayHeight: number; + readonly measuredOverlayHeight: number; + readonly gap: number; +}): number { + return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx new file mode 100644 index 00000000000..488766f3695 --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -0,0 +1,21 @@ +import { + SelectableMarkdownText as T3SelectableMarkdownText, + type SelectableMarkdownTextProps, +} from "@t3tools/mobile-markdown-text/renderer"; + +import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { + return ; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx new file mode 100644 index 00000000000..403f32a1de4 --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -0,0 +1,16 @@ +import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return false; +} + +export function SelectableMarkdownText(_props: MobileSelectableMarkdownTextProps) { + return null; +} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx new file mode 100644 index 00000000000..6778b0455d5 --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -0,0 +1,180 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { requireNativeView } from "expo"; +import { useImperativeHandle, useMemo, useRef, type Ref } from "react"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; +import { Image, StyleSheet } from "react-native"; + +import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { useThemeColor } from "../lib/useThemeColor"; +import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; + +const NATIVE_MODULE_NAME = "T3ComposerEditor"; +const EMPTY_SKILLS: NonNullable = []; + +type NativeEditorEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly selection: ComposerEditorSelection; +}>; + +type NativeSelectionEvent = NativeSyntheticEvent<{ + readonly selection: ComposerEditorSelection; +}>; + +type NativePasteImagesEvent = NativeSyntheticEvent<{ + readonly uris: ReadonlyArray; +}>; + +interface NativeComposerEditorRef { + focus: () => Promise; + blur: () => Promise; + setSelection: (start: number, end: number) => Promise; +} + +interface NativeComposerEditorProps extends ViewProps { + readonly ref?: Ref; + readonly value: string; + readonly tokensJson: string; + readonly selectionJson: string; + readonly themeJson: string; + readonly placeholder: string; + readonly fontFamily: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly contentInsetVertical: number; + readonly editable: boolean; + readonly scrollEnabled: boolean; + readonly autoFocus: boolean; + readonly autoCorrect: boolean; + readonly spellCheck: boolean; + readonly onComposerChange: (event: NativeEditorEvent) => void; + readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; + readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; + readonly onComposerFocus?: () => void; + readonly onComposerBlur?: () => void; +} + +const NativeView = requireNativeView(NATIVE_MODULE_NAME); + +function basename(path: string): string { + const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separator >= 0 ? path.slice(separator + 1) : path; +} + +function fileIconUri(path: string): string { + return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri; +} + +export function ComposerEditor({ + ref, + skills = EMPTY_SKILLS, + selection, + style, + textStyle, + onChangeText, + onSelectionChange, + onPasteImages, + onFocus, + onBlur, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const nativeRef = useRef(null); + const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); + const textColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const chipBackground = useThemeColor("--color-subtle"); + const chipBorder = useThemeColor("--color-border"); + const chipText = useThemeColor("--color-foreground"); + const skillBackground = useThemeColor("--color-inline-skill-background"); + const skillBorder = useThemeColor("--color-inline-skill-border"); + const skillText = useThemeColor("--color-inline-skill-foreground"); + const fileTint = useThemeColor("--color-icon-muted"); + + useImperativeHandle( + ref, + () => ({ + focus: () => void nativeRef.current?.focus(), + blur: () => void nativeRef.current?.blur(), + setSelection: (nextSelection) => + void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + const skillLabels = useMemo( + () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])), + [skills], + ); + const tokensJson = useMemo(() => { + const tokens = collectComposerInlineTokens(props.value, { + preserveTrailingFrom: confirmedTokensRef.current, + }); + confirmedTokensRef.current = tokens; + return JSON.stringify( + tokens.map((token) => ({ + type: token.type, + source: token.source, + start: token.start, + end: token.end, + label: + token.type === "skill" + ? (skillLabels.get(token.value) ?? token.value) + : basename(token.value), + iconUri: token.type === "mention" ? fileIconUri(token.value) : null, + })), + ); + }, [props.value, skillLabels]); + const themeJson = JSON.stringify({ + text: String(textColor), + placeholder: String(placeholderColor), + chipBackground: String(chipBackground), + chipBorder: String(chipBorder), + chipText: String(chipText), + skillBackground: String(skillBackground), + skillBorder: String(skillBorder), + skillText: String(skillText), + fileTint: String(fileTint), + }); + const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; + return ( + } + onComposerChange={(event) => { + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + }} + onComposerSelectionChange={(event) => onSelectionChange?.(event.nativeEvent.selection)} + onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerFocus={onFocus} + onComposerBlur={onBlur} + /> + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx new file mode 100644 index 00000000000..0f20e9e042d --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -0,0 +1,65 @@ +import { TextInputWrapper } from "expo-paste-input"; +import { useImperativeHandle, useRef } from "react"; +import { TextInput, type TextInput as RNTextInput } from "react-native"; + +import { useThemeColor } from "../lib/useThemeColor"; +import { useNativePaste } from "../lib/useNativePaste"; +import type { ComposerEditorProps } from "./T3ComposerEditor.types"; + +export function ComposerEditor({ + ref, + skills: _skills, + selection, + onPasteImages, + style, + textStyle, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const inputRef = useRef(null); + const foregroundColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); + + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + setSelection: (nextSelection) => + inputRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + return ( + + props.onSelectionChange?.(event.nativeEvent.selection)} + multiline={props.multiline ?? true} + placeholderTextColor={placeholderColor} + style={[ + { + flex: 1, + minHeight: 0, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + fontSize: 15, + lineHeight: 22, + paddingVertical: contentInsetVertical, + }, + textStyle, + ]} + /> + + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts new file mode 100644 index 00000000000..d70d63fa437 --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -0,0 +1,38 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { Ref } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; + +export type ComposerEditorSelection = { + readonly start: number; + readonly end: number; +}; + +export interface ComposerEditorHandle { + focus: () => void; + blur: () => void; + setSelection: (selection: ComposerEditorSelection) => void; +} + +export interface ComposerEditorProps { + readonly ref?: Ref; + readonly value: string; + readonly skills?: ReadonlyArray< + Pick + >; + readonly selection?: ComposerEditorSelection; + readonly placeholder?: string; + readonly autoFocus?: boolean; + readonly editable?: boolean; + readonly scrollEnabled?: boolean; + readonly autoCorrect?: boolean; + readonly spellCheck?: boolean; + readonly multiline?: boolean; + readonly contentInsetVertical?: number; + readonly style?: StyleProp; + readonly textStyle?: StyleProp; + readonly onChangeText: (value: string) => void; + readonly onSelectionChange?: (selection: ComposerEditorSelection) => void; + readonly onPasteImages?: (uris: ReadonlyArray) => void; + readonly onFocus?: () => void; + readonly onBlur?: () => void; +} diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 5cbd6c442f5..56ada5f2a02 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -58,9 +58,9 @@ export function AgentActivity( : "now"; const activeLabel = `${props.activeCount} active`; const isLight = environment.colorScheme === "light"; - const primaryForeground = isLight ? "#0f172a" : "#ffffff"; - const secondaryForeground = isLight ? "#475569" : "#cbd5e1"; - const mutedForeground = isLight ? "#64748b" : "#94a3b8"; + const primaryForeground = isLight ? "#262626" : "#f5f5f5"; + const secondaryForeground = isLight ? "#525252" : "#a3a3a3"; + const mutedForeground = isLight ? "#737373" : "#8e8e93"; const tint = environment.isLuminanceReduced ? secondaryForeground : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" diff --git a/apps/server/package.json b/apps/server/package.json index 866962cf7f2..a6aa8c2f0a4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,7 +28,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@ff-labs/fff-node": "^0.9.4", + "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/web/package.json b/apps/web/package.json index 7704365b0ec..d6751c73486 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,8 +14,8 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", - "@clerk/clerk-js": "^6.13.0", - "@clerk/react": "^6.7.2", + "@clerk/clerk-js": "^6.16.0", + "@clerk/react": "^6.9.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 16846646652..8b9a53808f8 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -2,6 +2,10 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, } from "./lib/terminalContext"; +import { + collectComposerInlineTokens, + type ComposerInlineToken, +} from "@t3tools/shared/composerInlineTokens"; export type ComposerPromptSegment = | { @@ -22,12 +26,6 @@ export type ComposerPromptSegment = context: TerminalContextDraft | null; }; -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; -const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; -const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; -const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; -const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; - function rangeIncludesIndex(start: number, end: number, index: number): boolean { return start <= index && index < end; } @@ -42,84 +40,6 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void segments.push({ type: "text", text }); } -type InlineTokenMatch = - | { - type: "mention"; - value: string; - start: number; - end: number; - } - | { - type: "skill"; - value: string; - start: number; - end: number; - }; - -type MentionTokenMatch = Extract; - -function collectMentionTokenMatches(text: string): MentionTokenMatch[] { - const matches: MentionTokenMatch[] = []; - - for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); - const encodedPath = match[3] ?? ""; - let path = encodedPath; - try { - path = decodeURIComponent(encodedPath); - } catch { - // Keep the source value when malformed percent escapes are present. - } - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; - const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); - if (!path || hasExternalScheme || label !== basename) { - continue; - } - const matchIndex = match.index ?? 0; - const start = matchIndex + prefix.length; - const end = start + fullMatch.length - prefix.length; - matches.push({ type: "mention", value: path, start, end }); - } - - for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const quotedPath = match[2]; - const unquotedPath = match[3]; - const path = - quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (unquotedPath ?? ""); - const matchIndex = match.index ?? 0; - const start = matchIndex + prefix.length; - const end = start + fullMatch.length - prefix.length; - if (path.length > 0) { - matches.push({ type: "mention", value: path, start, end }); - } - } - - return matches; -} - -function collectInlineTokenMatches(text: string): InlineTokenMatch[] { - const matches: InlineTokenMatch[] = collectMentionTokenMatches(text); - - for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const skillName = match[2] ?? ""; - const matchIndex = match.index ?? 0; - const start = matchIndex + prefix.length; - const end = start + fullMatch.length - prefix.length; - if (skillName.length > 0) { - matches.push({ type: "skill", value: skillName, start, end }); - } - } - - return matches.toSorted((left, right) => left.start - right.start); -} - function forEachPromptSegmentSlice( prompt: string, visitor: ( @@ -186,10 +106,16 @@ function forEachPromptTextSlice( function forEachMentionMatch( prompt: string, - visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void, + visitor: ( + match: Extract, + promptOffset: number, + ) => boolean | void, ): boolean { return forEachPromptTextSlice(prompt, (text, promptOffset) => { - for (const match of collectMentionTokenMatches(text)) { + for (const match of collectComposerInlineTokens(text)) { + if (match.type !== "mention") { + continue; + } if (visitor(match, promptOffset) === true) { return true; } @@ -204,7 +130,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen return segments; } - const tokenMatches = collectInlineTokenMatches(text); + const tokenMatches = collectComposerInlineTokens(text); let cursor = 0; for (const match of tokenMatches) { if (match.start < cursor) { @@ -219,7 +145,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen segments.push({ type: "mention", path: match.value, - source: text.slice(match.start, match.end), + source: match.source, }); } else { segments.push({ type: "skill", name: match.value }); diff --git a/infra/relay/package.json b/infra/relay/package.json index 4fa7686e76e..213c1fe5cc8 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -9,7 +9,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@clerk/backend": "3.4.14", + "@clerk/backend": "3.6.1", "@effect/sql-pg": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", diff --git a/packages/shared/package.json b/packages/shared/package.json index d8d34b34d4d..2360b7c2a21 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -151,6 +151,10 @@ "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" }, + "./composerInlineTokens": { + "types": "./src/composerInlineTokens.ts", + "import": "./src/composerInlineTokens.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts new file mode 100644 index 00000000000..f99d0b6654e --- /dev/null +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { collectComposerInlineTokens } from "./composerInlineTokens.ts"; + +describe("collectComposerInlineTokens", () => { + it("collects file links, mentions, and skills with source ranges", () => { + const text = "Use $ui and inspect [Chat.tsx](src/Chat.tsx) with @AGENTS.md please"; + + expect(collectComposerInlineTokens(text)).toEqual([ + { + type: "skill", + value: "ui", + source: "$ui", + start: 4, + end: 7, + }, + { + type: "mention", + value: "src/Chat.tsx", + source: "[Chat.tsx](src/Chat.tsx)", + start: 20, + end: 44, + }, + { + type: "mention", + value: "AGENTS.md", + source: "@AGENTS.md", + start: 50, + end: 60, + }, + ]); + }); + + it("does not convert incomplete trailing tokens", () => { + expect(collectComposerInlineTokens("Use $ui")).toEqual([]); + expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]); + }); + + it("keeps the delimiter after a token outside its source range", () => { + const text = "Inspect [package.json](package.json) next"; + + expect(collectComposerInlineTokens(text)).toEqual([ + { + type: "mention", + value: "package.json", + source: "[package.json](package.json)", + start: 8, + end: 36, + }, + ]); + expect(text.slice(36)).toBe(" next"); + }); + + it("preserves a confirmed pill when only its trailing delimiter is removed", () => { + const withDelimiter = "[package.json](package.json) "; + const confirmed = collectComposerInlineTokens(withDelimiter); + + expect( + collectComposerInlineTokens(withDelimiter.trimEnd(), { preserveTrailingFrom: confirmed }), + ).toEqual([ + { + type: "mention", + value: "package.json", + source: "[package.json](package.json)", + start: 0, + end: 28, + }, + ]); + }); + + it("does not preserve a pill after its source is edited", () => { + const confirmed = collectComposerInlineTokens("[package.json](package.json) "); + + expect( + collectComposerInlineTokens("[package.json](package-json)", { + preserveTrailingFrom: confirmed, + }), + ).toEqual([]); + }); + + it("ignores normal web links", () => { + expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]); + }); +}); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts new file mode 100644 index 00000000000..aa5e67d6fc8 --- /dev/null +++ b/packages/shared/src/composerInlineTokens.ts @@ -0,0 +1,118 @@ +export type ComposerInlineToken = + | { + readonly type: "mention"; + readonly value: string; + readonly source: string; + readonly start: number; + readonly end: number; + } + | { + readonly type: "skill"; + readonly value: string; + readonly source: string; + readonly start: number; + readonly end: number; + }; + +export interface CollectComposerInlineTokensOptions { + readonly preserveTrailingFrom?: ReadonlyArray; +} + +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; +const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; +const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; +const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; + +function collectMentionTokens(text: string): ComposerInlineToken[] { + const matches: ComposerInlineToken[] = []; + + for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); + const encodedPath = match[3] ?? ""; + let path = encodedPath; + try { + path = decodeURIComponent(encodedPath); + } catch { + // Preserve malformed source rather than dropping a user-authored token. + } + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); + if (!path || hasExternalScheme || label !== basename) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + fullMatch.length - prefix.length; + matches.push({ + type: "mention", + value: path, + source: text.slice(start, end), + start, + end, + }); + } + + for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const quotedPath = match[2]; + const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); + if (!path) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + fullMatch.length - prefix.length; + matches.push({ + type: "mention", + value: path, + source: text.slice(start, end), + start, + end, + }); + } + + return matches; +} + +export function collectComposerInlineTokens( + text: string, + options: CollectComposerInlineTokensOptions = {}, +): ReadonlyArray { + const matches = collectMentionTokens(text); + + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const value = match[2] ?? ""; + if (!value) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + fullMatch.length - prefix.length; + matches.push({ + type: "skill", + value, + source: text.slice(start, end), + start, + end, + }); + } + + for (const token of options.preserveTrailingFrom ?? []) { + if ( + token.end === text.length && + text.slice(token.start, token.end) === token.source && + !matches.some( + (match) => + match.type === token.type && match.start === token.start && match.end === token.end, + ) + ) { + matches.push(token); + } + } + + return [...matches].sort((left, right) => left.start - right.start); +} diff --git a/patches/@ff-labs__fff-node@0.9.4.patch b/patches/@ff-labs__fff-node@0.9.4.patch new file mode 100644 index 00000000000..2d0c16133eb --- /dev/null +++ b/patches/@ff-labs__fff-node@0.9.4.patch @@ -0,0 +1,37 @@ +diff --git a/dist/src/binary.js b/dist/src/binary.js +index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef10af75ca7 100644 +--- a/dist/src/binary.js ++++ b/dist/src/binary.js +@@ -7,7 +7,7 @@ + */ + import { existsSync, readFileSync } from "node:fs"; + import { createRequire } from "node:module"; +-import { dirname, join } from "node:path"; ++import { dirname, join, sep } from "node:path"; + import { fileURLToPath } from "node:url"; + import { getLibFilename, getNpmPackageName } from "./platform.js"; + /** +@@ -46,6 +46,14 @@ function getPackageDir() { + // Fallback: assume we're one level deep in src/ + return dirname(currentDir); + } ++function resolveUnpackedAsarPath(binaryPath) { ++ const asarSegment = `${sep}app.asar${sep}`; ++ if (!binaryPath.includes(asarSegment)) { ++ return binaryPath; ++ } ++ const unpackedPath = binaryPath.replace(asarSegment, `${sep}app.asar.unpacked${sep}`); ++ return existsSync(unpackedPath) ? unpackedPath : binaryPath; ++} + /** + * Check if the binary exists in any known location + */ +@@ -69,7 +77,7 @@ function resolveFromNpmPackage() { + const packageDir = dirname(packageJsonPath); + const binaryPath = join(packageDir, getLibFilename()); + if (existsSync(binaryPath)) { +- return binaryPath; ++ return resolveUnpackedAsarPath(binaryPath); + } + } + catch { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1bd60b2fc9..17d3f4d63e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ patchedDependencies: '@expo/metro-config@56.0.13': hash: 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 path: patches/@expo%2Fmetro-config@56.0.13.patch + '@ff-labs/fff-node@0.9.4': + hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 + path: patches/@ff-labs__fff-node@0.9.4.patch '@pierre/diffs@1.3.0-beta.4': hash: f5d41705ce94bbafc731d92e9cb1671db710df046dd135cb894e3e3e9164a75b path: patches/@pierre%2Fdiffs@1.3.0-beta.4.patch @@ -176,8 +179,8 @@ importers: specifier: ^0.7.1 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: ^3.3.0 - version: 3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ^3.4.1 + version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0) @@ -220,6 +223,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/mobile-markdown-text': + specifier: file:./modules/t3-markdown-text + version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -241,6 +247,9 @@ importers: expo: specifier: ^56.0.0 version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-asset: + specifier: ~56.0.15 + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-auth-session: specifier: ~56.0.12 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -365,6 +374,9 @@ importers: '@effect/vitest': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@pierre/trees': + specifier: 1.0.0-beta.4 + version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/react': specifier: ~19.2.0 version: 19.2.16 @@ -396,8 +408,8 @@ importers: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) '@ff-labs/fff-node': - specifier: ^0.9.4 - version: 0.9.4 + specifier: 0.9.4 + version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -448,11 +460,11 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/clerk-js': - specifier: ^6.13.0 - version: 6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^6.16.0 + version: 6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: ^6.7.2 - version: 6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^6.9.0 + version: 6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -611,8 +623,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.4.14 - version: 3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.6.1 + version: 3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) @@ -1506,16 +1518,16 @@ packages: resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.4.14': - resolution: {integrity: sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==} + '@clerk/backend@3.6.1': + resolution: {integrity: sha512-LkfekzF/0UMXacX+17xy3ExRraO0mm+thXejC8Q32gWHd1wLdxK3YXDsLDF00E1r1InWBKIt2ZOxs6hTwZPJjA==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.14.0': - resolution: {integrity: sha512-xreDPw31OIk/VQj36qdgjzc4Rk2HwMar25nOu/ts2gf7PrbhU4XQdrtnt74g4fTmSMp8xeyjzHqa9adDXVjISw==} + '@clerk/clerk-js@6.16.0': + resolution: {integrity: sha512-8xv/XDsxhOZd1n4DNIRJ2EehIRUg6UiqKAnfd0L88R2t1g6sVnLi1FInJ5i8Qyx5oY/creXx6X1AZ1V5PobRkA==} engines: {node: '>=20.9.0'} - '@clerk/expo@3.3.1': - resolution: {integrity: sha512-c4g64z5sgJoGYjK0NeasNwOMy9Di7cEjICq56BHSowdOuB+6UGtWBNw+yHzgS1gxi2kJgl7WQCmmXRsoZNWxAg==} + '@clerk/expo@3.4.1': + resolution: {integrity: sha512-gpAXsuUnsUdUD0/2XjyxaC9quF5rT+2umkmV74nBLVAFurGMMMMHvnHqrQEtZ7tH5GHNXYw5+pgmnzd1HiMQbQ==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/expo-passkeys': '>=0.0.6' @@ -1547,16 +1559,18 @@ packages: optional: true expo-web-browser: optional: true + react-dom: + optional: true - '@clerk/react@6.7.3': - resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==} + '@clerk/react@6.9.0': + resolution: {integrity: sha512-M0QGyGS732tYBXeG+28UgElXM2TfoSZ+4mWGisC8yxJX8NjH4hEPJTAQuZmYRLNaCyGQCuzjYVQiQRC+GbDtmA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.15.0': - resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==} + '@clerk/shared@4.17.0': + resolution: {integrity: sha512-YeQ+6zDmqyor1mPHjZx18j+LssL6Pobvid8hb7HQMioSo8sGDBEVi/Z12bs+gUhe9KbdP+ygHsKOqqeGAPuPZA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -4165,6 +4179,17 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': + resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} + peerDependencies: + expo-asset: '*' + expo-clipboard: '*' + expo-haptics: '*' + expo-symbols: '*' + react: '*' + react-native: '*' + react-native-nitro-markdown: '*' + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} @@ -10826,18 +10851,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10852,9 +10877,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10869,15 +10894,14 @@ snapshots: - react - react-dom - '@clerk/expo@3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@clerk/clerk-js': 6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 @@ -10887,22 +10911,23 @@ snapshots: expo-crypto: 56.0.4(expo@56.0.8) expo-secure-store: 56.0.4(expo@56.0.8) expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-dom: 19.2.3(react@19.2.3) - '@clerk/react@6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -10912,7 +10937,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11854,7 +11879,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.9.4': optional: true - '@ff-labs/fff-node@0.9.4': + '@ff-labs/fff-node@0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)': dependencies: ffi-rs: 1.3.2 optionalDependencies: @@ -12677,6 +12702,13 @@ snapshots: react-dom: 19.2.6(react@19.2.6) shiki: 3.23.0 + '@pierre/trees@1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + preact: 11.0.0-beta.0 + preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: preact: 11.0.0-beta.0 @@ -13449,6 +13481,16 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)': + dependencies: + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-haptics: 56.0.3(expo@56.0.8) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 605fe4df4aa..58f4b6e0dfe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,22 +1,11 @@ packages: - - "apps/*" - - "infra/*" - - "oxlint-plugin-t3code" - - "packages/*" - - "scripts" - -onlyBuiltDependencies: - - electron - - esbuild - - msgpackr-extract - - node-pty - - sharp - -ignoredBuiltDependencies: - - msw + - apps/* + - infra/* + - oxlint-plugin-t3code + - packages/* + - scripts catalog: - effect: 4.0.0-beta.78 "@effect/atom-react": 4.0.0-beta.78 "@effect/openapi-generator": 4.0.0-beta.78 "@effect/platform-bun": 4.0.0-beta.78 @@ -24,23 +13,33 @@ catalog: "@effect/platform-node-shared": 4.0.0-beta.78 "@effect/sql-pg": 4.0.0-beta.78 "@effect/sql-sqlite-bun": 4.0.0-beta.78 - "@effect/vitest": 4.0.0-beta.78 "@effect/tsgo": 0.13.2 + "@effect/vitest": 4.0.0-beta.78 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 "@pierre/diffs": 1.3.0-beta.4 - "@vitest/runner": 4.1.8 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260604.1 + "@vitest/runner": 4.1.8 + effect: 4.0.0-beta.78 jose: 6.2.2 typescript: ~6.0.3 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.24 vite: npm:@voidzero-dev/vite-plus-core@0.1.24 vite-plus: 0.1.24 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.24 yaml: ^2.9.0 + +ignoredBuiltDependencies: + - msw + +onlyBuiltDependencies: + - electron + - esbuild + - msgpackr-extract + - node-pty + - sharp + overrides: - # Clerk publishes wallet auth integrations as required dependencies. T3 Code does - # not support wallet auth, so keep that unused dependency tree out of installs. "@clerk/clerk-js>@base-org/account": "-" "@clerk/clerk-js>@coinbase/wallet-sdk": "-" "@clerk/clerk-js>@solana/wallet-adapter-base": "-" @@ -56,22 +55,13 @@ overrides: "@effect/vitest": "catalog:" "@effect/vitest>@vitest/runner": "-" "@effect/vitest>vitest": "-" - # Pinned to the version the patch in patchedDependencies targets — a fresh - # resolve (e.g. release-smoke regenerating the lockfile) must not drift to a - # newer release and orphan the patch (ERR_PNPM_UNUSED_PATCH). - "@expo/metro-config": "56.0.13" + "@expo/metro-config": 56.0.13 "@types/node": "catalog:" effect: "catalog:" vite: "catalog:" vitest: "catalog:" yaml: "catalog:" -peerDependencyRules: - allowAny: - - vite - - vitest - allowedVersions: - vite: "*" - vitest: "*" + packageExtensions: "@effect/vitest@*": dependencies: @@ -79,14 +69,22 @@ packageExtensions: peerDependenciesMeta: vitest: optional: true - "vite-plus@*": + vite-plus@*: dependencies: vite: "catalog:" + patchedDependencies: - # Keep non-browser consumers off the root entrypoint, which eagerly imports - # DOM-dependent renderers. These utility/type subpaths are safe for mobile. - "@pierre/diffs@1.3.0-beta.4": patches/@pierre%2Fdiffs@1.3.0-beta.4.patch "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch + "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch + "@pierre/diffs@1.3.0-beta.4": patches/@pierre%2Fdiffs@1.3.0-beta.4.patch effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch + +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: "*" + vitest: "*" diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 974f3d036f0..8135f7e259d 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -6,8 +6,11 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { + createStageWorkspaceConfig, createStagePnpmConfig, + DESKTOP_ASAR_UNPACK, resolveDesktopRuntimeDependencies, + resolveFffNativeDependencies, resolveBuildOptions, resolveDesktopBuildIconAssets, resolveDesktopProductName, @@ -15,6 +18,7 @@ import { resolveGitHubPublishConfig, resolveMockUpdateServerPort, resolveMockUpdateServerUrl, + STAGE_INSTALL_ARGS, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -114,17 +118,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { createStagePnpmConfig( { "@expo/metro-config@56.0.13": "patches/@expo%2Fmetro-config@56.0.13.patch", + "@ff-labs/fff-node@0.9.4": "patches/@ff-labs__fff-node@0.9.4.patch", "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch", "alchemy@2.0.0-beta.49": "patches/alchemy@2.0.0-beta.49.patch", "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch", }, { + "@ff-labs/fff-node": "0.9.4", "@pierre/diffs": "1.1.20", effect: "4.0.0-beta.73", }, ), { patchedDependencies: { + "@ff-labs/fff-node@0.9.4": "patches/@ff-labs__fff-node@0.9.4.patch", "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch", "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch", }, @@ -142,6 +149,49 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); }); + it("installs optional native dependencies for the target desktop architecture", () => { + assert.deepStrictEqual(STAGE_INSTALL_ARGS, ["install", "--prod"]); + assert.deepStrictEqual(createStageWorkspaceConfig("mac", "x64"), { + supportedArchitectures: { + os: ["darwin"], + cpu: ["x64"], + }, + }); + assert.deepStrictEqual(createStageWorkspaceConfig("win", "arm64"), { + supportedArchitectures: { + os: ["win32"], + cpu: ["arm64"], + }, + }); + assert.deepStrictEqual(createStageWorkspaceConfig("mac", "universal"), { + supportedArchitectures: { + os: ["darwin"], + cpu: ["arm64", "x64"], + }, + }); + }); + + it("unpacks the fff shared library for filesystem and FFI access", () => { + assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); + }); + + it("promotes target fff binaries to direct staged dependencies", () => { + assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { + "@ff-labs/fff-bin-darwin-arm64": "0.9.4", + }); + assert.deepStrictEqual(resolveFffNativeDependencies("mac", "universal", "0.9.4"), { + "@ff-labs/fff-bin-darwin-arm64": "0.9.4", + "@ff-labs/fff-bin-darwin-x64": "0.9.4", + }); + assert.deepStrictEqual(resolveFffNativeDependencies("win", "x64", "0.9.4"), { + "@ff-labs/fff-bin-win32-x64": "0.9.4", + }); + assert.deepStrictEqual(resolveFffNativeDependencies("linux", "arm64", "0.9.4"), { + "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", + "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", + }); + }); + it("falls back to the default mock update port when the configured port is blank", () => { assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000"); assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index c11da53f4c1..b9788ddfa7c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -38,11 +38,19 @@ const WorkspaceConfig = Schema.Struct({ }); type WorkspaceConfig = typeof WorkspaceConfig.Type; +const StageWorkspaceConfig = Schema.Struct({ + supportedArchitectures: Schema.Struct({ + os: Schema.Array(Schema.String), + cpu: Schema.Array(Schema.String), + }), +}); + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); +const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { const fs = yield* FileSystem.FileSystem; @@ -282,6 +290,47 @@ interface StagePackageJson { }; } +export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; +export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; + +export function resolveFffNativeDependencies( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, + version: string, +): Record { + const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch]; + + if (platform === "mac") { + return Object.fromEntries( + architectures.map((architecture) => [`@ff-labs/fff-bin-darwin-${architecture}`, version]), + ); + } + + if (platform === "win") { + return Object.fromEntries( + architectures.map((architecture) => [`@ff-labs/fff-bin-win32-${architecture}`, version]), + ); + } + + return Object.fromEntries( + architectures.flatMap((architecture) => + ["gnu", "musl"].map((libc) => [`@ff-labs/fff-bin-linux-${architecture}-${libc}`, version]), + ), + ); +} + +export function createStageWorkspaceConfig( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +): typeof StageWorkspaceConfig.Type { + return { + supportedArchitectures: { + os: [platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"], + cpu: arch === "universal" ? ["arm64", "x64"] : [arch], + }, + }; +} + export function createStagePnpmConfig( patchedDependencies: Record, dependencies: Record, @@ -702,6 +751,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( appId: "com.t3tools.t3code", productName: resolveDesktopProductName(version), artifactName: "T3-Code-${version}-${arch}.${ext}", + asarUnpack: [...DESKTOP_ASAR_UNPACK], directories: { buildResources: "apps/desktop/resources", }, @@ -909,6 +959,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stageDependencies = { ...resolvedServerDependencies, ...resolvedDesktopRuntimeDependencies, + ...resolveFffNativeDependencies( + options.platform, + options.arch, + serverPackageJson.dependencies["@ff-labs/fff-node"], + ), }; const stagePnpmConfig = createStagePnpmConfig(workspacePatchedDependencies, stageDependencies); const stagePackageJson: StagePackageJson = { @@ -939,19 +994,25 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stagePackageJsonString = yield* encodeJsonString(stagePackageJson); yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`); + const stageWorkspaceConfig = createStageWorkspaceConfig(options.platform, options.arch); + const stageWorkspaceConfigString = yield* encodeStageWorkspaceConfig(stageWorkspaceConfig); + yield* fs.writeFileString( + path.join(stageAppDir, "pnpm-workspace.yaml"), + stageWorkspaceConfigString, + ); if (Object.keys(workspacePatchedDependencies).length > 0) { yield* fs.copy(path.join(repoRoot, "patches"), path.join(stageAppDir, "patches")); } yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); - const installCommand = yield* resolveSpawnCommand("vp", ["install", "--prod", "--no-optional"]); + const installCommand = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]); yield* runCommand( ChildProcess.make(installCommand.command, installCommand.args, { cwd: stageAppDir, shell: installCommand.shell, }), - { label: "vp install --prod --no-optional", verbose: options.verbose }, + { label: "vp install --prod", verbose: options.verbose }, ); // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 01676087cbd..2fe67164666 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -26,6 +26,7 @@ const workspaceFiles = [ "apps/web/package.json", "apps/mobile/package.json", "apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz", + "apps/mobile/modules/t3-markdown-text/package.json", "apps/mobile/modules/t3-review-diff/package.json", "apps/mobile/modules/t3-terminal/package.json", "apps/marketing/package.json",