diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index ac32d74ec69..d7252d3f8d9 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -189,6 +189,55 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("keeps element picking active during subframe navigation", () => + withManager((manager) => + Effect.gen(function* () { + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isFocused: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + once: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + + listeners.get("did-start-navigation")?.({}, "about:blank", false, false); + yield* Effect.yieldNow; + expect(pick.pollUnsafe()).toBeUndefined(); + + listeners.get("did-start-navigation")?.({}, "https://example.com/next", false, true); + expect(yield* Fiber.join(pick)).toBeNull(); + }), + ), + ); + effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index f82741d908f..2c4096e8cfb 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1344,7 +1344,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }; const onDestroyed = () => settle(null); - const onNavigated = () => settle(null); + const onNavigated = ( + _event: Electron.Event, + _url: string, + _isInPlace: boolean, + isMainFrame: boolean, + ) => { + if (isMainFrame) settle(null); + }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { yield* attempt("pickElement.register", () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 2d77b64291b..c075691fe11 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -68,6 +68,7 @@ function makeFakeBrowserWindow() { once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), + setAutoHideCursor: vi.fn(), setTitle: vi.fn(), setTitleBarOverlay: vi.fn(), show: vi.fn(), @@ -78,6 +79,7 @@ function makeFakeBrowserWindow() { window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, openDevTools: webContents.openDevTools, + setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, }; } @@ -134,10 +136,17 @@ function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; + readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly openedExternalUrls?: unknown[]; }) { const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { - create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), + create: (options) => + Effect.sync(() => { + input.createdWindowOptions?.push(options); + }).pipe( + Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), + Effect.as(input.window), + ), main: Ref.get(input.mainWindow), currentMainOrFirst: Ref.get(input.mainWindow), focusedMainOrFirst: Ref.get(input.mainWindow), @@ -206,10 +215,12 @@ describe("DesktopWindow", () => { const fakeWindow = makeFakeBrowserWindow(); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, + createdWindowOptions, }); yield* Effect.gen(function* () { @@ -219,6 +230,8 @@ describe("DesktopWindow", () => { yield* desktopWindow.handleBackendReady; assert.equal(yield* Ref.get(createCount), 1); + assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); + assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); }).pipe(Effect.provide(layer)); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6f045eb80b6..642abd535ae 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -193,6 +193,7 @@ const make = Effect.gen(function* () { minHeight: 620, show: false, autoHideMenuBar: true, + ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), ...iconOption, title: environment.displayName, @@ -206,6 +207,10 @@ const make = Effect.gen(function* () { }, }); + if (environment.platform === "darwin") { + window.setAutoHideCursor(false); + } + yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( 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 b812ad9e347..ddf5b2a0250 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -34,6 +34,7 @@ "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" }, @@ -54,6 +55,7 @@ "@shikijs/themes": "4.2.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:*", 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: { { }, ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); const tracedApplication = Layer.effectDiscard( - Effect.void.pipe(Effect.withSpan("mobile.test.span")), + Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), ).pipe(Layer.provide(tracingLayer)); return Effect.gen(function* () { diff --git a/apps/mobile/src/features/observability/mobileTracing.ts b/apps/mobile/src/features/observability/mobileTracing.ts index 32f3d9f94c3..dfc6f875c1b 100644 --- a/apps/mobile/src/features/observability/mobileTracing.ts +++ b/apps/mobile/src/features/observability/mobileTracing.ts @@ -1,7 +1,5 @@ import Constants from "expo-constants"; -import * as Layer from "effect/Layer"; -import type { HttpClient } from "effect/unstable/http"; -import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; +import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; @@ -28,27 +26,13 @@ export function resolveMobileTracingConfig(): MobileTracingConfig | null { export function makeMobileTracingLayer( config: MobileTracingConfig | null, resource: MobileTracingResource, -): Layer.Layer { - if (config === null) { - return Layer.empty; - } - - return OtlpTracer.layer({ - url: config.tracesUrl, - headers: { - Authorization: `Bearer ${config.tracesToken}`, - "X-Axiom-Dataset": config.tracesDataset, - }, - resource: { - serviceName: "t3-mobile", - serviceVersion: resource.serviceVersion, - attributes: { - "service.runtime": "react-native", - "service.component": "mobile", - "deployment.environment.name": resource.appVariant, - }, - }, - }).pipe(Layer.provide(OtlpSerialization.layerJson)); +) { + return makeRelayClientTracingLayer(config, { + serviceName: "t3-mobile-relay-client", + serviceVersion: resource.serviceVersion, + runtime: "react-native", + client: `mobile-${resource.appVariant}`, + }); } export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), { diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index fedf6e10b96..0bc6469426e 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ReviewRenderableFile } from "./reviewModel"; -import { highlightReviewFile } from "./shikiReviewHighlighter"; +import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; function makeRenderableFile( input: Partial & 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 94354df744e..b500752c5d9 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 & @@ -48,7 +49,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"), @@ -86,22 +87,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" }], }, ]); }); @@ -163,10 +158,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/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 5d33cef7413..226294f9895 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -73,7 +73,7 @@ import { type TestProviderAdapterHarness, } from "./TestProviderAdapter.integration.ts"; import { deriveServerPaths, ServerConfig } from "../src/config.ts"; -import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; @@ -356,7 +356,7 @@ export const makeOrchestrationIntegrationHarness = ( }), ), Layer.provideMerge( - WorkspaceEntriesLive.pipe( + WorkspaceEntries.layer.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistry.layer), Layer.provide(NodeServices.layer), diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 167fb75a37c..54f9fd40da9 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Console from "effect/Console"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -29,6 +30,7 @@ import * as CliState from "../cloud/CliState.ts"; import * as CliTokenManager from "../cloud/CliTokenManager.ts"; import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; import { relayUrlConfig } from "../cloud/publicConfig.ts"; +import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; import { ServerConfig } from "../config.ts"; import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; @@ -228,6 +230,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f httpClient.execute, Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), + withRelayClientTracing, ); return response.ok ? ({ status: "revoked" } satisfies RelayUnlinkResult) @@ -299,6 +302,7 @@ const runCloudCommand = ( RelayClient.layerCloudflared({ baseDir: config.baseDir }), EnvironmentAuth.runtimeLayer, ServerEnvironmentLive, + headlessRelayClientTracingLayer, ).pipe( Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(Layer.succeed(ServerConfig, config)), diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 799ab609f43..8ea7ca06f9a 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -3,13 +3,19 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; -import { HttpClient } from "effect/unstable/http"; +import * as Tracer from "effect/Tracer"; +import { HttpClient, HttpServerRequest } from "effect/unstable/http"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; +import { + consumeCloudReplayGuards, + reconcileDesiredCloudLink, + traceRelayBrokerHandler, +} from "./http.ts"; import { CloudManagedEndpointRuntime, type CloudManagedEndpointRuntimeShape, @@ -69,6 +75,38 @@ describe("consumeCloudReplayGuards", () => { ); }); +describe("traceRelayBrokerHandler", () => { + it.effect("continues the incoming relay trace with the product tracer", () => + Effect.gen(function* () { + const spans: Array = []; + const productTracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const request = HttpServerRequest.fromWeb( + new Request("https://environment.example.test/api/t3-cloud/mint-credential", { + headers: { + traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", + }, + }), + ); + + yield* traceRelayBrokerHandler(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + Effect.provideService(RelayClientTracer, Option.some(productTracer)), + ); + + expect(spans).toHaveLength(1); + const span = spans[0]!; + expect(span.traceId).toBe("0123456789abcdef0123456789abcdef"); + expect(Option.getOrUndefined(span.parent)?.spanId).toBe("0123456789abcdef"); + }), + ); +}); + describe("reconcileDesiredCloudLink", () => { it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 896990849b6..b78d47a20c1 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -29,6 +29,7 @@ import { RelayLinkProofRequest, RelayManagedEndpointOrigin, } from "@t3tools/contracts/relay"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { normalizeRelayIssuer, RELAY_HEALTH_REQUEST_TYP, @@ -47,7 +48,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/unstable/http"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -110,6 +111,19 @@ const requireRelayUrl = relayUrlConfig.pipe( ), ); +export const traceRelayBrokerHandler = ( + effect: Effect.Effect, +): Effect.Effect => + HttpServerRequest.HttpServerRequest.pipe( + Effect.flatMap((request) => + Option.match(HttpTraceContext.fromHeaders(request.headers), { + onNone: () => effect, + onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)), + }), + ), + withRelayClientTracing, + ); + function bytesToString(bytes: Uint8Array): string { return new TextDecoder().decode(bytes); } @@ -512,6 +526,7 @@ const relayClientRequest = ( message: `T3 Connect relay request failed: ${String(cause)}`, }), ), + withRelayClientTracing, ); const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( @@ -938,7 +953,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group( .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) .handle("t3MintCredential", ({ payload }) => - cloudMintCredentialHandler(dependencies, payload), + traceRelayBrokerHandler(cloudMintCredentialHandler(dependencies, payload)), ); }), ); diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 558560bfffb..4cce901fa55 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -2,7 +2,11 @@ import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; -import { makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts"; +import { + makeCloudCliOAuthConfig, + makeRelayUrlConfig, + resolveRelayClientTracingConfig, +} from "./publicConfig.ts"; const provideEnv = (env: Readonly>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))); @@ -83,3 +87,39 @@ it.effect("requires Clerk OAuth config when the server bundle has no injected va clerkCliOAuthClientIdFallback: "", }).pipe(provideEnv({}), Effect.flip), ); + +it("resolves relay client tracing from runtime config with build-time fallback", () => { + const fallback = { + tracesUrl: "https://embedded.example.test/v1/traces", + tracesDataset: "embedded-dataset", + tracesToken: "embedded-token", + }; + + assert.deepEqual(resolveRelayClientTracingConfig({}, fallback), fallback); + assert.deepEqual( + resolveRelayClientTracingConfig( + { + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://runtime.example.test/v1/traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", + }, + fallback, + ), + { + tracesUrl: "https://runtime.example.test/v1/traces", + tracesDataset: "runtime-dataset", + tracesToken: "runtime-token", + }, + ); + assert.equal( + resolveRelayClientTracingConfig( + { + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "http://insecure.example.test/v1/traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", + }, + fallback, + ), + null, + ); +}); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 5c64a242377..b344107d756 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -9,6 +9,9 @@ import * as SchemaIssue from "effect/SchemaIssue"; declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined; +declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined; +declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined; +declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const; @@ -32,6 +35,15 @@ function readBuildTimeValue(value: string | undefined): string { return typeof value === "undefined" ? "" : value.trim(); } +function normalizeSecureUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + export const buildTimeRelayUrl = typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" ? "" @@ -46,6 +58,37 @@ export const buildTimeClerkCliOAuthClientId = readBuildTimeValue( ? undefined : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, ); +export const buildTimeRelayClientTracing = { + tracesUrl: readBuildTimeValue( + typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__ === "undefined" + ? undefined + : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__, + ), + tracesDataset: readBuildTimeValue( + typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__ === "undefined" + ? undefined + : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__, + ), + tracesToken: readBuildTimeValue( + typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__ === "undefined" + ? undefined + : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__, + ), +} as const; + +export function resolveRelayClientTracingConfig( + env: Readonly> = process.env, + fallback = buildTimeRelayClientTracing, +) { + const tracesUrl = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() || fallback.tracesUrl; + const tracesDataset = + env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() || fallback.tracesDataset; + const tracesToken = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() || fallback.tracesToken; + const normalizedTracesUrl = normalizeSecureUrl(tracesUrl); + return normalizedTracesUrl && tracesDataset && tracesToken + ? { tracesUrl: normalizedTracesUrl, tracesDataset, tracesToken } + : null; +} export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { const runtimeConfig = Config.nonEmptyString("T3CODE_RELAY_URL"); diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts new file mode 100644 index 00000000000..e35c94545a5 --- /dev/null +++ b/apps/server/src/cloud/relayTracing.ts @@ -0,0 +1,21 @@ +import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; + +import { resolveRelayClientTracingConfig } from "./publicConfig.ts"; + +const relayClientTracingConfig = resolveRelayClientTracingConfig(); + +export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer( + relayClientTracingConfig, + { + serviceName: "t3-headless-relay-client", + runtime: "node", + client: "headless-cli", + }, +); + +export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, { + serviceName: "t3-server", + runtime: "node", + client: "environment-server", + component: "relay-broker", +}); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index f60652609f5..25509dc593f 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,10 +1,11 @@ import { expect, it } from "@effect/vitest"; +import { NodeHttpServer } from "@effect/platform-node"; import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { McpSchema, McpServer } from "effect/unstable/ai"; -import { HttpServerResponse } from "effect/unstable/http"; +import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -48,6 +49,59 @@ it("normalizes empty successful notification responses to accepted", () => { expect(resultResponse.status).toBe(200); }); +it.effect("terminates HTTP MCP sessions with DELETE", () => + Effect.scoped( + Effect.gen(function* () { + const serverLayer = McpServer.layerHttp({ + name: "MCP termination test", + version: "1.0.0", + path: "/mcp", + }); + yield* HttpRouter.serve(serverLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.build); + const httpClient = yield* HttpClient.HttpClient; + + const initializeResponse = yield* httpClient.post("/mcp", { + headers: { accept: "application/json, text/event-stream" }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-test","version":"1.0.0"}}}`, + "application/json", + ), + }); + const sessionId = initializeResponse.headers["mcp-session-id"]; + expect(initializeResponse.status).toBe(200); + expect(sessionId).not.toBeNull(); + + const missingSessionResponse = yield* httpClient.del("/mcp"); + expect(missingSessionResponse.status).toBe(400); + + const unknownSessionResponse = yield* httpClient.del("/mcp", { + headers: { "mcp-session-id": "unknown-session" }, + }); + expect(unknownSessionResponse.status).toBe(404); + + const terminateResponse = yield* httpClient.del("/mcp", { + headers: { "mcp-session-id": sessionId! }, + }); + expect(terminateResponse.status).toBe(204); + + const reusedSessionResponse = yield* httpClient.post("/mcp", { + headers: { + accept: "application/json, text/event-stream", + "mcp-session-id": sessionId!, + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}`, + "application/json", + ), + }); + expect(reusedSessionResponse.status).toBe(404); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("registers annotated tools and preserves authenticated request context", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index b48ccd33bd1..402da90682d 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -56,7 +56,7 @@ import { } from "../../provider/Services/ProviderService.ts"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; -import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -330,7 +330,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), Layer.provideMerge( - WorkspaceEntriesLive.pipe( + WorkspaceEntries.layer.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistry.layer), ), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index cb2259ea834..94992741956 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -35,7 +35,7 @@ import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; -import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -84,7 +84,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const checkpointStore = yield* CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const appendRevertFailureActivity = (input: { @@ -259,9 +259,9 @@ const make = Effect.gen(function* () { checkpointRef: targetCheckpointRef, }); - // Invalidate the workspace entry cache so the @-mention file picker + // Refresh the workspace entry index so the @-mention file picker // reflects files created or deleted during this turn. - yield* workspaceEntries.invalidate(input.cwd); + yield* workspaceEntries.refresh(input.cwd); const files = yield* checkpointStore .diffCheckpoints({ @@ -709,9 +709,9 @@ const make = Effect.gen(function* () { return; } - // Invalidate the workspace entry cache so the @-mention file picker + // Refresh the workspace entry index so the @-mention file picker // reflects the reverted filesystem state. - yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); + yield* workspaceEntries.refresh(sessionRuntime.value.cwd); if (rolledBackTurns > 0) { yield* providerService.rollbackConversation({ diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index bbfbd236ad0..9e0ad364d97 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -17,6 +17,7 @@ import type { RelayAgentActivityState, } from "@t3tools/contracts/relay"; import { CommandId, ProviderInstanceId } from "@t3tools/contracts"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -25,6 +26,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; @@ -522,6 +524,20 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const runFork = Effect.runForkWith(context); const events = yield* Queue.unbounded(); const fetchSeen = yield* Deferred.make(); + const userSpans: Array = []; + const productSpans: Array = []; + const collectingTracer = (spans: Array) => + Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + spans.push(span.name); + }; + return span; + }, + }); const secrets = makeMemorySecretStore(); const now = "2026-05-25T00:00:00.000Z"; const projectId = "project-1" as ProjectId; @@ -648,6 +664,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds")); expect(url.origin).toBe("https://transport.example.test"); + expect(productSpans).toContain("makePublishProof"); + expect(userSpans).not.toContain("makePublishProof"); }).pipe( Effect.provide( AgentAwarenessRelay.layer.pipe( @@ -655,6 +673,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Layer.provideMerge(NodeServices.layer), ), ), + Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), + Effect.withTracer(collectingTracer(userSpans)), ); }), ), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 960f27e752b..d02c83d563e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -12,6 +12,7 @@ import type { } from "@t3tools/contracts"; import { projectThreadAwareness } from "@t3tools/shared/agentAwareness"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { RELAY_ACTIVITY_PUBLISH_TYP, signRelayJwt, @@ -409,6 +410,7 @@ const make = Effect.gen(function* () { }); }), Effect.withSpan("AgentAwarenessRelay.publishThread"), + withRelayClientTracing, ); const publishActiveThreadsUnsafe = Effect.gen(function* () { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b6cddea6c05..05d42ed66f0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -51,6 +51,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { FetchHttpClient, @@ -118,7 +119,7 @@ import { ServerEnvironment, type ServerEnvironmentShape, } from "./environment/Services/ServerEnvironment.ts"; -import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -504,7 +505,7 @@ const buildAppUnderTest = (options?: { const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); - const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( + const workspaceEntriesLayer = WorkspaceEntries.layer.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(vcsDriverRegistryLayer), ); @@ -4354,7 +4355,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.isAtLeast(response.entries.length, 1); assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts")); assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => @@ -4390,7 +4391,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { byteLength: 26, truncated: false, }); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => @@ -4447,7 +4448,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.entries.length, 0); assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); it.effect("routes websocket rpc projects.searchEntries errors", () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index cd4e114879c..42a692c5394 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -54,7 +54,7 @@ import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import { ServerSettingsLive } from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; -import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -74,6 +74,7 @@ import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts"; +import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; @@ -247,10 +248,7 @@ const PreviewLayerLive = Layer.empty.pipe( Layer.provideMerge(PortScannerLayerLive), ); -const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provideMerge(VcsDriverRegistryLayerLive), -); +const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive)); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( Layer.provide(WorkspacePathsLive), @@ -480,6 +478,7 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), + Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts deleted file mode 100644 index 2128a78bc19..00000000000 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ /dev/null @@ -1,555 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeOS from "node:os"; -import fsPromises from "node:fs/promises"; -import type { Dirent } from "node:fs"; - -import * as Cache from "effect/Cache"; -import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { - insertRankedSearchResult, - normalizeSearchQuery, - scoreQueryMatch, - type RankedSearchResult, -} from "@t3tools/shared/searchRanking"; - -import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; -import { - WorkspaceEntries, - WorkspaceEntriesBrowseError, - WorkspaceEntriesError, - type WorkspaceEntriesShape, -} from "../Services/WorkspaceEntries.ts"; -import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; - -const WORKSPACE_CACHE_TTL_MS = 15_000; -const WORKSPACE_CACHE_MAX_KEYS = 4; -const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; -const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; -const IGNORED_DIRECTORY_NAMES = new Set([ - ".git", - ".convex", - "node_modules", - ".next", - ".turbo", - ".vite-plus", - "dist", - "build", - "out", - ".cache", -]); - -interface WorkspaceIndex { - scannedAt: number; - entries: SearchableWorkspaceEntry[]; - truncated: boolean; -} - -interface SearchableWorkspaceEntry extends ProjectEntry { - normalizedPath: string; - normalizedName: string; -} - -type RankedWorkspaceEntry = RankedSearchResult; - -function toPosixPath(input: string): string { - return input.replaceAll("\\", "/"); -} - -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - -function parentPathOf(input: string): string | undefined { - const separatorIndex = input.lastIndexOf("/"); - if (separatorIndex === -1) { - return undefined; - } - return input.slice(0, separatorIndex); -} - -function basenameOf(input: string): string { - const separatorIndex = input.lastIndexOf("/"); - if (separatorIndex === -1) { - return input; - } - return input.slice(separatorIndex + 1); -} - -function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { - const normalizedPath = entry.path.toLowerCase(); - return { - ...entry, - normalizedPath, - normalizedName: basenameOf(normalizedPath), - }; -} - -function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { - if (!query) { - return entry.kind === "directory" ? 0 : 1; - } - - const { normalizedPath, normalizedName } = entry; - - const scores = [ - scoreQueryMatch({ - value: normalizedName, - query, - exactBase: 0, - prefixBase: 2, - includesBase: 5, - fuzzyBase: 100, - }), - scoreQueryMatch({ - value: normalizedPath, - query, - exactBase: 1, - prefixBase: 3, - boundaryBase: 4, - includesBase: 6, - fuzzyBase: 200, - boundaryMarkers: ["/"], - }), - ].filter((score): score is number => score !== null); - - if (scores.length === 0) { - return null; - } - - return Math.min(...scores); -} - -function isPathInIgnoredDirectory(relativePath: string): boolean { - const firstSegment = relativePath.split("/")[0]; - if (!firstSegment) return false; - return IGNORED_DIRECTORY_NAMES.has(firstSegment); -} - -function directoryAncestorsOf(relativePath: string): string[] { - const segments = relativePath.split("/").filter((segment) => segment.length > 0); - if (segments.length <= 1) return []; - - const directories: string[] = []; - for (let index = 1; index < segments.length; index += 1) { - directories.push(segments.slice(0, index).join("/")); - } - return directories; -} - -const resolveBrowseTarget = ( - input: FilesystemBrowseInput, - pathService: Path.Path, -): Effect.Effect => - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { - return yield* new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.resolveBrowseTarget", - detail: "Windows-style paths are only supported on Windows.", - }); - } - - if (!isExplicitRelativePath(input.partialPath)) { - return pathService.resolve(expandHomePath(input.partialPath, pathService)); - } - - if (!input.cwd) { - return yield* new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.resolveBrowseTarget", - detail: "Relative filesystem browse paths require a current project.", - }); - } - - return pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath); - }); - -export const makeWorkspaceEntries = Effect.gen(function* () { - const path = yield* Path.Path; - const vcsRegistry = yield* VcsDriverRegistry; - const workspacePaths = yield* WorkspacePaths; - - const isInsideVcsWorkTree = (cwd: string): Effect.Effect => - vcsRegistry.detect({ cwd }).pipe( - Effect.map((handle) => handle !== null), - Effect.orElseSucceed(() => false), - ); - - const filterVcsIgnoredPaths = ( - cwd: string, - relativePaths: string[], - ): Effect.Effect => - vcsRegistry.detect({ cwd }).pipe( - Effect.flatMap((handle) => - handle - ? handle.driver.filterIgnoredPaths(cwd, relativePaths).pipe( - Effect.map((paths) => [...paths]), - Effect.orElseSucceed(() => relativePaths), - ) - : Effect.succeed(relativePaths), - ), - Effect.orElseSucceed(() => relativePaths), - ); - - const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( - function* (cwd: string) { - const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.orElseSucceed(() => null)); - if (!vcs) { - return null; - } - - const listedFiles = yield* vcs.driver - .listWorkspaceFiles(cwd) - .pipe(Effect.orElseSucceed(() => null)); - - if (!listedFiles) { - return null; - } - - const listedPaths: Array = []; - for (const rawEntry of listedFiles.paths) { - const entry = toPosixPath(rawEntry); - if (entry.length > 0 && !isPathInIgnoredDirectory(entry)) { - listedPaths.push(entry); - } - } - const filePaths = yield* vcs.driver.filterIgnoredPaths(cwd, listedPaths).pipe( - Effect.map((paths) => [...paths]), - Effect.catch(() => filterVcsIgnoredPaths(cwd, listedPaths)), - ); - - const directorySet = new Set(); - for (const filePath of filePaths) { - for (const directoryPath of directoryAncestorsOf(filePath)) { - if (!isPathInIgnoredDirectory(directoryPath)) { - directorySet.add(directoryPath); - } - } - } - - const directoryEntries = [...directorySet] - .toSorted((left, right) => left.localeCompare(right)) - .map( - (directoryPath): ProjectEntry => ({ - path: directoryPath, - kind: "directory", - parentPath: parentPathOf(directoryPath), - }), - ) - .map(toSearchableWorkspaceEntry); - const fileEntries = [...new Set(filePaths)] - .toSorted((left, right) => left.localeCompare(right)) - .map( - (filePath): ProjectEntry => ({ - path: filePath, - kind: "file", - parentPath: parentPathOf(filePath), - }), - ) - .map(toSearchableWorkspaceEntry); - - const now = yield* DateTime.now; - const entries = [...directoryEntries, ...fileEntries]; - return { - scannedAt: now.epochMilliseconds, - entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), - truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, - }; - }, - ); - - const readDirectoryEntries = Effect.fn("WorkspaceEntries.readDirectoryEntries")(function* ( - cwd: string, - relativeDir: string, - ): Effect.fn.Return< - { readonly relativeDir: string; readonly dirents: Dirent[] | null }, - WorkspaceEntriesError - > { - return yield* Effect.tryPromise({ - try: async () => { - const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; - const dirents = await fsPromises.readdir(absoluteDir, { withFileTypes: true }); - return { relativeDir, dirents }; - }, - catch: (cause) => - new WorkspaceEntriesError({ - cwd, - operation: "workspaceEntries.readDirectoryEntries", - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }).pipe( - Effect.catchIf( - () => relativeDir.length > 0, - () => Effect.succeed({ relativeDir, dirents: null }), - ), - ); - }); - - const buildWorkspaceIndexFromFilesystem = Effect.fn( - "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", - )(function* (cwd: string): Effect.fn.Return { - const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd); - - let pendingDirectories: string[] = [""]; - const entries: SearchableWorkspaceEntry[] = []; - let truncated = false; - - while (pendingDirectories.length > 0 && !truncated) { - const currentDirectories = pendingDirectories; - pendingDirectories = []; - - const directoryEntries = yield* Effect.forEach( - currentDirectories, - (relativeDir) => readDirectoryEntries(cwd, relativeDir), - { concurrency: WORKSPACE_SCAN_READDIR_CONCURRENCY }, - ); - - const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { - const { relativeDir, dirents } = directoryEntry; - if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; - - dirents.sort((left, right) => left.name.localeCompare(right.name)); - const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; - for (const dirent of dirents) { - if (!dirent.name || dirent.name === "." || dirent.name === "..") { - continue; - } - if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { - continue; - } - if (!dirent.isDirectory() && !dirent.isFile()) { - continue; - } - - const relativePath = toPosixPath( - relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, - ); - if (isPathInIgnoredDirectory(relativePath)) { - continue; - } - candidates.push({ dirent, relativePath }); - } - return candidates; - }); - - const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => - candidateEntries.map((entry) => entry.relativePath), - ); - const allowedPathSet = shouldFilterWithGitIgnore - ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths)) - : null; - - for (const candidateEntries of candidateEntriesByDirectory) { - for (const candidate of candidateEntries) { - if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { - continue; - } - - const entry = toSearchableWorkspaceEntry({ - path: candidate.relativePath, - kind: candidate.dirent.isDirectory() ? "directory" : "file", - parentPath: parentPathOf(candidate.relativePath), - }); - entries.push(entry); - - if (candidate.dirent.isDirectory()) { - pendingDirectories.push(candidate.relativePath); - } - - if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { - truncated = true; - break; - } - } - - if (truncated) { - break; - } - } - } - - const now = yield* DateTime.now; - return { - scannedAt: now.epochMilliseconds, - entries, - truncated, - }; - }); - - const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( - cwd: string, - ): Effect.fn.Return { - const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd); - if (vcsIndexed) { - return vcsIndexed; - } - return yield* buildWorkspaceIndexFromFilesystem(cwd); - }); - - const workspaceIndexCache = yield* Cache.makeWith( - buildWorkspaceIndex, - { - capacity: WORKSPACE_CACHE_MAX_KEYS, - timeToLive: (exit) => - Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, - }, - ); - - const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( - cwd: string, - ): Effect.fn.Return { - return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( - Effect.mapError( - (cause) => - new WorkspaceEntriesError({ - cwd, - operation: "workspaceEntries.normalizeWorkspaceRoot", - detail: cause.message, - cause, - }), - ), - ); - }); - - const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")( - function* (cwd) { - const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( - Effect.orElseSucceed(() => cwd), - ); - yield* Cache.invalidate(workspaceIndexCache, cwd); - if (normalizedCwd !== cwd) { - yield* Cache.invalidate(workspaceIndexCache, normalizedCwd); - } - }, - ); - - const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")( - function* (input) { - const resolvedInputPath = yield* resolveBrowseTarget(input, path); - const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; - const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); - const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); - - const dirents = yield* Effect.tryPromise({ - try: () => fsPromises.readdir(parentPath, { withFileTypes: true }), - catch: (cause) => - new WorkspaceEntriesBrowseError({ - cwd: input.cwd, - partialPath: input.partialPath, - operation: "workspaceEntries.browse.readDirectory", - detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, - cause, - }), - }).pipe( - // The user can deny macOS TCC prompts for the target dir (Documents, - // Downloads, Music, etc.); surface an empty listing instead of an - // error so the caller doesn't retry-loop the prompt. - Effect.catchIf( - (error) => { - const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; - return code === "EACCES" || code === "EPERM"; - }, - () => Effect.succeed([]), - ), - ); - - const showHidden = endsWithSeparator || prefix.startsWith("."); - const lowerPrefix = prefix.toLowerCase(); - const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; - for (const dirent of dirents) { - if ( - dirent.isDirectory() && - dirent.name.toLowerCase().startsWith(lowerPrefix) && - (showHidden || !dirent.name.startsWith(".")) - ) { - entries.push({ - name: dirent.name, - fullPath: path.join(parentPath, dirent.name), - }); - } - } - - return { - parentPath, - entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), - }; - }, - ); - - const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( - function* (input) { - const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( - Effect.map((index) => { - const normalizedQuery = normalizeSearchQuery(input.query, { - trimLeadingPattern: /^[@./]+/, - }); - const limit = Math.max(0, Math.floor(input.limit)); - const rankedEntries: RankedWorkspaceEntry[] = []; - let matchedEntryCount = 0; - - for (const entry of index.entries) { - const score = scoreEntry(entry, normalizedQuery); - if (score === null) { - continue; - } - - matchedEntryCount += 1; - insertRankedSearchResult( - rankedEntries, - { item: entry, score, tieBreaker: entry.path }, - limit, - ); - } - - return { - entries: rankedEntries.map((candidate) => candidate.item), - truncated: index.truncated || matchedEntryCount > limit, - }; - }), - ); - }, - ); - - const list: WorkspaceEntriesShape["list"] = Effect.fn("WorkspaceEntries.list")(function* (input) { - const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( - Effect.map((index) => ({ - entries: index.entries.map(({ path: entryPath, kind, parentPath }) => ({ - path: entryPath, - kind, - ...(parentPath ? { parentPath } : {}), - })), - truncated: index.truncated, - })), - ); - }); - - return { - browse, - invalidate, - list, - search, - } satisfies WorkspaceEntriesShape; -}); - -export const WorkspaceEntriesLive = Layer.effect(WorkspaceEntries, makeWorkspaceEntries); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index 149873b3e50..5a4ec54686e 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -8,20 +8,19 @@ import * as Path from "effect/Path"; import { ServerConfig } from "../../config.ts"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; -import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; const ProjectLayer = WorkspaceFileSystemLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), ); const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), - Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( @@ -53,7 +52,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); -it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { +it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { describe("readFile", () => { it.effect("reads UTF-8 files relative to the workspace root", () => Effect.gen(function* () { @@ -135,20 +134,15 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { it.effect("invalidates workspace entry search cache after writes", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); - const beforeWrite = yield* workspaceEntries.search({ - cwd, - query: "rpc", - limit: 10, - }); - expect(beforeWrite).toEqual({ - entries: [], - truncated: false, - }); + const beforeWrite = yield* workspaceEntries.list({ cwd }); + expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( + false, + ); yield* workspaceFileSystem.writeFile({ cwd, @@ -156,11 +150,7 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { contents: "# Plan\n", }); - const afterWrite = yield* workspaceEntries.search({ - cwd, - query: "rpc", - limit: 10, - }); + const afterWrite = yield* workspaceEntries.list({ cwd }); expect(afterWrite.entries).toEqual( expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), ); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index ff88e45024b..f87137115ba 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -11,7 +11,7 @@ import { WorkspaceFileSystemError, type WorkspaceFileSystemShape, } from "../Services/WorkspaceFileSystem.ts"; -import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "../WorkspaceEntries.ts"; import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; @@ -32,7 +32,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths; - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( function* (input) { @@ -141,7 +141,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { }), ), ); - yield* workspaceEntries.invalidate(input.cwd); + yield* workspaceEntries.refresh(input.cwd); return { relativePath: target.relativePath }; }); return { readFile, writeFile } satisfies WorkspaceFileSystemShape; diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts deleted file mode 100644 index 888eff2ef3d..00000000000 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * WorkspaceEntries - Effect service contract for cached workspace entry search. - * - * Owns indexed workspace entry search plus cache invalidation for workspace - * roots when the underlying filesystem changes. - * - * @module WorkspaceEntries - */ -import * as Schema from "effect/Schema"; -import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; - -import type { - FilesystemBrowseInput, - FilesystemBrowseResult, - ProjectListEntriesInput, - ProjectListEntriesResult, - ProjectSearchEntriesInput, - ProjectSearchEntriesResult, -} from "@t3tools/contracts"; - -export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesError", - { - cwd: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} - -export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()( - "WorkspaceEntriesBrowseError", - { - cwd: Schema.optional(Schema.String), - partialPath: Schema.String, - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} - -/** - * WorkspaceEntriesShape - Service API for workspace entry search and cache - * invalidation. - */ -export interface WorkspaceEntriesShape { - /** - * Browse matching directories for the provided partial path. - */ - readonly browse: ( - input: FilesystemBrowseInput, - ) => Effect.Effect; - - /** - * List the cached workspace index for a project root. - */ - readonly list: ( - input: ProjectListEntriesInput, - ) => Effect.Effect; - - /** - * Search indexed workspace entries for files and directories matching the - * provided query. - */ - readonly search: ( - input: ProjectSearchEntriesInput, - ) => Effect.Effect; - - /** - * Drop any cached workspace entries for the given workspace root. - */ - readonly invalidate: (cwd: string) => Effect.Effect; -} - -/** - * WorkspaceEntries - Service tag for cached workspace entry search. - */ -export class WorkspaceEntries extends Context.Service()( - "t3/workspace/Services/WorkspaceEntries", -) {} diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts similarity index 76% rename from apps/server/src/workspace/Layers/WorkspaceEntries.test.ts rename to apps/server/src/workspace/WorkspaceEntries.test.ts index 88562d6f57e..f8a518d8b33 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,27 +1,24 @@ // @effect-diagnostics nodeBuiltinImport:off import fsPromises from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { FileFinder } from "@ff-labs/fff-node"; import { it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import { ServerConfig } from "../../config.ts"; +import { ServerConfig } from "../config.ts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; -import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; -import { WorkspacePathsLive } from "./WorkspacePaths.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( - Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", @@ -71,7 +68,7 @@ const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; return yield* workspaceEntries.search(input); }); @@ -82,7 +79,7 @@ const appendSeparator = (input: string) => : `${input}${platform === "win32" ? "\\" : "/"}`, ); -it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { +it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { afterEach(() => { vi.restoreAllMocks(); }); @@ -95,17 +92,16 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { yield* writeTextFile(cwd, "README.md"); yield* writeTextFile(cwd, "node_modules/pkg/index.js"); - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const result = yield* workspaceEntries.list({ cwd }); expect(result.entries).toEqual( expect.arrayContaining([ { path: "src", kind: "directory" }, - { path: "src/components", kind: "directory", parentPath: "src" }, + { path: "src/components", kind: "directory" }, { path: "src/components/Composer.tsx", kind: "file", - parentPath: "src/components", }, { path: "README.md", kind: "file" }, ]), @@ -253,94 +249,39 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); - it.effect("deduplicates concurrent index builds for the same cwd", () => + it.effect("supports typo-resistant file search through fff", () => Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-concurrent-build-" }); + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fff-typo-" }); yield* writeTextFile(cwd, "src/components/Composer.tsx"); - let rootReadCount = 0; - let releaseRootRead: (() => void) | undefined; - const rootReadGate = new Promise((resolve) => { - releaseRootRead = resolve; - }); - const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation((async ( - ...args: Parameters - ) => { - if (args[0] === cwd) { - rootReadCount += 1; - await rootReadGate; - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir); - - const searches = yield* Effect.all( - [ - searchWorkspaceEntries({ cwd, query: "", limit: 100 }), - searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), - searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.forkScoped); - for (let attempt = 0; attempt < 50; attempt += 1) { - if (rootReadCount > 0) { - break; - } - yield* Effect.yieldNow; - } - releaseRootRead?.(); - yield* Fiber.join(searches); - - expect(rootReadCount).toBe(1); + const result = yield* searchWorkspaceEntries({ cwd, query: "compoesr", limit: 10 }); + + expect(result.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "src/components/Composer.tsx" }), + ]), + ); }), ); - it.effect("limits concurrent directory reads while walking the filesystem", () => + it.effect("rebuilds the cached index after refresh fails", () => Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-read-concurrency-" }); - yield* Effect.forEach( - Array.from({ length: 80 }, (_, index) => index), - (index) => writeTextFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"), - { discard: true }, - ); + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-refresh-failure-" }); + yield* writeTextFile(cwd, "src/index.ts", "export {};\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const createSpy = vi.spyOn(FileFinder, "create"); + yield* workspaceEntries.list({ cwd }); + expect(createSpy).toHaveBeenCalledTimes(1); - let activeReads = 0; - let peakReads = 0; - let releaseReads: (() => void) | undefined; - const readsGate = new Promise((resolve) => { - releaseReads = resolve; + vi.spyOn(FileFinder.prototype, "scanFiles").mockReturnValueOnce({ + ok: false, + error: "scan failed", }); - const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation((async ( - ...args: Parameters - ) => { - const target = args[0]; - if (typeof target === "string" && target.startsWith(cwd)) { - activeReads += 1; - peakReads = Math.max(peakReads, activeReads); - await readsGate; - try { - return await originalReaddir(...args); - } finally { - activeReads -= 1; - } - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir); - - const search = yield* searchWorkspaceEntries({ cwd, query: "", limit: 200 }).pipe( - Effect.forkScoped, - ); - for (let attempt = 0; attempt < 50; attempt += 1) { - if (activeReads > 0) { - break; - } - yield* Effect.yieldNow; - } - releaseReads?.(); - yield* Fiber.join(search); - - expect(peakReads).toBeLessThanOrEqual(32); + yield* workspaceEntries.refresh(cwd); + + yield* workspaceEntries.list({ cwd }); + expect(createSpy).toHaveBeenCalledTimes(2); }), ); }); @@ -348,7 +289,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); @@ -371,7 +312,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("shows dot directories in directory mode and hidden-prefix mode", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); yield* writeTextFile(cwd, ".config/settings.json", "{}"); @@ -395,7 +336,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("supports relative paths when cwd is provided", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); yield* writeTextFile(cwd, "packages/pkg.json", "{}"); @@ -414,7 +355,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("rejects relative paths without cwd", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const error = yield* workspaceEntries .browse({ @@ -428,7 +369,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { it.effect("returns an empty listing when the OS denies directory access", () => Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts new file mode 100644 index 00000000000..bf9a51c74db --- /dev/null +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -0,0 +1,252 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as RcMap from "effect/RcMap"; +import * as Schema from "effect/Schema"; + +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + ProjectListEntriesInput, + ProjectListEntriesResult, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; + +import * as WorkspacePaths from "./Services/WorkspacePaths.ts"; +import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; + +export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesError", + { + cwd: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesBrowseError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class WorkspaceEntries extends Context.Service< + WorkspaceEntries, + { + readonly browse: ( + input: FilesystemBrowseInput, + ) => Effect.Effect; + readonly list: ( + input: ProjectListEntriesInput, + ) => Effect.Effect; + readonly search: ( + input: ProjectSearchEntriesInput, + ) => Effect.Effect; + readonly refresh: (cwd: string) => Effect.Effect; + } +>()("t3/workspace/WorkspaceEntries") {} + +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return NodeOS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(NodeOS.homedir(), input.slice(2)); + } + return input; +} + +const resolveBrowseTarget = ( + input: FilesystemBrowseInput, + path: Path.Path, +): Effect.Effect => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Windows-style paths are only supported on Windows.", + }); + } + + if (!isExplicitRelativePath(input.partialPath)) { + return path.resolve(expandHomePath(input.partialPath, path)); + } + + if (!input.cwd) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Relative filesystem browse paths require a current project.", + }); + } + + return path.resolve(expandHomePath(input.cwd, path), input.partialPath); + }); + +const make = Effect.gen(function* () { + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; + + const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( + cwd: string, + ): Effect.fn.Return { + return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( + Effect.mapError( + (cause) => + new WorkspaceEntriesError({ + cwd, + operation: "workspaceEntries.normalizeWorkspaceRoot", + detail: cause.message, + cause, + }), + ), + ); + }); + + const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( + function* (cwd) { + const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( + Effect.orElseSucceed(() => cwd), + ); + if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { + return; + } + yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + yield* searchIndex.refresh(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), + Effect.catch((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + cause, + }); + yield* workspaceSearchIndexes.invalidate(normalizedCwd); + }), + ), + ); + }, + ); + + const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( + function* (input) { + const resolvedInputPath = yield* resolveBrowseTarget(input, path); + const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; + const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); + const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + + const dirents = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.browse.readDirectory", + detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }).pipe( + Effect.catchIf( + (error) => { + const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; + return code === "EACCES" || code === "EPERM"; + }, + () => Effect.succeed([]), + ), + ); + + const showHidden = endsWithSeparator || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; + for (const dirent of dirents) { + if ( + dirent.isDirectory() && + dirent.name.toLowerCase().startsWith(lowerPrefix) && + (showHidden || !dirent.name.startsWith(".")) + ) { + entries.push({ + name: dirent.name, + fullPath: path.join(parentPath, dirent.name), + }); + } + } + + return { + parentPath, + entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), + }; + }, + ); + + const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( + function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + const normalizedQuery = input.query + .trim() + .toLowerCase() + .replace(/^[@./]+/, ""); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.search(normalizedQuery, input.limit); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), + Effect.mapError( + (cause) => + new WorkspaceEntriesError({ + cwd: input.cwd, + operation: "workspaceEntries.search", + detail: cause.message, + cause, + }), + ), + ); + }, + ); + + const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( + function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.list(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), + Effect.mapError( + (cause) => + new WorkspaceEntriesError({ + cwd: input.cwd, + operation: "workspaceEntries.list", + detail: cause.message, + cause, + }), + ), + ); + }, + ); + + return WorkspaceEntries.of({ browse, list, refresh, search }); +}); + +export const layer = Layer.effect(WorkspaceEntries, make).pipe( + Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts new file mode 100644 index 00000000000..4bee3cbc089 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -0,0 +1,245 @@ +import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as LayerMap from "effect/LayerMap"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; + +import type { + ProjectEntry, + ProjectListEntriesResult, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; + +const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; +const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; +const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; +const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; +const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; + +export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexCreateFailed", + { + cwd: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return `Failed to create the workspace search index for '${this.cwd}': ${this.reason}`; + } +} + +export class WorkspaceSearchIndexScanTimedOut extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexScanTimedOut", + { + cwd: Schema.String, + timeout: Schema.String, + }, +) { + override get message(): string { + return `Workspace search index for '${this.cwd}' did not finish scanning within ${this.timeout}`; + } +} + +export class WorkspaceSearchIndexSearchFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexSearchFailed", + { + cwd: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return `Workspace search failed for '${this.cwd}': ${this.reason}`; + } +} + +export class WorkspaceSearchIndexRefreshFailed extends Schema.TaggedErrorClass()( + "WorkspaceSearchIndexRefreshFailed", + { + cwd: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return `Failed to refresh the workspace search index for '${this.cwd}': ${this.reason}`; + } +} + +export type WorkspaceSearchIndexError = + | WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndexSearchFailed + | WorkspaceSearchIndexRefreshFailed; + +export class WorkspaceSearchIndex extends Context.Service< + WorkspaceSearchIndex, + { + readonly list: () => Effect.Effect; + readonly search: ( + query: string, + limit: number, + ) => Effect.Effect; + readonly refresh: () => Effect.Effect< + void, + WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut + >; + } +>()("t3/workspace/WorkspaceSearchIndex") {} + +function toPosixPath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function trimDirectorySeparator(input: string): string { + return input.endsWith("/") ? input.slice(0, -1) : input; +} + +function parentPathOf(input: string): string | undefined { + const separatorIndex = input.lastIndexOf("/"); + return separatorIndex === -1 ? undefined : input.slice(0, separatorIndex); +} + +function toProjectEntry(item: MixedItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.item.relativePath)); + if (!normalizedPath) { + return null; + } + + return { + path: normalizedPath, + kind: item.type, + }; +} + +function mapMixedSearchResult( + result: MixedSearchResult, + limit: number, +): { readonly entries: ProjectEntry[]; readonly truncated: boolean } { + const entries: ProjectEntry[] = []; + for (const item of result.items) { + const entry = toProjectEntry(item); + if (entry) { + entries.push(entry); + } + if (entries.length >= limit) { + break; + } + } + + const rootDirectoryCount = result.items.some( + (item) => item.type === "directory" && item.item.relativePath.length === 0, + ) + ? 1 + : 0; + return { + entries, + truncated: result.totalMatched - rootDirectoryCount > limit, + }; +} + +function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { + const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); + for (const entry of entries) { + let parentPath = parentPathOf(entry.path); + while (parentPath) { + if (!entryByPath.has(parentPath)) { + entryByPath.set(parentPath, { path: parentPath, kind: "directory" }); + } + parentPath = parentPathOf(parentPath); + } + } + return [...entryByPath.values()]; +} + +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { + const result = FileFinder.create({ + basePath: cwd, + disableMmapCache: true, + disableContentIndexing: true, + aiMode: false, + enableFsRootScanning: true, + enableHomeDirScanning: true, + }); + if (result.ok) return result.value; + return yield* new WorkspaceSearchIndexCreateFailed({ cwd, reason: result.error }); +}); + +const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( + cwd: string, + finder: FileFinder, +) { + yield* Effect.sync(() => finder.isScanning()).pipe( + Effect.repeat({ + while: (scanning) => scanning, + schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), + }), + Effect.timeoutOrElse({ + duration: WORKSPACE_INDEX_SCAN_TIMEOUT, + orElse: () => + new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), + }), + ); +}); + +const makeWorkspaceSearchIndex = (cwd: string) => + Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy())).pipe( + Effect.tap((finder) => waitForScan(cwd, finder)), + Effect.map((finder) => { + const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( + query: string, + pageSize: number, + ) { + const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); + if (!result.ok) { + return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); + } + return result.value; + }); + + const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( + "WorkspaceSearchIndex.refresh", + )(function* () { + const result = yield* Effect.sync(() => finder.scanFiles()); + if (!result.ok) { + return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); + } + yield* waitForScan(cwd, finder); + }); + + const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( + function* () { + const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); + const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); + const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => + left.path.localeCompare(right.path), + ); + const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); + return { + entries, + truncated: mapped.truncated || entries.length < sortedEntries.length, + }; + }, + ); + + const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( + "WorkspaceSearchIndex.search", + )(function* (query, limit) { + const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); + return mapMixedSearchResult(result, limit); + }); + + return WorkspaceSearchIndex.of({ list, refresh, search }); + }), + ); + +const workspaceSearchIndexLayer = (cwd: string) => + Layer.effect(WorkspaceSearchIndex, makeWorkspaceSearchIndex(cwd)); + +export class WorkspaceSearchIndexMap extends LayerMap.Service()( + "t3/workspace/WorkspaceSearchIndexMap", + { + lookup: workspaceSearchIndexLayer, + idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL, + }, +) {} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 99ee6146ad9..34c993de84f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -78,7 +78,7 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; -import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; +import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; @@ -272,7 +272,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const startup = yield* ServerRuntimeStartup; - const workspaceEntries = yield* WorkspaceEntries; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 86fcdf61df7..8ed5238bd6b 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -80,6 +80,15 @@ export default mergeConfig( __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify( repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "", ), + __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: JSON.stringify( + repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() ?? "", + ), + __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: JSON.stringify( + repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() ?? "", + ), + __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: JSON.stringify( + repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() ?? "", + ), }, }, test: { diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index dc09a7fa043..30cb596781a 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -318,6 +318,14 @@ describe("web cloud link environment client", () => { method: "POST", url: "https://managed.example.test/oauth/token", }); + const traceparents = fetchMock.mock.calls.map( + (call) => call[1]?.headers.traceparent as string | undefined, + ); + expect(traceparents.every((traceparent) => typeof traceparent === "string")).toBe(true); + expect(new Set(traceparents.map((traceparent) => traceparent?.split("-")[1])).size).toBe(1); + expect(connection.relayTraceHeaders.traceparent?.split("-")[1]).toBe( + traceparents[0]?.split("-")[1], + ); }), ); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index b13b324a411..4c94ab41660 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -1,7 +1,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, type EnvironmentCloudLinkStateResult, @@ -29,6 +29,7 @@ import { ManagedRelayDpopSigner, type WsRpcClient, } from "@t3tools/client-runtime"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { ensureLocalApi } from "../localApi"; import { @@ -245,6 +246,7 @@ export interface CloudManagedConnection { readonly wsBaseUrl: string; readonly relayUrl: string; readonly accessToken: string; + readonly relayTraceHeaders: Headers.Headers; } export function collectCloudLinkTargets(input: { @@ -437,8 +439,15 @@ export function connectManagedCloudEnvironment(input: { wsBaseUrl: connected.endpoint.wsBaseUrl, relayUrl: configuredRelayUrl, accessToken: session.access_token, + relayTraceHeaders: HttpTraceContext.toHeaders(yield* Effect.currentSpan.pipe(Effect.orDie)), }; - }); + }).pipe( + Effect.withSpan("relay.environment.connect", { + root: true, + attributes: { "relay.environment_id": input.environment.environmentId }, + }), + withRelayClientTracing, + ); } export function readPrimaryCloudLinkState(): Effect.Effect< diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index 291f1830ca3..f7b3ca6bc31 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -5,12 +5,26 @@ export interface CloudPublicConfig { readonly clerkPublishableKey: string | null; readonly clerkJwtTemplate: string | null; readonly relayUrl: string | null; + readonly relayTracing: { + readonly tracesUrl: string | null; + readonly tracesDataset: string | null; + readonly tracesToken: string | null; + }; } function trimNonEmpty(value: string | undefined): string | null { return value?.trim() || null; } +function normalizeSecureUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + export function resolveCloudPublicConfig(): CloudPublicConfig { return { clerkPublishableKey: trimNonEmpty( @@ -20,9 +34,29 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { relayUrl: normalizeSecureRelayUrl( (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "", ), + relayTracing: { + tracesUrl: normalizeSecureUrl( + (import.meta.env.VITE_RELAY_OTLP_TRACES_URL as string | undefined) ?? "", + ), + tracesDataset: trimNonEmpty( + import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET as string | undefined, + ), + tracesToken: trimNonEmpty(import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN as string | undefined), + }, }; } +export function resolveRelayTracingConfig() { + const { relayTracing } = resolveCloudPublicConfig(); + return relayTracing.tracesUrl && relayTracing.tracesDataset && relayTracing.tracesToken + ? { + tracesUrl: relayTracing.tracesUrl, + tracesDataset: relayTracing.tracesDataset, + tracesToken: relayTracing.tracesToken, + } + : null; +} + export function hasCloudPublicConfig(): boolean { const config = resolveCloudPublicConfig(); return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 2e45d1edde9..695d89bf042 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2129,10 +2129,6 @@ describe("ChatView timeline estimator parity (full app)", () => { () => document.querySelector('button[aria-label="Toggle right panel"]'), "Unable to find right panel toggle.", ); - const headerActions = await waitForElement( - () => document.querySelector("[data-chat-header-actions]"), - "Unable to find chat header actions.", - ); const chatHeader = await waitForElement( () => document.querySelector("[data-chat-header]"), "Unable to find chat header.", @@ -2146,6 +2142,12 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(panelLayoutControls.getBoundingClientRect().top).toBe( chatHeader.getBoundingClientRect().top, ); + expect( + window.getComputedStyle(panelLayoutControls).getPropertyValue("-webkit-app-region"), + ).toBe("no-drag"); + expect(chatHeader.classList.contains("drag-region")).toBe(false); + expect(chatHeader.contains(panelLayoutControls)).toBe(true); + expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); const initialTerminalRect = terminalToggle.getBoundingClientRect(); const initialRightPanelRect = rightPanelToggle.getBoundingClientRect(); const initialControlRects = [initialTerminalRect, initialRightPanelRect]; @@ -2157,16 +2159,13 @@ describe("ChatView timeline estimator parity (full app)", () => { true, ); expect(initialRightPanelRect.left - initialTerminalRect.right).toBe(4); - expect( - initialTerminalRect.left - - (headerActions.lastElementChild?.getBoundingClientRect().right ?? Number.NaN), - ).toBe(4); document.documentElement.classList.add("wco"); expect(panelLayoutControls.getBoundingClientRect().height).toBe(52); expect(panelLayoutControls.getBoundingClientRect().top).toBe( chatHeader.getBoundingClientRect().top, ); + expect(window.innerWidth - panelLayoutControls.getBoundingClientRect().right).toBe(12); document.documentElement.classList.remove("wco"); rightPanelToggle.click(); @@ -2179,17 +2178,51 @@ describe("ChatView timeline estimator parity (full app)", () => { () => document.querySelector("[data-right-panel-tabbar]"), "Unable to find right panel tab bar.", ); + const rightPanelTabList = await waitForElement( + () => document.querySelector("[data-right-panel-tab-list]"), + "Unable to find right panel tab list.", + ); const maximizeRect = maximizeButton.getBoundingClientRect(); const rightPanelTabbarRect = rightPanelTabbar.getBoundingClientRect(); + const openPanelLayoutControls = await waitForElement( + () => document.querySelector("[data-panel-layout-controls]"), + "Unable to find open panel layout controls.", + ); + const openTerminalToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle terminal drawer"]'), + "Unable to find open panel terminal toggle.", + ); + const openRightPanelToggle = await waitForElement( + () => document.querySelector('button[aria-label="Toggle right panel"]'), + "Unable to find open panel right panel toggle.", + ); expect(document.querySelector('button[aria-label="Add panel surface"]')).toBeNull(); expect(rightPanelTabbarRect.height).toBe(52); expect(rightPanelTabbarRect.top).toBe(chatHeader.getBoundingClientRect().top); + expect(chatHeader.contains(openPanelLayoutControls)).toBe(false); + expect( + window.getComputedStyle(rightPanelTabbar).getPropertyValue("-webkit-app-region"), + ).not.toBe("drag"); + expect(rightPanelTabList.classList.contains("drag-region")).toBe(false); + expect(window.getComputedStyle(maximizeButton).getPropertyValue("-webkit-app-region")).toBe( + "no-drag", + ); + expect( + window.getComputedStyle(openTerminalToggle).getPropertyValue("-webkit-app-region"), + ).toBe("no-drag"); + expect( + window.getComputedStyle(openRightPanelToggle).getPropertyValue("-webkit-app-region"), + ).toBe("no-drag"); expect(maximizeRect.width).toBe(28); expect(maximizeRect.height).toBe(28); expect(maximizeRect.top).toBe(initialTerminalRect.top); expect(initialTerminalRect.left - maximizeRect.right).toBe(4); - expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo(initialTerminalRect.left, 1); - expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo( + expect(openTerminalToggle.getBoundingClientRect().left).toBeCloseTo( + initialTerminalRect.left, + 1, + ); + expect(openRightPanelToggle.getBoundingClientRect().left).toBeCloseTo( initialRightPanelRect.left, 1, ); @@ -2206,10 +2239,10 @@ describe("ChatView timeline estimator parity (full app)", () => { document.documentElement.classList.add("wco"); expect(rightPanelTabbar.getBoundingClientRect().height).toBe( - panelLayoutControls.getBoundingClientRect().height, + openPanelLayoutControls.getBoundingClientRect().height, ); expect(rightPanelTabbar.getBoundingClientRect().top).toBe( - panelLayoutControls.getBoundingClientRect().top, + openPanelLayoutControls.getBoundingClientRect().top, ); document.documentElement.classList.remove("wco"); @@ -2227,20 +2260,83 @@ describe("ChatView timeline estimator parity (full app)", () => { expect( document.querySelector('button[aria-label="Restore panel size"]'), ).not.toBeNull(); - expect(terminalToggle.getBoundingClientRect().left).toBeCloseTo( - initialTerminalRect.left, - 1, - ); - expect(rightPanelToggle.getBoundingClientRect().left).toBeCloseTo( - initialRightPanelRect.left, - 1, - ); + expect( + document + .querySelector('button[aria-label="Toggle terminal drawer"]') + ?.getBoundingClientRect().left, + ).toBeCloseTo(initialTerminalRect.left, 1); + expect( + document + .querySelector('button[aria-label="Toggle right panel"]') + ?.getBoundingClientRect().left, + ).toBeCloseTo(initialRightPanelRect.left, 1); }); } finally { await mounted.cleanup(); } }); + it("loads file previews from the active thread worktree", async () => { + const worktreePath = "/repo/worktrees/file-preview-thread"; + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-worktree-file-preview" as MessageId, + targetText: "open the worktree file preview", + }); + const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); + if (!targetThread) { + throw new Error("Missing target thread fixture."); + } + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, worktreePath } : thread, + ), + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.projectsListEntries) { + return { entries: [{ path: "src/index.ts", kind: "file" }], truncated: false }; + } + if (body._tag === WS_METHODS.projectsReadFile) { + return { + relativePath: "src/index.ts", + contents: "export const worktree = true;\n", + byteLength: 30, + truncated: false, + }; + } + return undefined; + }, + }); + + try { + useRightPanelStore.getState().open(THREAD_REF, "files"); + await waitForElement( + () => document.querySelector("[data-file-browser-panel]"), + "Unable to find the worktree file explorer.", + ); + + useRightPanelStore.getState().openFile(THREAD_REF, "src/index.ts"); + await waitForElement( + () => document.querySelector(".file-preview-virtualizer"), + "Unable to find the worktree file preview.", + ); + + const listRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.projectsListEntries, + ); + const readRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.projectsReadFile, + ); + expect(listRequest).toMatchObject({ cwd: worktreePath }); + expect(readRequest).toMatchObject({ cwd: worktreePath, relativePath: "src/index.ts" }); + } finally { + await mounted.cleanup(); + } + }); + it("scrolls file tabs and preserves the workspace explorer across file previews", async () => { const workspaceEntries = [ { path: "src", kind: "directory" as const }, @@ -2355,6 +2451,7 @@ describe("ChatView timeline estimator parity (full app)", () => { () => document.querySelector("[data-file-breadcrumbs]"), "Unable to find the responsive file breadcrumbs.", ); + const fileSubheader = breadcrumbs.closest("[data-surface-subheader]"); const breadcrumbViewport = await waitForElement( () => breadcrumbs.querySelector('[data-slot="scroll-area-viewport"]'), "Unable to find the file breadcrumb viewport.", @@ -2382,6 +2479,9 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(explorerToggle.getAttribute("aria-pressed")).toBe("true"); expect(explorerToggle.getBoundingClientRect().width).toBe(28); expect(explorerToggle.getBoundingClientRect().height).toBe(28); + expect(fileSubheader?.getBoundingClientRect().height).toBe(40); + expect(window.getComputedStyle(fileSubheader!).borderTopWidth).toBe("0px"); + expect(window.getComputedStyle(fileSubheader!).borderBottomWidth).toBe("1px"); }); const fileSearchButton = await waitForElement( @@ -4332,7 +4432,6 @@ describe("ChatView timeline estimator parity (full app)", () => { { path: "path/to/package.json", kind: "file", - parentPath: "path/to", }, ], truncated: false, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a74800fa518..673a24dbe02 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4589,24 +4589,28 @@ function ChatViewContent(props: ChatViewProps) { return ; } + const panelLayoutControls = ( + + ); + return (
{isElectron && activeThreadRef ? ( ) : null} - + {rightPanelOpen ? panelLayoutControls : null}
+ {!rightPanelOpen ? panelLayoutControls : null} ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && - activeProject ? ( + activeProject && + activeWorkspaceRoot ? ( ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && - activeProject ? ( + activeProject && + activeWorkspaceRoot ? ( { - const api = readLocalApi(); - if (!api) return; - const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); + openDiffFilePrimaryAction({ + threadRef: routeThreadRef, + filePath, + activeCwd, + openInEditor: (targetPath) => { + const api = readLocalApi(); + if (!api) return; + void openInPreferredEditor(api, targetPath).catch((error) => { + console.warn("Failed to open diff file in editor.", error); + }); + }, }); }, - [activeCwd], + [activeCwd, routeThreadRef], ); const toggleDiffFileCollapsed = useCallback((fileKey: string) => { setCollapsedDiffFileKeys((current) => { @@ -668,7 +674,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { return node.hasAttribute("data-title"); }); if (!clickedHeader) return; - openDiffFileInEditor(filePath); + openDiffFile(filePath); }} > { + it("uses the shared compact surface subheader in embedded mode", async () => { + const screen = await render( + Diff controls}> +
Diff content
+
, + ); + const subheader = screen.container.querySelector("[data-surface-subheader]"); + + expect(subheader).not.toBeNull(); + expect(subheader?.getBoundingClientRect().height).toBe(40); + expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px"); + expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px"); + }); +}); diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 6a2219d610f..4dd569d2823 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -13,7 +13,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { "flex items-center justify-between gap-2 px-4", shouldUseDragRegion ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" - : "h-12 wco:max-h-[env(titlebar-area-height)]", + : "surface-subheader", ); } @@ -36,8 +36,8 @@ export function DiffPanelShell(props: { {shouldUseDragRegion ? (
{props.header}
) : ( -
-
{props.header}
+
+ {props.header}
)} {props.children} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 330ee67b006..bf99a7f2048 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,7 +1,7 @@ import type { PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react"; import { isElectron } from "~/env"; import type { RightPanelSurface } from "~/rightPanelStore"; @@ -35,6 +35,40 @@ interface RightPanelTabsProps { children: ReactNode; } +const SURFACE_DISABLED_REASONS = { + browser: "Browser previews are only available in the T3 Code desktop app.", + files: "Files are only available when a project is open.", + diff: "Diff is only available for server threads in Git repositories.", +} as const; + +function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { + return ( + + + {props.reason} + + ); +} + +function SurfaceMenuItem(props: { + available: boolean; + disabledReason?: string; + onClick: () => void; + children: ReactNode; +}) { + const item = ( + + {props.children} + + ); + if (props.available || !props.disabledReason) return item; + return ; +} + function RightPanelEmptyState(props: { onAddBrowser: () => void; onAddTerminal: () => void; @@ -50,6 +84,7 @@ function RightPanelEmptyState(props: { description: "Open a local app or URL.", icon: Globe2, available: props.browserAvailable, + disabledReason: SURFACE_DISABLED_REASONS.browser, onClick: props.onAddBrowser, }, { @@ -57,6 +92,7 @@ function RightPanelEmptyState(props: { description: "Start a shell in this workspace.", icon: TerminalSquare, available: true, + disabledReason: null, onClick: props.onAddTerminal, }, { @@ -64,6 +100,7 @@ function RightPanelEmptyState(props: { description: "Browse and read workspace files.", icon: Files, available: props.filesAvailable, + disabledReason: SURFACE_DISABLED_REASONS.files, onClick: props.onAddFiles, }, { @@ -71,6 +108,7 @@ function RightPanelEmptyState(props: { description: "Review changes in this thread.", icon: FileDiff, available: props.diffAvailable, + disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, }, ] as const; @@ -84,24 +122,46 @@ function RightPanelEmptyState(props: { Choose what to show in the right panel.

-
+
{actions.map((action) => { const Icon = action.icon; - return ( - + ); + } + const disabledCard = ( + ); + return ( + + ); })}
@@ -211,8 +271,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { className={cn( "px-2 pr-28", props.mode === "inline" ? "workspace-topbar" : "flex h-10 shrink-0 items-center", - ownsDesktopTitleBar && - "drag-region wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", + ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", )} data-right-panel-tabbar > @@ -220,7 +279,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ref={tabListRef} hideScrollbars scrollFade - className="min-w-0 flex-1 rounded-none" + className={cn("min-w-0 flex-1 rounded-none", ownsDesktopTitleBar && "drag-region")} data-right-panel-tab-list >
@@ -291,22 +350,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) { - + Browser - - + + Terminal - - + + Files - - + + Diff - + ) : null} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index aea59dc40b4..bc05952dccc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, CloudIcon, FolderPlusIcon, + Globe2Icon, SearchIcon, SettingsIcon, SquarePenIcon, @@ -76,6 +77,7 @@ import { } from "../store"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -92,6 +94,7 @@ import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; import { useThreadActions } from "../hooks/useThreadActions"; import { @@ -356,6 +359,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr threadId: thread.id, }); const isMobile = useIsMobile(); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -426,6 +433,17 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [handleThreadClick, orderedProjectThreadKeys, threadRef], ); + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void openDiscoveredPort({ threadRef, port }); + }, + [discoveredPorts, navigateToThread, threadRef], + ); const handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { // Already renaming this row: a double-click on the row chrome (outside the @@ -639,6 +657,26 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )}
+ {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} {terminalStatus && ( +
{rightPanelOpen ? ( {relativePath ? ( -
+
{ + it("uses the shared compact surface subheader treatment", async () => { + const screen = await render(); + const subheader = screen.container.querySelector("[data-surface-subheader]"); + + expect(subheader).not.toBeNull(); + expect(subheader?.getBoundingClientRect().height).toBe(40); + expect(window.getComputedStyle(subheader!).borderTopWidth).toBe("0px"); + expect(window.getComputedStyle(subheader!).borderBottomWidth).toBe("1px"); + }); + it("only focuses the URL input after an explicit focus request", async () => { const previouslyFocused = document.createElement("button"); document.body.append(previouslyFocused); diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 057db6d801f..469be486ee8 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -110,10 +110,7 @@ export function PreviewChromeRow({ return (
-
+
; - -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/apps/web/src/diffFileActions.test.ts b/apps/web/src/diffFileActions.test.ts new file mode 100644 index 00000000000..38032c07a88 --- /dev/null +++ b/apps/web/src/diffFileActions.test.ts @@ -0,0 +1,51 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { openDiffFilePrimaryAction } from "./diffFileActions"; +import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; + +const THREAD_REF = scopeThreadRef( + EnvironmentId.make("environment-local"), + ThreadId.make("thread-1"), +); + +describe("openDiffFilePrimaryAction", () => { + beforeEach(() => { + useRightPanelStore.setState({ byThreadKey: {} }); + }); + + it("opens diff files in the thread file viewer", () => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath: "apps/web/src/components/DiffPanel.tsx", + activeCwd: "/repo/project", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: "file:apps/web/src/components/DiffPanel.tsx", + }); + expect(openInEditor).not.toHaveBeenCalled(); + }); + + it("falls back to the editor without thread context", () => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: null, + filePath: "apps/web/src/components/DiffPanel.tsx", + activeCwd: "/repo/project", + openInEditor, + }); + + expect(openInEditor).toHaveBeenCalledWith( + "/repo/project/apps/web/src/components/DiffPanel.tsx", + ); + }); +}); diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts new file mode 100644 index 00000000000..335ad21fccf --- /dev/null +++ b/apps/web/src/diffFileActions.ts @@ -0,0 +1,25 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { useRightPanelStore } from "./rightPanelStore"; +import { resolvePathLinkTarget } from "./terminal-links"; + +interface OpenDiffFilePrimaryActionInput { + readonly threadRef: ScopedThreadRef | null; + readonly filePath: string; + readonly activeCwd: string | undefined; + readonly openInEditor: (targetPath: string) => void; +} + +export function openDiffFilePrimaryAction({ + threadRef, + filePath, + activeCwd, + openInEditor, +}: OpenDiffFilePrimaryActionInput): void { + if (threadRef) { + useRightPanelStore.getState().openFile(threadRef, filePath); + return; + } + + openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath); +} diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index f099338ab97..a9da256f8fe 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -1,8 +1,13 @@ import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; +import { Headers } from "effect/unstable/http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; + const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError); let mockSavedRecords: Array> = []; @@ -12,8 +17,11 @@ const mockFetchRemoteEnvironmentDescriptor = vi.fn(); const mockBootstrapRemoteBearerSession = vi.fn(); const mockFetchRemoteSessionState = vi.fn(); const mockFetchRemoteDpopSessionState = vi.fn(); +const mockResolveRemoteDpopWebSocketConnectionUrl = vi.fn(); const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); +const mockWsTransportConnectors: Array<() => Promise> = []; let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner; +let mockRelayClientTracer = Option.none(); const mockRemoteHttpRunPromise = vi.fn((effect: Effect.Effect) => Effect.runPromise( effect.pipe( @@ -24,6 +32,7 @@ const mockRemoteHttpRunPromise = vi.fn((effect: Effect.Effect) => createProof: () => Effect.succeed("dpop-proof"), }), ), + Effect.provideService(RelayClientTracer, mockRelayClientTracer), ), ), ); @@ -164,12 +173,16 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, fetchRemoteSessionState: mockFetchRemoteSessionState, fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState, + resolveRemoteDpopWebSocketConnectionUrl: mockResolveRemoteDpopWebSocketConnectionUrl, resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, }; }); vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: vi.fn(), + WsTransport: vi.fn(function WsTransport(connect: () => Promise) { + mockWsTransportConnectors.push(connect); + return {}; + }), })); describe("addSavedEnvironment", () => { @@ -181,6 +194,8 @@ describe("addSavedEnvironment", () => { vi.resetModules(); vi.clearAllMocks(); mockSavedRecords = []; + mockRelayClientTracer = Option.none(); + mockWsTransportConnectors.length = 0; vi.stubGlobal("window", { desktopBridge: { ensureSshEnvironment: mockEnsureSshEnvironment, @@ -237,6 +252,9 @@ describe("addSavedEnvironment", () => { mockResolveRemoteWebSocketConnectionUrl.mockReturnValue( Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"), ); + mockResolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( + Effect.succeed("wss://remote.example.com/?wsTicket=remote-dpop-token"), + ); mockFetchSshEnvironmentDescriptor.mockResolvedValue({ environmentId: EnvironmentId.make("environment-1"), label: "Remote environment", @@ -386,6 +404,7 @@ describe("addSavedEnvironment", () => { wsBaseUrl: "wss://managed.example.com/", relayUrl: "https://relay.example.com", accessToken: "managed-access-token", + relayTraceHeaders: Headers.empty, }); expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith( @@ -406,6 +425,19 @@ describe("addSavedEnvironment", () => { it("renews expired managed DPoP credentials through the relay", async () => { const environmentId = EnvironmentId.make("environment-1"); + const productSpans: Array = []; + mockRelayClientTracer = Option.some( + Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + productSpans.push(span); + return span; + }, + }), + ); + const relayTraceHeaders = Headers.fromInput({ + traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", + }); mockSavedRecords = [ { environmentId, @@ -443,6 +475,7 @@ describe("addSavedEnvironment", () => { wsBaseUrl: "wss://managed.example.com/", relayUrl: "https://relay.example.com", accessToken: "renewed-access-token", + relayTraceHeaders, }), ); @@ -460,6 +493,16 @@ describe("addSavedEnvironment", () => { method: "dpop", accessToken: "renewed-access-token", }); + const renewedTransportConnector = mockWsTransportConnectors.at(-1); + expect(renewedTransportConnector).toBeDefined(); + await renewedTransportConnector!(); + expect(mockResolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ + wsBaseUrl: "wss://managed.example.com/", + httpBaseUrl: "https://managed.example.com/", + accessToken: "renewed-access-token", + dpopProof: "dpop-proof", + }); + expect(productSpans.some((span) => span.name === "relay.environment.reconnect")).toBe(false); await resetEnvironmentServiceForTests(); }); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index dddb4cecdab..466a6f501e2 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -26,7 +26,10 @@ import { import { type QueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { Headers, HttpTraceContext } from "effect/unstable/http"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { createKnownEnvironment, getKnownEnvironmentWsBaseUrl, @@ -1173,6 +1176,7 @@ function createPrimaryEnvironmentClient( function createSavedEnvironmentClient( environmentId: EnvironmentId, credentialRef: { current: SavedEnvironmentCredential }, + relayTraceHeadersRef: { current: Headers.Headers | null }, ): WsRpcClient { useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); @@ -1196,21 +1200,10 @@ function createSavedEnvironmentClient( } if (credential.method === "dpop") { try { + const relayTraceHeaders = relayTraceHeadersRef.current; + relayTraceHeadersRef.current = null; return await webRuntime.runPromise( - createManagedRelayDpopProof({ - method: "POST", - url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(), - accessToken: credential.accessToken, - }).pipe( - Effect.flatMap((proof) => - resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - accessToken: credential.accessToken, - dpopProof: proof, - }), - ), - ), + resolveManagedRelayWebSocketUrl(record, credential, relayTraceHeaders), ); } catch (error) { if (!isEnvironmentAuthInvalidError(error)) { @@ -1223,19 +1216,10 @@ function createSavedEnvironmentClient( const renewedCredential = renewed.credential; credentialRef.current = renewedCredential; return await webRuntime.runPromise( - createManagedRelayDpopProof({ - method: "POST", - url: new URL("/api/auth/websocket-ticket", renewed.record.httpBaseUrl).toString(), - accessToken: renewedCredential.accessToken, - }).pipe( - Effect.flatMap((proof) => - resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: renewed.record.wsBaseUrl, - httpBaseUrl: renewed.record.httpBaseUrl, - accessToken: renewedCredential.accessToken, - dpopProof: proof, - }), - ), + resolveManagedRelayWebSocketUrl( + renewed.record, + renewedCredential, + renewed.relayTraceHeaders, ), ); } @@ -1339,9 +1323,44 @@ async function refreshSavedEnvironmentMetadata( .rename(record.environmentId, serverConfig.environment.label); } +const resolveManagedRelayWebSocketUrl = Effect.fn( + "web.environment.resolveManagedRelayWebSocketUrl", +)(function* ( + record: SavedEnvironmentRecord, + credential: Extract, + traceHeaders: Headers.Headers | null, +) { + const request = createManagedRelayDpopProof({ + method: "POST", + url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(), + accessToken: credential.accessToken, + }).pipe( + Effect.flatMap((proof) => + resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: record.wsBaseUrl, + httpBaseUrl: record.httpBaseUrl, + accessToken: credential.accessToken, + dpopProof: proof, + }), + ), + ); + const parent = traceHeaders ? HttpTraceContext.fromHeaders(traceHeaders) : Option.none(); + return yield* ( + Option.isSome(parent) + ? request.pipe(Effect.withParentSpan(parent.value)) + : request.pipe( + Effect.withSpan("relay.environment.reconnect", { + root: true, + attributes: { "relay.environment_id": record.environmentId }, + }), + ) + ).pipe(withRelayClientTracing); +}); + async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{ readonly record: SavedEnvironmentRecord; readonly credential: SavedEnvironmentCredential; + readonly relayTraceHeaders: Headers.Headers; } | null> { if (!record.relayManaged) { return null; @@ -1382,7 +1401,7 @@ async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Prom throw new Error("Unable to persist refreshed managed environment credentials."); } useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); - return { record: nextRecord, credential }; + return { record: nextRecord, credential, relayTraceHeaders: connected.relayTraceHeaders }; } function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { @@ -1466,6 +1485,7 @@ async function ensureSavedEnvironmentConnection( readonly scopes?: ReadonlyArray | null; readonly serverConfig?: ServerConfig | null; readonly allowManagedRenewal?: boolean; + readonly relayTraceHeaders?: Headers.Headers; }, ): Promise { const existing = environmentConnections.get(record.environmentId); @@ -1511,9 +1531,14 @@ async function ensureSavedEnvironmentConnection( } const activeCredential = { current: credential }; + const relayTraceHeaders = { current: options?.relayTraceHeaders ?? null }; const client = options?.client ?? - createSavedEnvironmentClient(activeRecord.environmentId, activeCredential); + createSavedEnvironmentClient( + activeRecord.environmentId, + activeCredential, + relayTraceHeaders, + ); const initialConfigSnapshot = createDeferredPromise(); const knownEnvironment = createKnownEnvironment({ id: activeRecord.environmentId, @@ -1589,6 +1614,7 @@ async function ensureSavedEnvironmentConnection( scopes: scopeHint, serverConfig: options?.serverConfig ?? null, allowManagedRenewal: false, + relayTraceHeaders: renewed.relayTraceHeaders, }); } } @@ -1916,6 +1942,7 @@ export async function addManagedRelayEnvironment(input: { readonly wsBaseUrl: string; readonly relayUrl: string; readonly accessToken: string; + readonly relayTraceHeaders: Headers.Headers; }): Promise { const existingRecord = getSavedEnvironmentRecord(input.environmentId); const record: SavedEnvironmentRecord = { @@ -1939,7 +1966,10 @@ export async function addManagedRelayEnvironment(input: { } useSavedEnvironmentRegistryStore.getState().upsert(record); await removeConnection(record.environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(record, { credential }); + await ensureSavedEnvironmentConnection(record, { + credential, + relayTraceHeaders: input.relayTraceHeaders, + }); return record; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 93d998577ad..fc4a755e5bb 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -11,20 +11,14 @@ --workspace-native-controls-inset: 0px; } -@media (width >= 40rem) { - :root { - --workspace-controls-right: calc(env(safe-area-inset-right) + 1.25rem); - } -} - .wco { --workspace-topbar-height: env(titlebar-area-height, 52px); --workspace-controls-top: env(titlebar-area-y, 0px); --workspace-controls-right: calc( - 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 1em + 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem ); --workspace-native-controls-inset: calc( - 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 1em + 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem ); } @@ -114,6 +108,11 @@ display: flex; height: var(--workspace-topbar-height); align-items: center; + -webkit-app-region: no-drag; + } + + .surface-subheader { + @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background; } } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a731a2aa006..7e30c772d4c 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -613,6 +613,40 @@ describe("resolveShortcutCommand", () => { "script.setup.run", ); }); + + it("matches bracket shortcuts using the physical key code", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "{", code: "BracketLeft", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), + "thread.previous", + ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "}", code: "BracketRight", ctrlKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "Linux", + }, + ), + "thread.next", + ); + }); + + it("matches Option-modified letters using the physical key code on macOS", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "∫", code: "KeyB", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { platform: "MacIntel" }, + ), + "rightPanel.toggle", + ); + }); }); describe("formatShortcutLabel", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index aa3e495e514..8f66b53a2f4 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -72,6 +72,10 @@ function normalizeEventKey(key: string): string { function resolveEventKeys(event: ShortcutEventLike): Set { const keys = new Set([normalizeEventKey(event.key)]); + const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]; + if (letterCode) { + keys.add(letterCode.toLowerCase()); + } const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined; if (!aliases) return keys; diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 6c17a2f0b18..1d7903ced06 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -5,6 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http"; import { remoteHttpClientLayer } from "@t3tools/client-runtime"; import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; +import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; import { PrimaryEnvironmentHttpClient, primaryEnvironmentHttpClientLive, @@ -13,13 +14,19 @@ import { primaryEnvironmentRequestInit } from "../environments/primary/requestIn import { browserCryptoLayer } from "../cloud/dpop"; import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; -import { resolveCloudPublicConfig } from "../cloud/publicConfig"; +import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; } const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch); +const webRelayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { + serviceName: "t3-web-relay-client", + serviceVersion: import.meta.env.APP_VERSION, + runtime: "browser", + client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", +}).pipe(Layer.provide(webHttpClientLayer)); export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer); @@ -57,6 +64,7 @@ export const webRuntime = ManagedRuntime.make( browserCryptoLayer, webManagedRelayClientLayer(configuredRelayUrl()).pipe( Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)), + Layer.provideMerge(webRelayTracingLayer), ), ), ); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 0c06dbb9ddd..d8a6d71b49a 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -9,6 +9,9 @@ interface ImportMetaEnv { readonly VITE_HOSTED_APP_CHANNEL: string; readonly VITE_CLERK_PUBLISHABLE_KEY: string; readonly VITE_CLERK_JWT_TEMPLATE: string; + readonly VITE_RELAY_OTLP_TRACES_URL: string; + readonly VITE_RELAY_OTLP_TRACES_DATASET: string; + readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; readonly APP_VERSION: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ec890fa14a4..6ad73571add 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -19,6 +19,9 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; +const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || ""; +const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; +const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredHostedAppUrl = (() => { @@ -136,6 +139,11 @@ export default defineConfig(() => { "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate), + "import.meta.env.VITE_RELAY_OTLP_TRACES_URL": JSON.stringify(configuredRelayTracingUrl), + "import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET": JSON.stringify( + configuredRelayTracingDataset, + ), + "import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN": JSON.stringify(configuredRelayTracingToken), "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index 5dbdea8dfd7..b9e35fd132d 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -42,6 +42,9 @@ export default Alchemy.Stack( mobileTracingUrl: observability.traces.otelTracesEndpoint, mobileTracingDataset: observability.traces.name, mobileTracingToken: observability.mobileIngestToken.token, + clientTracingUrl: observability.traces.otelTracesEndpoint, + clientTracingDataset: observability.traces.name, + clientTracingToken: observability.clientIngestToken.token, }; }), ); 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/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts index 08f6bb55cb2..992cb2d887b 100644 --- a/infra/relay/scripts/deploy.test.ts +++ b/infra/relay/scripts/deploy.test.ts @@ -1,10 +1,16 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { hasDeployChanges, + publicConfigFromOutput, reconcileRootEnvPublicConfig, reconcileRootEnvRelayUrl, serializeGithubOutput, + serializeRelayClientTracingEnvironment, } from "./deploy.ts"; describe("hasDeployChanges", () => { @@ -62,6 +68,9 @@ describe("reconcileRootEnvPublicConfig", () => { mobileTracingUrl: "https://api.axiom.co/v1/traces", mobileTracingDataset: "t3-code-mobile-traces-dev", mobileTracingToken: "xaat-public-ingest", + clientTracingUrl: "https://api.axiom.co/v1/traces", + clientTracingDataset: "t3-code-relay-client-traces-dev", + clientTracingToken: "xaat-relay-client-ingest", } as const; it("adds the complete local client config", () => { @@ -71,6 +80,9 @@ describe("reconcileRootEnvPublicConfig", () => { "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev", "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest", "", ].join("\n"), ); @@ -85,6 +97,9 @@ describe("reconcileRootEnvPublicConfig", () => { "T3CODE_MOBILE_OTLP_TRACES_URL=https://old.example.test/v1/traces", "T3CODE_MOBILE_OTLP_TRACES_DATASET=old-dataset", "T3CODE_MOBILE_OTLP_TRACES_TOKEN=old-token", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://old.example.test/v1/traces", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=old-client-dataset", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=old-client-token", "", ].join("\n"), config, @@ -96,6 +111,9 @@ describe("reconcileRootEnvPublicConfig", () => { "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev", "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest", "", ].join("\n"), ); @@ -113,3 +131,73 @@ describe("serializeGithubOutput", () => { ).toBe("changed=false\nresult=noop\nrelay_url=https://relay.example.test\n"); }); }); + +describe("serializeRelayClientTracingEnvironment", () => { + it("serializes tracing config for downstream GITHUB_ENV loading", () => { + expect( + serializeRelayClientTracingEnvironment({ + relayUrl: "https://relay.example.test", + mobileTracingUrl: "https://api.axiom.co/v1/traces", + mobileTracingDataset: "mobile", + mobileTracingToken: "mobile-token", + clientTracingUrl: "https://api.axiom.co/v1/traces", + clientTracingDataset: "relay", + clientTracingToken: "client-token", + }), + ).toBe( + [ + "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=relay", + "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-token", + "", + ].join("\n"), + ); + }); +}); + +describe("release workflow tracing config propagation", () => { + it.effect("uses an artifact instead of a masked cross-job token output", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workflowPath = yield* path.fromFileUrl( + new URL("../../../.github/workflows/release.yml", import.meta.url), + ); + const workflow = yield* fileSystem.readFileString(workflowPath); + + expect(workflow).not.toContain("client_tracing_token:"); + expect(workflow).not.toContain("needs.relay_public_config.outputs.client_tracing_token"); + expect(workflow).toContain('--github-env-file "$RUNNER_TEMP/relay-client-tracing.env"'); + expect(workflow).toContain("name: relay-client-tracing-config"); + expect(workflow).toContain('cat "$config_path" >> "$GITHUB_ENV"'); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("publicConfigFromOutput", () => { + it("reads the complete public tracing config from persisted Alchemy output", () => { + expect( + publicConfigFromOutput({ + url: "https://relay.example.test", + mobileTracingUrl: "https://api.axiom.co/v1/traces", + mobileTracingDataset: "mobile", + mobileTracingToken: "mobile-token", + clientTracingUrl: "https://api.axiom.co/v1/traces", + clientTracingDataset: "relay", + clientTracingToken: "client-token", + }), + ).toEqual({ + relayUrl: "https://relay.example.test", + mobileTracingUrl: "https://api.axiom.co/v1/traces", + mobileTracingDataset: "mobile", + mobileTracingToken: "mobile-token", + clientTracingUrl: "https://api.axiom.co/v1/traces", + clientTracingDataset: "relay", + clientTracingToken: "client-token", + }); + }); + + it("rejects incomplete stack output", () => { + expect(publicConfigFromOutput({ url: "https://relay.example.test" })).toBeNull(); + }); +}); diff --git a/infra/relay/scripts/deploy.ts b/infra/relay/scripts/deploy.ts index e4ed492dbbe..2ef82ec4ffa 100644 --- a/infra/relay/scripts/deploy.ts +++ b/infra/relay/scripts/deploy.ts @@ -8,10 +8,12 @@ import { provideFreshArtifactStore } from "alchemy/Artifacts"; import { AuthProviders } from "alchemy/Auth/AuthProvider"; import { CredentialsStoreLive } from "alchemy/Auth/Credentials"; import { ProfileLive } from "alchemy/Auth/Profile"; +import * as Cloudflare from "alchemy/Cloudflare"; import { Cli } from "alchemy/Cli/Cli"; import { LoggingCli } from "alchemy/Cli/LoggingCli"; import * as Plan from "alchemy/Plan"; import * as Stage from "alchemy/Stage"; +import * as State from "alchemy/State/State"; import { TelemetryLive } from "alchemy/Telemetry/Layer"; import { PlatformServices } from "alchemy/Util/PlatformServices"; import * as Config from "effect/Config"; @@ -41,6 +43,8 @@ export interface RelayDeployOptions { readonly yes: boolean; readonly adopt: boolean; readonly githubOutput: boolean; + readonly githubEnvFile: Option.Option; + readonly readState: boolean; } export interface RelayPublicConfig { @@ -48,6 +52,9 @@ export interface RelayPublicConfig { readonly mobileTracingUrl: string; readonly mobileTracingDataset: string; readonly mobileTracingToken: string; + readonly clientTracingUrl: string; + readonly clientTracingDataset: string; + readonly clientTracingToken: string; } const publicConfigEnvEntries = (config: RelayPublicConfig) => @@ -56,6 +63,9 @@ const publicConfigEnvEntries = (config: RelayPublicConfig) => T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl, T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset, T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileTracingToken, + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl, + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset, + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.clientTracingToken, }) as const; export function reconcileRootEnvPublicConfig(contents: string, config: RelayPublicConfig): string { @@ -82,9 +92,13 @@ export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): st mobileTracingUrl: "", mobileTracingDataset: "", mobileTracingToken: "", + clientTracingUrl: "", + clientTracingDataset: "", + clientTracingToken: "", }) .split("\n") .filter((line) => !line.startsWith("T3CODE_MOBILE_OTLP_TRACES_")) + .filter((line) => !line.startsWith("T3CODE_RELAY_CLIENT_OTLP_TRACES_")) .join("\n"); } @@ -98,7 +112,7 @@ export function hasDeployChanges(plan: Plan.Plan): boolean { ); } -export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled"; +export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled" | "state"; export interface RelayDeployOutcome { readonly result: RelayDeployResult; @@ -112,6 +126,14 @@ export function serializeGithubOutput(entries: Readonly path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -151,7 +173,7 @@ const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* ( const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; yield* fs.writeFileString(rootEnvPath, reconcileRootEnvPublicConfig(contents, config)); - yield* Console.log(`Updated ${rootEnvPath} with relay URL and mobile tracing configuration`); + yield* Console.log(`Updated ${rootEnvPath} with relay public client configuration`); }); const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* ( @@ -165,14 +187,34 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* changed: outcome.changed, result: outcome.result, ...(Option.isSome(outcome.publicConfig) - ? { relay_url: outcome.publicConfig.value.relayUrl } + ? { + relay_url: outcome.publicConfig.value.relayUrl, + } : {}), }), { flag: "a" }, ); }); -const deployServices = Layer.mergeAll( +const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* ( + outcome: RelayDeployOutcome, + outputPath: string, +) { + if (Option.isNone(outcome.publicConfig)) { + return yield* new RelayDeployError({ + message: "Relay public client config is unavailable for the GitHub environment file", + }); + } + const fs = yield* FileSystem.FileSystem; + yield* Console.log(`::add-mask::${outcome.publicConfig.value.clientTracingToken}`); + yield* fs.writeFileString( + outputPath, + serializeRelayClientTracingEnvironment(outcome.publicConfig.value), + ); +}); + +const deployBaseServices = Layer.mergeAll( + Layer.succeed(AuthProviders, {}), Layer.provideMerge(AlchemyContextLive, PlatformServices), Layer.provide(ProfileLive, PlatformServices), Layer.provide(CredentialsStoreLive, PlatformServices), @@ -180,6 +222,64 @@ const deployServices = Layer.mergeAll( TelemetryLive, LoggingCli, ); +const deployServices = deployBaseServices; + +export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null { + if (typeof output !== "object" || output === null) { + return null; + } + const value = output as Record; + const text = (name: string) => (typeof value[name] === "string" ? value[name] : undefined); + const secret = (name: string): string | undefined => { + const candidate = value[name]; + if (!Redacted.isRedacted(candidate)) { + return text(name); + } + const redacted = Redacted.value(candidate); + return typeof redacted === "string" ? redacted : undefined; + }; + const relayUrl = text("url"); + const mobileTracingUrl = text("mobileTracingUrl"); + const mobileTracingDataset = text("mobileTracingDataset"); + const mobileTracingToken = secret("mobileTracingToken"); + const clientTracingUrl = text("clientTracingUrl"); + const clientTracingDataset = text("clientTracingDataset"); + const clientTracingToken = secret("clientTracingToken"); + return relayUrl && + mobileTracingUrl && + mobileTracingDataset && + mobileTracingToken && + clientTracingUrl && + clientTracingDataset && + clientTracingToken + ? { + relayUrl, + mobileTracingUrl, + mobileTracingDataset, + mobileTracingToken, + clientTracingUrl, + clientTracingDataset, + clientTracingToken, + } + : null; +} + +const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) { + const state = yield* State.State; + const service = yield* state; + const output = yield* service.getOutput({ stack: "T3CodeRelay", stage }); + const publicConfig = publicConfigFromOutput(output); + if (publicConfig === null) { + return yield* new RelayDeployError({ + message: `Alchemy relay state for stage ${stage} did not include complete public client config`, + }); + } + return { + result: "state", + changed: false, + publicConfig: Option.some(publicConfig), + } satisfies RelayDeployOutcome; +}); const runRelayDeploy = Effect.fn("relay.deploy.run")( function* ( @@ -188,7 +288,6 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( _stage: string, ) { const stack = yield* RelayStack; - const cli = yield* Cli; const plan = yield* Plan.make(stack, { force: options.force }).pipe( Effect.provide(stack.services), @@ -223,7 +322,10 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( output.url === undefined || output.mobileTracingUrl === undefined || output.mobileTracingDataset === undefined || - output.mobileTracingToken === undefined + output.mobileTracingToken === undefined || + output.clientTracingUrl === undefined || + output.clientTracingDataset === undefined || + output.clientTracingToken === undefined ) { return yield* new RelayDeployError({ message: "Alchemy relay deploy output did not include complete public client config", @@ -237,6 +339,9 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( mobileTracingUrl: output.mobileTracingUrl, mobileTracingDataset: output.mobileTracingDataset, mobileTracingToken: Redacted.value(output.mobileTracingToken), + clientTracingUrl: output.clientTracingUrl, + clientTracingDataset: output.clientTracingDataset, + clientTracingToken: Redacted.value(output.clientTracingToken), }), } satisfies RelayDeployOutcome; }, @@ -264,13 +369,18 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO Effect.provide(ConfigProvider.layer(configProvider)), ); const stage = Option.getOrElse(options.stage, () => configuredStage); - const outcome = yield* runRelayDeploy(options, configProvider, stage); + const outcome = options.readState + ? yield* readRelayPublicConfig(stage).pipe(Effect.provide(Cloudflare.state())) + : yield* runRelayDeploy(options, configProvider, stage); if (Option.isSome(outcome.publicConfig)) { yield* reconcileRootEnv(outcome.publicConfig.value); } if (options.githubOutput) { yield* writeGithubOutput(outcome); } + if (Option.isSome(options.githubEnvFile)) { + yield* writeGithubEnvFile(outcome, options.githubEnvFile.value); + } }); export const relayDeployCommand = Command.make( @@ -306,6 +416,16 @@ export const relayDeployCommand = Command.make( Flag.withDescription("Append relay deployment metadata to GITHUB_OUTPUT."), Flag.withDefault(false), ), + githubEnvFile: Flag.string("github-env-file").pipe( + Flag.withDescription( + "Write relay client tracing variables to a file suitable for GITHUB_ENV.", + ), + Flag.optional, + ), + readState: Flag.boolean("read-state").pipe( + Flag.withDescription("Read the deployed stack output without planning or applying changes."), + Flag.withDefault(false), + ), }, deploy, ).pipe(Command.withDescription("Deploy the T3 Code relay through Alchemy.")); diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index 0f831586ece..e0a79599cf5 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -50,6 +50,14 @@ export const RelayObservability = Effect.gen(function* () { })), }); + const clientIngestToken = yield* Axiom.ApiToken("RelayClientAxiomIngestToken", { + name: relayResourceNameForStage("t3-code-relay-client-otel-ingest", stage), + description: "Owned by Alchemy. Scoped OTLP ingest token for first-party relay client spans.", + datasetCapabilities: Output.map(traces.name, (dataset) => ({ + [dataset]: { ingest: ["create" as const] }, + })), + }); + yield* Axiom.View("RelayRecentSpansView", { name: relayResourceNameForStage("t3-code-relay-recent-spans", stage), description: "Recent relay HTTP request spans.", @@ -57,7 +65,7 @@ export const RelayObservability = Effect.gen(function* () { aplQuery: Output.map(traces.name, relayRecentSpansQuery), }); - return { traces, workerIngestToken, mobileIngestToken } as const; + return { traces, workerIngestToken, mobileIngestToken, clientIngestToken } as const; }); export const withSpanAttributes = diff --git a/packages/client-runtime/src/composerPathSearchState.ts b/packages/client-runtime/src/composerPathSearchState.ts index e8652ce561e..693d60cb46f 100644 --- a/packages/client-runtime/src/composerPathSearchState.ts +++ b/packages/client-runtime/src/composerPathSearchState.ts @@ -8,7 +8,6 @@ import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; export interface ComposerPathSearchEntry { readonly path: string; readonly kind: "file" | "directory"; - readonly parentPath?: string; } export interface ComposerPathSearchState { @@ -84,11 +83,7 @@ export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget) function toSearchEntries( entries: ProjectSearchEntriesResult["entries"], ): ReadonlyArray { - return entries.map((entry) => ({ - path: entry.path, - kind: entry.kind === "directory" ? "directory" : "file", - ...(entry.parentPath !== undefined ? { parentPath: entry.parentPath } : {}), - })); + return entries; } export function createComposerPathSearchManager(config: { diff --git a/packages/client-runtime/src/managedRelay.ts b/packages/client-runtime/src/managedRelay.ts index a3bc973c64c..f4b9b1f9353 100644 --- a/packages/client-runtime/src/managedRelay.ts +++ b/packages/client-runtime/src/managedRelay.ts @@ -26,6 +26,7 @@ import { RelayUnregisterDeviceEndpoint, } from "@t3tools/contracts/relay"; import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -357,6 +358,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) relayClientError("Could not list relay-managed environments.", cause), ), timeoutRelayRequest("Relay environment listing timed out."), + withRelayClientTracing, ), listDevices: (input) => client.client @@ -369,6 +371,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) relayClientError("Could not list relay client devices.", cause), ), timeoutRelayRequest("Relay client device listing timed out."), + withRelayClientTracing, ), createEnvironmentLinkChallenge: (input) => client.client @@ -381,6 +384,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) relayClientError("Could not create relay environment link challenge.", cause), ), timeoutRelayRequest("Relay environment link challenge timed out."), + withRelayClientTracing, ), linkEnvironment: (input) => client.client @@ -393,6 +397,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) relayClientError("Could not link relay environment.", cause), ), timeoutRelayRequest("Relay environment linking timed out."), + withRelayClientTracing, ), unlinkEnvironment: (input) => client.client @@ -405,6 +410,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) relayClientError("Could not unlink relay environment.", cause), ), timeoutRelayRequest("Relay environment unlinking timed out."), + withRelayClientTracing, ), getEnvironmentStatus: (input) => Effect.gen(function* () { @@ -424,7 +430,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) ), timeoutRelayRequest("Relay environment status request timed out."), ); - }), + }).pipe(withRelayClientTracing), connectEnvironment: (input) => Effect.gen(function* () { const authorization = yield* authorize({ @@ -448,7 +454,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) ), timeoutRelayRequest("Relay environment connection timed out."), ); - }), + }).pipe(withRelayClientTracing), registerDevice: (input) => Effect.gen(function* () { const authorization = yield* authorizeMobileRegistration({ @@ -466,7 +472,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) ), timeoutRelayRequest("Relay mobile device registration timed out."), ); - }), + }).pipe(withRelayClientTracing), unregisterDevice: (input) => Effect.gen(function* () { const authorization = yield* authorizeMobileRegistration({ @@ -484,7 +490,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) ), timeoutRelayRequest("Relay mobile device unregistration timed out."), ); - }), + }).pipe(withRelayClientTracing), registerLiveActivity: (input) => Effect.gen(function* () { const authorization = yield* authorizeMobileRegistration({ @@ -502,7 +508,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) ), timeoutRelayRequest("Relay Live Activity registration timed out."), ); - }), + }).pipe(withRelayClientTracing), resetTokenCache: SynchronizedRef.set(cachedTokens, []), }); }), diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0d38efec209..29610845288 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -17,7 +17,6 @@ const ProjectEntryKind = Schema.Literals(["file", "directory"]); export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, - parentPath: Schema.optional(TrimmedNonEmptyString), }); export type ProjectEntry = typeof ProjectEntry.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index ad3db08eb2e..8daad0b9b8d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -143,6 +143,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" @@ -158,6 +162,10 @@ "./hostProcess": { "types": "./src/hostProcess.ts", "import": "./src/hostProcess.ts" + }, + "./relayTracing": { + "types": "./src/relayTracing.ts", + "import": "./src/relayTracing.ts" } }, "scripts": { 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/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts new file mode 100644 index 00000000000..10f4e1087a3 --- /dev/null +++ b/packages/shared/src/relayTracing.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Tracer from "effect/Tracer"; + +import { RelayClientTracer, withRelayClientTracing } from "./relayTracing.ts"; + +function collectingTracer(spans: Array): Tracer.Tracer { + return Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + spans.push(span.name); + }; + return span; + }, + }); +} + +describe("withRelayClientTracing", () => { + it.effect("uses the product tracer only for relay operations", () => + Effect.gen(function* () { + const userSpans: Array = []; + const productSpans: Array = []; + const userTracer = collectingTracer(userSpans); + const productTracer = collectingTracer(productSpans); + + yield* Effect.void.pipe(Effect.withSpan("user.operation"), Effect.withTracer(userTracer)); + yield* Effect.void.pipe( + Effect.withSpan("relay.operation"), + withRelayClientTracing, + Effect.provideService(RelayClientTracer, Option.some(productTracer)), + Effect.withTracer(userTracer), + ); + + expect(userSpans).toEqual(["user.operation"]); + expect(productSpans).toEqual(["relay.operation"]); + }), + ); + + it.effect("preserves the active tracer when product tracing is disabled", () => + Effect.gen(function* () { + const userSpans: Array = []; + const userTracer = collectingTracer(userSpans); + + yield* Effect.void.pipe( + Effect.withSpan("relay.operation"), + withRelayClientTracing, + Effect.withTracer(userTracer), + ); + + expect(userSpans).toEqual(["relay.operation"]); + }), + ); +}); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts new file mode 100644 index 00000000000..6005856d9d5 --- /dev/null +++ b/packages/shared/src/relayTracing.ts @@ -0,0 +1,70 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Tracer from "effect/Tracer"; +import type { HttpClient } from "effect/unstable/http"; +import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; + +export interface RelayClientTracingConfig { + readonly tracesUrl: string; + readonly tracesDataset: string; + readonly tracesToken: string; +} + +export interface RelayClientTracingResource { + readonly serviceName: string; + readonly serviceVersion?: string; + readonly runtime: string; + readonly client: string; + readonly component?: string; +} + +export class RelayClientTracer extends Context.Reference( + "@t3tools/shared/relayTracing/RelayClientTracer", + { + defaultValue: () => Option.none(), + }, +) {} + +export const withRelayClientTracing = ( + effect: Effect.Effect, +): Effect.Effect => + RelayClientTracer.pipe( + Effect.flatMap( + Option.match({ + onNone: () => effect, + onSome: (tracer) => effect.pipe(Effect.provideService(Tracer.Tracer, tracer)), + }), + ), + ); + +export function makeRelayClientTracingLayer( + config: RelayClientTracingConfig | null, + resource: RelayClientTracingResource, +): Layer.Layer { + if (config === null) { + return Layer.succeed(RelayClientTracer, Option.none()); + } + + const tracerLayer = OtlpTracer.layer({ + url: config.tracesUrl, + headers: { + Authorization: `Bearer ${config.tracesToken}`, + "X-Axiom-Dataset": config.tracesDataset, + }, + resource: { + serviceName: resource.serviceName, + serviceVersion: resource.serviceVersion, + attributes: { + "service.runtime": resource.runtime, + "service.component": resource.component ?? "relay-client", + "t3.client.surface": resource.client, + }, + }, + }).pipe(Layer.provide(OtlpSerialization.layerJson)); + + return Layer.effect(RelayClientTracer, Tracer.Tracer.pipe(Effect.map(Option.some))).pipe( + Layer.provide(tracerLayer), + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0db0bbe437..76b6df54435 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,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(b4c51ae5246df809fa0dfff78a24113e) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -4024,6 +4027,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} @@ -13309,6 +13323,16 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(b4c51ae5246df809fa0dfff78a24113e)': + dependencies: + expo-asset: 56.0.17(expo@56.0.12)(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.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.4(expo@56.0.12)(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.17)(react@19.2.3))(react@19.2.3) + expo-haptics: 56.0.3(expo@56.0.12) + expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(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.17)(react@19.2.3))(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.17)(react@19.2.3) + 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.17)(react@19.2.3))(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.17)(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.17)(react@19.2.3))(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/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 6dd8aa0ef8a..2bfa1eee764 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; @@ -298,6 +306,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, @@ -718,7 +767,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( appId: "com.t3tools.t3code", productName: resolveDesktopProductName(version), artifactName: "T3-Code-${version}-${arch}.${ext}", - asarUnpack: ["node_modules/@github/copilot*/**/*"], + asarUnpack: ["node_modules/@github/copilot*/**/*", ...DESKTOP_ASAR_UNPACK], directories: { buildResources: "apps/desktop/resources", }, @@ -940,6 +989,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ...resolvedServerDependencies, ...resolvedDesktopRuntimeDependencies, ...bundledCopilotPlatformDependencies, + ...resolveFffNativeDependencies( + options.platform, + options.arch, + serverPackageJson.dependencies["@ff-labs/fff-node"], + ), }; const stagePnpmConfig = createStagePnpmConfig(workspacePatchedDependencies, stageDependencies); const stagePackageJson: StagePackageJson = { @@ -970,19 +1024,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/lib/public-config.test.ts b/scripts/lib/public-config.test.ts index 3d4a8f6dea5..6f6e8315664 100644 --- a/scripts/lib/public-config.test.ts +++ b/scripts/lib/public-config.test.ts @@ -33,6 +33,12 @@ describe("loadRepoEnv", () => { expect(env.EXPO_PUBLIC_OTLP_TRACES_URL).toBeUndefined(); expect(env.EXPO_PUBLIC_OTLP_TRACES_DATASET).toBeUndefined(); expect(env.EXPO_PUBLIC_OTLP_TRACES_TOKEN).toBeUndefined(); + expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL).toBeUndefined(); + expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET).toBeUndefined(); + expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN).toBeUndefined(); + expect(env.VITE_RELAY_OTLP_TRACES_URL).toBeUndefined(); + expect(env.VITE_RELAY_OTLP_TRACES_DATASET).toBeUndefined(); + expect(env.VITE_RELAY_OTLP_TRACES_TOKEN).toBeUndefined(); }); it("applies process, root local, and root precedence in that order", () => { @@ -91,6 +97,29 @@ describe("loadRepoEnv", () => { mobileOtlpTracesUrl: "https://api.axiom.co/v1/traces", mobileOtlpTracesDataset: "mobile-traces", mobileOtlpTracesToken: "mobile-token", + relayClientOtlpTracesUrl: undefined, + relayClientOtlpTracesDataset: undefined, + relayClientOtlpTracesToken: undefined, + }); + }); + + it("projects canonical relay client tracing values to web build aliases", () => { + expect( + loadRepoEnv({ + baseEnv: { + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "relay-client-traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "relay-client-token", + }, + repoRoot: makeTemporaryDirectory(), + }), + ).toEqual({ + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "relay-client-traces", + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "relay-client-token", + VITE_RELAY_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", + VITE_RELAY_OTLP_TRACES_DATASET: "relay-client-traces", + VITE_RELAY_OTLP_TRACES_TOKEN: "relay-client-token", }); }); diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index fcae1531200..9b988df9c5b 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -12,6 +12,9 @@ export interface T3CodePublicConfig { readonly mobileOtlpTracesUrl: string | undefined; readonly mobileOtlpTracesDataset: string | undefined; readonly mobileOtlpTracesToken: string | undefined; + readonly relayClientOtlpTracesUrl: string | undefined; + readonly relayClientOtlpTracesDataset: string | undefined; + readonly relayClientOtlpTracesToken: string | undefined; } type Environment = Readonly>; @@ -78,6 +81,24 @@ export function loadRepoEnv({ EXPO_PUBLIC_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken, } : {}), + ...(config.relayClientOtlpTracesUrl + ? { + T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.relayClientOtlpTracesUrl, + VITE_RELAY_OTLP_TRACES_URL: config.relayClientOtlpTracesUrl, + } + : {}), + ...(config.relayClientOtlpTracesDataset + ? { + T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.relayClientOtlpTracesDataset, + VITE_RELAY_OTLP_TRACES_DATASET: config.relayClientOtlpTracesDataset, + } + : {}), + ...(config.relayClientOtlpTracesToken + ? { + T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.relayClientOtlpTracesToken, + VITE_RELAY_OTLP_TRACES_TOKEN: config.relayClientOtlpTracesToken, + } + : {}), }; } @@ -112,6 +133,21 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP "T3CODE_MOBILE_OTLP_TRACES_TOKEN", "EXPO_PUBLIC_OTLP_TRACES_TOKEN", ), + relayClientOtlpTracesUrl: firstNonEmpty( + sources, + "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL", + "VITE_RELAY_OTLP_TRACES_URL", + ), + relayClientOtlpTracesDataset: firstNonEmpty( + sources, + "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET", + "VITE_RELAY_OTLP_TRACES_DATASET", + ), + relayClientOtlpTracesToken: firstNonEmpty( + sources, + "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN", + "VITE_RELAY_OTLP_TRACES_TOKEN", + ), }; } 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",