authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-16 09:31:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-16 09:31:57-07:00
log689a882045384f9f20e0c016b27fab20b7d76969
tree93b8a06239b1b591e39058122a2a8d8d98ae4dd4
parent85fe62d4021dfabe14572b7a7ef55e3f4f1144f4
signature Signed by PGP key B5690EEEBB952194

[codex] Add native mobile composer and markdown (#3101)

Co-authored-by: codex <codex@users.noreply.github.com>

148 files changed, 8550 insertions(+), 715 deletions(-)

apps/mobile/clerk-theme.json+2-2
...@@ -13,7 +13,7 @@...@@ -13,7 +13,7 @@
13 "neutral": "#F5F5F5",13 "neutral": "#F5F5F5",
14 "border": "#E5E5EA",14 "border": "#E5E5EA",
15 "ring": "#A3A3A3",15 "ring": "#A3A3A3",
16 "muted": "#F5F5F5",16 "muted": "#F2F2F7",
17 "shadow": "#000000"17 "shadow": "#000000"
18 },18 },
19 "darkColors": {19 "darkColors": {
...@@ -30,7 +30,7 @@...@@ -30,7 +30,7 @@
30 "neutral": "#1C1C1C",30 "neutral": "#1C1C1C",
31 "border": "#2A2A2A",31 "border": "#2A2A2A",
32 "ring": "#525252",32 "ring": "#525252",
33 "muted": "#1C1C1C",33 "muted": "#0E0E0E",
34 "shadow": "#000000"34 "shadow": "#000000"
35 },35 },
36 "design": {36 "design": {
apps/mobile/global.css+15-5
...@@ -18,7 +18,7 @@...@@ -18,7 +18,7 @@
18 --color-foreground: #262626;18 --color-foreground: #262626;
19 --color-foreground-secondary: #525252;19 --color-foreground-secondary: #525252;
20 --color-foreground-muted: #737373;20 --color-foreground-muted: #737373;
21 --color-foreground-tertiary: #a3a3a3;21 --color-foreground-tertiary: #8e8e93;
2222
23 /* Borders & separators */23 /* Borders & separators */
24 --color-border: rgba(0, 0, 0, 0.08);24 --color-border: rgba(0, 0, 0, 0.08);
...@@ -28,6 +28,9 @@...@@ -28,6 +28,9 @@
28 /* Subtle backgrounds (badges, pills, overlays) */28 /* Subtle backgrounds (badges, pills, overlays) */
29 --color-subtle: rgba(0, 0, 0, 0.04);29 --color-subtle: rgba(0, 0, 0, 0.04);
30 --color-subtle-strong: rgba(0, 0, 0, 0.08);30 --color-subtle-strong: rgba(0, 0, 0, 0.08);
31 --color-inline-skill-background: rgba(217, 70, 239, 0.12);
32 --color-inline-skill-border: rgba(217, 70, 239, 0.25);
33 --color-inline-skill-foreground: #a21caf;
3134
32 /* Primary action */35 /* Primary action */
33 --color-primary: #262626;36 --color-primary: #262626;
...@@ -58,6 +61,8 @@...@@ -58,6 +61,8 @@
58 /* Header / glass chrome */61 /* Header / glass chrome */
59 --color-header: rgba(255, 255, 255, 0.97);62 --color-header: rgba(255, 255, 255, 0.97);
60 --color-header-border: rgba(0, 0, 0, 0.06);63 --color-header-border: rgba(0, 0, 0, 0.06);
64 --color-glass-surface: rgba(255, 255, 255, 0.72);
65 --color-glass-tint: rgba(255, 255, 255, 0.18);
6166
62 /* StatusBar */67 /* StatusBar */
63 --color-status-bar: #f2f2f7;68 --color-status-bar: #f2f2f7;
...@@ -105,8 +110,8 @@...@@ -105,8 +110,8 @@
105 /* Text */110 /* Text */
106 --color-foreground: #f5f5f5;111 --color-foreground: #f5f5f5;
107 --color-foreground-secondary: #a3a3a3;112 --color-foreground-secondary: #a3a3a3;
108 --color-foreground-muted: #737373;113 --color-foreground-muted: #8e8e93;
109 --color-foreground-tertiary: #525252;114 --color-foreground-tertiary: #636366;
110115
111 /* Borders & separators */116 /* Borders & separators */
112 --color-border: rgba(255, 255, 255, 0.06);117 --color-border: rgba(255, 255, 255, 0.06);
...@@ -116,6 +121,9 @@...@@ -116,6 +121,9 @@
116 /* Subtle backgrounds (badges, pills, overlays) */121 /* Subtle backgrounds (badges, pills, overlays) */
117 --color-subtle: rgba(255, 255, 255, 0.04);122 --color-subtle: rgba(255, 255, 255, 0.04);
118 --color-subtle-strong: rgba(255, 255, 255, 0.08);123 --color-subtle-strong: rgba(255, 255, 255, 0.08);
124 --color-inline-skill-background: rgba(217, 70, 239, 0.12);
125 --color-inline-skill-border: rgba(217, 70, 239, 0.25);
126 --color-inline-skill-foreground: #f0abfc;
119127
120 /* Primary action */128 /* Primary action */
121 --color-primary: #f5f5f5;129 --color-primary: #f5f5f5;
...@@ -136,16 +144,18 @@...@@ -136,16 +144,18 @@
136 /* Inputs */144 /* Inputs */
137 --color-input: #141414;145 --color-input: #141414;
138 --color-input-border: rgba(255, 255, 255, 0.08);146 --color-input-border: rgba(255, 255, 255, 0.08);
139 --color-placeholder: #737373;147 --color-placeholder: #8e8e93;
140148
141 /* Icons */149 /* Icons */
142 --color-icon: #f5f5f5;150 --color-icon: #f5f5f5;
143 --color-icon-muted: #a3a3a3;151 --color-icon-muted: #a3a3a3;
144 --color-icon-subtle: #737373;152 --color-icon-subtle: #8e8e93;
145153
146 /* Header / glass chrome */154 /* Header / glass chrome */
147 --color-header: rgba(10, 10, 10, 0.97);155 --color-header: rgba(10, 10, 10, 0.97);
148 --color-header-border: rgba(255, 255, 255, 0.06);156 --color-header-border: rgba(255, 255, 255, 0.06);
157 --color-glass-surface: rgba(23, 23, 23, 0.78);
158 --color-glass-tint: rgba(23, 23, 23, 0.24);
149159
150 /* StatusBar */160 /* StatusBar */
151 --color-status-bar: #0a0a0a;161 --color-status-bar: #0a0a0a;
apps/mobile/modules/t3-composer-editor/LICENSE created+21
...@@ -0,0 +1,21 @@
1The MIT License (MIT)
2
3Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
apps/mobile/modules/t3-composer-editor/expo-module.config.json created+6
...@@ -0,0 +1,6 @@
1{
2 "platforms": ["apple"],
3 "apple": {
4 "modules": ["T3ComposerEditorModule"]
5 }
6}
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec created+21
...@@ -0,0 +1,21 @@
1Pod::Spec.new do |s|
2 s.name = 'T3ComposerEditor'
3 s.version = '1.0.0'
4 s.summary = 'Native attributed composer editor for T3 Code mobile.'
5 s.description = 'UIKit-backed rich text composer with atomic skill and file tokens.'
6 s.author = 'T3 Tools'
7 s.homepage = 'https://t3tools.com'
8 s.platforms = {
9 :ios => '16.4',
10 }
11 s.source = { :path => '.' }
12 s.static_framework = true
13
14 s.dependency 'ExpoModulesCore'
15 # Swift/Objective-C compatibility
16 s.pod_target_xcconfig = {
17 'DEFINES_MODULE' => 'YES',
18 }
19
20 s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
21end
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift created+71
...@@ -0,0 +1,71 @@
1import ExpoModulesCore
2
3public class T3ComposerEditorModule: Module {
4 public func definition() -> ModuleDefinition {
5 Name("T3ComposerEditor")
6
7 View(T3ComposerEditorView.self) {
8 Prop("value") { (view: T3ComposerEditorView, value: String) in
9 view.setValue(value)
10 }
11 Prop("tokensJson") { (view: T3ComposerEditorView, tokensJson: String) in
12 view.setTokensJson(tokensJson)
13 }
14 Prop("selectionJson") { (view: T3ComposerEditorView, selectionJson: String) in
15 view.setSelectionJson(selectionJson)
16 }
17 Prop("themeJson") { (view: T3ComposerEditorView, themeJson: String) in
18 view.setThemeJson(themeJson)
19 }
20 Prop("placeholder") { (view: T3ComposerEditorView, placeholder: String) in
21 view.setPlaceholder(placeholder)
22 }
23 Prop("fontFamily") { (view: T3ComposerEditorView, fontFamily: String) in
24 view.setFontFamily(fontFamily)
25 }
26 Prop("fontSize") { (view: T3ComposerEditorView, fontSize: Double) in
27 view.setFontSize(CGFloat(fontSize))
28 }
29 Prop("lineHeight") { (view: T3ComposerEditorView, lineHeight: Double) in
30 view.setLineHeight(CGFloat(lineHeight))
31 }
32 Prop("contentInsetVertical") { (view: T3ComposerEditorView, contentInsetVertical: Double) in
33 view.setContentInsetVertical(CGFloat(contentInsetVertical))
34 }
35 Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in
36 view.setEditable(editable)
37 }
38 Prop("scrollEnabled") { (view: T3ComposerEditorView, scrollEnabled: Bool) in
39 view.setScrollEnabled(scrollEnabled)
40 }
41 Prop("autoFocus") { (view: T3ComposerEditorView, autoFocus: Bool) in
42 view.setAutoFocus(autoFocus)
43 }
44 Prop("autoCorrect") { (view: T3ComposerEditorView, autoCorrect: Bool) in
45 view.setAutoCorrect(autoCorrect)
46 }
47 Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in
48 view.setSpellCheck(spellCheck)
49 }
50
51 Events(
52 "onComposerChange",
53 "onComposerSelectionChange",
54 "onComposerFocus",
55 "onComposerBlur",
56 "onComposerPasteImages",
57 "onComposerContentSizeChange"
58 )
59
60 AsyncFunction("focus") { (view: T3ComposerEditorView) in
61 view.focusEditor()
62 }
63 AsyncFunction("blur") { (view: T3ComposerEditorView) in
64 view.blurEditor()
65 }
66 AsyncFunction("setSelection") { (view: T3ComposerEditorView, start: Int, end: Int) in
67 view.setSelection(start: start, end: end)
68 }
69 }
70 }
71}
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift created+819
...@@ -0,0 +1,819 @@
1import ExpoModulesCore
2import UIKit
3
4private struct ComposerTokenPayload: Decodable {
5 let type: String
6 let source: String
7 let label: String
8 let iconUri: String?
9 let start: Int
10 let end: Int
11}
12
13private struct ComposerSelectionPayload: Decodable {
14 let start: Int
15 let end: Int
16}
17
18private struct ComposerThemePayload: Decodable {
19 let text: String
20 let placeholder: String
21 let chipBackground: String
22 let chipBorder: String
23 let chipText: String
24 let skillBackground: String
25 let skillBorder: String
26 let skillText: String
27 let fileTint: String
28}
29
30private struct ComposerChipStyle {
31 let tint: UIColor
32 let backgroundColor: UIColor
33 let borderColor: UIColor
34 let textColor: UIColor
35}
36
37private final class ComposerTextAttachment: NSTextAttachment {
38 let source: String
39
40 init(source: String, image: UIImage, size: CGSize, baselineOffset: CGFloat) {
41 self.source = source
42 super.init(data: nil, ofType: nil)
43 self.image = image
44 bounds = CGRect(x: 0, y: baselineOffset, width: size.width, height: size.height)
45 }
46
47 required init?(coder: NSCoder) {
48 nil
49 }
50}
51
52private final class ComposerTextView: UITextView {
53 private static let pastedImageDirectoryName = "t3-composer-paste"
54 private static let stalePastedImageAge: TimeInterval = 60 * 60
55
56 var onPasteImages: (([String]) -> Void)?
57 var onAttributedMutation: (() -> Void)?
58
59 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
60 if action == #selector(paste(_:)) {
61 let pasteboard = UIPasteboard.general
62 if pasteboard.hasImages ||
63 pasteboard.itemProviders.contains(where: {
64 $0.canLoadObject(ofClass: UIImage.self)
65 }) {
66 return true
67 }
68 }
69 return super.canPerformAction(action, withSender: sender)
70 }
71
72 override func paste(_ sender: Any?) {
73 let pasteboard = UIPasteboard.general
74 let imageProviders = pasteboard.itemProviders.filter {
75 $0.canLoadObject(ofClass: UIImage.self)
76 }
77 if !imageProviders.isEmpty {
78 loadImages(from: imageProviders)
79 return
80 }
81
82 let images = pasteboard.images ?? []
83 if !images.isEmpty {
84 let urls = images.compactMap(Self.writeTemporaryImage)
85 if !urls.isEmpty {
86 onPasteImages?(urls)
87 return
88 }
89 }
90 super.paste(sender)
91 }
92
93 override func deleteBackward() {
94 guard selectedRange.length == 0, selectedRange.location > 0 else {
95 super.deleteBackward()
96 return
97 }
98
99 let previousOffset = selectedRange.location - 1
100 if textStorage.attribute(.attachment, at: previousOffset, effectiveRange: nil)
101 is ComposerTextAttachment {
102 replaceDisplayRange(NSRange(location: previousOffset, length: 1))
103 return
104 }
105
106 super.deleteBackward()
107 }
108
109 private func replaceDisplayRange(_ range: NSRange) {
110 guard let start = position(from: beginningOfDocument, offset: range.location),
111 let end = position(from: start, offset: range.length),
112 let textRange = textRange(from: start, to: end) else {
113 return
114 }
115 replace(textRange, withText: "")
116 }
117
118 private func loadImages(from providers: [NSItemProvider]) {
119 let group = DispatchGroup()
120 let lock = NSLock()
121 var images = [UIImage?](repeating: nil, count: providers.count)
122
123 for (index, provider) in providers.enumerated() {
124 group.enter()
125 provider.loadObject(ofClass: UIImage.self) { object, _ in
126 defer { group.leave() }
127 guard let image = object as? UIImage else {
128 return
129 }
130 lock.lock()
131 images[index] = image
132 lock.unlock()
133 }
134 }
135
136 group.notify(queue: .main) { [weak self] in
137 let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage)
138 if !urls.isEmpty {
139 self?.onPasteImages?(urls)
140 }
141 }
142 }
143
144 override func copy(_ sender: Any?) {
145 guard selectedRange.length > 0 else {
146 return super.copy(sender)
147 }
148 UIPasteboard.general.string = serializedText(in: selectedRange)
149 }
150
151 override func cut(_ sender: Any?) {
152 guard isEditable, selectedRange.length > 0 else {
153 return super.cut(sender)
154 }
155 copy(sender)
156 textStorage.replaceCharacters(in: selectedRange, with: "")
157 selectedRange = NSRange(location: selectedRange.location, length: 0)
158 onAttributedMutation?()
159 }
160
161 func serializedText() -> String {
162 serializedText(in: NSRange(location: 0, length: attributedText.length))
163 }
164
165 func serializedText(in range: NSRange) -> String {
166 guard range.length > 0 else {
167 return ""
168 }
169
170 let source = NSMutableString()
171 let nsString = attributedText.string as NSString
172 var cursor = range.location
173 let end = NSMaxRange(range)
174 attributedText.enumerateAttribute(.attachment, in: range) { value, attachmentRange, _ in
175 if attachmentRange.location > cursor {
176 source.append(
177 nsString.substring(
178 with: NSRange(location: cursor, length: attachmentRange.location - cursor)
179 )
180 )
181 }
182 if let attachment = value as? ComposerTextAttachment {
183 source.append(attachment.source)
184 } else {
185 source.append(nsString.substring(with: attachmentRange))
186 }
187 cursor = NSMaxRange(attachmentRange)
188 }
189 if cursor < end {
190 source.append(nsString.substring(with: NSRange(location: cursor, length: end - cursor)))
191 }
192 return source as String
193 }
194
195 func sourceOffset(forDisplayOffset displayOffset: Int) -> Int {
196 let boundedOffset = max(0, min(attributedText.length, displayOffset))
197 if boundedOffset == 0 {
198 return 0
199 }
200
201 var sourceOffset = 0
202 let range = NSRange(location: 0, length: boundedOffset)
203 attributedText.enumerateAttribute(.attachment, in: range) { value, attributeRange, _ in
204 if let attachment = value as? ComposerTextAttachment {
205 sourceOffset += (attachment.source as NSString).length
206 } else {
207 sourceOffset += attributeRange.length
208 }
209 }
210 return sourceOffset
211 }
212
213 private static func writeTemporaryImage(_ image: UIImage) -> String? {
214 guard let data = image.pngData() else {
215 return nil
216 }
217 let directory = FileManager.default.temporaryDirectory
218 .appendingPathComponent(pastedImageDirectoryName, isDirectory: true)
219 do {
220 try FileManager.default.createDirectory(
221 at: directory,
222 withIntermediateDirectories: true
223 )
224 removeStaleTemporaryImages(in: directory)
225 let url = directory.appendingPathComponent("\(UUID().uuidString).png")
226 try data.write(to: url, options: .atomic)
227 return url.absoluteString
228 } catch {
229 return nil
230 }
231 }
232
233 private static func removeStaleTemporaryImages(in directory: URL) {
234 let cutoff = Date().addingTimeInterval(-stalePastedImageAge)
235 guard let urls = try? FileManager.default.contentsOfDirectory(
236 at: directory,
237 includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey],
238 options: [.skipsHiddenFiles]
239 ) else {
240 return
241 }
242
243 for url in urls {
244 guard
245 let values = try? url.resourceValues(
246 forKeys: [.contentModificationDateKey, .isRegularFileKey]
247 ),
248 values.isRegularFile == true,
249 let modifiedAt = values.contentModificationDate,
250 modifiedAt < cutoff
251 else {
252 continue
253 }
254 try? FileManager.default.removeItem(at: url)
255 }
256 }
257}
258
259public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {
260 private let textView = ComposerTextView()
261 private let placeholderLabel = UILabel()
262 private var value = ""
263 private var tokensJson = "[]"
264 private var tokens: [ComposerTokenPayload] = []
265 private var requestedSelection: ComposerSelectionPayload?
266 private var theme = ComposerThemePayload(
267 text: "#262626",
268 placeholder: "#8e8e93",
269 chipBackground: "#f2f2f7",
270 chipBorder: "#dedee3",
271 chipText: "#262626",
272 skillBackground: "#f9e8fb",
273 skillBorder: "#e5a6eb",
274 skillText: "#a21caf",
275 fileTint: "#737373"
276 )
277 private var fontFamily = "DMSans_400Regular"
278 private var fontSize: CGFloat = 15
279 private var lineHeight: CGFloat = 22
280 private var contentInsetVertical: CGFloat = 0
281 private var shouldAutoFocus = false
282 private var didAutoFocus = false
283 private var isApplyingControlledValue = false
284 private var lastContentSize = CGSize.zero
285 private var iconImages: [String: UIImage] = [:]
286 private var pendingIconUris = Set<String>()
287 private var tokensNeedRebuild = false
288
289 let onComposerChange = EventDispatcher()
290 let onComposerSelectionChange = EventDispatcher()
291 let onComposerFocus = EventDispatcher()
292 let onComposerBlur = EventDispatcher()
293 let onComposerPasteImages = EventDispatcher()
294 let onComposerContentSizeChange = EventDispatcher()
295
296 public required init(appContext: AppContext? = nil) {
297 super.init(appContext: appContext)
298
299 clipsToBounds = false
300 textView.delegate = self
301 textView.backgroundColor = .clear
302 textView.textContainerInset = .zero
303 textView.textContainer.lineFragmentPadding = 0
304 textView.keyboardDismissMode = .interactive
305 textView.alwaysBounceVertical = false
306 textView.showsVerticalScrollIndicator = true
307 textView.adjustsFontForContentSizeCategory = true
308 textView.onPasteImages = { [weak self] urls in
309 self?.onComposerPasteImages(["uris": urls])
310 }
311 textView.onAttributedMutation = { [weak self] in
312 self?.emitTextChange()
313 }
314 addSubview(textView)
315
316 placeholderLabel.numberOfLines = 0
317 placeholderLabel.adjustsFontForContentSizeCategory = true
318 addSubview(placeholderLabel)
319 applyTypography()
320 applyTheme()
321 }
322
323 public override func layoutSubviews() {
324 super.layoutSubviews()
325 textView.frame = bounds
326 let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding
327 let placeholderY = textView.textContainerInset.top
328 let placeholderWidth = max(
329 0,
330 bounds.width - placeholderX - textView.textContainerInset.right -
331 textView.textContainer.lineFragmentPadding
332 )
333 placeholderLabel.frame = CGRect(
334 x: placeholderX,
335 y: placeholderY,
336 width: placeholderWidth,
337 height: max(lineHeight, placeholderLabel.font.lineHeight)
338 )
339 emitContentSizeIfNeeded()
340 }
341
342 public override func didMoveToWindow() {
343 super.didMoveToWindow()
344 guard window != nil, shouldAutoFocus, !didAutoFocus else {
345 return
346 }
347 didAutoFocus = true
348 DispatchQueue.main.async { [weak self] in
349 self?.textView.becomeFirstResponder()
350 }
351 }
352
353 func setValue(_ value: String) {
354 self.value = value
355 applyControlledDocument(force: tokensNeedRebuild)
356 if tokensMatchCurrentValue() {
357 tokensNeedRebuild = false
358 }
359 }
360
361 func setTokensJson(_ tokensJson: String) {
362 guard self.tokensJson != tokensJson else {
363 return
364 }
365 self.tokensJson = tokensJson
366 tokens = decode([ComposerTokenPayload].self, from: tokensJson) ?? []
367 tokensNeedRebuild = true
368 applyControlledDocument(force: true)
369 if tokensMatchCurrentValue() {
370 tokensNeedRebuild = false
371 }
372 }
373
374 func setSelectionJson(_ selectionJson: String) {
375 requestedSelection = decode(ComposerSelectionPayload.self, from: selectionJson)
376 applyRequestedSelection()
377 }
378
379 func setThemeJson(_ themeJson: String) {
380 guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else {
381 return
382 }
383 theme = nextTheme
384 applyTheme()
385 applyControlledDocument(force: true)
386 }
387
388 func setPlaceholder(_ placeholder: String) {
389 placeholderLabel.text = placeholder
390 setNeedsLayout()
391 }
392
393 func setFontFamily(_ fontFamily: String) {
394 self.fontFamily = fontFamily
395 applyTypography()
396 applyControlledDocument(force: true)
397 }
398
399 func setFontSize(_ fontSize: CGFloat) {
400 self.fontSize = fontSize
401 applyTypography()
402 applyControlledDocument(force: true)
403 }
404
405 func setLineHeight(_ lineHeight: CGFloat) {
406 self.lineHeight = lineHeight
407 applyTypography()
408 applyControlledDocument(force: true)
409 }
410
411 func setContentInsetVertical(_ contentInsetVertical: CGFloat) {
412 self.contentInsetVertical = contentInsetVertical
413 textView.textContainerInset = UIEdgeInsets(
414 top: contentInsetVertical,
415 left: 0,
416 bottom: contentInsetVertical,
417 right: 0
418 )
419 setNeedsLayout()
420 }
421
422 func setEditable(_ editable: Bool) {
423 textView.isEditable = editable
424 }
425
426 func setScrollEnabled(_ scrollEnabled: Bool) {
427 textView.isScrollEnabled = scrollEnabled
428 }
429
430 func setAutoFocus(_ autoFocus: Bool) {
431 shouldAutoFocus = autoFocus
432 }
433
434 func setAutoCorrect(_ autoCorrect: Bool) {
435 textView.autocorrectionType = autoCorrect ? .yes : .no
436 }
437
438 func setSpellCheck(_ spellCheck: Bool) {
439 textView.spellCheckingType = spellCheck ? .yes : .no
440 }
441
442 func focusEditor() {
443 textView.becomeFirstResponder()
444 }
445
446 func blurEditor() {
447 textView.resignFirstResponder()
448 }
449
450 func setSelection(start: Int, end: Int) {
451 requestedSelection = ComposerSelectionPayload(start: start, end: end)
452 applyRequestedSelection()
453 }
454
455 public func textViewDidChange(_ textView: UITextView) {
456 emitTextChange()
457 }
458
459 public func textViewDidChangeSelection(_ textView: UITextView) {
460 guard !isApplyingControlledValue else {
461 return
462 }
463 emitSelection()
464 }
465
466 public func textViewDidBeginEditing(_ textView: UITextView) {
467 onComposerFocus()
468 }
469
470 public func textViewDidEndEditing(_ textView: UITextView) {
471 onComposerBlur()
472 }
473
474 private func applyControlledDocument(force: Bool = false) {
475 let currentSource = textView.serializedText()
476 guard force || currentSource != value || !documentMatchesExpectedTokens() else {
477 updatePlaceholderVisibility()
478 return
479 }
480
481 let previousSelection = sourceSelection()
482 isApplyingControlledValue = true
483 textView.attributedText = makeAttributedDocument()
484 let targetSelection = requestedSelection ?? previousSelection
485 requestedSelection = nil
486 textView.selectedRange = displayRange(for: targetSelection)
487 isApplyingControlledValue = false
488 updatePlaceholderVisibility()
489 emitContentSizeIfNeeded()
490 }
491
492 private func makeAttributedDocument() -> NSAttributedString {
493 let result = NSMutableAttributedString()
494 let source = value as NSString
495 var cursor = 0
496 let validTokens = tokens.filter {
497 $0.start >= cursor &&
498 $0.end > $0.start &&
499 $0.end <= source.length &&
500 source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source
501 }
502
503 for token in validTokens {
504 if token.start < cursor {
505 continue
506 }
507 if token.start > cursor {
508 appendPlainText(
509 source.substring(with: NSRange(location: cursor, length: token.start - cursor)),
510 to: result
511 )
512 }
513 result.append(makeAttachmentString(token))
514 cursor = token.end
515 }
516 if cursor < source.length {
517 appendPlainText(
518 source.substring(with: NSRange(location: cursor, length: source.length - cursor)),
519 to: result
520 )
521 }
522 return result
523 }
524
525 private func appendPlainText(_ text: String, to result: NSMutableAttributedString) {
526 result.append(NSAttributedString(string: text, attributes: baseAttributes()))
527 }
528
529 private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString {
530 let isSkill = token.type == "skill"
531 let tint = UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel
532 let iconName = isSkill ? "cube" : "doc"
533 let iconImage = token.iconUri.flatMap(iconImage(for:))
534 let style = ComposerChipStyle(
535 tint: tint,
536 backgroundColor: UIColor(
537 composerHex: isSkill ? theme.skillBackground : theme.chipBackground
538 ) ?? .secondarySystemFill,
539 borderColor: UIColor(
540 composerHex: isSkill ? theme.skillBorder : theme.chipBorder
541 ) ?? .separator,
542 textColor: UIColor(composerHex: isSkill ? theme.skillText : theme.chipText) ?? .label
543 )
544 let image = renderChip(
545 label: token.label,
546 iconName: iconName,
547 iconImage: iconImage,
548 style: style
549 )
550 let font = UIFont(name: fontFamily, size: fontSize)
551 ?? UIFont.systemFont(ofSize: fontSize)
552 let baselineOffset = floor((font.capHeight - image.size.height) / 2)
553 let attachment = ComposerTextAttachment(
554 source: token.source,
555 image: image,
556 size: image.size,
557 baselineOffset: baselineOffset
558 )
559 return NSAttributedString(attachment: attachment)
560 }
561
562 private func renderChip(
563 label: String,
564 iconName: String,
565 iconImage: UIImage?,
566 style: ComposerChipStyle
567 ) -> UIImage {
568 let font = UIFont(name: "DMSans_500Medium", size: max(12, fontSize - 2))
569 ?? UIFont.systemFont(ofSize: max(12, fontSize - 2), weight: .medium)
570 let fallbackIcon = UIImage(
571 systemName: iconName,
572 withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium)
573 )
574 let icon = iconImage ?? fallbackIcon
575 let textSize = (label as NSString).size(withAttributes: [.font: font])
576 let iconWidth = icon == nil ? 0 : 14
577 let iconGap = icon == nil ? 0 : 5
578 let height: CGFloat = 24
579 let width = ceil(9 + CGFloat(iconWidth + iconGap) + textSize.width + 9)
580 let format = UIGraphicsImageRendererFormat.preferred()
581 format.opaque = false
582 let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format)
583 return renderer.image { context in
584 let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height))
585 let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 7)
586 style.backgroundColor.setFill()
587 path.fill()
588 style.borderColor.setStroke()
589 path.lineWidth = 1
590 path.stroke()
591
592 var x: CGFloat = 9
593 if let icon {
594 let renderedIcon = iconImage == nil
595 ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal)
596 : icon
597 renderedIcon.draw(
598 in: CGRect(x: x, y: 5, width: 14, height: 14)
599 )
600 x += 19
601 }
602 let paragraph = NSMutableParagraphStyle()
603 paragraph.alignment = .left
604 (label as NSString).draw(
605 in: CGRect(x: x, y: 3, width: textSize.width + 1, height: 18),
606 withAttributes: [
607 .font: font,
608 .foregroundColor: style.textColor,
609 .paragraphStyle: paragraph,
610 ]
611 )
612 context.cgContext.setAllowsAntialiasing(true)
613 }
614 }
615
616 private func iconImage(for uri: String) -> UIImage? {
617 if let image = iconImages[uri] {
618 return image
619 }
620 guard !pendingIconUris.contains(uri), let url = URL(string: uri) else {
621 return nil
622 }
623
624 if url.isFileURL, let image = UIImage(contentsOfFile: url.path) {
625 iconImages[uri] = image
626 return image
627 }
628
629 pendingIconUris.insert(uri)
630 URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
631 guard let self, let data, let image = UIImage(data: data) else {
632 DispatchQueue.main.async {
633 self?.pendingIconUris.remove(uri)
634 }
635 return
636 }
637 DispatchQueue.main.async {
638 self.pendingIconUris.remove(uri)
639 self.iconImages[uri] = image
640 self.applyControlledDocument(force: true)
641 }
642 }.resume()
643 return nil
644 }
645
646 private func baseAttributes() -> [NSAttributedString.Key: Any] {
647 let font = UIFont(name: fontFamily, size: fontSize)
648 ?? UIFont.systemFont(ofSize: fontSize)
649 let paragraph = NSMutableParagraphStyle()
650 paragraph.minimumLineHeight = lineHeight
651 paragraph.maximumLineHeight = lineHeight
652 return [
653 .font: font,
654 .foregroundColor: UIColor(composerHex: theme.text) ?? .label,
655 .paragraphStyle: paragraph,
656 ]
657 }
658
659 private func applyTypography() {
660 let font = UIFont(name: fontFamily, size: fontSize)
661 ?? UIFont.systemFont(ofSize: fontSize)
662 textView.font = font
663 textView.typingAttributes = baseAttributes()
664 placeholderLabel.font = font
665 setNeedsLayout()
666 }
667
668 private func applyTheme() {
669 textView.textColor = UIColor(composerHex: theme.text) ?? .label
670 placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText
671 tintColor = UIColor.systemBlue
672 }
673
674 private func emitTextChange() {
675 guard !isApplyingControlledValue else {
676 return
677 }
678 value = textView.serializedText()
679 let selection = sourceSelection()
680 onComposerChange([
681 "value": value,
682 "selection": ["start": selection.start, "end": selection.end],
683 ])
684 updatePlaceholderVisibility()
685 emitContentSizeIfNeeded()
686 }
687
688 private func emitSelection() {
689 let selection = sourceSelection()
690 onComposerSelectionChange([
691 "selection": ["start": selection.start, "end": selection.end],
692 ])
693 }
694
695 private func sourceSelection() -> ComposerSelectionPayload {
696 ComposerSelectionPayload(
697 start: textView.sourceOffset(forDisplayOffset: textView.selectedRange.location),
698 end: textView.sourceOffset(forDisplayOffset: NSMaxRange(textView.selectedRange))
699 )
700 }
701
702 private func displayRange(for selection: ComposerSelectionPayload) -> NSRange {
703 let start = displayOffset(forSourceOffset: selection.start)
704 let end = displayOffset(forSourceOffset: selection.end)
705 return NSRange(location: start, length: max(0, end - start))
706 }
707
708 private func displayOffset(forSourceOffset sourceOffset: Int) -> Int {
709 let boundedOffset = max(0, min((value as NSString).length, sourceOffset))
710 var collapsedLength = 0
711 for token in tokens where token.end <= boundedOffset {
712 collapsedLength += max(0, token.end - token.start - 1)
713 }
714 if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) {
715 return token.start - collapsedLength + 1
716 }
717 return boundedOffset - collapsedLength
718 }
719
720 private func applyRequestedSelection() {
721 guard let requestedSelection else {
722 return
723 }
724 let nextRange = displayRange(for: requestedSelection)
725 guard nextRange.location <= textView.attributedText.length,
726 NSMaxRange(nextRange) <= textView.attributedText.length else {
727 return
728 }
729 isApplyingControlledValue = true
730 textView.selectedRange = nextRange
731 isApplyingControlledValue = false
732 }
733
734 private func updatePlaceholderVisibility() {
735 placeholderLabel.isHidden = !value.isEmpty
736 }
737
738 private func emitContentSizeIfNeeded() {
739 let nextSize = textView.contentSize
740 guard abs(nextSize.width - lastContentSize.width) > 0.5 ||
741 abs(nextSize.height - lastContentSize.height) > 0.5 else {
742 return
743 }
744 lastContentSize = nextSize
745 onComposerContentSizeChange(["width": nextSize.width, "height": nextSize.height])
746 }
747
748 private func decode<T: Decodable>(_ type: T.Type, from json: String) -> T? {
749 guard let data = json.data(using: .utf8) else {
750 return nil
751 }
752 return try? JSONDecoder().decode(type, from: data)
753 }
754
755 private func tokensMatchCurrentValue() -> Bool {
756 let source = value as NSString
757 return tokens.allSatisfy {
758 $0.start >= 0 &&
759 $0.end > $0.start &&
760 $0.end <= source.length &&
761 source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source
762 }
763 }
764
765 private func documentMatchesExpectedTokens() -> Bool {
766 let source = value as NSString
767 let expectedSources = tokens.compactMap { token -> String? in
768 guard token.start >= 0,
769 token.end > token.start,
770 token.end <= source.length,
771 source.substring(
772 with: NSRange(location: token.start, length: token.end - token.start)
773 ) == token.source else {
774 return nil
775 }
776 return token.source
777 }
778 var renderedSources: [String] = []
779 textView.attributedText.enumerateAttribute(
780 .attachment,
781 in: NSRange(location: 0, length: textView.attributedText.length)
782 ) { value, _, _ in
783 if let attachment = value as? ComposerTextAttachment {
784 renderedSources.append(attachment.source)
785 }
786 }
787 return renderedSources == expectedSources
788 }
789}
790
791private extension UIColor {
792 convenience init?(composerHex hex: String?) {
793 guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
794 return nil
795 }
796 if value.hasPrefix("#") {
797 value.removeFirst()
798 }
799 guard value.count == 6 || value.count == 8,
800 let raw = UInt64(value, radix: 16) else {
801 return nil
802 }
803 if value.count == 8 {
804 self.init(
805 red: CGFloat((raw >> 24) & 0xff) / 255,
806 green: CGFloat((raw >> 16) & 0xff) / 255,
807 blue: CGFloat((raw >> 8) & 0xff) / 255,
808 alpha: CGFloat(raw & 0xff) / 255
809 )
810 } else {
811 self.init(
812 red: CGFloat((raw >> 16) & 0xff) / 255,
813 green: CGFloat((raw >> 8) & 0xff) / 255,
814 blue: CGFloat(raw & 0xff) / 255,
815 alpha: 1
816 )
817 }
818 }
819}
apps/mobile/modules/t3-markdown-text/LICENSE created+20
...@@ -0,0 +1,20 @@
1MIT License
2
3Copyright (c) 2024-25 Bluesky PBC
4Permission is hereby granted, free of charge, to any person obtaining a copy
5of this software and associated documentation files (the "Software"), to deal
6in the Software without restriction, including without limitation the rights
7to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8copies of the Software, and to permit persons to whom the Software is
9furnished to do so, subject to the following conditions:
10
11The above copyright notice and this permission notice shall be included in all
12copies or substantial portions of the Software.
13
14THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20SOFTWARE.
apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec created+25
...@@ -0,0 +1,25 @@
1require "json"
2
3package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1"
5
6Pod::Spec.new do |s|
7 s.name = "T3MarkdownText"
8 s.version = package["version"]
9 s.summary = "Native selectable markdown renderer for T3 Code mobile."
10 s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code."
11 s.homepage = "https://t3tools.com"
12 s.license = { :type => "MIT", :file => "LICENSE" }
13 s.author = { "T3 Tools" => "hello@t3tools.com" }
14 s.platforms = { :ios => min_ios_version_supported }
15 s.source = { :path => "." }
16 s.source_files = "ios/**/*.{h,m,mm,cpp}"
17
18 install_modules_dependencies(s)
19
20 if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled
21 add_dependency(s, "React-FabricComponents", :additional_framework_paths => [
22 "react/renderer/textlayoutmanager/platform/ios",
23 ])
24 end
25end
apps/mobile/modules/t3-markdown-text/UPSTREAM.md created+12
...@@ -0,0 +1,12 @@
1# Upstream Attribution
2
3The Fabric attributed-text component in this module originated from
4[`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview),
5version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`.
6
7The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under
8the MIT License included in this directory.
9
10T3 Code has substantially modified and renamed the implementation, integrated
11its markdown renderer, and owns the resulting module going forward. This is not
12an upstream package dependency or a compatibility fork.
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png created
Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png differ
apps/mobile/modules/t3-markdown-text/index.ts created+27
...@@ -0,0 +1,27 @@
1export { markdownFileIconSource } from "./src/markdownFileIcons";
2export {
3 resolveMarkdownFileIcon,
4 resolveMarkdownLinkPresentation,
5 type MarkdownFileIcon,
6 type MarkdownLinkPresentation,
7} from "./src/markdownLinks";
8export {
9 nativeMarkdownChunkSpacing,
10 nativeMarkdownDocumentChunks,
11 nativeMarkdownDocumentRuns,
12 nativeMarkdownListItemBlocks,
13 nativeMarkdownTextRuns,
14 type NativeMarkdownDocumentChunk,
15 type NativeMarkdownTextRun,
16} from "./src/nativeMarkdownText";
17export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive";
18export {
19 SelectableMarkdownText,
20 type MarkdownCodeHighlighter,
21 type MarkdownHighlightedToken,
22} from "./src/SelectableMarkdownText";
23export type {
24 NativeMarkdownTextStyle,
25 SelectableMarkdownSkill,
26 SelectableMarkdownTextProps,
27} from "./src/SelectableMarkdownText.types";
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h created+13
...@@ -0,0 +1,13 @@
1#import <React/RCTViewComponentView.h>
2#import <UIKit/UIKit.h>
3
4#ifndef T3MarkdownTextNativeComponent_h
5#define T3MarkdownTextNativeComponent_h
6
7NS_ASSUME_NONNULL_BEGIN
8@interface T3MarkdownText : RCTViewComponentView
9@end
10
11NS_ASSUME_NONNULL_END
12
13#endif /* UitextviewViewNativeComponent_h */
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm created+688
...@@ -0,0 +1,688 @@
1#import "T3MarkdownText.h"
2#import "T3MarkdownTextShadowNode.h"
3#import "T3MarkdownTextComponentDescriptor.h"
4#import "T3MarkdownTextRun.h"
5#import <React/RCTConversions.h>
6#import <objc/runtime.h>
7
8#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
9#import <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h>
10#import <react/renderer/components/T3MarkdownTextSpec/Props.h>
11#import <react/renderer/components/T3MarkdownTextSpec/RCTComponentViewHelpers.h>
12#import "RCTFabricComponentsPlugins.h"
13
14using namespace facebook::react;
15
16static void T3MarkdownTextApplyParagraphStyles(
17 NSMutableAttributedString *attributedString,
18 const std::vector<T3MarkdownTextParagraphStyleRange> &styleRanges)
19{
20 for (const auto &styleRange : styleRanges) {
21 if (styleRange.length == 0 || styleRange.location >= attributedString.length) {
22 continue;
23 }
24
25 const NSRange markerRange = NSMakeRange(
26 styleRange.location,
27 MIN(styleRange.length, attributedString.length - styleRange.location));
28 const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange];
29 const NSParagraphStyle *existingStyle =
30 [attributedString attribute:NSParagraphStyleAttributeName
31 atIndex:paragraphRange.location
32 effectiveRange:nil];
33 NSMutableParagraphStyle *paragraphStyle =
34 existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new];
35 paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent;
36 paragraphStyle.headIndent = styleRange.headIndent;
37 paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing;
38 paragraphStyle.tabStops = @[
39 [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft
40 location:styleRange.headIndent
41 options:@{}]
42 ];
43 paragraphStyle.defaultTabInterval = styleRange.headIndent;
44 [attributedString addAttribute:NSParagraphStyleAttributeName
45 value:paragraphStyle
46 range:paragraphRange];
47 }
48}
49
50static void T3MarkdownTextApplyAttachments(
51 NSMutableAttributedString *attributedString,
52 const std::vector<T3MarkdownTextAttachmentRange> &attachmentRanges,
53 NSDictionary<NSString *, UIImage *> *images)
54{
55 for (const auto &attachmentRange : attachmentRanges) {
56 if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) {
57 continue;
58 }
59
60 NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()];
61 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
62 UIImage *image = images[imageUri];
63 if ([imageUri hasPrefix:@"sf:"]) {
64 NSString *symbolName = [imageUri substringFromIndex:3];
65 UIColor *foregroundColor =
66 [attributedString attribute:NSForegroundColorAttributeName
67 atIndex:attachmentRange.location
68 effectiveRange:nil] ?: UIColor.labelColor;
69 image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor
70 renderingMode:UIImageRenderingModeAlwaysOriginal];
71 }
72 attachment.image = image ?: [[UIImage alloc] init];
73 attachment.bounds = CGRectMake(0, -0.5, 10, 10);
74 const NSRange range = NSMakeRange(
75 attachmentRange.location,
76 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
77 NSAttributedString *attachmentString =
78 [NSAttributedString attributedStringWithAttachment:attachment];
79 [attributedString replaceCharactersInRange:range withAttributedString:attachmentString];
80 }
81}
82
83static NSArray<NSDictionary<NSString *, id> *> *T3MarkdownTextExtractChipBackgrounds(
84 NSMutableAttributedString *attributedString,
85 const std::vector<T3MarkdownTextChipRange> &chipRanges)
86{
87 NSMutableArray<NSDictionary<NSString *, id> *> *backgrounds = [NSMutableArray array];
88 for (const auto &chipRange : chipRanges) {
89 if (chipRange.length == 0 || chipRange.location >= attributedString.length) {
90 continue;
91 }
92
93 const NSRange range = NSMakeRange(
94 chipRange.location,
95 MIN(chipRange.length, attributedString.length - chipRange.location));
96 UIColor *color = [attributedString attribute:NSBackgroundColorAttributeName
97 atIndex:range.location
98 effectiveRange:nil];
99 UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName
100 atIndex:range.location
101 effectiveRange:nil];
102 if (color == nil) {
103 continue;
104 }
105 [backgrounds addObject:@{
106 @"range": [NSValue valueWithRange:range],
107 @"color": color,
108 @"strokeColor": [foregroundColor
109 colorWithAlphaComponent:chipRange.isSkill ? 0.25 : 0.1] ?: UIColor.clearColor,
110 }];
111 [attributedString removeAttribute:NSBackgroundColorAttributeName range:range];
112 }
113 return backgrounds;
114}
115
116@interface T3MarkdownTextBackingView : UITextView
117@property(nonatomic, copy) NSArray<NSDictionary<NSString *, id> *> *chipBackgrounds;
118@end
119
120@implementation T3MarkdownTextBackingView
121
122- (void)drawRect:(CGRect)rect
123{
124 [self.layoutManager ensureLayoutForTextContainer:self.textContainer];
125 CGContextRef context = UIGraphicsGetCurrentContext();
126 if (context != nil) {
127 CGContextSaveGState(context);
128 CGContextResetClip(context);
129 CGContextClipToRect(context, self.bounds);
130 }
131 for (NSDictionary<NSString *, id> *background in self.chipBackgrounds) {
132 const NSRange characterRange = [background[@"range"] rangeValue];
133 UIColor *color = background[@"color"];
134 UIColor *strokeColor = background[@"strokeColor"];
135 if (characterRange.length == 0 || NSMaxRange(characterRange) > self.textStorage.length) {
136 continue;
137 }
138
139 const NSRange glyphRange =
140 [self.layoutManager glyphRangeForCharacterRange:characterRange actualCharacterRange:nil];
141 [color setFill];
142 [self.layoutManager
143 enumerateEnclosingRectsForGlyphRange:glyphRange
144 withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0)
145 inTextContainer:self.textContainer
146 usingBlock:^(CGRect glyphRect, BOOL *stop) {
147 const CGFloat chipHeight = 22;
148 CGRect chipRect = CGRectMake(
149 glyphRect.origin.x - 4,
150 CGRectGetMidY(glyphRect) - chipHeight / 2,
151 glyphRect.size.width + 8,
152 chipHeight);
153 chipRect.origin.x += self.textContainerInset.left;
154 chipRect.origin.y += self.textContainerInset.top;
155 const CGFloat minimumX = self.textContainerInset.left + 0.5;
156 const CGFloat maximumX =
157 CGRectGetWidth(self.bounds) - self.textContainerInset.right - 0.5;
158 if (chipRect.origin.x < minimumX) {
159 chipRect.size.width -= minimumX - chipRect.origin.x;
160 chipRect.origin.x = minimumX;
161 }
162 if (CGRectGetMaxX(chipRect) > maximumX) {
163 chipRect.size.width = MAX(0, maximumX - chipRect.origin.x);
164 }
165 UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect cornerRadius:6];
166 [path fill];
167 [strokeColor setStroke];
168 path.lineWidth = 1;
169 [path stroke];
170 }];
171 }
172 if (context != nil) {
173 CGContextRestoreGState(context);
174 }
175
176 [super drawRect:rect];
177}
178
179@end
180
181@protocol T3MarkdownOutsideTapTarget <NSObject>
182- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView;
183@end
184
185@interface T3MarkdownOutsideTapCoordinator : NSObject <UIGestureRecognizerDelegate>
186
187- (instancetype)initWithWindow:(UIWindow *)window;
188- (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target;
189- (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target;
190
191@end
192
193static const void *T3MarkdownOutsideTapCoordinatorKey =
194 &T3MarkdownOutsideTapCoordinatorKey;
195
196@implementation T3MarkdownOutsideTapCoordinator {
197 __weak UIWindow *_window;
198 UITapGestureRecognizer *_recognizer;
199 NSHashTable<id<T3MarkdownOutsideTapTarget>> *_targets;
200}
201
202- (instancetype)initWithWindow:(UIWindow *)window
203{
204 if (self = [super init]) {
205 _window = window;
206 _targets = [NSHashTable weakObjectsHashTable];
207 _recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
208 action:@selector(handleTap:)];
209 _recognizer.cancelsTouchesInView = NO;
210 _recognizer.delegate = self;
211 [window addGestureRecognizer:_recognizer];
212 }
213 return self;
214}
215
216- (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target
217{
218 [_targets addObject:target];
219}
220
221- (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target
222{
223 [_targets removeObject:target];
224 if (_targets.count > 0) {
225 return;
226 }
227
228 UIWindow *window = _window;
229 [window removeGestureRecognizer:_recognizer];
230 if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) {
231 objc_setAssociatedObject(
232 window,
233 T3MarkdownOutsideTapCoordinatorKey,
234 nil,
235 OBJC_ASSOCIATION_RETAIN_NONATOMIC);
236 }
237}
238
239- (void)handleTap:(UITapGestureRecognizer *)sender
240{
241 UIWindow *window = _window;
242 if (window == nil) {
243 return;
244 }
245
246 UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil];
247 if (hitView == nil) {
248 return;
249 }
250 for (id<T3MarkdownOutsideTapTarget> target in _targets.allObjects) {
251 [target clearSelectionForOutsideTapWithHitView:hitView];
252 }
253}
254
255- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
256 shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
257{
258 return YES;
259}
260
261@end
262
263static T3MarkdownOutsideTapCoordinator *
264T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
265{
266 T3MarkdownOutsideTapCoordinator *coordinator =
267 objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey);
268 if (coordinator == nil) {
269 coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window];
270 objc_setAssociatedObject(
271 window,
272 T3MarkdownOutsideTapCoordinatorKey,
273 coordinator,
274 OBJC_ASSOCIATION_RETAIN_NONATOMIC);
275 }
276 return coordinator;
277}
278
279@interface T3MarkdownText () <RCTT3MarkdownTextViewProtocol, UIGestureRecognizerDelegate, UITextViewDelegate>
280
281@end
282
283@interface T3MarkdownText () <T3MarkdownOutsideTapTarget>
284@end
285
286@implementation T3MarkdownText {
287 UIView * _view;
288 T3MarkdownTextBackingView * _textView;
289 T3MarkdownTextShadowNode::ConcreteState::Shared _state;
290 __weak UIWindow * _outsideTapWindow;
291 BOOL _suppressSelectionChange;
292 NSMutableDictionary<NSString *, UIImage *> * _attachmentImages;
293 NSMutableSet<NSString *> * _pendingAttachmentUris;
294}
295
296+ (ComponentDescriptorProvider)componentDescriptorProvider
297{
298 return concreteComponentDescriptorProvider<T3MarkdownTextComponentDescriptor>();
299}
300
301- (instancetype)initWithFrame:(CGRect)frame
302{
303 if (self = [super initWithFrame:frame]) {
304 static const auto defaultProps = std::make_shared<const T3MarkdownTextProps>();
305 _props = defaultProps;
306
307 _view = [[UIView alloc] init];
308 self.contentView = _view;
309 self.clipsToBounds = true;
310
311 _textView = [[T3MarkdownTextBackingView alloc] init];
312 _attachmentImages = [[NSMutableDictionary alloc] init];
313 _pendingAttachmentUris = [[NSMutableSet alloc] init];
314 _textView.scrollEnabled = false;
315 _textView.editable = false;
316 _textView.textContainerInset = UIEdgeInsetsZero;
317 _textView.textContainer.lineFragmentPadding = 0;
318 _textView.delegate = self;
319 // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO.
320 _textView.layoutManager.usesFontLeading = NO;
321 [self addSubview:_textView];
322
323 const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
324 action:@selector(handleLongPressIfNecessary:)];
325 longPressGestureRecognizer.delegate = self;
326
327 const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
328 action:@selector(handlePressIfNecessary:)];
329 pressGestureRecognizer.delegate = self;
330 [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer];
331
332 [_textView addGestureRecognizer:pressGestureRecognizer];
333 [_textView addGestureRecognizer:longPressGestureRecognizer];
334 }
335
336 return self;
337}
338
339- (void)didMoveToWindow
340{
341 [super didMoveToWindow];
342 if (_outsideTapWindow == self.window) {
343 return;
344 }
345 if (_outsideTapWindow != nil) {
346 T3MarkdownOutsideTapCoordinator *coordinator =
347 objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey);
348 [coordinator removeTarget:self];
349 }
350 _outsideTapWindow = self.window;
351 if (_outsideTapWindow != nil) {
352 [T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self];
353 }
354}
355
356- (void)dealloc
357{
358 T3MarkdownOutsideTapCoordinator *coordinator =
359 objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey);
360 [coordinator removeTarget:self];
361}
362
363// See RCTParagraphComponentView
364- (void)prepareForRecycle
365{
366 [super prepareForRecycle];
367 T3MarkdownOutsideTapCoordinator *coordinator =
368 objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey);
369 [coordinator removeTarget:self];
370 _outsideTapWindow = nil;
371 _state.reset();
372
373 // Reset the frame to zero so that when it properly lays out on the next use
374 _textView.frame = CGRectZero;
375 _textView.attributedText = nil;
376}
377
378- (void)layoutSubviews
379{
380 [super layoutSubviews];
381 // _textView's frame is assigned inside drawRect, which only fires when
382 // state changes. Trigger a redraw whenever the host frame moves out from
383 // under it (rotation, parent relayout) so the text view resizes and
384 // onTextLayout re-fires with the new line wrapping.
385 if (!CGRectEqualToRect(_textView.frame, _view.frame)) {
386 [self setNeedsDisplay];
387 }
388}
389
390- (void)drawRect:(CGRect)rect
391{
392 if (!_state) {
393 return;
394 }
395
396 const auto &props = *std::static_pointer_cast<T3MarkdownTextProps const>(_props);
397
398 const auto attrString = _state->getData().attributedString;
399 NSMutableAttributedString *convertedAttrString =
400 [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy];
401 T3MarkdownTextApplyParagraphStyles(
402 convertedAttrString,
403 _state->getData().paragraphStyleRanges);
404 T3MarkdownTextApplyAttachments(
405 convertedAttrString,
406 _state->getData().attachmentRanges,
407 _attachmentImages);
408 _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds(
409 convertedAttrString,
410 _state->getData().chipRanges);
411 [self loadAttachmentImages:_state->getData().attachmentRanges];
412
413 // Setting attributedText clears any active text selection, and re-assigning
414 // the frame triggers a layout flush that has the same effect. Bail out
415 // entirely when nothing actually changed so a JS-side state update made in
416 // response to onSelectionChange doesn't deselect what the user is selecting.
417 const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString];
418 const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame);
419 if (!textChanged && !frameChanged) {
420 return;
421 }
422 if (textChanged) {
423 // Reassigning attributedText clears any active selection. Save it and
424 // restore after, while suppressing the synthetic textViewDidChangeSelection
425 // events the clear-then-restore would otherwise produce — those would
426 // round-trip to JS and re-trigger this same path, causing a loop.
427 const NSRange savedRange = _textView.selectedRange;
428 _suppressSelectionChange = YES;
429 _textView.attributedText = convertedAttrString;
430 if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) {
431 _textView.selectedRange = savedRange;
432 }
433 _suppressSelectionChange = NO;
434 }
435 if (frameChanged) {
436 _textView.frame = _view.frame;
437 }
438
439 __block std::vector<std::string> lines;
440 const int maxLines = props.numberOfLines;
441 [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect,
442 CGRect usedRect,
443 NSTextContainer * _Nonnull textContainer,
444 NSRange glyphRange,
445 BOOL * _Nonnull stop) {
446 const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil];
447 const auto line = [self->_textView.text substringWithRange:charRange];
448 lines.push_back(line.UTF8String);
449 // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS
450 // 18, so cap explicitly.
451 if (maxLines > 0 && lines.size() >= (size_t)maxLines) {
452 *stop = YES;
453 }
454 }];
455
456 if (_eventEmitter != nullptr) {
457 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter)
458 ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines});
459 };
460}
461
462- (void)loadAttachmentImages:(const std::vector<T3MarkdownTextAttachmentRange> &)attachmentRanges
463{
464 for (const auto &attachmentRange : attachmentRanges) {
465 NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()];
466 if ([imageUri hasPrefix:@"sf:"]) {
467 continue;
468 }
469 if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) {
470 continue;
471 }
472
473 NSURL *url = [NSURL URLWithString:imageUri];
474 if (url == nil) {
475 continue;
476 }
477 if (url.isFileURL) {
478 UIImage *image = [UIImage imageWithContentsOfFile:url.path];
479 if (image != nil) {
480 _attachmentImages[imageUri] = image;
481 dispatch_async(dispatch_get_main_queue(), ^{
482 [self refreshDisplayedAttachments];
483 });
484 }
485 continue;
486 }
487
488 [_pendingAttachmentUris addObject:imageUri];
489 [[[NSURLSession sharedSession] dataTaskWithURL:url
490 completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
491 UIImage *image = data == nil ? nil : [UIImage imageWithData:data];
492 dispatch_async(dispatch_get_main_queue(), ^{
493 [self->_pendingAttachmentUris removeObject:imageUri];
494 if (image != nil) {
495 self->_attachmentImages[imageUri] = image;
496 [self refreshDisplayedAttachments];
497 }
498 });
499 }] resume];
500 }
501}
502
503- (void)refreshDisplayedAttachments
504{
505 if (!_state || _textView.attributedText == nil) {
506 return;
507 }
508
509 NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy];
510 T3MarkdownTextApplyAttachments(
511 attributedText,
512 _state->getData().attachmentRanges,
513 _attachmentImages);
514
515 const NSRange savedRange = _textView.selectedRange;
516 _suppressSelectionChange = YES;
517 _textView.attributedText = attributedText;
518 if (savedRange.location != NSNotFound &&
519 NSMaxRange(savedRange) <= _textView.attributedText.length) {
520 _textView.selectedRange = savedRange;
521 }
522 _suppressSelectionChange = NO;
523 [_textView setNeedsDisplay];
524}
525
526- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
527{
528 const auto &oldViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(_props);
529 const auto &newViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(props);
530
531 if (oldViewProps.numberOfLines != newViewProps.numberOfLines) {
532 _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines;
533 }
534
535 if (oldViewProps.selectable != newViewProps.selectable) {
536 _textView.selectable = newViewProps.selectable;
537 }
538
539 if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) {
540 if (@available(iOS 11.0, *)) {
541 _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling;
542 }
543 }
544
545 if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) {
546 if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) {
547 _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead;
548 } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) {
549 _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle;
550 } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) {
551 _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail;
552 } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) {
553 _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping;
554 }
555 }
556
557
558 // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text
559 // is in will have this background color applied. To apply it just to a particular part of a string, you'd need
560 // to do <Text><Text style={{backgroundColor: 'blue'}}>Hello</Text></Text>.
561 // This is how the base <Text> component works though, so we'll go with it for now. Can change later if we want.
562 if (oldViewProps.backgroundColor != newViewProps.backgroundColor) {
563 _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor);
564 }
565
566 [super updateProps:props oldProps:oldProps];
567}
568
569// See RCTParagraphComponentView
570- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState
571{
572 _state = std::static_pointer_cast<const T3MarkdownTextShadowNode::ConcreteState>(state);
573 [self setNeedsDisplay];
574}
575
576// MARK: - UIGestureRecognizerDelegate
577
578- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
579{
580 return YES;
581}
582
583- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
584{
585 return YES;
586}
587
588- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView
589{
590 if ([hitView isDescendantOfView:self]) {
591 return;
592 }
593 // Defer past the current event loop turn so any in-flight edit-menu action
594 // (Copy / Define / Look Up / …) reads the live selection before we clear it.
595 UITextView *textView = _textView;
596 dispatch_async(dispatch_get_main_queue(), ^{
597 UITextRange *range = textView.selectedTextRange;
598 if (range != nil && !range.isEmpty) {
599 textView.selectedTextRange = nil;
600 }
601 });
602}
603
604// MARK: - Touch handling
605
606- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender
607{
608 return [sender locationInView:_textView];
609}
610
611- (T3MarkdownTextRun*)getTouchChild:(CGPoint)location
612{
613 const auto charIndex = [_textView.layoutManager characterIndexForPoint:location
614 inTextContainer:_textView.textContainer
615 fractionOfDistanceBetweenInsertionPoints:nil
616 ];
617
618 int currIndex = -1;
619 for (UIView* child in self.subviews) {
620 if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
621 continue;
622 }
623
624 T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child;
625
626 // This is UTF16 code units!!
627 currIndex += textChild.text.length;
628
629 if (charIndex <= currIndex) {
630 return textChild;
631 }
632 }
633
634 return nil;
635}
636
637- (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender
638{
639 const auto location = [self getLocationOfPress:sender];
640 const auto child = [self getTouchChild:location];
641
642 if (child) {
643 [child onPress];
644 }
645}
646
647- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender
648{
649 const auto location = [self getLocationOfPress:sender];
650 const auto child = [self getTouchChild:location];
651
652 if (child) {
653 [child onLongPress];
654 }
655}
656
657// MARK: - UITextViewDelegate
658
659- (void)textViewDidChangeSelection:(UITextView *)textView
660{
661 if (_suppressSelectionChange) {
662 return;
663 }
664 if (_eventEmitter == nullptr) {
665 return;
666 }
667
668 const NSRange selectedRange = textView.selectedRange;
669 if (selectedRange.location == NSNotFound) {
670 return;
671 }
672
673 // Fires on programmatic selection changes too (e.g. the outside-tap clear
674 // in handleOutsideTap:), so JS will see a synthetic empty-range event then.
675 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter)
676 ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{
677 static_cast<int>(self.tag),
678 static_cast<int>(selectedRange.location),
679 static_cast<int>(selectedRange.location + selectedRange.length),
680 });
681}
682
683Class<RCTComponentViewProtocol> T3MarkdownTextCls(void)
684{
685 return T3MarkdownText.class;
686}
687
688@end
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h created+13
...@@ -0,0 +1,13 @@
1#pragma once
2
3#include "T3MarkdownTextShadowNode.h"
4
5#include <react/renderer/core/ConcreteComponentDescriptor.h>
6#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
7
8namespace facebook::react {
9using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor<T3MarkdownTextShadowNode>;
10
11void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen(
12 std::shared_ptr<const ComponentDescriptorProviderRegistry> registry);
13}
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm created+36
...@@ -0,0 +1,36 @@
1#import <React/RCTViewManager.h>
2#import <React/RCTUIManager.h>
3#import "RCTBridge.h"
4#import "Utils.h"
5
6@interface T3MarkdownTextManager : RCTViewManager
7@end
8
9@implementation T3MarkdownTextManager
10
11RCT_EXPORT_MODULE(T3MarkdownText)
12
13- (UIView *)view
14{
15 return [[UIView alloc] init];
16}
17
18RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView)
19{
20}
21
22@end
23
24@interface T3MarkdownTextRunManager : RCTViewManager
25@end
26
27@implementation T3MarkdownTextRunManager
28
29RCT_EXPORT_MODULE(T3MarkdownTextRun)
30
31- (UIView *)view
32{
33 return nil;
34}
35
36@end
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h created+24
...@@ -0,0 +1,24 @@
1// This guard prevent this file to be compiled in the old architecture.
2#ifdef RCT_NEW_ARCH_ENABLED
3#import <React/RCTViewComponentView.h>
4#import <React/RCTComponent.h>
5#import <UIKit/UIKit.h>
6
7#ifndef T3MarkdownTextRunNativeComponent_h
8#define T3MarkdownTextRunNativeComponent_h
9
10NS_ASSUME_NONNULL_BEGIN
11
12@interface T3MarkdownTextRun : RCTViewComponentView
13
14@property (nonatomic, copy, nullable) NSString *text;
15
16- (void)onPress;
17- (void)onLongPress;
18
19@end
20
21NS_ASSUME_NONNULL_END
22
23#endif /* UitextviewViewNativeComponent_h */
24#endif /* RCT_NEW_ARCH_ENABLED */
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm created+72
...@@ -0,0 +1,72 @@
1#import "T3MarkdownTextRun.h"
2#import "T3MarkdownText.h"
3#import "T3MarkdownTextRunComponentDescriptor.h"
4#import <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h>
5#import <react/renderer/components/T3MarkdownTextSpec/Props.h>
6#import <react/renderer/components/T3MarkdownTextSpec/RCTComponentViewHelpers.h>
7#import "RCTFabricComponentsPlugins.h"
8#import "Utils.h"
9
10using namespace facebook::react;
11
12@interface T3MarkdownTextRun () <RCTT3MarkdownTextRunViewProtocol>
13
14@end
15
16@implementation T3MarkdownTextRun {
17 NSString * _text;
18 RCTBubblingEventBlock _onPress;
19 RCTBubblingEventBlock _onLongPress;
20}
21
22+ (ComponentDescriptorProvider)componentDescriptorProvider
23{
24 return concreteComponentDescriptorProvider<T3MarkdownTextRunComponentDescriptor>();
25}
26
27- (instancetype)initWithFrame:(CGRect)frame
28{
29 if (self = [super initWithFrame:frame]) {
30 static const auto defaultProps = std::make_shared<const T3MarkdownTextRunProps>();
31 _props = defaultProps;
32 }
33 return self;
34}
35
36- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
37{
38 const auto &oldViewProps = *std::static_pointer_cast<T3MarkdownTextRunProps const>(_props);
39 const auto &newViewProps = *std::static_pointer_cast<T3MarkdownTextRunProps const>(props);
40
41 if (newViewProps.text != oldViewProps.text) {
42 NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()];
43 _text = text;
44 }
45
46 [super updateProps:props oldProps:oldProps];
47}
48
49- (void)onPress {
50 if (_eventEmitter != nullptr) {
51 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)
52 ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{});
53 }
54}
55
56- (void)onLongPress {
57 if (_eventEmitter != nullptr) {
58 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)
59 ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{});
60 }
61}
62
63+ (BOOL)shouldBeRecycled {
64 return NO;
65}
66
67Class<RCTComponentViewProtocol> T3MarkdownTextRunCls(void)
68{
69 return T3MarkdownTextRun.class;
70}
71
72@end
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h created+13
...@@ -0,0 +1,13 @@
1#pragma once
2
3#include "T3MarkdownTextRunShadowNode.h"
4
5#include <react/renderer/core/ConcreteComponentDescriptor.h>
6#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
7
8namespace facebook::react {
9using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor<T3MarkdownTextRunShadowNode>;
10
11void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen(
12 std::shared_ptr<const ComponentDescriptorProviderRegistry> registry);
13}
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp created+6
...@@ -0,0 +1,6 @@
1#include "T3MarkdownTextRunShadowNode.h"
2
3namespace facebook::react {
4
5extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun";
6} // namespace facebook::react
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h created+16
...@@ -0,0 +1,16 @@
1#pragma once
2
3#include <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h>
4#include <react/renderer/components/T3MarkdownTextSpec/Props.h>
5#include <react/renderer/components/T3MarkdownTextSpec/States.h>
6#include <react/renderer/components/view/ConcreteViewShadowNode.h>
7
8namespace facebook::react {
9extern const char T3MarkdownTextRunComponentName[];
10
11using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode<
12 T3MarkdownTextRunComponentName,
13 T3MarkdownTextRunProps,
14 T3MarkdownTextRunEventEmitter,
15 T3MarkdownTextRunState>;
16}
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h created+77
...@@ -0,0 +1,77 @@
1#pragma once
2
3#include <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h>
4#include <react/renderer/components/T3MarkdownTextSpec/Props.h>
5#include <react/renderer/components/view/ConcreteViewShadowNode.h>
6#include <react/renderer/textlayoutmanager/TextLayoutManager.h>
7#include <react/renderer/core/LayoutContext.h>
8#include <react/renderer/core/ShadowNode.h>
9
10#include <string>
11#include <vector>
12
13namespace facebook::react {
14
15extern const char T3MarkdownTextComponentName[];
16
17struct T3MarkdownTextParagraphStyleRange {
18 size_t location;
19 size_t length;
20 Float firstLineHeadIndent;
21 Float headIndent;
22 Float paragraphSpacing;
23};
24
25struct T3MarkdownTextAttachmentRange {
26 size_t location;
27 size_t length;
28 std::string imageUri;
29};
30
31struct T3MarkdownTextChipRange {
32 size_t location;
33 size_t length;
34 bool isSkill;
35};
36
37class T3MarkdownTextStateReal final {
38 public:
39 AttributedString attributedString;
40 std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges;
41 std::vector<T3MarkdownTextAttachmentRange> attachmentRanges;
42 std::vector<T3MarkdownTextChipRange> chipRanges;
43};
44
45class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode<
46T3MarkdownTextComponentName,
47T3MarkdownTextProps,
48T3MarkdownTextEventEmitter,
49T3MarkdownTextStateReal> {
50public:
51 using ConcreteViewShadowNode::ConcreteViewShadowNode;
52
53 T3MarkdownTextShadowNode(
54 const ShadowNode& sourceShadowNode,
55 const ShadowNodeFragment& fragment
56 );
57
58 static ShadowNodeTraits BaseTraits() {
59 auto traits = ConcreteViewShadowNode::BaseTraits();
60 traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
61 traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode);
62 return traits;
63 }
64
65 void layout(LayoutContext layoutContext) override;
66
67 Size measureContent(
68 const LayoutContext& layoutContext,
69 const LayoutConstraints& layoutConstraints) const override;
70
71private:
72 mutable AttributedString _attributedString;
73 mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges;
74 mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges;
75 mutable std::vector<T3MarkdownTextChipRange> _chipRanges;
76};
77} // namespace facebook::React
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm created+269
...@@ -0,0 +1,269 @@
1#include "T3MarkdownTextShadowNode.h"
2#include "T3MarkdownTextRunShadowNode.h"
3#include <react/renderer/components/view/ViewShadowNode.h>
4#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
5
6#include <algorithm>
7#include <cmath>
8
9namespace facebook::react {
10
11static constexpr Float ParagraphStyleEncodingOffset = 1000;
12static constexpr auto ChipNativeIdPrefix = "t3-chip-";
13static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:";
14static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:";
15
16static void applyParagraphStyles(
17 NSMutableAttributedString *attributedString,
18 const std::vector<T3MarkdownTextParagraphStyleRange> &styleRanges)
19{
20 for (const auto &styleRange : styleRanges) {
21 if (styleRange.length == 0 || styleRange.location >= attributedString.length) {
22 continue;
23 }
24
25 const NSRange markerRange = NSMakeRange(
26 styleRange.location,
27 MIN(styleRange.length, attributedString.length - styleRange.location));
28 const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange];
29 const NSParagraphStyle *existingStyle =
30 [attributedString attribute:NSParagraphStyleAttributeName
31 atIndex:paragraphRange.location
32 effectiveRange:nil];
33 NSMutableParagraphStyle *paragraphStyle =
34 existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new];
35 paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent;
36 paragraphStyle.headIndent = styleRange.headIndent;
37 paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing;
38 paragraphStyle.tabStops = @[
39 [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft
40 location:styleRange.headIndent
41 options:@{}]
42 ];
43 paragraphStyle.defaultTabInterval = styleRange.headIndent;
44 [attributedString addAttribute:NSParagraphStyleAttributeName
45 value:paragraphStyle
46 range:paragraphRange];
47 }
48}
49
50static void applyAttachments(
51 NSMutableAttributedString *attributedString,
52 const std::vector<T3MarkdownTextAttachmentRange> &attachmentRanges)
53{
54 for (const auto &attachmentRange : attachmentRanges) {
55 if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) {
56 continue;
57 }
58
59 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
60 attachment.image = [[UIImage alloc] init];
61 attachment.bounds = CGRectMake(0, -0.5, 10, 10);
62 const NSRange range = NSMakeRange(
63 attachmentRange.location,
64 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
65 NSAttributedString *attachmentString =
66 [NSAttributedString attributedStringWithAttachment:attachment];
67 [attributedString replaceCharactersInRange:range withAttributedString:attachmentString];
68 }
69}
70
71T3MarkdownTextShadowNode::T3MarkdownTextShadowNode(
72 const ShadowNode& sourceShadowNode,
73 const ShadowNodeFragment& fragment
74) : ConcreteViewShadowNode(sourceShadowNode, fragment) {
75};
76
77Size T3MarkdownTextShadowNode::measureContent(
78 const LayoutContext& layoutContext,
79 const LayoutConstraints& layoutConstraints) const {
80 const auto &baseProps = getConcreteProps();
81
82 auto baseTextAttributes = TextAttributes::defaultTextAttributes();
83 baseTextAttributes.backgroundColor = baseProps.backgroundColor;
84 baseTextAttributes.allowFontScaling = baseProps.allowFontScaling;
85
86 Float fontSizeMultiplier = 1.0;
87 if (baseTextAttributes.allowFontScaling) {
88 fontSizeMultiplier = layoutContext.fontSizeMultiplier;
89 }
90
91 auto baseAttributedString = AttributedString{};
92 auto paragraphStyleRanges = std::vector<T3MarkdownTextParagraphStyleRange>{};
93 auto attachmentRanges = std::vector<T3MarkdownTextAttachmentRange>{};
94 auto chipRanges = std::vector<T3MarkdownTextChipRange>{};
95 size_t utf16Offset = 0;
96 const auto &children = getChildren();
97 for (size_t i = 0; i < children.size(); i++) {
98 const auto child = children[i].get();
99 if (auto textViewChild = dynamic_cast<const T3MarkdownTextRunShadowNode *>(child)) {
100 auto &props = textViewChild->getConcreteProps();
101 auto fragment = AttributedString::Fragment{};
102 auto textAttributes = TextAttributes::defaultTextAttributes();
103
104 textAttributes.allowFontScaling = baseProps.allowFontScaling;
105 textAttributes.backgroundColor = props.backgroundColor;
106 textAttributes.fontSize = props.fontSize * fontSizeMultiplier;
107 textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier;
108 textAttributes.foregroundColor = props.color;
109 const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset;
110 if (!hasParagraphStyle) {
111 textAttributes.textShadowColor = props.shadowColor;
112 textAttributes.textShadowOffset = props.shadowOffset;
113 textAttributes.textShadowRadius = props.shadowRadius;
114 }
115 textAttributes.letterSpacing = props.letterSpacing;
116 textAttributes.textDecorationColor = props.textDecorationColor;
117 textAttributes.fontFamily = props.fontFamily;
118
119 if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) {
120 textAttributes.fontStyle = FontStyle::Italic;
121 } else {
122 textAttributes.fontStyle = FontStyle::Normal;
123 }
124
125 if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) {
126 textAttributes.fontWeight = FontWeight::Bold;
127 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) {
128 textAttributes.fontWeight = FontWeight::UltraLight;
129 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) {
130 textAttributes.fontWeight = FontWeight::Light;
131 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) {
132 textAttributes.fontWeight = FontWeight::Medium;
133 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) {
134 textAttributes.fontWeight = FontWeight::Semibold;
135 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) {
136 textAttributes.fontWeight = FontWeight::Heavy;
137 } else {
138 textAttributes.fontWeight = FontWeight::Regular;
139 }
140
141 if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) {
142 textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough;
143 } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) {
144 textAttributes.textDecorationLineType = TextDecorationLineType::Underline;
145 } else {
146 textAttributes.textDecorationLineType = TextDecorationLineType::None;
147 }
148
149 if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) {
150 textAttributes.textDecorationStyle = TextDecorationStyle::Solid;
151 } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) {
152 textAttributes.textDecorationStyle = TextDecorationStyle::Dotted;
153 } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) {
154 textAttributes.textDecorationStyle = TextDecorationStyle::Dashed;
155 } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) {
156 textAttributes.textDecorationStyle = TextDecorationStyle::Double;
157 }
158
159 if (props.textAlign == T3MarkdownTextRunTextAlign::Left) {
160 textAttributes.alignment = TextAlignment::Left;
161 } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) {
162 textAttributes.alignment = TextAlignment::Right;
163 } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) {
164 textAttributes.alignment = TextAlignment::Center;
165 } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) {
166 textAttributes.alignment = TextAlignment::Justified;
167 } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) {
168 textAttributes.alignment = TextAlignment::Natural;
169 }
170
171 textAttributes.backgroundColor = props.backgroundColor;
172
173 fragment.string = props.text;
174 fragment.textAttributes = textAttributes;
175
176 NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()];
177 const size_t fragmentLength = fragmentText.length;
178 if (hasParagraphStyle) {
179 paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{
180 utf16Offset,
181 fragmentLength,
182 props.shadowOffset.width,
183 props.shadowOffset.height,
184 props.shadowRadius - ParagraphStyleEncodingOffset,
185 });
186 }
187 if (props.nativeId.rfind(ChipNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
188 chipRanges.push_back(T3MarkdownTextChipRange{
189 utf16Offset,
190 fragmentLength,
191 props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0,
192 });
193 }
194 if (props.nativeId.rfind(FileChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) {
195 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
196 utf16Offset + 1,
197 1,
198 props.nativeId.substr(std::char_traits<char>::length(FileChipNativeIdPrefix)),
199 });
200 } else if (
201 props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) {
202 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
203 utf16Offset + 1,
204 1,
205 props.nativeId.substr(std::char_traits<char>::length(SkillChipNativeIdPrefix)),
206 });
207 }
208 utf16Offset += fragmentLength;
209 baseAttributedString.appendFragment(std::move(fragment));
210 }
211 }
212
213 _attributedString = baseAttributedString;
214 _paragraphStyleRanges = paragraphStyleRanges;
215 _attachmentRanges = attachmentRanges;
216 _chipRanges = chipRanges;
217
218 NSMutableAttributedString *convertedAttributedString =
219 [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy];
220 applyParagraphStyles(convertedAttributedString, paragraphStyleRanges);
221 applyAttachments(convertedAttributedString, attachmentRanges);
222
223 const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width)
224 ? layoutConstraints.maximumSize.width
225 : CGFLOAT_MAX;
226 NSTextStorage *textStorage =
227 [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString];
228 NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
229 layoutManager.usesFontLeading = NO;
230 NSTextContainer *textContainer =
231 [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)];
232 textContainer.lineFragmentPadding = 0;
233 textContainer.maximumNumberOfLines = baseProps.numberOfLines;
234 if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) {
235 textContainer.lineBreakMode = NSLineBreakByTruncatingHead;
236 } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) {
237 textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle;
238 } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) {
239 textContainer.lineBreakMode = NSLineBreakByTruncatingTail;
240 } else {
241 textContainer.lineBreakMode = NSLineBreakByClipping;
242 }
243 [layoutManager addTextContainer:textContainer];
244 [textStorage addLayoutManager:layoutManager];
245 [layoutManager ensureLayoutForTextContainer:textContainer];
246 const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer];
247
248 return {
249 std::clamp(
250 static_cast<Float>(std::ceil(usedRect.size.width)),
251 layoutConstraints.minimumSize.width,
252 layoutConstraints.maximumSize.width),
253 std::clamp(
254 static_cast<Float>(std::ceil(usedRect.size.height)),
255 layoutConstraints.minimumSize.height,
256 layoutConstraints.maximumSize.height),
257 };
258}
259
260void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) {
261 ensureUnsealed();
262 setStateData(T3MarkdownTextStateReal{
263 _attributedString,
264 _paragraphStyleRanges,
265 _attachmentRanges,
266 _chipRanges,
267 });
268}
269}
apps/mobile/modules/t3-markdown-text/package.json created+51
...@@ -0,0 +1,51 @@
1{
2 "name": "@t3tools/mobile-markdown-text",
3 "version": "0.0.0",
4 "private": true,
5 "source": "./index.ts",
6 "files": [
7 "assets",
8 "ios",
9 "src",
10 "index.ts",
11 "LICENSE",
12 "UPSTREAM.md",
13 "T3MarkdownText.podspec",
14 "react-native.config.js"
15 ],
16 "main": "./index.ts",
17 "types": "./index.ts",
18 "react-native": "./index.ts",
19 "exports": {
20 ".": "./index.ts",
21 "./file-icons": "./src/markdownFileIcons.ts",
22 "./links": "./src/markdownLinks.ts",
23 "./markdown": "./src/nativeMarkdownText.ts",
24 "./primitive": "./src/MarkdownTextPrimitive.tsx",
25 "./renderer": "./src/SelectableMarkdownText.ios.tsx",
26 "./types": "./src/SelectableMarkdownText.types.ts"
27 },
28 "peerDependencies": {
29 "expo-asset": "*",
30 "expo-clipboard": "*",
31 "expo-haptics": "*",
32 "expo-symbols": "*",
33 "react": "*",
34 "react-native": "*",
35 "react-native-nitro-markdown": "*"
36 },
37 "codegenConfig": {
38 "name": "T3MarkdownTextSpec",
39 "type": "all",
40 "jsSrcsDir": "src",
41 "ios": {
42 "componentProvider": {
43 "T3MarkdownText": "T3MarkdownText",
44 "T3MarkdownTextRun": "T3MarkdownTextRun"
45 }
46 },
47 "outputDir": {
48 "ios": "ios/generated"
49 }
50 }
51}
apps/mobile/modules/t3-markdown-text/react-native.config.js created+10
...@@ -0,0 +1,10 @@
1module.exports = {
2 dependency: {
3 platforms: {
4 ios: {
5 podspecPath: "T3MarkdownText.podspec",
6 },
7 android: null,
8 },
9 },
10};
apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs created+133
...@@ -0,0 +1,133 @@
1import { execFileSync } from "node:child_process";
2import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3import { dirname, join, resolve } from "node:path";
4import { fileURLToPath } from "node:url";
5
6import { getBuiltInSpriteSheet } from "@pierre/trees";
7
8const scriptDirectory = dirname(fileURLToPath(import.meta.url));
9const moduleDirectory = resolve(scriptDirectory, "..");
10const repositoryRoot = resolve(moduleDirectory, "../../../..");
11const outputDirectory = join(moduleDirectory, "assets/file-icons");
12const generatedModulePath = join(moduleDirectory, "src/markdownFileIcons.generated.ts");
13const webIconSource = readFileSync(join(repositoryRoot, "apps/web/src/pierre-icons.ts"), "utf8");
14const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1];
15
16if (!customSprite) {
17 throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts");
18}
19
20const colors = {
21 astro: "#a631be",
22 babel: "#d5a910",
23 bash: "#199f43",
24 biome: "#1a85d4",
25 bootstrap: "#693acf",
26 browserslist: "#d5a910",
27 bun: "#594c5b",
28 c: "#1a85d4",
29 claude: "#d47628",
30 cpp: "#1a85d4",
31 css: "#693acf",
32 database: "#a631be",
33 default: "#84848a",
34 docker: "#1a85d4",
35 eslint: "#693acf",
36 font: "#84848a",
37 git: "#ff8c5b",
38 go: "#1ca1c7",
39 graphql: "#d32a61",
40 html: "#d47628",
41 image: "#d32a61",
42 javascript: "#d5a910",
43 json: "#d47628",
44 markdown: "#199f43",
45 mcp: "#17a5af",
46 nextjs: "#84848a",
47 npm: "#d52c36",
48 oxc: "#1ca1c7",
49 postcss: "#d52c36",
50 prettier: "#17a5af",
51 python: "#1a85d4",
52 react: "#1ca1c7",
53 ruby: "#d52c36",
54 rust: "#d47628",
55 sass: "#d32a61",
56 stylelint: "#84848a",
57 svelte: "#d52c36",
58 svg: "#d47628",
59 svgo: "#199f43",
60 swift: "#d47628",
61 table: "#17a5af",
62 tailwind: "#1ca1c7",
63 terraform: "#693acf",
64 text: "#84848a",
65 typescript: "#1a85d4",
66 vite: "#a631be",
67 vscode: "#1a85d4",
68 vue: "#199f43",
69 wasm: "#693acf",
70 webpack: "#1a85d4",
71 yml: "#d52c36",
72 zig: "#d47628",
73 zip: "#d47628",
74};
75
76const customIcons = {
77 agents: "t3-file-icon-agents",
78 claude: "t3-file-icon-claude",
79 package: "t3-file-icon-package-json",
80 pnpm: "t3-file-icon-pnpm",
81 readme: "t3-file-icon-readme",
82 tsconfig: "t3-file-icon-tsconfig",
83};
84
85function symbolFromSprite(sprite, id) {
86 const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
87 const match = sprite.match(
88 new RegExp(`<symbol id="${escapedId}"([^>]*)>([\\s\\S]*?)<\\/symbol>`),
89 );
90 if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`);
91 return {
92 body: match[2],
93 viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16",
94 };
95}
96
97function renderIcon(token, symbol, color) {
98 const svgPath = join(outputDirectory, `.pierre-${token}.svg`);
99 const pngPath = join(outputDirectory, `pierre_${token}.png`);
100 writeFileSync(
101 svgPath,
102 `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="${symbol.viewBox}" style="color:${color}">${symbol.body}</svg>`,
103 );
104 execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], {
105 stdio: "ignore",
106 });
107 rmSync(svgPath);
108}
109
110rmSync(outputDirectory, { recursive: true, force: true });
111mkdirSync(outputDirectory, { recursive: true });
112
113const builtInSprite = getBuiltInSpriteSheet("complete");
114const builtInTokens = [...builtInSprite.matchAll(/<symbol id="file-tree-builtin-([^"]+)"/g)]
115 .map((match) => match[1])
116 .sort();
117
118for (const token of builtInTokens) {
119 renderIcon(
120 token,
121 symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`),
122 colors[token] ?? colors.default,
123 );
124}
125for (const [token, symbolId] of Object.entries(customIcons)) {
126 renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default);
127}
128
129const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort();
130const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens
131 .map((token) => ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`)
132 .join("\n")}\n} as const satisfies Readonly<Record<string, ImageSourcePropType>>;\n`;
133writeFileSync(generatedModulePath, generatedSource);
apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx created+73
...@@ -0,0 +1,73 @@
1import { SymbolView } from "expo-symbols";
2import * as Clipboard from "expo-clipboard";
3import * as Haptics from "expo-haptics";
4import { memo, useEffect, useRef, useState } from "react";
5import { Pressable, type ColorValue } from "react-native";
6
7const COPY_FEEDBACK_DURATION_MS = 1200;
8
9function copyTextWithHaptic(value: string): void {
10 void Clipboard.setStringAsync(value);
11 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
12}
13
14export const CopyTextButton = memo(function CopyTextButton(props: {
15 readonly accessibilityLabel: string;
16 readonly text: string;
17 readonly tintColor: ColorValue;
18 readonly copiedTintColor?: ColorValue;
19 readonly backgroundColor?: ColorValue;
20 readonly borderColor?: ColorValue;
21 readonly iconSize?: number;
22 readonly buttonSize?: number;
23}) {
24 const [copied, setCopied] = useState(false);
25 const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
26
27 useEffect(
28 () => () => {
29 if (resetTimeoutRef.current) {
30 clearTimeout(resetTimeoutRef.current);
31 }
32 },
33 [],
34 );
35
36 return (
37 <Pressable
38 accessibilityRole="button"
39 accessibilityLabel={copied ? "Copied" : props.accessibilityLabel}
40 disabled={props.text.length === 0}
41 hitSlop={8}
42 onPress={() => {
43 copyTextWithHaptic(props.text);
44 setCopied(true);
45 if (resetTimeoutRef.current) {
46 clearTimeout(resetTimeoutRef.current);
47 }
48 resetTimeoutRef.current = setTimeout(() => {
49 setCopied(false);
50 resetTimeoutRef.current = null;
51 }, COPY_FEEDBACK_DURATION_MS);
52 }}
53 style={({ pressed }) => ({
54 width: props.buttonSize ?? 30,
55 height: props.buttonSize ?? 30,
56 alignItems: "center",
57 justifyContent: "center",
58 borderRadius: 9,
59 borderWidth: props.borderColor ? 1 : 0,
60 borderColor: props.borderColor,
61 backgroundColor: props.backgroundColor,
62 opacity: pressed ? 0.52 : 1,
63 })}
64 >
65 <SymbolView
66 name={copied ? "checkmark" : "doc.on.doc"}
67 size={props.iconSize ?? 13}
68 tintColor={copied ? (props.copiedTintColor ?? props.tintColor) : props.tintColor}
69 type="monochrome"
70 />
71 </Pressable>
72 );
73});
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx created+109
...@@ -0,0 +1,109 @@
1import React from "react";
2import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native";
3import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";
4import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";
5import { flattenStyles } from "./util";
6
7const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
8 false,
9 StyleSheet.create({}),
10]);
11
12const textDefaults: TextProps = {
13 allowFontScaling: true,
14 selectable: true,
15};
16
17const useTextAncestorContext = () => React.useContext(TextAncestorContext);
18
19/**
20 * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices
21 * into the rendered string. `start === end` means the selection was cleared.
22 */
23export type SelectionChangeEvent = {
24 nativeEvent: { target: number; start: number; end: number };
25};
26
27export type MarkdownTextPrimitiveProps = TextProps & {
28 uiTextView?: boolean;
29 /**
30 * Fired when the native text selection changes. Only fires on iOS when
31 * `uiTextView` is true. Note: fires on every selection-edge adjustment
32 * (e.g. dragging a selection handle), so consumers driving expensive work
33 * off this event should debounce.
34 */
35 onSelectionChange?: (event: SelectionChangeEvent) => void;
36};
37
38function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) {
39 const [isAncestor, rootStyle] = useTextAncestorContext();
40
41 // Flatten the styles, and apply the root styles when needed
42 const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]);
43 const contextValue = React.useMemo<[boolean, ViewStyle]>(
44 () => [true, flattenedStyle],
45 [flattenedStyle],
46 );
47 let childPosition = 0;
48 const nativeChildren = React.Children.toArray(children).map((child) => {
49 const position = childPosition;
50 childPosition += 1;
51
52 if (React.isValidElement(child)) {
53 return child;
54 }
55 if (typeof child !== "string" && typeof child !== "number") {
56 return null;
57 }
58
59 const text = child.toString();
60 return (
61 // @ts-expect-error The generated run props do not include inherited Text props.
62 <T3MarkdownTextRunNativeComponent
63 key={`text-${position}-${text.length}-${text}`}
64 style={flattenedStyle}
65 text={text}
66 {...rest}
67 />
68 );
69 });
70
71 if (!isAncestor) {
72 return (
73 <TextAncestorContext.Provider value={contextValue}>
74 <T3MarkdownTextNativeComponent
75 {...textDefaults}
76 {...rest}
77 // ellipsizeMode={rest.ellipsizeMode ?? rest.lineBreakMode ?? 'tail'}
78 style={[flattenedStyle]}
79 // @ts-expect-error Weirdness
80 onPress={undefined}
81 onLongPress={undefined}
82 >
83 {nativeChildren}
84 </T3MarkdownTextNativeComponent>
85 </TextAncestorContext.Provider>
86 );
87 }
88
89 return <>{nativeChildren}</>;
90}
91
92function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) {
93 const [isAncestor] = useTextAncestorContext();
94
95 // Even if the uiTextView prop is set, we can still default to using
96 // normal selection (i.e. base RN text) if the text doesn't need to be
97 // selectable
98 if ((!props.selectable || !props.uiTextView) && !isAncestor) {
99 return <RNText {...props} />;
100 }
101 return <MarkdownTextPrimitiveChild {...props} />;
102}
103
104export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) {
105 if (Platform.OS !== "ios") {
106 return <RNText {...props} />;
107 }
108 return <MarkdownTextPrimitiveInner {...props} />;
109}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx created+647
...@@ -0,0 +1,647 @@
1import { useEffect, useState } from "react";
2import { Image, ScrollView, Text, useColorScheme, View } from "react-native";
3import type { MarkdownNode } from "react-native-nitro-markdown/headless";
4
5import { CopyTextButton } from "./CopyTextButton";
6import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
7import {
8 nativeMarkdownDocumentRuns,
9 nativeMarkdownListItemBlocks,
10 nativeMarkdownTextRuns,
11} from "./nativeMarkdownText";
12import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
13import type {
14 MarkdownCodeHighlighter,
15 MarkdownHighlightedToken,
16 NativeMarkdownTextStyle,
17} from "./SelectableMarkdownText.types";
18
19type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
20
21const highlightedCodeCache = new Map<string, HighlightedCode>();
22const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();
23const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;
24
25function nodeKey(node: MarkdownNode, index: number): string {
26 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
27}
28
29function nodeText(node: MarkdownNode): string {
30 if (node.content !== undefined) {
31 return node.content;
32 }
33 return (node.children ?? []).map(nodeText).join("");
34}
35
36function documentFor(node: MarkdownNode): MarkdownNode {
37 return node.type === "document" ? node : { type: "document", children: [node] };
38}
39
40function SelectableNode(props: {
41 readonly node: MarkdownNode;
42 readonly textStyle: NativeMarkdownTextStyle;
43}) {
44 return (
45 <NativeMarkdownSelectableText
46 runs={nativeMarkdownDocumentRuns(documentFor(props.node))}
47 textStyle={props.textStyle}
48 />
49 );
50}
51
52function codeHighlightCacheKey(
53 code: string,
54 language: string | undefined,
55 theme: "light" | "dark",
56): string {
57 return `${theme}:${language ?? "text"}:${code}`;
58}
59
60function cacheHighlightedCode(key: string, tokens: HighlightedCode): void {
61 highlightedCodeCache.delete(key);
62 highlightedCodeCache.set(key, tokens);
63
64 while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) {
65 const oldestKey = highlightedCodeCache.keys().next().value;
66 if (oldestKey === undefined) {
67 break;
68 }
69 highlightedCodeCache.delete(oldestKey);
70 }
71}
72
73function loadHighlightedCode(
74 code: string,
75 language: string | undefined,
76 theme: "light" | "dark",
77 highlightCode: MarkdownCodeHighlighter,
78): Promise<HighlightedCode> {
79 const key = codeHighlightCacheKey(code, language, theme);
80 const cached = highlightedCodeCache.get(key);
81 if (cached) {
82 return Promise.resolve(cached);
83 }
84
85 const pending = highlightedCodePromiseCache.get(key);
86 if (pending) {
87 return pending;
88 }
89
90 const promise = highlightCode({ code, language, theme })
91 .then((tokens) => {
92 cacheHighlightedCode(key, tokens);
93 highlightedCodePromiseCache.delete(key);
94 return tokens;
95 })
96 .catch((error) => {
97 highlightedCodePromiseCache.delete(key);
98 throw error;
99 });
100 highlightedCodePromiseCache.set(key, promise);
101 return promise;
102}
103
104function useHighlightedCode(
105 code: string,
106 language: string | undefined,
107 theme: "light" | "dark",
108 highlightCode: MarkdownCodeHighlighter,
109): HighlightedCode | null {
110 const key = codeHighlightCacheKey(code, language, theme);
111 const [highlighted, setHighlighted] = useState<{
112 readonly key: string;
113 readonly tokens: HighlightedCode | null;
114 }>(() => ({
115 key,
116 tokens: highlightedCodeCache.get(key) ?? null,
117 }));
118
119 useEffect(() => {
120 let active = true;
121 const cached = highlightedCodeCache.get(key);
122 if (cached) {
123 cacheHighlightedCode(key, cached);
124 setHighlighted({ key, tokens: cached });
125 return () => {
126 active = false;
127 };
128 }
129
130 void loadHighlightedCode(code, language, theme, highlightCode)
131 .then((tokens) => {
132 if (active) {
133 setHighlighted({ key, tokens });
134 }
135 })
136 .catch(() => {
137 if (active) {
138 setHighlighted({ key, tokens: null });
139 }
140 });
141 return () => {
142 active = false;
143 };
144 }, [code, highlightCode, key, language, theme]);
145
146 return highlighted.key === key ? highlighted.tokens : null;
147}
148
149function HighlightedCodeText(props: {
150 readonly content: string;
151 readonly highlighted: HighlightedCode | null;
152 readonly textStyle: NativeMarkdownTextStyle;
153}) {
154 if (!props.highlighted) {
155 return (
156 <MarkdownTextPrimitive
157 uiTextView
158 selectable
159 style={{
160 color: props.textStyle.codeColor,
161 fontFamily: "ui-monospace",
162 fontSize: 12,
163 lineHeight: 18,
164 }}
165 >
166 {props.content}
167 </MarkdownTextPrimitive>
168 );
169 }
170 const highlighted = props.highlighted;
171 let sourceOffset = 0;
172 const keyOccurrences = new Map<string, number>();
173 const keyedLines = highlighted.map((line) => {
174 const lineStart = sourceOffset;
175 const tokens = line.map((token) => {
176 const start = sourceOffset;
177 sourceOffset += token.content.length;
178 const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`;
179 const occurrence = keyOccurrences.get(signature) ?? 0;
180 keyOccurrences.set(signature, occurrence + 1);
181 return { key: `${signature}:${occurrence}`, token };
182 });
183 sourceOffset += 1;
184 return {
185 key: `line:${lineStart}:${line.map((token) => token.content).join("")}`,
186 tokens,
187 };
188 });
189
190 return (
191 <MarkdownTextPrimitive
192 uiTextView
193 selectable
194 style={{
195 color: props.textStyle.codeColor,
196 fontFamily: "ui-monospace",
197 fontSize: 12,
198 lineHeight: 18,
199 }}
200 >
201 {keyedLines.map((line, lineIndex) => (
202 <MarkdownTextPrimitive key={line.key}>
203 {line.tokens.map(({ key, token }) => (
204 <MarkdownTextPrimitive
205 key={key}
206 style={{
207 color: token.color ?? props.textStyle.codeColor,
208 fontFamily: "ui-monospace",
209 fontStyle:
210 token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal",
211 fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",
212 }}
213 >
214 {token.content}
215 </MarkdownTextPrimitive>
216 ))}
217 {lineIndex + 1 < keyedLines.length ? "\n" : ""}
218 </MarkdownTextPrimitive>
219 ))}
220 </MarkdownTextPrimitive>
221 );
222}
223
224function NativeCodeBlock(props: {
225 readonly node: MarkdownNode;
226 readonly textStyle: NativeMarkdownTextStyle;
227 readonly highlightCode: MarkdownCodeHighlighter;
228 readonly compact?: boolean;
229}) {
230 const content = nodeText(props.node).replace(/\n$/, "");
231 const colorScheme = useColorScheme();
232 const theme = colorScheme === "dark" ? "dark" : "light";
233 const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode);
234 const languageLabel = props.node.language?.toUpperCase() ?? "CODE";
235 return (
236 <View
237 style={{
238 backgroundColor: props.textStyle.codeBlockBackgroundColor,
239 borderColor: props.textStyle.dividerColor,
240 borderCurve: "continuous",
241 borderRadius: 10,
242 borderWidth: 1,
243 marginVertical: props.compact ? 7 : 0,
244 overflow: "hidden",
245 }}
246 >
247 <View
248 style={{
249 minHeight: 42,
250 borderBottomColor: props.textStyle.dividerColor,
251 borderBottomWidth: 1,
252 paddingLeft: 14,
253 paddingRight: 6,
254 flexDirection: "row",
255 alignItems: "center",
256 justifyContent: "space-between",
257 }}
258 >
259 <Text
260 selectable
261 style={{
262 flex: 1,
263 color: props.textStyle.mutedColor,
264 fontFamily: "ui-monospace",
265 fontSize: 12,
266 }}
267 >
268 {languageLabel}
269 </Text>
270 <CopyTextButton
271 accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}
272 text={content}
273 tintColor={props.textStyle.mutedColor}
274 copiedTintColor={props.textStyle.linkColor}
275 backgroundColor={props.textStyle.codeBackgroundColor}
276 borderColor={props.textStyle.dividerColor}
277 buttonSize={34}
278 iconSize={14}
279 />
280 </View>
281 <ScrollView
282 horizontal
283 bounces={false}
284 showsHorizontalScrollIndicator={false}
285 contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}
286 >
287 <HighlightedCodeText
288 content={content}
289 highlighted={highlighted}
290 textStyle={props.textStyle}
291 />
292 </ScrollView>
293 </View>
294 );
295}
296
297function collectTableRows(node: MarkdownNode): MarkdownNode[] {
298 const rows: MarkdownNode[] = [];
299 const visit = (child: MarkdownNode) => {
300 if (child.type === "table_row") {
301 rows.push(child);
302 return;
303 }
304 for (const nested of child.children ?? []) {
305 visit(nested);
306 }
307 };
308 visit(node);
309 return rows;
310}
311
312function NativeTable(props: {
313 readonly node: MarkdownNode;
314 readonly textStyle: NativeMarkdownTextStyle;
315}) {
316 const rows = collectTableRows(props.node);
317 return (
318 <ScrollView horizontal bounces={false} showsHorizontalScrollIndicator={false}>
319 <View
320 style={{
321 borderColor: props.textStyle.dividerColor,
322 borderCurve: "continuous",
323 borderRadius: 8,
324 borderWidth: 1,
325 overflow: "hidden",
326 }}
327 >
328 {rows.map((row, rowIndex) => (
329 <View
330 key={nodeKey(row, rowIndex)}
331 style={{
332 flexDirection: "row",
333 backgroundColor: rowIndex === 0 ? props.textStyle.codeBackgroundColor : "transparent",
334 borderTopColor: props.textStyle.dividerColor,
335 borderTopWidth: rowIndex === 0 ? 0 : 1,
336 }}
337 >
338 {(row.children ?? []).map((cell, cellIndex) => (
339 <View
340 key={nodeKey(cell, cellIndex)}
341 style={{
342 width: 160,
343 borderLeftColor: props.textStyle.dividerColor,
344 borderLeftWidth: cellIndex === 0 ? 0 : 1,
345 paddingHorizontal: 10,
346 paddingVertical: 8,
347 }}
348 >
349 <NativeMarkdownSelectableText
350 runs={nativeMarkdownTextRuns(cell).map((run) =>
351 rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run,
352 )}
353 textStyle={props.textStyle}
354 />
355 </View>
356 ))}
357 </View>
358 ))}
359 </View>
360 </ScrollView>
361 );
362}
363
364function NativeMarkdownImage(props: {
365 readonly node: MarkdownNode;
366 readonly textStyle: NativeMarkdownTextStyle;
367}) {
368 const href = props.node.href;
369 if (!href) {
370 return <SelectableNode node={props.node} textStyle={props.textStyle} />;
371 }
372
373 return (
374 <View style={{ gap: 6 }}>
375 <Image
376 source={{ uri: href }}
377 resizeMode="contain"
378 accessibilityLabel={props.node.alt ?? props.node.title}
379 style={{
380 width: "100%",
381 aspectRatio: 16 / 9,
382 backgroundColor: props.textStyle.codeBackgroundColor,
383 borderRadius: 10,
384 }}
385 />
386 {props.node.alt ? (
387 <Text
388 selectable
389 style={{
390 color: props.textStyle.mutedColor,
391 fontFamily: props.textStyle.fontFamily,
392 fontSize: 12,
393 lineHeight: 16,
394 }}
395 >
396 {props.node.alt}
397 </Text>
398 ) : null}
399 </View>
400 );
401}
402
403function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] {
404 const groups: MarkdownNode[] = [];
405 let inline: MarkdownNode[] = [];
406 const flush = () => {
407 if (inline.length === 0) {
408 return;
409 }
410 groups.push({ type: "paragraph", children: inline });
411 inline = [];
412 };
413
414 for (const node of nodes) {
415 if (node.type === "image") {
416 flush();
417 groups.push(node);
418 } else {
419 inline.push(node);
420 }
421 }
422 flush();
423 return groups;
424}
425
426function NativeMixedParagraph(props: {
427 readonly node: MarkdownNode;
428 readonly textStyle: NativeMarkdownTextStyle;
429}) {
430 return (
431 <View style={{ gap: 8 }}>
432 {inlineGroups(props.node.children ?? []).map((child, index) =>
433 child.type === "image" ? (
434 <NativeMarkdownImage
435 key={nodeKey(child, index)}
436 node={child}
437 textStyle={props.textStyle}
438 />
439 ) : (
440 <SelectableNode key={nodeKey(child, index)} node={child} textStyle={props.textStyle} />
441 ),
442 )}
443 </View>
444 );
445}
446
447function NativeList(props: {
448 readonly node: MarkdownNode;
449 readonly textStyle: NativeMarkdownTextStyle;
450 readonly highlightCode: MarkdownCodeHighlighter;
451 readonly depth: number;
452}) {
453 const ordered = props.node.ordered ?? false;
454 const start = props.node.start ?? 1;
455 const nested = props.depth > 0;
456 return (
457 <View
458 style={{
459 gap: nested ? 3 : 5,
460 }}
461 >
462 {(props.node.children ?? []).map((item, index) => {
463 const taskMarker = item.type === "task_list_item";
464 const marker = taskMarker
465 ? item.checked
466 ? "☑︎"
467 : "☐︎"
468 : ordered
469 ? `${start + index}.`
470 : props.depth % 3 === 1
471 ? "◦"
472 : props.depth % 3 === 2
473 ? "▪︎"
474 : "•";
475 const markerWidth = ordered ? 28 : taskMarker ? 20 : 18;
476 const markerOffset = taskMarker ? 3 : ordered ? 0 : 2;
477 return (
478 <View
479 key={nodeKey(item, index)}
480 style={{ alignItems: "flex-start", flexDirection: "row" }}
481 >
482 <View
483 style={{
484 width: markerWidth,
485 height: props.textStyle.lineHeight,
486 marginRight: 6,
487 alignItems: ordered ? "flex-end" : "center",
488 justifyContent: "flex-start",
489 }}
490 >
491 <Text
492 style={{
493 color: props.textStyle.color,
494 fontFamily: props.textStyle.fontFamily,
495 fontSize: taskMarker ? 14 : props.textStyle.fontSize,
496 lineHeight: props.textStyle.lineHeight,
497 fontVariant: ordered ? ["tabular-nums"] : undefined,
498 transform: [{ translateY: markerOffset }],
499 }}
500 >
501 {marker}
502 </Text>
503 </View>
504 <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
505 {nativeMarkdownListItemBlocks(item).map((child, childIndex) => (
506 <NativeMarkdownBlock
507 key={nodeKey(child, childIndex)}
508 node={child}
509 textStyle={props.textStyle}
510 highlightCode={props.highlightCode}
511 depth={props.depth + 1}
512 compact
513 />
514 ))}
515 </View>
516 </View>
517 );
518 })}
519 </View>
520 );
521}
522
523export function NativeMarkdownBlock(props: {
524 readonly node: MarkdownNode;
525 readonly textStyle: NativeMarkdownTextStyle;
526 readonly highlightCode: MarkdownCodeHighlighter;
527 readonly depth?: number;
528 readonly compact?: boolean;
529}) {
530 const depth = props.depth ?? 0;
531 switch (props.node.type) {
532 case "document":
533 return (
534 <View style={{ gap: 8 }}>
535 {(props.node.children ?? []).map((child, index) => (
536 <NativeMarkdownBlock
537 key={nodeKey(child, index)}
538 node={child}
539 textStyle={props.textStyle}
540 highlightCode={props.highlightCode}
541 depth={depth}
542 />
543 ))}
544 </View>
545 );
546 case "code_block":
547 return (
548 <NativeCodeBlock
549 node={props.node}
550 textStyle={props.textStyle}
551 highlightCode={props.highlightCode}
552 compact={props.compact}
553 />
554 );
555 case "table":
556 return <NativeTable node={props.node} textStyle={props.textStyle} />;
557 case "image":
558 return <NativeMarkdownImage node={props.node} textStyle={props.textStyle} />;
559 case "horizontal_rule":
560 return (
561 <View
562 style={{
563 height: 1,
564 backgroundColor: props.textStyle.dividerColor,
565 }}
566 />
567 );
568 case "blockquote":
569 return (
570 <View
571 style={{
572 borderLeftColor: props.textStyle.quoteMarkerColor,
573 borderLeftWidth: 2,
574 marginVertical: props.compact ? 4 : 0,
575 paddingLeft: 11,
576 paddingVertical: 2,
577 gap: 6,
578 }}
579 >
580 {(props.node.children ?? []).map((child, index) => (
581 <NativeMarkdownBlock
582 key={nodeKey(child, index)}
583 node={child}
584 textStyle={props.textStyle}
585 highlightCode={props.highlightCode}
586 depth={depth}
587 compact
588 />
589 ))}
590 </View>
591 );
592 case "list":
593 return (
594 <NativeList
595 node={props.node}
596 textStyle={props.textStyle}
597 highlightCode={props.highlightCode}
598 depth={depth}
599 />
600 );
601 case "paragraph":
602 return (props.node.children ?? []).some((child) => child.type === "image") ? (
603 <NativeMixedParagraph node={props.node} textStyle={props.textStyle} />
604 ) : (
605 <SelectableNode node={props.node} textStyle={props.textStyle} />
606 );
607 case "html_block":
608 case "math_block":
609 return (
610 <View
611 style={{
612 marginVertical: props.compact ? 2 : 0,
613 paddingHorizontal: props.node.type === "math_block" ? 10 : 0,
614 paddingVertical: props.node.type === "math_block" ? 8 : 0,
615 backgroundColor:
616 props.node.type === "math_block"
617 ? props.textStyle.codeBackgroundColor
618 : "transparent",
619 }}
620 >
621 <SelectableNode node={props.node} textStyle={props.textStyle} />
622 </View>
623 );
624 case "table_head":
625 case "table_body":
626 case "table_row":
627 case "table_cell":
628 case "list_item":
629 case "task_list_item":
630 return (
631 <View style={{ gap: 4 }}>
632 {(props.node.children ?? []).map((child, index) => (
633 <NativeMarkdownBlock
634 key={nodeKey(child, index)}
635 node={child}
636 textStyle={props.textStyle}
637 highlightCode={props.highlightCode}
638 depth={depth}
639 compact
640 />
641 ))}
642 </View>
643 );
644 default:
645 return <SelectableNode node={props.node} textStyle={props.textStyle} />;
646 }
647}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx created+257
...@@ -0,0 +1,257 @@
1import { useEffect, useMemo, useState } from "react";
2import { Asset } from "expo-asset";
3import { Image, Linking, type TextStyle, useColorScheme } from "react-native";
4
5import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
6import { markdownFileIconSource } from "./markdownFileIcons";
7import type { MarkdownFileIcon } from "./markdownLinks";
8import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
9import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types";
10
11const EXTERNAL_LINK_PREFIX = "◉ ";
12const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0";
13const CHIP_SUFFIX = "\u00A0";
14const SKILL_ICON_PLACEHOLDER = "\uFFFC";
15const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;
16
17function useFileIconUris(runs: ReadonlyArray<NativeMarkdownTextRun>) {
18 const iconSignature = JSON.stringify(
19 [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(),
20 );
21 const icons = useMemo(
22 () => JSON.parse(iconSignature) as ReadonlyArray<MarkdownFileIcon>,
23 [iconSignature],
24 );
25 const [uris, setUris] = useState<ReadonlyMap<string, string>>(() => new Map());
26
27 useEffect(() => {
28 let cancelled = false;
29
30 void Promise.all(
31 icons.map(async (icon) => {
32 const source = markdownFileIconSource(icon);
33 const fallbackUri = Image.resolveAssetSource(source).uri;
34 if (typeof source !== "number" && typeof source !== "string") {
35 return [icon, fallbackUri] as const;
36 }
37 try {
38 const asset = Asset.fromModule(source);
39 await asset.downloadAsync();
40 return [icon, asset.localUri ?? fallbackUri] as const;
41 } catch {
42 return [icon, fallbackUri] as const;
43 }
44 }),
45 ).then((entries) => {
46 if (!cancelled) {
47 setUris(new Map(entries));
48 }
49 });
50
51 return () => {
52 cancelled = true;
53 };
54 }, [icons]);
55
56 return uris;
57}
58
59function runKeySignature(run: NativeMarkdownTextRun): string {
60 return [
61 run.text,
62 run.bold,
63 run.italic,
64 run.strikethrough,
65 run.code,
66 run.href,
67 run.externalHost,
68 run.fileIcon,
69 run.skillName,
70 run.skillLabel,
71 run.role,
72 run.headingLevel,
73 run.depth,
74 run.spacing,
75 run.firstLineHeadIndent,
76 run.headIndent,
77 run.paragraphSpacing,
78 ].join(":");
79}
80
81function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
82 const isFile = run.fileIcon != null;
83 const isSkill = run.skillName != null;
84 const isChip = isFile || isSkill;
85 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
86 const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15;
87 const isHeading = run.role === "heading";
88 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
89 const hasParagraphStyle = run.headIndent !== undefined;
90 const textDecorationLine = run.strikethrough ? "line-through" : run.href ? "underline" : "none";
91
92 return {
93 color: isFile
94 ? textStyle.fileTextColor
95 : isSkill
96 ? textStyle.skillTextColor
97 : run.href
98 ? textStyle.linkColor
99 : isHeading
100 ? textStyle.strongColor
101 : run.role === "quote-marker"
102 ? textStyle.quoteMarkerColor
103 : run.role === "divider"
104 ? textStyle.dividerColor
105 : run.role === "code-language"
106 ? textStyle.mutedColor
107 : run.role === "list-marker"
108 ? textStyle.mutedColor
109 : run.code || isFile
110 ? textStyle.codeColor
111 : run.bold
112 ? textStyle.strongColor
113 : textStyle.color,
114 fontFamily: isChip
115 ? "DMSans_500Medium"
116 : run.code || isCodeBlock
117 ? "ui-monospace"
118 : isHeading
119 ? textStyle.headingFontFamily
120 : run.bold
121 ? textStyle.boldFontFamily
122 : textStyle.fontFamily,
123 fontSize:
124 run.role === "spacer"
125 ? (run.spacing ?? 10)
126 : run.role === "list-break"
127 ? textStyle.fontSize
128 : isHeading
129 ? headingFontSize
130 : run.role === "code-language"
131 ? 11
132 : run.code || isChip || isCodeBlock
133 ? Math.max(12, textStyle.fontSize - 2)
134 : textStyle.fontSize,
135 lineHeight:
136 run.role === "spacer"
137 ? (run.spacing ?? 10)
138 : run.role === "list-break"
139 ? textStyle.lineHeight + (run.spacing ?? 0)
140 : isHeading
141 ? Math.max(headingFontSize + 6, 20)
142 : isCodeBlock
143 ? 18
144 : textStyle.lineHeight,
145 fontStyle: run.italic ? "italic" : "normal",
146 fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400",
147 textDecorationLine,
148 backgroundColor: isCodeBlock
149 ? textStyle.codeBlockBackgroundColor
150 : isSkill
151 ? textStyle.skillBackgroundColor
152 : run.code
153 ? textStyle.codeBackgroundColor
154 : isFile
155 ? textStyle.fileBackgroundColor
156 : undefined,
157 ...(hasParagraphStyle
158 ? {
159 shadowColor: "transparent",
160 shadowOffset: {
161 width: run.firstLineHeadIndent ?? 0,
162 height: run.headIndent,
163 },
164 shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0),
165 }
166 : {}),
167 };
168}
169
170export function NativeMarkdownSelectableText(props: {
171 readonly runs: ReadonlyArray<NativeMarkdownTextRun>;
172 readonly textStyle: NativeMarkdownTextStyle;
173}) {
174 const colorScheme = useColorScheme();
175 const fileIconUris = useFileIconUris(props.runs);
176 const occurrences = new Map<string, number>();
177 const prefixedExternalLinks = new Set<string>();
178 const keyedRuns = props.runs.map((run) => {
179 const signature = runKeySignature(run);
180 const occurrence = occurrences.get(signature) ?? 0;
181 occurrences.set(signature, occurrence + 1);
182
183 let text = run.text;
184 if (run.fileIcon) {
185 text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`;
186 } else if (run.skillName && run.skillLabel) {
187 text = `\u00A0${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}${CHIP_SUFFIX}`;
188 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
189 prefixedExternalLinks.add(run.href);
190 text = `${EXTERNAL_LINK_PREFIX}${text}`;
191 }
192
193 return { key: `${signature}:${occurrence}`, run, text };
194 });
195 // T3MarkdownText only rebuilds its attributed string during native layout. A
196 // color-only child update can otherwise leave the previous appearance cached.
197 const appearanceKey = [
198 colorScheme ?? "unspecified",
199 props.textStyle.color,
200 props.textStyle.strongColor,
201 props.textStyle.mutedColor,
202 props.textStyle.linkColor,
203 props.textStyle.codeColor,
204 props.textStyle.codeBackgroundColor,
205 props.textStyle.codeBlockBackgroundColor,
206 props.textStyle.fileBackgroundColor,
207 props.textStyle.fileTextColor,
208 props.textStyle.skillBackgroundColor,
209 props.textStyle.skillTextColor,
210 props.textStyle.quoteMarkerColor,
211 props.textStyle.dividerColor,
212 ].join(":");
213
214 return (
215 <MarkdownTextPrimitive
216 key={appearanceKey}
217 uiTextView
218 selectable
219 style={{
220 width: "100%",
221 color: props.textStyle.color,
222 fontFamily: props.textStyle.fontFamily,
223 fontSize: props.textStyle.fontSize,
224 lineHeight: props.textStyle.lineHeight,
225 }}
226 >
227 {keyedRuns.map(({ key, run, text }) => {
228 const href = run.href;
229 return (
230 <MarkdownTextPrimitive
231 key={key}
232 nativeID={
233 run.fileIcon
234 ? `t3-chip-file:${
235 fileIconUris.get(run.fileIcon) ??
236 Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri
237 }`
238 : run.skillName
239 ? "t3-chip-skill:sf:cube"
240 : undefined
241 }
242 style={runStyle(run, props.textStyle)}
243 onPress={
244 href
245 ? () => {
246 void Linking.openURL(href);
247 }
248 : undefined
249 }
250 >
251 {text}
252 </MarkdownTextPrimitive>
253 );
254 })}
255 </MarkdownTextPrimitive>
256 );
257}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx created+80
...@@ -0,0 +1,80 @@
1import { useMemo } from "react";
2import { View } from "react-native";
3import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
4
5import {
6 nativeMarkdownChunkSpacing,
7 nativeMarkdownDocumentChunks,
8 nativeMarkdownDocumentRuns,
9} from "./nativeMarkdownText";
10import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
11import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
12import type {
13 SelectableMarkdownSkill,
14 SelectableMarkdownTextProps,
15} from "./SelectableMarkdownText.types";
16
17const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = [];
18
19export type {
20 MarkdownCodeHighlighter,
21 MarkdownHighlightedToken,
22 NativeMarkdownTextStyle,
23 SelectableMarkdownSkill,
24 SelectableMarkdownTextProps,
25} from "./SelectableMarkdownText.types";
26
27export function hasNativeSelectableMarkdownText(): boolean {
28 return true;
29}
30
31export function SelectableMarkdownText({
32 markdown,
33 skills = EMPTY_SKILLS,
34 textStyle,
35 highlightCode,
36 marginTop = 0,
37 marginBottom = 0,
38}: SelectableMarkdownTextProps) {
39 const chunks = useMemo(() => {
40 const document = parseMarkdownWithOptions(markdown, {
41 gfm: true,
42 html: true,
43 math: false,
44 });
45 return nativeMarkdownDocumentChunks(document).map((chunk) =>
46 chunk.kind === "selectable"
47 ? {
48 ...chunk,
49 runs: nativeMarkdownDocumentRuns(chunk.node, skills),
50 }
51 : chunk,
52 );
53 }, [markdown, skills]);
54
55 return (
56 <View style={{ width: "100%", marginTop, marginBottom }}>
57 {chunks.map((chunk, index) => {
58 const content =
59 chunk.kind === "rich" ? (
60 <NativeMarkdownBlock
61 node={chunk.node}
62 textStyle={textStyle}
63 highlightCode={highlightCode}
64 />
65 ) : (
66 <NativeMarkdownSelectableText runs={chunk.runs} textStyle={textStyle} />
67 );
68
69 return (
70 <View
71 key={chunk.key}
72 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
73 >
74 {content}
75 </View>
76 );
77 })}
78 </View>
79 );
80}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx created+13
...@@ -0,0 +1,13 @@
1import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";
2
3export type {
4 MarkdownCodeHighlighter,
5 MarkdownHighlightedToken,
6 NativeMarkdownTextStyle,
7 SelectableMarkdownSkill,
8 SelectableMarkdownTextProps,
9} from "./SelectableMarkdownText.types";
10
11export function SelectableMarkdownText(_props: SelectableMarkdownTextProps) {
12 return null;
13}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts created+46
...@@ -0,0 +1,46 @@
1export interface NativeMarkdownTextStyle {
2 readonly color: string;
3 readonly strongColor: string;
4 readonly mutedColor: string;
5 readonly linkColor: string;
6 readonly codeColor: string;
7 readonly codeBackgroundColor: string;
8 readonly codeBlockBackgroundColor: string;
9 readonly fileBackgroundColor: string;
10 readonly fileTextColor: string;
11 readonly skillBackgroundColor: string;
12 readonly skillTextColor: string;
13 readonly quoteMarkerColor: string;
14 readonly dividerColor: string;
15 readonly fontSize: number;
16 readonly lineHeight: number;
17 readonly fontFamily: string;
18 readonly headingFontFamily: string;
19 readonly boldFontFamily: string;
20}
21
22export interface MarkdownHighlightedToken {
23 readonly content: string;
24 readonly color: string | null;
25 readonly fontStyle: number | null;
26}
27
28export type MarkdownCodeHighlighter = (input: {
29 readonly code: string;
30 readonly language?: string | null;
31 readonly theme: "light" | "dark";
32}) => Promise<ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>>;
33
34export interface SelectableMarkdownSkill {
35 readonly name: string;
36 readonly displayName?: string | null;
37}
38
39export interface SelectableMarkdownTextProps {
40 readonly markdown: string;
41 readonly textStyle: NativeMarkdownTextStyle;
42 readonly highlightCode: MarkdownCodeHighlighter;
43 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
44 readonly marginTop?: number;
45 readonly marginBottom?: number;
46}
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts created+55
...@@ -0,0 +1,55 @@
1import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent";
2import type { ViewProps } from "react-native";
3import type {
4 BubblingEventHandler,
5 Int32,
6 WithDefault,
7} from "react-native/Libraries/Types/CodegenTypes";
8
9interface TargetedEvent {
10 target: Int32;
11}
12
13interface TextLayoutEvent extends TargetedEvent {
14 lines: string[];
15}
16
17/**
18 * Event fired when text selection changes in the MarkdownTextPrimitive.
19 * @property target - The view tag identifier
20 * @property start - The start index of the selected range (0-based)
21 * @property end - The end index of the selected range (0-based, exclusive)
22 */
23interface SelectionChangeEvent extends TargetedEvent {
24 start: Int32;
25 end: Int32;
26}
27
28type EllipsizeMode = "head" | "middle" | "tail" | "clip";
29
30interface NativeProps extends ViewProps {
31 numberOfLines?: Int32;
32 allowFontScaling?: WithDefault<boolean, true>;
33 ellipsizeMode?: WithDefault<EllipsizeMode, "tail">;
34 selectable?: boolean;
35 onTextLayout?: BubblingEventHandler<TextLayoutEvent>;
36 /**
37 * Callback fired when the text selection changes.
38 *
39 * @example
40 * ```tsx
41 * <MarkdownTextPrimitive
42 * onSelectionChange={(event) => {
43 * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end);
44 * }}
45 * >
46 * Selectable text
47 * </MarkdownTextPrimitive>
48 * ```
49 */
50 onSelectionChange?: BubblingEventHandler<SelectionChangeEvent>;
51}
52
53export default codegenNativeComponent<NativeProps>("T3MarkdownText", {
54 excludedPlatforms: ["android"],
55});
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts created+51
...@@ -0,0 +1,51 @@
1import type { ColorValue, ViewProps } from "react-native";
2import type {
3 BubblingEventHandler,
4 Float,
5 Int32,
6 WithDefault,
7} from "react-native/Libraries/Types/CodegenTypes";
8import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent";
9
10interface TargetedEvent {
11 target: Int32;
12}
13
14type TextDecorationLine = "none" | "underline" | "line-through";
15
16type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed";
17
18export type NativeFontWeight =
19 | "normal"
20 | "bold"
21 | "ultraLight"
22 | "light"
23 | "medium"
24 | "semibold"
25 | "heavy";
26
27type FontStyle = "normal" | "italic";
28
29type TextAlign = "auto" | "left" | "right" | "center" | "justify";
30
31interface NativeProps extends ViewProps {
32 text: string;
33 color?: ColorValue;
34 fontSize?: Float;
35 fontStyle?: WithDefault<FontStyle, "normal">;
36 fontWeight?: WithDefault<NativeFontWeight, "normal">;
37 fontFamily?: string;
38 letterSpacing?: Float;
39 lineHeight?: Float;
40 textDecorationLine?: WithDefault<TextDecorationLine, "none">;
41 textDecorationStyle?: WithDefault<TextDecorationStyle, "solid">;
42 textDecorationColor?: ColorValue;
43 textAlign?: WithDefault<TextAlign, "auto">;
44 shadowRadius?: WithDefault<Float, 0>;
45 onPress?: BubblingEventHandler<TargetedEvent>;
46 onLongPress?: BubblingEventHandler<TargetedEvent>;
47}
48
49export default codegenNativeComponent<NativeProps>("T3MarkdownTextRun", {
50 excludedPlatforms: ["android"],
51});
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts created+62
...@@ -0,0 +1,62 @@
1import type { ImageSourcePropType } from "react-native";
2
3export const MARKDOWN_FILE_ICON_SOURCES = {
4 agents: require("../assets/file-icons/pierre_agents.png"),
5 astro: require("../assets/file-icons/pierre_astro.png"),
6 babel: require("../assets/file-icons/pierre_babel.png"),
7 bash: require("../assets/file-icons/pierre_bash.png"),
8 biome: require("../assets/file-icons/pierre_biome.png"),
9 bootstrap: require("../assets/file-icons/pierre_bootstrap.png"),
10 browserslist: require("../assets/file-icons/pierre_browserslist.png"),
11 bun: require("../assets/file-icons/pierre_bun.png"),
12 c: require("../assets/file-icons/pierre_c.png"),
13 claude: require("../assets/file-icons/pierre_claude.png"),
14 cpp: require("../assets/file-icons/pierre_cpp.png"),
15 css: require("../assets/file-icons/pierre_css.png"),
16 database: require("../assets/file-icons/pierre_database.png"),
17 default: require("../assets/file-icons/pierre_default.png"),
18 docker: require("../assets/file-icons/pierre_docker.png"),
19 eslint: require("../assets/file-icons/pierre_eslint.png"),
20 font: require("../assets/file-icons/pierre_font.png"),
21 git: require("../assets/file-icons/pierre_git.png"),
22 go: require("../assets/file-icons/pierre_go.png"),
23 graphql: require("../assets/file-icons/pierre_graphql.png"),
24 html: require("../assets/file-icons/pierre_html.png"),
25 image: require("../assets/file-icons/pierre_image.png"),
26 javascript: require("../assets/file-icons/pierre_javascript.png"),
27 json: require("../assets/file-icons/pierre_json.png"),
28 markdown: require("../assets/file-icons/pierre_markdown.png"),
29 mcp: require("../assets/file-icons/pierre_mcp.png"),
30 nextjs: require("../assets/file-icons/pierre_nextjs.png"),
31 npm: require("../assets/file-icons/pierre_npm.png"),
32 oxc: require("../assets/file-icons/pierre_oxc.png"),
33 package: require("../assets/file-icons/pierre_package.png"),
34 pnpm: require("../assets/file-icons/pierre_pnpm.png"),
35 postcss: require("../assets/file-icons/pierre_postcss.png"),
36 prettier: require("../assets/file-icons/pierre_prettier.png"),
37 python: require("../assets/file-icons/pierre_python.png"),
38 react: require("../assets/file-icons/pierre_react.png"),
39 readme: require("../assets/file-icons/pierre_readme.png"),
40 ruby: require("../assets/file-icons/pierre_ruby.png"),
41 rust: require("../assets/file-icons/pierre_rust.png"),
42 sass: require("../assets/file-icons/pierre_sass.png"),
43 stylelint: require("../assets/file-icons/pierre_stylelint.png"),
44 svelte: require("../assets/file-icons/pierre_svelte.png"),
45 svg: require("../assets/file-icons/pierre_svg.png"),
46 svgo: require("../assets/file-icons/pierre_svgo.png"),
47 swift: require("../assets/file-icons/pierre_swift.png"),
48 table: require("../assets/file-icons/pierre_table.png"),
49 tailwind: require("../assets/file-icons/pierre_tailwind.png"),
50 terraform: require("../assets/file-icons/pierre_terraform.png"),
51 text: require("../assets/file-icons/pierre_text.png"),
52 tsconfig: require("../assets/file-icons/pierre_tsconfig.png"),
53 typescript: require("../assets/file-icons/pierre_typescript.png"),
54 vite: require("../assets/file-icons/pierre_vite.png"),
55 vscode: require("../assets/file-icons/pierre_vscode.png"),
56 vue: require("../assets/file-icons/pierre_vue.png"),
57 wasm: require("../assets/file-icons/pierre_wasm.png"),
58 webpack: require("../assets/file-icons/pierre_webpack.png"),
59 yml: require("../assets/file-icons/pierre_yml.png"),
60 zig: require("../assets/file-icons/pierre_zig.png"),
61 zip: require("../assets/file-icons/pierre_zip.png"),
62} as const satisfies Readonly<Record<string, ImageSourcePropType>>;
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts created+8
...@@ -0,0 +1,8 @@
1import type { ImageSourcePropType } from "react-native";
2
3import type { MarkdownFileIcon } from "./markdownLinks";
4import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
5
6export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType {
7 return MARKDOWN_FILE_ICON_SOURCES[icon];
8}
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts created+349
...@@ -0,0 +1,349 @@
1import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
2
3const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
4const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
5const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
6const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
7const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
8const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
9const POSIX_FILE_ROOT_PREFIXES = [
10 "/Users/",
11 "/home/",
12 "/tmp/",
13 "/var/",
14 "/etc/",
15 "/opt/",
16 "/mnt/",
17 "/Volumes/",
18 "/private/",
19 "/root/",
20] as const;
21
22export type MarkdownLinkPresentation =
23 | {
24 readonly kind: "external";
25 readonly href: string;
26 readonly host: string;
27 }
28 | {
29 readonly kind: "file";
30 readonly icon: MarkdownFileIcon;
31 readonly label: string;
32 }
33 | {
34 readonly kind: "link";
35 readonly href: string | null;
36 };
37
38export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES;
39
40const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {
41 ".babelrc": "babel",
42 ".babelrc.json": "babel",
43 ".bash_profile": "bash",
44 ".bashrc": "bash",
45 ".browserslistrc": "browserslist",
46 ".dockerignore": "docker",
47 ".eslintignore": "eslint",
48 ".eslintrc": "eslint",
49 ".eslintrc.cjs": "eslint",
50 ".eslintrc.js": "eslint",
51 ".eslintrc.json": "eslint",
52 ".eslintrc.yaml": "eslint",
53 ".eslintrc.yml": "eslint",
54 ".gitattributes": "git",
55 ".gitignore": "git",
56 ".gitkeep": "git",
57 ".gitmodules": "git",
58 ".oxlintrc.json": "oxc",
59 ".postcssrc": "postcss",
60 ".postcssrc.json": "postcss",
61 ".postcssrc.yaml": "postcss",
62 ".postcssrc.yml": "postcss",
63 ".prettierignore": "prettier",
64 ".prettierrc": "prettier",
65 ".prettierrc.json": "prettier",
66 ".prettierrc.cjs": "prettier",
67 ".prettierrc.js": "prettier",
68 ".prettierrc.mjs": "prettier",
69 ".prettierrc.toml": "prettier",
70 ".prettierrc.yaml": "prettier",
71 ".prettierrc.yml": "prettier",
72 ".stylelintignore": "stylelint",
73 ".stylelintrc": "stylelint",
74 ".stylelintrc.cjs": "stylelint",
75 ".stylelintrc.js": "stylelint",
76 ".stylelintrc.json": "stylelint",
77 ".stylelintrc.mjs": "stylelint",
78 ".stylelintrc.yaml": "stylelint",
79 ".stylelintrc.yml": "stylelint",
80 ".terraform.lock.hcl": "terraform",
81 ".zprofile": "bash",
82 ".zshenv": "bash",
83 ".zshrc": "bash",
84 "agents.md": "agents",
85 "babel.config.js": "babel",
86 "babel.config.cjs": "babel",
87 "babel.config.json": "babel",
88 "babel.config.mjs": "babel",
89 "biome.json": "biome",
90 "biome.jsonc": "biome",
91 "bun.lock": "bun",
92 "bun.lockb": "bun",
93 "bunfig.toml": "bun",
94 "claude.md": "claude",
95 "compose.yaml": "docker",
96 "compose.yml": "docker",
97 "docker-compose.yaml": "docker",
98 "docker-compose.yml": "docker",
99 "docker-compose.override.yml": "docker",
100 dockerfile: "docker",
101 "eslint.config.js": "eslint",
102 "eslint.config.cjs": "eslint",
103 "eslint.config.mjs": "eslint",
104 "eslint.config.mts": "eslint",
105 "eslint.config.ts": "eslint",
106 gemfile: "ruby",
107 "next.config.js": "nextjs",
108 "next.config.mjs": "nextjs",
109 "next.config.mts": "nextjs",
110 "next.config.ts": "nextjs",
111 "package.json": "package",
112 "pnpm-lock.yaml": "pnpm",
113 "pnpm-workspace.yaml": "pnpm",
114 "postcss.config.js": "postcss",
115 "postcss.config.cjs": "postcss",
116 "postcss.config.mjs": "postcss",
117 "postcss.config.ts": "postcss",
118 "prettier.config.js": "prettier",
119 "prettier.config.cjs": "prettier",
120 "prettier.config.mjs": "prettier",
121 rakefile: "ruby",
122 "readme.md": "readme",
123 "stylelint.config.js": "stylelint",
124 "stylelint.config.cjs": "stylelint",
125 "stylelint.config.mjs": "stylelint",
126 "svgo.config.js": "svgo",
127 "svgo.config.cjs": "svgo",
128 "svgo.config.mjs": "svgo",
129 "svgo.config.ts": "svgo",
130 "tailwind.config.js": "tailwind",
131 "tailwind.config.cjs": "tailwind",
132 "tailwind.config.mjs": "tailwind",
133 "tailwind.config.ts": "tailwind",
134 "tsconfig.json": "tsconfig",
135 "vite.config.js": "vite",
136 "vite.config.mjs": "vite",
137 "vite.config.mts": "vite",
138 "vite.config.ts": "vite",
139 "webpack.config.js": "webpack",
140 "webpack.config.babel.js": "webpack",
141 "webpack.config.cjs": "webpack",
142 "webpack.config.mjs": "webpack",
143 "webpack.config.ts": "webpack",
144};
145
146const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = {
147 "7z": "zip",
148 astro: "astro",
149 avif: "image",
150 "code-workspace": "vscode",
151 bash: "bash",
152 bmp: "image",
153 bz2: "zip",
154 c: "c",
155 cc: "cpp",
156 cpp: "cpp",
157 cxx: "cpp",
158 css: "css",
159 csv: "table",
160 cts: "typescript",
161 db: "database",
162 env: "text",
163 "env.development": "text",
164 "env.local": "text",
165 "env.production": "text",
166 eot: "font",
167 erb: "ruby",
168 fish: "bash",
169 gif: "image",
170 go: "go",
171 gql: "graphql",
172 graphql: "graphql",
173 gz: "zip",
174 h: "c",
175 hh: "cpp",
176 hpp: "cpp",
177 hxx: "cpp",
178 htm: "html",
179 html: "html",
180 ico: "image",
181 icns: "image",
182 ini: "text",
183 inl: "cpp",
184 jar: "zip",
185 jpeg: "image",
186 jpg: "image",
187 js: "javascript",
188 jsx: "react",
189 json: "json",
190 jsonc: "json",
191 less: "css",
192 md: "markdown",
193 mdx: "markdown",
194 "mdx.tsx": "markdown",
195 mjs: "javascript",
196 mts: "typescript",
197 png: "image",
198 postcss: "css",
199 py: "python",
200 pyi: "python",
201 pyw: "python",
202 pyx: "python",
203 rake: "ruby",
204 rar: "zip",
205 rb: "ruby",
206 rs: "rust",
207 sass: "sass",
208 scss: "sass",
209 sh: "bash",
210 sql: "database",
211 sqlite: "database",
212 sqlite3: "database",
213 svelte: "svelte",
214 svg: "svg",
215 swift: "swift",
216 tar: "zip",
217 tf: "terraform",
218 tfstate: "terraform",
219 tfvars: "terraform",
220 tgz: "zip",
221 ts: "typescript",
222 tsv: "table",
223 tsx: "react",
224 txt: "text",
225 woff: "font",
226 woff2: "font",
227 vue: "vue",
228 wasm: "wasm",
229 webp: "image",
230 yml: "yml",
231 yaml: "yml",
232 zig: "zig",
233 zip: "zip",
234 zsh: "bash",
235};
236
237function safeDecode(value: string): string {
238 try {
239 return decodeURIComponent(value);
240 } catch {
241 return value;
242 }
243}
244
245function normalizeDestination(value: string): string {
246 const trimmed = value.trim();
247 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
248}
249
250function fileUrlPath(href: string): string | null {
251 try {
252 const parsed = new URL(href);
253 if (parsed.protocol.toLowerCase() !== "file:") {
254 return null;
255 }
256 const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname)
257 ? parsed.pathname.slice(1)
258 : parsed.pathname;
259 const lineMatch = parsed.hash.match(/^#L(\d+)(?:C(\d+))?$/i);
260 return `${safeDecode(path)}${
261 lineMatch?.[1] ? `:${lineMatch[1]}${lineMatch[2] ? `:${lineMatch[2]}` : ""}` : ""
262 }`;
263 } catch {
264 return null;
265 }
266}
267
268function looksLikePosixFilesystemPath(path: string): boolean {
269 if (!path.startsWith("/")) {
270 return false;
271 }
272 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) {
273 return true;
274 }
275 if (POSITION_SUFFIX_PATTERN.test(path)) {
276 return true;
277 }
278 const basename = path.slice(path.lastIndexOf("/") + 1);
279 return /\.[A-Za-z0-9_-]+$/.test(basename);
280}
281
282function looksLikeFilePath(value: string): boolean {
283 if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) {
284 return true;
285 }
286 if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) {
287 return true;
288 }
289 if (value.startsWith("/")) {
290 return looksLikePosixFilesystemPath(value);
291 }
292 if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) {
293 return true;
294 }
295 return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value);
296}
297
298function fileLabel(value: string): string {
299 const normalized = value.replaceAll("\\", "/");
300 const basename = normalized.slice(normalized.lastIndexOf("/") + 1);
301 return basename || normalized;
302}
303
304export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
305 const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
306 const exactIcon = FILE_ICON_BY_NAME[basename];
307 if (exactIcon) return exactIcon;
308 if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {
309 return "tsconfig";
310 }
311 const segments = basename.split(".");
312 for (let index = 1; index < segments.length; index += 1) {
313 const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")];
314 if (icon) return icon;
315 }
316 return "default";
317}
318
319export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
320 const normalized = normalizeDestination(href);
321 try {
322 const parsed = new URL(normalized);
323 if (parsed.protocol === "http:" || parsed.protocol === "https:") {
324 return {
325 kind: "external",
326 href: parsed.toString(),
327 host: parsed.hostname,
328 };
329 }
330 } catch {
331 // Relative paths and non-URL link destinations are handled below.
332 }
333
334 const fileTarget = normalized.toLowerCase().startsWith("file:")
335 ? fileUrlPath(normalized)
336 : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized);
337 if (fileTarget && looksLikeFilePath(fileTarget)) {
338 return {
339 kind: "file",
340 icon: resolveMarkdownFileIcon(fileTarget),
341 label: fileLabel(fileTarget),
342 };
343 }
344
345 return {
346 kind: "link",
347 href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null,
348 };
349}
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts created+751
...@@ -0,0 +1,751 @@
1import type { MarkdownNode } from "react-native-nitro-markdown/headless";
2
3import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
4import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks";
5
6export interface NativeMarkdownTextRun {
7 readonly text: string;
8 readonly bold?: boolean;
9 readonly italic?: boolean;
10 readonly strikethrough?: boolean;
11 readonly code?: boolean;
12 readonly href?: string;
13 readonly externalHost?: string;
14 readonly fileIcon?: MarkdownFileIcon;
15 readonly skillName?: string;
16 readonly skillLabel?: string;
17 readonly role?:
18 | "body"
19 | "heading"
20 | "list-marker"
21 | "list-break"
22 | "quote-marker"
23 | "code-block"
24 | "code-language"
25 | "divider"
26 | "spacer";
27 readonly headingLevel?: number;
28 readonly depth?: number;
29 readonly spacing?: number;
30 readonly firstLineHeadIndent?: number;
31 readonly headIndent?: number;
32 readonly paragraphSpacing?: number;
33}
34
35export type NativeMarkdownDocumentChunk =
36 | {
37 readonly kind: "selectable";
38 readonly key: string;
39 readonly node: MarkdownNode;
40 }
41 | {
42 readonly kind: "rich";
43 readonly key: string;
44 readonly node: MarkdownNode;
45 };
46
47interface RunContext {
48 readonly bold: boolean;
49 readonly italic: boolean;
50 readonly strikethrough: boolean;
51 readonly code: boolean;
52 readonly href?: string;
53 readonly externalHost?: string;
54 readonly fileIcon?: MarkdownFileIcon;
55 readonly role?: NativeMarkdownTextRun["role"];
56 readonly headingLevel?: number;
57 readonly depth?: number;
58 readonly spacing?: number;
59 readonly firstLineHeadIndent?: number;
60 readonly headIndent?: number;
61 readonly paragraphSpacing?: number;
62}
63
64const EMPTY_CONTEXT: RunContext = {
65 bold: false,
66 italic: false,
67 strikethrough: false,
68 code: false,
69};
70
71const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi;
72
73function decodeHtmlEntitiesOnce(value: string): string {
74 return value.replace(
75 /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi,
76 (entity, decimal: string | undefined, hexadecimal: string | undefined) => {
77 if (decimal) {
78 return String.fromCodePoint(Number.parseInt(decimal, 10));
79 }
80 if (hexadecimal) {
81 return String.fromCodePoint(Number.parseInt(hexadecimal, 16));
82 }
83 switch (entity.toLowerCase()) {
84 case "&amp;":
85 return "&";
86 case "&apos;":
87 return "'";
88 case "&gt;":
89 return ">";
90 case "&lt;":
91 return "<";
92 case "&nbsp;":
93 return "\u00a0";
94 case "&quot;":
95 return '"';
96 default:
97 return entity;
98 }
99 },
100 );
101}
102
103function decodeHtmlEntities(value: string): string {
104 let decoded = value;
105 for (let pass = 0; pass < 2; pass += 1) {
106 const next = decodeHtmlEntitiesOnce(decoded);
107 if (next === decoded) {
108 break;
109 }
110 decoded = next;
111 }
112 return decoded;
113}
114
115function textNodeContent(value: string): string {
116 return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, "");
117}
118
119function inlineHtmlText(value: string): string {
120 if (/^<br\s*\/?>$/i.test(value.trim())) {
121 return "\n";
122 }
123 return decodeHtmlEntities(value.replace(/<[^>]+>/g, ""));
124}
125
126function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean {
127 return (
128 left.bold === right.bold &&
129 left.italic === right.italic &&
130 left.strikethrough === right.strikethrough &&
131 left.code === right.code &&
132 left.href === right.href &&
133 left.externalHost === right.externalHost &&
134 left.fileIcon === right.fileIcon &&
135 left.skillName === right.skillName &&
136 left.skillLabel === right.skillLabel &&
137 left.role === right.role &&
138 left.headingLevel === right.headingLevel &&
139 left.depth === right.depth &&
140 left.spacing === right.spacing &&
141 left.firstLineHeadIndent === right.firstLineHeadIndent &&
142 left.headIndent === right.headIndent &&
143 left.paragraphSpacing === right.paragraphSpacing
144 );
145}
146
147function appendRun(
148 runs: NativeMarkdownTextRun[],
149 text: string,
150 context: RunContext,
151): NativeMarkdownTextRun[] {
152 if (text.length === 0) {
153 return runs;
154 }
155
156 const run: NativeMarkdownTextRun = {
157 text,
158 ...(context.bold ? { bold: true } : {}),
159 ...(context.italic ? { italic: true } : {}),
160 ...(context.strikethrough ? { strikethrough: true } : {}),
161 ...(context.code ? { code: true } : {}),
162 ...(context.href ? { href: context.href } : {}),
163 ...(context.externalHost ? { externalHost: context.externalHost } : {}),
164 ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}),
165 ...(context.role ? { role: context.role } : {}),
166 ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}),
167 ...(context.depth ? { depth: context.depth } : {}),
168 ...(context.spacing ? { spacing: context.spacing } : {}),
169 ...(context.firstLineHeadIndent !== undefined
170 ? { firstLineHeadIndent: context.firstLineHeadIndent }
171 : {}),
172 ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}),
173 ...(context.paragraphSpacing !== undefined
174 ? { paragraphSpacing: context.paragraphSpacing }
175 : {}),
176 };
177 const previous = runs.at(-1);
178 if (previous && sameRunStyle(previous, run)) {
179 runs[runs.length - 1] = { ...previous, text: previous.text + run.text };
180 return runs;
181 }
182
183 runs.push(run);
184 return runs;
185}
186
187const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
188
189function formatSkillLabel(skill: SelectableMarkdownSkill): string {
190 const displayName = skill.displayName?.trim();
191 if (displayName) {
192 return displayName;
193 }
194 return skill.name
195 .split(/[\s:_-]+/)
196 .filter(Boolean)
197 .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
198 .join(" ");
199}
200
201function decorateSkillRuns(
202 runs: ReadonlyArray<NativeMarkdownTextRun>,
203 skills: ReadonlyArray<SelectableMarkdownSkill>,
204): ReadonlyArray<NativeMarkdownTextRun> {
205 if (skills.length === 0) {
206 return runs;
207 }
208 const skillByName = new Map(skills.map((skill) => [skill.name, skill]));
209 const decorated: NativeMarkdownTextRun[] = [];
210
211 for (const run of runs) {
212 if (run.code || run.href || run.fileIcon || run.role === "code-block") {
213 decorated.push(run);
214 continue;
215 }
216
217 let cursor = 0;
218 let matched = false;
219 for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) {
220 const prefix = match[1] ?? "";
221 const name = match[2] ?? "";
222 const skill = skillByName.get(name);
223 if (!skill) {
224 continue;
225 }
226 const start = (match.index ?? 0) + prefix.length;
227 const end = start + name.length + 1;
228 if (start > cursor) {
229 decorated.push({ ...run, text: run.text.slice(cursor, start) });
230 }
231 decorated.push({
232 ...run,
233 text: run.text.slice(start, end),
234 skillName: name,
235 skillLabel: formatSkillLabel(skill),
236 });
237 cursor = end;
238 matched = true;
239 }
240 if (!matched) {
241 decorated.push(run);
242 } else if (cursor < run.text.length) {
243 decorated.push({ ...run, text: run.text.slice(cursor) });
244 }
245 }
246
247 return decorated;
248}
249
250function appendChildren(
251 runs: NativeMarkdownTextRun[],
252 node: MarkdownNode,
253 context: RunContext,
254): NativeMarkdownTextRun[] {
255 for (const child of node.children ?? []) {
256 appendNode(runs, child, context);
257 }
258 return runs;
259}
260
261function nodeTextContent(node: MarkdownNode): string {
262 if (node.content !== undefined) {
263 return node.content;
264 }
265 return (node.children ?? []).map(nodeTextContent).join("");
266}
267
268function appendNode(
269 runs: NativeMarkdownTextRun[],
270 node: MarkdownNode,
271 context: RunContext,
272): NativeMarkdownTextRun[] {
273 switch (node.type) {
274 case "text":
275 case "math_inline":
276 return appendRun(runs, textNodeContent(nodeTextContent(node)), context);
277 case "html_inline":
278 return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context);
279 case "code_inline":
280 return appendRun(runs, nodeTextContent(node), { ...context, code: true });
281 case "soft_break":
282 return appendRun(runs, " ", context);
283 case "line_break":
284 return appendRun(runs, "\n", context);
285 case "bold":
286 return appendChildren(runs, node, { ...context, bold: true });
287 case "italic":
288 return appendChildren(runs, node, { ...context, italic: true });
289 case "strikethrough":
290 return appendChildren(runs, node, { ...context, strikethrough: true });
291 case "link": {
292 const presentation = resolveMarkdownLinkPresentation(node.href ?? "");
293 if (presentation.kind === "file") {
294 return appendRun(runs, presentation.label, {
295 ...context,
296 fileIcon: presentation.icon,
297 });
298 }
299 if (presentation.kind === "external") {
300 return appendChildren(runs, node, {
301 ...context,
302 href: presentation.href,
303 externalHost: presentation.host,
304 });
305 }
306 return appendChildren(runs, node, {
307 ...context,
308 ...(presentation.href ? { href: presentation.href } : {}),
309 });
310 }
311 case "image":
312 return appendRun(runs, node.alt ?? node.title ?? "", context);
313 default:
314 return appendChildren(runs, node, context);
315 }
316}
317
318export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray<NativeMarkdownTextRun> {
319 return appendChildren([], node, EMPTY_CONTEXT);
320}
321
322function appendBlockTerminator(
323 runs: NativeMarkdownTextRun[],
324 context: RunContext,
325): NativeMarkdownTextRun[] {
326 return appendRun(runs, "\n", context);
327}
328
329function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] {
330 return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing });
331}
332
333function appendInlineChildren(
334 runs: NativeMarkdownTextRun[],
335 node: MarkdownNode,
336 context: RunContext,
337): NativeMarkdownTextRun[] {
338 for (const child of node.children ?? []) {
339 appendNode(runs, child, context);
340 }
341 return runs;
342}
343
344function isInlineNode(node: MarkdownNode): boolean {
345 return (
346 node.type === "text" ||
347 node.type === "bold" ||
348 node.type === "italic" ||
349 node.type === "strikethrough" ||
350 node.type === "link" ||
351 node.type === "image" ||
352 node.type === "code_inline" ||
353 node.type === "math_inline" ||
354 node.type === "html_inline" ||
355 node.type === "soft_break" ||
356 node.type === "line_break"
357 );
358}
359
360export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray<MarkdownNode> {
361 const blocks: MarkdownNode[] = [];
362 let inlineNodes: MarkdownNode[] = [];
363 const flushInlineNodes = () => {
364 if (inlineNodes.length === 0) {
365 return;
366 }
367 blocks.push({ type: "paragraph", children: inlineNodes });
368 inlineNodes = [];
369 };
370
371 for (const child of node.children ?? []) {
372 if (isInlineNode(child)) {
373 inlineNodes.push(child);
374 continue;
375 }
376
377 flushInlineNodes();
378 blocks.push(child);
379 }
380 flushInlineNodes();
381 return blocks;
382}
383
384function appendListItem(
385 runs: NativeMarkdownTextRun[],
386 node: MarkdownNode,
387 marker: string,
388 depth: number,
389 markerColumnWidth: number,
390): NativeMarkdownTextRun[] {
391 const firstLineHeadIndent = Math.max(0, depth - 1) * 20;
392 appendRun(runs, `${marker}\t`, {
393 ...EMPTY_CONTEXT,
394 role: "list-marker",
395 depth,
396 firstLineHeadIndent,
397 headIndent: firstLineHeadIndent + markerColumnWidth,
398 paragraphSpacing: 2,
399 });
400
401 const children = node.children ?? [];
402 let wroteInlineContent = false;
403 for (const child of children) {
404 if (child.type === "paragraph") {
405 appendInlineChildren(runs, child, {
406 ...EMPTY_CONTEXT,
407 role: "body",
408 depth,
409 });
410 wroteInlineContent = true;
411 continue;
412 }
413 if (child.type === "list") {
414 if (wroteInlineContent) {
415 appendBlockTerminator(runs, {
416 ...EMPTY_CONTEXT,
417 role: "list-break",
418 depth,
419 spacing: 1,
420 });
421 }
422 appendList(runs, child, depth + 1);
423 wroteInlineContent = false;
424 continue;
425 }
426 if (isInlineNode(child)) {
427 appendNode(runs, child, {
428 ...EMPTY_CONTEXT,
429 role: "body",
430 depth,
431 });
432 wroteInlineContent = true;
433 continue;
434 }
435 appendDocumentBlock(runs, child, depth);
436 wroteInlineContent = true;
437 }
438
439 if (wroteInlineContent) {
440 appendBlockTerminator(runs, {
441 ...EMPTY_CONTEXT,
442 role: "list-break",
443 depth,
444 spacing: depth === 1 ? 4 : 2,
445 });
446 }
447 return runs;
448}
449
450function appendList(
451 runs: NativeMarkdownTextRun[],
452 node: MarkdownNode,
453 depth: number,
454): NativeMarkdownTextRun[] {
455 const ordered = node.ordered ?? false;
456 const start = node.start ?? 1;
457 const children = node.children ?? [];
458 const markers = children.map((child, index) =>
459 child.type === "task_list_item"
460 ? child.checked
461 ? "☑︎"
462 : "☐︎"
463 : ordered
464 ? `${start + index}.`
465 : depth % 3 === 2
466 ? "◦"
467 : depth % 3 === 0
468 ? "▪︎"
469 : "•",
470 );
471 const markerWidth = ordered
472 ? Math.max(0, ...markers.map((marker) => Array.from(marker).length))
473 : 0;
474
475 for (const [index, child] of children.entries()) {
476 const marker = markers[index] ?? "•";
477 const alignedMarker =
478 child.type === "task_list_item"
479 ? marker
480 : ordered
481 ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}`
482 : marker;
483 const markerColumnWidth =
484 child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24;
485 appendListItem(runs, child, alignedMarker, depth, markerColumnWidth);
486 }
487 return runs;
488}
489
490function appendQuoteBlock(
491 runs: NativeMarkdownTextRun[],
492 node: MarkdownNode,
493 depth: number,
494): NativeMarkdownTextRun[] {
495 for (const [index, child] of (node.children ?? []).entries()) {
496 if (index > 0) {
497 appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
498 }
499 appendRun(runs, "│\u00a0", {
500 ...EMPTY_CONTEXT,
501 role: "quote-marker",
502 depth,
503 });
504 if (child.type === "paragraph") {
505 appendInlineChildren(runs, child, {
506 ...EMPTY_CONTEXT,
507 role: "body",
508 depth,
509 });
510 } else {
511 appendDocumentBlock(runs, child, depth);
512 }
513 }
514 appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
515 return runs;
516}
517
518function appendTableRow(
519 runs: NativeMarkdownTextRun[],
520 node: MarkdownNode,
521 depth: number,
522): NativeMarkdownTextRun[] {
523 const cells = node.children ?? [];
524 for (const [index, cell] of cells.entries()) {
525 if (index > 0) {
526 appendRun(runs, "\u00a0│\u00a0", {
527 ...EMPTY_CONTEXT,
528 role: "divider",
529 depth,
530 });
531 }
532 appendInlineChildren(runs, cell, {
533 ...EMPTY_CONTEXT,
534 role: "body",
535 bold: cell.isHeader ?? false,
536 depth,
537 });
538 }
539 appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
540 return runs;
541}
542
543function appendTable(
544 runs: NativeMarkdownTextRun[],
545 node: MarkdownNode,
546 depth: number,
547): NativeMarkdownTextRun[] {
548 const visit = (child: MarkdownNode) => {
549 if (child.type === "table_row") {
550 appendTableRow(runs, child, depth);
551 return;
552 }
553 for (const nested of child.children ?? []) {
554 visit(nested);
555 }
556 };
557 visit(node);
558 return runs;
559}
560
561function appendDocumentBlock(
562 runs: NativeMarkdownTextRun[],
563 node: MarkdownNode,
564 depth = 0,
565): NativeMarkdownTextRun[] {
566 switch (node.type) {
567 case "document": {
568 const children = node.children ?? [];
569 for (const [index, child] of children.entries()) {
570 if (index > 0) {
571 const previous = children[index - 1];
572 appendSpacer(
573 runs,
574 child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12,
575 );
576 }
577 appendDocumentBlock(runs, child, depth);
578 }
579 return runs;
580 }
581 case "heading": {
582 const context: RunContext = {
583 ...EMPTY_CONTEXT,
584 role: "heading",
585 headingLevel: node.level ?? 1,
586 depth,
587 };
588 appendInlineChildren(runs, node, context);
589 return appendBlockTerminator(runs, context);
590 }
591 case "paragraph": {
592 const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth };
593 appendInlineChildren(runs, node, context);
594 return appendBlockTerminator(runs, context);
595 }
596 case "list":
597 return appendList(runs, node, depth + 1);
598 case "blockquote":
599 return appendQuoteBlock(runs, node, depth);
600 case "code_block": {
601 if (node.language) {
602 appendRun(runs, `${node.language.toUpperCase()}\n`, {
603 ...EMPTY_CONTEXT,
604 role: "code-language",
605 code: true,
606 depth,
607 });
608 }
609 const content = nodeTextContent(node);
610 appendRun(runs, content, {
611 ...EMPTY_CONTEXT,
612 role: "code-block",
613 code: true,
614 depth,
615 });
616 if (!content.endsWith("\n")) {
617 appendBlockTerminator(runs, {
618 ...EMPTY_CONTEXT,
619 role: "code-block",
620 code: true,
621 depth,
622 });
623 }
624 return runs;
625 }
626 case "horizontal_rule":
627 appendRun(runs, "────────────────────────\n", {
628 ...EMPTY_CONTEXT,
629 role: "divider",
630 depth,
631 });
632 return runs;
633 case "table":
634 return appendTable(runs, node, depth);
635 case "html_block":
636 appendRun(runs, inlineHtmlText(nodeTextContent(node)), {
637 ...EMPTY_CONTEXT,
638 role: "body",
639 depth,
640 });
641 return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
642 case "math_block":
643 appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth });
644 return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
645 default:
646 appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth });
647 return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
648 }
649}
650
651function containsRichBlock(node: MarkdownNode): boolean {
652 if (
653 node.type === "code_block" ||
654 node.type === "table" ||
655 node.type === "image" ||
656 node.type === "horizontal_rule" ||
657 node.type === "html_block" ||
658 node.type === "math_block"
659 ) {
660 return true;
661 }
662 return (node.children ?? []).some(containsRichBlock);
663}
664
665export function nativeMarkdownDocumentChunks(
666 document: MarkdownNode,
667): ReadonlyArray<NativeMarkdownDocumentChunk> {
668 const chunks: NativeMarkdownDocumentChunk[] = [];
669 let selectableNodes: MarkdownNode[] = [];
670
671 const flushSelectable = () => {
672 if (selectableNodes.length === 0) {
673 return;
674 }
675 const first = selectableNodes[0];
676 const last = selectableNodes.at(-1);
677 chunks.push({
678 kind: "selectable",
679 key: `selectable:${first?.beg ?? "start"}:${last?.end ?? "end"}`,
680 node: {
681 type: "document",
682 children: selectableNodes,
683 },
684 });
685 selectableNodes = [];
686 };
687
688 for (const [index, child] of (document.children ?? []).entries()) {
689 if (!containsRichBlock(child)) {
690 selectableNodes.push(child);
691 continue;
692 }
693
694 flushSelectable();
695 chunks.push({
696 kind: "rich",
697 key: `rich:${child.type}:${child.beg ?? index}:${child.end ?? index}`,
698 node: child,
699 });
700 }
701 flushSelectable();
702 return chunks;
703}
704
705function topLevelNodes(node: MarkdownNode): ReadonlyArray<MarkdownNode> {
706 return node.type === "document" ? (node.children ?? []) : [node];
707}
708
709export function nativeMarkdownChunkSpacing(
710 previous: NativeMarkdownDocumentChunk | undefined,
711 current: NativeMarkdownDocumentChunk,
712): number {
713 if (!previous) {
714 return 0;
715 }
716
717 const previousLast = topLevelNodes(previous.node).at(-1);
718 const currentFirst = topLevelNodes(current.node)[0];
719
720 if (currentFirst?.type === "heading") {
721 return 20;
722 }
723 if (previousLast?.type === "heading") {
724 return 10;
725 }
726 if (previousLast?.type === "list" && currentFirst?.type === "list") {
727 return 12;
728 }
729 return 14;
730}
731
732export function nativeMarkdownDocumentRuns(
733 node: MarkdownNode,
734 skills: ReadonlyArray<SelectableMarkdownSkill> = [],
735): ReadonlyArray<NativeMarkdownTextRun> {
736 const runs = appendDocumentBlock([], node);
737 while (runs.length > 0) {
738 const lastIndex = runs.length - 1;
739 const last = runs[lastIndex];
740 if (!last?.text.endsWith("\n")) {
741 break;
742 }
743 const text = last.text.slice(0, -1);
744 if (text.length === 0) {
745 runs.pop();
746 } else {
747 runs[lastIndex] = { ...last, text };
748 }
749 }
750 return decorateSkillRuns(runs, skills);
751}
apps/mobile/modules/t3-markdown-text/src/util.ts created+62
...@@ -0,0 +1,62 @@
1import { type StyleProp, StyleSheet, type TextStyle } from "react-native";
2import type { NativeFontWeight } from "./T3MarkdownTextRunNativeComponent";
3
4export function flattenStyles(rootStyle: TextStyle, style: StyleProp<TextStyle>) {
5 const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle;
6 return {
7 ...flattenedStyle,
8 fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"),
9 backgroundColor: flattenedStyle.backgroundColor
10 ? flattenedStyle.backgroundColor
11 : "transparent",
12 shadowOffset: flattenedStyle.shadowOffset
13 ? flattenedStyle.shadowOffset
14 : { width: 0, height: 0 },
15 };
16}
17
18// Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop
19// value before returning flattened styles.
20function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight {
21 switch (fontWeight) {
22 case "normal":
23 return "normal";
24 case "bold":
25 return "bold";
26 case 100:
27 case "100":
28 case "ultralight":
29 return "ultraLight";
30 case 200:
31 case "200":
32 return "ultraLight";
33 case 300:
34 case "300":
35 case "light":
36 return "light";
37 case 400:
38 case "400":
39 case "regular":
40 return "normal";
41 case 500:
42 case "500":
43 case "medium":
44 return "medium";
45 case 600:
46 case "600":
47 case "semibold":
48 return "semibold";
49 case 700:
50 case "700":
51 return "semibold";
52 case 800:
53 case "800":
54 return "bold";
55 case 900:
56 case "900":
57 case "heavy":
58 return "heavy";
59 default:
60 return "normal";
61 }
62}
apps/mobile/package.json+5-1
...@@ -34,12 +34,13 @@...@@ -34,12 +34,13 @@
34 "config:preview": "APP_VARIANT=preview expo config",34 "config:preview": "APP_VARIANT=preview expo config",
35 "config:prod": "APP_VARIANT=production expo config",35 "config:prod": "APP_VARIANT=production expo config",
36 "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review",36 "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review",
37 "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs",
37 "test": "vp test run",38 "test": "vp test run",
38 "typecheck": "tsc --noEmit"39 "typecheck": "tsc --noEmit"
39 },40 },
40 "dependencies": {41 "dependencies": {
41 "@callstack/liquid-glass": "^0.7.1",42 "@callstack/liquid-glass": "^0.7.1",
42 "@clerk/expo": "^3.3.0",43 "@clerk/expo": "^3.4.1",
43 "@effect/atom-react": "catalog:",44 "@effect/atom-react": "catalog:",
44 "@expo-google-fonts/dm-sans": "^0.4.2",45 "@expo-google-fonts/dm-sans": "^0.4.2",
45 "@expo/ui": "~56.0.8",46 "@expo/ui": "~56.0.8",
...@@ -54,6 +55,7 @@...@@ -54,6 +55,7 @@
54 "@shikijs/themes": "3.23.0",55 "@shikijs/themes": "3.23.0",
55 "@t3tools/client-runtime": "workspace:*",56 "@t3tools/client-runtime": "workspace:*",
56 "@t3tools/contracts": "workspace:*",57 "@t3tools/contracts": "workspace:*",
58 "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text",
57 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",59 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
58 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",60 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
59 "@t3tools/shared": "workspace:*",61 "@t3tools/shared": "workspace:*",
...@@ -61,6 +63,7 @@...@@ -61,6 +63,7 @@
61 "diff": "8.0.3",63 "diff": "8.0.3",
62 "effect": "catalog:",64 "effect": "catalog:",
63 "expo": "^56.0.0",65 "expo": "^56.0.0",
66 "expo-asset": "~56.0.15",
64 "expo-auth-session": "~56.0.12",67 "expo-auth-session": "~56.0.12",
65 "expo-build-properties": "~56.0.15",68 "expo-build-properties": "~56.0.15",
66 "expo-camera": "~56.0.7",69 "expo-camera": "~56.0.7",
...@@ -104,6 +107,7 @@...@@ -104,6 +107,7 @@
104 },107 },
105 "devDependencies": {108 "devDependencies": {
106 "@effect/vitest": "catalog:",109 "@effect/vitest": "catalog:",
110 "@pierre/trees": "1.0.0-beta.4",
107 "@types/react": "~19.2.0",111 "@types/react": "~19.2.0",
108 "babel-preset-expo": "~56.0.0",112 "babel-preset-expo": "~56.0.0",
109 "tailwindcss": "^4.0.0",113 "tailwindcss": "^4.0.0",
apps/mobile/src/app/_layout.tsx+36-4
...@@ -5,7 +5,9 @@ import {...@@ -5,7 +5,9 @@ import {
5 DMSans_700Bold,5 DMSans_700Bold,
6 useFonts,6 useFonts,
7} from "@expo-google-fonts/dm-sans";7} from "@expo-google-fonts/dm-sans";
8import { usePathname } from "expo-router";
8import Stack from "expo-router/stack";9import Stack from "expo-router/stack";
10import { useCallback } from "react";
9import { StatusBar, useColorScheme } from "react-native";11import { StatusBar, useColorScheme } from "react-native";
10import { GestureHandlerRootView } from "react-native-gesture-handler";12import { GestureHandlerRootView } from "react-native-gesture-handler";
11import { KeyboardProvider } from "react-native-keyboard-controller";13import { KeyboardProvider } from "react-native-keyboard-controller";
...@@ -21,15 +23,41 @@ import {...@@ -21,15 +23,41 @@ import {
21import { RegistryContext } from "@effect/atom-react";23import { RegistryContext } from "@effect/atom-react";
22import { appAtomRegistry } from "../state/atom-registry";24import { appAtomRegistry } from "../state/atom-registry";
23import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";25import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
26import {
27 ClerkSettingsSheetDetentProvider,
28 useClerkSettingsSheetDetent,
29} from "../features/cloud/ClerkSettingsSheetDetent";
24import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";30import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";
31import { useThemeColor } from "../lib/useThemeColor";
2532
26function AppNavigator() {33function AppNavigator() {
34 const pathname = usePathname();
35 const clerkRouteIsActive = pathname === "/settings/auth";
36
37 return (
38 <ClerkSettingsSheetDetentProvider initiallyExpanded={clerkRouteIsActive}>
39 <AppNavigatorContent />
40 </ClerkSettingsSheetDetentProvider>
41 );
42}
43
44function AppNavigatorContent() {
27 const { isLoadingSavedConnection } = useRemoteEnvironmentState();45 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
46 const { collapse, isExpanded } = useClerkSettingsSheetDetent();
28 const colorScheme = useColorScheme();47 const colorScheme = useColorScheme();
29 const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7";48 const statusBarBg = useThemeColor("--color-status-bar");
30 const sheetStyle = useResolveClassNames("bg-sheet");49 const sheetStyle = useResolveClassNames("bg-sheet");
31 useAgentNotificationNavigation();50 useAgentNotificationNavigation();
3251
52 const handleSettingsTransitionEnd = useCallback(
53 (event: { data: { closing: boolean } }) => {
54 if (event.data.closing) {
55 collapse();
56 }
57 },
58 [collapse],
59 );
60
33 const newTaskScreenOptions = {61 const newTaskScreenOptions = {
34 contentStyle: sheetStyle,62 contentStyle: sheetStyle,
35 gestureEnabled: true,63 gestureEnabled: true,
...@@ -50,7 +78,7 @@ function AppNavigator() {...@@ -50,7 +78,7 @@ function AppNavigator() {
5078
51 const settingsSheetScreenOptions = {79 const settingsSheetScreenOptions = {
52 ...connectionSheetScreenOptions,80 ...connectionSheetScreenOptions,
53 sheetAllowedDetents: [0.7],81 sheetAllowedDetents: isExpanded ? [0.92] : [0.7],
54 };82 };
5583
56 if (isLoadingSavedConnection) {84 if (isLoadingSavedConnection) {
...@@ -61,7 +89,7 @@ function AppNavigator() {...@@ -61,7 +89,7 @@ function AppNavigator() {
61 <>89 <>
62 <StatusBar90 <StatusBar
63 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}91 barStyle={colorScheme === "dark" ? "light-content" : "dark-content"}
64 backgroundColor={statusBarBg as string}92 backgroundColor={String(statusBarBg)}
65 translucent93 translucent
66 />94 />
67 <Stack screenOptions={{ headerShown: false }}>95 <Stack screenOptions={{ headerShown: false }}>
...@@ -74,7 +102,11 @@ function AppNavigator() {...@@ -74,7 +102,11 @@ function AppNavigator() {
74 headerShadowVisible: false,102 headerShadowVisible: false,
75 }}103 }}
76 />104 />
77 <Stack.Screen name="settings" options={settingsSheetScreenOptions} />105 <Stack.Screen
106 name="settings"
107 listeners={{ transitionEnd: handleSettingsTransitionEnd }}
108 options={settingsSheetScreenOptions}
109 />
78 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />110 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
79 <Stack.Screen name="new" options={newTaskScreenOptions} />111 <Stack.Screen name="new" options={newTaskScreenOptions} />
80 <Stack.Screen112 <Stack.Screen
apps/mobile/src/app/connections/_layout.tsx+3-4
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import Stack from "expo-router/stack";1import Stack from "expo-router/stack";
2import { useColorScheme } from "react-native";
3import { useResolveClassNames } from "uniwind";2import { useResolveClassNames } from "uniwind";
3import { useThemeColor } from "../../lib/useThemeColor";
44
5export const unstable_settings = {5export const unstable_settings = {
6 anchor: "index",6 anchor: "index",
...@@ -8,9 +8,8 @@ export const unstable_settings = {...@@ -8,9 +8,8 @@ export const unstable_settings = {
88
9export default function ConnectionsLayout() {9export default function ConnectionsLayout() {
10 const contentStyle = useResolveClassNames("bg-sheet");10 const contentStyle = useResolveClassNames("bg-sheet");
11 const isDark = useColorScheme() === "dark";11 const connSheetBg = useThemeColor("--color-sheet");
12 const connSheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)";12 const headerTint = useThemeColor("--color-foreground");
13 const headerTint = isDark ? "#f5f5f5" : "#262626";
1413
15 return (14 return (
16 <Stack15 <Stack
apps/mobile/src/app/index.tsx+7-5
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1import { Stack, useRouter } from "expo-router";1import { Stack, useRouter } from "expo-router";
2import { useState } from "react";2import { useState } from "react";
3import { Text as RNText, View, useColorScheme } from "react-native";3import { Text as RNText, View } from "react-native";
44
5import { buildThreadRoutePath } from "../lib/routes";5import { buildThreadRoutePath } from "../lib/routes";
6import { useRemoteCatalog } from "../state/use-remote-catalog";6import { useRemoteCatalog } from "../state/use-remote-catalog";
7import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry";7import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry";
8import { HomeScreen } from "../features/home/HomeScreen";8import { HomeScreen } from "../features/home/HomeScreen";
9import { useThemeColor } from "../lib/useThemeColor";
910
10/* ─── Route screen ───────────────────────────────────────────────────── */11/* ─── Route screen ───────────────────────────────────────────────────── */
1112
...@@ -15,8 +16,9 @@ export default function HomeRouteScreen() {...@@ -15,8 +16,9 @@ export default function HomeRouteScreen() {
15 const router = useRouter();16 const router = useRouter();
16 const [searchQuery, setSearchQuery] = useState("");17 const [searchQuery, setSearchQuery] = useState("");
1718
18 const isDark = useColorScheme() === "dark";19 const iconColor = useThemeColor("--color-icon");
19 const iconColor = isDark ? "#f5f5f5" : "#262626";20 const mutedColor = useThemeColor("--color-foreground-muted");
21 const subtleColor = useThemeColor("--color-subtle");
2022
21 return (23 return (
22 <>24 <>
...@@ -54,7 +56,7 @@ export default function HomeRouteScreen() {...@@ -54,7 +56,7 @@ export default function HomeRouteScreen() {
54 </RNText>56 </RNText>
55 <View57 <View
56 style={{58 style={{
57 backgroundColor: isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)",59 backgroundColor: subtleColor,
58 borderRadius: 99,60 borderRadius: 99,
59 paddingHorizontal: 8,61 paddingHorizontal: 8,
60 paddingVertical: 3,62 paddingVertical: 3,
...@@ -64,7 +66,7 @@ export default function HomeRouteScreen() {...@@ -64,7 +66,7 @@ export default function HomeRouteScreen() {
64 style={{66 style={{
65 fontFamily: "DMSans_700Bold",67 fontFamily: "DMSans_700Bold",
66 fontSize: 10,68 fontSize: 10,
67 color: "#737373",69 color: mutedColor,
68 letterSpacing: 1.1,70 letterSpacing: 1.1,
69 textTransform: "uppercase",71 textTransform: "uppercase",
70 }}72 }}
apps/mobile/src/app/new/_layout.tsx+3-4
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import Stack from "expo-router/stack";1import Stack from "expo-router/stack";
2import { useColorScheme } from "react-native";
3import { useResolveClassNames } from "uniwind";2import { useResolveClassNames } from "uniwind";
43
5import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";4import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
5import { useThemeColor } from "../../lib/useThemeColor";
66
7export const unstable_settings = {7export const unstable_settings = {
8 anchor: "index",8 anchor: "index",
...@@ -10,9 +10,8 @@ export const unstable_settings = {...@@ -10,9 +10,8 @@ export const unstable_settings = {
1010
11export default function NewTaskLayout() {11export default function NewTaskLayout() {
12 const sheetStyle = useResolveClassNames("bg-sheet");12 const sheetStyle = useResolveClassNames("bg-sheet");
13 const isDark = useColorScheme() === "dark";13 const sheetBg = useThemeColor("--color-sheet");
14 const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)";14 const headerTint = useThemeColor("--color-foreground");
15 const headerTint = isDark ? "#f5f5f5" : "#262626";
1615
17 return (16 return (
18 <NewTaskFlowProvider>17 <NewTaskFlowProvider>
apps/mobile/src/app/settings/_layout.tsx+20-4
...@@ -1,16 +1,27 @@...@@ -1,16 +1,27 @@
1import Stack from "expo-router/stack";1import Stack from "expo-router/stack";
2import { useColorScheme } from "react-native";2import { useCallback } from "react";
3import { useResolveClassNames } from "uniwind";3import { useResolveClassNames } from "uniwind";
44
5import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
6import { useThemeColor } from "../../lib/useThemeColor";
7
5export const unstable_settings = {8export const unstable_settings = {
6 anchor: "index",9 anchor: "index",
7};10};
811
9export default function SettingsLayout() {12export default function SettingsLayout() {
13 const { collapse } = useClerkSettingsSheetDetent();
10 const contentStyle = useResolveClassNames("bg-sheet");14 const contentStyle = useResolveClassNames("bg-sheet");
11 const isDark = useColorScheme() === "dark";15 const sheetBg = useThemeColor("--color-sheet");
12 const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)";16 const headerTint = useThemeColor("--color-foreground");
13 const headerTint = isDark ? "#f5f5f5" : "#262626";17 const handleClerkRouteTransitionEnd = useCallback(
18 (event: { data: { closing: boolean } }) => {
19 if (event.data.closing) {
20 collapse();
21 }
22 },
23 [collapse],
24 );
1425
15 return (26 return (
16 <Stack27 <Stack
...@@ -36,6 +47,11 @@ export default function SettingsLayout() {...@@ -36,6 +47,11 @@ export default function SettingsLayout() {
36 name="waitlist"47 name="waitlist"
37 options={{ animation: "slide_from_right", title: "Join the waitlist" }}48 options={{ animation: "slide_from_right", title: "Join the waitlist" }}
38 />49 />
50 <Stack.Screen
51 name="auth"
52 listeners={{ transitionEnd: handleClerkRouteTransitionEnd }}
53 options={{ animation: "slide_from_right", title: "Sign in" }}
54 />
39 </Stack>55 </Stack>
40 );56 );
41}57}
apps/mobile/src/app/settings/auth.tsx created+33
...@@ -0,0 +1,33 @@
1import { useAuth } from "@clerk/expo";
2import { AuthView, UserProfileView } from "@clerk/expo/native";
3import { Redirect, Stack } from "expo-router";
4import { View } from "react-native";
5
6import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
7
8export default function SettingsAuthRouteScreen() {
9 return hasCloudPublicConfig() ? (
10 <ConfiguredSettingsAuthRouteScreen />
11 ) : (
12 <Redirect href="/settings" />
13 );
14}
15
16function ConfiguredSettingsAuthRouteScreen() {
17 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
18
19 return (
20 <>
21 <Stack.Screen options={{ title: isSignedIn ? "Account" : "Sign in" }} />
22 <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
23 {isLoaded ? (
24 isSignedIn ? (
25 <UserProfileView isDismissible={false} />
26 ) : (
27 <AuthView isDismissible={false} />
28 )
29 ) : null}
30 </View>
31 </>
32 );
33}
apps/mobile/src/app/settings/index.tsx+6-6
...@@ -13,6 +13,7 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li...@@ -13,6 +13,7 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li
13import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";13import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";
14import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";14import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";
15import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState";15import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState";
16import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
16import {17import {
17 hasCloudPublicConfig,18 hasCloudPublicConfig,
18 resolveRelayClerkTokenOptions,19 resolveRelayClerkTokenOptions,
...@@ -66,6 +67,7 @@ function LocalSettingsRouteScreen() {...@@ -66,6 +67,7 @@ function LocalSettingsRouteScreen() {
66function ConfiguredSettingsRouteScreen() {67function ConfiguredSettingsRouteScreen() {
67 const insets = useSafeAreaInsets();68 const insets = useSafeAreaInsets();
68 const { push } = useRouter();69 const { push } = useRouter();
70 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
69 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });71 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
70 const { user } = useUser();72 const { user } = useUser();
71 const { savedConnectionsById } = useRemoteEnvironmentState();73 const { savedConnectionsById } = useRemoteEnvironmentState();
...@@ -265,11 +267,9 @@ function ConfiguredSettingsRouteScreen() {...@@ -265,11 +267,9 @@ function ConfiguredSettingsRouteScreen() {
265 push("/settings/waitlist");267 push("/settings/waitlist");
266 return;268 return;
267 }269 }
268 Alert.alert(270 expandClerkSheet();
269 "T3 Cloud unavailable",271 push("/settings/auth");
270 "Native T3 Cloud account management is not available in this build.",272 }, [expandClerkSheet, isLoaded, isSignedIn, push]);
271 );
272 }, [isLoaded, isSignedIn, push]);
273273
274 return (274 return (
275 <View collapsable={false} className="flex-1 bg-sheet">275 <View collapsable={false} className="flex-1 bg-sheet">
...@@ -335,7 +335,7 @@ type SymbolName = ComponentProps<typeof SymbolView>["name"];...@@ -335,7 +335,7 @@ type SymbolName = ComponentProps<typeof SymbolView>["name"];
335function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {335function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {
336 return (336 return (
337 <View className="gap-2">337 <View className="gap-2">
338 <Text className="px-2 font-t3-bold text-[17px] text-foreground-tertiary">{props.title}</Text>338 <Text className="px-2 text-[13px] font-t3-medium text-foreground-muted">{props.title}</Text>
339 <View339 <View
340 className="overflow-hidden rounded-[28px] bg-card"340 className="overflow-hidden rounded-[28px] bg-card"
341 style={{ borderCurve: "continuous" }}341 style={{ borderCurve: "continuous" }}
apps/mobile/src/app/settings/waitlist.tsx+21-4
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import { Redirect, Stack } from "expo-router";1import { useAuth } from "@clerk/expo";
2import { Redirect, Stack, useFocusEffect, useRouter } from "expo-router";
3import { useCallback } from "react";
2import { ScrollView } from "react-native";4import { ScrollView } from "react-native";
35
4import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment";6import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment";
7import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent";
5import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";8import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
6import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal";
79
8export default function SettingsWaitlistRouteScreen() {10export default function SettingsWaitlistRouteScreen() {
9 return hasCloudPublicConfig() ? (11 return hasCloudPublicConfig() ? (
...@@ -14,7 +16,17 @@ export default function SettingsWaitlistRouteScreen() {...@@ -14,7 +16,17 @@ export default function SettingsWaitlistRouteScreen() {
14}16}
1517
16function ConfiguredSettingsWaitlistRouteScreen() {18function ConfiguredSettingsWaitlistRouteScreen() {
17 const { presentAuth } = useNativeClerkAuthModal();19 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
20 const { expand } = useClerkSettingsSheetDetent();
21 const router = useRouter();
22
23 useFocusEffect(
24 useCallback(() => {
25 if (isLoaded && isSignedIn) {
26 router.replace("/settings");
27 }
28 }, [isLoaded, isSignedIn, router]),
29 );
1830
19 return (31 return (
20 <>32 <>
...@@ -31,7 +43,12 @@ function ConfiguredSettingsWaitlistRouteScreen() {...@@ -31,7 +43,12 @@ function ConfiguredSettingsWaitlistRouteScreen() {
31 keyboardShouldPersistTaps="handled"43 keyboardShouldPersistTaps="handled"
32 showsVerticalScrollIndicator={false}44 showsVerticalScrollIndicator={false}
33 >45 >
34 <CloudWaitlistEnrollment onSignIn={() => void presentAuth()} />46 <CloudWaitlistEnrollment
47 onSignIn={() => {
48 expand();
49 router.push("/settings/auth");
50 }}
51 />
35 </ScrollView>52 </ScrollView>
36 </>53 </>
37 );54 );
apps/mobile/src/components/ComposerEditor.tsx created+6
...@@ -0,0 +1,6 @@
1export { ComposerEditor } from "../native/T3ComposerEditor";
2export type {
3 ComposerEditorHandle,
4 ComposerEditorProps,
5 ComposerEditorSelection,
6} from "../native/T3ComposerEditor";
apps/mobile/src/components/CopyTextButton.tsx created+68
...@@ -0,0 +1,68 @@
1import { SymbolView } from "expo-symbols";
2import { memo, useEffect, useRef, useState } from "react";
3import { Pressable, type ColorValue } from "react-native";
4
5import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
6
7const COPY_FEEDBACK_DURATION_MS = 1200;
8
9export const CopyTextButton = memo(function CopyTextButton(props: {
10 readonly accessibilityLabel: string;
11 readonly text: string;
12 readonly tintColor: ColorValue;
13 readonly copiedTintColor?: ColorValue;
14 readonly backgroundColor?: ColorValue;
15 readonly borderColor?: ColorValue;
16 readonly iconSize?: number;
17 readonly buttonSize?: number;
18}) {
19 const [copied, setCopied] = useState(false);
20 const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21
22 useEffect(
23 () => () => {
24 if (resetTimeoutRef.current) {
25 clearTimeout(resetTimeoutRef.current);
26 }
27 },
28 [],
29 );
30
31 return (
32 <Pressable
33 accessibilityRole="button"
34 accessibilityLabel={copied ? "Copied" : props.accessibilityLabel}
35 disabled={props.text.length === 0}
36 hitSlop={8}
37 onPress={() => {
38 copyTextWithHaptic(props.text);
39 setCopied(true);
40 if (resetTimeoutRef.current) {
41 clearTimeout(resetTimeoutRef.current);
42 }
43 resetTimeoutRef.current = setTimeout(() => {
44 setCopied(false);
45 resetTimeoutRef.current = null;
46 }, COPY_FEEDBACK_DURATION_MS);
47 }}
48 style={({ pressed }) => ({
49 width: props.buttonSize ?? 30,
50 height: props.buttonSize ?? 30,
51 alignItems: "center",
52 justifyContent: "center",
53 borderRadius: 9,
54 borderWidth: props.borderColor ? 1 : 0,
55 borderColor: props.borderColor,
56 backgroundColor: props.backgroundColor,
57 opacity: pressed ? 0.52 : 1,
58 })}
59 >
60 <SymbolView
61 name={copied ? "checkmark" : "doc.on.doc"}
62 size={props.iconSize ?? 13}
63 tintColor={copied ? (props.copiedTintColor ?? props.tintColor) : props.tintColor}
64 type="monochrome"
65 />
66 </Pressable>
67 );
68});
apps/mobile/src/components/GlassSafeAreaView.tsx+8-5
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { ReactNode } from "react";1import type { ReactNode } from "react";
2import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native";2import { View, type StyleProp, type ViewStyle } from "react-native";
3import { useSafeAreaInsets } from "react-native-safe-area-context";3import { useSafeAreaInsets } from "react-native-safe-area-context";
4import { useThemeColor } from "../lib/useThemeColor";
45
5import { GlassSurface } from "./GlassSurface";6import { GlassSurface } from "./GlassSurface";
67
...@@ -17,14 +18,16 @@ export function GlassSafeAreaView({...@@ -17,14 +18,16 @@ export function GlassSafeAreaView({
17 rightSlot,18 rightSlot,
18 style,19 style,
19}: GlassSafeAreaViewProps) {20}: GlassSafeAreaViewProps) {
20 const isDarkMode = useColorScheme() === "dark";
21 const insets = useSafeAreaInsets();21 const insets = useSafeAreaInsets();
22 const headerColor = useThemeColor("--color-header");
23 const headerBorderColor = useThemeColor("--color-header-border");
24 const glassTint = useThemeColor("--color-glass-tint");
22 const headerPaddingTop = insets.top + 16;25 const headerPaddingTop = insets.top + 16;
23 const surfaceStyle = {26 const surfaceStyle = {
24 borderRadius: 0,27 borderRadius: 0,
25 backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)",28 backgroundColor: headerColor,
26 borderBottomWidth: 1,29 borderBottomWidth: 1,
27 borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)",30 borderBottomColor: headerBorderColor,
28 } as const;31 } as const;
2932
30 return (33 return (
...@@ -32,7 +35,7 @@ export function GlassSafeAreaView({...@@ -32,7 +35,7 @@ export function GlassSafeAreaView({
32 <GlassSurface35 <GlassSurface
33 chrome="none"36 chrome="none"
34 glassEffectStyle="regular"37 glassEffectStyle="regular"
35 tintColor={isDarkMode ? "rgba(15,23,42,0.24)" : "rgba(255,255,255,0.18)"}38 tintColor={glassTint}
36 style={{ borderRadius: 0, backgroundColor: "transparent" }}39 style={{ borderRadius: 0, backgroundColor: "transparent" }}
37 >40 >
38 <View41 <View
apps/mobile/src/components/GlassSurface.tsx+17-16
...@@ -1,11 +1,19 @@...@@ -1,11 +1,19 @@
1import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";1import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
2import type { ReactNode } from "react";2import type { ReactNode } from "react";
3import { Platform, useColorScheme, View, type ViewProps, type ViewStyle } from "react-native";3import {
4 Platform,
5 useColorScheme,
6 View,
7 type ColorValue,
8 type ViewProps,
9 type ViewStyle,
10} from "react-native";
11import { useThemeColor } from "../lib/useThemeColor";
412
5export interface GlassSurfaceProps extends ViewProps {13export interface GlassSurfaceProps extends ViewProps {
6 readonly children: ReactNode;14 readonly children: ReactNode;
7 readonly glassEffectStyle?: "clear" | "regular" | "none";15 readonly glassEffectStyle?: "clear" | "regular" | "none";
8 readonly tintColor?: string;16 readonly tintColor?: ColorValue;
9 readonly chrome?: "default" | "none";17 readonly chrome?: "default" | "none";
10}18}
1119
...@@ -18,24 +26,17 @@ export function GlassSurface({...@@ -18,24 +26,17 @@ export function GlassSurface({
18 ...props26 ...props
19}: GlassSurfaceProps) {27}: GlassSurfaceProps) {
20 const isDarkMode = useColorScheme() === "dark";28 const isDarkMode = useColorScheme() === "dark";
29 const borderColor = useThemeColor("--color-border");
30 const glassSurface = useThemeColor("--color-glass-surface");
31 const glassTint = useThemeColor("--color-glass-tint");
21 const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();32 const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();
22 const surfaceStyle: ViewStyle = {33 const surfaceStyle: ViewStyle = {
23 borderRadius: 32,34 borderRadius: 32,
24 overflow: "hidden",35 overflow: "hidden",
25 borderWidth: chrome === "none" ? 0 : 1,36 borderWidth: chrome === "none" ? 0 : 1,
26 borderColor:37 borderColor: chrome === "none" ? "transparent" : borderColor,
27 chrome === "none"38 backgroundColor: chrome === "none" ? "transparent" : glassSurface,
28 ? "transparent"39 shadowColor: chrome === "none" ? "transparent" : "#000000",
29 : isDarkMode
30 ? "rgba(255,255,255,0.08)"
31 : "rgba(226,232,240,0.9)",
32 backgroundColor:
33 chrome === "none"
34 ? "transparent"
35 : isDarkMode
36 ? "rgba(15,23,42,0.78)"
37 : "rgba(255,255,255,0.72)",
38 shadowColor: chrome === "none" ? "transparent" : "#020617",
39 shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08,40 shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08,
40 shadowRadius: chrome === "none" ? 0 : 28,41 shadowRadius: chrome === "none" ? 0 : 28,
41 shadowOffset:42 shadowOffset:
...@@ -56,7 +57,7 @@ export function GlassSurface({...@@ -56,7 +57,7 @@ export function GlassSurface({
56 <GlassView57 <GlassView
57 {...props}58 {...props}
58 glassEffectStyle={glassEffectStyle}59 glassEffectStyle={glassEffectStyle}
59 tintColor={tintColor ?? (isDarkMode ? "rgba(15,23,42,0.18)" : "rgba(255,255,255,0.18)")}60 tintColor={String(tintColor ?? glassTint)}
60 colorScheme={isDarkMode ? "dark" : "light"}61 colorScheme={isDarkMode ? "dark" : "light"}
61 style={[surfaceStyle, style]}62 style={[surfaceStyle, style]}
62 >63 >
apps/mobile/src/components/PierreEntryIcon.tsx created+25
...@@ -0,0 +1,25 @@
1import { SymbolView } from "expo-symbols";
2import { Image, type ImageStyle, type StyleProp } from "react-native";
3
4import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
5import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
6
7export function PierreEntryIcon(props: {
8 readonly path: string;
9 readonly kind: "file" | "directory";
10 readonly size?: number;
11 readonly style?: StyleProp<ImageStyle>;
12}) {
13 const size = props.size ?? 16;
14 if (props.kind === "directory") {
15 return <SymbolView name="folder" size={size} tintColor="#a1a1aa" type="monochrome" />;
16 }
17
18 return (
19 <Image
20 source={markdownFileIconSource(resolveMarkdownFileIcon(props.path))}
21 resizeMode="contain"
22 style={[{ width: size, height: size }, props.style]}
23 />
24 );
25}
apps/mobile/src/components/ProviderIcon.tsx+1-1
...@@ -24,7 +24,7 @@ export function ProviderIcon(props: ProviderIconProps) {...@@ -24,7 +24,7 @@ export function ProviderIcon(props: ProviderIconProps) {
24 return (24 return (
25 <Svg width={size} height={size} viewBox="0 0 256 260" fill="none">25 <Svg width={size} height={size} viewBox="0 0 256 260" fill="none">
26 <Path26 <Path
27 fill={isDarkMode ? "#e2e8f0" : "#171717"}27 fill={isDarkMode ? "#e5e5e5" : "#171717"}
28 d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"28 d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"
29 />29 />
30 </Svg>30 </Svg>
apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx created+44
...@@ -0,0 +1,44 @@
1import {
2 createContext,
3 type PropsWithChildren,
4 useCallback,
5 useContext,
6 useMemo,
7 useState,
8} from "react";
9
10interface ClerkSettingsSheetDetentValue {
11 collapse: () => void;
12 expand: () => void;
13 isExpanded: boolean;
14}
15
16const ClerkSettingsSheetDetentContext = createContext<ClerkSettingsSheetDetentValue | null>(null);
17
18interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren {
19 initiallyExpanded: boolean;
20}
21
22export function ClerkSettingsSheetDetentProvider({
23 children,
24 initiallyExpanded,
25}: ClerkSettingsSheetDetentProviderProps) {
26 const [isExpanded, setIsExpanded] = useState(initiallyExpanded);
27 const collapse = useCallback(() => setIsExpanded(false), []);
28 const expand = useCallback(() => setIsExpanded(true), []);
29 const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]);
30
31 return (
32 <ClerkSettingsSheetDetentContext value={value}>{children}</ClerkSettingsSheetDetentContext>
33 );
34}
35
36export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue {
37 const value = useContext(ClerkSettingsSheetDetentContext);
38 if (!value) {
39 throw new Error(
40 "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider",
41 );
42 }
43 return value;
44}
apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts deleted-134
...@@ -1,134 +0,0 @@
1import { getClerkInstance } from "@clerk/expo";
2import { tokenCache } from "@clerk/expo/token-cache";
3import * as Data from "effect/Data";
4import { useCallback, useRef } from "react";
5import type { TurboModule } from "react-native";
6import { TurboModuleRegistry } from "react-native";
7
8const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt";
9
10interface NativeClerkModule extends TurboModule {
11 readonly getClientToken?: () => Promise<string | null>;
12 readonly presentAuth?: (options: {
13 readonly dismissable: boolean;
14 readonly mode: "signInOrUp";
15 }) => Promise<NativeAuthResult | null>;
16}
17
18interface NativeAuthResult {
19 readonly cancelled?: boolean;
20 readonly session?: {
21 readonly id?: string;
22 };
23 readonly sessionId?: string;
24}
25
26interface ClerkWithNativeSync {
27 readonly __internal_reloadInitialResources?: () => Promise<void>;
28 readonly setActive?: (params: { readonly session: string }) => Promise<void>;
29}
30
31const NativeClerk = TurboModuleRegistry.get<NativeClerkModule>("ClerkExpo");
32
33class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{
34 readonly message: string;
35 readonly cause?: unknown;
36}> {}
37
38async function syncNativeSession(sessionId: string): Promise<void> {
39 const getClientToken = NativeClerk?.getClientToken;
40 let nativeClientToken: string | null = null;
41 if (getClientToken) {
42 try {
43 nativeClientToken = await getClientToken();
44 } catch (cause) {
45 throw new NativeClerkAuthError({
46 message: "Could not read native Clerk client token.",
47 cause,
48 });
49 }
50 }
51 if (nativeClientToken) {
52 const saveToken = tokenCache?.saveToken;
53 if (saveToken) {
54 try {
55 await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken);
56 } catch (cause) {
57 throw new NativeClerkAuthError({
58 message: "Could not save native Clerk client token.",
59 cause,
60 });
61 }
62 }
63 }
64
65 const clerk = getClerkInstance();
66 const clerkWithNativeSync = clerk as ClerkWithNativeSync;
67 const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources;
68 if (reloadInitialResources) {
69 try {
70 await reloadInitialResources();
71 } catch (cause) {
72 throw new NativeClerkAuthError({
73 message: "Could not reload Clerk resources after native auth.",
74 cause,
75 });
76 }
77 }
78 const setActive = clerkWithNativeSync.setActive;
79 if (setActive) {
80 try {
81 await setActive({ session: sessionId });
82 } catch (cause) {
83 throw new NativeClerkAuthError({
84 message: "Could not activate native Clerk session.",
85 cause,
86 });
87 }
88 }
89}
90
91export function useNativeClerkAuthModal() {
92 const presentingRef = useRef(false);
93
94 const presentAuth = useCallback(async (): Promise<void> => {
95 if (presentingRef.current || !NativeClerk?.presentAuth) {
96 return;
97 }
98
99 presentingRef.current = true;
100 const presentNativeAuth = NativeClerk.presentAuth;
101 try {
102 // Clerk's iOS AuthView is not inline. It presents this same native modal
103 // internally; call the presenter directly so Expo Router does not render
104 // an empty formSheet behind it.
105 let result: NativeAuthResult | null;
106 try {
107 result = await presentNativeAuth({
108 dismissable: true,
109 mode: "signInOrUp",
110 });
111 } catch (cause) {
112 throw new NativeClerkAuthError({
113 message: "Native Clerk auth presentation failed.",
114 cause,
115 });
116 }
117 const sessionId = result?.sessionId ?? result?.session?.id ?? null;
118 if (sessionId && !result?.cancelled) {
119 await syncNativeSession(sessionId);
120 }
121 } catch (error) {
122 if (__DEV__) {
123 console.error("[useNativeClerkAuthModal] presentAuth failed:", error);
124 }
125 } finally {
126 presentingRef.current = false;
127 }
128 }, []);
129
130 return {
131 isAvailable: !!NativeClerk?.presentAuth,
132 presentAuth,
133 };
134}
apps/mobile/src/features/home/HomeScreen.tsx+6-5
...@@ -146,8 +146,8 @@ function ProjectGroupLabel(props: {...@@ -146,8 +146,8 @@ function ProjectGroupLabel(props: {
146 bearerToken={props.bearerToken}146 bearerToken={props.bearerToken}
147 />147 />
148 <Text148 <Text
149 className="flex-1 text-[13px] font-t3-bold uppercase text-foreground-muted"149 className="flex-1 text-[12px] font-t3-medium uppercase text-foreground-muted"
150 style={{ letterSpacing: 0.6 }}150 style={{ letterSpacing: 0.5 }}
151 numberOfLines={1}151 numberOfLines={1}
152 >152 >
153 {props.project.title}153 {props.project.title}
...@@ -156,8 +156,8 @@ function ProjectGroupLabel(props: {...@@ -156,8 +156,8 @@ function ProjectGroupLabel(props: {
156 {hiddenCount > 0 ? (156 {hiddenCount > 0 ? (
157 <Pressable onPress={props.onToggleExpand} hitSlop={8}>157 <Pressable onPress={props.onToggleExpand} hitSlop={8}>
158 <Text158 <Text
159 className="text-[13px] font-t3-bold text-foreground-muted"159 className="text-[12px] font-t3-medium text-foreground-muted"
160 style={{ letterSpacing: 0.6 }}160 style={{ letterSpacing: 0.4 }}
161 >161 >
162 {props.isExpanded ? "Show less" : `${hiddenCount} more`}162 {props.isExpanded ? "Show less" : `${hiddenCount} more`}
163 </Text>163 </Text>
...@@ -191,6 +191,7 @@ function ThreadRow(props: {...@@ -191,6 +191,7 @@ function ThreadRow(props: {
191 readonly isLast: boolean;191 readonly isLast: boolean;
192}) {192}) {
193 const separatorColor = useThemeColor("--color-separator");193 const separatorColor = useThemeColor("--color-separator");
194 const iconSubtleColor = useThemeColor("--color-icon-subtle");
194 const { bg, fg } = statusColors(props.thread);195 const { bg, fg } = statusColors(props.thread);
195 const tone = threadStatusTone(props.thread);196 const tone = threadStatusTone(props.thread);
196 const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt);197 const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt);
...@@ -267,7 +268,7 @@ function ThreadRow(props: {...@@ -267,7 +268,7 @@ function ThreadRow(props: {
267 <SymbolView268 <SymbolView
268 name="arrow.triangle.branch"269 name="arrow.triangle.branch"
269 size={10}270 size={10}
270 tintColor="#737373"271 tintColor={iconSubtleColor}
271 type="monochrome"272 type="monochrome"
272 />273 />
273 <Text274 <Text
apps/mobile/src/features/review/shikiReviewHighlighter.test.ts+20-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import type { ReviewRenderableFile } from "./reviewModel";3import type { ReviewRenderableFile } from "./reviewModel";
4import { highlightReviewFile } from "./shikiReviewHighlighter";4import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter";
55
6function makeRenderableFile(6function makeRenderableFile(
7 input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">,7 input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">,
...@@ -119,3 +119,22 @@ describe("highlightReviewFile", () => {...@@ -119,3 +119,22 @@ describe("highlightReviewFile", () => {
119 ]);119 ]);
120 });120 });
121});121});
122
123describe("highlightCodeSnippet", () => {
124 it("resolves language aliases and returns syntax-colored tokens", async () => {
125 const source = "const answer: number = 42;";
126 const highlighted = await highlightCodeSnippet({
127 code: source,
128 language: "ts",
129 theme: "dark",
130 });
131
132 expect(
133 highlighted
134 .flat()
135 .map((token) => token.content)
136 .join(""),
137 ).toBe(source);
138 expect(highlighted.flat().some((token) => token.color !== null)).toBe(true);
139 });
140});
apps/mobile/src/features/review/shikiReviewHighlighter.ts+10
...@@ -685,6 +685,16 @@ async function highlightLines(...@@ -685,6 +685,16 @@ async function highlightLines(
685 return highlightedLines;685 return highlightedLines;
686}686}
687687
688export async function highlightCodeSnippet(input: {
689 readonly code: string;
690 readonly language?: string | null;
691 readonly theme: ReviewDiffTheme;
692}): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> {
693 const languageHint = input.language?.trim() || "text";
694 const language = await resolveLanguageFromPath(`snippet.${languageHint}`, languageHint);
695 return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]);
696}
697
688async function highlightPatchLinesInChunks(input: {698async function highlightPatchLinesInChunks(input: {
689 readonly lines: ReadonlyArray<string>;699 readonly lines: ReadonlyArray<string>;
690 readonly language: string;700 readonly language: string;
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+8-3
...@@ -6,6 +6,7 @@ import { memo } from "react";...@@ -6,6 +6,7 @@ import { memo } from "react";
6import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native";6import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native";
77
8import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
9import { PierreEntryIcon } from "../../components/PierreEntryIcon";
910
10export type ComposerCommandItem =11export type ComposerCommandItem =
11 | {12 | {
...@@ -88,13 +89,13 @@ function PopoverSurface(props: {...@@ -88,13 +89,13 @@ function PopoverSurface(props: {
8889
89function itemIcon(item: ComposerCommandItem) {90function itemIcon(item: ComposerCommandItem) {
90 switch (item.type) {91 switch (item.type) {
91 case "path":
92 return item.kind === "directory" ? ("folder" as const) : ("doc" as const);
93 case "slash-command":92 case "slash-command":
94 case "provider-slash-command":93 case "provider-slash-command":
95 return "terminal" as const;94 return "terminal" as const;
96 case "skill":95 case "skill":
97 return "cube" as const;96 return "cube" as const;
97 case "path":
98 return null;
98 }99 }
99}100}
100101
...@@ -149,7 +150,11 @@ const CommandRow = memo(function CommandRow(props: {...@@ -149,7 +150,11 @@ const CommandRow = memo(function CommandRow(props: {
149 borderBottomColor: "rgba(255,255,255,0.1)",150 borderBottomColor: "rgba(255,255,255,0.1)",
150 })}151 })}
151 >152 >
152 <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" />153 {props.item.type === "path" ? (
154 <PierreEntryIcon path={props.item.path} kind={props.item.kind} size={16} />
155 ) : iconName ? (
156 <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" />
157 ) : null}
153 <Text158 <Text
154 className="text-[14px] font-t3-medium text-foreground"159 className="text-[14px] font-t3-medium text-foreground"
155 numberOfLines={1}160 numberOfLines={1}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+15-30
...@@ -1,19 +1,13 @@...@@ -1,19 +1,13 @@
1import { Stack, useRouter } from "expo-router";1import { Stack, useRouter } from "expo-router";
2import { TextInputWrapper } from "expo-paste-input";
3import { useCallback, useEffect, useMemo, useRef } from "react";2import { useCallback, useEffect, useMemo, useRef } from "react";
4import {3import { InteractionManager, View, useColorScheme } from "react-native";
5 InteractionManager,
6 View,
7 useColorScheme,
8 type TextInput as RNTextInput,
9} from "react-native";
10import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";4import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
11import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
12import { useThemeColor } from "../../lib/useThemeColor";6import { useThemeColor } from "../../lib/useThemeColor";
137
14import { EnvironmentId, type ModelSelection } from "@t3tools/contracts";8import { EnvironmentId, type ModelSelection } from "@t3tools/contracts";
159
16import { AppTextInput as TextInput } from "../../components/AppText";10import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
17import {11import {
18 ComposerToolbarButton,12 ComposerToolbarButton,
19 ComposerToolbarRow,13 ComposerToolbarRow,
...@@ -27,7 +21,6 @@ import { ProviderIcon } from "../../components/ProviderIcon";...@@ -27,7 +21,6 @@ import { ProviderIcon } from "../../components/ProviderIcon";
27import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";21import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
28import { buildThreadRoutePath } from "../../lib/routes";22import { buildThreadRoutePath } from "../../lib/routes";
29import { useRemoteCatalog } from "../../state/use-remote-catalog";23import { useRemoteCatalog } from "../../state/use-remote-catalog";
30import { useNativePaste } from "../../lib/useNativePaste";
31import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";24import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";
32import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";25import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
33import { useProjectActions } from "./use-project-actions";26import { useProjectActions } from "./use-project-actions";
...@@ -75,7 +68,7 @@ export function NewTaskDraftScreen(props: {...@@ -75,7 +68,7 @@ export function NewTaskDraftScreen(props: {
75 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);68 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
76 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);69 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
77 const { logicalProjects, selectedProject, setProject } = flow;70 const { logicalProjects, selectedProject, setProject } = flow;
78 const promptInputRef = useRef<RNTextInput>(null);71 const promptInputRef = useRef<ComposerEditorHandle>(null);
7972
80 const borderColor = useThemeColor("--color-border");73 const borderColor = useThemeColor("--color-border");
81 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";74 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
...@@ -410,10 +403,6 @@ export function NewTaskDraftScreen(props: {...@@ -410,10 +403,6 @@ export function NewTaskDraftScreen(props: {
410 [flow],403 [flow],
411 );404 );
412405
413 const handleNativePaste = useNativePaste((uris) => {
414 void handleNativePasteImages(uris);
415 });
416
417 async function handleStart(): Promise<void> {406 async function handleStart(): Promise<void> {
418 if (407 if (
419 !flow.selectedProject ||408 !flow.selectedProject ||
...@@ -478,23 +467,19 @@ export function NewTaskDraftScreen(props: {...@@ -478,23 +467,19 @@ export function NewTaskDraftScreen(props: {
478467
479 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>468 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>
480 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>469 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>
481 <TextInputWrapper470 <ComposerEditor
482 onPaste={(payload) => void handleNativePaste(payload)}471 ref={promptInputRef}
472 autoFocus
473 multiline
474 scrollEnabled
475 value={flow.prompt}
476 skills={flow.selectedProviderSkills}
477 onChangeText={flow.setPrompt}
478 onPasteImages={(uris) => void handleNativePasteImages(uris)}
479 placeholder={`Describe a coding task in ${selectedProject.title}`}
483 style={{ flex: 1, minHeight: 0 }}480 style={{ flex: 1, minHeight: 0 }}
484 >481 textStyle={{ fontSize: 18, lineHeight: 28 }}
485 <TextInput482 />
486 ref={promptInputRef}
487 autoFocus
488 multiline
489 scrollEnabled
490 value={flow.prompt}
491 onChangeText={flow.setPrompt}
492 placeholder={`Describe a coding task in ${selectedProject.title}`}
493 textAlignVertical="top"
494 className="h-full flex-1 border-0 bg-transparent text-[18px] leading-[28px]"
495 style={{ flex: 1, minHeight: 0 }}
496 />
497 </TextInputWrapper>
498 </View>483 </View>
499484
500 <View485 <View
apps/mobile/src/features/threads/ThreadComposer.tsx+82-85
...@@ -13,24 +13,19 @@ import {...@@ -13,24 +13,19 @@ import {
13 serializeComposerFileLink,13 serializeComposerFileLink,
14 type ComposerTrigger,14 type ComposerTrigger,
15} from "@t3tools/shared/composerTrigger";15} from "@t3tools/shared/composerTrigger";
16import { TextInputWrapper } from "expo-paste-input";
17import type { ReactNode } from "react";16import type { ReactNode } from "react";
18import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";17import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
19import {18import { Image, Pressable, useColorScheme, View, type ViewStyle } from "react-native";
20 Image,
21 Pressable,
22 TextInput as RNTextInput,
23 useColorScheme,
24 View,
25 type NativeSyntheticEvent,
26 type TextInputSelectionChangeEventData,
27 type ViewStyle,
28} from "react-native";
29import ImageViewing from "react-native-image-viewing";19import ImageViewing from "react-native-image-viewing";
30import { useThemeColor } from "../../lib/useThemeColor";20import { useThemeColor } from "../../lib/useThemeColor";
3121
32import { AppText as Text } from "../../components/AppText";22import { AppText as Text } from "../../components/AppText";
33import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";23import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
24import {
25 ComposerEditor,
26 type ComposerEditorHandle,
27 type ComposerEditorSelection,
28} from "../../components/ComposerEditor";
34import {29import {
35 ComposerToolbarButton,30 ComposerToolbarButton,
36 ComposerToolbarRow,31 ComposerToolbarRow,
...@@ -42,7 +37,6 @@ import { ProviderIcon } from "../../components/ProviderIcon";...@@ -42,7 +37,6 @@ import { ProviderIcon } from "../../components/ProviderIcon";
42import type { DraftComposerImageAttachment } from "../../lib/composerImages";37import type { DraftComposerImageAttachment } from "../../lib/composerImages";
43import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";38import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
44import type { RemoteClientConnectionState } from "../../lib/connection";39import type { RemoteClientConnectionState } from "../../lib/connection";
45import { useNativePaste } from "../../lib/useNativePaste";
46import {40import {
47 insertRankedSearchResult,41 insertRankedSearchResult,
48 normalizeSearchQuery,42 normalizeSearchQuery,
...@@ -60,7 +54,7 @@ import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerComm...@@ -60,7 +54,7 @@ import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerComm
60 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).54 * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
61 * Exported so the parent can compute feed overlap / content insets.55 * Exported so the parent can compute feed overlap / content insets.
62 */56 */
63export const COMPOSER_COLLAPSED_CHROME = 68;57export const COMPOSER_COLLAPSED_CHROME = 60;
6458
65/**59/**
66 * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset).60 * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset).
...@@ -68,13 +62,6 @@ export const COMPOSER_COLLAPSED_CHROME = 68;...@@ -68,13 +62,6 @@ export const COMPOSER_COLLAPSED_CHROME = 68;
68 */62 */
69export const COMPOSER_EXPANDED_CHROME = 174;63export const COMPOSER_EXPANDED_CHROME = 174;
7064
71/**
72 * Height of the expanded-only toolbar below the text surface.
73 * Used by the feed inset because KeyboardAvoidingLegendList only accounts for
74 * keyboard height; the floating toolbar remains an additional overlay.
75 */
76export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 60;
77
78export interface ThreadComposerProps {65export interface ThreadComposerProps {
79 readonly draftMessage: string;66 readonly draftMessage: string;
80 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;67 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
...@@ -87,6 +74,7 @@ export interface ThreadComposerProps {...@@ -87,6 +74,7 @@ export interface ThreadComposerProps {
87 readonly activeThreadBusy: boolean;74 readonly activeThreadBusy: boolean;
88 readonly environmentId: EnvironmentId;75 readonly environmentId: EnvironmentId;
89 readonly projectCwd: string | null;76 readonly projectCwd: string | null;
77 readonly editorRef?: RefObject<ComposerEditorHandle | null>;
90 readonly onChangeDraftMessage: (value: string) => void;78 readonly onChangeDraftMessage: (value: string) => void;
91 readonly onPickDraftImages: () => Promise<void>;79 readonly onPickDraftImages: () => Promise<void>;
92 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;80 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
...@@ -156,10 +144,9 @@ function formatTitleCase(value: string): string {...@@ -156,10 +144,9 @@ function formatTitleCase(value: string): string {
156144
157export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {145export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
158 const isDarkMode = useColorScheme() === "dark";146 const isDarkMode = useColorScheme() === "dark";
159 const themePlaceholderColor = useThemeColor("--color-placeholder");
160 const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor;
161 const foregroundColor = useThemeColor("--color-foreground");147 const foregroundColor = useThemeColor("--color-foreground");
162 const inputRef = useRef<RNTextInput>(null);148 const fallbackInputRef = useRef<ComposerEditorHandle>(null);
149 const inputRef = props.editorRef ?? fallbackInputRef;
163 const [isFocused, setIsFocused] = useState(false);150 const [isFocused, setIsFocused] = useState(false);
164 const wasExpandedBeforePreviewRef = useRef(false);151 const wasExpandedBeforePreviewRef = useRef(false);
165 const { onExpandedChange } = props;152 const { onExpandedChange } = props;
...@@ -182,11 +169,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -182,11 +169,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
182 if (wasExpandedBeforePreviewRef.current) {169 if (wasExpandedBeforePreviewRef.current) {
183 setTimeout(() => inputRef.current?.focus(), 100);170 setTimeout(() => inputRef.current?.focus(), 100);
184 }171 }
185 }, []);172 }, [inputRef]);
186173
187 useEffect(() => {174 const handleFocus = useCallback(() => {
188 onExpandedChange?.(isExpanded);175 setIsFocused(true);
189 }, [isExpanded, onExpandedChange]);176 onExpandedChange?.(true);
177 }, [onExpandedChange]);
178
179 const handleBlur = useCallback(() => {
180 setIsFocused(false);
181 onExpandedChange?.(false);
182 }, [onExpandedChange]);
190 const showStopAction =183 const showStopAction =
191 props.selectedThread.session?.status === "running" ||184 props.selectedThread.session?.status === "running" ||
192 props.selectedThread.session?.status === "starting" ||185 props.selectedThread.session?.status === "starting" ||
...@@ -219,26 +212,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -219,26 +212,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
219 selectedProviderDriver === "claudeAgent"212 selectedProviderDriver === "claudeAgent"
220 ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M")213 ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M")
221 : "1M";214 : "1M";
222
223 const handleNativePaste = useNativePaste((uris) => {
224 void props.onNativePasteImages(uris);
225 });
226
227 // ── Trigger detection ────────────────────────────────────215 // ── Trigger detection ────────────────────────────────────
228 const [cursorPosition, setCursorPosition] = useState(0);216 const [composerSelection, setComposerSelection] = useState(() => ({
217 start: props.draftMessage.length,
218 end: props.draftMessage.length,
219 }));
229220
230 const handleSelectionChange = useCallback(221 const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => {
231 (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {222 setComposerSelection(selection);
232 const { start } = event.nativeEvent.selection;223 }, []);
233 setCursorPosition(start);224 useEffect(() => {
234 },225 const end = props.draftMessage.length;
235 [],226 setComposerSelection((selection) => {
236 );227 const start = Math.min(selection.start, end);
228 const selectionEnd = Math.min(selection.end, end);
229 if (start === selection.start && selectionEnd === selection.end) {
230 return selection;
231 }
232 return { start, end: selectionEnd };
233 });
234 }, [props.draftMessage.length]);
237235
238 const composerTrigger = useMemo<ComposerTrigger | null>(236 const composerTrigger = useMemo<ComposerTrigger | null>(() => {
239 () => detectComposerTrigger(props.draftMessage, cursorPosition),237 if (composerSelection.start !== composerSelection.end) {
240 [cursorPosition, props.draftMessage],238 return null;
241 );239 }
240 return detectComposerTrigger(props.draftMessage, composerSelection.end);
241 }, [composerSelection, props.draftMessage]);
242 const pathSearch = useComposerPathSearch({242 const pathSearch = useComposerPathSearch({
243 environmentId: props.environmentId,243 environmentId: props.environmentId,
244 cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,244 cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,
...@@ -411,7 +411,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -411,7 +411,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
411 composerTrigger.rangeEnd,411 composerTrigger.rangeEnd,
412 "",412 "",
413 );413 );
414 setCursorPosition(result.cursor);414 setComposerSelection({ start: result.cursor, end: result.cursor });
415 onChangeDraftMessage(result.text);415 onChangeDraftMessage(result.text);
416 void onUpdateInteractionMode(item.command);416 void onUpdateInteractionMode(item.command);
417 return;417 return;
...@@ -434,7 +434,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -434,7 +434,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
434 composerTrigger.rangeEnd,434 composerTrigger.rangeEnd,
435 replacement,435 replacement,
436 );436 );
437 setCursorPosition(result.cursor);437 setComposerSelection({ start: result.cursor, end: result.cursor });
438 onChangeDraftMessage(result.text);438 onChangeDraftMessage(result.text);
439 },439 },
440 [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode],440 [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode],
...@@ -624,8 +624,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -624,8 +624,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
624 <View624 <View
625 style={{625 style={{
626 paddingHorizontal: 16,626 paddingHorizontal: 16,
627 paddingTop: isExpanded ? 12 : 10,627 paddingTop: isExpanded ? 8 : 6,
628 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10),628 paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 8 : 6),
629 experimental_backgroundImage: isDarkMode629 experimental_backgroundImage: isDarkMode
630 ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)"630 ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)"
631 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",631 : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)",
...@@ -685,42 +685,39 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -685,42 +685,39 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
685 ) : null}685 ) : null}
686686
687 <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}>687 <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}>
688 <TextInputWrapper onPaste={handleNativePaste}>688 <ComposerEditor
689 <RNTextInput689 ref={inputRef}
690 ref={inputRef}690 multiline
691 multiline691 value={props.draftMessage}
692 value={props.draftMessage}692 skills={selectedProviderStatus?.skills ?? []}
693 onChangeText={props.onChangeDraftMessage}693 selection={composerSelection}
694 onSelectionChange={handleSelectionChange}694 onChangeText={props.onChangeDraftMessage}
695 placeholder={props.placeholder}695 onSelectionChange={handleSelectionChange}
696 placeholderTextColor={placeholderColor}696 onPasteImages={(uris) => void props.onNativePasteImages(uris)}
697 editable={props.connectionState === "ready"}697 placeholder={props.placeholder}
698 onFocus={() => setIsFocused(true)}698 onFocus={handleFocus}
699 onBlur={() => setIsFocused(false)}699 onBlur={handleBlur}
700 textAlignVertical={isExpanded ? "top" : "center"}700 scrollEnabled={isExpanded}
701 style={701 contentInsetVertical={isExpanded ? 0 : 6}
702 isExpanded702 style={
703 ? {703 isExpanded
704 minHeight: 80,704 ? {
705 maxHeight: 160,705 minHeight: 80,
706 paddingHorizontal: 4,706 maxHeight: 160,
707 paddingVertical: 4,707 paddingHorizontal: 4,
708 fontSize: 15,708 paddingVertical: 4,
709 lineHeight: 22,709 }
710 color: foregroundColor,710 : {
711 fontFamily: "DMSans_400Regular",711 height: 36,
712 }712 }
713 : {713 }
714 maxHeight: 36,714 textStyle={{
715 paddingVertical: 6,715 fontSize: 15,
716 fontSize: 15,716 lineHeight: isExpanded ? 22 : 20,
717 lineHeight: 20,717 color: foregroundColor,
718 color: foregroundColor,718 fontFamily: "DMSans_400Regular",
719 fontFamily: "DMSans_400Regular",719 }}
720 }720 />
721 }
722 />
723 </TextInputWrapper>
724 </View>721 </View>
725 {!isExpanded && props.draftAttachments.length > 0 ? (722 {!isExpanded && props.draftAttachments.length > 0 ? (
726 <View style={{ flexDirection: "row", gap: 4, paddingLeft: 4 }}>723 <View style={{ flexDirection: "row", gap: 4, paddingLeft: 4 }}>
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+76-15
...@@ -11,17 +11,20 @@ import type {...@@ -11,17 +11,20 @@ import type {
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import { formatElapsed } from "@t3tools/shared/orchestrationTiming";12import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
13import * as Haptics from "expo-haptics";13import * as Haptics from "expo-haptics";
14import { useHeaderHeight } from "expo-router/build/react-navigation/elements";
14import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";15import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
15import { View, type LayoutChangeEvent } from "react-native";16import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native";
16import { Gesture, GestureDetector } from "react-native-gesture-handler";17import { Gesture, GestureDetector } from "react-native-gesture-handler";
17import { KeyboardStickyView } from "react-native-keyboard-controller";18import { KeyboardStickyView } from "react-native-keyboard-controller";
18import { useSafeAreaInsets } from "react-native-safe-area-context";19import { useSafeAreaInsets } from "react-native-safe-area-context";
19import { runOnJS } from "react-native-reanimated";20import { runOnJS } from "react-native-reanimated";
2021
21import { AppText as Text } from "../../components/AppText";22import { AppText as Text } from "../../components/AppText";
23import type { ComposerEditorHandle } from "../../components/ComposerEditor";
22import type { StatusTone } from "../../components/StatusPill";24import type { StatusTone } from "../../components/StatusPill";
23import type { DraftComposerImageAttachment } from "../../lib/composerImages";25import type { DraftComposerImageAttachment } from "../../lib/composerImages";
24import type { MobileLayoutVariant } from "../../lib/mobileLayout";26import type { MobileLayoutVariant } from "../../lib/mobileLayout";
27import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout";
25import type {28import type {
26 PendingApproval,29 PendingApproval,
27 PendingUserInput,30 PendingUserInput,
...@@ -33,7 +36,6 @@ import { PendingUserInputCard } from "./PendingUserInputCard";...@@ -33,7 +36,6 @@ import { PendingUserInputCard } from "./PendingUserInputCard";
33import {36import {
34 COMPOSER_COLLAPSED_CHROME,37 COMPOSER_COLLAPSED_CHROME,
35 COMPOSER_EXPANDED_CHROME,38 COMPOSER_EXPANDED_CHROME,
36 COMPOSER_EXPANDED_TOOLBAR_CHROME,
37 ThreadComposer,39 ThreadComposer,
38} from "./ThreadComposer";40} from "./ThreadComposer";
39import { ThreadFeed } from "./ThreadFeed";41import { ThreadFeed } from "./ThreadFeed";
...@@ -200,7 +202,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -200,7 +202,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
200 const { onOpenDrawer } = props;202 const { onOpenDrawer } = props;
201203
202 const insets = useSafeAreaInsets();204 const insets = useSafeAreaInsets();
205 const headerHeight = useHeaderHeight();
203 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;206 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
207 const composerRef = useRef<ComposerEditorHandle>(null);
208 const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);
204 const [composerExpanded, setComposerExpanded] = useState(false);209 const [composerExpanded, setComposerExpanded] = useState(false);
205 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);210 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
206 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;211 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
...@@ -211,10 +216,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -211,10 +216,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
211 const showContent = props.showContent ?? true;216 const showContent = props.showContent ?? true;
212 const layoutVariant = props.layoutVariant ?? "compact";217 const layoutVariant = props.layoutVariant ?? "compact";
213 const isSplitLayout = layoutVariant === "split";218 const isSplitLayout = layoutVariant === "split";
219 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
214 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);220 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
215 const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0;221 const feedBottomInset = resolveThreadFeedBottomInset({
216 const feedBottomInset =222 estimatedOverlayHeight,
217 Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8;223 measuredOverlayHeight,
224 gap: 8,
225 });
226 const selectedProviderSkills = useMemo(
227 () =>
228 props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)
229 ?.skills ?? [],
230 [props.serverConfig, selectedInstanceId],
231 );
218232
219 const completeDrawerGesture = useCallback(() => {233 const completeDrawerGesture = useCallback(() => {
220 void Haptics.selectionAsync();234 void Haptics.selectionAsync();
...@@ -245,20 +259,66 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -245,20 +259,66 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
245 );259 );
246 }, []);260 }, []);
247261
262 const collapseComposer = useCallback(() => {
263 composerRef.current?.blur();
264 }, []);
265
266 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
267 feedTouchStartRef.current = {
268 pageX: event.nativeEvent.pageX,
269 pageY: event.nativeEvent.pageY,
270 };
271 }, []);
272
273 const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => {
274 const start = feedTouchStartRef.current;
275 if (!start) {
276 return;
277 }
278 const deltaX = event.nativeEvent.pageX - start.pageX;
279 const deltaY = event.nativeEvent.pageY - start.pageY;
280 if (Math.hypot(deltaX, deltaY) > 8) {
281 feedTouchStartRef.current = null;
282 }
283 }, []);
284
285 const handleFeedTouchEnd = useCallback(() => {
286 if (feedTouchStartRef.current) {
287 collapseComposer();
288 }
289 feedTouchStartRef.current = null;
290 }, [collapseComposer]);
291
292 const handleFeedTouchCancel = useCallback(() => {
293 feedTouchStartRef.current = null;
294 }, []);
295
248 return (296 return (
249 <GestureDetector gesture={headerDrawerGesture}>297 <GestureDetector gesture={headerDrawerGesture}>
250 <View className="flex-1">298 <View className="flex-1">
251 {showContent ? (299 {showContent ? (
252 <ThreadFeed300 <View
253 threadId={props.selectedThread.id}301 style={{ flex: 1 }}
254 feed={props.selectedThreadFeed}302 onTouchStart={handleFeedTouchStart}
255 httpBaseUrl={props.httpBaseUrl}303 onTouchMove={handleFeedTouchMove}
256 bearerToken={props.bearerToken}304 onTouchEnd={handleFeedTouchEnd}
257 agentLabel={agentLabel}305 onTouchCancel={handleFeedTouchCancel}
258 contentBottomInset={feedBottomInset}306 >
259 layoutVariant={layoutVariant}307 <ThreadFeed
260 composerExpanded={composerExpanded}308 key={props.selectedThread.id}
261 />309 threadId={props.selectedThread.id}
310 feed={props.selectedThreadFeed}
311 httpBaseUrl={props.httpBaseUrl}
312 bearerToken={props.bearerToken}
313 agentLabel={agentLabel}
314 latestTurn={props.selectedThread.latestTurn}
315 contentTopInset={headerHeight}
316 contentBottomInset={feedBottomInset}
317 layoutVariant={layoutVariant}
318 composerExpanded={composerExpanded}
319 skills={selectedProviderSkills}
320 />
321 </View>
262 ) : (322 ) : (
263 <View style={{ flex: 1 }} />323 <View style={{ flex: 1 }} />
264 )}324 )}
...@@ -298,6 +358,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -298,6 +358,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
298 ) : null}358 ) : null}
299359
300 <ThreadComposer360 <ThreadComposer
361 editorRef={composerRef}
301 draftMessage={props.draftMessage}362 draftMessage={props.draftMessage}
302 draftAttachments={props.draftAttachments}363 draftAttachments={props.draftAttachments}
303 placeholder="Ask the repo agent, or run a command…"364 placeholder="Ask the repo agent, or run a command…"
apps/mobile/src/features/threads/ThreadFeed.tsx+790-205
...@@ -2,9 +2,9 @@ import * as Clipboard from "expo-clipboard";...@@ -2,9 +2,9 @@ import * as Clipboard from "expo-clipboard";
2import * as Haptics from "expo-haptics";2import * as Haptics from "expo-haptics";
3import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";3import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";
4import { type LegendListRef } from "@legendapp/list/react-native";4import { type LegendListRef } from "@legendapp/list/react-native";
5import type { ThreadId } from "@t3tools/contracts";5import type { ThreadId, TurnId } from "@t3tools/contracts";
6import { SymbolView } from "expo-symbols";6import { SymbolView } from "expo-symbols";
7import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";7import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
8import {8import {
9 Markdown,9 Markdown,
10 type CustomRenderers,10 type CustomRenderers,
...@@ -13,6 +13,9 @@ import {...@@ -13,6 +13,9 @@ import {
13} from "react-native-nitro-markdown";13} from "react-native-nitro-markdown";
14import {14import {
15 Image,15 Image,
16 Linking,
17 type NativeScrollEvent,
18 type NativeSyntheticEvent,
16 Pressable,19 Pressable,
17 ScrollView,20 ScrollView,
18 StyleSheet,21 StyleSheet,
...@@ -26,9 +29,16 @@ import { TouchableOpacity } from "react-native-gesture-handler";...@@ -26,9 +29,16 @@ import { TouchableOpacity } from "react-native-gesture-handler";
26import ImageViewing from "react-native-image-viewing";29import ImageViewing from "react-native-image-viewing";
27import { useSafeAreaInsets } from "react-native-safe-area-context";30import { useSafeAreaInsets } from "react-native-safe-area-context";
28import { useThemeColor } from "../../lib/useThemeColor";31import { useThemeColor } from "../../lib/useThemeColor";
32import {
33 hasNativeSelectableMarkdownText,
34 SelectableMarkdownText,
35 type NativeMarkdownTextStyle,
36 type SelectableMarkdownSkill,
37} from "../../native/SelectableMarkdownText";
2938
30import { AppText as Text } from "../../components/AppText";39import { AppText as Text } from "../../components/AppText";
31import { EmptyState } from "../../components/EmptyState";40import { EmptyState } from "../../components/EmptyState";
41import { CopyTextButton } from "../../components/CopyTextButton";
32import {42import {
33 parseReviewCommentMessageSegments,43 parseReviewCommentMessageSegments,
34 type ReviewInlineComment,44 type ReviewInlineComment,
...@@ -44,19 +54,43 @@ import {...@@ -44,19 +54,43 @@ import {
44import { buildReviewParsedDiff } from "../review/reviewModel";54import { buildReviewParsedDiff } from "../review/reviewModel";
45import { cn } from "../../lib/cn";55import { cn } from "../../lib/cn";
46import type { MobileLayoutVariant } from "../../lib/mobileLayout";56import type { MobileLayoutVariant } from "../../lib/mobileLayout";
47import type { ThreadFeedEntry } from "../../lib/threadActivity";57import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
58import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
59import {
60 deriveThreadFeedPresentation,
61 type ThreadFeedEntry,
62 type ThreadFeedLatestTurn,
63} from "../../lib/threadActivity";
64import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout";
48import { relativeTime } from "../../lib/time";65import { relativeTime } from "../../lib/time";
49import { messageImageUrl } from "./threadPresentation";66import { messageImageUrl } from "./threadPresentation";
5067
68const THREAD_FEED_END_THRESHOLD = 80;
69const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
70 hour: "numeric",
71 minute: "2-digit",
72});
73
74function formatMessageTime(input: string): string {
75 const timestamp = Date.parse(input);
76 if (Number.isNaN(timestamp)) {
77 return "";
78 }
79 return MESSAGE_TIME_FORMATTER.format(timestamp);
80}
81
51export interface ThreadFeedProps {82export interface ThreadFeedProps {
52 readonly threadId: ThreadId;83 readonly threadId: ThreadId;
53 readonly feed: ReadonlyArray<ThreadFeedEntry>;84 readonly feed: ReadonlyArray<ThreadFeedEntry>;
54 readonly httpBaseUrl: string | null;85 readonly httpBaseUrl: string | null;
55 readonly bearerToken: string | null;86 readonly bearerToken: string | null;
56 readonly agentLabel: string;87 readonly agentLabel: string;
88 readonly latestTurn: ThreadFeedLatestTurn | null;
89 readonly contentTopInset?: number;
57 readonly contentBottomInset?: number;90 readonly contentBottomInset?: number;
58 readonly layoutVariant?: MobileLayoutVariant;91 readonly layoutVariant?: MobileLayoutVariant;
59 readonly composerExpanded?: boolean;92 readonly composerExpanded?: boolean;
93 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
60}94}
6195
62function stripShellWrapper(value: string): string {96function stripShellWrapper(value: string): string {
...@@ -75,34 +109,48 @@ function compactActivityDetail(detail: string | null): string | null {...@@ -75,34 +109,48 @@ function compactActivityDetail(detail: string | null): string | null {
75}109}
76110
77function buildActivityRows(111function buildActivityRows(
78 activities: ReadonlyArray<{112 activities: Extract<ThreadFeedEntry, { type: "activity-group" }>["activities"],
79 readonly id: string;
80 readonly createdAt: string;
81 readonly summary: string;
82 readonly detail: string | null;
83 readonly status: string | null;
84 }>,
85) {113) {
86 return activities.map<{114 return activities.map((activity) => ({
87 id: string;115 ...activity,
88 createdAt: string;
89 summary: string;
90 detail: string | null;
91 status: string | null;
92 }>((activity) => ({
93 id: activity.id,
94 createdAt: activity.createdAt,
95 summary: activity.summary,
96 detail: compactActivityDetail(activity.detail),116 detail: compactActivityDetail(activity.detail),
97 status: activity.status,
98 }));117 }));
99}118}
100119
101const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;120const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
102121
103function toMarkdownThemeColor(value: ColorValue): string {122const MARKDOWN_COLORS = {
104 return value as string;123 light: {
105}124 body: "#111111",
125 strong: "#000000",
126 link: "#2563eb",
127 blockquoteBorder: "rgba(0, 0, 0, 0.08)",
128 blockquoteBackground: "rgba(0, 0, 0, 0.02)",
129 codeBackground: "rgba(0, 0, 0, 0.04)",
130 codeText: "#262626",
131 horizontalRule: "rgba(0, 0, 0, 0.08)",
132 userBody: "#ffffff",
133 userCodeBackground: "rgba(255, 255, 255, 0.22)",
134 userCodeText: "#ffffff",
135 userFenceBackground: "rgba(0, 0, 0, 0.16)",
136 userFenceText: "#ffffff",
137 },
138 dark: {
139 body: "#e5e5e5",
140 strong: "#f5f5f5",
141 link: "#60a5fa",
142 blockquoteBorder: "rgba(255, 255, 255, 0.1)",
143 blockquoteBackground: "rgba(255, 255, 255, 0.03)",
144 codeBackground: "rgba(255, 255, 255, 0.06)",
145 codeText: "#e5e5e5",
146 horizontalRule: "rgba(255, 255, 255, 0.08)",
147 userBody: "#ffffff",
148 userCodeBackground: "rgba(255, 255, 255, 0.18)",
149 userCodeText: "#ffffff",
150 userFenceBackground: "rgba(0, 0, 0, 0.28)",
151 userFenceText: "#ffffff",
152 },
153} as const;
106154
107interface MarkdownStyleSets {155interface MarkdownStyleSets {
108 readonly user: MarkdownStyleSet;156 readonly user: MarkdownStyleSet;
...@@ -113,6 +161,7 @@ interface MarkdownStyleSet {...@@ -113,6 +161,7 @@ interface MarkdownStyleSet {
113 readonly theme: PartialMarkdownTheme;161 readonly theme: PartialMarkdownTheme;
114 readonly styles: NodeStyleOverrides;162 readonly styles: NodeStyleOverrides;
115 readonly renderers: CustomRenderers;163 readonly renderers: CustomRenderers;
164 readonly nativeTextStyle: NativeMarkdownTextStyle;
116}165}
117166
118interface ReviewCommentColors {167interface ReviewCommentColors {
...@@ -124,6 +173,70 @@ interface ReviewCommentColors {...@@ -124,6 +173,70 @@ interface ReviewCommentColors {
124 readonly codeBackground: ColorValue;173 readonly codeBackground: ColorValue;
125}174}
126175
176const failedMarkdownFaviconHosts = new Set<string>();
177const markdownLinkStyles = StyleSheet.create({
178 favicon: {
179 width: 14,
180 height: 14,
181 borderRadius: 3,
182 marginHorizontal: 3,
183 transform: [{ translateY: 2 }],
184 },
185 file: {
186 borderRadius: 5,
187 borderWidth: StyleSheet.hairlineWidth,
188 fontFamily: "DMSans_500Medium",
189 fontSize: 13,
190 lineHeight: 20,
191 paddingHorizontal: 6,
192 paddingVertical: 2,
193 },
194 fileIcon: {
195 width: 15,
196 height: 15,
197 marginRight: 4,
198 transform: [{ translateY: 2 }],
199 },
200});
201
202const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
203 readonly children: ReactNode;
204 readonly color: string;
205 readonly host: string;
206 readonly href: string;
207}) {
208 const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host));
209
210 return (
211 <NativeText
212 onPress={() => {
213 void Linking.openURL(props.href);
214 }}
215 style={{
216 color: props.color,
217 fontFamily: "DMSans_400Regular",
218 textDecorationLine: "none",
219 }}
220 >
221 {!failed ? (
222 <Image
223 source={{
224 uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`,
225 }}
226 style={markdownLinkStyles.favicon}
227 onError={() => {
228 failedMarkdownFaviconHosts.add(props.host);
229 setFailed(true);
230 }}
231 />
232 ) : (
233 <NativeText style={{ color: props.color }}>{" ◉ "}</NativeText>
234 )}
235 {props.children}
236 </NativeText>
237 );
238});
239
127function useReviewCommentColors(): ReviewCommentColors {240function useReviewCommentColors(): ReviewCommentColors {
128 const colorScheme = useColorScheme();241 const colorScheme = useColorScheme();
129 const isDark = colorScheme === "dark";242 const isDark = colorScheme === "dark";
...@@ -148,34 +261,26 @@ function useReviewCommentColors(): ReviewCommentColors {...@@ -148,34 +261,26 @@ function useReviewCommentColors(): ReviewCommentColors {
148}261}
149262
150function useMarkdownStyles(): MarkdownStyleSets {263function useMarkdownStyles(): MarkdownStyleSets {
151 const bodyColor = useThemeColor("--color-md-body");264 const colorScheme = useColorScheme();
152 const strongColor = useThemeColor("--color-md-strong");265 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];
153 const linkColor = useThemeColor("--color-md-link");266 const inlineChipBackground = String(useThemeColor("--color-subtle"));
154 const blockquoteBg = useThemeColor("--color-md-blockquote-bg");267 const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background"));
155 const blockquoteBorder = useThemeColor("--color-md-blockquote-border");268 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
156 const codeBg = useThemeColor("--color-md-code-bg");
157 const codeText = useThemeColor("--color-md-code-text");
158 const hrColor = useThemeColor("--color-md-hr");
159 const userBodyColor = useThemeColor("--color-user-bubble-foreground");
160 const userCodeBg = useThemeColor("--color-md-user-code-bg");
161 const userCodeText = useThemeColor("--color-md-user-code-text");
162 const userFenceBg = useThemeColor("--color-md-user-fence-bg");
163 const userFenceText = useThemeColor("--color-md-user-fence-text");
164269
165 return useMemo(() => {270 return useMemo(() => {
166 const markdownBodyColor = toMarkdownThemeColor(bodyColor);271 const markdownBodyColor = colors.body;
167 const markdownStrongColor = toMarkdownThemeColor(strongColor);272 const markdownStrongColor = colors.strong;
168 const markdownLinkColor = toMarkdownThemeColor(linkColor);273 const markdownLinkColor = colors.link;
169 const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg);274 const markdownBlockquoteBg = colors.blockquoteBackground;
170 const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder);275 const markdownBlockquoteBorder = colors.blockquoteBorder;
171 const markdownCodeBg = toMarkdownThemeColor(codeBg);276 const markdownCodeBg = colors.codeBackground;
172 const markdownCodeText = toMarkdownThemeColor(codeText);277 const markdownCodeText = colors.codeText;
173 const markdownHrColor = toMarkdownThemeColor(hrColor);278 const markdownHrColor = colors.horizontalRule;
174 const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor);279 const markdownUserBodyColor = colors.userBody;
175 const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg);280 const markdownUserCodeBg = colors.userCodeBackground;
176 const markdownUserCodeText = toMarkdownThemeColor(userCodeText);281 const markdownUserCodeText = colors.userCodeText;
177 const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg);282 const markdownUserFenceBg = colors.userFenceBackground;
178 const markdownUserFenceText = toMarkdownThemeColor(userFenceText);283 const markdownUserFenceText = colors.userFenceText;
179284
180 const baseTheme: PartialMarkdownTheme = {285 const baseTheme: PartialMarkdownTheme = {
181 colors: {286 colors: {
...@@ -202,12 +307,12 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -202,12 +307,12 @@ function useMarkdownStyles(): MarkdownStyleSets {
202 fontSizes: {307 fontSizes: {
203 s: 13,308 s: 13,
204 m: 15,309 m: 15,
205 h1: 22,310 h1: 20,
206 h2: 19,311 h2: 18,
207 h3: 17,312 h3: 16,
208 h4: 15,313 h4: 14,
209 h5: 15,314 h5: 14,
210 h6: 15,315 h6: 14,
211 },316 },
212 fontFamilies: {317 fontFamilies: {
213 regular: "DMSans_400Regular",318 regular: "DMSans_400Regular",
...@@ -225,8 +330,8 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -225,8 +330,8 @@ function useMarkdownStyles(): MarkdownStyleSets {
225330
226 const baseStyles: NodeStyleOverrides = {331 const baseStyles: NodeStyleOverrides = {
227 document: { flexShrink: 1 },332 document: { flexShrink: 1 },
228 paragraph: { marginTop: 0, marginBottom: 8 },333 paragraph: { marginTop: 0, marginBottom: 10 },
229 list: { marginTop: 4, marginBottom: 4 },334 list: { marginTop: 4, marginBottom: 8 },
230 list_item: { marginTop: 0, marginBottom: 4 },335 list_item: { marginTop: 0, marginBottom: 4 },
231 task_list_item: { marginTop: 0, marginBottom: 4 },336 task_list_item: { marginTop: 0, marginBottom: 4 },
232 text: { lineHeight: 22 },337 text: { lineHeight: 22 },
...@@ -241,20 +346,18 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -241,20 +346,18 @@ function useMarkdownStyles(): MarkdownStyleSets {
241 textDecorationLine: "underline" as const,346 textDecorationLine: "underline" as const,
242 },347 },
243 blockquote: {348 blockquote: {
244 borderLeftWidth: 3,349 borderLeftWidth: 2,
245 borderLeftColor: markdownBlockquoteBorder,350 borderLeftColor: markdownBlockquoteBorder,
246 backgroundColor: markdownBlockquoteBg,351 paddingLeft: 11,
247 paddingLeft: 12,352 paddingVertical: 2,
248 paddingVertical: 6,
249 marginLeft: 0,353 marginLeft: 0,
250 marginVertical: 4,354 marginVertical: 10,
251 borderRadius: 4,
252 },355 },
253 heading: {356 heading: {
254 fontFamily: "DMSans_700Bold",357 fontFamily: "DMSans_700Bold",
255 color: markdownStrongColor,358 color: markdownStrongColor,
256 marginTop: 12,359 marginTop: 18,
257 marginBottom: 6,360 marginBottom: 8,
258 },361 },
259 horizontal_rule: {362 horizontal_rule: {
260 backgroundColor: markdownHrColor,363 backgroundColor: markdownHrColor,
...@@ -263,44 +366,173 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -263,44 +366,173 @@ function useMarkdownStyles(): MarkdownStyleSets {
263 },366 },
264 };367 };
265368
266 const createCodeRenderers = (369 const createMarkdownRenderers = (
267 inlineBackgroundColor: string,370 inlineBackgroundColor: string,
268 inlineTextColor: string,371 inlineTextColor: string,
269 blockBackgroundColor: string,372 blockBackgroundColor: string,
270 blockTextColor: string,373 blockTextColor: string,
271 ): CustomRenderers => ({374 ): CustomRenderers => ({
272 code_inline: ({ content }) => (375 link: ({ children, href = "" }) => {
273 <NativeText376 const presentation = resolveMarkdownLinkPresentation(href);
274 style={{377 if (presentation.kind === "file") {
275 backgroundColor: inlineBackgroundColor,378 return (
276 color: inlineTextColor,379 <NativeText
277 borderRadius: 5,380 style={[
278 paddingHorizontal: 5,381 markdownLinkStyles.file,
279 paddingVertical: 1,382 {
280 fontFamily: "ui-monospace",383 backgroundColor: inlineBackgroundColor,
281 fontSize: 13,384 borderColor: markdownHrColor,
282 }}385 color: inlineTextColor,
283 >386 },
284 {content}387 ]}
285 </NativeText>388 >
389 <Image
390 source={markdownFileIconSource(presentation.icon)}
391 style={markdownLinkStyles.fileIcon}
392 />
393 {presentation.label}
394 </NativeText>
395 );
396 }
397 if (presentation.kind === "external") {
398 return (
399 <MarkdownExternalLink
400 href={presentation.href}
401 host={presentation.host}
402 color={markdownLinkColor}
403 >
404 {children}
405 </MarkdownExternalLink>
406 );
407 }
408 const linkHref = presentation.href;
409 return (
410 <NativeText
411 onPress={
412 linkHref
413 ? () => {
414 void Linking.openURL(linkHref);
415 }
416 : undefined
417 }
418 style={{
419 color: markdownLinkColor,
420 textDecorationLine: "underline",
421 }}
422 >
423 {children}
424 </NativeText>
425 );
426 },
427 list: ({ node, Renderer, ordered = false, start = 1 }) => (
428 <View style={{ marginTop: 2, marginBottom: 8 }}>
429 {node.children?.map((child, index) => {
430 const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`;
431 if (child.type === "task_list_item") {
432 return (
433 <Renderer key={childKey} node={child} depth={1} inListItem parentIsText={false} />
434 );
435 }
436 return (
437 <View
438 key={childKey}
439 style={{
440 flexDirection: "row",
441 alignItems: "flex-start",
442 marginBottom: 3,
443 }}
444 >
445 <NativeText
446 style={{
447 width: ordered ? 22 : 12,
448 marginRight: 5,
449 color: inlineTextColor,
450 fontFamily: "DMSans_400Regular",
451 fontSize: 15,
452 lineHeight: 22,
453 textAlign: ordered ? "right" : "center",
454 }}
455 >
456 {ordered ? `${start + index}.` : "•"}
457 </NativeText>
458 <View style={{ flex: 1, minWidth: 0 }}>
459 <Renderer node={child} depth={1} inListItem parentIsText={false} />
460 </View>
461 </View>
462 );
463 })}
464 </View>
286 ),465 ),
287 code_block: ({ content }) => (466 code_inline: ({ content }) => {
467 const value = content ?? "";
468 const wrapsPoorly =
469 value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":");
470 return (
471 <NativeText
472 style={{
473 color: inlineTextColor,
474 fontFamily: "ui-monospace",
475 fontSize: 12,
476 lineHeight: 18,
477 ...(wrapsPoorly
478 ? { opacity: 0.82 }
479 : {
480 backgroundColor: inlineBackgroundColor,
481 borderRadius: 4,
482 paddingHorizontal: 3,
483 }),
484 }}
485 >
486 {value}
487 </NativeText>
488 );
489 },
490 code_block: ({ content, language }) => (
288 <View491 <View
289 style={{492 style={{
290 backgroundColor: blockBackgroundColor,493 backgroundColor: blockBackgroundColor,
291 borderRadius: 12,494 borderRadius: 10,
292 padding: 12,495 borderWidth: 1,
293 marginVertical: 8,496 borderColor: markdownHrColor,
497 marginVertical: 12,
498 overflow: "hidden",
294 }}499 }}
295 >500 >
296 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>501 {language ? (
502 <View
503 style={{
504 borderBottomWidth: 1,
505 borderBottomColor: markdownHrColor,
506 paddingHorizontal: 14,
507 paddingVertical: 8,
508 }}
509 >
510 <NativeText
511 style={{
512 color: markdownBodyColor,
513 fontFamily: "ui-monospace",
514 fontSize: 12,
515 opacity: 0.7,
516 textTransform: "uppercase",
517 }}
518 >
519 {language}
520 </NativeText>
521 </View>
522 ) : null}
523 <ScrollView
524 horizontal
525 showsHorizontalScrollIndicator={false}
526 bounces={false}
527 contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}
528 >
297 <NativeText529 <NativeText
298 selectable530 selectable
299 style={{531 style={{
300 color: blockTextColor,532 color: blockTextColor,
301 fontFamily: "ui-monospace",533 fontFamily: "ui-monospace",
302 fontSize: 13,534 fontSize: 12,
303 lineHeight: 19,535 lineHeight: 18,
304 }}536 }}
305 >537 >
306 {content}538 {content}
...@@ -333,6 +565,8 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -333,6 +565,8 @@ function useMarkdownStyles(): MarkdownStyleSets {
333 heading: {565 heading: {
334 ...baseStyles.heading,566 ...baseStyles.heading,
335 color: markdownUserBodyColor,567 color: markdownUserBodyColor,
568 marginTop: 8,
569 marginBottom: 4,
336 },570 },
337 link: {571 link: {
338 color: markdownUserBodyColor,572 color: markdownUserBodyColor,
...@@ -357,48 +591,79 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -357,48 +591,79 @@ function useMarkdownStyles(): MarkdownStyleSets {
357 user: {591 user: {
358 theme: userTheme,592 theme: userTheme,
359 styles: userStyles,593 styles: userStyles,
360 renderers: createCodeRenderers(594 renderers: createMarkdownRenderers(
361 markdownUserCodeBg,595 markdownUserCodeBg,
362 markdownUserCodeText,596 markdownUserCodeText,
363 markdownUserFenceBg,597 markdownUserFenceBg,
364 markdownUserFenceText,598 markdownUserFenceText,
365 ),599 ),
600 nativeTextStyle: {
601 color: markdownUserBodyColor,
602 strongColor: markdownUserBodyColor,
603 mutedColor: markdownUserBodyColor,
604 linkColor: markdownUserBodyColor,
605 codeColor: markdownUserCodeText,
606 codeBackgroundColor: markdownUserCodeBg,
607 codeBlockBackgroundColor: markdownUserFenceBg,
608 fileBackgroundColor: "rgba(255, 255, 255, 0.12)",
609 fileTextColor: "#ffffff",
610 skillBackgroundColor: "rgba(217, 70, 239, 0.24)",
611 skillTextColor: "#ffffff",
612 quoteMarkerColor: markdownUserBodyColor,
613 dividerColor: markdownUserBodyColor,
614 fontSize: 15,
615 lineHeight: 22,
616 fontFamily: "DMSans_400Regular",
617 headingFontFamily: "DMSans_700Bold",
618 boldFontFamily: "DMSans_700Bold",
619 },
366 },620 },
367 assistant: {621 assistant: {
368 theme: assistantTheme,622 theme: assistantTheme,
369 styles: assistantStyles,623 styles: assistantStyles,
370 renderers: createCodeRenderers(624 renderers: createMarkdownRenderers(
371 markdownCodeBg,625 markdownCodeBg,
372 markdownCodeText,626 markdownCodeText,
373 markdownCodeBg,627 markdownCodeBg,
374 markdownCodeText,628 markdownCodeText,
375 ),629 ),
630 nativeTextStyle: {
631 color: markdownBodyColor,
632 strongColor: markdownStrongColor,
633 mutedColor: markdownBodyColor,
634 linkColor: markdownLinkColor,
635 codeColor: markdownCodeText,
636 codeBackgroundColor: markdownCodeBg,
637 codeBlockBackgroundColor: markdownCodeBg,
638 fileBackgroundColor: inlineChipBackground,
639 fileTextColor: markdownCodeText,
640 skillBackgroundColor: inlineSkillBackground,
641 skillTextColor: inlineSkillForeground,
642 quoteMarkerColor: markdownBlockquoteBorder,
643 dividerColor: markdownHrColor,
644 fontSize: 15,
645 lineHeight: 22,
646 fontFamily: "DMSans_400Regular",
647 headingFontFamily: "DMSans_700Bold",
648 boldFontFamily: "DMSans_700Bold",
649 },
376 },650 },
377 };651 };
378 }, [652 }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]);
379 blockquoteBg,
380 blockquoteBorder,
381 bodyColor,
382 codeBg,
383 codeText,
384 hrColor,
385 linkColor,
386 strongColor,
387 userBodyColor,
388 userCodeBg,
389 userCodeText,
390 userFenceBg,
391 userFenceText,
392 ]);
393}653}
394654
395function renderFeedEntry(655function renderFeedEntry(
396 info: { item: ThreadFeedEntry; index: number },656 info: { item: ThreadFeedEntry; index: number },
397 props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl"> & {657 props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl" | "skills"> & {
398 readonly copiedRowId: string | null;658 readonly copiedRowId: string | null;
399 readonly expandedWorkGroups: Record<string, boolean>;659 readonly expandedWorkGroups: Record<string, boolean>;
660 readonly expandedWorkRows: Record<string, boolean>;
661 readonly terminalAssistantMessageIds: ReadonlySet<string>;
662 readonly unsettledTurnId: TurnId | null;
400 readonly onCopyWorkRow: (rowId: string, value: string) => void;663 readonly onCopyWorkRow: (rowId: string, value: string) => void;
401 readonly onToggleWorkGroup: (groupId: string) => void;664 readonly onToggleWorkGroup: (groupId: string) => void;
665 readonly onToggleWorkRow: (rowId: string) => void;
666 readonly onToggleTurnFold: (turnId: TurnId) => void;
402 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;667 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
403 readonly iconSubtleColor: string | import("react-native").ColorValue;668 readonly iconSubtleColor: string | import("react-native").ColorValue;
404 readonly userBubbleColor: string | import("react-native").ColorValue;669 readonly userBubbleColor: string | import("react-native").ColorValue;
...@@ -410,19 +675,50 @@ function renderFeedEntry(...@@ -410,19 +675,50 @@ function renderFeedEntry(
410 const entry = info.item;675 const entry = info.item;
411 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;676 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
412677
678 if (entry.type === "turn-fold") {
679 return (
680 <Pressable
681 accessibilityRole="button"
682 accessibilityState={{ expanded: entry.expanded }}
683 onPress={() => props.onToggleTurnFold(entry.turnId)}
684 hitSlop={4}
685 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]"
686 >
687 <Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
688 {entry.label}
689 </Text>
690 <SymbolView
691 name={entry.expanded ? "chevron.down" : "chevron.right"}
692 size={15}
693 tintColor={iconSubtleColor}
694 type="monochrome"
695 />
696 </Pressable>
697 );
698 }
699
413 if (entry.type === "message") {700 if (entry.type === "message") {
414 const { message } = entry;701 const { message } = entry;
415 const isUser = message.role === "user";702 const isUser = message.role === "user";
416 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;703 const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
417 const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`;704 const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
418 const attachments = message.attachments ?? [];705 const attachments = message.attachments ?? [];
419 const hasReviewCommentContext = message.text.includes("<review_comment");706 const hasReviewCommentContext = message.text.includes("<review_comment");
707 const assistantTurnStillInProgress =
708 message.role === "assistant" &&
709 props.unsettledTurnId !== null &&
710 message.turnId === props.unsettledTurnId;
711 const showAssistantMeta =
712 message.role === "assistant" &&
713 props.terminalAssistantMessageIds.has(message.id) &&
714 !assistantTurnStillInProgress &&
715 !message.streaming;
420716
421 if (isUser) {717 if (isUser) {
422 return (718 return (
423 <View className="mb-5 items-end">719 <View className="mb-5 items-end">
424 <View720 <View
425 className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5"721 className="max-w-[85%] gap-2 rounded-[20px] px-3.5 py-2.5"
426 style={{722 style={{
427 backgroundColor: userBubbleColor,723 backgroundColor: userBubbleColor,
428 ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),724 ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),
...@@ -433,6 +729,7 @@ function renderFeedEntry(...@@ -433,6 +729,7 @@ function renderFeedEntry(
433 text={message.text}729 text={message.text}
434 markdownStyles={styles}730 markdownStyles={styles}
435 reviewCommentColors={props.reviewCommentColors}731 reviewCommentColors={props.reviewCommentColors}
732 skills={props.skills}
436 />733 />
437 ) : null}734 ) : null}
438 {attachments.map((attachment) => {735 {attachments.map((attachment) => {
...@@ -459,9 +756,20 @@ function renderFeedEntry(...@@ -459,9 +756,20 @@ function renderFeedEntry(
459 );756 );
460 })}757 })}
461 </View>758 </View>
462 <Text className="mt-1.5 px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">759 <View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">
463 {timestampLabel}760 <Text className="font-t3-medium text-xs tabular-nums text-neutral-600 dark:text-neutral-400">
464 </Text>761 {timestampLabel}
762 </Text>
763 {message.text.trim().length > 0 ? (
764 <CopyTextButton
765 accessibilityLabel="Copy message"
766 text={message.text}
767 tintColor={iconSubtleColor}
768 buttonSize={28}
769 iconSize={13}
770 />
771 ) : null}
772 </View>
465 </View>773 </View>
466 );774 );
467 }775 }
...@@ -473,16 +781,24 @@ function renderFeedEntry(...@@ -473,16 +781,24 @@ function renderFeedEntry(
473 }781 }
474782
475 return (783 return (
476 <View className="mb-5 px-1">784 <View className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}>
477 {message.text.trim().length > 0 ? (785 {message.text.trim().length > 0 ? (
478 <Markdown786 hasNativeSelectableMarkdownText() ? (
479 options={{ gfm: true }}787 <SelectableMarkdownText
480 renderers={styles.renderers}788 markdown={message.text}
481 styles={styles.styles}789 skills={props.skills}
482 theme={styles.theme}790 textStyle={styles.nativeTextStyle}
483 >791 />
484 {message.text}792 ) : (
485 </Markdown>793 <Markdown
794 options={{ gfm: true }}
795 renderers={styles.renderers}
796 styles={styles.styles}
797 theme={styles.theme}
798 >
799 {message.text}
800 </Markdown>
801 )
486 ) : null}802 ) : null}
487 {attachments.map((attachment) => {803 {attachments.map((attachment) => {
488 const uri = messageImageUrl(props.httpBaseUrl, attachment.id);804 const uri = messageImageUrl(props.httpBaseUrl, attachment.id);
...@@ -508,9 +824,20 @@ function renderFeedEntry(...@@ -508,9 +824,20 @@ function renderFeedEntry(
508 </TouchableOpacity>824 </TouchableOpacity>
509 );825 );
510 })}826 })}
511 <Text className="mt-1.5 font-t3-medium text-xs text-neutral-600 dark:text-neutral-400">827 {showAssistantMeta ? (
512 {timestampLabel}828 <View className="mt-1 flex-row items-center gap-1">
513 </Text>829 <CopyTextButton
830 accessibilityLabel="Copy message"
831 text={message.text}
832 tintColor={iconSubtleColor}
833 buttonSize={28}
834 iconSize={13}
835 />
836 <Text className="font-t3-medium text-xs tabular-nums text-neutral-600 dark:text-neutral-400">
837 {timestampLabel}
838 </Text>
839 </View>
840 ) : null}
514 </View>841 </View>
515 );842 );
516 }843 }
...@@ -539,67 +866,121 @@ function renderFeedEntry(...@@ -539,67 +866,121 @@ function renderFeedEntry(
539 );866 );
540 }867 }
541868
542 const rows = buildActivityRows(entry.activities);869 const rows = buildActivityRows(entry.activities).filter(
870 (activity) => !(activity.toolLike && activity.status === "neutral"),
871 );
872 if (rows.length === 0) {
873 return null;
874 }
543 const isExpanded = props.expandedWorkGroups[entry.id] ?? false;875 const isExpanded = props.expandedWorkGroups[entry.id] ?? false;
544 const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES;876 const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
545 const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows;877 const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows;
546 const hiddenCount = rows.length - visibleRows.length;878 const hiddenCount = rows.length - visibleRows.length;
547 const showHeader = hasOverflow;879 const onlyToolRows = rows.every((row) => row.toolLike);
880 const headerTitle = onlyToolRows
881 ? rows.length === 1
882 ? "1 tool call"
883 : `${rows.length} tool calls`
884 : "Work log";
548885
549 return (886 return (
550 <View className="mb-3 rounded-[20px] border border-neutral-200/80 bg-neutral-50/85 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.025]">887 <View className="mb-3 rounded-[16px] border border-neutral-300/70 bg-background px-2 py-2.5 dark:border-white/[0.1] dark:bg-white/[0.035]">
551 {showHeader ? (888 <View className="mb-1.5 flex-row items-center justify-between gap-3 px-2">
552 <View className="mb-1.5 flex-row items-center justify-between gap-3 px-0.5">889 <Text className="font-t3-medium text-xs text-foreground">{headerTitle}</Text>
553 <Text className="font-t3-bold text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500">890 {hasOverflow ? (
554 Tool calls ({rows.length})891 <Pressable
555 </Text>892 accessibilityRole="button"
556 <Pressable onPress={() => props.onToggleWorkGroup(entry.id)}>893 accessibilityState={{ expanded: isExpanded }}
557 <Text className="font-t3-medium text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500">894 onPress={() => props.onToggleWorkGroup(entry.id)}
895 className="flex-row items-center gap-1"
896 >
897 <Text className="font-t3-medium text-xs text-foreground-muted">
558 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}898 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
559 </Text>899 </Text>
900 <SymbolView
901 name={isExpanded ? "chevron.up" : "chevron.down"}
902 size={12}
903 tintColor={iconSubtleColor}
904 type="monochrome"
905 />
560 </Pressable>906 </Pressable>
561 </View>907 ) : null}
562 ) : null}908 </View>
563 {visibleRows.map((row, index) => (909 {visibleRows.map((row, index) => (
564 <View910 <Pressable
565 key={row.id}911 key={row.id}
912 accessibilityRole={row.fullDetail ? "button" : undefined}
913 accessibilityState={
914 row.fullDetail ? { expanded: props.expandedWorkRows[row.id] ?? false } : undefined
915 }
916 onPress={() => {
917 if (row.fullDetail) {
918 props.onToggleWorkRow(row.id);
919 }
920 }}
921 onLongPress={() => props.onCopyWorkRow(row.id, row.copyText)}
566 className={cn(922 className={cn(
567 "flex-row items-center gap-2 rounded-lg px-1 py-1",923 "rounded-lg px-2 py-1.5",
568 index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]",924 index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]",
569 )}925 )}
570 >926 >
571 <View className="items-center justify-center pt-0.5">927 <View className="flex-row items-center gap-2">
572 <SymbolView name="terminal" size={13} tintColor={iconSubtleColor} type="monochrome" />928 <View className="w-4 items-center justify-center">
573 </View>929 <SymbolView
574 <ScrollView930 name={
575 horizontal931 row.status === "failure"
576 nestedScrollEnabled932 ? "xmark"
577 directionalLockEnabled933 : row.status === "success"
578 showsHorizontalScrollIndicator={false}934 ? "checkmark"
579 bounces={false}935 : row.status === "neutral"
580 className="flex-1"936 ? "minus"
581 contentContainerStyle={{ paddingRight: 12 }}937 : "terminal"
582 style={{ flex: 1 }}938 }
583 >939 size={row.status ? 11 : 13}
940 tintColor={row.status === "failure" ? "#e11d48" : iconSubtleColor}
941 type="monochrome"
942 />
943 </View>
584 <Text944 <Text
585 className="text-[12px] leading-[18px] text-neutral-600 dark:text-neutral-400"945 className="min-w-0 flex-1 text-[12px] leading-[18px] text-neutral-700 dark:text-neutral-300"
586 onLongPress={() => {946 numberOfLines={props.expandedWorkRows[row.id] ? undefined : 1}
587 const copyValue = row.detail ?? row.summary;
588 props.onCopyWorkRow(row.id, copyValue);
589 }}
590 style={{
591 fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace",
592 }}
593 >947 >
594 {row.detail ? `${row.summary} - ${row.detail}` : row.summary}948 {row.detail ? `${row.summary} - ${row.detail}` : row.summary}
595 </Text>949 </Text>
596 </ScrollView>950 {row.fullDetail ? (
597 {props.copiedRowId === row.id ? (951 <SymbolView
598 <Text className="shrink-0 font-t3-medium text-[10px] uppercase tracking-[0.8px] text-emerald-600 dark:text-emerald-400">952 name={props.expandedWorkRows[row.id] ? "chevron.up" : "chevron.down"}
599 Copied953 size={11}
600 </Text>954 tintColor={iconSubtleColor}
955 type="monochrome"
956 />
957 ) : null}
958 {props.copiedRowId === row.id ? (
959 <Text className="shrink-0 font-t3-medium text-[10px] text-emerald-600 dark:text-emerald-400">
960 Copied
961 </Text>
962 ) : null}
963 </View>
964 {row.fullDetail && props.expandedWorkRows[row.id] ? (
965 <ScrollView
966 horizontal
967 nestedScrollEnabled
968 directionalLockEnabled
969 showsHorizontalScrollIndicator
970 bounces={false}
971 className="mt-2 rounded-lg bg-neutral-100 dark:bg-black/20"
972 contentContainerStyle={{ paddingHorizontal: 10, paddingVertical: 8 }}
973 >
974 <Text
975 selectable
976 className="text-[11px] leading-[17px] text-neutral-600 dark:text-neutral-400"
977 style={{ fontFamily: "ui-monospace" }}
978 >
979 {row.fullDetail}
980 </Text>
981 </ScrollView>
601 ) : null}982 ) : null}
602 </View>983 </Pressable>
603 ))}984 ))}
604 </View>985 </View>
605 );986 );
...@@ -609,10 +990,20 @@ function UserMessageContent(props: {...@@ -609,10 +990,20 @@ function UserMessageContent(props: {
609 readonly text: string;990 readonly text: string;
610 readonly markdownStyles: MarkdownStyleSet;991 readonly markdownStyles: MarkdownStyleSet;
611 readonly reviewCommentColors: ReviewCommentColors;992 readonly reviewCommentColors: ReviewCommentColors;
993 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
612}) {994}) {
613 const segments = parseReviewCommentMessageSegments(props.text);995 const segments = parseReviewCommentMessageSegments(props.text);
614 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");996 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
615 if (!hasReviewComment) {997 if (!hasReviewComment) {
998 if (hasNativeSelectableMarkdownText()) {
999 return (
1000 <SelectableMarkdownText
1001 markdown={props.text}
1002 skills={props.skills}
1003 textStyle={props.markdownStyles.nativeTextStyle}
1004 />
1005 );
1006 }
616 return (1007 return (
617 <Markdown1008 <Markdown
618 options={{ gfm: true }}1009 options={{ gfm: true }}
...@@ -643,7 +1034,14 @@ function UserMessageContent(props: {...@@ -643,7 +1034,14 @@ function UserMessageContent(props: {
643 return null;1034 return null;
644 }1035 }
6451036
646 return (1037 return hasNativeSelectableMarkdownText() ? (
1038 <SelectableMarkdownText
1039 key={segment.id}
1040 markdown={text}
1041 skills={props.skills}
1042 textStyle={props.markdownStyles.nativeTextStyle}
1043 />
1044 ) : (
647 <Markdown1045 <Markdown
648 key={segment.id}1046 key={segment.id}
649 options={{ gfm: true }}1047 options={{ gfm: true }}
...@@ -819,14 +1217,30 @@ function compactFileName(filePath: string): string {...@@ -819,14 +1217,30 @@ function compactFileName(filePath: string): string {
819 return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;1217 return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;
820}1218}
8211219
822const IOS_NAV_BAR_HEIGHT = 44;
823
824export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {1220export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
825 const listRef = useRef<LegendListRef>(null);1221 const listRef = useRef<LegendListRef>(null);
826 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);1222 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1223 const scrollFrameRef = useRef<number | null>(null);
1224 const foldSettleFrameRef = useRef<number | null>(null);
1225 const foldSettleSecondFrameRef = useRef<number | null>(null);
1226 const suppressAutoFollowRef = useRef(false);
1227 const previousLatestTurnRef = useRef(props.latestTurn);
1228 const isNearEndRef = useRef(true);
1229 const initialScrollReadyRef = useRef(false);
1230 const lastContentHeightRef = useRef(0);
827 const { width: viewportWidth } = useWindowDimensions();1231 const { width: viewportWidth } = useWindowDimensions();
828 const [copiedRowId, setCopiedRowId] = useState<string | null>(null);1232 const [interactionState, setInteractionState] = useState<{
829 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});1233 readonly copiedRowId: string | null;
1234 readonly expandedWorkGroups: Record<string, boolean>;
1235 readonly expandedWorkRows: Record<string, boolean>;
1236 readonly expandedTurnIds: ReadonlySet<TurnId>;
1237 }>({
1238 copiedRowId: null,
1239 expandedWorkGroups: {},
1240 expandedWorkRows: {},
1241 expandedTurnIds: new Set(),
1242 });
1243 const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState;
830 const [expandedImage, setExpandedImage] = useState<{1244 const [expandedImage, setExpandedImage] = useState<{
831 uri: string;1245 uri: string;
832 headers?: Record<string, string>;1246 headers?: Record<string, string>;
...@@ -835,47 +1249,193 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -835,47 +1249,193 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
835 const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2);1249 const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2);
836 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);1250 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
837 const insets = useSafeAreaInsets();1251 const insets = useSafeAreaInsets();
838 const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;1252 const topContentInset = props.contentTopInset ?? insets.top + 44;
839 const bottomContentInset = props.contentBottomInset ?? 18;1253 const bottomContentInset = props.contentBottomInset ?? 18;
8401254
841 const iconSubtleColor = useThemeColor("--color-icon-subtle");1255 const iconSubtleColor = useThemeColor("--color-icon-subtle");
842 const userBubbleColor = useThemeColor("--color-user-bubble");1256 const userBubbleColor = useThemeColor("--color-user-bubble");
843 const markdownStyles = useMarkdownStyles();1257 const markdownStyles = useMarkdownStyles();
844 const reviewCommentColors = useReviewCommentColors();1258 const reviewCommentColors = useReviewCommentColors();
1259 const listAppearanceData = useMemo(
1260 () => ({
1261 iconSubtleColor,
1262 markdownStyles,
1263 reviewCommentColors,
1264 userBubbleColor,
1265 }),
1266 [iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor],
1267 );
1268 const presentedFeed = useMemo(
1269 () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds),
1270 [expandedTurnIds, props.feed, props.latestTurn],
1271 );
1272 const terminalAssistantMessageIds = useMemo(() => {
1273 const terminalIdsByTurn = new Map<TurnId, string>();
1274 for (const entry of props.feed) {
1275 if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {
1276 terminalIdsByTurn.set(entry.message.turnId, entry.message.id);
1277 }
1278 }
1279 return new Set(terminalIdsByTurn.values());
1280 }, [props.feed]);
1281 const unsettledTurnId =
1282 props.latestTurn &&
1283 (props.latestTurn.completedAt === null || props.latestTurn.state === "running")
1284 ? props.latestTurn.turnId
1285 : null;
1286
1287 const scrollToEnd = useCallback(() => {
1288 if (scrollFrameRef.current !== null) {
1289 return;
1290 }
1291 scrollFrameRef.current = requestAnimationFrame(() => {
1292 scrollFrameRef.current = null;
1293 listRef.current?.scrollToEnd({ animated: false });
1294 });
1295 }, []);
1296
1297 const onListScroll = useCallback(
1298 (event: NativeSyntheticEvent<NativeScrollEvent> | NativeScrollEvent) => {
1299 const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event;
1300 const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent;
1301 isNearEndRef.current = isThreadFeedNearEnd(
1302 {
1303 contentHeight: contentSize.height,
1304 viewportHeight: layoutMeasurement.height,
1305 offsetY: contentOffset.y,
1306 bottomInset: contentInset.bottom,
1307 },
1308 THREAD_FEED_END_THRESHOLD,
1309 );
1310 },
1311 [],
1312 );
1313
1314 const onListContentSizeChange = useCallback(
1315 (_width: number, height: number) => {
1316 const contentGrew = height > lastContentHeightRef.current + 0.5;
1317 lastContentHeightRef.current = height;
1318
1319 if (
1320 initialScrollReadyRef.current &&
1321 contentGrew &&
1322 isNearEndRef.current &&
1323 !suppressAutoFollowRef.current
1324 ) {
1325 scrollToEnd();
1326 }
1327 },
1328 [scrollToEnd],
1329 );
1330
1331 const onListLoad = useCallback(() => {
1332 initialScrollReadyRef.current = true;
1333 }, []);
8451334
846 useEffect(() => {1335 useEffect(() => {
847 setCopiedRowId(null);1336 const previous = previousLatestTurnRef.current;
848 setExpandedWorkGroups({});1337 previousLatestTurnRef.current = props.latestTurn;
849 }, [props.threadId]);1338 if (!props.latestTurn || !previous) {
1339 return;
1340 }
1341 if (props.latestTurn.turnId === previous.turnId) {
1342 if (previous.state === "running" && props.latestTurn.state === "interrupted") {
1343 const interruptedTurnId = props.latestTurn.turnId;
1344 setInteractionState((current) => ({
1345 ...current,
1346 expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId),
1347 }));
1348 }
1349 return;
1350 }
1351 setInteractionState((current) => {
1352 if (!current.expandedTurnIds.has(previous.turnId)) {
1353 return current;
1354 }
1355 const next = new Set(current.expandedTurnIds);
1356 next.delete(previous.turnId);
1357 return { ...current, expandedTurnIds: next };
1358 });
1359 }, [props.latestTurn]);
8501360
851 useEffect(() => {1361 useEffect(() => {
852 return () => {1362 return () => {
853 if (copyFeedbackTimeoutRef.current) {1363 if (copyFeedbackTimeoutRef.current) {
854 clearTimeout(copyFeedbackTimeoutRef.current);1364 clearTimeout(copyFeedbackTimeoutRef.current);
855 }1365 }
1366 if (scrollFrameRef.current !== null) {
1367 cancelAnimationFrame(scrollFrameRef.current);
1368 }
1369 if (foldSettleFrameRef.current !== null) {
1370 cancelAnimationFrame(foldSettleFrameRef.current);
1371 }
1372 if (foldSettleSecondFrameRef.current !== null) {
1373 cancelAnimationFrame(foldSettleSecondFrameRef.current);
1374 }
856 };1375 };
857 }, []);1376 }, []);
8581377
859 const onCopyWorkRow = useCallback((rowId: string, value: string) => {1378 const onCopyWorkRow = useCallback((rowId: string, value: string) => {
860 void Clipboard.setStringAsync(value);1379 void Clipboard.setStringAsync(value);
861 void Haptics.selectionAsync();1380 void Haptics.selectionAsync();
862 setCopiedRowId(rowId);1381 setInteractionState((current) => ({ ...current, copiedRowId: rowId }));
863 if (copyFeedbackTimeoutRef.current) {1382 if (copyFeedbackTimeoutRef.current) {
864 clearTimeout(copyFeedbackTimeoutRef.current);1383 clearTimeout(copyFeedbackTimeoutRef.current);
865 }1384 }
866 copyFeedbackTimeoutRef.current = setTimeout(() => {1385 copyFeedbackTimeoutRef.current = setTimeout(() => {
867 setCopiedRowId((current) => (current === rowId ? null : current));1386 setInteractionState((current) =>
1387 current.copiedRowId === rowId ? { ...current, copiedRowId: null } : current,
1388 );
868 copyFeedbackTimeoutRef.current = null;1389 copyFeedbackTimeoutRef.current = null;
869 }, 1200);1390 }, 1200);
870 }, []);1391 }, []);
8711392
872 const onToggleWorkGroup = useCallback((groupId: string) => {1393 const onToggleWorkGroup = useCallback((groupId: string) => {
873 setExpandedWorkGroups((current) => ({1394 setInteractionState((current) => ({
874 ...current,1395 ...current,
875 [groupId]: !(current[groupId] ?? false),1396 expandedWorkGroups: {
1397 ...current.expandedWorkGroups,
1398 [groupId]: !(current.expandedWorkGroups[groupId] ?? false),
1399 },
876 }));1400 }));
877 }, []);1401 }, []);
8781402
1403 const onToggleWorkRow = useCallback((rowId: string) => {
1404 setInteractionState((current) => ({
1405 ...current,
1406 expandedWorkRows: {
1407 ...current.expandedWorkRows,
1408 [rowId]: !(current.expandedWorkRows[rowId] ?? false),
1409 },
1410 }));
1411 }, []);
1412
1413 const onToggleTurnFold = useCallback((turnId: TurnId) => {
1414 suppressAutoFollowRef.current = true;
1415 if (foldSettleFrameRef.current !== null) {
1416 cancelAnimationFrame(foldSettleFrameRef.current);
1417 }
1418 if (foldSettleSecondFrameRef.current !== null) {
1419 cancelAnimationFrame(foldSettleSecondFrameRef.current);
1420 }
1421 setInteractionState((current) => {
1422 const next = new Set(current.expandedTurnIds);
1423 if (next.has(turnId)) {
1424 next.delete(turnId);
1425 } else {
1426 next.add(turnId);
1427 }
1428 return { ...current, expandedTurnIds: next };
1429 });
1430 foldSettleFrameRef.current = requestAnimationFrame(() => {
1431 foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
1432 suppressAutoFollowRef.current = false;
1433 foldSettleFrameRef.current = null;
1434 foldSettleSecondFrameRef.current = null;
1435 });
1436 });
1437 }, []);
1438
879 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {1439 const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => {
880 setExpandedImage({ uri, headers });1440 setExpandedImage({ uri, headers });
881 }, []);1441 }, []);
...@@ -887,18 +1447,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -887,18 +1447,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
887 copiedRowId,1447 copiedRowId,
888 httpBaseUrl: props.httpBaseUrl,1448 httpBaseUrl: props.httpBaseUrl,
889 expandedWorkGroups,1449 expandedWorkGroups,
1450 expandedWorkRows,
1451 terminalAssistantMessageIds,
1452 unsettledTurnId,
890 onCopyWorkRow,1453 onCopyWorkRow,
891 onToggleWorkGroup,1454 onToggleWorkGroup,
1455 onToggleWorkRow,
1456 onToggleTurnFold,
892 onPressImage,1457 onPressImage,
893 iconSubtleColor,1458 iconSubtleColor,
894 userBubbleColor,1459 userBubbleColor,
895 markdownStyles,1460 markdownStyles,
896 reviewCommentColors,1461 reviewCommentColors,
897 reviewCommentBubbleWidth,1462 reviewCommentBubbleWidth,
1463 skills: props.skills,
898 }),1464 }),
899 [1465 [
900 copiedRowId,1466 copiedRowId,
901 expandedWorkGroups,1467 expandedWorkGroups,
1468 expandedWorkRows,
1469 terminalAssistantMessageIds,
1470 unsettledTurnId,
902 iconSubtleColor,1471 iconSubtleColor,
903 userBubbleColor,1472 userBubbleColor,
904 markdownStyles,1473 markdownStyles,
...@@ -906,9 +1475,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -906,9 +1475,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
906 reviewCommentBubbleWidth,1475 reviewCommentBubbleWidth,
907 onCopyWorkRow,1476 onCopyWorkRow,
908 onPressImage,1477 onPressImage,
1478 onToggleTurnFold,
909 onToggleWorkGroup,1479 onToggleWorkGroup,
1480 onToggleWorkRow,
910 props.bearerToken,1481 props.bearerToken,
911 props.httpBaseUrl,1482 props.httpBaseUrl,
1483 props.skills,
912 ],1484 ],
913 );1485 );
9141486
...@@ -935,33 +1507,46 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -935,33 +1507,46 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
9351507
936 return (1508 return (
937 <>1509 <>
938 <KeyboardAvoidingLegendList1510 <View style={{ flex: 1 }}>
939 ref={listRef}1511 <KeyboardAvoidingLegendList
940 key={props.threadId}1512 ref={listRef}
941 style={{ flex: 1 }}1513 key={props.threadId}
942 alignItemsAtEnd1514 style={{ flex: 1 }}
943 contentInsetAdjustmentBehavior="never"1515 automaticallyAdjustsScrollIndicatorInsets={false}
944 contentInset={{ top: topContentInset, bottom: bottomContentInset }}1516 contentInset={{ top: 0, bottom: 0 }}
945 scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }}1517 scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }}
946 data={props.feed as ThreadFeedEntry[]}1518 alignItemsAtEnd
947 renderItem={renderItem}1519 maintainScrollAtEnd={{
948 keyExtractor={(entry) => `${entry.type}:${entry.id}`}1520 animated: false,
949 getItemType={(entry) =>1521 on: {
950 entry.type === "message" ? `message:${entry.message.role}` : entry.type1522 dataChange: true,
951 }1523 itemLayout: true,
952 keyboardShouldPersistTaps="handled"1524 layout: true,
953 estimatedItemSize={180}1525 },
954 initialScrollAtEnd1526 }}
955 maintainScrollAtEnd={{1527 data={presentedFeed}
956 on: { layout: true, itemLayout: true, dataChange: true },1528 extraData={listAppearanceData}
957 }}1529 renderItem={renderItem}
958 maintainScrollAtEndThreshold={0.1}1530 keyExtractor={(entry) => `${entry.type}:${entry.id}`}
959 safeAreaInsetBottom={insets.bottom}1531 getItemType={(entry) =>
960 contentContainerStyle={{1532 entry.type === "message" ? `message:${entry.message.role}` : entry.type
961 paddingTop: 12,1533 }
962 paddingHorizontal: horizontalPadding,1534 keyboardShouldPersistTaps="always"
963 }}1535 keyboardDismissMode="none"
964 />1536 estimatedItemSize={180}
1537 initialScrollAtEnd
1538 onContentSizeChange={onListContentSizeChange}
1539 onLoad={onListLoad}
1540 onScroll={onListScroll}
1541 scrollEventThrottle={16}
1542 ListHeaderComponent={<View style={{ height: topContentInset }} />}
1543 contentContainerStyle={{
1544 paddingTop: 12,
1545 paddingBottom: bottomContentInset,
1546 paddingHorizontal: horizontalPadding,
1547 }}
1548 />
1549 </View>
9651550
966 <ImageViewing1551 <ImageViewing
967 images={1552 images={
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2-3
...@@ -5,7 +5,7 @@ import * as Option from "effect/Option";...@@ -5,7 +5,7 @@ import * as Option from "effect/Option";
5import { pipe } from "effect/Function";5import { pipe } from "effect/Function";
6import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";6import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
7import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";7import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
8import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";8import { Pressable, ScrollView, Text as RNText, View } from "react-native";
9import { useThemeColor } from "../../lib/useThemeColor";9import { useThemeColor } from "../../lib/useThemeColor";
10import { useVcsStatus } from "../../state/use-vcs-status";10import { useVcsStatus } from "../../state/use-vcs-status";
11import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";11import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
...@@ -91,10 +91,9 @@ export function ThreadRouteScreen() {...@@ -91,10 +91,9 @@ export function ThreadRouteScreen() {
91 pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError;91 pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError;
9292
93 /* ─── Native header theming ──────────────────────────────────────── */93 /* ─── Native header theming ──────────────────────────────────────── */
94 const isDark = useColorScheme() === "dark";
95 const iconColor = String(useThemeColor("--color-icon"));94 const iconColor = String(useThemeColor("--color-icon"));
96 const foregroundColor = String(useThemeColor("--color-foreground"));95 const foregroundColor = String(useThemeColor("--color-foreground"));
97 const secondaryFg = isDark ? "#a3a3a3" : "#525252";96 const secondaryFg = String(useThemeColor("--color-foreground-secondary"));
9897
99 /* ─── Git status for native header trigger ───────────────────────── */98 /* ─── Git status for native header trigger ───────────────────────── */
100 const gitStatus = useVcsStatus({99 const gitStatus = useVcsStatus({
apps/mobile/src/features/threads/new-task-flow-provider.tsx+10
...@@ -5,6 +5,7 @@ import type {...@@ -5,6 +5,7 @@ import type {
5 ModelSelection,5 ModelSelection,
6 ProviderInteractionMode,6 ProviderInteractionMode,
7 RuntimeMode,7 RuntimeMode,
8 ServerProviderSkill,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";10import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";
10import * as Arr from "effect/Array";11import * as Arr from "effect/Array";
...@@ -94,6 +95,7 @@ type NewTaskFlowContextValue = {...@@ -94,6 +95,7 @@ type NewTaskFlowContextValue = {
94 readonly modelOptions: ReadonlyArray<ModelOption>;95 readonly modelOptions: ReadonlyArray<ModelOption>;
95 readonly selectedModel: ModelSelection | null;96 readonly selectedModel: ModelSelection | null;
96 readonly selectedModelOption: ModelOption | null;97 readonly selectedModelOption: ModelOption | null;
98 readonly selectedProviderSkills: ReadonlyArray<ServerProviderSkill>;
97 readonly providerGroups: ReadonlyArray<ProviderGroup>;99 readonly providerGroups: ReadonlyArray<ProviderGroup>;
98 readonly filteredBranches: ReadonlyArray<VcsRef>;100 readonly filteredBranches: ReadonlyArray<VcsRef>;
99 readonly reset: () => void;101 readonly reset: () => void;
...@@ -283,6 +285,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -283,6 +285,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
283 option.selection.instanceId === selectedModel.instanceId &&285 option.selection.instanceId === selectedModel.instanceId &&
284 option.selection.model === selectedModel.model,286 option.selection.model === selectedModel.model,
285 ) ?? null;287 ) ?? null;
288 const selectedProviderSkills =
289 (selectedProject
290 ? serverConfigByEnvironmentId[selectedProject.environmentId]
291 : null
292 )?.providers.find((provider) => provider.instanceId === selectedModel?.instanceId)?.skills ??
293 [];
286294
287 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);295 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
288 const setPrompt = useCallback(296 const setPrompt = useCallback(
...@@ -450,6 +458,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -450,6 +458,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
450 modelOptions,458 modelOptions,
451 selectedModel,459 selectedModel,
452 selectedModelOption,460 selectedModelOption,
461 selectedProviderSkills,
453 providerGroups,462 providerGroups,
454 filteredBranches,463 filteredBranches,
455 reset,464 reset,
...@@ -498,6 +507,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -498,6 +507,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
498 selectedModel,507 selectedModel,
499 selectedModelKey,508 selectedModelKey,
500 selectedModelOption,509 selectedModelOption,
510 selectedProviderSkills,
501 selectedProject,511 selectedProject,
502 selectedProjectKey,512 selectedProjectKey,
503 selectedWorktreePath,513 selectedWorktreePath,
apps/mobile/src/lib/composerImages.test.ts created+94
...@@ -0,0 +1,94 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
3
4const files = new Map<string, { base64: string; deleted: boolean }>();
5
6vi.mock("expo-file-system", () => ({
7 File: class {
8 readonly uri: string;
9
10 constructor(uri: string) {
11 this.uri = uri;
12 }
13
14 get exists(): boolean {
15 return files.has(this.uri) && files.get(this.uri)?.deleted === false;
16 }
17
18 async base64(): Promise<string> {
19 const entry = files.get(this.uri);
20 if (!entry || entry.deleted) {
21 throw new Error("missing file");
22 }
23 return entry.base64;
24 }
25
26 delete(): void {
27 const entry = files.get(this.uri);
28 if (entry) {
29 entry.deleted = true;
30 }
31 }
32 },
33}));
34
35vi.mock("./uuid", () => ({
36 uuidv4: () => "attachment-id",
37}));
38
39import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages";
40
41describe("native pasted image cleanup", () => {
42 beforeEach(() => {
43 files.clear();
44 });
45
46 it("recognizes only files created in the native composer paste directory", () => {
47 expect(
48 isOwnedPastedImageUri(
49 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png",
50 ),
51 ).toBe(true);
52 expect(isOwnedPastedImageUri("file:///private/var/mobile/photos/id.png")).toBe(false);
53 expect(isOwnedPastedImageUri("https://example.com/t3-composer-paste/id.png")).toBe(false);
54 });
55
56 it("converts owned files to data-backed previews and deletes the source", async () => {
57 const uri =
58 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png";
59 files.set(uri, { base64: "aGVsbG8=", deleted: false });
60
61 const attachments = await convertPastedImagesToAttachments({
62 uris: [uri],
63 existingCount: 0,
64 });
65
66 expect(attachments).toEqual([
67 expect.objectContaining({
68 dataUrl: "data:image/png;base64,aGVsbG8=",
69 previewUri: "data:image/png;base64,aGVsbG8=",
70 }),
71 ]);
72 expect(files.get(uri)?.deleted).toBe(true);
73 });
74
75 it("deletes rejected and overflow owned files without deleting user-owned files", async () => {
76 const rejected =
77 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/bad.png";
78 const overflow =
79 "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/overflow.png";
80 const userOwned = "file:///private/var/mobile/photos/library.png";
81 files.set(rejected, { base64: "", deleted: false });
82 files.set(overflow, { base64: "aGVsbG8=", deleted: false });
83 files.set(userOwned, { base64: "aGVsbG8=", deleted: false });
84
85 await convertPastedImagesToAttachments({
86 uris: [rejected, overflow, userOwned],
87 existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1,
88 });
89
90 expect(files.get(rejected)?.deleted).toBe(true);
91 expect(files.get(overflow)?.deleted).toBe(true);
92 expect(files.get(userOwned)?.deleted).toBe(false);
93 });
94});
apps/mobile/src/lib/composerImages.ts+34-3
...@@ -10,6 +10,8 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment...@@ -10,6 +10,8 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment
10 readonly previewUri: string;10 readonly previewUri: string;
11}11}
1212
13const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";
14
13function estimateBase64ByteSize(base64: string): number {15function estimateBase64ByteSize(base64: string): number {
14 const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;16 const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
15 return Math.floor((base64.length * 3) / 4) - padding;17 return Math.floor((base64.length * 3) / 4) - padding;
...@@ -213,17 +215,35 @@ function mimeTypeFromUri(uri: string): string {...@@ -213,17 +215,35 @@ function mimeTypeFromUri(uri: string): string {
213 }215 }
214}216}
215217
218export function isOwnedPastedImageUri(uri: string): boolean {
219 try {
220 const url = new URL(uri);
221 if (url.protocol !== "file:") {
222 return false;
223 }
224 const segments = url.pathname.split("/").filter(Boolean);
225 return (
226 segments.at(-2) === OWNED_PASTED_IMAGE_DIRECTORY && segments.at(-1)?.endsWith(".png") === true
227 );
228 } catch {
229 return false;
230 }
231}
232
216export async function convertPastedImagesToAttachments(input: {233export async function convertPastedImagesToAttachments(input: {
217 readonly uris: ReadonlyArray<string>;234 readonly uris: ReadonlyArray<string>;
218 readonly existingCount: number;235 readonly existingCount: number;
219}): Promise<ReadonlyArray<DraftComposerImageAttachment>> {236}): Promise<ReadonlyArray<DraftComposerImageAttachment>> {
220 const { File } = await import("expo-file-system");237 const { File } = await import("expo-file-system");
221 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;238 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
222 const uris = input.uris.slice(0, Math.max(0, remainingSlots));
223 const results: DraftComposerImageAttachment[] = [];239 const results: DraftComposerImageAttachment[] = [];
224240
225 for (const uri of uris) {241 for (const [index, uri] of input.uris.entries()) {
242 const ownedTemporaryFile = isOwnedPastedImageUri(uri);
226 try {243 try {
244 if (index >= Math.max(0, remainingSlots)) {
245 continue;
246 }
227 const file = new File(uri);247 const file = new File(uri);
228 const base64 = await file.base64();248 const base64 = await file.base64();
229 const sizeBytes = estimateBase64ByteSize(base64);249 const sizeBytes = estimateBase64ByteSize(base64);
...@@ -238,10 +258,21 @@ export async function convertPastedImagesToAttachments(input: {...@@ -238,10 +258,21 @@ export async function convertPastedImagesToAttachments(input: {
238 mimeType,258 mimeType,
239 sizeBytes,259 sizeBytes,
240 dataUrl: `data:${mimeType};base64,${base64}`,260 dataUrl: `data:${mimeType};base64,${base64}`,
241 previewUri: uri,261 previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri,
242 });262 });
243 } catch (error) {263 } catch (error) {
244 console.warn("Failed to read pasted image", uri, error);264 console.warn("Failed to read pasted image", uri, error);
265 } finally {
266 if (ownedTemporaryFile) {
267 try {
268 const file = new File(uri);
269 if (file.exists) {
270 file.delete();
271 }
272 } catch (error) {
273 console.warn("Failed to remove temporary pasted image", uri, error);
274 }
275 }
245 }276 }
246 }277 }
247278
apps/mobile/src/lib/copyTextWithHaptic.test.ts created+34
...@@ -0,0 +1,34 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({
4 impactAsync: vi.fn(),
5 setStringAsync: vi.fn(),
6}));
7
8vi.mock("expo-clipboard", () => ({
9 setStringAsync: mocks.setStringAsync,
10}));
11
12vi.mock("expo-haptics", () => ({
13 ImpactFeedbackStyle: {
14 Light: "light",
15 },
16 impactAsync: mocks.impactAsync,
17}));
18
19import { copyTextWithHaptic } from "./copyTextWithHaptic";
20
21describe("copyTextWithHaptic", () => {
22 beforeEach(() => {
23 vi.clearAllMocks();
24 mocks.setStringAsync.mockReturnValue(new Promise<void>(() => undefined));
25 mocks.impactAsync.mockResolvedValue(undefined);
26 });
27
28 it("triggers haptic feedback without waiting for the clipboard promise", () => {
29 copyTextWithHaptic("trace-123");
30
31 expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123");
32 expect(mocks.impactAsync).toHaveBeenCalledWith("light");
33 });
34});
apps/mobile/src/lib/copyTextWithHaptic.ts created+7
...@@ -0,0 +1,7 @@
1import * as Clipboard from "expo-clipboard";
2import * as Haptics from "expo-haptics";
3
4export function copyTextWithHaptic(value: string): void {
5 void Clipboard.setStringAsync(value);
6 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
7}
apps/mobile/src/lib/markdownLinks.test.ts created+67
...@@ -0,0 +1,67 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
4
5describe("resolveMarkdownLinkPresentation", () => {
6 it("extracts external link hosts", () => {
7 expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({
8 kind: "external",
9 href: "https://example.com/docs?q=1",
10 host: "example.com",
11 });
12 });
13
14 it("renders file URLs as basename pills with positions", () => {
15 expect(
16 resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"),
17 ).toEqual({
18 kind: "file",
19 icon: "typescript",
20 label: "main.ts:42:7",
21 });
22 });
23
24 it("recognizes relative source paths and bare filenames", () => {
25 expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({
26 kind: "file",
27 icon: "typescript",
28 label: "index.ts:10",
29 });
30 expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({
31 kind: "file",
32 icon: "agents",
33 label: "AGENTS.md",
34 });
35 expect(resolveMarkdownLinkPresentation("package.json")).toEqual({
36 kind: "file",
37 icon: "package",
38 label: "package.json",
39 });
40 });
41
42 it("uses the Pierre complete icon mappings", () => {
43 expect(resolveMarkdownLinkPresentation("src/Button.tsx")).toMatchObject({
44 kind: "file",
45 icon: "react",
46 });
47 expect(resolveMarkdownLinkPresentation("vite.config.ts")).toMatchObject({
48 kind: "file",
49 icon: "vite",
50 });
51 expect(resolveMarkdownLinkPresentation("Dockerfile")).toMatchObject({
52 kind: "file",
53 icon: "docker",
54 });
55 expect(resolveMarkdownLinkPresentation("pnpm-lock.yaml")).toMatchObject({
56 kind: "file",
57 icon: "pnpm",
58 });
59 });
60
61 it("does not style app routes as file links", () => {
62 expect(resolveMarkdownLinkPresentation("/chat/settings")).toEqual({
63 kind: "link",
64 href: null,
65 });
66 });
67});
apps/mobile/src/lib/nativeMarkdownText.test.ts created+734
...@@ -0,0 +1,734 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { MarkdownNode } from "react-native-nitro-markdown/headless";
3
4import {
5 nativeMarkdownChunkSpacing,
6 nativeMarkdownDocumentChunks,
7 nativeMarkdownDocumentRuns,
8 nativeMarkdownListItemBlocks,
9 nativeMarkdownTextRuns,
10} from "@t3tools/mobile-markdown-text/markdown";
11
12describe("nativeMarkdownTextRuns", () => {
13 it("preserves inline emphasis and code styles", () => {
14 const node: MarkdownNode = {
15 type: "paragraph",
16 children: [
17 { type: "text", content: "plain " },
18 { type: "bold", children: [{ type: "text", content: "bold" }] },
19 { type: "text", content: " " },
20 { type: "code_inline", content: "const value = 1" },
21 ],
22 };
23
24 expect(nativeMarkdownTextRuns(node)).toEqual([
25 { text: "plain " },
26 { text: "bold", bold: true },
27 { text: " " },
28 { text: "const value = 1", code: true },
29 ]);
30 });
31
32 it("normalizes external and file links for native presentation", () => {
33 const node: MarkdownNode = {
34 type: "paragraph",
35 children: [
36 {
37 type: "link",
38 href: "https://example.com/docs",
39 children: [{ type: "text", content: "Docs" }],
40 },
41 { type: "text", content: " " },
42 {
43 type: "link",
44 href: "file:///repo/README.md#L12",
45 children: [{ type: "text", content: "ignored label" }],
46 },
47 ],
48 };
49
50 expect(nativeMarkdownTextRuns(node)).toEqual([
51 {
52 text: "Docs",
53 href: "https://example.com/docs",
54 externalHost: "example.com",
55 },
56 { text: " " },
57 { text: "README.md:12", fileIcon: "readme" },
58 ]);
59 });
60
61 it("keeps hard breaks and collapses soft breaks", () => {
62 const node: MarkdownNode = {
63 type: "paragraph",
64 children: [
65 { type: "text", content: "first" },
66 { type: "soft_break" },
67 { type: "text", content: "second" },
68 { type: "line_break" },
69 { type: "text", content: "third" },
70 ],
71 };
72
73 expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]);
74 });
75
76 it("normalizes common inline HTML and entities", () => {
77 const node: MarkdownNode = {
78 type: "paragraph",
79 children: [
80 { type: "text", content: "Less than: &lt; " },
81 { type: "html_inline", content: "<kbd>" },
82 { type: "text", content: "⌘" },
83 { type: "html_inline", content: "</kbd>" },
84 { type: "html_inline", content: "<br />" },
85 { type: "html_inline", content: "<mark>highlighted</mark>" },
86 ],
87 };
88
89 expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "Less than: < ⌘\nhighlighted" }]);
90 });
91
92 it("normalizes double-encoded entities and inline tags emitted as text", () => {
93 const node: MarkdownNode = {
94 type: "paragraph",
95 children: [
96 {
97 type: "text",
98 content:
99 "Keyboard: <kbd>⌘</kbd> + <kbd>K</kbd>; Less than: &amp;lt;; Greater than: &amp;gt;",
100 },
101 ],
102 };
103
104 expect(nativeMarkdownTextRuns(node)).toEqual([
105 { text: "Keyboard: ⌘ + K; Less than: <; Greater than: >" },
106 ]);
107 });
108
109 it("reads inline content from nested text nodes", () => {
110 const node: MarkdownNode = {
111 type: "paragraph",
112 children: [
113 {
114 type: "text",
115 children: [{ type: "text", content: "Plain text" }],
116 },
117 { type: "text", content: " and " },
118 {
119 type: "code_inline",
120 children: [{ type: "text", content: "inline code" }],
121 },
122 ],
123 };
124
125 expect(nativeMarkdownTextRuns(node)).toEqual([
126 { text: "Plain text and " },
127 { text: "inline code", code: true },
128 ]);
129 });
130});
131
132describe("nativeMarkdownDocumentRuns", () => {
133 it("decorates known skill references as selectable skill chips", () => {
134 const node: MarkdownNode = {
135 type: "document",
136 children: [
137 {
138 type: "paragraph",
139 children: [{ type: "text", content: "Use $ui for this." }],
140 },
141 ],
142 };
143
144 expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([
145 { text: "Use ", role: "body" },
146 {
147 text: "$ui",
148 role: "body",
149 skillName: "ui",
150 skillLabel: "UI",
151 },
152 { text: " for this.", role: "body" },
153 ]);
154 });
155
156 it("leaves unknown skill-like text unchanged", () => {
157 const node: MarkdownNode = {
158 type: "document",
159 children: [
160 {
161 type: "paragraph",
162 children: [{ type: "text", content: "Use $unknown for this." }],
163 },
164 ],
165 };
166
167 expect(nativeMarkdownDocumentRuns(node, [])).toEqual([
168 { text: "Use $unknown for this.", role: "body" },
169 ]);
170 });
171
172 it("keeps headings, paragraphs, and lists in one continuous document", () => {
173 const node: MarkdownNode = {
174 type: "document",
175 children: [
176 {
177 type: "heading",
178 level: 1,
179 children: [{ type: "text", content: "Header One" }],
180 },
181 {
182 type: "paragraph",
183 children: [
184 { type: "text", content: "A paragraph with " },
185 { type: "bold", children: [{ type: "text", content: "bold text" }] },
186 { type: "text", content: "." },
187 ],
188 },
189 {
190 type: "list",
191 ordered: false,
192 children: [
193 {
194 type: "list_item",
195 children: [
196 {
197 type: "paragraph",
198 children: [{ type: "text", content: "First item" }],
199 },
200 ],
201 },
202 {
203 type: "list_item",
204 children: [
205 {
206 type: "paragraph",
207 children: [{ type: "text", content: "Second item" }],
208 },
209 ],
210 },
211 ],
212 },
213 ],
214 };
215
216 const runs = nativeMarkdownDocumentRuns(node);
217 expect(runs.map((run) => run.text).join("")).toBe(
218 "Header One\n\nA paragraph with bold text.\n\n•\tFirst item\n•\tSecond item",
219 );
220 expect(runs).toContainEqual({
221 text: "Header One\n",
222 role: "heading",
223 headingLevel: 1,
224 });
225 expect(runs).toContainEqual({
226 text: "bold text",
227 bold: true,
228 role: "body",
229 });
230 expect(runs).toContainEqual({
231 text: "•\t",
232 role: "list-marker",
233 depth: 1,
234 firstLineHeadIndent: 0,
235 headIndent: 24,
236 paragraphSpacing: 2,
237 });
238 });
239
240 it("uses distinct section, heading-content, and body spacing", () => {
241 const node: MarkdownNode = {
242 type: "document",
243 children: [
244 {
245 type: "paragraph",
246 children: [{ type: "text", content: "Intro" }],
247 },
248 {
249 type: "heading",
250 level: 2,
251 children: [{ type: "text", content: "Section" }],
252 },
253 {
254 type: "paragraph",
255 children: [{ type: "text", content: "First paragraph" }],
256 },
257 {
258 type: "paragraph",
259 children: [{ type: "text", content: "Second paragraph" }],
260 },
261 ],
262 };
263
264 expect(
265 nativeMarkdownDocumentRuns(node)
266 .filter((run) => run.role === "spacer")
267 .map((run) => run.spacing),
268 ).toEqual([20, 10, 12]);
269 });
270
271 it("renders tight list items whose inline nodes are direct children", () => {
272 const node: MarkdownNode = {
273 type: "document",
274 children: [
275 {
276 type: "list",
277 children: [
278 {
279 type: "list_item",
280 children: [
281 {
282 type: "bold",
283 children: [{ type: "text", content: "Finding:" }],
284 },
285 { type: "text", content: " details with " },
286 { type: "code_inline", content: "inline code" },
287 { type: "text", content: "." },
288 ],
289 },
290 ],
291 },
292 ],
293 };
294
295 expect(nativeMarkdownDocumentRuns(node)).toEqual([
296 {
297 text: "•\t",
298 role: "list-marker",
299 depth: 1,
300 firstLineHeadIndent: 0,
301 headIndent: 24,
302 paragraphSpacing: 2,
303 },
304 { text: "Finding:", bold: true, role: "body", depth: 1 },
305 { text: " details with ", role: "body", depth: 1 },
306 { text: "inline code", code: true, role: "body", depth: 1 },
307 { text: ".", role: "body", depth: 1 },
308 ]);
309 });
310
311 it("includes quotes and fenced code in the same selectable string", () => {
312 const node: MarkdownNode = {
313 type: "document",
314 children: [
315 {
316 type: "blockquote",
317 children: [
318 {
319 type: "paragraph",
320 children: [{ type: "text", content: "Read this" }],
321 },
322 ],
323 },
324 {
325 type: "code_block",
326 language: "ts",
327 content: "const answer = 42;",
328 },
329 ],
330 };
331
332 const runs = nativeMarkdownDocumentRuns(node);
333 expect(runs.map((run) => run.text).join("")).toBe("│\u00a0Read this\n\nTS\nconst answer = 42;");
334 expect(runs).toContainEqual({
335 text: "const answer = 42;",
336 code: true,
337 role: "code-block",
338 });
339 });
340
341 it("reads fenced code content from child text nodes", () => {
342 const node: MarkdownNode = {
343 type: "document",
344 children: [
345 {
346 type: "code_block",
347 language: "bash",
348 children: [{ type: "text", content: "pnpm install\n" }],
349 },
350 ],
351 };
352
353 expect(
354 nativeMarkdownDocumentRuns(node)
355 .map((run) => run.text)
356 .join(""),
357 ).toBe("BASH\npnpm install");
358 });
359});
360
361describe("nativeMarkdownListItemBlocks", () => {
362 it("groups consecutive inline nodes into one paragraph block", () => {
363 const item: MarkdownNode = {
364 type: "list_item",
365 children: [
366 { type: "text", content: "Finding: " },
367 { type: "bold", children: [{ type: "text", content: "important" }] },
368 { type: "text", content: " details." },
369 {
370 type: "list",
371 children: [
372 {
373 type: "list_item",
374 children: [{ type: "text", content: "Nested" }],
375 },
376 ],
377 },
378 { type: "text", content: "Trailing prose." },
379 ],
380 };
381
382 expect(nativeMarkdownListItemBlocks(item)).toEqual([
383 {
384 type: "paragraph",
385 children: item.children?.slice(0, 3),
386 },
387 item.children?.[3],
388 {
389 type: "paragraph",
390 children: [item.children?.[4]],
391 },
392 ]);
393 });
394});
395
396describe("nativeMarkdownDocumentChunks", () => {
397 it("keeps headings and plain lists in one selectable document", () => {
398 const document: MarkdownNode = {
399 type: "document",
400 children: [
401 {
402 type: "heading",
403 level: 2,
404 children: [{ type: "text", content: "Tasks" }],
405 },
406 {
407 type: "list",
408 children: [
409 {
410 type: "task_list_item",
411 checked: true,
412 children: [
413 {
414 type: "paragraph",
415 children: [{ type: "text", content: "Completed" }],
416 },
417 ],
418 },
419 {
420 type: "list_item",
421 children: [
422 {
423 type: "paragraph",
424 children: [{ type: "text", content: "Parent" }],
425 },
426 {
427 type: "list",
428 children: [
429 {
430 type: "list_item",
431 children: [
432 {
433 type: "paragraph",
434 children: [{ type: "text", content: "Nested" }],
435 },
436 ],
437 },
438 ],
439 },
440 ],
441 },
442 ],
443 },
444 ],
445 };
446
447 const chunks = nativeMarkdownDocumentChunks(document);
448 expect(chunks).toHaveLength(1);
449 expect(chunks[0]).toMatchObject({ kind: "selectable" });
450 expect(
451 nativeMarkdownDocumentRuns(chunks[0]?.node ?? document)
452 .map((run) => run.text)
453 .join(""),
454 ).toBe("Tasks\n\n☑︎\tCompleted\n•\tParent\n◦\tNested");
455 });
456
457 it("aligns ordered markers while keeping the list in one selectable string", () => {
458 const document: MarkdownNode = {
459 type: "document",
460 children: [
461 {
462 type: "list",
463 ordered: true,
464 start: 9,
465 children: [
466 {
467 type: "list_item",
468 children: [{ type: "text", content: "Ninth" }],
469 },
470 {
471 type: "list_item",
472 children: [{ type: "text", content: "Tenth" }],
473 },
474 ],
475 },
476 ],
477 };
478
479 expect(
480 nativeMarkdownDocumentRuns(document)
481 .map((run) => run.text)
482 .join(""),
483 ).toBe("\u20079.\tNinth\n10.\tTenth");
484 });
485
486 it("keeps prose selectable while exposing rich AST blocks", () => {
487 const document: MarkdownNode = {
488 type: "document",
489 children: [
490 {
491 type: "heading",
492 level: 1,
493 beg: 0,
494 end: 9,
495 children: [{ type: "text", content: "Install" }],
496 },
497 {
498 type: "code_block",
499 language: "bash",
500 beg: 11,
501 end: 35,
502 children: [{ type: "text", content: "pnpm install\n" }],
503 },
504 {
505 type: "paragraph",
506 beg: 37,
507 end: 42,
508 children: [{ type: "text", content: "Done." }],
509 },
510 ],
511 };
512
513 const chunks = nativeMarkdownDocumentChunks(document);
514 expect(chunks).toHaveLength(3);
515 expect(chunks[0]).toMatchObject({ kind: "selectable" });
516 expect(chunks[1]).toEqual({
517 kind: "rich",
518 key: "rich:code_block:11:35",
519 node: document.children?.[1],
520 });
521 expect(chunks[2]).toMatchObject({ kind: "selectable" });
522 });
523
524 it("keeps a list containing fenced code as one rich AST container", () => {
525 const document: MarkdownNode = {
526 type: "document",
527 children: [
528 {
529 type: "list",
530 beg: 0,
531 end: 45,
532 children: [
533 {
534 type: "list_item",
535 children: [
536 {
537 type: "paragraph",
538 children: [{ type: "text", content: "Install" }],
539 },
540 {
541 type: "code_block",
542 language: "bash",
543 children: [{ type: "text", content: "pnpm install\n" }],
544 },
545 ],
546 },
547 ],
548 },
549 ],
550 };
551
552 expect(nativeMarkdownDocumentChunks(document)).toEqual([
553 {
554 kind: "rich",
555 key: "rich:list:0:45",
556 node: document.children?.[0],
557 },
558 ]);
559 });
560
561 it("keeps surrounding prose selectable when rich nodes have no source offsets", () => {
562 const document: MarkdownNode = {
563 type: "document",
564 children: [
565 {
566 type: "heading",
567 level: 1,
568 children: [{ type: "text", content: "Before" }],
569 },
570 { type: "horizontal_rule" },
571 {
572 type: "paragraph",
573 children: [{ type: "text", content: "After." }],
574 },
575 ],
576 };
577
578 const chunks = nativeMarkdownDocumentChunks(document);
579 expect(chunks).toHaveLength(3);
580 expect(chunks[0]).toMatchObject({ kind: "selectable" });
581 expect(chunks[1]).toEqual({
582 kind: "rich",
583 key: "rich:horizontal_rule:1:1",
584 node: document.children?.[1],
585 });
586 expect(chunks[2]).toMatchObject({ kind: "selectable" });
587 });
588
589 it("keeps offset-free structural lists isolated without promoting the whole document", () => {
590 const document: MarkdownNode = {
591 type: "document",
592 children: [
593 {
594 type: "paragraph",
595 children: [{ type: "text", content: "Before." }],
596 },
597 {
598 type: "list",
599 ordered: true,
600 children: [
601 {
602 type: "list_item",
603 children: [
604 {
605 type: "paragraph",
606 children: [{ type: "text", content: "Install" }],
607 },
608 {
609 type: "code_block",
610 language: "bash",
611 children: [{ type: "text", content: "pnpm install\n" }],
612 },
613 ],
614 },
615 ],
616 },
617 {
618 type: "paragraph",
619 children: [{ type: "text", content: "After." }],
620 },
621 ],
622 };
623
624 const chunks = nativeMarkdownDocumentChunks(document);
625 expect(chunks).toHaveLength(3);
626 expect(chunks[0]).toMatchObject({ kind: "selectable" });
627 expect(chunks[1]).toEqual({
628 kind: "rich",
629 key: "rich:list:1:1",
630 node: document.children?.[1],
631 });
632 expect(chunks[2]).toMatchObject({ kind: "selectable" });
633 });
634
635 it("never collapses a rich subtree into a second markdown parsing pass", () => {
636 const document: MarkdownNode = {
637 type: "document",
638 children: [
639 {
640 type: "paragraph",
641 children: [{ type: "text", content: "Before." }],
642 },
643 {
644 type: "blockquote",
645 children: [
646 {
647 type: "list",
648 children: [
649 {
650 type: "list_item",
651 children: [
652 { type: "text", content: "Run this" },
653 {
654 type: "code_block",
655 language: "sh",
656 children: [{ type: "text", content: "vp check\n" }],
657 },
658 ],
659 },
660 ],
661 },
662 ],
663 },
664 {
665 type: "paragraph",
666 children: [{ type: "text", content: "After." }],
667 },
668 ],
669 };
670
671 const chunks = nativeMarkdownDocumentChunks(document);
672 expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich", "selectable"]);
673 expect(chunks[1]).toMatchObject({
674 kind: "rich",
675 node: { type: "blockquote" },
676 });
677 });
678
679 it("keeps a plain list in one selectable native text container", () => {
680 const list: MarkdownNode = {
681 type: "list",
682 ordered: false,
683 children: [
684 {
685 type: "list_item",
686 children: [{ type: "text", content: "First" }],
687 },
688 ],
689 };
690
691 const chunks = nativeMarkdownDocumentChunks({
692 type: "document",
693 children: [list],
694 });
695
696 expect(chunks).toHaveLength(1);
697 expect(chunks[0]).toMatchObject({
698 kind: "selectable",
699 node: { type: "document", children: [list] },
700 });
701 });
702
703 it("separates sections more than related rich blocks", () => {
704 const headingChunk = {
705 kind: "selectable" as const,
706 key: "heading",
707 node: {
708 type: "document",
709 children: [
710 {
711 type: "heading",
712 level: 2,
713 children: [{ type: "text", content: "Section" }],
714 },
715 ],
716 } satisfies MarkdownNode,
717 };
718 const firstList = {
719 kind: "rich" as const,
720 key: "list-1",
721 node: { type: "list", children: [] } satisfies MarkdownNode,
722 };
723 const secondList = {
724 kind: "rich" as const,
725 key: "list-2",
726 node: { type: "list", children: [] } satisfies MarkdownNode,
727 };
728
729 expect(nativeMarkdownChunkSpacing(undefined, headingChunk)).toBe(0);
730 expect(nativeMarkdownChunkSpacing(headingChunk, firstList)).toBe(10);
731 expect(nativeMarkdownChunkSpacing(firstList, secondList)).toBe(12);
732 expect(nativeMarkdownChunkSpacing(firstList, headingChunk)).toBe(20);
733 });
734});
apps/mobile/src/lib/threadActivity.test.ts+204-18
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 EventId,4 EventId,
5 MessageId,
5 ProjectId,6 ProjectId,
6 ProviderInstanceId,7 ProviderInstanceId,
7 ThreadId,8 ThreadId,
...@@ -10,7 +11,7 @@ import {...@@ -10,7 +11,7 @@ import {
10 type OrchestrationThreadActivity,11 type OrchestrationThreadActivity,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
1213
13import { buildThreadFeed } from "./threadActivity";14import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity";
1415
15function makeActivity(16function makeActivity(
16 input: Partial<OrchestrationThreadActivity> &17 input: Partial<OrchestrationThreadActivity> &
...@@ -48,7 +49,7 @@ function makeThread(...@@ -48,7 +49,7 @@ function makeThread(
48}49}
4950
50describe("buildThreadFeed", () => {51describe("buildThreadFeed", () => {
51 it("includes runtime warnings from the latest turn", () => {52 it("keeps historic work entries attributed to their turns", () => {
52 const thread = makeThread({53 const thread = makeThread({
53 id: ThreadId.make("thread-1"),54 id: ThreadId.make("thread-1"),
54 projectId: ProjectId.make("project-1"),55 projectId: ProjectId.make("project-1"),
...@@ -86,22 +87,16 @@ describe("buildThreadFeed", () => {...@@ -86,22 +87,16 @@ describe("buildThreadFeed", () => {
86 });87 });
8788
88 const feed = buildThreadFeed(thread, [], null);89 const feed = buildThreadFeed(thread, [], null);
89 const group = feed[0];90 expect(feed).toMatchObject([
9091 {
91 expect(group).toMatchObject({92 type: "activity-group",
92 type: "activity-group",93 turnId: "turn-old",
93 });94 activities: [{ id: "activity-old", turnId: "turn-old" }],
94 if (!group || group.type !== "activity-group") {95 },
95 return;
96 }
97
98 expect(group.activities).toEqual([
99 {96 {
100 id: "activity-latest",97 type: "activity-group",
101 createdAt: "2026-04-01T00:00:03.000Z",98 turnId: "turn-latest",
102 summary: "Runtime warning",99 activities: [{ id: "activity-latest", turnId: "turn-latest" }],
103 detail: null,
104 status: null,
105 },100 },
106 ]);101 ]);
107 });102 });
...@@ -163,10 +158,201 @@ describe("buildThreadFeed", () => {...@@ -163,10 +158,201 @@ describe("buildThreadFeed", () => {
163 {158 {
164 id: "tool-completed",159 id: "tool-completed",
165 createdAt: "2026-04-01T00:00:02.000Z",160 createdAt: "2026-04-01T00:00:02.000Z",
161 turnId: "turn-1",
166 summary: "Run tests",162 summary: "Run tests",
167 detail: "bun run test",163 detail: "bun run test",
168 status: null,164 fullDetail: null,
165 copyText: "Run tests\nbun run test",
166 toolLike: true,
167 status: "success",
169 },168 },
170 ]);169 ]);
171 });170 });
171
172 it("folds settled turn work while leaving the terminal answer visible", () => {
173 const turnId = TurnId.make("turn-1");
174 const thread = makeThread({
175 id: ThreadId.make("thread-3"),
176 projectId: ProjectId.make("project-1"),
177 title: "Folded work",
178 latestTurn: {
179 turnId,
180 state: "completed",
181 requestedAt: "2026-04-01T00:00:00.000Z",
182 startedAt: "2026-04-01T00:00:01.000Z",
183 completedAt: "2026-04-01T00:00:18.000Z",
184 assistantMessageId: MessageId.make("assistant-final"),
185 },
186 messages: [
187 {
188 id: MessageId.make("assistant-commentary"),
189 role: "assistant",
190 text: "I am checking.",
191 turnId,
192 streaming: false,
193 createdAt: "2026-04-01T00:00:02.000Z",
194 updatedAt: "2026-04-01T00:00:03.000Z",
195 },
196 {
197 id: MessageId.make("assistant-final"),
198 role: "assistant",
199 text: "Done.",
200 turnId,
201 streaming: false,
202 createdAt: "2026-04-01T00:00:17.000Z",
203 updatedAt: "2026-04-01T00:00:18.000Z",
204 },
205 ],
206 activities: [
207 makeActivity({
208 id: EventId.make("tool-completed"),
209 kind: "tool.completed",
210 tone: "tool",
211 summary: "Read files",
212 createdAt: "2026-04-01T00:00:05.000Z",
213 turnId,
214 payload: {
215 title: "Read files",
216 itemType: "file_read",
217 status: "completed",
218 },
219 }),
220 ],
221 });
222
223 const feed = buildThreadFeed(thread, [], null);
224 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
225 expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]);
226 expect(collapsed[0]).toMatchObject({
227 type: "turn-fold",
228 label: "Worked for 17s",
229 expanded: false,
230 });
231
232 const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId]));
233 expect(expanded.map((entry) => entry.id)).toEqual([
234 "turn-fold:turn-1",
235 "assistant-commentary",
236 "tool-completed",
237 "assistant-final",
238 ]);
239 });
240
241 it("measures a steer-superseded turn from its user boundary through trailing work", () => {
242 const firstTurnId = TurnId.make("turn-1");
243 const secondTurnId = TurnId.make("turn-2");
244 const thread = makeThread({
245 id: ThreadId.make("thread-steered"),
246 projectId: ProjectId.make("project-1"),
247 title: "Steered work",
248 latestTurn: {
249 turnId: secondTurnId,
250 state: "running",
251 requestedAt: "2026-04-01T00:00:14.000Z",
252 startedAt: "2026-04-01T00:00:14.000Z",
253 completedAt: null,
254 assistantMessageId: MessageId.make("assistant-next"),
255 },
256 messages: [
257 {
258 id: MessageId.make("user-1"),
259 role: "user",
260 text: "Do it once more.",
261 turnId: null,
262 streaming: false,
263 createdAt: "2026-04-01T00:00:00.000Z",
264 updatedAt: "2026-04-01T00:00:00.000Z",
265 },
266 {
267 id: MessageId.make("assistant-commentary"),
268 role: "assistant",
269 text: "Kicking off call 1.",
270 turnId: firstTurnId,
271 streaming: false,
272 createdAt: "2026-04-01T00:00:09.000Z",
273 updatedAt: "2026-04-01T00:00:09.000Z",
274 },
275 {
276 id: MessageId.make("user-2"),
277 role: "user",
278 text: "Actually do 15.",
279 turnId: null,
280 streaming: false,
281 createdAt: "2026-04-01T00:00:14.000Z",
282 updatedAt: "2026-04-01T00:00:14.000Z",
283 },
284 {
285 id: MessageId.make("assistant-next"),
286 role: "assistant",
287 text: "One down - adjusting.",
288 turnId: secondTurnId,
289 streaming: true,
290 createdAt: "2026-04-01T00:00:17.000Z",
291 updatedAt: "2026-04-01T00:00:17.000Z",
292 },
293 ],
294 activities: [
295 makeActivity({
296 id: EventId.make("work-1"),
297 kind: "tool.completed",
298 tone: "tool",
299 summary: "Ran command",
300 createdAt: "2026-04-01T00:00:12.000Z",
301 turnId: firstTurnId,
302 payload: {
303 title: "Ran command",
304 itemType: "command_execution",
305 status: "completed",
306 },
307 }),
308 ],
309 });
310
311 const feed = buildThreadFeed(thread, [], null);
312 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
313 expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({
314 turnId: firstTurnId,
315 label: "Worked for 12s",
316 });
317 });
318
319 it("keeps an active turn expanded and classifies error-shaped tool output", () => {
320 const turnId = TurnId.make("turn-running");
321 const thread = makeThread({
322 id: ThreadId.make("thread-4"),
323 projectId: ProjectId.make("project-1"),
324 title: "Running work",
325 latestTurn: {
326 turnId,
327 state: "running",
328 requestedAt: "2026-04-01T00:00:00.000Z",
329 startedAt: "2026-04-01T00:00:01.000Z",
330 completedAt: null,
331 assistantMessageId: null,
332 },
333 activities: [
334 makeActivity({
335 id: EventId.make("tool-failed"),
336 kind: "tool.completed",
337 tone: "tool",
338 summary: "Run command",
339 createdAt: "2026-04-01T00:00:05.000Z",
340 turnId,
341 payload: {
342 title: "Run command",
343 itemType: "command_execution",
344 detail: "zsh: command not found: nope",
345 status: "completed",
346 },
347 }),
348 ],
349 });
350
351 const feed = buildThreadFeed(thread, [], null);
352 expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed);
353 expect(feed[0]).toMatchObject({
354 type: "activity-group",
355 activities: [{ status: "failure" }],
356 });
357 });
172});358});
apps/mobile/src/lib/threadActivity.ts+356-23
...@@ -3,13 +3,15 @@ import type {...@@ -3,13 +3,15 @@ import type {
3 CommandId,3 CommandId,
4 EnvironmentId,4 EnvironmentId,
5 MessageId,5 MessageId,
6 OrchestrationLatestTurn,
6 OrchestrationThread,7 OrchestrationThread,
7 OrchestrationThreadActivity,8 OrchestrationThreadActivity,
8 TurnId,
9 ToolLifecycleItemType,9 ToolLifecycleItemType,
10 ThreadId,10 ThreadId,
11 TurnId,
11 UserInputQuestion,12 UserInputQuestion,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1315
14import type { DraftComposerImageAttachment } from "./composerImages";16import type { DraftComposerImageAttachment } from "./composerImages";
15import * as Arr from "effect/Array";17import * as Arr from "effect/Array";
...@@ -46,14 +48,21 @@ export interface QueuedThreadMessage {...@@ -46,14 +48,21 @@ export interface QueuedThreadMessage {
46export interface ThreadFeedActivity {48export interface ThreadFeedActivity {
47 readonly id: string;49 readonly id: string;
48 readonly createdAt: string;50 readonly createdAt: string;
51 readonly turnId: TurnId | null;
49 readonly summary: string;52 readonly summary: string;
50 readonly detail: string | null;53 readonly detail: string | null;
51 readonly status: string | null;54 readonly fullDetail: string | null;
55 readonly copyText: string;
56 readonly toolLike: boolean;
57 readonly status: "success" | "failure" | "neutral" | null;
52}58}
5359
60type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped";
61
54interface WorkLogEntry {62interface WorkLogEntry {
55 id: string;63 id: string;
56 createdAt: string;64 createdAt: string;
65 turnId: TurnId | null;
57 label: string;66 label: string;
58 detail?: string;67 detail?: string;
59 command?: string;68 command?: string;
...@@ -63,6 +72,7 @@ interface WorkLogEntry {...@@ -63,6 +72,7 @@ interface WorkLogEntry {
63 toolTitle?: string;72 toolTitle?: string;
64 itemType?: ToolLifecycleItemType;73 itemType?: ToolLifecycleItemType;
65 requestKind?: PendingApproval["requestKind"];74 requestKind?: PendingApproval["requestKind"];
75 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
66}76}
6777
68interface DerivedWorkLogEntry extends WorkLogEntry {78interface DerivedWorkLogEntry extends WorkLogEntry {
...@@ -88,6 +98,7 @@ type RawThreadFeedEntry =...@@ -88,6 +98,7 @@ type RawThreadFeedEntry =
88 readonly type: "activity";98 readonly type: "activity";
89 readonly id: string;99 readonly id: string;
90 readonly createdAt: string;100 readonly createdAt: string;
101 readonly turnId: TurnId | null;
91 readonly activity: ThreadFeedActivity;102 readonly activity: ThreadFeedActivity;
92 };103 };
93104
...@@ -97,9 +108,23 @@ export type ThreadFeedEntry =...@@ -97,9 +108,23 @@ export type ThreadFeedEntry =
97 readonly type: "activity-group";108 readonly type: "activity-group";
98 readonly id: string;109 readonly id: string;
99 readonly createdAt: string;110 readonly createdAt: string;
111 readonly turnId: TurnId | null;
100 readonly activities: ReadonlyArray<ThreadFeedActivity>;112 readonly activities: ReadonlyArray<ThreadFeedActivity>;
113 }
114 | {
115 readonly type: "turn-fold";
116 readonly id: string;
117 readonly createdAt: string;
118 readonly turnId: TurnId;
119 readonly label: string;
120 readonly expanded: boolean;
101 };121 };
102122
123export type ThreadFeedLatestTurn = Pick<
124 OrchestrationLatestTurn,
125 "turnId" | "state" | "startedAt" | "completedAt"
126>;
127
103function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {128function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
104 switch (requestType) {129 switch (requestType) {
105 case "command_execution_approval":130 case "command_execution_approval":
...@@ -202,14 +227,12 @@ function resolvePendingUserInputAnswer(...@@ -202,14 +227,12 @@ function resolvePendingUserInputAnswer(
202227
203function deriveWorkLogEntries(228function deriveWorkLogEntries(
204 activities: ReadonlyArray<OrchestrationThreadActivity>,229 activities: ReadonlyArray<OrchestrationThreadActivity>,
205 latestTurnId: TurnId | undefined,
206): WorkLogEntry[] {230): WorkLogEntry[] {
207 const ordered = Arr.sort(activities, activityOrder);231 const ordered = Arr.sort(activities, activityOrder);
208 const entries: DerivedWorkLogEntry[] = [];232 const entries: DerivedWorkLogEntry[] = [];
209 for (const activity of ordered) {233 for (const activity of ordered) {
210 if (latestTurnId && activity.turnId !== latestTurnId) continue;
211 if (activity.kind === "tool.started") continue;234 if (activity.kind === "tool.started") continue;
212 if (activity.kind === "task.started" || activity.kind === "task.completed") continue;235 if (activity.kind === "task.started") continue;
213 if (activity.kind === "context-window.updated") continue;236 if (activity.kind === "context-window.updated") continue;
214 if (activity.summary === "Checkpoint captured") continue;237 if (activity.summary === "Checkpoint captured") continue;
215 if (isPlanBoundaryToolActivity(activity)) continue;238 if (isPlanBoundaryToolActivity(activity)) continue;
...@@ -240,16 +263,40 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -240,16 +263,40 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
240 const commandPreview = extractToolCommand(payload);263 const commandPreview = extractToolCommand(payload);
241 const changedFiles = extractChangedFiles(payload);264 const changedFiles = extractChangedFiles(payload);
242 const title = extractToolTitle(payload);265 const title = extractToolTitle(payload);
266 const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";
267 const taskSummary =
268 isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
269 ? payload.summary
270 : null;
271 const taskDetailAsLabel =
272 isTaskActivity &&
273 !taskSummary &&
274 typeof payload?.detail === "string" &&
275 payload.detail.length > 0
276 ? payload.detail
277 : null;
278 const taskLabel = taskSummary || taskDetailAsLabel;
243 const entry: DerivedWorkLogEntry = {279 const entry: DerivedWorkLogEntry = {
244 id: activity.id,280 id: activity.id,
245 createdAt: activity.createdAt,281 createdAt: activity.createdAt,
246 label: activity.summary,282 turnId: activity.turnId,
247 tone: activity.tone === "approval" ? "info" : activity.tone,283 label: taskLabel || activity.summary,
284 tone:
285 activity.kind === "task.progress"
286 ? "thinking"
287 : activity.tone === "approval"
288 ? "info"
289 : activity.tone,
248 activityKind: activity.kind,290 activityKind: activity.kind,
249 };291 };
250 const itemType = extractWorkLogItemType(payload);292 const itemType = extractWorkLogItemType(payload);
251 const requestKind = extractWorkLogRequestKind(payload);293 const requestKind = extractWorkLogRequestKind(payload);
252 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {294 if (
295 !taskDetailAsLabel &&
296 payload &&
297 typeof payload.detail === "string" &&
298 payload.detail.length > 0
299 ) {
253 const detail = stripTrailingExitCode(payload.detail).output;300 const detail = stripTrailingExitCode(payload.detail).output;
254 if (detail) {301 if (detail) {
255 entry.detail = detail;302 entry.detail = detail;
...@@ -273,6 +320,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -273,6 +320,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
273 if (requestKind) {320 if (requestKind) {
274 entry.requestKind = requestKind;321 entry.requestKind = requestKind;
275 }322 }
323 let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
324 if (!toolLifecycleStatus && activity.kind === "tool.completed") {
325 toolLifecycleStatus = "completed";
326 }
327 if (toolLifecycleStatus) {
328 entry.toolLifecycleStatus = toolLifecycleStatus;
329 }
276 const collapseKey = deriveToolLifecycleCollapseKey(entry);330 const collapseKey = deriveToolLifecycleCollapseKey(entry);
277 if (collapseKey) {331 if (collapseKey) {
278 entry.collapseKey = collapseKey;332 entry.collapseKey = collapseKey;
...@@ -323,6 +377,7 @@ function mergeDerivedWorkLogEntries(...@@ -323,6 +377,7 @@ function mergeDerivedWorkLogEntries(
323 const itemType = next.itemType ?? previous.itemType;377 const itemType = next.itemType ?? previous.itemType;
324 const requestKind = next.requestKind ?? previous.requestKind;378 const requestKind = next.requestKind ?? previous.requestKind;
325 const collapseKey = next.collapseKey ?? previous.collapseKey;379 const collapseKey = next.collapseKey ?? previous.collapseKey;
380 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
326 return {381 return {
327 ...previous,382 ...previous,
328 ...next,383 ...next,
...@@ -334,6 +389,7 @@ function mergeDerivedWorkLogEntries(...@@ -334,6 +389,7 @@ function mergeDerivedWorkLogEntries(
334 ...(itemType ? { itemType } : {}),389 ...(itemType ? { itemType } : {}),
335 ...(requestKind ? { requestKind } : {}),390 ...(requestKind ? { requestKind } : {}),
336 ...(collapseKey ? { collapseKey } : {}),391 ...(collapseKey ? { collapseKey } : {}),
392 ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
337 };393 };
338}394}
339395
...@@ -365,6 +421,78 @@ function normalizeCompactToolLabel(value: string): string {...@@ -365,6 +421,78 @@ function normalizeCompactToolLabel(value: string): string {
365 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();421 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
366}422}
367423
424function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
425 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
426 return true;
427 }
428 if (entry.command !== undefined && entry.command.trim().length > 0) {
429 return true;
430 }
431 if (entry.requestKind !== undefined) {
432 return true;
433 }
434 return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
435}
436
437function toolDetailTextLooksLikeFailure(text: string): boolean {
438 const normalized = text.toLowerCase();
439 return (
440 normalized.includes("file not found") ||
441 normalized.includes("no files found") ||
442 normalized.includes("enoent") ||
443 normalized.includes("no such file or directory") ||
444 normalized.includes("no such file") ||
445 normalized.includes("commandnotfoundexception") ||
446 normalized.includes("command not found") ||
447 (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) ||
448 (normalized.includes("is not recognized") && normalized.includes("the term '")) ||
449 /<exited with exit code\s+[1-9]\d*\s*>/i.test(text) ||
450 /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) ||
451 /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)
452 );
453}
454
455function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean {
456 if (entry.tone === "error") {
457 return true;
458 }
459 if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") {
460 return true;
461 }
462 if (!workLogEntryIsToolLike(entry)) {
463 return false;
464 }
465 return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n"));
466}
467
468function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
469 if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) {
470 return false;
471 }
472 if (entry.tone === "thinking") {
473 return false;
474 }
475 return (
476 entry.toolLifecycleStatus !== "inProgress" &&
477 entry.toolLifecycleStatus !== "stopped" &&
478 entry.toolLifecycleStatus !== "failed" &&
479 entry.toolLifecycleStatus !== "declined"
480 );
481}
482
483function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
484 if (!workLogEntryIsToolLike(entry)) {
485 return null;
486 }
487 if (workEntryIndicatesToolFailure(entry)) {
488 return "failure";
489 }
490 if (workEntryIndicatesToolSuccess(entry)) {
491 return "success";
492 }
493 return "neutral";
494}
495
368function workEntryPreview(496function workEntryPreview(
369 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,497 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,
370): string | null {498): string | null {
...@@ -592,6 +720,22 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul...@@ -592,6 +720,22 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
592 return asTrimmedString(payload?.title);720 return asTrimmedString(payload?.title);
593}721}
594722
723function extractWorkLogToolLifecycleStatus(
724 payload: Record<string, unknown> | null,
725): WorkLogToolLifecycleStatus | undefined {
726 const status = payload?.status;
727 if (
728 status === "inProgress" ||
729 status === "completed" ||
730 status === "failed" ||
731 status === "declined" ||
732 status === "stopped"
733 ) {
734 return status;
735 }
736 return undefined;
737}
738
595function stripTrailingExitCode(value: string): {739function stripTrailingExitCode(value: string): {
596 output: string | null;740 output: string | null;
597 exitCode?: number | undefined;741 exitCode?: number | undefined;
...@@ -743,7 +887,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th...@@ -743,7 +887,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
743 }887 }
744888
745 const previous = grouped.at(-1);889 const previous = grouped.at(-1);
746 if (previous?.type === "activity-group") {890 if (previous?.type === "activity-group" && previous.turnId === entry.turnId) {
747 grouped[grouped.length - 1] = {891 grouped[grouped.length - 1] = {
748 ...previous,892 ...previous,
749 activities: [...previous.activities, entry.activity],893 activities: [...previous.activities, entry.activity],
...@@ -755,6 +899,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th...@@ -755,6 +899,7 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
755 type: "activity-group",899 type: "activity-group",
756 id: entry.id,900 id: entry.id,
757 createdAt: entry.createdAt,901 createdAt: entry.createdAt,
902 turnId: entry.turnId,
758 activities: [entry.activity],903 activities: [entry.activity],
759 });904 });
760 }905 }
...@@ -762,6 +907,179 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th...@@ -762,6 +907,179 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
762 return grouped;907 return grouped;
763}908}
764909
910function computeElapsedMs(startIso: string, endIso: string): number | null {
911 const start = Date.parse(startIso);
912 const end = Date.parse(endIso);
913 if (!Number.isFinite(start) || !Number.isFinite(end)) {
914 return null;
915 }
916 return Math.max(0, end - start);
917}
918
919function maxIsoTimestamp(a: string | null, b: string | null): string | null {
920 if (a === null) return b;
921 if (b === null) return a;
922 const aMs = Date.parse(a);
923 const bMs = Date.parse(b);
924 if (!Number.isFinite(aMs)) return b;
925 if (!Number.isFinite(bMs)) return a;
926 return bMs > aMs ? b : a;
927}
928
929function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null {
930 if (!latestTurn) {
931 return null;
932 }
933 const settled = latestTurn.completedAt !== null && latestTurn.state !== "running";
934 return settled ? null : latestTurn.turnId;
935}
936
937interface ThreadFeedTurnFold {
938 readonly turnId: TurnId;
939 readonly createdAt: string;
940 readonly hiddenEntryIds: ReadonlySet<string>;
941 readonly label: string;
942}
943
944function deriveThreadFeedTurnFolds(
945 feed: ReadonlyArray<ThreadFeedEntry>,
946 latestTurn: ThreadFeedLatestTurn | null,
947): ReadonlyMap<string, ThreadFeedTurnFold> {
948 const terminalAssistantMessageIdByTurn = new Map<TurnId, string>();
949 for (const entry of feed) {
950 if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) {
951 terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id);
952 }
953 }
954
955 interface TurnGroup {
956 readonly entries: ThreadFeedEntry[];
957 readonly startBoundary: string | null;
958 }
959 const groupsByTurnId = new Map<TurnId, TurnGroup>();
960 let pendingUserBoundary: string | null = null;
961 for (const entry of feed) {
962 if (entry.type === "message" && entry.message.role === "user") {
963 pendingUserBoundary = entry.message.createdAt;
964 continue;
965 }
966 const turnId =
967 entry.type === "message" && entry.message.role === "assistant"
968 ? entry.message.turnId
969 : entry.type === "activity-group"
970 ? entry.turnId
971 : null;
972 if (!turnId) {
973 continue;
974 }
975 let group = groupsByTurnId.get(turnId);
976 if (!group) {
977 group = {
978 entries: [],
979 startBoundary: pendingUserBoundary,
980 };
981 pendingUserBoundary = null;
982 groupsByTurnId.set(turnId, group);
983 }
984 group.entries.push(entry);
985 }
986
987 const unsettledTurnId = deriveUnsettledTurnId(latestTurn);
988 const foldsByAnchorId = new Map<string, ThreadFeedTurnFold>();
989 for (const [turnId, group] of groupsByTurnId) {
990 const { entries } = group;
991 if (turnId === unsettledTurnId) {
992 continue;
993 }
994 if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) {
995 continue;
996 }
997
998 const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId);
999 const hiddenEntryIds = new Set(
1000 entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id),
1001 );
1002 if (hiddenEntryIds.size === 0) {
1003 continue;
1004 }
1005
1006 const firstEntry = entries[0];
1007 const lastEntry = entries.at(-1);
1008 if (!firstEntry || !lastEntry) {
1009 continue;
1010 }
1011 const terminalEntry = terminalAssistantMessageId
1012 ? entries.find((entry) => entry.id === terminalAssistantMessageId)
1013 : null;
1014 const latestTurnMatches = latestTurn?.turnId === turnId;
1015 const lastEntryEnd =
1016 lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;
1017 const elapsedMs =
1018 latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt
1019 ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt)
1020 : computeElapsedMs(
1021 group.startBoundary ?? firstEntry.createdAt,
1022 maxIsoTimestamp(
1023 terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null,
1024 lastEntryEnd,
1025 ) ?? lastEntryEnd,
1026 );
1027 const duration = elapsedMs === null ? null : formatDuration(elapsedMs);
1028 const interrupted = latestTurnMatches && latestTurn.state === "interrupted";
1029 const label = interrupted
1030 ? duration
1031 ? `You stopped after ${duration}`
1032 : "You stopped this response"
1033 : duration
1034 ? `Worked for ${duration}`
1035 : "Worked";
1036
1037 foldsByAnchorId.set(firstEntry.id, {
1038 turnId,
1039 createdAt: firstEntry.createdAt,
1040 hiddenEntryIds,
1041 label,
1042 });
1043 }
1044 return foldsByAnchorId;
1045}
1046
1047export function deriveThreadFeedPresentation(
1048 feed: ReadonlyArray<ThreadFeedEntry>,
1049 latestTurn: ThreadFeedLatestTurn | null,
1050 expandedTurnIds: ReadonlySet<TurnId>,
1051): ThreadFeedEntry[] {
1052 const sourceFeed = feed.filter((entry) => entry.type !== "turn-fold");
1053 const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
1054 const collapsedEntryIds = new Set<string>();
1055 for (const fold of foldsByAnchorId.values()) {
1056 if (!expandedTurnIds.has(fold.turnId)) {
1057 for (const entryId of fold.hiddenEntryIds) {
1058 collapsedEntryIds.add(entryId);
1059 }
1060 }
1061 }
1062
1063 const result: ThreadFeedEntry[] = [];
1064 for (const entry of sourceFeed) {
1065 const fold = foldsByAnchorId.get(entry.id);
1066 if (fold) {
1067 result.push({
1068 type: "turn-fold",
1069 id: `turn-fold:${fold.turnId}`,
1070 createdAt: fold.createdAt,
1071 turnId: fold.turnId,
1072 label: fold.label,
1073 expanded: expandedTurnIds.has(fold.turnId),
1074 });
1075 }
1076 if (!collapsedEntryIds.has(entry.id)) {
1077 result.push(entry);
1078 }
1079 }
1080 return result;
1081}
1082
765export function derivePendingApprovals(1083export function derivePendingApprovals(
766 activities: ReadonlyArray<OrchestrationThreadActivity>,1084 activities: ReadonlyArray<OrchestrationThreadActivity>,
767): PendingApproval[] {1085): PendingApproval[] {
...@@ -893,10 +1211,7 @@ export function buildThreadFeed(...@@ -893,10 +1211,7 @@ export function buildThreadFeed(
893 const loadedMessages = options?.loadedMessages ?? thread.messages;1211 const loadedMessages = options?.loadedMessages ?? thread.messages;
894 const oldestLoadedMessageCreatedAt =1212 const oldestLoadedMessageCreatedAt =
895 options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;1213 options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;
896 const workLogEntries = deriveWorkLogEntries(1214 const workLogEntries = deriveWorkLogEntries(thread.activities);
897 thread.activities,
898 thread.latestTurn?.turnId ?? undefined,
899 );
900 const entries = Arr.sortWith(1215 const entries = Arr.sortWith(
901 [1216 [
902 ...loadedMessages.map<RawThreadFeedEntry>((message) => ({1217 ...loadedMessages.map<RawThreadFeedEntry>((message) => ({
...@@ -921,18 +1236,36 @@ export function buildThreadFeed(...@@ -921,18 +1236,36 @@ export function buildThreadFeed(
921 oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt1236 oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt
922 );1237 );
923 })1238 })
924 .map<RawThreadFeedEntry>((entry) => ({1239 .map<RawThreadFeedEntry>((entry) => {
925 type: "activity",1240 const summary = workEntryHeading(entry);
926 id: entry.id,1241 const detail = workEntryPreview(entry);
927 createdAt: entry.createdAt,1242 const normalizedFullDetail = entry.detail
928 activity: {1243 ? unwrapKnownShellCommandWrapper(entry.detail)
1244 : null;
1245 const fullDetail =
1246 normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null;
1247 return {
1248 type: "activity",
929 id: entry.id,1249 id: entry.id,
930 createdAt: entry.createdAt,1250 createdAt: entry.createdAt,
931 summary: workEntryHeading(entry),1251 turnId: entry.turnId,
932 detail: workEntryPreview(entry),1252 activity: {
933 status: null,1253 id: entry.id,
934 },1254 createdAt: entry.createdAt,
935 })),1255 turnId: entry.turnId,
1256 summary,
1257 detail,
1258 fullDetail,
1259 copyText: [summary, detail, fullDetail]
1260 .filter((value, index, values): value is string => {
1261 return Boolean(value) && values.indexOf(value) === index;
1262 })
1263 .join("\n"),
1264 toolLike: workLogEntryIsToolLike(entry),
1265 status: workEntryStatus(entry),
1266 },
1267 };
1268 }),
936 ],1269 ],
937 (s) => new Date(s.createdAt),1270 (s) => new Date(s.createdAt),
938 Order.Date,1271 Order.Date,
apps/mobile/src/lib/threadFeedLayout.test.ts created+32
...@@ -0,0 +1,32 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 isThreadFeedNearEnd,
5 resolveThreadFeedBottomInset,
6 threadFeedDistanceFromEnd,
7} from "./threadFeedLayout";
8
9describe("thread feed layout", () => {
10 it("accounts for the bottom inset when measuring distance from the end", () => {
11 const metrics = {
12 contentHeight: 900,
13 viewportHeight: 600,
14 offsetY: 380,
15 bottomInset: 100,
16 };
17
18 expect(threadFeedDistanceFromEnd(metrics)).toBe(20);
19 expect(isThreadFeedNearEnd(metrics, 50)).toBe(true);
20 expect(isThreadFeedNearEnd(metrics, 10)).toBe(false);
21 });
22
23 it("does not double count chrome already included in the measured composer overlay", () => {
24 expect(
25 resolveThreadFeedBottomInset({
26 estimatedOverlayHeight: 162,
27 measuredOverlayHeight: 182,
28 gap: 8,
29 }),
30 ).toBe(190);
31 });
32});
apps/mobile/src/lib/threadFeedLayout.ts created+22
...@@ -0,0 +1,22 @@
1export interface ThreadFeedScrollMetrics {
2 readonly contentHeight: number;
3 readonly viewportHeight: number;
4 readonly offsetY: number;
5 readonly bottomInset: number;
6}
7
8export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number {
9 return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY;
10}
11
12export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean {
13 return threadFeedDistanceFromEnd(metrics) <= threshold;
14}
15
16export function resolveThreadFeedBottomInset(input: {
17 readonly estimatedOverlayHeight: number;
18 readonly measuredOverlayHeight: number;
19 readonly gap: number;
20}): number {
21 return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap;
22}
apps/mobile/src/native/SelectableMarkdownText.ios.tsx created+21
...@@ -0,0 +1,21 @@
1import {
2 SelectableMarkdownText as T3SelectableMarkdownText,
3 type SelectableMarkdownTextProps,
4} from "@t3tools/mobile-markdown-text/renderer";
5
6import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter";
7
8type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
9
10export type {
11 NativeMarkdownTextStyle,
12 SelectableMarkdownSkill,
13} from "@t3tools/mobile-markdown-text/types";
14
15export function hasNativeSelectableMarkdownText(): boolean {
16 return true;
17}
18
19export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) {
20 return <T3SelectableMarkdownText {...props} highlightCode={highlightCodeSnippet} />;
21}
apps/mobile/src/native/SelectableMarkdownText.tsx created+16
...@@ -0,0 +1,16 @@
1import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer";
2
3type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
4
5export type {
6 NativeMarkdownTextStyle,
7 SelectableMarkdownSkill,
8} from "@t3tools/mobile-markdown-text/types";
9
10export function hasNativeSelectableMarkdownText(): boolean {
11 return false;
12}
13
14export function SelectableMarkdownText(_props: MobileSelectableMarkdownTextProps) {
15 return null;
16}
apps/mobile/src/native/T3ComposerEditor.ios.tsx created+180
...@@ -0,0 +1,180 @@
1import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
2import { requireNativeView } from "expo";
3import { useImperativeHandle, useMemo, useRef, type Ref } from "react";
4import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native";
5import { Image, StyleSheet } from "react-native";
6
7import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
8import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
9import { useThemeColor } from "../lib/useThemeColor";
10import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types";
11
12const NATIVE_MODULE_NAME = "T3ComposerEditor";
13const EMPTY_SKILLS: NonNullable<ComposerEditorProps["skills"]> = [];
14
15type NativeEditorEvent = NativeSyntheticEvent<{
16 readonly value: string;
17 readonly selection: ComposerEditorSelection;
18}>;
19
20type NativeSelectionEvent = NativeSyntheticEvent<{
21 readonly selection: ComposerEditorSelection;
22}>;
23
24type NativePasteImagesEvent = NativeSyntheticEvent<{
25 readonly uris: ReadonlyArray<string>;
26}>;
27
28interface NativeComposerEditorRef {
29 focus: () => Promise<void>;
30 blur: () => Promise<void>;
31 setSelection: (start: number, end: number) => Promise<void>;
32}
33
34interface NativeComposerEditorProps extends ViewProps {
35 readonly ref?: Ref<NativeComposerEditorRef>;
36 readonly value: string;
37 readonly tokensJson: string;
38 readonly selectionJson: string;
39 readonly themeJson: string;
40 readonly placeholder: string;
41 readonly fontFamily: string;
42 readonly fontSize: number;
43 readonly lineHeight: number;
44 readonly contentInsetVertical: number;
45 readonly editable: boolean;
46 readonly scrollEnabled: boolean;
47 readonly autoFocus: boolean;
48 readonly autoCorrect: boolean;
49 readonly spellCheck: boolean;
50 readonly onComposerChange: (event: NativeEditorEvent) => void;
51 readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void;
52 readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void;
53 readonly onComposerFocus?: () => void;
54 readonly onComposerBlur?: () => void;
55}
56
57const NativeView = requireNativeView<NativeComposerEditorProps>(NATIVE_MODULE_NAME);
58
59function basename(path: string): string {
60 const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
61 return separator >= 0 ? path.slice(separator + 1) : path;
62}
63
64function fileIconUri(path: string): string {
65 return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri;
66}
67
68export function ComposerEditor({
69 ref,
70 skills = EMPTY_SKILLS,
71 selection,
72 style,
73 textStyle,
74 onChangeText,
75 onSelectionChange,
76 onPasteImages,
77 onFocus,
78 onBlur,
79 contentInsetVertical = 0,
80 ...props
81}: ComposerEditorProps) {
82 const nativeRef = useRef<NativeComposerEditorRef>(null);
83 const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
84 const textColor = useThemeColor("--color-foreground");
85 const placeholderColor = useThemeColor("--color-placeholder");
86 const chipBackground = useThemeColor("--color-subtle");
87 const chipBorder = useThemeColor("--color-border");
88 const chipText = useThemeColor("--color-foreground");
89 const skillBackground = useThemeColor("--color-inline-skill-background");
90 const skillBorder = useThemeColor("--color-inline-skill-border");
91 const skillText = useThemeColor("--color-inline-skill-foreground");
92 const fileTint = useThemeColor("--color-icon-muted");
93
94 useImperativeHandle(
95 ref,
96 () => ({
97 focus: () => void nativeRef.current?.focus(),
98 blur: () => void nativeRef.current?.blur(),
99 setSelection: (nextSelection) =>
100 void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end),
101 }),
102 [],
103 );
104
105 const skillLabels = useMemo(
106 () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])),
107 [skills],
108 );
109 const tokensJson = useMemo(() => {
110 const tokens = collectComposerInlineTokens(props.value, {
111 preserveTrailingFrom: confirmedTokensRef.current,
112 });
113 confirmedTokensRef.current = tokens;
114 return JSON.stringify(
115 tokens.map((token) => ({
116 type: token.type,
117 source: token.source,
118 start: token.start,
119 end: token.end,
120 label:
121 token.type === "skill"
122 ? (skillLabels.get(token.value) ?? token.value)
123 : basename(token.value),
124 iconUri: token.type === "mention" ? fileIconUri(token.value) : null,
125 })),
126 );
127 }, [props.value, skillLabels]);
128 const themeJson = JSON.stringify({
129 text: String(textColor),
130 placeholder: String(placeholderColor),
131 chipBackground: String(chipBackground),
132 chipBorder: String(chipBorder),
133 chipText: String(chipText),
134 skillBackground: String(skillBackground),
135 skillBorder: String(skillBorder),
136 skillText: String(skillText),
137 fileTint: String(fileTint),
138 });
139 const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {};
140 return (
141 <NativeView
142 ref={nativeRef}
143 value={props.value}
144 tokensJson={tokensJson}
145 selectionJson={selection ? JSON.stringify(selection) : ""}
146 themeJson={themeJson}
147 placeholder={props.placeholder ?? ""}
148 fontFamily={
149 typeof resolvedTextStyle.fontFamily === "string"
150 ? resolvedTextStyle.fontFamily
151 : "DMSans_400Regular"
152 }
153 fontSize={typeof resolvedTextStyle.fontSize === "number" ? resolvedTextStyle.fontSize : 15}
154 lineHeight={
155 typeof resolvedTextStyle.lineHeight === "number" ? resolvedTextStyle.lineHeight : 22
156 }
157 contentInsetVertical={contentInsetVertical}
158 editable={props.editable ?? true}
159 scrollEnabled={props.scrollEnabled ?? true}
160 autoFocus={props.autoFocus ?? false}
161 autoCorrect={props.autoCorrect ?? true}
162 spellCheck={props.spellCheck ?? true}
163 style={style as StyleProp<ViewStyle>}
164 onComposerChange={(event) => {
165 onChangeText(event.nativeEvent.value);
166 onSelectionChange?.(event.nativeEvent.selection);
167 }}
168 onComposerSelectionChange={(event) => onSelectionChange?.(event.nativeEvent.selection)}
169 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
170 onComposerFocus={onFocus}
171 onComposerBlur={onBlur}
172 />
173 );
174}
175
176export type {
177 ComposerEditorHandle,
178 ComposerEditorProps,
179 ComposerEditorSelection,
180} from "./T3ComposerEditor.types";
apps/mobile/src/native/T3ComposerEditor.tsx created+65
...@@ -0,0 +1,65 @@
1import { TextInputWrapper } from "expo-paste-input";
2import { useImperativeHandle, useRef } from "react";
3import { TextInput, type TextInput as RNTextInput } from "react-native";
4
5import { useThemeColor } from "../lib/useThemeColor";
6import { useNativePaste } from "../lib/useNativePaste";
7import type { ComposerEditorProps } from "./T3ComposerEditor.types";
8
9export function ComposerEditor({
10 ref,
11 skills: _skills,
12 selection,
13 onPasteImages,
14 style,
15 textStyle,
16 contentInsetVertical = 0,
17 ...props
18}: ComposerEditorProps) {
19 const inputRef = useRef<RNTextInput>(null);
20 const foregroundColor = useThemeColor("--color-foreground");
21 const placeholderColor = useThemeColor("--color-placeholder");
22 const handlePaste = useNativePaste((uris) => onPasteImages?.(uris));
23
24 useImperativeHandle(
25 ref,
26 () => ({
27 focus: () => inputRef.current?.focus(),
28 blur: () => inputRef.current?.blur(),
29 setSelection: (nextSelection) =>
30 inputRef.current?.setSelection(nextSelection.start, nextSelection.end),
31 }),
32 [],
33 );
34
35 return (
36 <TextInputWrapper onPaste={handlePaste} style={[{ minHeight: 0 }, style]}>
37 <TextInput
38 ref={inputRef}
39 {...props}
40 selection={selection}
41 onSelectionChange={(event) => props.onSelectionChange?.(event.nativeEvent.selection)}
42 multiline={props.multiline ?? true}
43 placeholderTextColor={placeholderColor}
44 style={[
45 {
46 flex: 1,
47 minHeight: 0,
48 color: foregroundColor,
49 fontFamily: "DMSans_400Regular",
50 fontSize: 15,
51 lineHeight: 22,
52 paddingVertical: contentInsetVertical,
53 },
54 textStyle,
55 ]}
56 />
57 </TextInputWrapper>
58 );
59}
60
61export type {
62 ComposerEditorHandle,
63 ComposerEditorProps,
64 ComposerEditorSelection,
65} from "./T3ComposerEditor.types";
apps/mobile/src/native/T3ComposerEditor.types.ts created+38
...@@ -0,0 +1,38 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";
2import type { Ref } from "react";
3import type { StyleProp, TextStyle, ViewStyle } from "react-native";
4
5export type ComposerEditorSelection = {
6 readonly start: number;
7 readonly end: number;
8};
9
10export interface ComposerEditorHandle {
11 focus: () => void;
12 blur: () => void;
13 setSelection: (selection: ComposerEditorSelection) => void;
14}
15
16export interface ComposerEditorProps {
17 readonly ref?: Ref<ComposerEditorHandle>;
18 readonly value: string;
19 readonly skills?: ReadonlyArray<
20 Pick<ServerProviderSkill, "name" | "displayName" | "shortDescription" | "description">
21 >;
22 readonly selection?: ComposerEditorSelection;
23 readonly placeholder?: string;
24 readonly autoFocus?: boolean;
25 readonly editable?: boolean;
26 readonly scrollEnabled?: boolean;
27 readonly autoCorrect?: boolean;
28 readonly spellCheck?: boolean;
29 readonly multiline?: boolean;
30 readonly contentInsetVertical?: number;
31 readonly style?: StyleProp<ViewStyle>;
32 readonly textStyle?: StyleProp<TextStyle>;
33 readonly onChangeText: (value: string) => void;
34 readonly onSelectionChange?: (selection: ComposerEditorSelection) => void;
35 readonly onPasteImages?: (uris: ReadonlyArray<string>) => void;
36 readonly onFocus?: () => void;
37 readonly onBlur?: () => void;
38}
apps/mobile/src/widgets/AgentActivity.tsx+3-3
...@@ -58,9 +58,9 @@ export function AgentActivity(...@@ -58,9 +58,9 @@ export function AgentActivity(
58 : "now";58 : "now";
59 const activeLabel = `${props.activeCount} active`;59 const activeLabel = `${props.activeCount} active`;
60 const isLight = environment.colorScheme === "light";60 const isLight = environment.colorScheme === "light";
61 const primaryForeground = isLight ? "#0f172a" : "#ffffff";61 const primaryForeground = isLight ? "#262626" : "#f5f5f5";
62 const secondaryForeground = isLight ? "#475569" : "#cbd5e1";62 const secondaryForeground = isLight ? "#525252" : "#a3a3a3";
63 const mutedForeground = isLight ? "#64748b" : "#94a3b8";63 const mutedForeground = isLight ? "#737373" : "#8e8e93";
64 const tint = environment.isLuminanceReduced64 const tint = environment.isLuminanceReduced
65 ? secondaryForeground65 ? secondaryForeground
66 : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input"66 : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input"
apps/web/package.json+2-2
...@@ -14,8 +14,8 @@...@@ -14,8 +14,8 @@
14 },14 },
15 "dependencies": {15 "dependencies": {
16 "@base-ui/react": "^1.4.1",16 "@base-ui/react": "^1.4.1",
17 "@clerk/clerk-js": "^6.13.0",17 "@clerk/clerk-js": "^6.16.0",
18 "@clerk/react": "^6.7.2",18 "@clerk/react": "^6.9.0",
19 "@dnd-kit/core": "^6.3.1",19 "@dnd-kit/core": "^6.3.1",
20 "@dnd-kit/modifiers": "^9.0.0",20 "@dnd-kit/modifiers": "^9.0.0",
21 "@dnd-kit/sortable": "^10.0.0",21 "@dnd-kit/sortable": "^10.0.0",
apps/web/src/composer-editor-mentions.ts+14-88
...@@ -2,6 +2,10 @@ import {...@@ -2,6 +2,10 @@ import {
2 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,2 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
3 type TerminalContextDraft,3 type TerminalContextDraft,
4} from "./lib/terminalContext";4} from "./lib/terminalContext";
5import {
6 collectComposerInlineTokens,
7 type ComposerInlineToken,
8} from "@t3tools/shared/composerInlineTokens";
59
6export type ComposerPromptSegment =10export type ComposerPromptSegment =
7 | {11 | {
...@@ -22,12 +26,6 @@ export type ComposerPromptSegment =...@@ -22,12 +26,6 @@ export type ComposerPromptSegment =
22 context: TerminalContextDraft | null;26 context: TerminalContextDraft | null;
23 };27 };
2428
25const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
26const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
27const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g;
28const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
29const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
30
31function rangeIncludesIndex(start: number, end: number, index: number): boolean {29function rangeIncludesIndex(start: number, end: number, index: number): boolean {
32 return start <= index && index < end;30 return start <= index && index < end;
33}31}
...@@ -42,84 +40,6 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void...@@ -42,84 +40,6 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void
42 segments.push({ type: "text", text });40 segments.push({ type: "text", text });
43}41}
4442
45type InlineTokenMatch =
46 | {
47 type: "mention";
48 value: string;
49 start: number;
50 end: number;
51 }
52 | {
53 type: "skill";
54 value: string;
55 start: number;
56 end: number;
57 };
58
59type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>;
60
61function collectMentionTokenMatches(text: string): MentionTokenMatch[] {
62 const matches: MentionTokenMatch[] = [];
63
64 for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) {
65 const fullMatch = match[0];
66 const prefix = match[1] ?? "";
67 const label = (match[2] ?? "").replace(/\\(.)/g, "$1");
68 const encodedPath = match[3] ?? "";
69 let path = encodedPath;
70 try {
71 path = decodeURIComponent(encodedPath);
72 } catch {
73 // Keep the source value when malformed percent escapes are present.
74 }
75 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
76 const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
77 const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path);
78 if (!path || hasExternalScheme || label !== basename) {
79 continue;
80 }
81 const matchIndex = match.index ?? 0;
82 const start = matchIndex + prefix.length;
83 const end = start + fullMatch.length - prefix.length;
84 matches.push({ type: "mention", value: path, start, end });
85 }
86
87 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
88 const fullMatch = match[0];
89 const prefix = match[1] ?? "";
90 const quotedPath = match[2];
91 const unquotedPath = match[3];
92 const path =
93 quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (unquotedPath ?? "");
94 const matchIndex = match.index ?? 0;
95 const start = matchIndex + prefix.length;
96 const end = start + fullMatch.length - prefix.length;
97 if (path.length > 0) {
98 matches.push({ type: "mention", value: path, start, end });
99 }
100 }
101
102 return matches;
103}
104
105function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
106 const matches: InlineTokenMatch[] = collectMentionTokenMatches(text);
107
108 for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
109 const fullMatch = match[0];
110 const prefix = match[1] ?? "";
111 const skillName = match[2] ?? "";
112 const matchIndex = match.index ?? 0;
113 const start = matchIndex + prefix.length;
114 const end = start + fullMatch.length - prefix.length;
115 if (skillName.length > 0) {
116 matches.push({ type: "skill", value: skillName, start, end });
117 }
118 }
119
120 return matches.toSorted((left, right) => left.start - right.start);
121}
122
123function forEachPromptSegmentSlice(43function forEachPromptSegmentSlice(
124 prompt: string,44 prompt: string,
125 visitor: (45 visitor: (
...@@ -186,10 +106,16 @@ function forEachPromptTextSlice(...@@ -186,10 +106,16 @@ function forEachPromptTextSlice(
186106
187function forEachMentionMatch(107function forEachMentionMatch(
188 prompt: string,108 prompt: string,
189 visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void,109 visitor: (
110 match: Extract<ComposerInlineToken, { type: "mention" }>,
111 promptOffset: number,
112 ) => boolean | void,
190): boolean {113): boolean {
191 return forEachPromptTextSlice(prompt, (text, promptOffset) => {114 return forEachPromptTextSlice(prompt, (text, promptOffset) => {
192 for (const match of collectMentionTokenMatches(text)) {115 for (const match of collectComposerInlineTokens(text)) {
116 if (match.type !== "mention") {
117 continue;
118 }
193 if (visitor(match, promptOffset) === true) {119 if (visitor(match, promptOffset) === true) {
194 return true;120 return true;
195 }121 }
...@@ -204,7 +130,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen...@@ -204,7 +130,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen
204 return segments;130 return segments;
205 }131 }
206132
207 const tokenMatches = collectInlineTokenMatches(text);133 const tokenMatches = collectComposerInlineTokens(text);
208 let cursor = 0;134 let cursor = 0;
209 for (const match of tokenMatches) {135 for (const match of tokenMatches) {
210 if (match.start < cursor) {136 if (match.start < cursor) {
...@@ -219,7 +145,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen...@@ -219,7 +145,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen
219 segments.push({145 segments.push({
220 type: "mention",146 type: "mention",
221 path: match.value,147 path: match.value,
222 source: text.slice(match.start, match.end),148 source: match.source,
223 });149 });
224 } else {150 } else {
225 segments.push({ type: "skill", name: match.value });151 segments.push({ type: "skill", name: match.value });
infra/relay/package.json+1-1
...@@ -9,7 +9,7 @@...@@ -9,7 +9,7 @@
9 "typecheck": "tsgo --noEmit"9 "typecheck": "tsgo --noEmit"
10 },10 },
11 "dependencies": {11 "dependencies": {
12 "@clerk/backend": "3.4.14",12 "@clerk/backend": "3.6.1",
13 "@effect/sql-pg": "catalog:",13 "@effect/sql-pg": "catalog:",
14 "@t3tools/client-runtime": "workspace:*",14 "@t3tools/client-runtime": "workspace:*",
15 "@t3tools/contracts": "workspace:*",15 "@t3tools/contracts": "workspace:*",
packages/shared/package.json+4
...@@ -143,6 +143,10 @@...@@ -143,6 +143,10 @@
143 "types": "./src/composerTrigger.ts",143 "types": "./src/composerTrigger.ts",
144 "import": "./src/composerTrigger.ts"144 "import": "./src/composerTrigger.ts"
145 },145 },
146 "./composerInlineTokens": {
147 "types": "./src/composerInlineTokens.ts",
148 "import": "./src/composerInlineTokens.ts"
149 },
146 "./terminalLabels": {150 "./terminalLabels": {
147 "types": "./src/terminalLabels.ts",151 "types": "./src/terminalLabels.ts",
148 "import": "./src/terminalLabels.ts"152 "import": "./src/terminalLabels.ts"
packages/shared/src/composerInlineTokens.test.ts created+84
...@@ -0,0 +1,84 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { collectComposerInlineTokens } from "./composerInlineTokens.ts";
4
5describe("collectComposerInlineTokens", () => {
6 it("collects file links, mentions, and skills with source ranges", () => {
7 const text = "Use $ui and inspect [Chat.tsx](src/Chat.tsx) with @AGENTS.md please";
8
9 expect(collectComposerInlineTokens(text)).toEqual([
10 {
11 type: "skill",
12 value: "ui",
13 source: "$ui",
14 start: 4,
15 end: 7,
16 },
17 {
18 type: "mention",
19 value: "src/Chat.tsx",
20 source: "[Chat.tsx](src/Chat.tsx)",
21 start: 20,
22 end: 44,
23 },
24 {
25 type: "mention",
26 value: "AGENTS.md",
27 source: "@AGENTS.md",
28 start: 50,
29 end: 60,
30 },
31 ]);
32 });
33
34 it("does not convert incomplete trailing tokens", () => {
35 expect(collectComposerInlineTokens("Use $ui")).toEqual([]);
36 expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]);
37 });
38
39 it("keeps the delimiter after a token outside its source range", () => {
40 const text = "Inspect [package.json](package.json) next";
41
42 expect(collectComposerInlineTokens(text)).toEqual([
43 {
44 type: "mention",
45 value: "package.json",
46 source: "[package.json](package.json)",
47 start: 8,
48 end: 36,
49 },
50 ]);
51 expect(text.slice(36)).toBe(" next");
52 });
53
54 it("preserves a confirmed pill when only its trailing delimiter is removed", () => {
55 const withDelimiter = "[package.json](package.json) ";
56 const confirmed = collectComposerInlineTokens(withDelimiter);
57
58 expect(
59 collectComposerInlineTokens(withDelimiter.trimEnd(), { preserveTrailingFrom: confirmed }),
60 ).toEqual([
61 {
62 type: "mention",
63 value: "package.json",
64 source: "[package.json](package.json)",
65 start: 0,
66 end: 28,
67 },
68 ]);
69 });
70
71 it("does not preserve a pill after its source is edited", () => {
72 const confirmed = collectComposerInlineTokens("[package.json](package.json) ");
73
74 expect(
75 collectComposerInlineTokens("[package.json](package-json)", {
76 preserveTrailingFrom: confirmed,
77 }),
78 ).toEqual([]);
79 });
80
81 it("ignores normal web links", () => {
82 expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]);
83 });
84});
packages/shared/src/composerInlineTokens.ts created+118
...@@ -0,0 +1,118 @@
1export type ComposerInlineToken =
2 | {
3 readonly type: "mention";
4 readonly value: string;
5 readonly source: string;
6 readonly start: number;
7 readonly end: number;
8 }
9 | {
10 readonly type: "skill";
11 readonly value: string;
12 readonly source: string;
13 readonly start: number;
14 readonly end: number;
15 };
16
17export interface CollectComposerInlineTokensOptions {
18 readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>;
19}
20
21const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
22const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
23const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g;
24const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
25const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
26
27function collectMentionTokens(text: string): ComposerInlineToken[] {
28 const matches: ComposerInlineToken[] = [];
29
30 for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) {
31 const fullMatch = match[0];
32 const prefix = match[1] ?? "";
33 const label = (match[2] ?? "").replace(/\\(.)/g, "$1");
34 const encodedPath = match[3] ?? "";
35 let path = encodedPath;
36 try {
37 path = decodeURIComponent(encodedPath);
38 } catch {
39 // Preserve malformed source rather than dropping a user-authored token.
40 }
41 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
42 const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
43 const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path);
44 if (!path || hasExternalScheme || label !== basename) {
45 continue;
46 }
47 const start = (match.index ?? 0) + prefix.length;
48 const end = start + fullMatch.length - prefix.length;
49 matches.push({
50 type: "mention",
51 value: path,
52 source: text.slice(start, end),
53 start,
54 end,
55 });
56 }
57
58 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
59 const fullMatch = match[0];
60 const prefix = match[1] ?? "";
61 const quotedPath = match[2];
62 const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? "");
63 if (!path) {
64 continue;
65 }
66 const start = (match.index ?? 0) + prefix.length;
67 const end = start + fullMatch.length - prefix.length;
68 matches.push({
69 type: "mention",
70 value: path,
71 source: text.slice(start, end),
72 start,
73 end,
74 });
75 }
76
77 return matches;
78}
79
80export function collectComposerInlineTokens(
81 text: string,
82 options: CollectComposerInlineTokensOptions = {},
83): ReadonlyArray<ComposerInlineToken> {
84 const matches = collectMentionTokens(text);
85
86 for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
87 const fullMatch = match[0];
88 const prefix = match[1] ?? "";
89 const value = match[2] ?? "";
90 if (!value) {
91 continue;
92 }
93 const start = (match.index ?? 0) + prefix.length;
94 const end = start + fullMatch.length - prefix.length;
95 matches.push({
96 type: "skill",
97 value,
98 source: text.slice(start, end),
99 start,
100 end,
101 });
102 }
103
104 for (const token of options.preserveTrailingFrom ?? []) {
105 if (
106 token.end === text.length &&
107 text.slice(token.start, token.end) === token.source &&
108 !matches.some(
109 (match) =>
110 match.type === token.type && match.start === token.start && match.end === token.end,
111 )
112 ) {
113 matches.push(token);
114 }
115 }
116
117 return [...matches].sort((left, right) => left.start - right.start);
118}
pnpm-lock.yaml+74-35
...@@ -179,8 +179,8 @@ importers:...@@ -179,8 +179,8 @@ importers:
179 specifier: ^0.7.1179 specifier: ^0.7.1
180 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)180 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
181 '@clerk/expo':181 '@clerk/expo':
182 specifier: ^3.3.0182 specifier: ^3.4.1
183 version: 3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)183 version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
184 '@effect/atom-react':184 '@effect/atom-react':
185 specifier: 4.0.0-beta.78185 specifier: 4.0.0-beta.78
186 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0)186 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0)
...@@ -223,6 +223,9 @@ importers:...@@ -223,6 +223,9 @@ importers:
223 '@t3tools/contracts':223 '@t3tools/contracts':
224 specifier: workspace:*224 specifier: workspace:*
225 version: link:../../packages/contracts225 version: link:../../packages/contracts
226 '@t3tools/mobile-markdown-text':
227 specifier: file:./modules/t3-markdown-text
228 version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)
226 '@t3tools/mobile-review-diff-native':229 '@t3tools/mobile-review-diff-native':
227 specifier: file:./modules/t3-review-diff230 specifier: file:./modules/t3-review-diff
228 version: file:apps/mobile/modules/t3-review-diff231 version: file:apps/mobile/modules/t3-review-diff
...@@ -244,6 +247,9 @@ importers:...@@ -244,6 +247,9 @@ importers:
244 expo:247 expo:
245 specifier: ^56.0.0248 specifier: ^56.0.0
246 version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)249 version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
250 expo-asset:
251 specifier: ~56.0.15
252 version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
247 expo-auth-session:253 expo-auth-session:
248 specifier: ~56.0.12254 specifier: ~56.0.12
249 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)255 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
...@@ -368,6 +374,9 @@ importers:...@@ -368,6 +374,9 @@ importers:
368 '@effect/vitest':374 '@effect/vitest':
369 specifier: 4.0.0-beta.78375 specifier: 4.0.0-beta.78
370 version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))376 version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
377 '@pierre/trees':
378 specifier: 1.0.0-beta.4
379 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
371 '@types/react':380 '@types/react':
372 specifier: ~19.2.0381 specifier: ~19.2.0
373 version: 19.2.16382 version: 19.2.16
...@@ -451,11 +460,11 @@ importers:...@@ -451,11 +460,11 @@ importers:
451 specifier: ^1.4.1460 specifier: ^1.4.1
452 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)461 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
453 '@clerk/clerk-js':462 '@clerk/clerk-js':
454 specifier: ^6.13.0463 specifier: ^6.16.0
455 version: 6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)464 version: 6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
456 '@clerk/react':465 '@clerk/react':
457 specifier: ^6.7.2466 specifier: ^6.9.0
458 version: 6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)467 version: 6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
459 '@dnd-kit/core':468 '@dnd-kit/core':
460 specifier: ^6.3.1469 specifier: ^6.3.1
461 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)470 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
...@@ -614,8 +623,8 @@ importers:...@@ -614,8 +623,8 @@ importers:
614 infra/relay:623 infra/relay:
615 dependencies:624 dependencies:
616 '@clerk/backend':625 '@clerk/backend':
617 specifier: 3.4.14626 specifier: 3.6.1
618 version: 3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)627 version: 3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
619 '@effect/sql-pg':628 '@effect/sql-pg':
620 specifier: 4.0.0-beta.78629 specifier: 4.0.0-beta.78
621 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))630 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
...@@ -1509,16 +1518,16 @@ packages:...@@ -1509,16 +1518,16 @@ packages:
1509 resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==}1518 resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==}
1510 engines: {node: '>= 20.12.0'}1519 engines: {node: '>= 20.12.0'}
15111520
1512 '@clerk/backend@3.4.14':1521 '@clerk/backend@3.6.1':
1513 resolution: {integrity: sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==}1522 resolution: {integrity: sha512-LkfekzF/0UMXacX+17xy3ExRraO0mm+thXejC8Q32gWHd1wLdxK3YXDsLDF00E1r1InWBKIt2ZOxs6hTwZPJjA==}
1514 engines: {node: '>=20.9.0'}1523 engines: {node: '>=20.9.0'}
15151524
1516 '@clerk/clerk-js@6.14.0':1525 '@clerk/clerk-js@6.16.0':
1517 resolution: {integrity: sha512-xreDPw31OIk/VQj36qdgjzc4Rk2HwMar25nOu/ts2gf7PrbhU4XQdrtnt74g4fTmSMp8xeyjzHqa9adDXVjISw==}1526 resolution: {integrity: sha512-8xv/XDsxhOZd1n4DNIRJ2EehIRUg6UiqKAnfd0L88R2t1g6sVnLi1FInJ5i8Qyx5oY/creXx6X1AZ1V5PobRkA==}
1518 engines: {node: '>=20.9.0'}1527 engines: {node: '>=20.9.0'}
15191528
1520 '@clerk/expo@3.3.1':1529 '@clerk/expo@3.4.1':
1521 resolution: {integrity: sha512-c4g64z5sgJoGYjK0NeasNwOMy9Di7cEjICq56BHSowdOuB+6UGtWBNw+yHzgS1gxi2kJgl7WQCmmXRsoZNWxAg==}1530 resolution: {integrity: sha512-gpAXsuUnsUdUD0/2XjyxaC9quF5rT+2umkmV74nBLVAFurGMMMMHvnHqrQEtZ7tH5GHNXYw5+pgmnzd1HiMQbQ==}
1522 engines: {node: '>=20.9.0'}1531 engines: {node: '>=20.9.0'}
1523 peerDependencies:1532 peerDependencies:
1524 '@clerk/expo-passkeys': '>=0.0.6'1533 '@clerk/expo-passkeys': '>=0.0.6'
...@@ -1550,16 +1559,18 @@ packages:...@@ -1550,16 +1559,18 @@ packages:
1550 optional: true1559 optional: true
1551 expo-web-browser:1560 expo-web-browser:
1552 optional: true1561 optional: true
1562 react-dom:
1563 optional: true
15531564
1554 '@clerk/react@6.7.3':1565 '@clerk/react@6.9.0':
1555 resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==}1566 resolution: {integrity: sha512-M0QGyGS732tYBXeG+28UgElXM2TfoSZ+4mWGisC8yxJX8NjH4hEPJTAQuZmYRLNaCyGQCuzjYVQiQRC+GbDtmA==}
1556 engines: {node: '>=20.9.0'}1567 engines: {node: '>=20.9.0'}
1557 peerDependencies:1568 peerDependencies:
1558 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-01569 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
1559 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-01570 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
15601571
1561 '@clerk/shared@4.15.0':1572 '@clerk/shared@4.17.0':
1562 resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==}1573 resolution: {integrity: sha512-YeQ+6zDmqyor1mPHjZx18j+LssL6Pobvid8hb7HQMioSo8sGDBEVi/Z12bs+gUhe9KbdP+ygHsKOqqeGAPuPZA==}
1563 engines: {node: '>=20.9.0'}1574 engines: {node: '>=20.9.0'}
1564 peerDependencies:1575 peerDependencies:
1565 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-01576 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
...@@ -4168,6 +4179,17 @@ packages:...@@ -4168,6 +4179,17 @@ packages:
4168 resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}4179 resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
4169 engines: {node: '>=10'}4180 engines: {node: '>=10'}
41704181
4182 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text':
4183 resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory}
4184 peerDependencies:
4185 expo-asset: '*'
4186 expo-clipboard: '*'
4187 expo-haptics: '*'
4188 expo-symbols: '*'
4189 react: '*'
4190 react-native: '*'
4191 react-native-nitro-markdown: '*'
4192
4171 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff':4193 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff':
4172 resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory}4194 resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory}
41734195
...@@ -10829,18 +10851,18 @@ snapshots:...@@ -10829,18 +10851,18 @@ snapshots:
10829 fast-wrap-ansi: 0.2.210851 fast-wrap-ansi: 0.2.2
10830 sisteransi: 1.0.510852 sisteransi: 1.0.5
1083110853
10832 '@clerk/backend@3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':10854 '@clerk/backend@3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
10833 dependencies:10855 dependencies:
10834 '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)10856 '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
10835 standardwebhooks: 1.0.010857 standardwebhooks: 1.0.0
10836 tslib: 2.8.110858 tslib: 2.8.1
10837 transitivePeerDependencies:10859 transitivePeerDependencies:
10838 - react10860 - react
10839 - react-dom10861 - react-dom
1084010862
10841 '@clerk/clerk-js@6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':10863 '@clerk/clerk-js@6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
10842 dependencies:10864 dependencies:
10843 '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)10865 '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10844 '@stripe/stripe-js': 5.6.010866 '@stripe/stripe-js': 5.6.0
10845 '@swc/helpers': 0.5.2110867 '@swc/helpers': 0.5.21
10846 '@tanstack/query-core': 5.100.1410868 '@tanstack/query-core': 5.100.14
...@@ -10855,9 +10877,9 @@ snapshots:...@@ -10855,9 +10877,9 @@ snapshots:
10855 - react10877 - react
10856 - react-dom10878 - react-dom
1085710879
10858 '@clerk/clerk-js@6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':10880 '@clerk/clerk-js@6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
10859 dependencies:10881 dependencies:
10860 '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)10882 '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
10861 '@stripe/stripe-js': 5.6.010883 '@stripe/stripe-js': 5.6.0
10862 '@swc/helpers': 0.5.2110884 '@swc/helpers': 0.5.21
10863 '@tanstack/query-core': 5.100.1410885 '@tanstack/query-core': 5.100.14
...@@ -10872,15 +10894,14 @@ snapshots:...@@ -10872,15 +10894,14 @@ snapshots:
10872 - react10894 - react
10873 - react-dom10895 - react-dom
1087410896
10875 '@clerk/expo@3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':10897 '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
10876 dependencies:10898 dependencies:
10877 '@clerk/clerk-js': 6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)10899 '@clerk/clerk-js': 6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10878 '@clerk/react': 6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)10900 '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10879 '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)10901 '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10880 base-64: 1.0.010902 base-64: 1.0.0
10881 expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)10903 expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
10882 react: 19.2.310904 react: 19.2.3
10883 react-dom: 19.2.3(react@19.2.3)
10884 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)10905 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
10885 react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))10906 react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
10886 tslib: 2.8.110907 tslib: 2.8.1
...@@ -10890,22 +10911,23 @@ snapshots:...@@ -10890,22 +10911,23 @@ snapshots:
10890 expo-crypto: 56.0.4(expo@56.0.8)10911 expo-crypto: 56.0.4(expo@56.0.8)
10891 expo-secure-store: 56.0.4(expo@56.0.8)10912 expo-secure-store: 56.0.4(expo@56.0.8)
10892 expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))10913 expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
10914 react-dom: 19.2.3(react@19.2.3)
1089310915
10894 '@clerk/react@6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':10916 '@clerk/react@6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
10895 dependencies:10917 dependencies:
10896 '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)10918 '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10897 react: 19.2.310919 react: 19.2.3
10898 react-dom: 19.2.3(react@19.2.3)10920 react-dom: 19.2.3(react@19.2.3)
10899 tslib: 2.8.110921 tslib: 2.8.1
1090010922
10901 '@clerk/react@6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':10923 '@clerk/react@6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
10902 dependencies:10924 dependencies:
10903 '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)10925 '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
10904 react: 19.2.610926 react: 19.2.6
10905 react-dom: 19.2.6(react@19.2.6)10927 react-dom: 19.2.6(react@19.2.6)
10906 tslib: 2.8.110928 tslib: 2.8.1
1090710929
10908 '@clerk/shared@4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':10930 '@clerk/shared@4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
10909 dependencies:10931 dependencies:
10910 '@tanstack/query-core': 5.100.1410932 '@tanstack/query-core': 5.100.14
10911 dequal: 2.0.310933 dequal: 2.0.3
...@@ -10915,7 +10937,7 @@ snapshots:...@@ -10915,7 +10937,7 @@ snapshots:
10915 react: 19.2.310937 react: 19.2.3
10916 react-dom: 19.2.3(react@19.2.3)10938 react-dom: 19.2.3(react@19.2.3)
1091710939
10918 '@clerk/shared@4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':10940 '@clerk/shared@4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
10919 dependencies:10941 dependencies:
10920 '@tanstack/query-core': 5.100.1410942 '@tanstack/query-core': 5.100.14
10921 dequal: 2.0.310943 dequal: 2.0.3
...@@ -12680,6 +12702,13 @@ snapshots:...@@ -12680,6 +12702,13 @@ snapshots:
12680 react-dom: 19.2.6(react@19.2.6)12702 react-dom: 19.2.6(react@19.2.6)
12681 shiki: 3.23.012703 shiki: 3.23.0
1268212704
12705 '@pierre/trees@1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
12706 dependencies:
12707 preact: 11.0.0-beta.0
12708 preact-render-to-string: 6.6.5(preact@11.0.0-beta.0)
12709 react: 19.2.3
12710 react-dom: 19.2.3(react@19.2.3)
12711
12683 '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':12712 '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12684 dependencies:12713 dependencies:
12685 preact: 11.0.0-beta.012714 preact: 11.0.0-beta.0
...@@ -13452,6 +13481,16 @@ snapshots:...@@ -13452,6 +13481,16 @@ snapshots:
13452 dependencies:13481 dependencies:
13453 defer-to-connect: 2.0.113482 defer-to-connect: 2.0.1
1345413483
13484 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)':
13485 dependencies:
13486 expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
13487 expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13488 expo-haptics: 56.0.3(expo@56.0.8)
13489 expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13490 react: 19.2.3
13491 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
13492 react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
13493
13455 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {}13494 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {}
1345613495
13457 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {}13496 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {}
scripts/release-smoke.ts+1
...@@ -26,6 +26,7 @@ const workspaceFiles = [...@@ -26,6 +26,7 @@ const workspaceFiles = [
26 "apps/web/package.json",26 "apps/web/package.json",
27 "apps/mobile/package.json",27 "apps/mobile/package.json",
28 "apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz",28 "apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz",
29 "apps/mobile/modules/t3-markdown-text/package.json",
29 "apps/mobile/modules/t3-review-diff/package.json",30 "apps/mobile/modules/t3-review-diff/package.json",
30 "apps/mobile/modules/t3-terminal/package.json",31 "apps/mobile/modules/t3-terminal/package.json",
31 "apps/marketing/package.json",32 "apps/marketing/package.json",