History

Add archived threads and mobile file viewer (#3155)

Julius Marminge committed 4 months ago

Commit 30034ec1 parent 1fcc57a93 files · +6791 −1136Browse files

Unified · Split

93 files
  1. apps/desktop/src/preview/Manager.test.ts +52 −0
  2. apps/desktop/src/preview/Manager.ts +89 −12
  3. apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +8 −104
  4. apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +8 −7
  5. apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +15 −20
  6. apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +63 −8
  7. apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +38 −85
  8. apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +19 −4
  9. apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +3 −2
  10. apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +61 −11
  11. apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +10 −0
  12. apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +4 −0
  13. apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +55 −1
  14. apps/mobile/package.json +1 −0
  15. apps/mobile/src/app/_layout.tsx +3 −2
  16. apps/mobile/src/app/index.tsx +88 −89
  17. apps/mobile/src/app/settings/_layout.tsx +7 −2
  18. apps/mobile/src/app/settings/archive.tsx +3 −0
  19. apps/mobile/src/app/settings/index.tsx +16 −2
  20. apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx +26 −0
  21. apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx +5 −0
  22. apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx +5 −0
  23. apps/mobile/src/components/LoadingStrip.tsx +93 −0
  24. apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +95 −0
  25. apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +434 −0
  26. apps/mobile/src/features/archive/archivedThreadList.test.ts +144 −0
  27. apps/mobile/src/features/archive/archivedThreadList.ts +106 −0
  28. apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts +47 −0
  29. apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +1 −0
  30. apps/mobile/src/features/files/FileMarkdownPreview.tsx +166 −0
  31. apps/mobile/src/features/files/FileTreeBrowser.tsx +191 −0
  32. apps/mobile/src/features/files/SourceFileSurface.tsx +252 −0
  33. apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +631 −0
  34. apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +118 −0
  35. apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +62 −0
  36. apps/mobile/src/features/files/filePath.test.ts +43 −0
  37. apps/mobile/src/features/files/filePath.ts +116 −0
  38. apps/mobile/src/features/files/fileTree.test.ts +109 −0
  39. apps/mobile/src/features/files/fileTree.ts +220 −0
  40. apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts +46 −0
  41. apps/mobile/src/features/files/nativeSourceFileAdapter.ts +65 −0
  42. apps/mobile/src/features/files/sourceHighlightingState.test.ts +123 −0
  43. apps/mobile/src/features/files/sourceHighlightingState.ts +50 −0
  44. apps/mobile/src/features/files/workspace-file-image-cache.test.ts +64 −0
  45. apps/mobile/src/features/files/workspace-file-image-cache.ts +48 −0
  46. apps/mobile/src/features/files/workspaceFileAssetUrl.ts +31 −0
  47. apps/mobile/src/features/home/HomeHeader.tsx +244 −0
  48. apps/mobile/src/features/home/HomeScreen.tsx +287 −139
  49. apps/mobile/src/features/home/homeThreadList.test.ts +223 −0
  50. apps/mobile/src/features/home/homeThreadList.ts +140 −0
  51. apps/mobile/src/features/home/thread-swipe-actions.tsx +238 −0
  52. apps/mobile/src/features/home/useThreadListActions.ts +142 −0
  53. apps/mobile/src/features/review/shikiReviewHighlighter.ts +9 −0
  54. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +2 −0
  55. apps/mobile/src/features/threads/ThreadFeed.tsx +108 −193
  56. apps/mobile/src/features/threads/ThreadGitControls.tsx +10 −1
  57. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +2 −0
  58. apps/mobile/src/features/threads/thread-work-log.tsx +261 −0
  59. apps/mobile/src/lib/markdownLinks.test.ts +21 −0
  60. apps/mobile/src/lib/nativeMarkdownText.test.ts +22 −2
  61. apps/mobile/src/lib/routes.test.ts +45 −0
  62. apps/mobile/src/lib/routes.ts +53 −0
  63. apps/mobile/src/lib/threadActivity.test.ts +53 −2
  64. apps/mobile/src/lib/threadActivity.ts +72 −9
  65. apps/server/src/assets/AssetAccess.test.ts +36 −0
  66. apps/server/src/assets/AssetAccess.ts +45 −21
  67. apps/web/src/AppRoot.test.tsx +22 −0
  68. apps/web/src/AppRoot.tsx +19 −0
  69. apps/web/src/browser/HostedBrowserWebview.tsx +33 −10
  70. apps/web/src/browser/desktopTabLifetime.test.ts +45 −0
  71. apps/web/src/browser/desktopTabLifetime.ts +28 −14
  72. apps/web/src/components/ChatView.tsx +17 −24
  73. apps/web/src/components/preview/addBrowserSurface.test.ts +52 −0
  74. apps/web/src/components/preview/addBrowserSurface.ts +24 −0
  75. apps/web/src/components/preview/closePreviewSession.test.ts +79 −0
  76. apps/web/src/components/preview/closePreviewSession.ts +37 −0
  77. apps/web/src/components/preview/openPreviewSession.test.ts +18 −0
  78. apps/web/src/components/preview/openPreviewSession.ts +8 −6
  79. apps/web/src/lib/archivedThreadsState.ts +6 −39
  80. apps/web/src/lib/threadSort.ts +7 −86
  81. apps/web/src/logicalProject.ts +14 −184
  82. apps/web/src/main.tsx +2 −8
  83. apps/web/src/previewStateStore.test.ts +28 −3
  84. apps/web/src/previewStateStore.ts +42 −2
  85. apps/web/src/router.ts +0 −3
  86. packages/client-runtime/package.json +8 −0
  87. packages/client-runtime/src/state/archivedThreads.ts +44 −0
  88. packages/client-runtime/src/state/projectGrouping.ts +183 −0
  89. packages/client-runtime/src/state/threadSort.ts +101 −0
  90. packages/shared/package.json +4 −0
  91. packages/shared/src/filePreview.test.ts +36 −0
  92. packages/shared/src/filePreview.ts +29 −0
  93. pnpm-lock.yaml +58 −41
apps/desktop/src/preview/Manager.test.ts+52−0
@@ -128,6 +128,58 @@ describe("PreviewManager", () => { · 127 unmodified lines
128128 ),
129129 );
130130
131+ effectIt.effect("queues navigation until the webview registers", () =>
132+ withManager((manager) =>
133+ Effect.gen(function* () {
134+ const loadURL = vi.fn(async () => undefined);
135+ const listeners = new Map<string, (...args: never[]) => void>();
136+ fromId.mockReturnValue({
137+ id: 42,
138+ isDestroyed: () => false,
139+ getType: () => "webview",
140+ getURL: () => "about:blank",
141+ getTitle: () => "",
142+ isLoading: () => false,
143+ getZoomFactor: () => 1,
144+ setZoomFactor: vi.fn(),
145+ loadURL,
146+ on: vi.fn((event: string, listener: (...args: never[]) => void) => {
147+ listeners.set(event, listener);
148+ }),
149+ off: vi.fn(),
150+ ipc: { on: vi.fn(), off: vi.fn() },
151+ send: webviewSend,
152+ navigationHistory: { canGoBack: () => false, canGoForward: () => false },
153+ setWindowOpenHandler: vi.fn(),
154+ debugger: {
155+ isAttached: () => false,
156+ attach: vi.fn(),
157+ sendCommand: vi.fn(async () => undefined),
158+ on: vi.fn(),
159+ off: vi.fn(),
160+ },
161+ } as never);
162+
163+ yield* manager.navigate("tab_pending", "localhost:3200");
164+
165+ expect(yield* manager.automationStatus("tab_pending")).toEqual({
166+ available: false,
167+ visible: true,
168+ tabId: "tab_pending",
169+ url: "http://localhost:3200/",
170+ title: "",
171+ loading: true,
172+ });
173+
174+ yield* manager.registerWebview("tab_pending", 42);
175+ yield* Effect.yieldNow;
176+
177+ expect(loadURL).toHaveBeenCalledOnce();
178+ expect(loadURL).toHaveBeenCalledWith("http://localhost:3200/");
179+ }),
180+ ),
181+ );
182+
131183 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>
132184 withManager((manager) =>
133185 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+89−12
@@ -1195,26 +1195,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function · 1194 unmodified lines
11951195 }
11961196 yield* attachListeners(tabId, wc);
11971197 runFork(ensureControlSession(wc).pipe(Effect.ignore));
1198− if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
1199− yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe(
1200− Effect.ignore,
1201− );
1202− }
1203− yield* update(tabId, {
1204− webContentsId,
1205− navStatus: computeNavStatus(wc),
1206− canGoBack: wc.navigationHistory.canGoBack(),
1207− canGoForward: wc.navigationHistory.canGoForward(),
1208− zoomFactor: tab.zoomFactor,
1198+ const registeredAt = yield* currentIso;
1199+ const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1200+ const current = tabs.get(tabId);
1201+ if (!current) {
1202+ return [
1203+ Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(),
1204+ tabs,
1205+ ] as const;
1206+ }
1207+ const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
1208+ const next: PreviewTabState = {
1209+ ...current,
1210+ webContentsId,
1211+ navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
1212+ canGoBack: wc.navigationHistory.canGoBack(),
1213+ canGoForward: wc.navigationHistory.canGoForward(),
1214+ updatedAt: registeredAt,
1215+ };
1216+ return [
1217+ Option.some({
1218+ state: next,
1219+ pendingUrl,
1220+ }),
1221+ replaceMap(tabs, (copy) => {
1222+ copy.set(tabId, next);
1223+ }),
1224+ ] as const;
12091225 });
1226+ if (Option.isNone(registration)) {
1227+ return yield* fail("registerWebview", new PreviewTabNotFoundError(tabId));
1228+ }
1229+ const { state: registered, pendingUrl } = registration.value;
1230+ yield* emit(tabId, registered);
1231+ if (Math.abs(registered.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
1232+ yield* attempt("registerWebview.restoreZoom", () =>
1233+ wc.setZoomFactor(registered.zoomFactor),
1234+ ).pipe(Effect.ignore);
1235+ }
12101236 yield* attempt("registerWebview.sendTheme", () =>
12111237 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
12121238 );
1239+ const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus;
1240+ if (
1241+ pendingUrl &&
1242+ latestNavStatus?.kind === "Loading" &&
1243+ latestNavStatus.url === pendingUrl &&
1244+ wc.getURL() !== pendingUrl
1245+ ) {
1246+ runFork(
1247+ attemptPromise("registerWebview.loadPendingUrl", () => wc.loadURL(pendingUrl)).pipe(
1248+ Effect.ignore,
1249+ ),
1250+ );
1251+ }
12131252 });
12141253
12151254 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {
1216− const wc = yield* requireWebContents(tabId);
12171255 const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl));
1256+ const updatedAt = yield* currentIso;
1257+ const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1258+ const current = tabs.get(tabId);
1259+ const next: PreviewTabState = {
1260+ tabId,
1261+ webContentsId: current?.webContentsId ?? null,
1262+ navStatus: {
1263+ kind: "Loading",
1264+ url,
1265+ title: current?.navStatus.kind === "Idle" || !current ? "" : current.navStatus.title,
1266+ },
1267+ canGoBack: current?.canGoBack ?? false,
1268+ canGoForward: current?.canGoForward ?? false,
1269+ zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR,
1270+ controller: current?.controller ?? "none",
1271+ updatedAt,
1272+ };
1273+ return [
1274+ next,
1275+ replaceMap(tabs, (copy) => {
1276+ copy.set(tabId, next);
1277+ }),
1278+ ] as const;
1279+ });
1280+ yield* emit(tabId, pending);
1281+ if (pending.webContentsId == null) return;
1282+ const wc = webContents.fromId(pending.webContentsId);
1283+ if (!wc) {
1284+ const detached = { ...pending, webContentsId: null };
1285+ yield* SynchronizedRef.update(tabsRef, (tabs) =>
1286+ tabs.get(tabId)?.webContentsId !== pending.webContentsId
1287+ ? tabs
1288+ : replaceMap(tabs, (copy) => {
1289+ copy.set(tabId, detached);
1290+ }),
1291+ );
1292+ yield* emit(tabId, detached);
1293+ return;
1294+ }
12181295 if (wc.getURL() === url) {
12191296 yield* attempt("navigate.reload", () => wc.reload());
12201297 return;
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+8−104
@@ -70,7 +70,12 @@ static void T3MarkdownTextApplyAttachments( · 69 unmodified lines
7070 renderingMode:UIImageRenderingModeAlwaysOriginal];
7171 }
7272 attachment.image = image ?: [[UIImage alloc] init];
73− attachment.bounds = CGRectMake(0, -0.5, 10, 10);
73+ const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange);
74+ attachment.bounds = CGRectMake(
75+ 0,
76+ T3MarkdownTextAttachmentBaselineOffset(attachmentRange),
77+ attachmentSize,
78+ attachmentSize);
7479 const NSRange range = NSMakeRange(
7580 attachmentRange.location,
7681 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
@@ -80,104 +85,6 @@ static void T3MarkdownTextApplyAttachments( · 3 unmodified lines
8085 }
8186 }
8287
83−static 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−
18188 @protocol T3MarkdownOutsideTapTarget <NSObject>
18289 - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView;
18390 @end
@@ -285,7 +192,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) · 101 unmodified lines
285192
286193 @implementation T3MarkdownText {
287194 UIView * _view;
288− T3MarkdownTextBackingView * _textView;
195+ UITextView * _textView;
289196 T3MarkdownTextShadowNode::ConcreteState::Shared _state;
290197 __weak UIWindow * _outsideTapWindow;
291198 BOOL _suppressSelectionChange;
@@ -308,7 +215,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) · 16 unmodified lines
308215 self.contentView = _view;
309216 self.clipsToBounds = true;
310217
311− _textView = [[T3MarkdownTextBackingView alloc] init];
218+ _textView = [[UITextView alloc] init];
312219 _attachmentImages = [[NSMutableDictionary alloc] init];
313220 _pendingAttachmentUris = [[NSMutableSet alloc] init];
314221 _textView.scrollEnabled = false;
@@ -405,9 +312,6 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) · 90 unmodified lines
405312 convertedAttrString,
406313 _state->getData().attachmentRanges,
407314 _attachmentImages);
408− _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds(
409− convertedAttrString,
410− _state->getData().chipRanges);
411315 [self loadAttachmentImages:_state->getData().attachmentRanges];
412316
413317 // Setting attributedText clears any active text selection, and re-assigning
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h+8−7
@@ -28,18 +28,20 @@ struct T3MarkdownTextAttachmentRange { · 27 unmodified lines
2828 std::string imageUri;
2929 };
3030
31−struct T3MarkdownTextChipRange {
32− size_t location;
33− size_t length;
34− bool isSkill;
35−};
31+inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {
32+ return 14;
33+}
34+
35+inline Float T3MarkdownTextAttachmentBaselineOffset(
36+ const T3MarkdownTextAttachmentRange &) {
37+ return -2;
38+}
3639
3740 class T3MarkdownTextStateReal final {
3841 public:
3942 AttributedString attributedString;
4043 std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges;
4144 std::vector<T3MarkdownTextAttachmentRange> attachmentRanges;
42− std::vector<T3MarkdownTextChipRange> chipRanges;
4345 };
4446
4547 class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode<
@@ -72,6 +74,5 @@ private: · 26 unmodified lines
7274 mutable AttributedString _attributedString;
7375 mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges;
7476 mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges;
75− mutable std::vector<T3MarkdownTextChipRange> _chipRanges;
7677 };
7778 } // namespace facebook::React
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm+15−20
@@ -9,9 +9,8 @@ · 8 unmodified lines
99 namespace facebook::react {
1010
1111 static constexpr Float ParagraphStyleEncodingOffset = 1000;
12−static constexpr auto ChipNativeIdPrefix = "t3-chip-";
13−static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:";
14−static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:";
12+static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:";
13+static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:";
1514
1615 static void applyParagraphStyles(
1716 NSMutableAttributedString *attributedString,
@@ -58,7 +57,12 @@ static void applyAttachments( · 40 unmodified lines
5857
5958 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
6059 attachment.image = [[UIImage alloc] init];
61− attachment.bounds = CGRectMake(0, -0.5, 10, 10);
60+ const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange);
61+ attachment.bounds = CGRectMake(
62+ 0,
63+ T3MarkdownTextAttachmentBaselineOffset(attachmentRange),
64+ attachmentSize,
65+ attachmentSize);
6266 const NSRange range = NSMakeRange(
6367 attachmentRange.location,
6468 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
@@ -91,7 +95,6 @@ Size T3MarkdownTextShadowNode::measureContent( · 26 unmodified lines
9195 auto baseAttributedString = AttributedString{};
9296 auto paragraphStyleRanges = std::vector<T3MarkdownTextParagraphStyleRange>{};
9397 auto attachmentRanges = std::vector<T3MarkdownTextAttachmentRange>{};
94− auto chipRanges = std::vector<T3MarkdownTextChipRange>{};
9598 size_t utf16Offset = 0;
9699 const auto &children = getChildren();
97100 for (size_t i = 0; i < children.size(); i++) {
@@ -184,25 +187,19 @@ Size T3MarkdownTextShadowNode::measureContent( · 86 unmodified lines
184187 props.shadowRadius - ParagraphStyleEncodingOffset,
185188 });
186189 }
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) {
190+ if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
195191 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
196− utf16Offset + 1,
192+ utf16Offset,
197193 1,
198− props.nativeId.substr(std::char_traits<char>::length(FileChipNativeIdPrefix)),
194+ props.nativeId.substr(std::char_traits<char>::length(FileAttachmentNativeIdPrefix)),
199195 });
200196 } else if (
201− props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) {
197+ props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
202198 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
203− utf16Offset + 1,
199+ utf16Offset,
204200 1,
205− props.nativeId.substr(std::char_traits<char>::length(SkillChipNativeIdPrefix)),
201+ props.nativeId.substr(
202+ std::char_traits<char>::length(SkillAttachmentNativeIdPrefix)),
206203 });
207204 }
208205 utf16Offset += fragmentLength;
@@ -213,7 +210,6 @@ Size T3MarkdownTextShadowNode::measureContent( · 4 unmodified lines
213210 _attributedString = baseAttributedString;
214211 _paragraphStyleRanges = paragraphStyleRanges;
215212 _attachmentRanges = attachmentRanges;
216− _chipRanges = chipRanges;
217213
218214 NSMutableAttributedString *convertedAttributedString =
219215 [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy];
@@ -263,7 +259,6 @@ void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { · 43 unmodified lines
263259 _attributedString,
264260 _paragraphStyleRanges,
265261 _attachmentRanges,
266− _chipRanges,
267262 });
268263 }
269264 }
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+63−8
@@ -40,11 +40,13 @@ function documentFor(node: MarkdownNode): MarkdownNode { · 39 unmodified lines
4040 function SelectableNode(props: {
4141 readonly node: MarkdownNode;
4242 readonly textStyle: NativeMarkdownTextStyle;
43+ readonly onLinkPress?: (href: string) => void;
4344 }) {
4445 return (
4546 <NativeMarkdownSelectableText
4647 runs={nativeMarkdownDocumentRuns(documentFor(props.node))}
4748 textStyle={props.textStyle}
49+ onLinkPress={props.onLinkPress}
4850 />
4951 );
5052 }
@@ -312,6 +314,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] { · 261 unmodified lines
312314 function NativeTable(props: {
313315 readonly node: MarkdownNode;
314316 readonly textStyle: NativeMarkdownTextStyle;
317+ readonly onLinkPress?: (href: string) => void;
315318 }) {
316319 const rows = collectTableRows(props.node);
317320 return (
@@ -351,6 +354,7 @@ function NativeTable(props: { · 33 unmodified lines
351354 rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run,
352355 )}
353356 textStyle={props.textStyle}
357+ onLinkPress={props.onLinkPress}
354358 />
355359 </View>
356360 ))}
@@ -364,10 +368,17 @@ function NativeTable(props: { · 7 unmodified lines
364368 function NativeMarkdownImage(props: {
365369 readonly node: MarkdownNode;
366370 readonly textStyle: NativeMarkdownTextStyle;
371+ readonly onLinkPress?: (href: string) => void;
367372 }) {
368373 const href = props.node.href;
369374 if (!href) {
370− return <SelectableNode node={props.node} textStyle={props.textStyle} />;
375+ return (
376+ <SelectableNode
377+ node={props.node}
378+ textStyle={props.textStyle}
379+ onLinkPress={props.onLinkPress}
380+ />
381+ );
371382 }
372383
373384 return (
@@ -426,6 +437,7 @@ function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] { · 52 unmodified lines
426437 function NativeMixedParagraph(props: {
427438 readonly node: MarkdownNode;
428439 readonly textStyle: NativeMarkdownTextStyle;
440+ readonly onLinkPress?: (href: string) => void;
429441 }) {
430442 return (
431443 <View style={{ gap: 8 }}>
@@ -435,9 +447,15 @@ function NativeMixedParagraph(props: { · 3 unmodified lines
435447 key={nodeKey(child, index)}
436448 node={child}
437449 textStyle={props.textStyle}
450+ onLinkPress={props.onLinkPress}
438451 />
439452 ) : (
440− <SelectableNode key={nodeKey(child, index)} node={child} textStyle={props.textStyle} />
453+ <SelectableNode
454+ key={nodeKey(child, index)}
455+ node={child}
456+ textStyle={props.textStyle}
457+ onLinkPress={props.onLinkPress}
458+ />
441459 ),
442460 )}
443461 </View>
@@ -448,6 +466,7 @@ function NativeList(props: { · 4 unmodified lines
448466 readonly node: MarkdownNode;
449467 readonly textStyle: NativeMarkdownTextStyle;
450468 readonly highlightCode: MarkdownCodeHighlighter;
469+ readonly onLinkPress?: (href: string) => void;
451470 readonly depth: number;
452471 }) {
453472 const ordered = props.node.ordered ?? false;
@@ -508,6 +527,7 @@ function NativeList(props: { · 54 unmodified lines
508527 node={child}
509528 textStyle={props.textStyle}
510529 highlightCode={props.highlightCode}
530+ onLinkPress={props.onLinkPress}
511531 depth={props.depth + 1}
512532 compact
513533 />
@@ -524,6 +544,7 @@ export function NativeMarkdownBlock(props: { · 10 unmodified lines
524544 readonly node: MarkdownNode;
525545 readonly textStyle: NativeMarkdownTextStyle;
526546 readonly highlightCode: MarkdownCodeHighlighter;
547+ readonly onLinkPress?: (href: string) => void;
527548 readonly depth?: number;
528549 readonly compact?: boolean;
529550 }) {
@@ -538,6 +559,7 @@ export function NativeMarkdownBlock(props: { · 8 unmodified lines
538559 node={child}
539560 textStyle={props.textStyle}
540561 highlightCode={props.highlightCode}
562+ onLinkPress={props.onLinkPress}
541563 depth={depth}
542564 />
543565 ))}
@@ -553,9 +575,21 @@ export function NativeMarkdownBlock(props: { · 9 unmodified lines
553575 />
554576 );
555577 case "table":
556− return <NativeTable node={props.node} textStyle={props.textStyle} />;
578+ return (
579+ <NativeTable
580+ node={props.node}
581+ textStyle={props.textStyle}
582+ onLinkPress={props.onLinkPress}
583+ />
584+ );
557585 case "image":
558− return <NativeMarkdownImage node={props.node} textStyle={props.textStyle} />;
586+ return (
587+ <NativeMarkdownImage
588+ node={props.node}
589+ textStyle={props.textStyle}
590+ onLinkPress={props.onLinkPress}
591+ />
592+ );
559593 case "horizontal_rule":
560594 return (
561595 <View
@@ -583,6 +617,7 @@ export function NativeMarkdownBlock(props: { · 21 unmodified lines
583617 node={child}
584618 textStyle={props.textStyle}
585619 highlightCode={props.highlightCode}
620+ onLinkPress={props.onLinkPress}
586621 depth={depth}
587622 compact
588623 />
@@ -595,14 +630,23 @@ export function NativeMarkdownBlock(props: { · 6 unmodified lines
595630 node={props.node}
596631 textStyle={props.textStyle}
597632 highlightCode={props.highlightCode}
633+ onLinkPress={props.onLinkPress}
598634 depth={depth}
599635 />
600636 );
601637 case "paragraph":
602638 return (props.node.children ?? []).some((child) => child.type === "image") ? (
603− <NativeMixedParagraph node={props.node} textStyle={props.textStyle} />
639+ <NativeMixedParagraph
640+ node={props.node}
641+ textStyle={props.textStyle}
642+ onLinkPress={props.onLinkPress}
643+ />
604644 ) : (
605− <SelectableNode node={props.node} textStyle={props.textStyle} />
645+ <SelectableNode
646+ node={props.node}
647+ textStyle={props.textStyle}
648+ onLinkPress={props.onLinkPress}
649+ />
606650 );
607651 case "html_block":
608652 case "math_block":
@@ -618,7 +662,11 @@ export function NativeMarkdownBlock(props: { · 9 unmodified lines
618662 : "transparent",
619663 }}
620664 >
621− <SelectableNode node={props.node} textStyle={props.textStyle} />
665+ <SelectableNode
666+ node={props.node}
667+ textStyle={props.textStyle}
668+ onLinkPress={props.onLinkPress}
669+ />
622670 </View>
623671 );
624672 case "table_head":
@@ -635,6 +683,7 @@ export function NativeMarkdownBlock(props: { · 10 unmodified lines
635683 node={child}
636684 textStyle={props.textStyle}
637685 highlightCode={props.highlightCode}
686+ onLinkPress={props.onLinkPress}
638687 depth={depth}
639688 compact
640689 />
@@ -642,6 +691,12 @@ export function NativeMarkdownBlock(props: { · 1 unmodified line
642691 </View>
643692 );
644693 default:
645− return <SelectableNode node={props.node} textStyle={props.textStyle} />;
694+ return (
695+ <SelectableNode
696+ node={props.node}
697+ textStyle={props.textStyle}
698+ onLinkPress={props.onLinkPress}
699+ />
700+ );
646701 }
647702 }
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+38−85
@@ -1,61 +1,15 @@
1−import { useEffect, useMemo, useState } from "react";
2−import { Asset } from "expo-asset";
31 import { Image, Linking, type TextStyle, useColorScheme } from "react-native";
42
53 import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
64 import { markdownFileIconSource } from "./markdownFileIcons";
7−import type { MarkdownFileIcon } from "./markdownLinks";
85 import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
96 import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types";
107
118 const EXTERNAL_LINK_PREFIX = "◉ ";
12−const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0";
13−const CHIP_SUFFIX = "\u00A0";
9+const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
1410 const SKILL_ICON_PLACEHOLDER = "\uFFFC";
1511 const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;
1612
17−function 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−
5913 function runKeySignature(run: NativeMarkdownTextRun): string {
6014 return [
6115 run.text,
@@ -81,13 +35,16 @@ function runKeySignature(run: NativeMarkdownTextRun): string { · 19 unmodified lines
8135 function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
8236 const isFile = run.fileIcon != null;
8337 const isSkill = run.skillName != null;
84− const isChip = isFile || isSkill;
8538 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
8639 const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15;
8740 const isHeading = run.role === "heading";
8841 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
8942 const hasParagraphStyle = run.headIndent !== undefined;
90− const textDecorationLine = run.strikethrough ? "line-through" : run.href ? "underline" : "none";
43+ const textDecorationLine = run.strikethrough
44+ ? "line-through"
45+ : run.href && !isFile
46+ ? "underline"
47+ : "none";
9148
9249 return {
9350 color: isFile
@@ -106,20 +63,23 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 12 unmodified lines
10663 ? textStyle.mutedColor
10764 : run.role === "list-marker"
10865 ? textStyle.mutedColor
109− : run.code || isFile
66+ : isCodeBlock
11067 ? 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,
68+ : run.code
69+ ? textStyle.inlineCodeColor
70+ : run.bold
71+ ? textStyle.strongColor
72+ : textStyle.color,
73+ fontFamily:
74+ isFile || isSkill
75+ ? textStyle.boldFontFamily
76+ : run.code || isCodeBlock
77+ ? "ui-monospace"
78+ : isHeading
79+ ? textStyle.headingFontFamily
80+ : run.bold
81+ ? textStyle.boldFontFamily
82+ : textStyle.fontFamily,
12383 fontSize:
12484 run.role === "spacer"
12585 ? (run.spacing ?? 10)
@@ -129,7 +89,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 3 unmodified lines
12989 ? headingFontSize
13090 : run.role === "code-language"
13191 ? 11
132− : run.code || isChip || isCodeBlock
92+ : run.code || isCodeBlock
13393 ? Math.max(12, textStyle.fontSize - 2)
13494 : textStyle.fontSize,
13595 lineHeight:
@@ -143,17 +103,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 7 unmodified lines
143103 ? 18
144104 : textStyle.lineHeight,
145105 fontStyle: run.italic ? "italic" : "normal",
146− fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400",
106+ fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
147107 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,
108+ backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined,
157109 ...(hasParagraphStyle
158110 ? {
159111 shadowColor: "transparent",
@@ -170,9 +122,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 10 unmodified lines
170122 export function NativeMarkdownSelectableText(props: {
171123 readonly runs: ReadonlyArray<NativeMarkdownTextRun>;
172124 readonly textStyle: NativeMarkdownTextStyle;
125+ readonly onLinkPress?: (href: string) => void;
173126 }) {
174127 const colorScheme = useColorScheme();
175− const fileIconUris = useFileIconUris(props.runs);
176128 const occurrences = new Map<string, number>();
177129 const prefixedExternalLinks = new Set<string>();
178130 const keyedRuns = props.runs.map((run) => {
@@ -182,9 +134,9 @@ export function NativeMarkdownSelectableText(props: { · 3 unmodified lines
182134
183135 let text = run.text;
184136 if (run.fileIcon) {
185− text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`;
137+ text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
186138 } else if (run.skillName && run.skillLabel) {
187− text = `\u00A0${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}${CHIP_SUFFIX}`;
139+ text = `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`;
188140 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
189141 prefixedExternalLinks.add(run.href);
190142 text = `${EXTERNAL_LINK_PREFIX}${text}`;
@@ -200,12 +152,11 @@ export function NativeMarkdownSelectableText(props: { · 9 unmodified lines
200152 props.textStyle.strongColor,
201153 props.textStyle.mutedColor,
202154 props.textStyle.linkColor,
155+ props.textStyle.inlineCodeColor,
203156 props.textStyle.codeColor,
204157 props.textStyle.codeBackgroundColor,
205158 props.textStyle.codeBlockBackgroundColor,
206− props.textStyle.fileBackgroundColor,
207159 props.textStyle.fileTextColor,
208− props.textStyle.skillBackgroundColor,
209160 props.textStyle.skillTextColor,
210161 props.textStyle.quoteMarkerColor,
211162 props.textStyle.dividerColor,
@@ -217,7 +168,8 @@ export function NativeMarkdownSelectableText(props: { · 5 unmodified lines
217168 uiTextView
218169 selectable
219170 style={{
220− width: "100%",
171+ flexShrink: 1,
172+ minWidth: 0,
221173 color: props.textStyle.color,
222174 fontFamily: props.textStyle.fontFamily,
223175 fontSize: props.textStyle.fontSize,
@@ -231,19 +183,20 @@ export function NativeMarkdownSelectableText(props: { · 7 unmodified lines
231183 key={key}
232184 nativeID={
233185 run.fileIcon
234− ? `t3-chip-file:${
235− fileIconUris.get(run.fileIcon) ??
236− Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri
237− }`
186+ ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
238187 : run.skillName
239− ? "t3-chip-skill:sf:cube"
188+ ? "t3-skill:sf:cube"
240189 : undefined
241190 }
242191 style={runStyle(run, props.textStyle)}
243192 onPress={
244193 href
245194 ? () => {
246− void Linking.openURL(href);
195+ if (props.onLinkPress) {
196+ props.onLinkPress(href);
197+ } else {
198+ void Linking.openURL(href);
199+ }
247200 }
248201 : undefined
249202 }
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+19−4
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 nativeMarkdownChunkSpacing,
77 nativeMarkdownDocumentChunks,
88 nativeMarkdownDocumentRuns,
9+ nativeMarkdownWithPreservedSoftBreaks,
910 } from "./nativeMarkdownText";
1011 import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
1112 import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
@@ -33,15 +34,20 @@ export function SelectableMarkdownText({ · 21 unmodified lines
3334 skills = EMPTY_SKILLS,
3435 textStyle,
3536 highlightCode,
37+ preserveSoftBreaks = false,
38+ onLinkPress,
3639 marginTop = 0,
3740 marginBottom = 0,
3841 }: SelectableMarkdownTextProps) {
3942 const chunks = useMemo(() => {
40− const document = parseMarkdownWithOptions(markdown, {
43+ const parsedDocument = parseMarkdownWithOptions(markdown, {
4144 gfm: true,
4245 html: true,
4346 math: false,
4447 });
48+ const document = preserveSoftBreaks
49+ ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
50+ : parsedDocument;
4551 return nativeMarkdownDocumentChunks(document).map((chunk) =>
4652 chunk.kind === "selectable"
4753 ? {
@@ -50,10 +56,14 @@ export function SelectableMarkdownText({ · 2 unmodified lines
5056 }
5157 : chunk,
5258 );
53− }, [markdown, skills]);
59+ }, [markdown, preserveSoftBreaks, skills]);
5460
5561 return (
56− <View style={{ width: "100%", marginTop, marginBottom }}>
62+ // A percentage width here creates a cyclic intrinsic measurement inside
63+ // shrink-to-fit containers such as user-message bubbles. Yoga then gives
64+ // the native text node an unbounded second pass and the parent only clips
65+ // the resulting single-line width instead of reflowing it.
66+ <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
5767 {chunks.map((chunk, index) => {
5868 const content =
5969 chunk.kind === "rich" ? (
@@ -61,9 +71,14 @@ export function SelectableMarkdownText({ · 1 unmodified line
6171 node={chunk.node}
6272 textStyle={textStyle}
6373 highlightCode={highlightCode}
74+ onLinkPress={onLinkPress}
6475 />
6576 ) : (
66− <NativeMarkdownSelectableText runs={chunk.runs} textStyle={textStyle} />
77+ <NativeMarkdownSelectableText
78+ runs={chunk.runs}
79+ textStyle={textStyle}
80+ onLinkPress={onLinkPress}
81+ />
6782 );
6883
6984 return (
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+3−2
@@ -3,12 +3,11 @@ export interface NativeMarkdownTextStyle { · 2 unmodified lines
33 readonly strongColor: string;
44 readonly mutedColor: string;
55 readonly linkColor: string;
6+ readonly inlineCodeColor: string;
67 readonly codeColor: string;
78 readonly codeBackgroundColor: string;
89 readonly codeBlockBackgroundColor: string;
9− readonly fileBackgroundColor: string;
1010 readonly fileTextColor: string;
11− readonly skillBackgroundColor: string;
1211 readonly skillTextColor: string;
1312 readonly quoteMarkerColor: string;
1413 readonly dividerColor: string;
@@ -41,6 +40,8 @@ export interface SelectableMarkdownTextProps { · 26 unmodified lines
4140 readonly textStyle: NativeMarkdownTextStyle;
4241 readonly highlightCode: MarkdownCodeHighlighter;
4342 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
43+ readonly preserveSoftBreaks?: boolean;
44+ readonly onLinkPress?: (href: string) => void;
4445 readonly marginTop?: number;
4546 readonly marginBottom?: number;
4647 }
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+61−11
@@ -27,8 +27,12 @@ export type MarkdownLinkPresentation = · 26 unmodified lines
2727 }
2828 | {
2929 readonly kind: "file";
30+ readonly href: string;
3031 readonly icon: MarkdownFileIcon;
3132 readonly label: string;
33+ readonly path: string;
34+ readonly line?: number;
35+ readonly column?: number;
3236 }
3337 | {
3438 readonly kind: "link";
@@ -247,7 +251,7 @@ function normalizeDestination(value: string): string { · 212 unmodified lines
247251 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
248252 }
249253
250−function fileUrlPath(href: string): string | null {
254+function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null {
251255 try {
252256 const parsed = new URL(href);
253257 if (parsed.protocol.toLowerCase() !== "file:") {
@@ -256,15 +260,44 @@ function fileUrlPath(href: string): string | null { · 2 unmodified lines
256260 const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname)
257261 ? parsed.pathname.slice(1)
258262 : 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+ return { path, hash: parsed.hash };
263264 } catch {
264265 return null;
265266 }
266267 }
267268
269+function stripSearchAndHash(value: string): { readonly path: string; readonly hash: string } {
270+ const hashIndex = value.indexOf("#");
271+ const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
272+ const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
273+ const queryIndex = pathWithSearch.indexOf("?");
274+ return {
275+ path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch,
276+ hash,
277+ };
278+}
279+
280+function splitFilePosition(
281+ path: string,
282+ hash: string,
283+): { readonly path: string; readonly line?: number; readonly column?: number } {
284+ const suffixMatch = path.match(/:(\d+)(?::(\d+))?$/);
285+ const hashMatch = suffixMatch ? null : hash.match(/^#L(\d+)(?:C(\d+))?$/i);
286+ const match = suffixMatch ?? hashMatch;
287+ if (!match?.[1]) {
288+ return { path };
289+ }
290+
291+ const line = Number.parseInt(match[1], 10);
292+ const column = match[2] ? Number.parseInt(match[2], 10) : undefined;
293+ const pathWithoutPosition = suffixMatch ? path.slice(0, -suffixMatch[0].length) : path;
294+ return {
295+ path: pathWithoutPosition,
296+ ...(line > 0 ? { line } : {}),
297+ ...(column !== undefined && column > 0 ? { column } : {}),
298+ };
299+}
300+
268301 function looksLikePosixFilesystemPath(path: string): boolean {
269302 if (!path.startsWith("/")) {
270303 return false;
@@ -331,14 +364,31 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese · 60 unmodified lines
331364 // Relative paths and non-URL link destinations are handled below.
332365 }
333366
334− const fileTarget = normalized.toLowerCase().startsWith("file:")
335− ? fileUrlPath(normalized)
336− : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized);
337− if (fileTarget && looksLikeFilePath(fileTarget)) {
367+ const source = normalized.toLowerCase().startsWith("file:")
368+ ? fileUrlTarget(normalized)
369+ : stripSearchAndHash(normalized);
370+ const decodedSource = source
371+ ? { path: safeDecode(source.path.trim()), hash: safeDecode(source.hash.trim()) }
372+ : null;
373+ const fileTarget = decodedSource
374+ ? splitFilePosition(decodedSource.path, decodedSource.hash)
375+ : null;
376+ const targetWithPosition = fileTarget
377+ ? `${fileTarget.path}${
378+ fileTarget.line
379+ ? `:${fileTarget.line}${fileTarget.column ? `:${fileTarget.column}` : ""}`
380+ : ""
381+ }`
382+ : null;
383+ if (fileTarget && targetWithPosition && looksLikeFilePath(targetWithPosition)) {
338384 return {
339385 kind: "file",
340− icon: resolveMarkdownFileIcon(fileTarget),
341− label: fileLabel(fileTarget),
386+ href: normalized,
387+ icon: resolveMarkdownFileIcon(fileTarget.path),
388+ label: fileLabel(targetWithPosition),
389+ path: fileTarget.path,
390+ ...(fileTarget.line ? { line: fileTarget.line } : {}),
391+ ...(fileTarget.column ? { column: fileTarget.column } : {}),
342392 };
343393 }
344394
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+10−0
@@ -293,6 +293,7 @@ function appendNode( · 292 unmodified lines
293293 if (presentation.kind === "file") {
294294 return appendRun(runs, presentation.label, {
295295 ...context,
296+ href: presentation.href,
296297 fileIcon: presentation.icon,
297298 });
298299 }
@@ -319,6 +320,15 @@ export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray<Native · 20 unmodified lines
319320 return appendChildren([], node, EMPTY_CONTEXT);
320321 }
321322
323+export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): MarkdownNode {
324+ const children = node.children?.map(nativeMarkdownWithPreservedSoftBreaks);
325+ return {
326+ ...node,
327+ ...(node.type === "soft_break" ? { type: "line_break" as const } : {}),
328+ ...(children ? { children } : {}),
329+ };
330+}
331+
322332 function appendBlockTerminator(
323333 runs: NativeMarkdownTextRun[],
324334 context: RunContext,
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+4−0
@@ -57,6 +57,10 @@ public class T3ReviewDiffModule: Module { · 56 unmodified lines
5757 view.setContentWidth(CGFloat(contentWidth))
5858 }
5959
60+ Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in
61+ view.setInitialRowIndex(initialRowIndex)
62+ }
63+
6064 Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")
6165 }
6266 }
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+55−1
@@ -205,7 +205,7 @@ private struct ReviewDiffNativeStyle { · 204 unmodified lines
205205 ReviewDiffNativeStyle(
206206 rowHeight: metric(payload?.rowHeight, fallback: 24),
207207 contentWidth: metric(payload?.contentWidth, fallback: 2800),
208− changeBarWidth: metric(payload?.changeBarWidth, fallback: 4),
208+ changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4),
209209 gutterWidth: metric(payload?.gutterWidth, fallback: 50),
210210 codePadding: metric(payload?.codePadding, fallback: 8),
211211 textVerticalInset: metric(payload?.textVerticalInset, fallback: 3),
@@ -243,6 +243,13 @@ private struct ReviewDiffNativeStyle { · 31 unmodified lines
243243 return CGFloat(value)
244244 }
245245
246+ private static func nonNegativeMetric(_ value: Double?, fallback: CGFloat) -> CGFloat {
247+ guard let value, value.isFinite, value >= 0 else {
248+ return fallback
249+ }
250+ return CGFloat(value)
251+ }
252+
246253 private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight {
247254 switch value?.lowercased() {
248255 case "ultralight", "ultra-light":
@@ -316,6 +323,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 67 unmodified lines
316323 private var lastMetricsDebugKey = ""
317324 private var lastVisibleRangeDebugKey = ""
318325 private var tokensResetKey = ""
326+ private var initialRowIndex: Int?
327+ private var hasAppliedInitialRowIndex = false
319328
320329 let onDebug = EventDispatcher()
321330 let onToggleFile = EventDispatcher()
@@ -394,6 +403,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 72 unmodified lines
394403 do {
395404 rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
396405 contentView.rows = rows
406+ hasAppliedInitialRowIndex = false
397407 emitDebug("rows-decoded", [
398408 "rows": rows.count,
399409 "firstKind": rows.first?.kind ?? "none",
@@ -402,6 +412,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 2 unmodified lines
402412 } catch {
403413 rows = []
404414 contentView.rows = []
415+ hasAppliedInitialRowIndex = false
405416 updateContentMetrics()
406417 emitDebug("rows-decode-failed", [
407418 "error": error.localizedDescription,
@@ -561,6 +572,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 153 unmodified lines
561572 contentView.verticalOffset = scrollView.contentOffset.y
562573 contentView.invalidateVisibleViewport()
563574 contentView.setNeedsDisplay()
575+ applyInitialRowIndexIfNeeded()
564576
565577 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
566578 if debugKey != lastMetricsDebugKey {
@@ -645,6 +657,19 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 78 unmodified lines
645657 applyStyle()
646658 }
647659
660+ func setInitialRowIndex(_ initialRowIndex: Double) {
661+ let nextIndex: Int? = initialRowIndex.isFinite && initialRowIndex >= 0
662+ ? Int(initialRowIndex.rounded(.down))
663+ : nil
664+ guard nextIndex != self.initialRowIndex else {
665+ return
666+ }
667+
668+ self.initialRowIndex = nextIndex
669+ hasAppliedInitialRowIndex = false
670+ applyInitialRowIndexIfNeeded()
671+ }
672+
648673 private func applyStyle() {
649674 contentView.style = ReviewDiffNativeStyle
650675 .resolve(stylePayload)
@@ -662,6 +687,22 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { · 11 unmodified lines
662687 contentView.verticalOffset = scrollView.contentOffset.y
663688 contentView.invalidateVisibleViewport()
664689 }
690+
691+ private func applyInitialRowIndexIfNeeded() {
692+ guard !hasAppliedInitialRowIndex,
693+ let initialRowIndex,
694+ bounds.height > 0,
695+ let rowFrame = contentView.frameForRow(at: initialRowIndex) else {
696+ return
697+ }
698+
699+ let targetScreenY = max(0, (bounds.height - rowFrame.height) * 0.3)
700+ let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0)
701+ let targetOffset = min(max(rowFrame.minY - targetScreenY, 0), maxOffset)
702+ hasAppliedInitialRowIndex = true
703+ scrollView.setContentOffset(CGPoint(x: 0, y: targetOffset), animated: false)
704+ updateViewportFrame()
705+ }
665706 }
666707
667708 private enum ReviewDiffHorizontalPanKind {
@@ -820,6 +861,19 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { · 152 unmodified lines
820861 return style.rowHeight
821862 }
822863
864+ func frameForRow(at index: Int) -> CGRect? {
865+ guard rows.indices.contains(index), rowOffsets.indices.contains(index) else {
866+ return nil
867+ }
868+
869+ return CGRect(
870+ x: 0,
871+ y: rowOffsets[index],
872+ width: max(viewportWidth, 1),
873+ height: height(for: rows[index])
874+ )
875+ }
876+
823877 private func rebuildRowLayout() {
824878 var nextOffsets: [CGFloat] = []
825879 var nextFileHeaderRowIndices: [Int] = []
apps/mobile/package.json+1−0
@@ -101,6 +101,7 @@ · 100 unmodified lines
101101 "react-native-screens": "4.25.2",
102102 "react-native-shiki-engine": "^0.3.12",
103103 "react-native-svg": "15.15.4",
104+ "react-native-webview": "^13.16.1",
104105 "react-native-worklets": "0.8.3",
105106 "shiki": "4.2.0",
106107 "tailwind-merge": "^3.5.0",
apps/mobile/src/app/_layout.tsx+3−2
@@ -30,10 +30,11 @@ import { useThemeColor } from "../lib/useThemeColor"; · 29 unmodified lines
3030
3131 function AppNavigator() {
3232 const pathname = usePathname();
33− const clerkRouteIsActive = pathname === "/settings/auth";
33+ const expandedSettingsRouteIsActive =
34+ pathname === "/settings/archive" || pathname === "/settings/auth";
3435
3536 return (
36− <ClerkSettingsSheetDetentProvider initiallyExpanded={clerkRouteIsActive}>
37+ <ClerkSettingsSheetDetentProvider initiallyExpanded={expandedSettingsRouteIsActive}>
3738 <AppNavigatorContent />
3839 </ClerkSettingsSheetDetentProvider>
3940 );
apps/mobile/src/app/index.tsx+88−89
@@ -1,13 +1,33 @@
1−import { Stack, useRouter } from "expo-router";
2−import { useState } from "react";
3−import { Text as RNText, View } from "react-native";
1+import type {
2+ EnvironmentId,
3+ SidebarProjectGroupingMode,
4+ SidebarThreadSortOrder,
5+} from "@t3tools/contracts";
6+import {
7+ DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8+ DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9+ DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10+} from "@t3tools/contracts";
11+import * as Arr from "effect/Array";
12+import * as Order from "effect/Order";
13+import { useRouter } from "expo-router";
14+import { useCallback, useMemo, useState } from "react";
415
516 import { useProjects, useThreadShells } from "../state/entities";
617 import { useWorkspaceState } from "../state/workspace";
718 import { buildThreadRoutePath } from "../lib/routes";
819 import { useSavedRemoteConnections } from "../state/use-remote-environment-registry";
920 import { HomeScreen } from "../features/home/HomeScreen";
10−import { useThemeColor } from "../lib/useThemeColor";
21+import { HomeHeader } from "../features/home/HomeHeader";
22+import type { HomeProjectSortOrder } from "../features/home/homeThreadList";
23+import { useThreadListActions } from "../features/home/useThreadListActions";
24+
25+interface HomeListOptions {
26+ readonly selectedEnvironmentId: EnvironmentId | null;
27+ readonly projectSortOrder: HomeProjectSortOrder;
28+ readonly threadSortOrder: SidebarThreadSortOrder;
29+ readonly projectGroupingMode: SidebarProjectGroupingMode;
30+}
1131
1232 /* ─── Route screen ───────────────────────────────────────────────────── */
1333
@@ -18,103 +38,82 @@ export default function HomeRouteScreen() { · 4 unmodified lines
1838 const { savedConnectionsById } = useSavedRemoteConnections();
1939 const router = useRouter();
2040 const [searchQuery, setSearchQuery] = useState("");
21−
22− const iconColor = useThemeColor("--color-icon");
23− const mutedColor = useThemeColor("--color-foreground-muted");
24− const subtleColor = useThemeColor("--color-subtle");
41+ const [listOptions, setListOptions] = useState<HomeListOptions>({
42+ selectedEnvironmentId: null,
43+ projectSortOrder:
44+ DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
45+ ? "updated_at"
46+ : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
47+ threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
48+ projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
49+ });
50+ const { archiveThread, confirmDeleteThread } = useThreadListActions();
51+ const environments = useMemo(
52+ () =>
53+ Arr.sort(
54+ Object.values(savedConnectionsById).map((connection) => ({
55+ environmentId: connection.environmentId,
56+ label: connection.environmentLabel,
57+ })),
58+ Order.mapInput(
59+ Order.String,
60+ (environment: { readonly label: string }) => environment.label,
61+ ),
62+ ),
63+ [savedConnectionsById],
64+ );
65+ const selectedEnvironmentId = environments.some(
66+ (environment) => environment.environmentId === listOptions.selectedEnvironmentId,
67+ )
68+ ? listOptions.selectedEnvironmentId
69+ : null;
70+ const setSelectedEnvironmentId = useCallback((environmentId: EnvironmentId | null) => {
71+ setListOptions((current) => ({ ...current, selectedEnvironmentId: environmentId }));
72+ }, []);
73+ const setProjectSortOrder = useCallback((projectSortOrder: HomeProjectSortOrder) => {
74+ setListOptions((current) => ({ ...current, projectSortOrder }));
75+ }, []);
76+ const setThreadSortOrder = useCallback((threadSortOrder: SidebarThreadSortOrder) => {
77+ setListOptions((current) => ({ ...current, threadSortOrder }));
78+ }, []);
79+ const setProjectGroupingMode = useCallback((projectGroupingMode: SidebarProjectGroupingMode) => {
80+ setListOptions((current) => ({ ...current, projectGroupingMode }));
81+ }, []);
2582
2683 return (
2784 <>
28− <Stack.Screen
29− options={{
30− headerShown: true,
31− headerTransparent: true,
32− headerStyle: { backgroundColor: "transparent" },
33− headerShadowVisible: false,
34− headerTintColor: iconColor,
35− headerTitle: "",
36− headerSearchBarOptions: {
37− placeholder: "Search threads",
38− hideNavigationBar: false,
39− onChangeText: (event) => {
40− setSearchQuery(event.nativeEvent.text);
41− },
42− onCancelButtonPress: () => {
43− setSearchQuery("");
44− },
45− allowToolbarIntegration: true,
46− },
47− }}
85+ <HomeHeader
86+ environments={environments}
87+ selectedEnvironmentId={selectedEnvironmentId}
88+ projectSortOrder={listOptions.projectSortOrder}
89+ threadSortOrder={listOptions.threadSortOrder}
90+ projectGroupingMode={listOptions.projectGroupingMode}
91+ onEnvironmentChange={setSelectedEnvironmentId}
92+ onOpenSettings={() => router.push("/settings")}
93+ onProjectGroupingModeChange={setProjectGroupingMode}
94+ onProjectSortOrderChange={setProjectSortOrder}
95+ onSearchQueryChange={setSearchQuery}
96+ onStartNewTask={() => router.push("/new")}
97+ onThreadSortOrderChange={setThreadSortOrder}
4898 />
4999
50− {/* Header left: plain text, no Liquid Glass button chrome */}
51− <Stack.Toolbar placement="left">
52− <Stack.Toolbar.View hidesSharedBackground>
53− <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
54− <RNText
55− style={{
56− fontFamily: "DMSans_700Bold",
57− fontSize: 17,
58− color: iconColor,
59− letterSpacing: -0.4,
60− }}
61− >
62− T3 Code
63− </RNText>
64− <View
65− style={{
66− backgroundColor: subtleColor,
67− borderRadius: 99,
68− paddingHorizontal: 8,
69− paddingVertical: 3,
70− }}
71− >
72− <RNText
73− style={{
74− fontFamily: "DMSans_700Bold",
75− fontSize: 10,
76− color: mutedColor,
77− letterSpacing: 1.1,
78− textTransform: "uppercase",
79− }}
80− >
81− Alpha
82− </RNText>
83− </View>
84− </View>
85− </Stack.Toolbar.View>
86− </Stack.Toolbar>
87−
88− <Stack.Toolbar placement="right">
89− <Stack.Toolbar.Button
90− icon="gearshape"
91− onPress={() => router.push("/settings")}
92− separateBackground
93− />
94− </Stack.Toolbar>
95−
96− {/* Bottom toolbar: search + compose, visually split like iMessage */}
97− <Stack.Toolbar placement="bottom">
98− <Stack.Toolbar.SearchBarSlot />
99− <Stack.Toolbar.Spacer width={8} sharesBackground={false} />
100− <Stack.Toolbar.Button
101− icon="square.and.pencil"
102− onPress={() => router.push("/new")}
103− separateBackground
104− />
105− </Stack.Toolbar>
106−
107100 <HomeScreen
108− projects={projects}
109− threads={threads}
110101 catalogState={catalogState}
111− savedConnectionsById={savedConnectionsById}
112− searchQuery={searchQuery}
113102 onAddConnection={() => router.push("/connections/new")}
103+ onArchiveThread={archiveThread}
104+ onDeleteThread={confirmDeleteThread}
114105 onOpenEnvironments={() => router.push("/settings/environments")}
115106 onSelectThread={(thread) => {
116107 router.push(buildThreadRoutePath(thread));
117108 }}
109+ projectGroupingMode={listOptions.projectGroupingMode}
110+ projects={projects}
111+ projectSortOrder={listOptions.projectSortOrder}
112+ savedConnectionsById={savedConnectionsById}
113+ searchQuery={searchQuery}
114+ selectedEnvironmentId={selectedEnvironmentId}
115+ threads={threads}
116+ threadSortOrder={listOptions.threadSortOrder}
118117 />
119118 </>
120119 );
apps/mobile/src/app/settings/_layout.tsx+7−2
@@ -14,7 +14,7 @@ export default function SettingsLayout() { · 13 unmodified lines
1414 const contentStyle = useResolveClassNames("bg-sheet");
1515 const sheetBg = useThemeColor("--color-sheet");
1616 const headerTint = useThemeColor("--color-foreground");
17− const handleClerkRouteTransitionEnd = useCallback(
17+ const handleExpandedRouteTransitionEnd = useCallback(
1818 (event: { data: { closing: boolean } }) => {
1919 if (event.data.closing) {
2020 collapse();
@@ -47,9 +47,14 @@ export default function SettingsLayout() { · 26 unmodified lines
4747 name="waitlist"
4848 options={{ animation: "slide_from_right", title: "Join the waitlist" }}
4949 />
50+ <Stack.Screen
51+ name="archive"
52+ listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
53+ options={{ animation: "slide_from_right", title: "Archived Threads" }}
54+ />
5055 <Stack.Screen
5156 name="auth"
52− listeners={{ transitionEnd: handleClerkRouteTransitionEnd }}
57+ listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
5358 options={{ animation: "slide_from_right", title: "Sign in" }}
5459 />
5560 </Stack>
apps/mobile/src/app/settings/archive.tsx+3−0
@@ -0,0 +1,3 @@
1+import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen";
2+
3+export default ArchivedThreadsRouteScreen;
apps/mobile/src/app/settings/index.tsx+16−2
@@ -65,6 +65,8 @@ function LocalSettingsRouteScreen() { · 64 unmodified lines
6565 />
6666 </SettingsSection>
6767
68+ <ArchivedThreadsSettingsSection />
69+
6870 <AppSettingsSection />
6971 </ScrollView>
7072 </View>
@@ -374,6 +376,8 @@ function ConfiguredSettingsRouteScreen() { · 303 unmodified lines
374376 />
375377 </SettingsSection>
376378
379+ <ArchivedThreadsSettingsSection />
380+
377381 <AppSettingsSection />
378382 </ScrollView>
379383 </View>
@@ -416,12 +420,20 @@ function AppSettingsSection() { · 36 unmodified lines
416420 );
417421 }
418422
423+function ArchivedThreadsSettingsSection() {
424+ return (
425+ <SettingsSection title="Threads">
426+ <SettingsRow icon="archivebox" label="Archived Threads" href="/settings/archive" />
427+ </SettingsSection>
428+ );
429+}
430+
419431 function SettingsRow(props: {
420432 readonly disabled?: boolean;
421433 readonly icon: SymbolName;
422434 readonly label: string;
423435 readonly value?: string;
424− readonly href?: "/settings/environments";
436+ readonly href?: "/settings/archive" | "/settings/environments";
425437 readonly onPress?: () => void;
426438 }) {
427439 const icon = useThemeColor("--color-icon");
@@ -459,7 +471,9 @@ function SettingsRow(props: { · 31 unmodified lines
459471 if (props.href) {
460472 return (
461473 <Link href={props.href} asChild>
462− <Pressable>{content}</Pressable>
474+ <Pressable accessibilityLabel={props.label} accessibilityRole="button">
475+ {content}
476+ </Pressable>
463477 </Link>
464478 );
465479 }
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx+26−0
@@ -55,6 +55,32 @@ export default function ThreadLayout() { · 54 unmodified lines
5555 headerStyle: headerBg,
5656 }}
5757 />
58+ <Stack.Screen
59+ name="files/index"
60+ options={{
61+ animation: "slide_from_right",
62+ contentStyle: sheetStyle,
63+ headerBackButtonDisplayMode: "minimal",
64+ headerShown: true,
65+ headerTitle: "Files",
66+ headerBackTitle: "",
67+ headerShadowVisible: false,
68+ headerStyle: headerBg,
69+ }}
70+ />
71+ <Stack.Screen
72+ name="files/[...path]"
73+ options={{
74+ animation: "slide_from_right",
75+ contentStyle: sheetStyle,
76+ headerBackButtonDisplayMode: "minimal",
77+ headerShown: true,
78+ headerTitle: "File",
79+ headerBackTitle: "",
80+ headerShadowVisible: false,
81+ headerStyle: headerBg,
82+ }}
83+ />
5884 <Stack.Screen
5985 name="review-comment"
6086 options={{
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/[...path].tsx+5−0
@@ -0,0 +1,5 @@
1+import { ThreadFileScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2+
3+export default function ThreadFileRoute() {
4+ return <ThreadFileScreen />;
5+}
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx+5−0
@@ -0,0 +1,5 @@
1+import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2+
3+export default function ThreadFilesIndexRoute() {
4+ return <ThreadFilesTreeScreen />;
5+}
apps/mobile/src/components/LoadingStrip.tsx+93−0
@@ -0,0 +1,93 @@
1+import { useEffect, useState } from "react";
2+import { View } from "react-native";
3+import Animated, {
4+ cancelAnimation,
5+ Easing,
6+ useAnimatedStyle,
7+ useSharedValue,
8+ withRepeat,
9+ withTiming,
10+} from "react-native-reanimated";
11+
12+const INDICATOR_WIDTH_FRACTION = 0.3;
13+const MIN_INDICATOR_WIDTH = 48;
14+
15+function LoadingStripFrame(props: {
16+ readonly children: React.ReactNode;
17+ readonly onLayout?: (width: number) => void;
18+}) {
19+ return (
20+ <View
21+ pointerEvents="none"
22+ accessibilityElementsHidden
23+ importantForAccessibility="no-hide-descendants"
24+ className="absolute inset-x-0 top-0 z-10 h-0.5 overflow-hidden"
25+ onLayout={
26+ props.onLayout
27+ ? (event) => {
28+ props.onLayout?.(event.nativeEvent.layout.width);
29+ }
30+ : undefined
31+ }
32+ >
33+ {props.children}
34+ </View>
35+ );
36+}
37+
38+function IndeterminateLoadingStrip() {
39+ const [containerWidth, setContainerWidth] = useState(0);
40+ const travelProgress = useSharedValue(0);
41+ const indicatorWidth = Math.max(MIN_INDICATOR_WIDTH, containerWidth * INDICATOR_WIDTH_FRACTION);
42+
43+ useEffect(() => {
44+ travelProgress.value = 0;
45+ travelProgress.value = withRepeat(
46+ withTiming(1, {
47+ duration: 1100,
48+ easing: Easing.inOut(Easing.quad),
49+ }),
50+ -1,
51+ false,
52+ );
53+
54+ return () => {
55+ cancelAnimation(travelProgress);
56+ };
57+ }, [travelProgress]);
58+
59+ const indicatorStyle = useAnimatedStyle(
60+ () => ({
61+ transform: [
62+ {
63+ translateX: (containerWidth + indicatorWidth) * travelProgress.value - indicatorWidth,
64+ },
65+ ],
66+ width: indicatorWidth,
67+ }),
68+ [containerWidth, indicatorWidth],
69+ );
70+
71+ return (
72+ <LoadingStripFrame onLayout={setContainerWidth}>
73+ <Animated.View className="h-full rounded-full bg-primary" style={indicatorStyle} />
74+ </LoadingStripFrame>
75+ );
76+}
77+
78+export function LoadingStrip(props: { readonly progress?: number }) {
79+ if (props.progress === undefined) {
80+ return <IndeterminateLoadingStrip />;
81+ }
82+
83+ const clampedProgress = Math.min(1, Math.max(0, props.progress));
84+
85+ return (
86+ <LoadingStripFrame>
87+ <View
88+ className="h-full rounded-r-full bg-primary"
89+ style={{ width: `${clampedProgress * 100}%` }}
90+ />
91+ </LoadingStripFrame>
92+ );
93+}
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx+95−0
@@ -0,0 +1,95 @@
1+import type { EnvironmentId } from "@t3tools/contracts";
2+import * as Arr from "effect/Array";
3+import * as Order from "effect/Order";
4+import { useFocusEffect } from "expo-router";
5+import { useCallback, useMemo, useState } from "react";
6+
7+import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
8+import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
9+import { useArchivedThreadListActions } from "../home/useThreadListActions";
10+import {
11+ ArchivedThreadsScreen,
12+ type ArchivedThreadsHeaderEnvironment,
13+} from "./ArchivedThreadsScreen";
14+import { buildArchivedThreadGroups, type ArchivedThreadSortOrder } from "./archivedThreadList";
15+import {
16+ refreshArchivedThreadsForEnvironment,
17+ useArchivedThreadSnapshots,
18+} from "./useArchivedThreadSnapshots";
19+
20+export function ArchivedThreadsRouteScreen() {
21+ const { expand } = useClerkSettingsSheetDetent();
22+ const { savedConnectionsById } = useSavedRemoteConnections();
23+ const [searchQuery, setSearchQuery] = useState("");
24+ const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
25+ const [sortOrder, setSortOrder] = useState<ArchivedThreadSortOrder>("newest");
26+ const environments = useMemo<ReadonlyArray<ArchivedThreadsHeaderEnvironment>>(
27+ () =>
28+ Arr.sort(
29+ Object.values(savedConnectionsById).map((connection) => ({
30+ environmentId: connection.environmentId,
31+ label: connection.environmentLabel,
32+ })),
33+ Order.mapInput(Order.String, (environment: ArchivedThreadsHeaderEnvironment) =>
34+ environment.label.toLocaleLowerCase(),
35+ ),
36+ ),
37+ [savedConnectionsById],
38+ );
39+ const environmentIds = useMemo(
40+ () => environments.map((environment) => environment.environmentId),
41+ [environments],
42+ );
43+ const environmentLabels = useMemo(
44+ () =>
45+ Object.fromEntries(
46+ environments.map((environment) => [environment.environmentId, environment.label]),
47+ ),
48+ [environments],
49+ );
50+ const { error, isLoading, refresh, snapshots } = useArchivedThreadSnapshots(environmentIds);
51+ const groups = useMemo(
52+ () =>
53+ buildArchivedThreadGroups({
54+ snapshots,
55+ environmentLabels,
56+ environmentId: selectedEnvironmentId,
57+ searchQuery,
58+ sortOrder,
59+ }),
60+ [environmentLabels, searchQuery, selectedEnvironmentId, snapshots, sortOrder],
61+ );
62+ const refreshChangedEnvironment = useCallback(
63+ (thread: { readonly environmentId: EnvironmentId }) => {
64+ refreshArchivedThreadsForEnvironment(thread.environmentId);
65+ },
66+ [],
67+ );
68+ const { unarchiveThread, confirmDeleteThread } =
69+ useArchivedThreadListActions(refreshChangedEnvironment);
70+
71+ useFocusEffect(
72+ useCallback(() => {
73+ expand();
74+ refresh();
75+ }, [expand, refresh]),
76+ );
77+
78+ return (
79+ <ArchivedThreadsScreen
80+ environments={environments}
81+ error={error}
82+ groups={groups}
83+ isLoading={isLoading}
84+ onDeleteThread={confirmDeleteThread}
85+ onEnvironmentChange={setSelectedEnvironmentId}
86+ onRefresh={refresh}
87+ onSearchQueryChange={setSearchQuery}
88+ onSortOrderChange={setSortOrder}
89+ onUnarchiveThread={unarchiveThread}
90+ searchQuery={searchQuery}
91+ selectedEnvironmentId={selectedEnvironmentId}
92+ sortOrder={sortOrder}
93+ />
94+ );
95+}
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+434−0
@@ -0,0 +1,434 @@
1+import type {
2+ EnvironmentProject,
3+ EnvironmentThreadShell,
4+} from "@t3tools/client-runtime/state/shell";
5+import type { EnvironmentId } from "@t3tools/contracts";
6+import type { MenuAction } from "@react-native-menu/menu";
7+import * as Haptics from "expo-haptics";
8+import { Stack } from "expo-router";
9+import { SymbolView } from "expo-symbols";
10+import { useCallback, useRef } from "react";
11+import {
12+ ActivityIndicator,
13+ Pressable,
14+ RefreshControl,
15+ ScrollView,
16+ useWindowDimensions,
17+ View,
18+} from "react-native";
19+import ReanimatedSwipeable, {
20+ type SwipeableMethods,
21+} from "react-native-gesture-handler/ReanimatedSwipeable";
22+
23+import { AppText as Text } from "../../components/AppText";
24+import { ControlPillMenu } from "../../components/ControlPill";
25+import { EmptyState } from "../../components/EmptyState";
26+import { ProjectFavicon } from "../../components/ProjectFavicon";
27+import { relativeTime } from "../../lib/time";
28+import { useThemeColor } from "../../lib/useThemeColor";
29+import {
30+ THREAD_SWIPE_ACTIONS_WIDTH,
31+ THREAD_SWIPE_SPRING,
32+ ThreadSwipeActions,
33+} from "../home/thread-swipe-actions";
34+import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
35+
36+export interface ArchivedThreadsHeaderEnvironment {
37+ readonly environmentId: EnvironmentId;
38+ readonly label: string;
39+}
40+
41+const THREAD_ACTIONS: MenuAction[] = [
42+ {
43+ id: "unarchive",
44+ title: "Unarchive",
45+ image: "arrow.uturn.backward",
46+ },
47+ {
48+ id: "delete",
49+ title: "Delete",
50+ image: "trash",
51+ attributes: { destructive: true },
52+ },
53+];
54+
55+function ArchivedThreadsHeader(props: {
56+ readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
57+ readonly selectedEnvironmentId: EnvironmentId | null;
58+ readonly sortOrder: ArchivedThreadSortOrder;
59+ readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
60+ readonly onSearchQueryChange: (query: string) => void;
61+ readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
62+}) {
63+ const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";
64+
65+ return (
66+ <>
67+ <Stack.Screen
68+ options={{
69+ title: "Archived Threads",
70+ headerSearchBarOptions: {
71+ autoCapitalize: "none",
72+ hideNavigationBar: false,
73+ obscureBackground: false,
74+ placeholder: "Search archived threads",
75+ placement: "stacked",
76+ onChangeText: (event) => {
77+ props.onSearchQueryChange(event.nativeEvent.text);
78+ },
79+ onCancelButtonPress: () => {
80+ props.onSearchQueryChange("");
81+ },
82+ },
83+ }}
84+ />
85+
86+ <Stack.Toolbar placement="right">
87+ <Stack.Toolbar.Menu
88+ accessibilityLabel="Filter and sort archived threads"
89+ icon={
90+ hasCustomFilter
91+ ? "line.3.horizontal.decrease.circle.fill"
92+ : "line.3.horizontal.decrease.circle"
93+ }
94+ separateBackground
95+ title="Archived thread options"
96+ >
97+ <Stack.Toolbar.Menu title="Environment">
98+ <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>
99+ <Stack.Toolbar.MenuAction
100+ isOn={props.selectedEnvironmentId === null}
101+ onPress={() => props.onEnvironmentChange(null)}
102+ >
103+ <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>
104+ </Stack.Toolbar.MenuAction>
105+ {props.environments.map((environment) => (
106+ <Stack.Toolbar.MenuAction
107+ key={environment.environmentId}
108+ isOn={props.selectedEnvironmentId === environment.environmentId}
109+ onPress={() => props.onEnvironmentChange(environment.environmentId)}
110+ >
111+ <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>
112+ </Stack.Toolbar.MenuAction>
113+ ))}
114+ </Stack.Toolbar.Menu>
115+
116+ <Stack.Toolbar.Menu title="Sort by archived date">
117+ <Stack.Toolbar.Label>Sort by archived date</Stack.Toolbar.Label>
118+ <Stack.Toolbar.MenuAction
119+ isOn={props.sortOrder === "newest"}
120+ onPress={() => props.onSortOrderChange("newest")}
121+ >
122+ <Stack.Toolbar.Label>Newest first</Stack.Toolbar.Label>
123+ </Stack.Toolbar.MenuAction>
124+ <Stack.Toolbar.MenuAction
125+ isOn={props.sortOrder === "oldest"}
126+ onPress={() => props.onSortOrderChange("oldest")}
127+ >
128+ <Stack.Toolbar.Label>Oldest first</Stack.Toolbar.Label>
129+ </Stack.Toolbar.MenuAction>
130+ </Stack.Toolbar.Menu>
131+ </Stack.Toolbar.Menu>
132+ </Stack.Toolbar>
133+ </>
134+ );
135+}
136+
137+function ProjectGroupLabel(props: {
138+ readonly environmentLabel: string | null;
139+ readonly project: EnvironmentProject;
140+}) {
141+ return (
142+ <View className="flex-row items-center gap-2.5 px-1 pb-2">
143+ <ProjectFavicon
144+ environmentId={props.project.environmentId}
145+ projectTitle={props.project.title}
146+ size={18}
147+ workspaceRoot={props.project.workspaceRoot}
148+ />
149+ <Text
150+ className="flex-1 text-[12px] font-t3-medium uppercase text-foreground-muted"
151+ numberOfLines={1}
152+ style={{ letterSpacing: 0.5 }}
153+ >
154+ {props.project.title}
155+ </Text>
156+ {props.environmentLabel ? (
157+ <Text className="max-w-[42%] text-[11px] text-foreground-tertiary" numberOfLines={1}>
158+ {props.environmentLabel}
159+ </Text>
160+ ) : null}
161+ </View>
162+ );
163+}
164+
165+function ArchivedThreadRow(props: {
166+ readonly environmentLabel: string | null;
167+ readonly isLast: boolean;
168+ readonly onDelete: () => void;
169+ readonly onSwipeableClose: (methods: SwipeableMethods) => void;
170+ readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
171+ readonly onUnarchive: () => void;
172+ readonly thread: EnvironmentThreadShell;
173+}) {
174+ const swipeableRef = useRef<SwipeableMethods | null>(null);
175+ const fullSwipeArmedRef = useRef(false);
176+ const { width: windowWidth } = useWindowDimensions();
177+ const cardColor = useThemeColor("--color-card");
178+ const iconColor = useThemeColor("--color-icon-subtle");
179+ const separatorColor = useThemeColor("--color-separator");
180+ const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58);
181+ const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
182+ const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
183+ Boolean(part),
184+ );
185+ const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
186+ if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
187+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
188+ }
189+ fullSwipeArmedRef.current = armed;
190+ }, []);
191+ const handleMenuAction = useCallback(
192+ (event: { nativeEvent: { event: string } }) => {
193+ if (event.nativeEvent.event === "unarchive") {
194+ props.onUnarchive();
195+ } else if (event.nativeEvent.event === "delete") {
196+ props.onDelete();
197+ }
198+ },
199+ [props.onDelete, props.onUnarchive],
200+ );
201+
202+ return (
203+ <ReanimatedSwipeable
204+ ref={swipeableRef}
205+ animationOptions={THREAD_SWIPE_SPRING}
206+ childrenContainerStyle={{ backgroundColor: cardColor }}
207+ containerStyle={{ backgroundColor: cardColor }}
208+ dragOffsetFromRightEdge={8}
209+ enableTrackpadTwoFingerGesture
210+ friction={1}
211+ onSwipeableClose={() => {
212+ fullSwipeArmedRef.current = false;
213+ if (swipeableRef.current) {
214+ props.onSwipeableClose(swipeableRef.current);
215+ }
216+ }}
217+ onSwipeableOpenStartDrag={() => {
218+ if (swipeableRef.current) {
219+ props.onSwipeableWillOpen(swipeableRef.current);
220+ }
221+ }}
222+ onSwipeableWillOpen={() => {
223+ const methods = swipeableRef.current;
224+ if (!methods) return;
225+
226+ props.onSwipeableWillOpen(methods);
227+ if (fullSwipeArmedRef.current) {
228+ fullSwipeArmedRef.current = false;
229+ methods.close();
230+ props.onDelete();
231+ }
232+ }}
233+ overshootFriction={1}
234+ overshootRight
235+ renderRightActions={(_progress, translation, methods) => (
236+ <ThreadSwipeActions
237+ backgroundColor={cardColor}
238+ fullSwipeThreshold={fullSwipeThreshold}
239+ onDelete={props.onDelete}
240+ onFullSwipeArmedChange={handleFullSwipeArmedChange}
241+ primaryAction={{
242+ accessibilityLabel: `Unarchive ${props.thread.title}`,
243+ icon: "arrow.uturn.backward",
244+ label: "Unarchive",
245+ onPress: props.onUnarchive,
246+ }}
247+ swipeableMethods={methods}
248+ threadTitle={props.thread.title}
249+ translation={translation}
250+ />
251+ )}
252+ rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
253+ >
254+ <View
255+ className="flex-row items-center gap-3 bg-card px-4 py-3"
256+ style={{
257+ borderBottomColor: separatorColor,
258+ borderBottomWidth: props.isLast ? 0 : 1,
259+ }}
260+ >
261+ <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
262+ <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" />
263+ </View>
264+
265+ <View className="min-w-0 flex-1 gap-1">
266+ <View className="flex-row items-center gap-2">
267+ <Text
268+ className="min-w-0 flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground"
269+ numberOfLines={1}
270+ >
271+ {props.thread.title}
272+ </Text>
273+ <Text
274+ className="text-[12px] text-foreground-tertiary"
275+ style={{ fontVariant: ["tabular-nums"] }}
276+ >
277+ {timestamp}
278+ </Text>
279+ </View>
280+ {subtitle.length > 0 ? (
281+ <View className="flex-row items-center gap-1.5">
282+ <SymbolView
283+ name="arrow.triangle.branch"
284+ size={10}
285+ tintColor={iconColor}
286+ type="monochrome"
287+ />
288+ <Text
289+ className="min-w-0 flex-1 text-[11px] text-foreground-tertiary"
290+ numberOfLines={1}
291+ style={{ fontFamily: "monospace" }}
292+ >
293+ {subtitle.join(" · ")}
294+ </Text>
295+ </View>
296+ ) : null}
297+ </View>
298+
299+ <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}>
300+ <Pressable
301+ accessibilityLabel={`Actions for ${props.thread.title}`}
302+ accessibilityRole="button"
303+ className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
304+ hitSlop={6}
305+ >
306+ <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" />
307+ </Pressable>
308+ </ControlPillMenu>
309+ </View>
310+ </ReanimatedSwipeable>
311+ );
312+}
313+
314+function ArchiveError(props: { readonly message: string; readonly onRetry: () => void }) {
315+ return (
316+ <View className="rounded-[20px] border border-danger-border bg-danger p-4">
317+ <Text className="font-t3-bold text-danger-foreground">Could not load every archive</Text>
318+ <Text className="mt-1 text-[13px] leading-[18px] text-foreground-muted">{props.message}</Text>
319+ <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}>
320+ <Text className="text-[13px] font-t3-bold text-danger-foreground">Try again</Text>
321+ </Pressable>
322+ </View>
323+ );
324+}
325+
326+export function ArchivedThreadsScreen(props: {
327+ readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>;
328+ readonly error: string | null;
329+ readonly groups: ReadonlyArray<ArchivedThreadGroup>;
330+ readonly isLoading: boolean;
331+ readonly searchQuery: string;
332+ readonly selectedEnvironmentId: EnvironmentId | null;
333+ readonly sortOrder: ArchivedThreadSortOrder;
334+ readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
335+ readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
336+ readonly onRefresh: () => void;
337+ readonly onSearchQueryChange: (query: string) => void;
338+ readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
339+ readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void;
340+}) {
341+ const openSwipeableRef = useRef<SwipeableMethods | null>(null);
342+ const refreshTint = useThemeColor("--color-icon");
343+ const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
344+ if (openSwipeableRef.current && openSwipeableRef.current !== methods) {
345+ openSwipeableRef.current.close();
346+ }
347+ openSwipeableRef.current = methods;
348+ }, []);
349+ const handleSwipeableClose = useCallback((methods: SwipeableMethods) => {
350+ if (openSwipeableRef.current === methods) {
351+ openSwipeableRef.current = null;
352+ }
353+ }, []);
354+ const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null;
355+ const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null;
356+
357+ return (
358+ <View className="flex-1 bg-sheet">
359+ <ArchivedThreadsHeader
360+ environments={props.environments}
361+ onEnvironmentChange={props.onEnvironmentChange}
362+ onSearchQueryChange={props.onSearchQueryChange}
363+ onSortOrderChange={props.onSortOrderChange}
364+ selectedEnvironmentId={props.selectedEnvironmentId}
365+ sortOrder={props.sortOrder}
366+ />
367+
368+ <ScrollView
369+ className="flex-1"
370+ contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 16, paddingTop: 8 }}
371+ contentInsetAdjustmentBehavior="automatic"
372+ keyboardDismissMode="on-drag"
373+ keyboardShouldPersistTaps="handled"
374+ onScrollBeginDrag={() => openSwipeableRef.current?.close()}
375+ refreshControl={
376+ <RefreshControl
377+ onRefresh={props.onRefresh}
378+ refreshing={props.isLoading && !isInitialLoad}
379+ tintColor={String(refreshTint)}
380+ />
381+ }
382+ showsVerticalScrollIndicator={false}
383+ >
384+ {props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null}
385+
386+ {isInitialLoad ? (
387+ <View className="items-center py-16">
388+ <ActivityIndicator color={refreshTint} />
389+ <Text className="mt-3 text-[13px] text-foreground-muted">Loading archive…</Text>
390+ </View>
391+ ) : props.groups.length === 0 ? (
392+ <EmptyState
393+ detail={
394+ isFiltered
395+ ? "Try another search or environment."
396+ : "Threads you archive will appear here."
397+ }
398+ title={isFiltered ? "No matching threads" : "No archived threads"}
399+ />
400+ ) : (
401+ props.groups.map((group) => {
402+ const environmentLabel =
403+ props.environments.find(
404+ (environment) => environment.environmentId === group.project.environmentId,
405+ )?.label ?? null;
406+
407+ return (
408+ <View key={group.key} collapsable={false}>
409+ <ProjectGroupLabel environmentLabel={environmentLabel} project={group.project} />
410+ <View
411+ className="overflow-hidden rounded-[20px] bg-card"
412+ style={{ borderCurve: "continuous" }}
413+ >
414+ {group.threads.map((thread, index) => (
415+ <ArchivedThreadRow
416+ key={`${thread.environmentId}:${thread.id}`}
417+ environmentLabel={environmentLabel}
418+ isLast={index === group.threads.length - 1}
419+ onDelete={() => props.onDeleteThread(thread)}
420+ onSwipeableClose={handleSwipeableClose}
421+ onSwipeableWillOpen={handleSwipeableWillOpen}
422+ onUnarchive={() => props.onUnarchiveThread(thread)}
423+ thread={thread}
424+ />
425+ ))}
426+ </View>
427+ </View>
428+ );
429+ })
430+ )}
431+ </ScrollView>
432+ </View>
433+ );
434+}
apps/mobile/src/features/archive/archivedThreadList.test.ts+144−0
@@ -0,0 +1,144 @@
1+import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads";
2+import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3tools/contracts";
3+import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4+import { describe, expect, it } from "vite-plus/test";
5+
6+import { buildArchivedThreadGroups } from "./archivedThreadList";
7+
8+const environmentId = EnvironmentId.make("environment-1");
9+
10+function makeProject(
11+ input: Partial<OrchestrationProjectShell> & Pick<OrchestrationProjectShell, "id" | "title">,
12+): OrchestrationProjectShell {
13+ return {
14+ workspaceRoot: `/workspaces/${input.id}`,
15+ repositoryIdentity: null,
16+ defaultModelSelection: null,
17+ scripts: [],
18+ createdAt: "2026-06-01T00:00:00.000Z",
19+ updatedAt: "2026-06-01T00:00:00.000Z",
20+ ...input,
21+ };
22+}
23+
24+function makeThread(
25+ input: Partial<OrchestrationThreadShell> &
26+ Pick<OrchestrationThreadShell, "id" | "projectId" | "title">,
27+): OrchestrationThreadShell {
28+ return {
29+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
30+ runtimeMode: "full-access",
31+ interactionMode: "default",
32+ branch: null,
33+ worktreePath: null,
34+ latestTurn: null,
35+ createdAt: "2026-06-01T00:00:00.000Z",
36+ updatedAt: "2026-06-01T00:00:00.000Z",
37+ archivedAt: "2026-06-02T00:00:00.000Z",
38+ session: null,
39+ latestUserMessageAt: null,
40+ hasPendingApprovals: false,
41+ hasPendingUserInput: false,
42+ hasActionableProposedPlan: false,
43+ ...input,
44+ };
45+}
46+
47+function makeSnapshot(
48+ projects: ReadonlyArray<OrchestrationProjectShell>,
49+ threads: ReadonlyArray<OrchestrationThreadShell>,
50+ targetEnvironmentId = environmentId,
51+): ArchivedSnapshotEntry {
52+ return {
53+ environmentId: targetEnvironmentId,
54+ snapshot: {
55+ snapshotSequence: 1,
56+ projects,
57+ threads,
58+ updatedAt: "2026-06-04T00:00:00.000Z",
59+ },
60+ };
61+}
62+
63+describe("buildArchivedThreadGroups", () => {
64+ it("groups archived threads by project and sorts newest first", () => {
65+ const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" });
66+ const older = makeThread({
67+ id: ThreadId.make("thread-older"),
68+ projectId: project.id,
69+ title: "Older",
70+ });
71+ const newer = makeThread({
72+ archivedAt: "2026-06-03T00:00:00.000Z",
73+ id: ThreadId.make("thread-newer"),
74+ projectId: project.id,
75+ title: "Newer",
76+ });
77+
78+ const result = buildArchivedThreadGroups({
79+ snapshots: [makeSnapshot([project], [older, newer])],
80+ environmentLabels: { [environmentId]: "Julius's MacBook Pro" },
81+ environmentId: null,
82+ searchQuery: "",
83+ sortOrder: "newest",
84+ });
85+
86+ expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-newer", "thread-older"]);
87+ });
88+
89+ it("filters by environment and matches project, thread, and branch text", () => {
90+ const secondEnvironmentId = EnvironmentId.make("environment-2");
91+ const firstProject = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" });
92+ const secondProject = makeProject({ id: ProjectId.make("project-2"), title: "Website" });
93+ const firstThread = makeThread({
94+ branch: "fix/archive-screen",
95+ id: ThreadId.make("thread-1"),
96+ projectId: firstProject.id,
97+ title: "Build settings route",
98+ });
99+ const secondThread = makeThread({
100+ id: ThreadId.make("thread-2"),
101+ projectId: secondProject.id,
102+ title: "Unrelated",
103+ });
104+ const snapshots = [
105+ makeSnapshot([firstProject], [firstThread]),
106+ makeSnapshot([secondProject], [secondThread], secondEnvironmentId),
107+ ];
108+
109+ const result = buildArchivedThreadGroups({
110+ snapshots,
111+ environmentLabels: {
112+ [environmentId]: "Local",
113+ [secondEnvironmentId]: "Remote",
114+ },
115+ environmentId,
116+ searchQuery: "archive-screen",
117+ sortOrder: "oldest",
118+ });
119+
120+ expect(result).toHaveLength(1);
121+ expect(result[0]?.project.environmentId).toBe(environmentId);
122+ expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-1"]);
123+ });
124+
125+ it("ignores non-archived entries returned in a snapshot", () => {
126+ const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" });
127+ const active = makeThread({
128+ archivedAt: null,
129+ id: ThreadId.make("thread-active"),
130+ projectId: project.id,
131+ title: "Active",
132+ });
133+
134+ const result = buildArchivedThreadGroups({
135+ snapshots: [makeSnapshot([project], [active])],
136+ environmentLabels: {},
137+ environmentId: null,
138+ searchQuery: "",
139+ sortOrder: "newest",
140+ });
141+
142+ expect(result).toEqual([]);
143+ });
144+});
apps/mobile/src/features/archive/archivedThreadList.ts+106−0
@@ -0,0 +1,106 @@
1+import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads";
2+import {
3+ scopeProject,
4+ scopeThreadShell,
5+ type EnvironmentProject,
6+ type EnvironmentThreadShell,
7+} from "@t3tools/client-runtime/state/shell";
8+import type { EnvironmentId } from "@t3tools/contracts";
9+import * as Arr from "effect/Array";
10+import * as Order from "effect/Order";
11+
12+import { scopedProjectKey } from "../../lib/scopedEntities";
13+
14+export type ArchivedThreadSortOrder = "newest" | "oldest";
15+
16+export interface ArchivedThreadGroup {
17+ readonly key: string;
18+ readonly project: EnvironmentProject;
19+ readonly threads: ReadonlyArray<EnvironmentThreadShell>;
20+}
21+
22+function archiveTimestamp(thread: EnvironmentThreadShell): number {
23+ const timestamp = Date.parse(thread.archivedAt ?? thread.updatedAt);
24+ return Number.isNaN(timestamp) ? 0 : timestamp;
25+}
26+
27+function matchesQuery(value: string | null, query: string): boolean {
28+ return value?.toLocaleLowerCase().includes(query) ?? false;
29+}
30+
31+export function buildArchivedThreadGroups(input: {
32+ readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
33+ readonly environmentLabels: Readonly<Record<string, string>>;
34+ readonly environmentId: EnvironmentId | null;
35+ readonly searchQuery: string;
36+ readonly sortOrder: ArchivedThreadSortOrder;
37+}): ReadonlyArray<ArchivedThreadGroup> {
38+ const query = input.searchQuery.trim().toLocaleLowerCase();
39+ const groups: ArchivedThreadGroup[] = [];
40+
41+ for (const entry of input.snapshots) {
42+ if (input.environmentId !== null && input.environmentId !== entry.environmentId) {
43+ continue;
44+ }
45+
46+ const environmentLabel = input.environmentLabels[entry.environmentId] ?? null;
47+ const threadsByProjectId = new Map<string, EnvironmentThreadShell[]>();
48+ for (const thread of entry.snapshot.threads) {
49+ if (thread.archivedAt === null) {
50+ continue;
51+ }
52+ const threads = threadsByProjectId.get(thread.projectId) ?? [];
53+ threads.push(scopeThreadShell(entry.environmentId, thread));
54+ threadsByProjectId.set(thread.projectId, threads);
55+ }
56+
57+ for (const rawProject of entry.snapshot.projects) {
58+ const project = scopeProject(entry.environmentId, rawProject);
59+ const projectThreads = threadsByProjectId.get(project.id) ?? [];
60+ const groupMatches =
61+ query.length === 0 ||
62+ matchesQuery(project.title, query) ||
63+ matchesQuery(project.workspaceRoot, query) ||
64+ matchesQuery(environmentLabel, query);
65+ const matchingThreads = groupMatches
66+ ? projectThreads
67+ : projectThreads.filter(
68+ (thread) => matchesQuery(thread.title, query) || matchesQuery(thread.branch, query),
69+ );
70+
71+ if (matchingThreads.length === 0) {
72+ continue;
73+ }
74+
75+ const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number;
76+ groups.push({
77+ key: scopedProjectKey(project.environmentId, project.id),
78+ project,
79+ threads: Arr.sort(
80+ matchingThreads,
81+ Order.mapInput(
82+ Order.Struct({ timestamp: timestampOrder, title: Order.String, id: Order.String }),
83+ (thread: EnvironmentThreadShell) => ({
84+ timestamp: archiveTimestamp(thread),
85+ title: thread.title,
86+ id: thread.id,
87+ }),
88+ ),
89+ ),
90+ });
91+ }
92+ }
93+
94+ const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number;
95+ return Arr.sort(
96+ groups,
97+ Order.mapInput(
98+ Order.Struct({ timestamp: timestampOrder, title: Order.String, key: Order.String }),
99+ (group: ArchivedThreadGroup) => ({
100+ timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : 0,
101+ title: group.project.title,
102+ key: group.key,
103+ }),
104+ ),
105+ );
106+}
apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts+47−0
@@ -0,0 +1,47 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import {
3+ type ArchivedSnapshotEntry,
4+ createArchivedThreadSnapshotsAtomFamily,
5+ makeArchivedThreadsEnvironmentKey,
6+} from "@t3tools/client-runtime/state/threads";
7+import type { EnvironmentId } from "@t3tools/contracts";
8+import { useCallback, useMemo } from "react";
9+
10+import { appAtomRegistry } from "../../state/atom-registry";
11+import { orchestrationEnvironment } from "../../state/orchestration";
12+
13+function archivedSnapshotAtom(environmentId: EnvironmentId) {
14+ return orchestrationEnvironment.archivedShellSnapshot({
15+ environmentId,
16+ input: {},
17+ });
18+}
19+
20+const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({
21+ getSnapshotAtom: archivedSnapshotAtom,
22+ labelPrefix: "mobile:archived-thread-snapshots",
23+});
24+
25+export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {
26+ appAtomRegistry.refresh(archivedSnapshotAtom(environmentId));
27+}
28+
29+export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): {
30+ readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
31+ readonly error: string | null;
32+ readonly isLoading: boolean;
33+ readonly refresh: () => void;
34+} {
35+ const environmentKey = useMemo(
36+ () => makeArchivedThreadsEnvironmentKey(environmentIds),
37+ [environmentIds],
38+ );
39+ const result = useAtomValue(archivedSnapshotsAtom(environmentKey));
40+ const refresh = useCallback(() => {
41+ for (const environmentId of environmentIds) {
42+ appAtomRegistry.refresh(archivedSnapshotAtom(environmentId));
43+ }
44+ }, [environmentIds]);
45+
46+ return { ...result, refresh };
47+}
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+1−0
@@ -110,6 +110,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { · 109 unmodified lines
110110 readonly styleJson?: string;
111111 readonly rowHeight: number;
112112 readonly contentWidth: number;
113+ readonly initialRowIndex?: number;
113114 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
114115 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
115116 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
apps/mobile/src/features/files/FileMarkdownPreview.tsx+166−0
@@ -0,0 +1,166 @@
1+import { useMemo } from "react";
2+import {
3+ Markdown,
4+ type CustomRenderers,
5+ type NodeStyleOverrides,
6+ type PartialMarkdownTheme,
7+} from "react-native-nitro-markdown";
8+import { Linking, ScrollView, Text as NativeText, View } from "react-native";
9+
10+import { useThemeColor } from "../../lib/useThemeColor";
11+import {
12+ hasNativeSelectableMarkdownText,
13+ SelectableMarkdownText,
14+ type NativeMarkdownTextStyle,
15+} from "../../native/SelectableMarkdownText";
16+
17+interface MarkdownPreviewStyles {
18+ readonly theme: PartialMarkdownTheme;
19+ readonly styles: NodeStyleOverrides;
20+ readonly renderers: CustomRenderers;
21+ readonly nativeTextStyle: NativeMarkdownTextStyle;
22+}
23+
24+function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
25+ const body = String(useThemeColor("--color-md-body"));
26+ const strong = String(useThemeColor("--color-md-strong"));
27+ const link = String(useThemeColor("--color-md-link"));
28+ const blockquoteBorder = String(useThemeColor("--color-md-blockquote-border"));
29+ const blockquoteBackground = String(useThemeColor("--color-md-blockquote-bg"));
30+ const codeBackground = String(useThemeColor("--color-md-code-bg"));
31+ const codeText = String(useThemeColor("--color-md-code-text"));
32+ const horizontalRule = String(useThemeColor("--color-md-hr"));
33+
34+ return useMemo(() => {
35+ const renderers: CustomRenderers = {
36+ link: ({ href, children }) => (
37+ <NativeText
38+ onPress={() => {
39+ if (href) {
40+ void Linking.openURL(href);
41+ }
42+ }}
43+ style={{
44+ color: link,
45+ fontFamily: "DMSans_500Medium",
46+ textDecorationLine: "none",
47+ }}
48+ >
49+ {children}
50+ </NativeText>
51+ ),
52+ };
53+
54+ return {
55+ theme: {
56+ colors: {
57+ text: body,
58+ heading: strong,
59+ link,
60+ blockquote: blockquoteBorder,
61+ border: horizontalRule,
62+ surfaceLight: blockquoteBackground,
63+ accent: link,
64+ tableBorder: horizontalRule,
65+ tableHeader: blockquoteBackground,
66+ tableHeaderText: strong,
67+ code: codeText,
68+ codeBackground,
69+ },
70+ },
71+ styles: {
72+ text: {
73+ color: body,
74+ fontFamily: "DMSans_400Regular",
75+ fontSize: 15,
76+ lineHeight: 22,
77+ },
78+ heading: {
79+ color: strong,
80+ fontFamily: "DMSans_700Bold",
81+ },
82+ strong: {
83+ color: strong,
84+ fontFamily: "DMSans_700Bold",
85+ },
86+ link: {
87+ color: link,
88+ fontFamily: "DMSans_500Medium",
89+ },
90+ blockquote: {
91+ backgroundColor: blockquoteBackground,
92+ borderLeftColor: blockquoteBorder,
93+ borderLeftWidth: 3,
94+ paddingLeft: 12,
95+ },
96+ code: {
97+ backgroundColor: codeBackground,
98+ color: codeText,
99+ fontFamily: "ui-monospace",
100+ },
101+ codeBlock: {
102+ backgroundColor: codeBackground,
103+ borderRadius: 12,
104+ color: codeText,
105+ fontFamily: "ui-monospace",
106+ padding: 12,
107+ },
108+ hr: {
109+ backgroundColor: horizontalRule,
110+ },
111+ },
112+ renderers,
113+ nativeTextStyle: {
114+ color: body,
115+ strongColor: strong,
116+ mutedColor: body,
117+ linkColor: link,
118+ inlineCodeColor: codeText,
119+ codeColor: codeText,
120+ codeBackgroundColor: codeBackground,
121+ codeBlockBackgroundColor: codeBackground,
122+ fileTextColor: codeText,
123+ skillTextColor: codeText,
124+ quoteMarkerColor: blockquoteBorder,
125+ dividerColor: horizontalRule,
126+ fontSize: 15,
127+ lineHeight: 22,
128+ fontFamily: "DMSans_400Regular",
129+ headingFontFamily: "DMSans_700Bold",
130+ boldFontFamily: "DMSans_700Bold",
131+ },
132+ };
133+ }, [
134+ blockquoteBackground,
135+ blockquoteBorder,
136+ body,
137+ codeBackground,
138+ codeText,
139+ horizontalRule,
140+ link,
141+ strong,
142+ ]);
143+}
144+
145+export function FileMarkdownPreview(props: { readonly markdown: string }) {
146+ const styles = useMarkdownPreviewStyles();
147+
148+ return (
149+ <ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}>
150+ <View className="mx-auto w-full max-w-[760px]">
151+ {hasNativeSelectableMarkdownText() ? (
152+ <SelectableMarkdownText markdown={props.markdown} textStyle={styles.nativeTextStyle} />
153+ ) : (
154+ <Markdown
155+ options={{ gfm: true }}
156+ renderers={styles.renderers}
157+ styles={styles.styles}
158+ theme={styles.theme}
159+ >
160+ {props.markdown}
161+ </Markdown>
162+ )}
163+ </View>
164+ </ScrollView>
165+ );
166+}
apps/mobile/src/features/files/FileTreeBrowser.tsx+191−0
@@ -0,0 +1,191 @@
1+import type { ProjectEntry } from "@t3tools/contracts";
2+import { SymbolView } from "expo-symbols";
3+import { memo, useCallback, useEffect, useMemo, useState } from "react";
4+import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native";
5+
6+import { AppText as Text } from "../../components/AppText";
7+import { PierreEntryIcon } from "../../components/PierreEntryIcon";
8+import { cn } from "../../lib/cn";
9+import { useThemeColor } from "../../lib/useThemeColor";
10+import {
11+ buildFileTree,
12+ defaultExpandedTreePaths,
13+ flattenFileTree,
14+ type VisibleFileTreeNode,
15+} from "./fileTree";
16+
17+function ancestorPaths(path: string): ReadonlyArray<string> {
18+ const parts = path.split("/").filter(Boolean);
19+ const ancestors: string[] = [];
20+ for (let index = 1; index < parts.length; index += 1) {
21+ ancestors.push(parts.slice(0, index).join("/"));
22+ }
23+ return ancestors;
24+}
25+
26+const FileTreeRow = memo(function FileTreeRow(props: {
27+ readonly item: VisibleFileTreeNode;
28+ readonly selectedPath: string | null;
29+ readonly expanded: boolean;
30+ readonly iconColor: string;
31+ readonly onPressDirectory: (path: string) => void;
32+ readonly onPressFile: (path: string) => void;
33+}) {
34+ const { node, depth } = props.item;
35+ const selected = node.kind === "file" && node.path === props.selectedPath;
36+
37+ return (
38+ <Pressable
39+ accessibilityRole="button"
40+ accessibilityLabel={node.path}
41+ onPress={() => {
42+ if (node.kind === "directory") {
43+ props.onPressDirectory(node.path);
44+ return;
45+ }
46+ props.onPressFile(node.path);
47+ }}
48+ className={cn(
49+ "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle",
50+ selected && "bg-subtle-strong",
51+ )}
52+ style={{ paddingLeft: 8 + depth * 18 }}
53+ >
54+ {node.kind === "directory" ? (
55+ <SymbolView
56+ name={props.expanded ? "chevron.down" : "chevron.right"}
57+ size={12}
58+ tintColor={props.iconColor}
59+ type="monochrome"
60+ />
61+ ) : (
62+ <View className="w-3" />
63+ )}
64+ <PierreEntryIcon path={node.path} kind={node.kind} size={17} />
65+ <Text
66+ className={cn(
67+ "min-w-0 flex-1 text-[14px] leading-[19px]",
68+ selected ? "font-t3-bold text-foreground" : "font-t3-medium text-foreground-secondary",
69+ )}
70+ numberOfLines={1}
71+ >
72+ {node.name}
73+ </Text>
74+ {node.kind === "directory" ? (
75+ <Text className="text-[11px] font-t3-medium text-foreground-tertiary">
76+ {node.children.length}
77+ </Text>
78+ ) : null}
79+ </Pressable>
80+ );
81+});
82+
83+export function FileTreeBrowser(props: {
84+ readonly entries: ReadonlyArray<ProjectEntry>;
85+ readonly error: string | null;
86+ readonly isPending: boolean;
87+ readonly searchQuery: string;
88+ readonly selectedPath: string | null;
89+ readonly onRefresh: () => void;
90+ readonly onSelectFile: (path: string) => void;
91+}) {
92+ const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set());
93+ const iconColor = String(useThemeColor("--color-icon-muted"));
94+
95+ const tree = useMemo(() => buildFileTree(props.entries), [props.entries]);
96+ const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]);
97+ const visibleNodes = useMemo(
98+ () =>
99+ flattenFileTree({
100+ nodes: tree,
101+ expanded: expandedPaths,
102+ searchQuery: props.searchQuery,
103+ }),
104+ [expandedPaths, props.searchQuery, tree],
105+ );
106+
107+ useEffect(() => {
108+ setExpandedPaths((current) => {
109+ if (current.size > 0 || defaultExpanded.size === 0) {
110+ return current;
111+ }
112+ return new Set(defaultExpanded);
113+ });
114+ }, [defaultExpanded]);
115+
116+ useEffect(() => {
117+ if (!props.selectedPath) {
118+ return;
119+ }
120+ setExpandedPaths((current) => {
121+ const next = new Set(current);
122+ for (const ancestor of ancestorPaths(props.selectedPath ?? "")) {
123+ next.add(ancestor);
124+ }
125+ return next;
126+ });
127+ }, [props.selectedPath]);
128+
129+ const toggleDirectory = useCallback((path: string) => {
130+ setExpandedPaths((current) => {
131+ const next = new Set(current);
132+ if (next.has(path)) {
133+ next.delete(path);
134+ } else {
135+ next.add(path);
136+ }
137+ return next;
138+ });
139+ }, []);
140+
141+ return (
142+ <View className="flex-1 bg-sheet">
143+ {props.error && props.entries.length === 0 ? (
144+ <View className="px-4 py-5">
145+ <Text className="text-[13px] font-t3-bold text-foreground">Files unavailable</Text>
146+ <Text className="mt-1 text-[12px] leading-[18px] text-foreground-muted">
147+ {props.error}
148+ </Text>
149+ </View>
150+ ) : (
151+ <FlatList
152+ data={visibleNodes}
153+ keyExtractor={(item) => item.node.path}
154+ contentInsetAdjustmentBehavior="automatic"
155+ keyboardDismissMode="on-drag"
156+ keyboardShouldPersistTaps="handled"
157+ contentContainerStyle={{ paddingVertical: 8 }}
158+ refreshControl={
159+ <RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />
160+ }
161+ renderItem={({ item }) => (
162+ <FileTreeRow
163+ item={item}
164+ selectedPath={props.selectedPath}
165+ expanded={expandedPaths.has(item.node.path)}
166+ iconColor={iconColor}
167+ onPressDirectory={toggleDirectory}
168+ onPressFile={props.onSelectFile}
169+ />
170+ )}
171+ ListEmptyComponent={
172+ <View className="px-4 py-5">
173+ {props.isPending ? (
174+ <ActivityIndicator size="small" />
175+ ) : (
176+ <>
177+ <Text className="text-[13px] font-t3-bold text-foreground">No files found</Text>
178+ <Text className="mt-1 text-[12px] leading-[18px] text-foreground-muted">
179+ {props.searchQuery.trim().length > 0
180+ ? "Try a different search."
181+ : "The workspace file index is empty."}
182+ </Text>
183+ </>
184+ )}
185+ </View>
186+ }
187+ />
188+ )}
189+ </View>
190+ );
191+}
apps/mobile/src/features/files/SourceFileSurface.tsx+252−0
@@ -0,0 +1,252 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import { AsyncResult } from "effect/unstable/reactivity";
3+import type { ComponentType } from "react";
4+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
5+import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native";
6+
7+import { AppText as Text } from "../../components/AppText";
8+import { LoadingStrip } from "../../components/LoadingStrip";
9+import {
10+ type NativeReviewDiffViewProps,
11+ resolveNativeReviewDiffView,
12+} from "../diffs/nativeReviewDiffSurface";
13+import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";
14+import {
15+ REVIEW_DIFF_LINE_HEIGHT,
16+ REVIEW_MONO_FONT_FAMILY,
17+ renderVisibleWhitespace,
18+} from "../review/reviewDiffRendering";
19+import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
20+import { cn } from "../../lib/cn";
21+import {
22+ buildNativeSourceRows,
23+ buildNativeSourceTokens,
24+ NATIVE_SOURCE_CONTENT_WIDTH,
25+ NATIVE_SOURCE_ROW_HEIGHT,
26+ NATIVE_SOURCE_STYLE,
27+ nativeSourceRowId,
28+} from "./nativeSourceFileAdapter";
29+import { sourceHighlightAtom } from "./sourceHighlightingState";
30+
31+const SOURCE_LINE_HEIGHT = 24;
32+const SOURCE_LINE_NUMBER_WIDTH = 58;
33+const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE);
34+
35+interface SourceFileSurfaceProps {
36+ readonly contents: string;
37+ readonly path: string;
38+ readonly initialLine?: number | null;
39+}
40+
41+type SourceHighlightStatus = "highlighting" | "ready" | "error";
42+
43+function splitSourceLines(contents: string): ReadonlyArray<string> {
44+ return contents.replace(/\r\n?/g, "\n").split("\n");
45+}
46+
47+const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
48+ readonly index: number;
49+ readonly line: string;
50+ readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
51+ readonly highlighted: boolean;
52+}) {
53+ return (
54+ <View
55+ className={cn("flex-row", props.highlighted && "bg-primary/10")}
56+ style={{ minHeight: SOURCE_LINE_HEIGHT }}
57+ >
58+ <NativeText
59+ className="select-none pr-3 text-right text-[11px] leading-[24px] text-foreground-tertiary"
60+ style={{
61+ width: SOURCE_LINE_NUMBER_WIDTH,
62+ fontFamily: REVIEW_MONO_FONT_FAMILY,
63+ }}
64+ >
65+ {props.index + 1}
66+ </NativeText>
67+ <NativeText
68+ selectable
69+ numberOfLines={1}
70+ className="text-[13px] font-medium leading-[24px] text-foreground"
71+ style={{ fontFamily: REVIEW_MONO_FONT_FAMILY, minWidth: 320 }}
72+ >
73+ {props.tokens && props.tokens.length > 0
74+ ? (() => {
75+ let offset = 0;
76+ return props.tokens.map((token) => {
77+ const start = offset;
78+ offset += token.content.length;
79+
80+ const fontWeight =
81+ token.fontStyle !== null && (token.fontStyle & 2) === 2
82+ ? ("700" as const)
83+ : ("500" as const);
84+ const fontStyle =
85+ token.fontStyle !== null && (token.fontStyle & 1) === 1
86+ ? ("italic" as const)
87+ : ("normal" as const);
88+
89+ return (
90+ <NativeText
91+ key={`${start}:${token.content.length}:${token.color ?? ""}`}
92+ selectable
93+ style={{
94+ color: token.color ?? undefined,
95+ fontFamily: REVIEW_MONO_FONT_FAMILY,
96+ fontWeight,
97+ fontStyle,
98+ }}
99+ >
100+ {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}
101+ </NativeText>
102+ );
103+ });
104+ })()
105+ : renderVisibleWhitespace(props.line || " ")}
106+ </NativeText>
107+ </View>
108+ );
109+});
110+
111+function useSourceFileModel(props: SourceFileSurfaceProps) {
112+ const colorScheme = useColorScheme();
113+ const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";
114+ const normalizedContents = useMemo(
115+ () => props.contents.replace(/\r\n?/g, "\n"),
116+ [props.contents],
117+ );
118+ const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]);
119+ const targetIndex =
120+ props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0
121+ ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1))
122+ : null;
123+ const highlightAtom = useMemo(
124+ () => sourceHighlightAtom({ path: props.path, contents: normalizedContents, theme }),
125+ [normalizedContents, props.path, theme],
126+ );
127+ const highlightResult = useAtomValue(highlightAtom);
128+ const tokens = AsyncResult.isSuccess(highlightResult) ? highlightResult.value : null;
129+ const status: SourceHighlightStatus = AsyncResult.isFailure(highlightResult)
130+ ? "error"
131+ : AsyncResult.isSuccess(highlightResult)
132+ ? "ready"
133+ : "highlighting";
134+
135+ return { lines, status, targetIndex, theme, tokens };
136+}
137+
138+function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {
139+ if (props.status === "highlighting") {
140+ return <LoadingStrip />;
141+ }
142+ if (props.status === "error") {
143+ return (
144+ <View className="border-b border-border bg-card px-4 py-2">
145+ <Text className="text-[11px] font-t3-medium uppercase text-foreground-muted">
146+ Plain text
147+ </Text>
148+ </View>
149+ );
150+ }
151+ return null;
152+}
153+
154+function NativeSourceFileSurface(
155+ props: SourceFileSurfaceProps & {
156+ readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
157+ },
158+) {
159+ const { NativeView } = props;
160+ const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props);
161+ const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]);
162+ const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);
163+ const selectedRowIdsJson = useMemo(
164+ () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
165+ [targetIndex],
166+ );
167+ const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);
168+
169+ return (
170+ <View className="relative flex-1 bg-card">
171+ <SourceHighlightStatusView status={status} />
172+ <NativeView
173+ key={props.path}
174+ collapsable={false}
175+ testID="source-native-code-view"
176+ style={{ flex: 1 }}
177+ appearanceScheme={theme}
178+ contentWidth={NATIVE_SOURCE_CONTENT_WIDTH}
179+ initialRowIndex={targetIndex ?? -1}
180+ rowHeight={NATIVE_SOURCE_ROW_HEIGHT}
181+ rowsJson={rowsJson}
182+ selectedRowIdsJson={selectedRowIdsJson}
183+ styleJson={NATIVE_SOURCE_STYLE_JSON}
184+ themeJson={themeJson}
185+ tokensJson={tokensJson}
186+ />
187+ </View>
188+ );
189+}
190+
191+function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
192+ const { lines, status, targetIndex, tokens } = useSourceFileModel(props);
193+ const listRef = useRef<FlatList<string>>(null);
194+
195+ useEffect(() => {
196+ if (targetIndex === null) {
197+ return;
198+ }
199+ const frame = requestAnimationFrame(() => {
200+ listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 });
201+ });
202+ return () => cancelAnimationFrame(frame);
203+ }, [props.path, targetIndex]);
204+
205+ const renderLine = useCallback(
206+ ({ item, index }: { item: string; index: number }) => (
207+ <HighlightedSourceLine
208+ index={index}
209+ line={item}
210+ tokens={tokens?.[index] ?? null}
211+ highlighted={index === targetIndex}
212+ />
213+ ),
214+ [targetIndex, tokens],
215+ );
216+
217+ return (
218+ <View className="relative flex-1 bg-card">
219+ <SourceHighlightStatusView status={status} />
220+ <ScrollView horizontal bounces={false} className="flex-1">
221+ <FlatList
222+ ref={listRef}
223+ data={lines}
224+ keyExtractor={(_line, index) => String(index)}
225+ initialNumToRender={80}
226+ maxToRenderPerBatch={80}
227+ windowSize={12}
228+ getItemLayout={(_data, index) => ({
229+ length: SOURCE_LINE_HEIGHT,
230+ offset: SOURCE_LINE_HEIGHT * index,
231+ index,
232+ })}
233+ contentContainerStyle={{
234+ minWidth: "100%",
235+ paddingBottom: REVIEW_DIFF_LINE_HEIGHT,
236+ paddingTop: 8,
237+ }}
238+ renderItem={renderLine}
239+ />
240+ </ScrollView>
241+ </View>
242+ );
243+}
244+
245+export function SourceFileSurface(props: SourceFileSurfaceProps) {
246+ const NativeView = resolveNativeReviewDiffView();
247+ return NativeView ? (
248+ <NativeSourceFileSurface {...props} NativeView={NativeView} />
249+ ) : (
250+ <JavaScriptSourceFileSurface {...props} />
251+ );
252+}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+631−0
@@ -0,0 +1,631 @@
1+import Stack from "expo-router/stack";
2+import { SymbolView } from "expo-symbols";
3+import { useLocalSearchParams, useRouter } from "expo-router";
4+import { useCallback, useMemo, useRef, useState } from "react";
5+import {
6+ ActivityIndicator,
7+ Linking,
8+ Pressable,
9+ ScrollView,
10+ Text as RNText,
11+ View,
12+} from "react-native";
13+import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
14+import {
15+ EnvironmentId,
16+ type ProjectListEntriesResult,
17+ type ProjectReadFileResult,
18+ ThreadId,
19+} from "@t3tools/contracts";
20+
21+import { AppText as Text } from "../../components/AppText";
22+import { CopyTextButton } from "../../components/CopyTextButton";
23+import { EmptyState } from "../../components/EmptyState";
24+import { LoadingScreen } from "../../components/LoadingScreen";
25+import { cn } from "../../lib/cn";
26+import { buildThreadFilesNavigation } from "../../lib/routes";
27+import { useThemeColor } from "../../lib/useThemeColor";
28+import { useThreadSelection } from "../../state/use-thread-selection";
29+import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
30+import { useEnvironmentQuery } from "../../state/query";
31+import { projectEnvironment } from "../../state/projects";
32+import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
33+import { FileMarkdownPreview } from "./FileMarkdownPreview";
34+import { FileTreeBrowser } from "./FileTreeBrowser";
35+import { SourceFileSurface } from "./SourceFileSurface";
36+import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
37+import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
38+import {
39+ basename,
40+ fileBreadcrumbs,
41+ isBrowserPreviewFile,
42+ isImagePreviewFile,
43+ isMarkdownPreviewFile,
44+ isSvgImagePreviewFile,
45+} from "./filePath";
46+import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl";
47+
48+type FileViewMode = "preview" | "source";
49+
50+function firstRouteParam(value: string | string[] | undefined): string | null {
51+ if (Array.isArray(value)) {
52+ return value[0] ?? null;
53+ }
54+
55+ return value ?? null;
56+}
57+
58+function normalizeRoutePath(value: string | string[] | undefined): string | null {
59+ const path = Array.isArray(value) ? value.join("/") : value;
60+ if (path === undefined || path.trim().length === 0) {
61+ return null;
62+ }
63+ return path;
64+}
65+
66+function normalizeRouteLine(value: string | null): number | null {
67+ if (value === null) {
68+ return null;
69+ }
70+ const parsed = Number(value);
71+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
72+}
73+
74+function defaultViewMode(path: string | null): FileViewMode {
75+ return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path))
76+ ? "preview"
77+ : "source";
78+}
79+
80+function ModeButton(props: {
81+ readonly active: boolean;
82+ readonly icon: "doc.text" | "eye";
83+ readonly label: string;
84+ readonly onPress: () => void;
85+}) {
86+ const iconColor = String(
87+ useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"),
88+ );
89+
90+ return (
91+ <Pressable
92+ accessibilityRole="button"
93+ accessibilityState={{ selected: props.active }}
94+ className={cn(
95+ "h-8 flex-row items-center justify-center gap-1.5 rounded-full px-3 active:opacity-70",
96+ props.active ? "bg-primary" : "bg-subtle",
97+ )}
98+ onPress={props.onPress}
99+ >
100+ <SymbolView name={props.icon} size={13} tintColor={iconColor} type="monochrome" />
101+ <Text
102+ className={cn(
103+ "text-[12px] font-t3-bold",
104+ props.active ? "text-primary-foreground" : "text-foreground-muted",
105+ )}
106+ >
107+ {props.label}
108+ </Text>
109+ </Pressable>
110+ );
111+}
112+
113+function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) {
114+ const gradientId = `file-breadcrumb-${props.side}-fade`;
115+ const isLeft = props.side === "left";
116+
117+ return (
118+ <View
119+ pointerEvents="none"
120+ className={cn("absolute inset-y-0 w-7", isLeft ? "left-0" : "right-0")}
121+ >
122+ <Svg width="100%" height="100%">
123+ <Defs>
124+ <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
125+ <Stop offset="0%" stopColor={props.color} stopOpacity={isLeft ? 1 : 0} />
126+ <Stop offset="100%" stopColor={props.color} stopOpacity={isLeft ? 0 : 1} />
127+ </LinearGradient>
128+ </Defs>
129+ <Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
130+ </Svg>
131+ </View>
132+ );
133+}
134+
135+function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) {
136+ const iconColor = String(useThemeColor("--color-icon-muted"));
137+ const cardColor = String(useThemeColor("--color-card"));
138+ const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 });
139+ const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false });
140+ const breadcrumbs = useMemo(
141+ () => fileBreadcrumbs(props.projectName, props.relativePath),
142+ [props.projectName, props.relativePath],
143+ );
144+ const updateFadeVisibility = useCallback(
145+ (metrics: Partial<(typeof scrollMetrics)["current"]>) => {
146+ Object.assign(scrollMetrics.current, metrics);
147+ const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current;
148+ const maxOffset = Math.max(0, contentWidth - viewportWidth);
149+ const next = {
150+ left: maxOffset > 1 && offsetX > 1,
151+ right: maxOffset > 1 && offsetX < maxOffset - 1,
152+ };
153+
154+ setFadeVisibility((current) =>
155+ current.left === next.left && current.right === next.right ? current : next,
156+ );
157+ },
158+ [],
159+ );
160+
161+ return (
162+ <View className="min-w-0 flex-1">
163+ <ScrollView
164+ horizontal
165+ showsHorizontalScrollIndicator={false}
166+ onContentSizeChange={(contentWidth) => {
167+ updateFadeVisibility({ contentWidth });
168+ }}
169+ onLayout={(event) => {
170+ updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width });
171+ }}
172+ onScroll={(event) => {
173+ updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x });
174+ }}
175+ scrollEventThrottle={16}
176+ >
177+ <View className="h-8 flex-row items-center">
178+ {breadcrumbs.map((crumb, index) => (
179+ <View key={crumb.path || "project"} className="flex-row items-center">
180+ {index > 0 ? (
181+ <SymbolView
182+ name="chevron.right"
183+ size={10}
184+ tintColor={iconColor}
185+ type="monochrome"
186+ />
187+ ) : null}
188+ <Text
189+ className={cn(
190+ "max-w-[180px] px-1 text-[12px]",
191+ crumb.kind === "file"
192+ ? "font-t3-bold text-foreground"
193+ : "font-t3-medium text-foreground-muted",
194+ )}
195+ numberOfLines={1}
196+ >
197+ {crumb.label}
198+ </Text>
199+ </View>
200+ ))}
201+ </View>
202+ </ScrollView>
203+ {fadeVisibility.left ? <BreadcrumbFade color={cardColor} side="left" /> : null}
204+ {fadeVisibility.right ? <BreadcrumbFade color={cardColor} side="right" /> : null}
205+ </View>
206+ );
207+}
208+
209+function FilePreviewHeader(props: {
210+ readonly activeMode: FileViewMode;
211+ readonly showModeSelector: boolean;
212+ readonly externalPreviewUri?: string | null;
213+ readonly projectName: string;
214+ readonly relativePath: string;
215+ readonly onSetMode: (mode: FileViewMode) => void;
216+}) {
217+ const iconColor = String(useThemeColor("--color-icon-muted"));
218+
219+ return (
220+ <View className="border-b border-border bg-card px-3 py-2">
221+ <View className="flex-row items-center gap-2">
222+ <FileBreadcrumbs projectName={props.projectName} relativePath={props.relativePath} />
223+ <CopyTextButton
224+ accessibilityLabel="Copy file path"
225+ text={props.relativePath}
226+ tintColor={iconColor}
227+ buttonSize={32}
228+ iconSize={13}
229+ />
230+ </View>
231+ {props.showModeSelector ? (
232+ <View className="mt-2 flex-row items-center gap-2">
233+ <ModeButton
234+ active={props.activeMode === "preview"}
235+ icon="eye"
236+ label="Preview"
237+ onPress={() => props.onSetMode("preview")}
238+ />
239+ <ModeButton
240+ active={props.activeMode === "source"}
241+ icon="doc.text"
242+ label="Source"
243+ onPress={() => props.onSetMode("source")}
244+ />
245+ {props.externalPreviewUri !== undefined ? (
246+ <Pressable
247+ accessibilityRole="button"
248+ accessibilityLabel="Open preview in Safari"
249+ disabled={props.externalPreviewUri === null}
250+ hitSlop={8}
251+ className={cn(
252+ "ml-auto h-8 w-8 items-center justify-center rounded-full bg-subtle active:opacity-70",
253+ props.externalPreviewUri === null && "opacity-40",
254+ )}
255+ onPress={() => {
256+ if (typeof props.externalPreviewUri === "string") {
257+ void Linking.openURL(props.externalPreviewUri);
258+ }
259+ }}
260+ >
261+ <SymbolView name="safari" size={15} tintColor={iconColor} type="monochrome" />
262+ </Pressable>
263+ ) : null}
264+ </View>
265+ ) : null}
266+ </View>
267+ );
268+}
269+
270+function FileContent(props: {
271+ readonly activeMode: FileViewMode;
272+ readonly previewUri: string | null;
273+ readonly fileContents: string | null;
274+ readonly fileError: string | null;
275+ readonly relativePath: string;
276+ readonly initialLine: number | null;
277+ readonly truncated: boolean;
278+}) {
279+ const isMarkdown = isMarkdownPreviewFile(props.relativePath);
280+ const isBrowserFile = isBrowserPreviewFile(props.relativePath);
281+ const isImageFile = isImagePreviewFile(props.relativePath);
282+
283+ if (props.activeMode === "preview" && isImageFile) {
284+ if (isSvgImagePreviewFile(props.relativePath)) {
285+ return <WorkspaceFileWebPreview uri={props.previewUri} />;
286+ }
287+ return (
288+ <WorkspaceFileImagePreview
289+ accessibilityLabel={basename(props.relativePath)}
290+ uri={props.previewUri}
291+ />
292+ );
293+ }
294+
295+ if (props.activeMode === "preview" && isBrowserFile) {
296+ return <WorkspaceFileWebPreview uri={props.previewUri} />;
297+ }
298+
299+ if (props.fileError && props.fileContents === null) {
300+ return (
301+ <View className="flex-1 items-center justify-center bg-card px-6">
302+ <EmptyState title="File unavailable" detail={props.fileError} />
303+ </View>
304+ );
305+ }
306+
307+ if (props.fileContents === null) {
308+ return (
309+ <View className="flex-1 items-center justify-center gap-3 bg-card px-6">
310+ <ActivityIndicator />
311+ <Text className="text-center text-[13px] text-foreground-muted">Loading file...</Text>
312+ </View>
313+ );
314+ }
315+
316+ return (
317+ <View className="flex-1 bg-card">
318+ {props.truncated ? (
319+ <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40">
320+ <Text className="text-[11px] font-t3-bold uppercase text-amber-700 dark:text-amber-300">
321+ Partial file
322+ </Text>
323+ <Text className="text-[12px] leading-[17px] text-amber-800 dark:text-amber-200">
324+ Preview limited to the first 1 MB of a truncated file.
325+ </Text>
326+ </View>
327+ ) : null}
328+ {props.activeMode === "preview" && isMarkdown ? (
329+ <FileMarkdownPreview markdown={props.fileContents} />
330+ ) : (
331+ <SourceFileSurface
332+ contents={props.fileContents}
333+ path={props.relativePath}
334+ initialLine={props.initialLine}
335+ />
336+ )}
337+ </View>
338+ );
339+}
340+
341+function useThreadFilesWorkspace() {
342+ const params = useLocalSearchParams<{
343+ environmentId?: string | string[];
344+ threadId?: string | string[];
345+ }>();
346+ const routeEnvironmentId = firstRouteParam(params.environmentId);
347+ const routeThreadId = firstRouteParam(params.threadId);
348+ const { selectedThread, selectedThreadProject } = useThreadSelection();
349+ const { selectedThreadCwd } = useSelectedThreadWorktree();
350+ const environmentId =
351+ routeEnvironmentId !== null
352+ ? EnvironmentId.make(routeEnvironmentId)
353+ : (selectedThread?.environmentId ?? null);
354+ const threadId = routeThreadId !== null ? ThreadId.make(routeThreadId) : null;
355+ const project = selectedThreadProject as {
356+ readonly title?: string;
357+ readonly workspaceRoot?: string;
358+ } | null;
359+
360+ return {
361+ cwd: selectedThreadCwd ?? project?.workspaceRoot ?? null,
362+ environmentId,
363+ projectName: project?.title ?? "Files",
364+ selectedThread,
365+ threadId,
366+ };
367+}
368+
369+function FilesUnavailable() {
370+ return (
371+ <View className="flex-1 items-center justify-center bg-sheet px-6">
372+ <Stack.Screen options={{ title: "Files" }} />
373+ <EmptyState
374+ title="Files unavailable"
375+ detail="This thread does not have an active workspace path."
376+ />
377+ </View>
378+ );
379+}
380+
381+function FilesHeaderTitle(props: { readonly projectName: string }) {
382+ const foregroundColor = String(useThemeColor("--color-foreground"));
383+ const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary"));
384+
385+ return (
386+ <View style={{ alignItems: "center", maxWidth: 220 }}>
387+ <RNText
388+ numberOfLines={1}
389+ style={{
390+ color: foregroundColor,
391+ fontFamily: "DMSans_700Bold",
392+ fontSize: 18,
393+ fontWeight: "900",
394+ letterSpacing: -0.4,
395+ }}
396+ >
397+ Files
398+ </RNText>
399+ <RNText
400+ numberOfLines={1}
401+ style={{
402+ color: secondaryForegroundColor,
403+ fontFamily: "DMSans_500Medium",
404+ fontSize: 12,
405+ fontWeight: "500",
406+ letterSpacing: 0.2,
407+ }}
408+ >
409+ {props.projectName}
410+ </RNText>
411+ </View>
412+ );
413+}
414+
415+function FilesToolbarBottomFade() {
416+ const sheetColor = String(useThemeColor("--color-sheet"));
417+
418+ if (process.env.EXPO_OS !== "ios") {
419+ return null;
420+ }
421+
422+ return (
423+ <View
424+ pointerEvents="none"
425+ accessibilityElementsHidden
426+ importantForAccessibility="no-hide-descendants"
427+ style={{
428+ bottom: 0,
429+ height: 112,
430+ left: 0,
431+ position: "absolute",
432+ right: 0,
433+ zIndex: 1,
434+ }}
435+ >
436+ <Svg width="100%" height="100%">
437+ <Defs>
438+ <LinearGradient id="files-toolbar-bottom-fade" x1="0%" x2="0%" y1="0%" y2="100%">
439+ <Stop offset="0%" stopColor={sheetColor} stopOpacity={0} />
440+ <Stop offset="58%" stopColor={sheetColor} stopOpacity={0.72} />
441+ <Stop offset="100%" stopColor={sheetColor} stopOpacity={0.96} />
442+ </LinearGradient>
443+ </Defs>
444+ <Rect width="100%" height="100%" fill="url(#files-toolbar-bottom-fade)" />
445+ </Svg>
446+ </View>
447+ );
448+}
449+
450+export function ThreadFilesTreeScreen() {
451+ const router = useRouter();
452+ const [searchQuery, setSearchQuery] = useState("");
453+ const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();
454+ const entriesQuery = useEnvironmentQuery(
455+ environmentId !== null && cwd !== null
456+ ? projectEnvironment.listEntries({
457+ environmentId,
458+ input: { cwd },
459+ })
460+ : null,
461+ );
462+ const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
463+
464+ const handleSelectFile = useCallback(
465+ (path: string) => {
466+ if (environmentId === null || threadId === null) {
467+ return;
468+ }
469+ router.push(buildThreadFilesNavigation({ environmentId, threadId }, path));
470+ },
471+ [environmentId, router, threadId],
472+ );
473+
474+ if (selectedThread === null || environmentId === null || threadId === null) {
475+ return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />;
476+ }
477+
478+ if (cwd === null) {
479+ return <FilesUnavailable />;
480+ }
481+
482+ return (
483+ <View className="flex-1 bg-sheet">
484+ <Stack.Screen
485+ options={{
486+ title: "Files",
487+ headerShown: true,
488+ headerTransparent: true,
489+ headerStyle: { backgroundColor: "transparent" },
490+ headerShadowVisible: false,
491+ headerTitle: () => <FilesHeaderTitle projectName={projectName} />,
492+ headerSearchBarOptions: {
493+ allowToolbarIntegration: true,
494+ autoCapitalize: "none",
495+ hideNavigationBar: false,
496+ placeholder: "Search files",
497+ onChangeText: (event) => {
498+ setSearchQuery(event.nativeEvent.text);
499+ },
500+ onCancelButtonPress: () => {
501+ setSearchQuery("");
502+ },
503+ },
504+ }}
505+ />
506+ <Stack.Toolbar placement="right">
507+ <Stack.Toolbar.Button
508+ accessibilityLabel="Refresh files"
509+ icon="arrow.clockwise"
510+ onPress={entriesQuery.refresh}
511+ />
512+ </Stack.Toolbar>
513+ <Stack.Toolbar placement="bottom">
514+ <Stack.Toolbar.SearchBarSlot />
515+ </Stack.Toolbar>
516+ <FileTreeBrowser
517+ entries={entriesData?.entries ?? []}
518+ error={entriesQuery.error}
519+ isPending={entriesQuery.isPending}
520+ searchQuery={searchQuery}
521+ selectedPath={null}
522+ onRefresh={entriesQuery.refresh}
523+ onSelectFile={handleSelectFile}
524+ />
525+ <FilesToolbarBottomFade />
526+ </View>
527+ );
528+}
529+
530+export function ThreadFileScreen() {
531+ const params = useLocalSearchParams<{
532+ line?: string | string[];
533+ path?: string | string[];
534+ }>();
535+ const relativePath = normalizeRoutePath(params.path);
536+ const targetLine = normalizeRouteLine(firstRouteParam(params.line));
537+ const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace();
538+ const [modeOverride, setModeOverride] = useState<{
539+ readonly path: string;
540+ readonly mode: FileViewMode;
541+ } | null>(null);
542+ const [previewRevision, setPreviewRevision] = useState(0);
543+ const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath);
544+ const isImageFile = relativePath !== null && isImagePreviewFile(relativePath);
545+ const canPreview =
546+ relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile);
547+ const activeMode =
548+ relativePath !== null && modeOverride?.path === relativePath
549+ ? modeOverride.mode
550+ : defaultViewMode(relativePath);
551+ const resolvedActiveMode = canPreview ? activeMode : "source";
552+ const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null;
553+ const assetPreviewUri = useWorkspaceFileAssetUrl({
554+ cwd,
555+ environmentId,
556+ relativePath: assetPreviewPath,
557+ threadId,
558+ });
559+ const previewUri =
560+ assetPreviewUri === null || previewRevision === 0
561+ ? assetPreviewUri
562+ : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`;
563+ const needsFileContents =
564+ relativePath !== null &&
565+ (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath));
566+ const fileQuery = useEnvironmentQuery(
567+ environmentId !== null && cwd !== null && relativePath !== null && needsFileContents
568+ ? projectEnvironment.readFile({
569+ environmentId,
570+ input: { cwd, relativePath },
571+ })
572+ : null,
573+ );
574+ const fileData = fileQuery.data as ProjectReadFileResult | null;
575+
576+ if (selectedThread === null || environmentId === null || threadId === null) {
577+ return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
578+ }
579+
580+ if (cwd === null) {
581+ return <FilesUnavailable />;
582+ }
583+
584+ if (relativePath === null) {
585+ return (
586+ <View className="flex-1 items-center justify-center bg-sheet px-6">
587+ <Stack.Screen options={{ title: "Files" }} />
588+ <EmptyState title="File unavailable" detail="This file path is invalid." />
589+ </View>
590+ );
591+ }
592+
593+ return (
594+ <ReviewHighlighterProvider>
595+ <View className="flex-1 bg-sheet">
596+ <Stack.Screen options={{ title: basename(relativePath) }} />
597+ <Stack.Toolbar placement="right">
598+ <Stack.Toolbar.Button
599+ icon="arrow.clockwise"
600+ onPress={() => {
601+ if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) {
602+ setPreviewRevision((current) => current + 1);
603+ return;
604+ }
605+ fileQuery.refresh();
606+ }}
607+ />
608+ </Stack.Toolbar>
609+ <FilePreviewHeader
610+ activeMode={resolvedActiveMode}
611+ showModeSelector={canPreview && !isImageFile}
612+ externalPreviewUri={isBrowserFile ? assetPreviewUri : undefined}
613+ projectName={projectName}
614+ relativePath={relativePath}
615+ onSetMode={(mode) => {
616+ setModeOverride({ path: relativePath, mode });
617+ }}
618+ />
619+ <FileContent
620+ activeMode={resolvedActiveMode}
621+ previewUri={previewUri}
622+ fileContents={fileData?.contents ?? null}
623+ fileError={fileQuery.error}
624+ initialLine={targetLine}
625+ relativePath={relativePath}
626+ truncated={fileData?.truncated ?? false}
627+ />
628+ </View>
629+ </ReviewHighlighterProvider>
630+ );
631+}
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx+118−0
@@ -0,0 +1,118 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import { useMemo, useState } from "react";
3+import { ActivityIndicator, Image, Pressable, View } from "react-native";
4+import ImageViewing from "react-native-image-viewing";
5+import { AsyncResult } from "effect/unstable/reactivity";
6+
7+import { AppText as Text } from "../../components/AppText";
8+import { EmptyState } from "../../components/EmptyState";
9+import { workspaceFileImageAtom } from "./workspace-file-image-cache";
10+
11+function ResolvedWorkspaceFileImagePreview(props: {
12+ readonly accessibilityLabel: string;
13+ readonly uri: string;
14+}) {
15+ const [loadError, setLoadError] = useState<string | null>(null);
16+ const [fullScreenVisible, setFullScreenVisible] = useState(false);
17+ const imageSource = useMemo(
18+ () => ({ uri: props.uri, cache: "force-cache" as const }),
19+ [props.uri],
20+ );
21+ const fullScreenImages = useMemo(() => [imageSource], [imageSource]);
22+
23+ return (
24+ <View className="relative flex-1 bg-subtle">
25+ <Pressable
26+ accessibilityRole="button"
27+ accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`}
28+ disabled={loadError !== null}
29+ className="flex-1 p-4 active:bg-subtle-strong"
30+ onPress={() => setFullScreenVisible(true)}
31+ >
32+ <Image
33+ accessible={false}
34+ source={imageSource}
35+ className="h-full w-full"
36+ resizeMode="contain"
37+ onLoadStart={() => setLoadError(null)}
38+ onError={(event) => {
39+ setLoadError(event.nativeEvent.error || "The image could not be rendered.");
40+ }}
41+ />
42+ </Pressable>
43+
44+ {loadError !== null ? (
45+ <View className="absolute inset-0 items-center justify-center bg-card px-6">
46+ <EmptyState title="Image unavailable" detail={loadError} />
47+ </View>
48+ ) : null}
49+
50+ <ImageViewing
51+ images={fullScreenImages}
52+ imageIndex={0}
53+ visible={fullScreenVisible}
54+ onRequestClose={() => setFullScreenVisible(false)}
55+ swipeToCloseEnabled
56+ doubleTapToZoomEnabled
57+ />
58+ </View>
59+ );
60+}
61+
62+function CachedWorkspaceFileImagePreview(props: {
63+ readonly accessibilityLabel: string;
64+ readonly uri: string;
65+}) {
66+ const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]);
67+ const imageResult = useAtomValue(imageAtom);
68+
69+ if (AsyncResult.isFailure(imageResult)) {
70+ return (
71+ <View className="flex-1 items-center justify-center bg-card px-6">
72+ <EmptyState
73+ title="Image unavailable"
74+ detail="The image could not be loaded into the local cache."
75+ />
76+ </View>
77+ );
78+ }
79+
80+ if (!AsyncResult.isSuccess(imageResult)) {
81+ return (
82+ <View className="flex-1 items-center justify-center gap-3 bg-card px-6">
83+ <ActivityIndicator />
84+ <Text className="text-center text-[13px] text-foreground-muted">Loading image...</Text>
85+ </View>
86+ );
87+ }
88+
89+ return (
90+ <ResolvedWorkspaceFileImagePreview
91+ accessibilityLabel={props.accessibilityLabel}
92+ uri={imageResult.value}
93+ />
94+ );
95+}
96+
97+export function WorkspaceFileImagePreview(props: {
98+ readonly accessibilityLabel: string;
99+ readonly uri: string | null;
100+}) {
101+ if (props.uri === null) {
102+ return (
103+ <View className="flex-1 items-center justify-center gap-3 bg-card px-6">
104+ <ActivityIndicator />
105+ <Text className="text-center text-[13px] text-foreground-muted">
106+ Preparing image preview...
107+ </Text>
108+ </View>
109+ );
110+ }
111+
112+ return (
113+ <CachedWorkspaceFileImagePreview
114+ accessibilityLabel={props.accessibilityLabel}
115+ uri={props.uri}
116+ />
117+ );
118+}
apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx+62−0
@@ -0,0 +1,62 @@
1+import { useState } from "react";
2+import { ActivityIndicator, View } from "react-native";
3+import { WebView } from "react-native-webview";
4+
5+import { AppText as Text } from "../../components/AppText";
6+import { LoadingStrip } from "../../components/LoadingStrip";
7+
8+export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) {
9+ const [loadProgress, setLoadProgress] = useState(0);
10+ const [loadError, setLoadError] = useState<string | null>(null);
11+
12+ if (props.uri === null) {
13+ return (
14+ <View className="flex-1 items-center justify-center gap-3 bg-card px-6">
15+ <ActivityIndicator />
16+ <Text className="text-center text-[13px] text-foreground-muted">Preparing preview...</Text>
17+ </View>
18+ );
19+ }
20+
21+ return (
22+ <View className="relative flex-1 bg-card">
23+ {loadProgress > 0 && loadProgress < 1 ? <LoadingStrip progress={loadProgress} /> : null}
24+ {loadError ? (
25+ <View className="border-b border-border bg-card px-4 py-2">
26+ <Text className="text-[12px] font-t3-bold text-foreground">Preview failed</Text>
27+ <Text className="mt-0.5 text-[12px] leading-[17px] text-foreground-muted">
28+ {loadError}
29+ </Text>
30+ </View>
31+ ) : null}
32+ <WebView
33+ source={{ uri: props.uri }}
34+ originWhitelist={["*"]}
35+ allowsBackForwardNavigationGestures
36+ allowsFullscreenVideo
37+ setSupportMultipleWindows={false}
38+ startInLoadingState
39+ onLoadProgress={(event) => {
40+ setLoadProgress(event.nativeEvent.progress);
41+ }}
42+ onLoadStart={() => {
43+ setLoadProgress(0.05);
44+ setLoadError(null);
45+ }}
46+ onLoadEnd={() => {
47+ setLoadProgress(0);
48+ }}
49+ onError={(event) => {
50+ setLoadProgress(0);
51+ setLoadError(event.nativeEvent.description || "The file could not be rendered.");
52+ }}
53+ renderLoading={() => (
54+ <View className="absolute inset-0 items-center justify-center bg-card">
55+ <ActivityIndicator />
56+ </View>
57+ )}
58+ style={{ flex: 1, backgroundColor: "transparent" }}
59+ />
60+ </View>
61+ );
62+}
apps/mobile/src/features/files/filePath.test.ts+43−0
@@ -0,0 +1,43 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ isBrowserPreviewFile,
5+ isImagePreviewFile,
6+ isSvgImagePreviewFile,
7+ resolveWorkspaceRelativeFilePath,
8+} from "./filePath";
9+
10+describe("resolveWorkspaceRelativeFilePath", () => {
11+ it("keeps normalized workspace-relative paths", () => {
12+ expect(resolveWorkspaceRelativeFilePath("/repo", "./src/../src/main.ts")).toBe("src/main.ts");
13+ });
14+
15+ it("converts absolute paths inside the workspace", () => {
16+ expect(
17+ resolveWorkspaceRelativeFilePath("/Users/julius/repo", "/Users/julius/repo/src/main.ts"),
18+ ).toBe("src/main.ts");
19+ expect(resolveWorkspaceRelativeFilePath("C:\\repo", "c:\\repo\\src\\main.ts")).toBe(
20+ "src/main.ts",
21+ );
22+ });
23+
24+ it("rejects paths outside the workspace", () => {
25+ expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull();
26+ expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull();
27+ expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull();
28+ });
29+});
30+
31+describe("file preview types", () => {
32+ it("recognizes browser and image previews", () => {
33+ expect(isBrowserPreviewFile("reports/summary.html")).toBe(true);
34+ expect(isImagePreviewFile("assets/icon.png")).toBe(true);
35+ expect(isImagePreviewFile("assets/diagram.SVG?raw=1")).toBe(true);
36+ expect(isImagePreviewFile("src/image.ts")).toBe(false);
37+ });
38+
39+ it("identifies SVG images that need web rendering", () => {
40+ expect(isSvgImagePreviewFile("assets/diagram.svg#icon")).toBe(true);
41+ expect(isSvgImagePreviewFile("assets/photo.png")).toBe(false);
42+ });
43+});
apps/mobile/src/features/files/filePath.ts+116−0
@@ -0,0 +1,116 @@
1+import {
2+ isWorkspaceBrowserPreviewPath,
3+ isWorkspaceImagePreviewPath,
4+} from "@t3tools/shared/filePreview";
5+
6+export interface FileBreadcrumb {
7+ readonly label: string;
8+ readonly path: string;
9+ readonly kind: "project" | "directory" | "file";
10+}
11+
12+function isWindowsAbsolutePath(value: string): boolean {
13+ return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
14+}
15+
16+function isAbsolutePath(value: string): boolean {
17+ return value.startsWith("/") || isWindowsAbsolutePath(value);
18+}
19+
20+function isWindowsPathStyle(value: string): boolean {
21+ return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value);
22+}
23+
24+function joinPath(base: string, next: string, separator: "/" | "\\"): string {
25+ const cleanBase = base.replace(/[\\/]+$/, "");
26+ if (separator === "\\") {
27+ return `${cleanBase}\\${next.replaceAll("/", "\\")}`;
28+ }
29+ return `${cleanBase}/${next.replace(/^\/+/, "")}`;
30+}
31+
32+export function basename(path: string): string {
33+ const parts = path.split(/[\\/]/).filter(Boolean);
34+ return parts.at(-1) ?? path;
35+}
36+
37+export function resolveWorkspaceFilePath(cwd: string, relativePath: string): string {
38+ if (isAbsolutePath(relativePath)) {
39+ return relativePath;
40+ }
41+
42+ const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/";
43+ return joinPath(cwd, relativePath, separator);
44+}
45+
46+function normalizeRelativePath(value: string): string | null {
47+ const segments: string[] = [];
48+ for (const segment of value.replaceAll("\\", "/").split("/")) {
49+ if (segment.length === 0 || segment === ".") {
50+ continue;
51+ }
52+ if (segment === "..") {
53+ if (segments.length === 0) {
54+ return null;
55+ }
56+ segments.pop();
57+ continue;
58+ }
59+ segments.push(segment);
60+ }
61+ return segments.length > 0 ? segments.join("/") : null;
62+}
63+
64+export function resolveWorkspaceRelativeFilePath(
65+ workspaceRoot: string | null | undefined,
66+ targetPath: string,
67+): string | null {
68+ if (!isAbsolutePath(targetPath)) {
69+ if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) {
70+ return null;
71+ }
72+ return normalizeRelativePath(targetPath);
73+ }
74+ if (!workspaceRoot) {
75+ return null;
76+ }
77+
78+ const normalizedTarget = targetPath.replaceAll("\\", "/");
79+ const normalizedRoot = workspaceRoot.replaceAll("\\", "/").replace(/\/+$/, "");
80+ const caseInsensitive = isWindowsAbsolutePath(targetPath) || isWindowsAbsolutePath(workspaceRoot);
81+ const comparableTarget = caseInsensitive ? normalizedTarget.toLowerCase() : normalizedTarget;
82+ const comparableRoot = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
83+ if (!comparableTarget.startsWith(`${comparableRoot}/`)) {
84+ return null;
85+ }
86+
87+ return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1));
88+}
89+
90+export function isBrowserPreviewFile(path: string): boolean {
91+ return isWorkspaceBrowserPreviewPath(path);
92+}
93+
94+export function isImagePreviewFile(path: string): boolean {
95+ return isWorkspaceImagePreviewPath(path);
96+}
97+
98+export function isSvgImagePreviewFile(path: string): boolean {
99+ return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? "");
100+}
101+
102+export function isMarkdownPreviewFile(path: string): boolean {
103+ return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
104+}
105+
106+export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] {
107+ const parts = relativePath.split("/").filter(Boolean);
108+ return [
109+ { label: projectName, path: "", kind: "project" },
110+ ...parts.map((part, index) => ({
111+ label: part,
112+ path: parts.slice(0, index + 1).join("/"),
113+ kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const),
114+ })),
115+ ];
116+}
apps/mobile/src/features/files/fileTree.test.ts+109−0
@@ -0,0 +1,109 @@
1+import { describe, expect, it } from "vite-plus/test";
2+import type { ProjectEntry } from "@t3tools/contracts";
3+
4+import {
5+ buildFileTree,
6+ countFileNodes,
7+ defaultExpandedTreePaths,
8+ firstFilePath,
9+ flattenFileTree,
10+} from "./fileTree";
11+
12+const entries = [
13+ { kind: "file", path: "README.md" },
14+ { kind: "directory", path: "src" },
15+ { kind: "file", path: "src/index.ts" },
16+ { kind: "file", path: "src/components/App.tsx" },
17+ { kind: "file", path: "package.json" },
18+] satisfies ReadonlyArray<ProjectEntry>;
19+
20+describe("mobile file tree helpers", () => {
21+ it("builds a deterministic hierarchy with directories before files", () => {
22+ const tree = buildFileTree(entries);
23+
24+ expect(tree.map((node) => `${node.kind}:${node.path}`)).toEqual([
25+ "directory:src",
26+ "file:package.json",
27+ "file:README.md",
28+ ]);
29+ expect(tree[0]?.children.map((node) => `${node.kind}:${node.path}`)).toEqual([
30+ "directory:src/components",
31+ "file:src/index.ts",
32+ ]);
33+ expect(countFileNodes(tree)).toBe(4);
34+ expect(firstFilePath(tree)).toBe("src/components/App.tsx");
35+ });
36+
37+ it("flattens expanded directories and hides collapsed descendants", () => {
38+ const tree = buildFileTree(entries);
39+
40+ expect(
41+ flattenFileTree({
42+ nodes: tree,
43+ expanded: new Set(["src"]),
44+ }).map((item) => `${item.depth}:${item.node.path}`),
45+ ).toEqual(["0:src", "1:src/components", "1:src/index.ts", "0:package.json", "0:README.md"]);
46+
47+ expect(
48+ flattenFileTree({
49+ nodes: tree,
50+ expanded: new Set(),
51+ }).map((item) => item.node.path),
52+ ).toEqual(["src", "package.json", "README.md"]);
53+ });
54+
55+ it("includes matching descendants and their ancestors during search", () => {
56+ const tree = buildFileTree(entries);
57+
58+ expect(
59+ flattenFileTree({
60+ nodes: tree,
61+ expanded: new Set(),
62+ searchQuery: "app",
63+ }).map((item) => item.node.path),
64+ ).toEqual(["src", "src/components", "src/components/App.tsx"]);
65+ });
66+
67+ it("supports fuzzy, whitespace-separated path queries", () => {
68+ const tree = buildFileTree([
69+ {
70+ kind: "file",
71+ path: ".plans/19-version-control-phase-1-vcs-driver-foundation.md",
72+ },
73+ {
74+ kind: "file",
75+ path: ".repos/alchemy-effect/examples/aws-lambda/src/JobNotifications.ts",
76+ },
77+ { kind: "directory", path: "apps/web/src/components/chat" },
78+ { kind: "file", path: "apps/web/src/components/chat/ChatHeader.test.ts" },
79+ { kind: "file", path: "apps/web/src/components/chat/ChatHeader.tsx" },
80+ { kind: "file", path: "apps/web/src/components/chat/Composer.tsx" },
81+ ]);
82+
83+ const expectedPaths = [
84+ "apps",
85+ "apps/web",
86+ "apps/web/src",
87+ "apps/web/src/components",
88+ "apps/web/src/components/chat",
89+ "apps/web/src/components/chat/ChatHeader.test.ts",
90+ "apps/web/src/components/chat/ChatHeader.tsx",
91+ ];
92+
93+ for (const searchQuery of ["chat hea", "cht hdr"]) {
94+ expect(
95+ flattenFileTree({
96+ nodes: tree,
97+ expanded: new Set(),
98+ searchQuery,
99+ }).map((item) => item.node.path),
100+ ).toEqual(expectedPaths);
101+ }
102+ });
103+
104+ it("expands top-level directories by default", () => {
105+ const tree = buildFileTree(entries);
106+
107+ expect([...defaultExpandedTreePaths(tree)]).toEqual(["src"]);
108+ });
109+});
apps/mobile/src/features/files/fileTree.ts+220−0
@@ -0,0 +1,220 @@
1+import type { ProjectEntry } from "@t3tools/contracts";
2+import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";
3+
4+export interface FileTreeNode {
5+ readonly path: string;
6+ readonly name: string;
7+ readonly kind: ProjectEntry["kind"];
8+ readonly children: ReadonlyArray<FileTreeNode>;
9+ readonly searchSegments: ReadonlyArray<string>;
10+ readonly searchWords: ReadonlyArray<string>;
11+}
12+
13+export interface VisibleFileTreeNode {
14+ readonly node: FileTreeNode;
15+ readonly depth: number;
16+}
17+
18+interface MutableFileTreeNode {
19+ path: string;
20+ name: string;
21+ kind: ProjectEntry["kind"];
22+ children: Map<string, MutableFileTreeNode>;
23+}
24+
25+function createMutableNode(
26+ path: string,
27+ name: string,
28+ kind: ProjectEntry["kind"],
29+): MutableFileTreeNode {
30+ return {
31+ path,
32+ name,
33+ kind,
34+ children: new Map(),
35+ };
36+}
37+
38+function splitSearchWords(value: string): ReadonlyArray<string> {
39+ return value
40+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
41+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
42+ .split(/[^A-Za-z0-9]+/)
43+ .filter(Boolean)
44+ .map((word) => word.toLowerCase());
45+}
46+
47+function buildNodeSearchTerms(path: string): {
48+ readonly segments: ReadonlyArray<string>;
49+ readonly words: ReadonlyArray<string>;
50+} {
51+ const segments: string[] = [];
52+ const words: string[] = [];
53+
54+ for (const segment of path.split("/")) {
55+ if (!segment) {
56+ continue;
57+ }
58+ segments.push(segment.toLowerCase());
59+ words.push(...splitSearchWords(segment));
60+ }
61+
62+ return { segments, words };
63+}
64+
65+function freezeNode(node: MutableFileTreeNode): FileTreeNode {
66+ const searchTerms = buildNodeSearchTerms(node.path);
67+ return {
68+ path: node.path,
69+ name: node.name,
70+ kind: node.kind,
71+ children: [...node.children.values()].sort(compareNodes).map(freezeNode),
72+ searchSegments: searchTerms.segments,
73+ searchWords: searchTerms.words,
74+ };
75+}
76+
77+function compareNodes(
78+ left: Pick<FileTreeNode, "kind" | "name">,
79+ right: Pick<FileTreeNode, "kind" | "name">,
80+): number {
81+ if (left.kind !== right.kind) {
82+ return left.kind === "directory" ? -1 : 1;
83+ }
84+ return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
85+}
86+
87+export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> {
88+ const root = createMutableNode("", "", "directory");
89+
90+ for (const entry of entries) {
91+ const parts = entry.path.split("/").filter(Boolean);
92+ if (parts.length === 0) {
93+ continue;
94+ }
95+
96+ let current = root;
97+ for (let index = 0; index < parts.length; index += 1) {
98+ const part = parts[index];
99+ if (!part) {
100+ continue;
101+ }
102+
103+ const path = parts.slice(0, index + 1).join("/");
104+ const isLeaf = index === parts.length - 1;
105+ const kind = isLeaf ? entry.kind : "directory";
106+ let child = current.children.get(part);
107+ if (!child) {
108+ child = createMutableNode(path, part, kind);
109+ current.children.set(part, child);
110+ } else if (isLeaf) {
111+ child.kind = entry.kind;
112+ }
113+ current = child;
114+ }
115+ }
116+
117+ return [...root.children.values()].sort(compareNodes).map(freezeNode);
118+}
119+
120+export function countFileNodes(nodes: ReadonlyArray<FileTreeNode>): number {
121+ let count = 0;
122+ for (const node of nodes) {
123+ if (node.kind === "file") {
124+ count += 1;
125+ } else {
126+ count += countFileNodes(node.children);
127+ }
128+ }
129+ return count;
130+}
131+
132+export function defaultExpandedTreePaths(nodes: ReadonlyArray<FileTreeNode>): ReadonlySet<string> {
133+ const expanded = new Set<string>();
134+ for (const node of nodes) {
135+ if (node.kind === "directory") {
136+ expanded.add(node.path);
137+ }
138+ }
139+ return expanded;
140+}
141+
142+function valueMatchesSearchToken(value: string, token: string, fuzzy: boolean): boolean {
143+ return (
144+ scoreQueryMatch({
145+ value,
146+ query: token,
147+ exactBase: 0,
148+ prefixBase: 2,
149+ boundaryBase: 4,
150+ includesBase: 6,
151+ ...(fuzzy ? { fuzzyBase: 100 } : {}),
152+ boundaryMarkers: ["/", "-", "_", "."],
153+ }) !== null
154+ );
155+}
156+
157+function nodeMatchesSearch(node: FileTreeNode, tokens: ReadonlyArray<string>): boolean {
158+ return tokens.every(
159+ (token) =>
160+ node.searchSegments.some((segment) => valueMatchesSearchToken(segment, token, false)) ||
161+ node.searchWords.some((word) => valueMatchesSearchToken(word, token, true)),
162+ );
163+}
164+
165+function flattenNode(
166+ output: VisibleFileTreeNode[],
167+ node: FileTreeNode,
168+ depth: number,
169+ expanded: ReadonlySet<string>,
170+ searchTokens: ReadonlyArray<string>,
171+): boolean {
172+ const isSearching = searchTokens.length > 0;
173+ const matches = isSearching && nodeMatchesSearch(node, searchTokens);
174+ let descendantMatches = false;
175+ const childOutput: VisibleFileTreeNode[] = [];
176+
177+ if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) {
178+ for (const child of node.children) {
179+ if (flattenNode(childOutput, child, depth + 1, expanded, searchTokens)) {
180+ descendantMatches = true;
181+ }
182+ }
183+ }
184+
185+ const visible = !isSearching || matches || descendantMatches;
186+ if (!visible) {
187+ return false;
188+ }
189+
190+ output.push({ node, depth });
191+ output.push(...childOutput);
192+ return matches || descendantMatches;
193+}
194+
195+export function flattenFileTree(input: {
196+ readonly nodes: ReadonlyArray<FileTreeNode>;
197+ readonly expanded: ReadonlySet<string>;
198+ readonly searchQuery?: string;
199+}): ReadonlyArray<VisibleFileTreeNode> {
200+ const output: VisibleFileTreeNode[] = [];
201+ const normalizedSearch = normalizeSearchQuery(input.searchQuery ?? "");
202+ const searchTokens = normalizedSearch.split(/[\s/\\._-]+/).filter(Boolean);
203+ for (const node of input.nodes) {
204+ flattenNode(output, node, 0, input.expanded, searchTokens);
205+ }
206+ return output;
207+}
208+
209+export function firstFilePath(nodes: ReadonlyArray<FileTreeNode>): string | null {
210+ for (const node of nodes) {
211+ if (node.kind === "file") {
212+ return node.path;
213+ }
214+ const child = firstFilePath(node.children);
215+ if (child !== null) {
216+ return child;
217+ }
218+ }
219+ return null;
220+}
apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts+46−0
@@ -0,0 +1,46 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ buildNativeSourceRows,
5+ buildNativeSourceTokens,
6+ nativeSourceRowId,
7+} from "./nativeSourceFileAdapter";
8+
9+describe("nativeSourceFileAdapter", () => {
10+ it("maps plain source lines onto context rows with stable line numbers", () => {
11+ expect(buildNativeSourceRows(["const value = 1;", "\treturn value;"])).toEqual([
12+ {
13+ kind: "line",
14+ id: nativeSourceRowId(0),
15+ fileId: "source-file",
16+ content: "const value = 1;",
17+ change: "context",
18+ newLineNumber: 1,
19+ },
20+ {
21+ kind: "line",
22+ id: nativeSourceRowId(1),
23+ fileId: "source-file",
24+ content: " return value;",
25+ change: "context",
26+ newLineNumber: 2,
27+ },
28+ ]);
29+ });
30+
31+ it("maps cached source tokens to the same row identifiers", () => {
32+ expect(
33+ buildNativeSourceTokens([
34+ [{ content: "const", color: "#ff0000", fontStyle: 2 }],
35+ [{ content: "\tvalue", color: null, fontStyle: null }],
36+ ]),
37+ ).toEqual({
38+ [nativeSourceRowId(0)]: [{ content: "const", color: "#ff0000", fontStyle: 2 }],
39+ [nativeSourceRowId(1)]: [{ content: " value", color: null, fontStyle: null }],
40+ });
41+ });
42+
43+ it("clears native tokens while highlighting is unavailable", () => {
44+ expect(buildNativeSourceTokens(null)).toEqual({});
45+ });
46+});
apps/mobile/src/features/files/nativeSourceFileAdapter.ts+65−0
@@ -0,0 +1,65 @@
1+import type {
2+ NativeReviewDiffRow,
3+ NativeReviewDiffStyle,
4+ NativeReviewDiffToken,
5+} from "../diffs/nativeReviewDiffSurface";
6+import type { SourceHighlightTokens } from "./sourceHighlightingState";
7+
8+export const NATIVE_SOURCE_ROW_HEIGHT = 24;
9+export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000;
10+
11+export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = {
12+ rowHeight: NATIVE_SOURCE_ROW_HEIGHT,
13+ contentWidth: NATIVE_SOURCE_CONTENT_WIDTH,
14+ changeBarWidth: 0,
15+ gutterWidth: 58,
16+ codePadding: 8,
17+ codeFontSize: 13,
18+ codeFontWeight: "medium",
19+ lineNumberFontSize: 11,
20+ lineNumberFontWeight: "medium",
21+ emptyStateFontSize: 12,
22+ emptyStateFontWeight: "medium",
23+};
24+
25+const SOURCE_FILE_ID = "source-file";
26+
27+function expandTabs(value: string): string {
28+ return value.replace(/\t/g, " ");
29+}
30+
31+export function nativeSourceRowId(index: number): string {
32+ return `source-line:${index}`;
33+}
34+
35+export function buildNativeSourceRows(
36+ lines: ReadonlyArray<string>,
37+): ReadonlyArray<NativeReviewDiffRow> {
38+ return lines.map((line, index) => ({
39+ kind: "line",
40+ id: nativeSourceRowId(index),
41+ fileId: SOURCE_FILE_ID,
42+ content: expandTabs(line),
43+ change: "context",
44+ newLineNumber: index + 1,
45+ }));
46+}
47+
48+export function buildNativeSourceTokens(
49+ tokenLines: SourceHighlightTokens | null,
50+): Readonly<Record<string, ReadonlyArray<NativeReviewDiffToken>>> {
51+ if (tokenLines === null) {
52+ return {};
53+ }
54+
55+ return Object.fromEntries(
56+ tokenLines.map((tokens, index) => [
57+ nativeSourceRowId(index),
58+ tokens.map((token) => ({
59+ content: expandTabs(token.content),
60+ color: token.color,
61+ fontStyle: token.fontStyle,
62+ })),
63+ ]),
64+ );
65+}
apps/mobile/src/features/files/sourceHighlightingState.test.ts+123−0
@@ -0,0 +1,123 @@
1+import { AtomRegistry } from "effect/unstable/reactivity";
2+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4+
5+import {
6+ createSourceHighlightAtomFamily,
7+ type SourceHighlightTokens,
8+} from "./sourceHighlightingState";
9+
10+const highlightedTokens: SourceHighlightTokens = [
11+ [{ content: "const", color: "#0000ff", fontStyle: null }],
12+];
13+
14+afterEach(() => {
15+ vi.restoreAllMocks();
16+});
17+
18+describe("sourceHighlightingState", () => {
19+ it("reuses completed highlighting across equivalent route remounts", async () => {
20+ const highlight = vi.fn(async () => highlightedTokens);
21+ const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight, idleTtlMs: 1_000 });
22+ const registry = AtomRegistry.make({ timeoutResolution: 1 });
23+ const input = {
24+ path: "src/example.ts",
25+ contents: "const value = 1;",
26+ theme: "light" as const,
27+ };
28+ const firstAtom = sourceHighlightAtom(input);
29+ const firstUnmount = registry.mount(firstAtom);
30+
31+ await vi.waitFor(() => {
32+ expect(AsyncResult.isSuccess(registry.get(firstAtom))).toBe(true);
33+ });
34+ firstUnmount();
35+
36+ const remountedAtom = sourceHighlightAtom({ ...input });
37+ const secondUnmount = registry.mount(remountedAtom);
38+
39+ expect(remountedAtom).toBe(firstAtom);
40+ expect(AsyncResult.isSuccess(registry.get(remountedAtom))).toBe(true);
41+ expect(highlight).toHaveBeenCalledTimes(1);
42+
43+ secondUnmount();
44+ registry.dispose();
45+ });
46+
47+ it("does not reuse highlighting when the source contents change", async () => {
48+ const highlight = vi.fn(async () => highlightedTokens);
49+ const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight });
50+ const registry = AtomRegistry.make();
51+ const firstAtom = sourceHighlightAtom({
52+ path: "src/example.ts",
53+ contents: "const value = 1;",
54+ theme: "light",
55+ });
56+ const secondAtom = sourceHighlightAtom({
57+ path: "src/example.ts",
58+ contents: "const value = 2;",
59+ theme: "light",
60+ });
61+ const firstUnmount = registry.mount(firstAtom);
62+ const secondUnmount = registry.mount(secondAtom);
63+
64+ await vi.waitFor(() => {
65+ expect(AsyncResult.isSuccess(registry.get(firstAtom))).toBe(true);
66+ expect(AsyncResult.isSuccess(registry.get(secondAtom))).toBe(true);
67+ });
68+ expect(secondAtom).not.toBe(firstAtom);
69+ expect(highlight).toHaveBeenCalledTimes(2);
70+
71+ firstUnmount();
72+ secondUnmount();
73+ registry.dispose();
74+ });
75+
76+ it("recomputes highlighting after the idle cache entry expires", async () => {
77+ const highlight = vi.fn(async () => highlightedTokens);
78+ const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight, idleTtlMs: 5 });
79+ const registry = AtomRegistry.make({ timeoutResolution: 1 });
80+ const atom = sourceHighlightAtom({
81+ path: "src/example.ts",
82+ contents: "const value = 1;",
83+ theme: "light",
84+ });
85+ const firstUnmount = registry.mount(atom);
86+
87+ await vi.waitFor(() => {
88+ expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);
89+ });
90+ firstUnmount();
91+ await new Promise((resolve) => setTimeout(resolve, 25));
92+
93+ const secondUnmount = registry.mount(atom);
94+ await vi.waitFor(() => {
95+ expect(highlight).toHaveBeenCalledTimes(2);
96+ expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true);
97+ });
98+
99+ secondUnmount();
100+ registry.dispose();
101+ });
102+
103+ it("exposes highlighter errors as a failed async result", async () => {
104+ const highlight = vi.fn(async () => {
105+ throw new Error("highlight failed");
106+ });
107+ const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight });
108+ const registry = AtomRegistry.make();
109+ const atom = sourceHighlightAtom({
110+ path: "src/example.ts",
111+ contents: "const value = 1;",
112+ theme: "light",
113+ });
114+ const unmount = registry.mount(atom);
115+
116+ await vi.waitFor(() => {
117+ expect(AsyncResult.isFailure(registry.get(atom))).toBe(true);
118+ });
119+
120+ unmount();
121+ registry.dispose();
122+ });
123+});
apps/mobile/src/features/files/sourceHighlightingState.ts+50−0
@@ -0,0 +1,50 @@
1+import * as Data from "effect/Data";
2+import * as Effect from "effect/Effect";
3+import { Atom } from "effect/unstable/reactivity";
4+
5+import {
6+ highlightSourceFile,
7+ type ReviewDiffTheme,
8+ type ReviewHighlightedToken,
9+} from "../review/shikiReviewHighlighter";
10+
11+const SOURCE_HIGHLIGHT_IDLE_TTL_MS = 5 * 60_000;
12+
13+export interface SourceHighlightInput {
14+ readonly path: string;
15+ readonly contents: string;
16+ readonly theme: ReviewDiffTheme;
17+}
18+
19+export type SourceHighlightTokens = ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>;
20+
21+type SourceHighlighter = (input: SourceHighlightInput) => Promise<SourceHighlightTokens>;
22+
23+class SourceHighlightCacheKey extends Data.Class<SourceHighlightInput> {}
24+
25+class SourceHighlightError extends Data.TaggedError("SourceHighlightError")<{
26+ readonly cause: unknown;
27+}> {}
28+
29+export function createSourceHighlightAtomFamily(options?: {
30+ readonly highlight?: SourceHighlighter;
31+ readonly idleTtlMs?: number;
32+}) {
33+ const highlight = options?.highlight ?? highlightSourceFile;
34+ const idleTtlMs = options?.idleTtlMs ?? SOURCE_HIGHLIGHT_IDLE_TTL_MS;
35+ const family = Atom.family((request: SourceHighlightCacheKey) =>
36+ Atom.make(
37+ Effect.tryPromise({
38+ try: () => highlight(request),
39+ catch: (cause) => new SourceHighlightError({ cause }),
40+ }),
41+ ).pipe(
42+ Atom.setIdleTTL(idleTtlMs),
43+ Atom.withLabel(`mobile:source-highlight:${request.theme}:${request.path}`),
44+ ),
45+ );
46+
47+ return (input: SourceHighlightInput) => family(new SourceHighlightCacheKey(input));
48+}
49+
50+export const sourceHighlightAtom = createSourceHighlightAtomFamily();
apps/mobile/src/features/files/workspace-file-image-cache.test.ts+64−0
@@ -0,0 +1,64 @@
1+import { AtomRegistry } from "effect/unstable/reactivity";
2+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3+import { describe, expect, it, vi } from "vite-plus/test";
4+
5+import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache";
6+
7+describe("workspaceFileImageAtom", () => {
8+ it("reuses a prefetched image across route remounts", async () => {
9+ const prefetch = vi.fn(async () => true);
10+ const imageAtom = createWorkspaceFileImageAtomFamily({ idleTtlMs: 1_000, prefetch });
11+ const registry = AtomRegistry.make({ timeoutResolution: 1 });
12+ const first = imageAtom("https://example.test/image.png");
13+ const firstUnmount = registry.mount(first);
14+
15+ await vi.waitFor(() => {
16+ expect(AsyncResult.isSuccess(registry.get(first))).toBe(true);
17+ });
18+ firstUnmount();
19+
20+ const remounted = imageAtom("https://example.test/image.png");
21+ const secondUnmount = registry.mount(remounted);
22+
23+ expect(remounted).toBe(first);
24+ expect(AsyncResult.isSuccess(registry.get(remounted))).toBe(true);
25+ expect(prefetch).toHaveBeenCalledTimes(1);
26+
27+ secondUnmount();
28+ registry.dispose();
29+ });
30+
31+ it("prefetches different asset URLs independently", async () => {
32+ const prefetch = vi.fn(async () => true);
33+ const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch });
34+ const registry = AtomRegistry.make();
35+ const first = imageAtom("https://example.test/first.png");
36+ const second = imageAtom("https://example.test/second.png");
37+ const firstUnmount = registry.mount(first);
38+ const secondUnmount = registry.mount(second);
39+
40+ await vi.waitFor(() => {
41+ expect(AsyncResult.isSuccess(registry.get(first))).toBe(true);
42+ expect(AsyncResult.isSuccess(registry.get(second))).toBe(true);
43+ });
44+ expect(prefetch).toHaveBeenCalledTimes(2);
45+
46+ firstUnmount();
47+ secondUnmount();
48+ registry.dispose();
49+ });
50+
51+ it("exposes prefetch failures", async () => {
52+ const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false });
53+ const registry = AtomRegistry.make();
54+ const atom = imageAtom("https://example.test/missing.png");
55+ const unmount = registry.mount(atom);
56+
57+ await vi.waitFor(() => {
58+ expect(AsyncResult.isFailure(registry.get(atom))).toBe(true);
59+ });
60+
61+ unmount();
62+ registry.dispose();
63+ });
64+});
apps/mobile/src/features/files/workspace-file-image-cache.ts+48−0
@@ -0,0 +1,48 @@
1+import * as Data from "effect/Data";
2+import * as Effect from "effect/Effect";
3+import { Atom } from "effect/unstable/reactivity";
4+
5+const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000;
6+
7+type ImagePrefetch = (uri: string) => Promise<boolean>;
8+
9+class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {}
10+
11+export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{
12+ readonly cause?: unknown;
13+ readonly uri: string;
14+}> {}
15+
16+async function prefetchWithNativeImage(uri: string): Promise<boolean> {
17+ const { Image } = await import("react-native");
18+ return Image.prefetch(uri);
19+}
20+
21+export function createWorkspaceFileImageAtomFamily(options?: {
22+ readonly idleTtlMs?: number;
23+ readonly prefetch?: ImagePrefetch;
24+}) {
25+ const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS;
26+ const prefetch = options?.prefetch ?? prefetchWithNativeImage;
27+ const family = Atom.family((key: WorkspaceImageCacheKey) =>
28+ Atom.make(
29+ Effect.tryPromise({
30+ try: async () => {
31+ const cached = await prefetch(key.uri);
32+ if (!cached) {
33+ throw new WorkspaceImagePrefetchError({ uri: key.uri });
34+ }
35+ return key.uri;
36+ },
37+ catch: (cause) =>
38+ cause instanceof WorkspaceImagePrefetchError
39+ ? cause
40+ : new WorkspaceImagePrefetchError({ uri: key.uri, cause }),
41+ }),
42+ ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)),
43+ );
44+
45+ return (uri: string) => family(new WorkspaceImageCacheKey({ uri }));
46+}
47+
48+export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily();
apps/mobile/src/features/files/workspaceFileAssetUrl.ts+31−0
@@ -0,0 +1,31 @@
1+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2+import { useMemo } from "react";
3+
4+import { useAssetUrl } from "../../state/assets";
5+import { resolveWorkspaceFilePath } from "./filePath";
6+
7+export function useWorkspaceFileAssetUrl(props: {
8+ readonly cwd: string | null;
9+ readonly environmentId: EnvironmentId | null;
10+ readonly relativePath: string | null;
11+ readonly threadId: ThreadId | null;
12+}) {
13+ const absolutePath = useMemo(
14+ () =>
15+ props.cwd !== null && props.relativePath !== null
16+ ? resolveWorkspaceFilePath(props.cwd, props.relativePath)
17+ : null,
18+ [props.cwd, props.relativePath],
19+ );
20+
21+ return useAssetUrl(
22+ props.environmentId,
23+ absolutePath !== null && props.threadId !== null
24+ ? {
25+ _tag: "workspace-file",
26+ threadId: props.threadId,
27+ path: absolutePath,
28+ }
29+ : null,
30+ );
31+}
apps/mobile/src/features/home/HomeHeader.tsx+244−0
@@ -0,0 +1,244 @@
1+import type {
2+ EnvironmentId,
3+ SidebarProjectGroupingMode,
4+ SidebarThreadSortOrder,
5+} from "@t3tools/contracts";
6+import {
7+ DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8+ DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9+ DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10+} from "@t3tools/contracts";
11+import { Stack } from "expo-router";
12+import { Text as RNText, View } from "react-native";
13+
14+import { useThemeColor } from "../../lib/useThemeColor";
15+import type { HomeProjectSortOrder } from "./homeThreadList";
16+
17+export interface HomeHeaderEnvironment {
18+ readonly environmentId: EnvironmentId;
19+ readonly label: string;
20+}
21+
22+const PROJECT_SORT_OPTIONS: ReadonlyArray<{
23+ readonly value: HomeProjectSortOrder;
24+ readonly label: string;
25+}> = [
26+ { value: "updated_at", label: "Last user message" },
27+ { value: "created_at", label: "Created at" },
28+];
29+
30+const THREAD_SORT_OPTIONS: ReadonlyArray<{
31+ readonly value: SidebarThreadSortOrder;
32+ readonly label: string;
33+}> = [
34+ { value: "updated_at", label: "Last user message" },
35+ { value: "created_at", label: "Created at" },
36+];
37+
38+const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{
39+ readonly value: SidebarProjectGroupingMode;
40+ readonly label: string;
41+ readonly subtitle: string;
42+}> = [
43+ {
44+ value: "repository",
45+ label: "Group by repository",
46+ subtitle: "Combine matching repositories across environments",
47+ },
48+ {
49+ value: "repository_path",
50+ label: "Group by repository path",
51+ subtitle: "Combine only matching paths within a repository",
52+ },
53+ {
54+ value: "separate",
55+ label: "Keep separate",
56+ subtitle: "Show every project path separately",
57+ },
58+];
59+
60+export function HomeHeader(props: {
61+ readonly environments: ReadonlyArray<HomeHeaderEnvironment>;
62+ readonly selectedEnvironmentId: EnvironmentId | null;
63+ readonly projectSortOrder: HomeProjectSortOrder;
64+ readonly threadSortOrder: SidebarThreadSortOrder;
65+ readonly projectGroupingMode: SidebarProjectGroupingMode;
66+ readonly onSearchQueryChange: (query: string) => void;
67+ readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
68+ readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
69+ readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
70+ readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
71+ readonly onOpenSettings: () => void;
72+ readonly onStartNewTask: () => void;
73+}) {
74+ const iconColor = useThemeColor("--color-icon");
75+ const mutedColor = useThemeColor("--color-foreground-muted");
76+ const subtleColor = useThemeColor("--color-subtle");
77+ const hasCustomListOptions =
78+ props.selectedEnvironmentId !== null ||
79+ props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER ||
80+ props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ||
81+ props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE;
82+
83+ return (
84+ <>
85+ <Stack.Screen
86+ options={{
87+ headerShown: true,
88+ headerTransparent: true,
89+ headerStyle: { backgroundColor: "transparent" },
90+ headerShadowVisible: false,
91+ headerTintColor: iconColor,
92+ headerTitle: "",
93+ headerSearchBarOptions: {
94+ placeholder: "Search threads",
95+ hideNavigationBar: false,
96+ onChangeText: (event) => {
97+ props.onSearchQueryChange(event.nativeEvent.text);
98+ },
99+ onCancelButtonPress: () => {
100+ props.onSearchQueryChange("");
101+ },
102+ allowToolbarIntegration: true,
103+ },
104+ }}
105+ />
106+
107+ <Stack.Toolbar placement="left">
108+ <Stack.Toolbar.View hidesSharedBackground>
109+ <View
110+ style={{
111+ width: 128,
112+ height: 32,
113+ flexDirection: "row",
114+ alignItems: "center",
115+ gap: 8,
116+ }}
117+ >
118+ <RNText
119+ style={{
120+ fontFamily: "DMSans_700Bold",
121+ fontSize: 17,
122+ color: iconColor,
123+ letterSpacing: -0.4,
124+ }}
125+ >
126+ T3 Code
127+ </RNText>
128+ <View
129+ style={{
130+ backgroundColor: subtleColor,
131+ borderRadius: 99,
132+ paddingHorizontal: 8,
133+ paddingVertical: 3,
134+ }}
135+ >
136+ <RNText
137+ style={{
138+ fontFamily: "DMSans_700Bold",
139+ fontSize: 10,
140+ color: mutedColor,
141+ letterSpacing: 1.1,
142+ textTransform: "uppercase",
143+ }}
144+ >
145+ Alpha
146+ </RNText>
147+ </View>
148+ </View>
149+ </Stack.Toolbar.View>
150+ </Stack.Toolbar>
151+
152+ <Stack.Toolbar placement="right">
153+ <Stack.Toolbar.Menu
154+ accessibilityLabel="Filter and sort threads"
155+ icon={
156+ hasCustomListOptions
157+ ? "line.3.horizontal.decrease.circle.fill"
158+ : "line.3.horizontal.decrease.circle"
159+ }
160+ separateBackground
161+ title="Thread list options"
162+ >
163+ <Stack.Toolbar.Menu title="Environment">
164+ <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label>
165+ <Stack.Toolbar.MenuAction
166+ isOn={props.selectedEnvironmentId === null}
167+ onPress={() => props.onEnvironmentChange(null)}
168+ subtitle="Show threads from every environment"
169+ >
170+ <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label>
171+ </Stack.Toolbar.MenuAction>
172+ {props.environments.map((environment) => (
173+ <Stack.Toolbar.MenuAction
174+ key={environment.environmentId}
175+ isOn={props.selectedEnvironmentId === environment.environmentId}
176+ onPress={() => props.onEnvironmentChange(environment.environmentId)}
177+ >
178+ <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label>
179+ </Stack.Toolbar.MenuAction>
180+ ))}
181+ </Stack.Toolbar.Menu>
182+
183+ <Stack.Toolbar.Menu title="Sort projects">
184+ <Stack.Toolbar.Label>Sort projects</Stack.Toolbar.Label>
185+ {PROJECT_SORT_OPTIONS.map((option) => (
186+ <Stack.Toolbar.MenuAction
187+ key={option.value}
188+ isOn={props.projectSortOrder === option.value}
189+ onPress={() => props.onProjectSortOrderChange(option.value)}
190+ >
191+ <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>
192+ </Stack.Toolbar.MenuAction>
193+ ))}
194+ </Stack.Toolbar.Menu>
195+
196+ <Stack.Toolbar.Menu title="Sort threads">
197+ <Stack.Toolbar.Label>Sort threads</Stack.Toolbar.Label>
198+ {THREAD_SORT_OPTIONS.map((option) => (
199+ <Stack.Toolbar.MenuAction
200+ key={option.value}
201+ isOn={props.threadSortOrder === option.value}
202+ onPress={() => props.onThreadSortOrderChange(option.value)}
203+ >
204+ <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>
205+ </Stack.Toolbar.MenuAction>
206+ ))}
207+ </Stack.Toolbar.Menu>
208+
209+ <Stack.Toolbar.Menu title="Group projects">
210+ <Stack.Toolbar.Label>Group projects</Stack.Toolbar.Label>
211+ {PROJECT_GROUPING_OPTIONS.map((option) => (
212+ <Stack.Toolbar.MenuAction
213+ key={option.value}
214+ isOn={props.projectGroupingMode === option.value}
215+ onPress={() => props.onProjectGroupingModeChange(option.value)}
216+ subtitle={option.subtitle}
217+ >
218+ <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label>
219+ </Stack.Toolbar.MenuAction>
220+ ))}
221+ </Stack.Toolbar.Menu>
222+ </Stack.Toolbar.Menu>
223+
224+ <Stack.Toolbar.Button
225+ accessibilityLabel="Open settings"
226+ icon="gearshape"
227+ onPress={props.onOpenSettings}
228+ separateBackground
229+ />
230+ </Stack.Toolbar>
231+
232+ <Stack.Toolbar placement="bottom">
233+ <Stack.Toolbar.SearchBarSlot />
234+ <Stack.Toolbar.Spacer width={8} sharesBackground={false} />
235+ <Stack.Toolbar.Button
236+ accessibilityLabel="New task"
237+ icon="square.and.pencil"
238+ onPress={props.onStartNewTask}
239+ separateBackground
240+ />
241+ </Stack.Toolbar>
242+ </>
243+ );
244+}
apps/mobile/src/features/home/HomeScreen.tsx+287−139
@@ -2,12 +2,26 @@ import { · 1 unmodified line
22 type EnvironmentProject,
33 type EnvironmentThreadShell,
44 } from "@t3tools/client-runtime/state/shell";
5+import type {
6+ EnvironmentId,
7+ SidebarProjectGroupingMode,
8+ SidebarThreadSortOrder,
9+} from "@t3tools/contracts";
10+import * as Haptics from "expo-haptics";
511 import { SymbolView } from "expo-symbols";
6−import { useCallback, useMemo, useState } from "react";
7−import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
12+import { useCallback, useMemo, useRef, useState } from "react";
13+import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
14+import ReanimatedSwipeable, {
15+ type SwipeableMethods,
16+} from "react-native-gesture-handler/ReanimatedSwipeable";
17+import Animated, {
18+ Easing,
19+ LinearTransition,
20+ type ExitAnimationsValues,
21+ withDelay,
22+ withTiming,
23+} from "react-native-reanimated";
824 import { useSafeAreaInsets } from "react-native-safe-area-context";
9−import * as Arr from "effect/Array";
10−import * as Order from "effect/Order";
1125 import { useThemeColor } from "../../lib/useThemeColor";
1226
1327 import { AppText as Text } from "../../components/AppText";
@@ -15,9 +29,14 @@ import { EmptyState } from "../../components/EmptyState"; · 1 unmodified line
1529 import { ProjectFavicon } from "../../components/ProjectFavicon";
1630 import type { WorkspaceState } from "../../state/workspaceModel";
1731 import type { SavedRemoteConnection } from "../../lib/connection";
18−import { scopedProjectKey } from "../../lib/scopedEntities";
1932 import { relativeTime } from "../../lib/time";
2033 import { threadStatusTone } from "../threads/threadPresentation";
34+import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
35+import {
36+ THREAD_SWIPE_ACTIONS_WIDTH,
37+ THREAD_SWIPE_SPRING,
38+ ThreadSwipeActions,
39+} from "./thread-swipe-actions";
2140
2241 /* ─── Types ──────────────────────────────────────────────────────────── */
2342
@@ -27,26 +46,17 @@ interface HomeScreenProps { · 3 unmodified lines
2746 readonly catalogState: WorkspaceState;
2847 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
2948 readonly searchQuery: string;
49+ readonly selectedEnvironmentId: EnvironmentId | null;
50+ readonly projectSortOrder: HomeProjectSortOrder;
51+ readonly threadSortOrder: SidebarThreadSortOrder;
52+ readonly projectGroupingMode: SidebarProjectGroupingMode;
3053 readonly onAddConnection: () => void;
3154 readonly onOpenEnvironments: () => void;
3255 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
56+ readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
57+ readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
3358 }
3459
35−interface ProjectGroup {
36− readonly key: string;
37− readonly project: EnvironmentProject;
38− readonly threads: ReadonlyArray<EnvironmentThreadShell>;
39−}
40−
41−const projectGroupActivityOrder = Order.mapInput(
42− Order.Struct({
43− activityAt: Order.flip(Order.Number),
44− }),
45− (group: ProjectGroup) => ({
46− activityAt: new Date(group.threads[0]!.updatedAt ?? group.threads[0]!.createdAt).getTime(),
47− }),
48−);
49−
5060 /* ─── Status indicator colors ────────────────────────────────────────── */
5161
5262 function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } {
@@ -65,6 +75,33 @@ function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string · 12 unmodified lines
6575 }
6676
6777 const COLLAPSED_THREAD_LIMIT = 6;
78+const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic));
79+
80+function threadRowExit(values: ExitAnimationsValues) {
81+ "worklet";
82+
83+ return {
84+ initialValues: {
85+ height: values.currentHeight,
86+ opacity: 1,
87+ originX: values.currentOriginX,
88+ },
89+ animations: {
90+ height: withDelay(
91+ 90,
92+ withTiming(0, {
93+ duration: 170,
94+ easing: Easing.inOut(Easing.cubic),
95+ }),
96+ ),
97+ opacity: withDelay(80, withTiming(0, { duration: 100 })),
98+ originX: withTiming(values.currentOriginX - values.windowWidth, {
99+ duration: 190,
100+ easing: Easing.out(Easing.cubic),
101+ }),
102+ },
103+ };
104+}
68105
69106 function deriveEmptyState(props: {
70107 readonly catalogState: WorkspaceState;
@@ -133,6 +170,7 @@ function deriveEmptyState(props: { · 62 unmodified lines
133170
134171 function ProjectGroupLabel(props: {
135172 readonly project: EnvironmentProject;
173+ readonly title: string;
136174 readonly totalThreadCount: number;
137175 readonly isExpanded: boolean;
138176 readonly onToggleExpand: () => void;
@@ -152,7 +190,7 @@ function ProjectGroupLabel(props: { · 13 unmodified lines
152190 style={{ letterSpacing: 0.5 }}
153191 numberOfLines={1}
154192 >
155− {props.project.title}
193+ {props.title}
156194 </Text>
157195
158196 {hiddenCount > 0 ? (
@@ -175,95 +213,172 @@ function ThreadRow(props: { · 16 unmodified lines
175213 readonly thread: EnvironmentThreadShell;
176214 readonly environmentLabel: string | null;
177215 readonly onPress: () => void;
216+ readonly onArchive: () => void;
217+ readonly onDelete: () => void;
218+ readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
219+ readonly onSwipeableClose: (methods: SwipeableMethods) => void;
178220 readonly isLast: boolean;
179221 }) {
222+ const swipeableRef = useRef<SwipeableMethods | null>(null);
223+ const fullSwipeArmedRef = useRef(false);
224+ const { width: windowWidth } = useWindowDimensions();
180225 const separatorColor = useThemeColor("--color-separator");
181226 const iconSubtleColor = useThemeColor("--color-icon-subtle");
227+ const cardColor = useThemeColor("--color-card");
228+ const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58);
182229 const { bg, fg } = statusColors(props.thread);
183230 const tone = threadStatusTone(props.thread);
184− const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt);
231+ const timestamp = relativeTime(
232+ props.thread.latestUserMessageAt ?? props.thread.updatedAt ?? props.thread.createdAt,
233+ );
185234 const branch = props.thread.branch;
186235 const subtitleParts = [props.environmentLabel, branch].filter((part): part is string =>
187236 Boolean(part),
188237 );
238+ const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
239+ if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") {
240+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
241+ }
242+ fullSwipeArmedRef.current = armed;
243+ }, []);
189244
190245 return (
191− <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}>
192− <View
193− style={{
194− flexDirection: "row",
195− paddingLeft: 16,
196− paddingRight: 16,
197− paddingVertical: 10,
198− gap: 12,
199− borderBottomWidth: props.isLast ? 0 : 1,
200− borderBottomColor: separatorColor,
246+ <ReanimatedSwipeable
247+ ref={swipeableRef}
248+ animationOptions={THREAD_SWIPE_SPRING}
249+ childrenContainerStyle={{ backgroundColor: cardColor }}
250+ containerStyle={{ backgroundColor: cardColor }}
251+ dragOffsetFromRightEdge={8}
252+ enableTrackpadTwoFingerGesture
253+ friction={1}
254+ onSwipeableClose={() => {
255+ fullSwipeArmedRef.current = false;
256+ if (swipeableRef.current) {
257+ props.onSwipeableClose(swipeableRef.current);
258+ }
259+ }}
260+ onSwipeableOpenStartDrag={() => {
261+ if (swipeableRef.current) {
262+ props.onSwipeableWillOpen(swipeableRef.current);
263+ }
264+ }}
265+ onSwipeableWillOpen={() => {
266+ const methods = swipeableRef.current;
267+ if (!methods) {
268+ return;
269+ }
270+
271+ props.onSwipeableWillOpen(methods);
272+ if (fullSwipeArmedRef.current) {
273+ fullSwipeArmedRef.current = false;
274+ methods.close();
275+ props.onDelete();
276+ }
277+ }}
278+ overshootFriction={1}
279+ overshootRight
280+ renderRightActions={(_progress, translation, methods) => (
281+ <ThreadSwipeActions
282+ backgroundColor={cardColor}
283+ fullSwipeThreshold={fullSwipeThreshold}
284+ onDelete={props.onDelete}
285+ onFullSwipeArmedChange={handleFullSwipeArmedChange}
286+ primaryAction={{
287+ accessibilityLabel: `Archive ${props.thread.title}`,
288+ icon: "archivebox",
289+ label: "Archive",
290+ onPress: props.onArchive,
291+ }}
292+ swipeableMethods={methods}
293+ threadTitle={props.thread.title}
294+ translation={translation}
295+ />
296+ )}
297+ rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42}
298+ >
299+ <Pressable
300+ accessibilityHint="Swipe left for archive and delete actions"
301+ accessibilityLabel={props.thread.title}
302+ accessibilityRole="button"
303+ className="bg-card"
304+ onPress={() => {
305+ swipeableRef.current?.close();
306+ props.onPress();
201307 }}
308+ style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
202309 >
203− {/* Git status indicator */}
204310 <View
205311 style={{
206− width: 30,
207− height: 30,
208− borderRadius: 9,
209− backgroundColor: bg,
210− alignItems: "center",
211− justifyContent: "center",
212− marginTop: 2,
312+ flexDirection: "row",
313+ paddingLeft: 16,
314+ paddingRight: 16,
315+ paddingVertical: 10,
316+ gap: 12,
317+ borderBottomWidth: props.isLast ? 0 : 1,
318+ borderBottomColor: separatorColor,
213319 }}
214320 >
215− <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" />
216− </View>
217−
218− {/* Content */}
219− <View style={{ flex: 1, gap: 3 }}>
220− {/* Title + Status + Timestamp */}
221− <View className="flex-row items-center justify-between gap-2">
222− <Text
223− className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground"
224− numberOfLines={1}
225− >
226− {props.thread.title}
227− </Text>
228− <View className="flex-row items-center gap-2">
229− <View
230− className={tone.pillClassName}
231− style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }}
232− >
233− <Text className={`text-[10px] font-t3-bold ${tone.textClassName}`}>
234− {tone.label}
235− </Text>
236− </View>
237− <Text
238− className="text-[12px] text-foreground-tertiary"
239− style={{ fontVariant: ["tabular-nums"] }}
240− >
241− {timestamp}
242− </Text>
243− </View>
321+ <View
322+ style={{
323+ width: 30,
324+ height: 30,
325+ borderRadius: 9,
326+ backgroundColor: bg,
327+ alignItems: "center",
328+ justifyContent: "center",
329+ marginTop: 2,
330+ }}
331+ >
332+ <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" />
244333 </View>
245334
246− {/* Environment + branch */}
247− {subtitleParts.length > 0 ? (
248− <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}>
249− <SymbolView
250− name="arrow.triangle.branch"
251− size={10}
252− tintColor={iconSubtleColor}
253− type="monochrome"
254− />
335+ <View style={{ flex: 1, gap: 3 }}>
336+ <View className="flex-row items-center justify-between gap-2">
255337 <Text
256− className="text-[11px] text-foreground-tertiary"
338+ className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground"
257339 numberOfLines={1}
258− style={{ fontFamily: "monospace" }}
259340 >
260− {subtitleParts.join(" · ")}
341+ {props.thread.title}
261342 </Text>
343+ <View className="flex-row items-center gap-2">
344+ <View
345+ className={tone.pillClassName}
346+ style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }}
347+ >
348+ <Text className={`text-[10px] font-t3-bold ${tone.textClassName}`}>
349+ {tone.label}
350+ </Text>
351+ </View>
352+ <Text
353+ className="text-[12px] text-foreground-tertiary"
354+ style={{ fontVariant: ["tabular-nums"] }}
355+ >
356+ {timestamp}
357+ </Text>
358+ </View>
262359 </View>
263− ) : null}
360+
361+ {subtitleParts.length > 0 ? (
362+ <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}>
363+ <SymbolView
364+ name="arrow.triangle.branch"
365+ size={10}
366+ tintColor={iconSubtleColor}
367+ type="monochrome"
368+ />
369+ <Text
370+ className="text-[11px] text-foreground-tertiary"
371+ numberOfLines={1}
372+ style={{ fontFamily: "monospace" }}
373+ >
374+ {subtitleParts.join(" · ")}
375+ </Text>
376+ </View>
377+ ) : null}
378+ </View>
264379 </View>
265− </View>
266− </Pressable>
380+ </Pressable>
381+ </ReanimatedSwipeable>
267382 );
268383 }
269384
@@ -323,6 +438,7 @@ function StaleCatalogStatusPill(props: { · 53 unmodified lines
323438
324439 export function HomeScreen(props: HomeScreenProps) {
325440 const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set());
441+ const openSwipeableRef = useRef<SwipeableMethods | null>(null);
326442 const insets = useSafeAreaInsets();
327443 const accentColor = useThemeColor("--color-icon-muted");
328444
@@ -335,51 +451,50 @@ export function HomeScreen(props: HomeScreenProps) { · 6 unmodified lines
335451 });
336452 }, []);
337453
338− /* Build project title lookup for search */
339− const projectTitleByKey = useMemo(() => {
340− const map = new Map<string, string>();
341− for (const p of props.projects) {
342− map.set(scopedProjectKey(p.environmentId, p.id), p.title);
343− }
344− return map;
345− }, [props.projects]);
346−
347− /* Filter threads by search query */
348− const filteredThreads = useMemo(() => {
349− const q = props.searchQuery.trim().toLowerCase();
350− if (!q) return props.threads;
351− return props.threads.filter((t) => {
352− if (t.title.toLowerCase().includes(q)) return true;
353− const key = scopedProjectKey(t.environmentId, t.projectId);
354− return projectTitleByKey.get(key)?.toLowerCase().includes(q) ?? false;
355− });
356− }, [props.threads, props.searchQuery, projectTitleByKey]);
357−
358− /* Group filtered threads by project */
359− const projectGroups = useMemo<ReadonlyArray<ProjectGroup>>(() => {
360− const byProject = new Map<string, EnvironmentThreadShell[]>();
361− for (const thread of filteredThreads) {
362− const key = scopedProjectKey(thread.environmentId, thread.projectId);
363− const existing = byProject.get(key);
364− if (existing) existing.push(thread);
365− else byProject.set(key, [thread]);
454+ const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
455+ if (openSwipeableRef.current !== methods) {
456+ openSwipeableRef.current?.close();
457+ openSwipeableRef.current = methods;
366458 }
459+ }, []);
367460
368− const groups: ProjectGroup[] = [];
369− for (const project of props.projects) {
370− const key = scopedProjectKey(project.environmentId, project.id);
371− const threads = byProject.get(key);
372− if (threads && threads.length > 0) {
373− groups.push({ key, project, threads });
374− }
461+ const handleSwipeableClose = useCallback((methods: SwipeableMethods) => {
462+ if (openSwipeableRef.current === methods) {
463+ openSwipeableRef.current = null;
375464 }
465+ }, []);
376466
377− return Arr.sort(groups, projectGroupActivityOrder);
378− }, [props.projects, filteredThreads]);
467+ const projectGroups = useMemo(
468+ () =>
469+ buildHomeThreadGroups({
470+ projects: props.projects,
471+ threads: props.threads,
472+ environmentId: props.selectedEnvironmentId,
473+ searchQuery: props.searchQuery,
474+ projectSortOrder: props.projectSortOrder,
475+ threadSortOrder: props.threadSortOrder,
476+ projectGroupingMode: props.projectGroupingMode,
477+ }),
478+ [
479+ props.projectGroupingMode,
480+ props.projects,
481+ props.projectSortOrder,
482+ props.searchQuery,
483+ props.selectedEnvironmentId,
484+ props.threadSortOrder,
485+ props.threads,
486+ ],
487+ );
379488
380489 /* Empty states */
381− const hasAnyThreads = props.threads.length > 0;
382− const hasResults = filteredThreads.length > 0;
490+ const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null);
491+ const hasResults = projectGroups.length > 0;
492+ const selectedEnvironmentLabel =
493+ props.selectedEnvironmentId === null
494+ ? null
495+ : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ??
496+ "this environment");
497+ const hasSearchQuery = props.searchQuery.trim().length > 0;
383498 const shouldShowConnectionStatus =
384499 props.catalogState.networkStatus === "offline" ||
385500 props.catalogState.hasConnectingEnvironment ||
@@ -396,6 +511,7 @@ export function HomeScreen(props: HomeScreenProps) { · 10 unmodified lines
396511 showsVerticalScrollIndicator={false}
397512 keyboardDismissMode="on-drag"
398513 keyboardShouldPersistTaps="handled"
514+ onScrollBeginDrag={() => openSwipeableRef.current?.close()}
399515 className="flex-1"
400516 contentContainerStyle={{
401517 paddingHorizontal: 16,
@@ -418,8 +534,18 @@ export function HomeScreen(props: HomeScreenProps) { · 16 unmodified lines
418534 </View>
419535 ) : null}
420536 </View>
421− ) : !hasResults ? (
537+ ) : !hasResults && hasSearchQuery ? (
422538 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
539+ ) : !hasResults && selectedEnvironmentLabel ? (
540+ <EmptyState
541+ title={`No threads in ${selectedEnvironmentLabel}`}
542+ detail="Choose another environment or create a new task."
543+ />
544+ ) : !hasResults ? (
545+ <EmptyState
546+ title="No threads yet"
547+ detail="Create a task to start a new coding session."
548+ />
423549 ) : (
424550 projectGroups.map((group) => {
425551 const isExpanded = expandedProjects.has(group.key);
@@ -428,30 +554,52 @@ export function HomeScreen(props: HomeScreenProps) { · 2 unmodified lines
428554 : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
429555
430556 return (
431− <View key={group.key}>
557+ <Animated.View
558+ key={group.key}
559+ collapsable={false}
560+ exiting={threadRowExit}
561+ layout={THREAD_LAYOUT_TRANSITION}
562+ style={{ overflow: "hidden" }}
563+ >
432564 <ProjectGroupLabel
433− project={group.project}
434− totalThreadCount={group.threads.length}
435565 isExpanded={isExpanded}
436566 onToggleExpand={() => toggleExpanded(group.key)}
567+ project={group.representative}
568+ title={group.title}
569+ totalThreadCount={group.threads.length}
437570 />
438571 <View
439572 className="overflow-hidden rounded-[20px] bg-card"
440573 style={{ borderCurve: "continuous" }}
441574 >
442− {visibleThreads.map((thread, i) => (
443− <ThreadRow
444− key={`${thread.environmentId}:${thread.id}`}
445− thread={thread}
446− environmentLabel={
447− props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null
448− }
449− onPress={() => props.onSelectThread(thread)}
450− isLast={i === visibleThreads.length - 1}
451− />
452− ))}
575+ {visibleThreads.map((thread, i) => {
576+ const threadKey = `${thread.environmentId}:${thread.id}`;
577+ return (
578+ <Animated.View
579+ key={threadKey}
580+ collapsable={false}
581+ exiting={threadRowExit}
582+ layout={THREAD_LAYOUT_TRANSITION}
583+ style={{ overflow: "hidden" }}
584+ >
585+ <ThreadRow
586+ thread={thread}
587+ environmentLabel={
588+ props.savedConnectionsById[thread.environmentId]?.environmentLabel ??
589+ null
590+ }
591+ isLast={i === visibleThreads.length - 1}
592+ onArchive={() => props.onArchiveThread(thread)}
593+ onDelete={() => props.onDeleteThread(thread)}
594+ onPress={() => props.onSelectThread(thread)}
595+ onSwipeableClose={handleSwipeableClose}
596+ onSwipeableWillOpen={handleSwipeableWillOpen}
597+ />
598+ </Animated.View>
599+ );
600+ })}
453601 </View>
454− </View>
602+ </Animated.View>
455603 );
456604 })
457605 )}
apps/mobile/src/features/home/homeThreadList.test.ts+223−0
@@ -0,0 +1,223 @@
1+import type {
2+ EnvironmentProject,
3+ EnvironmentThreadShell,
4+} from "@t3tools/client-runtime/state/shell";
5+import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6+import { describe, expect, it } from "vite-plus/test";
7+
8+import { buildHomeThreadGroups } from "./homeThreadList";
9+
10+function makeProject(
11+ input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
12+): EnvironmentProject {
13+ return {
14+ workspaceRoot: `/workspaces/${input.id}`,
15+ repositoryIdentity: null,
16+ defaultModelSelection: null,
17+ scripts: [],
18+ createdAt: "2026-06-01T00:00:00.000Z",
19+ updatedAt: "2026-06-01T00:00:00.000Z",
20+ ...input,
21+ };
22+}
23+
24+function makeThread(
25+ input: Partial<EnvironmentThreadShell> &
26+ Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">,
27+): EnvironmentThreadShell {
28+ return {
29+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
30+ runtimeMode: "full-access",
31+ interactionMode: "default",
32+ branch: null,
33+ worktreePath: null,
34+ latestTurn: null,
35+ createdAt: "2026-06-01T00:00:00.000Z",
36+ updatedAt: "2026-06-01T00:00:00.000Z",
37+ archivedAt: null,
38+ session: null,
39+ latestUserMessageAt: null,
40+ hasPendingApprovals: false,
41+ hasPendingUserInput: false,
42+ hasActionableProposedPlan: false,
43+ ...input,
44+ };
45+}
46+
47+function buildGroups(
48+ projects: ReadonlyArray<EnvironmentProject>,
49+ threads: ReadonlyArray<EnvironmentThreadShell>,
50+ overrides: Partial<Parameters<typeof buildHomeThreadGroups>[0]> = {},
51+) {
52+ return buildHomeThreadGroups({
53+ projects,
54+ threads,
55+ environmentId: null,
56+ searchQuery: "",
57+ projectSortOrder: "updated_at",
58+ threadSortOrder: "updated_at",
59+ projectGroupingMode: "repository",
60+ ...overrides,
61+ });
62+}
63+
64+describe("buildHomeThreadGroups", () => {
65+ it("sorts the newest thread first regardless of snapshot order", () => {
66+ const environmentId = EnvironmentId.make("environment-1");
67+ const project = makeProject({
68+ environmentId,
69+ id: ProjectId.make("project-1"),
70+ title: "T3 Code",
71+ });
72+ const threads = [
73+ makeThread({
74+ environmentId,
75+ id: ThreadId.make("thread-old"),
76+ projectId: project.id,
77+ title: "Older thread",
78+ updatedAt: "2026-06-02T00:00:00.000Z",
79+ }),
80+ makeThread({
81+ environmentId,
82+ id: ThreadId.make("thread-new"),
83+ projectId: project.id,
84+ title: "Newer thread",
85+ updatedAt: "2026-06-03T00:00:00.000Z",
86+ }),
87+ ];
88+
89+ expect(buildGroups([project], threads)[0]?.threads.map((thread) => thread.id)).toEqual([
90+ "thread-new",
91+ "thread-old",
92+ ]);
93+ });
94+
95+ it("supports independent project and thread creation-time sorting", () => {
96+ const environmentId = EnvironmentId.make("environment-1");
97+ const olderProject = makeProject({
98+ environmentId,
99+ id: ProjectId.make("project-older"),
100+ title: "Older project",
101+ });
102+ const newerProject = makeProject({
103+ environmentId,
104+ id: ProjectId.make("project-newer"),
105+ title: "Newer project",
106+ });
107+ const threads = [
108+ makeThread({
109+ environmentId,
110+ id: ThreadId.make("old-created"),
111+ projectId: olderProject.id,
112+ title: "Updated recently",
113+ createdAt: "2026-06-01T00:00:00.000Z",
114+ updatedAt: "2026-06-05T00:00:00.000Z",
115+ }),
116+ makeThread({
117+ environmentId,
118+ id: ThreadId.make("new-created"),
119+ projectId: olderProject.id,
120+ title: "Created recently",
121+ createdAt: "2026-06-04T00:00:00.000Z",
122+ updatedAt: "2026-06-04T00:00:00.000Z",
123+ }),
124+ makeThread({
125+ environmentId,
126+ id: ThreadId.make("newest-project-thread"),
127+ projectId: newerProject.id,
128+ title: "Newest project",
129+ createdAt: "2026-06-06T00:00:00.000Z",
130+ }),
131+ ];
132+
133+ const groups = buildGroups([olderProject, newerProject], threads, {
134+ projectSortOrder: "created_at",
135+ threadSortOrder: "created_at",
136+ projectGroupingMode: "separate",
137+ });
138+
139+ expect(groups.map((group) => group.representative.id)).toEqual([
140+ "project-newer",
141+ "project-older",
142+ ]);
143+ expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["new-created", "old-created"]);
144+ });
145+
146+ it("filters both projects and threads to one environment", () => {
147+ const localEnvironmentId = EnvironmentId.make("environment-local");
148+ const remoteEnvironmentId = EnvironmentId.make("environment-remote");
149+ const projects = [
150+ makeProject({
151+ environmentId: localEnvironmentId,
152+ id: ProjectId.make("project-local"),
153+ title: "Local",
154+ }),
155+ makeProject({
156+ environmentId: remoteEnvironmentId,
157+ id: ProjectId.make("project-remote"),
158+ title: "Remote",
159+ }),
160+ ];
161+ const threads = projects.map((project) =>
162+ makeThread({
163+ environmentId: project.environmentId,
164+ id: ThreadId.make(`thread-${project.id}`),
165+ projectId: project.id,
166+ title: project.title,
167+ }),
168+ );
169+
170+ const groups = buildGroups(projects, threads, { environmentId: remoteEnvironmentId });
171+
172+ expect(groups).toHaveLength(1);
173+ expect(groups[0]?.representative.environmentId).toBe(remoteEnvironmentId);
174+ expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]);
175+ });
176+
177+ it("matches web repository, repository-path, and separate grouping modes", () => {
178+ const environmentId = EnvironmentId.make("environment-1");
179+ const repositoryIdentity = {
180+ canonicalKey: "github.com/t3tools/t3code",
181+ locator: {
182+ source: "git-remote" as const,
183+ remoteName: "origin",
184+ remoteUrl: "git@github.com:t3tools/t3code.git",
185+ },
186+ provider: "github",
187+ owner: "t3tools",
188+ name: "t3code",
189+ displayName: "T3 Code",
190+ rootPath: "/workspaces/t3code",
191+ };
192+ const projects = [
193+ makeProject({
194+ environmentId,
195+ id: ProjectId.make("project-web"),
196+ title: "Web",
197+ workspaceRoot: "/workspaces/t3code/apps/web",
198+ repositoryIdentity,
199+ }),
200+ makeProject({
201+ environmentId,
202+ id: ProjectId.make("project-mobile"),
203+ title: "Mobile",
204+ workspaceRoot: "/workspaces/t3code/apps/mobile",
205+ repositoryIdentity,
206+ }),
207+ ];
208+ const threads = projects.map((project) =>
209+ makeThread({
210+ environmentId,
211+ id: ThreadId.make(`thread-${project.id}`),
212+ projectId: project.id,
213+ title: project.title,
214+ }),
215+ );
216+
217+ expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1);
218+ expect(buildGroups(projects, threads, { projectGroupingMode: "repository_path" })).toHaveLength(
219+ 2,
220+ );
221+ expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2);
222+ });
223+});
apps/mobile/src/features/home/homeThreadList.ts+140−0
@@ -0,0 +1,140 @@
1+import {
2+ deriveLogicalProjectKey,
3+ deriveProjectGroupLabel,
4+} from "@t3tools/client-runtime/state/project-grouping";
5+import type {
6+ EnvironmentProject,
7+ EnvironmentThreadShell,
8+} from "@t3tools/client-runtime/state/shell";
9+import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort";
10+import type {
11+ EnvironmentId,
12+ SidebarProjectGroupingMode,
13+ SidebarProjectSortOrder,
14+ SidebarThreadSortOrder,
15+} from "@t3tools/contracts";
16+import * as Arr from "effect/Array";
17+import * as Order from "effect/Order";
18+
19+import { scopedProjectKey } from "../../lib/scopedEntities";
20+
21+export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
22+
23+export interface HomeThreadGroup {
24+ readonly key: string;
25+ readonly title: string;
26+ readonly representative: EnvironmentProject;
27+ readonly projects: ReadonlyArray<EnvironmentProject>;
28+ readonly threads: ReadonlyArray<EnvironmentThreadShell>;
29+}
30+
31+interface MutableHomeThreadGroup {
32+ readonly key: string;
33+ readonly projects: EnvironmentProject[];
34+ readonly threads: EnvironmentThreadShell[];
35+}
36+
37+function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number {
38+ return group.threads.reduce(
39+ (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)),
40+ Number.NEGATIVE_INFINITY,
41+ );
42+}
43+
44+export function buildHomeThreadGroups(input: {
45+ readonly projects: ReadonlyArray<EnvironmentProject>;
46+ readonly threads: ReadonlyArray<EnvironmentThreadShell>;
47+ readonly environmentId: EnvironmentId | null;
48+ readonly searchQuery: string;
49+ readonly projectSortOrder: HomeProjectSortOrder;
50+ readonly threadSortOrder: SidebarThreadSortOrder;
51+ readonly projectGroupingMode: SidebarProjectGroupingMode;
52+}): ReadonlyArray<HomeThreadGroup> {
53+ const groups = new Map<string, MutableHomeThreadGroup>();
54+ const groupKeyByProjectKey = new Map<string, string>();
55+
56+ for (const project of input.projects) {
57+ if (input.environmentId !== null && project.environmentId !== input.environmentId) {
58+ continue;
59+ }
60+
61+ const groupKey = deriveLogicalProjectKey(project, {
62+ groupingMode: input.projectGroupingMode,
63+ });
64+ const physicalKey = scopedProjectKey(project.environmentId, project.id);
65+ groupKeyByProjectKey.set(physicalKey, groupKey);
66+
67+ const existing = groups.get(groupKey);
68+ if (existing) {
69+ existing.projects.push(project);
70+ } else {
71+ groups.set(groupKey, { key: groupKey, projects: [project], threads: [] });
72+ }
73+ }
74+
75+ for (const thread of input.threads) {
76+ if (thread.archivedAt !== null) {
77+ continue;
78+ }
79+ if (input.environmentId !== null && thread.environmentId !== input.environmentId) {
80+ continue;
81+ }
82+
83+ const physicalKey = scopedProjectKey(thread.environmentId, thread.projectId);
84+ const groupKey = groupKeyByProjectKey.get(physicalKey);
85+ if (!groupKey) {
86+ continue;
87+ }
88+ groups.get(groupKey)?.threads.push(thread);
89+ }
90+
91+ const query = input.searchQuery.trim().toLocaleLowerCase();
92+ const result: HomeThreadGroup[] = [];
93+
94+ for (const group of groups.values()) {
95+ const representative = group.projects[0];
96+ if (!representative || group.threads.length === 0) {
97+ continue;
98+ }
99+
100+ const title =
101+ group.projects.length > 1
102+ ? deriveProjectGroupLabel({ representative, members: group.projects })
103+ : representative.title;
104+ const groupMatches =
105+ query.length === 0 ||
106+ title.toLocaleLowerCase().includes(query) ||
107+ group.projects.some((project) => project.title.toLocaleLowerCase().includes(query));
108+ const matchingThreads = groupMatches
109+ ? group.threads
110+ : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));
111+
112+ if (matchingThreads.length === 0) {
113+ continue;
114+ }
115+
116+ result.push({
117+ key: group.key,
118+ title,
119+ representative,
120+ projects: group.projects,
121+ threads: sortThreads(matchingThreads, input.threadSortOrder),
122+ });
123+ }
124+
125+ return Arr.sort(
126+ result,
127+ Order.mapInput(
128+ Order.Struct({
129+ timestamp: Order.flip(Order.Number),
130+ title: Order.String,
131+ key: Order.String,
132+ }),
133+ (group: HomeThreadGroup) => ({
134+ timestamp: groupSortTimestamp(group, input.projectSortOrder),
135+ title: group.title,
136+ key: group.key,
137+ }),
138+ ),
139+ );
140+}
apps/mobile/src/features/home/thread-swipe-actions.tsx+238−0
@@ -0,0 +1,238 @@
1+import { SymbolView } from "expo-symbols";
2+import type { ComponentProps } from "react";
3+import type { ColorValue } from "react-native";
4+import { Pressable, View } from "react-native";
5+import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
6+import Animated, {
7+ Extrapolation,
8+ interpolate,
9+ runOnJS,
10+ type SharedValue,
11+ useAnimatedReaction,
12+ useAnimatedStyle,
13+} from "react-native-reanimated";
14+
15+import { AppText as Text } from "../../components/AppText";
16+
17+const ACTION_ITEM_WIDTH = 50;
18+const ACTION_CIRCLE_SIZE = 36;
19+const ACTION_ICON_SIZE = 15;
20+
21+export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2;
22+export const THREAD_SWIPE_SPRING = {
23+ damping: 26,
24+ mass: 0.7,
25+ overshootClamping: true,
26+ stiffness: 330,
27+};
28+
29+function SwipeActionButton(props: {
30+ readonly accessibilityLabel: string;
31+ readonly backgroundColor: string;
32+ readonly entryRange: readonly [number, number];
33+ readonly fullSwipeThreshold: number;
34+ readonly icon: ComponentProps<typeof SymbolView>["name"];
35+ readonly label: string;
36+ readonly onPress: () => void;
37+ readonly stretchesOnFullSwipe: boolean;
38+ readonly translation: SharedValue<number>;
39+}) {
40+ const actionStyle = useAnimatedStyle(() => {
41+ const reveal = Math.max(-props.translation.value, 0);
42+ const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP);
43+ const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0);
44+ const fullSwipeProgress = interpolate(
45+ reveal,
46+ [THREAD_SWIPE_ACTIONS_WIDTH, props.fullSwipeThreshold + 20],
47+ [0, 1],
48+ Extrapolation.CLAMP,
49+ );
50+
51+ return {
52+ opacity: props.stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress),
53+ transform: [
54+ {
55+ translateX:
56+ interpolate(entryProgress, [0, 1], [22, 0]) -
57+ (props.stretchesOnFullSwipe ? 0 : stretch),
58+ },
59+ { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) },
60+ ],
61+ };
62+ });
63+ const circleStyle = useAnimatedStyle(() => {
64+ const reveal = Math.max(-props.translation.value, 0);
65+ const stretch = props.stretchesOnFullSwipe
66+ ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0)
67+ : 0;
68+
69+ return {
70+ transform: [{ translateX: -stretch }],
71+ width: ACTION_CIRCLE_SIZE + stretch,
72+ };
73+ });
74+ const iconStyle = useAnimatedStyle(() => {
75+ const reveal = Math.max(-props.translation.value, 0);
76+ const stretch = props.stretchesOnFullSwipe
77+ ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0)
78+ : 0;
79+ const armedProgress = interpolate(
80+ reveal,
81+ [props.fullSwipeThreshold, props.fullSwipeThreshold + 20],
82+ [0, 1],
83+ Extrapolation.CLAMP,
84+ );
85+
86+ return {
87+ transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.5) }],
88+ };
89+ });
90+ const labelStyle = useAnimatedStyle(() => {
91+ if (!props.stretchesOnFullSwipe) {
92+ return { opacity: 1 };
93+ }
94+
95+ const reveal = Math.max(-props.translation.value, 0);
96+ const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0);
97+ return {
98+ opacity: interpolate(
99+ reveal,
100+ [props.fullSwipeThreshold - 24, props.fullSwipeThreshold],
101+ [1, 0],
102+ Extrapolation.CLAMP,
103+ ),
104+ transform: [{ translateX: -stretch * 0.5 }],
105+ };
106+ });
107+
108+ return (
109+ <Animated.View
110+ style={[
111+ {
112+ alignItems: "center",
113+ height: "100%",
114+ justifyContent: "center",
115+ width: ACTION_ITEM_WIDTH,
116+ zIndex: props.stretchesOnFullSwipe ? 2 : 1,
117+ },
118+ actionStyle,
119+ ]}
120+ >
121+ <Pressable
122+ accessibilityLabel={props.accessibilityLabel}
123+ accessibilityRole="button"
124+ onPress={props.onPress}
125+ style={({ pressed }) => ({
126+ alignItems: "center",
127+ height: "100%",
128+ justifyContent: "center",
129+ opacity: pressed ? 0.72 : 1,
130+ width: "100%",
131+ })}
132+ >
133+ <View style={{ height: ACTION_CIRCLE_SIZE, width: ACTION_CIRCLE_SIZE }}>
134+ <Animated.View
135+ style={[
136+ {
137+ backgroundColor: props.backgroundColor,
138+ borderRadius: 999,
139+ height: ACTION_CIRCLE_SIZE,
140+ left: 0,
141+ position: "absolute",
142+ top: 0,
143+ },
144+ circleStyle,
145+ ]}
146+ />
147+ <Animated.View
148+ style={[
149+ {
150+ alignItems: "center",
151+ height: ACTION_CIRCLE_SIZE,
152+ justifyContent: "center",
153+ left: 0,
154+ position: "absolute",
155+ top: 0,
156+ width: ACTION_CIRCLE_SIZE,
157+ },
158+ iconStyle,
159+ ]}
160+ >
161+ <SymbolView
162+ name={props.icon}
163+ size={ACTION_ICON_SIZE}
164+ tintColor="#ffffff"
165+ type="monochrome"
166+ />
167+ </Animated.View>
168+ </View>
169+ <Animated.View style={[{ paddingTop: 2 }, labelStyle]}>
170+ <Text className="text-[10px] font-t3-medium text-foreground-muted">{props.label}</Text>
171+ </Animated.View>
172+ </Pressable>
173+ </Animated.View>
174+ );
175+}
176+
177+export function ThreadSwipeActions(props: {
178+ readonly backgroundColor: ColorValue;
179+ readonly fullSwipeThreshold: number;
180+ readonly onDelete: () => void;
181+ readonly onFullSwipeArmedChange: (armed: boolean) => void;
182+ readonly primaryAction: {
183+ readonly accessibilityLabel: string;
184+ readonly icon: ComponentProps<typeof SymbolView>["name"];
185+ readonly label: string;
186+ readonly onPress: () => void;
187+ };
188+ readonly swipeableMethods: SwipeableMethods;
189+ readonly threadTitle: string;
190+ readonly translation: SharedValue<number>;
191+}) {
192+ useAnimatedReaction(
193+ () => -props.translation.value >= props.fullSwipeThreshold,
194+ (armed, previous) => {
195+ if (armed !== previous) {
196+ runOnJS(props.onFullSwipeArmedChange)(armed);
197+ }
198+ },
199+ [props.fullSwipeThreshold, props.onFullSwipeArmedChange],
200+ );
201+
202+ return (
203+ <View
204+ style={{
205+ backgroundColor: props.backgroundColor,
206+ flexDirection: "row",
207+ height: "100%",
208+ width: THREAD_SWIPE_ACTIONS_WIDTH,
209+ }}
210+ >
211+ <SwipeActionButton
212+ accessibilityLabel={props.primaryAction.accessibilityLabel}
213+ backgroundColor="#007aff"
214+ entryRange={[ACTION_ITEM_WIDTH * 0.55, THREAD_SWIPE_ACTIONS_WIDTH * 0.85]}
215+ fullSwipeThreshold={props.fullSwipeThreshold}
216+ icon={props.primaryAction.icon}
217+ label={props.primaryAction.label}
218+ onPress={props.primaryAction.onPress}
219+ stretchesOnFullSwipe={false}
220+ translation={props.translation}
221+ />
222+ <SwipeActionButton
223+ accessibilityLabel={`Delete ${props.threadTitle}`}
224+ backgroundColor="#ff2d55"
225+ entryRange={[8, ACTION_ITEM_WIDTH * 0.72]}
226+ fullSwipeThreshold={props.fullSwipeThreshold}
227+ icon="trash"
228+ label="Delete"
229+ onPress={() => {
230+ props.swipeableMethods.close();
231+ props.onDelete();
232+ }}
233+ stretchesOnFullSwipe
234+ translation={props.translation}
235+ />
236+ </View>
237+ );
238+}
apps/mobile/src/features/home/useThreadListActions.ts+142−0
@@ -0,0 +1,142 @@
1+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2+import * as Cause from "effect/Cause";
3+import * as Haptics from "expo-haptics";
4+import { useCallback, useRef } from "react";
5+import { Alert } from "react-native";
6+
7+import { scopedThreadKey } from "../../lib/scopedEntities";
8+import { threadEnvironment } from "../../state/threads";
9+import { useAtomCommand } from "../../state/use-atom-command";
10+
11+type ThreadListAction = "archive" | "unarchive" | "delete";
12+
13+function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
14+ const error = Cause.squash(cause);
15+ if (error instanceof Error && error.message.trim().length > 0) {
16+ return error.message;
17+ }
18+ const verb =
19+ action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted";
20+ return `The thread could not be ${verb}.`;
21+}
22+
23+function selectionHaptic(): void {
24+ if (process.env.EXPO_OS === "ios") {
25+ void Haptics.selectionAsync();
26+ }
27+}
28+
29+function actionFailureTitle(action: ThreadListAction): string {
30+ if (action === "archive") return "Could not archive thread";
31+ if (action === "unarchive") return "Could not unarchive thread";
32+ return "Could not delete thread";
33+}
34+
35+function useThreadActionExecutor(
36+ onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
37+) {
38+ const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false });
39+ const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false });
40+ const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false });
41+ const inFlightThreadKeys = useRef(new Set<string>());
42+
43+ const executeAction = useCallback(
44+ async (action: ThreadListAction, thread: EnvironmentThreadShell) => {
45+ const key = scopedThreadKey(thread.environmentId, thread.id);
46+ if (inFlightThreadKeys.current.has(key)) {
47+ return;
48+ }
49+
50+ inFlightThreadKeys.current.add(key);
51+ selectionHaptic();
52+ try {
53+ const mutation =
54+ action === "archive"
55+ ? archiveMutation
56+ : action === "unarchive"
57+ ? unarchiveMutation
58+ : deleteMutation;
59+ const result = await mutation({
60+ environmentId: thread.environmentId,
61+ input: { threadId: thread.id },
62+ });
63+ if (result._tag === "Failure") {
64+ Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause));
65+ return;
66+ }
67+ onCompleted?.(action, thread);
68+ } finally {
69+ inFlightThreadKeys.current.delete(key);
70+ }
71+ },
72+ [archiveMutation, deleteMutation, onCompleted, unarchiveMutation],
73+ );
74+
75+ return executeAction;
76+}
77+
78+function useConfirmDeleteThread(
79+ executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise<void>,
80+) {
81+ return useCallback(
82+ (thread: EnvironmentThreadShell) => {
83+ Alert.alert(
84+ "Delete thread?",
85+ `“${thread.title}” will be permanently deleted, including its terminal history.`,
86+ [
87+ { text: "Cancel", style: "cancel" },
88+ {
89+ text: "Delete",
90+ style: "destructive",
91+ onPress: () => {
92+ void executeAction("delete", thread);
93+ },
94+ },
95+ ],
96+ );
97+ },
98+ [executeAction],
99+ );
100+}
101+
102+export function useThreadListActions(): {
103+ readonly archiveThread: (thread: EnvironmentThreadShell) => void;
104+ readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void;
105+} {
106+ const executeAction = useThreadActionExecutor();
107+
108+ const archiveThread = useCallback(
109+ (thread: EnvironmentThreadShell) => {
110+ void executeAction("archive", thread);
111+ },
112+ [executeAction],
113+ );
114+
115+ const confirmDeleteThread = useConfirmDeleteThread(executeAction);
116+
117+ return { archiveThread, confirmDeleteThread };
118+}
119+
120+export function useArchivedThreadListActions(
121+ onCompleted: (thread: EnvironmentThreadShell) => void,
122+): {
123+ readonly unarchiveThread: (thread: EnvironmentThreadShell) => void;
124+ readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void;
125+} {
126+ const handleCompleted = useCallback(
127+ (_action: ThreadListAction, thread: EnvironmentThreadShell) => {
128+ onCompleted(thread);
129+ },
130+ [onCompleted],
131+ );
132+ const executeAction = useThreadActionExecutor(handleCompleted);
133+ const unarchiveThread = useCallback(
134+ (thread: EnvironmentThreadShell) => {
135+ void executeAction("unarchive", thread);
136+ },
137+ [executeAction],
138+ );
139+ const confirmDeleteThread = useConfirmDeleteThread(executeAction);
140+
141+ return { unarchiveThread, confirmDeleteThread };
142+}
apps/mobile/src/features/review/shikiReviewHighlighter.ts+9−0
@@ -695,6 +695,15 @@ export async function highlightCodeSnippet(input: { · 694 unmodified lines
695695 return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]);
696696 }
697697
698+export async function highlightSourceFile(input: {
699+ readonly path: string;
700+ readonly contents: string;
701+ readonly theme: ReviewDiffTheme;
702+}): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> {
703+ const language = await resolveLanguageFromPath(input.path);
704+ return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]);
705+}
706+
698707 async function highlightPatchLinesInChunks(input: {
699708 readonly lines: ReadonlyArray<string>;
700709 readonly language: string;
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2−0
@@ -62,6 +62,7 @@ export interface ThreadDetailScreenProps { · 61 unmodified lines
6262 readonly activeThreadBusy: boolean;
6363 readonly environmentId: EnvironmentId;
6464 readonly projectWorkspaceRoot: string | null;
65+ readonly threadCwd: string | null;
6566 readonly selectedThreadQueueCount: number;
6667 readonly serverConfig: T3ServerConfig | null;
6768 readonly layoutVariant?: LayoutVariant;
@@ -309,6 +310,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 241 unmodified lines
309310 key={props.selectedThread.id}
310311 environmentId={props.environmentId}
311312 threadId={props.selectedThread.id}
313+ workspaceRoot={props.threadCwd}
312314 feed={props.selectedThreadFeed}
313315 contentPresentation={props.contentPresentation}
314316 agentLabel={agentLabel}
apps/mobile/src/features/threads/ThreadFeed.tsx+108−193
@@ -4,6 +4,7 @@ import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; · 3 unmodified lines
44 import { type LegendListRef } from "@legendapp/list/react-native";
55 import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";
66 import { SymbolView } from "expo-symbols";
7+import { useRouter } from "expo-router";
78 import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
89 import {
910 Markdown,
@@ -54,6 +55,7 @@ import { · 44 unmodified lines
5455 import { buildReviewParsedDiff } from "../review/reviewModel";
5556 import { cn } from "../../lib/cn";
5657 import type { LayoutVariant } from "../../lib/layout";
58+import { buildThreadFilesNavigation } from "../../lib/routes";
5759 import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
5860 import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
5961 import {
@@ -64,7 +66,9 @@ import { · 4 unmodified lines
6466 import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout";
6567 import { relativeTime } from "../../lib/time";
6668 import type { ThreadContentPresentation } from "./threadContentPresentation";
69+import { ThreadWorkLog } from "./thread-work-log";
6770 import { useAssetUrl } from "../../state/assets";
71+import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
6872
6973 const THREAD_FEED_END_THRESHOLD = 80;
7074 const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
@@ -83,6 +87,7 @@ function formatMessageTime(input: string): string { · 12 unmodified lines
8387 export interface ThreadFeedProps {
8488 readonly environmentId: EnvironmentId;
8589 readonly threadId: ThreadId;
90+ readonly workspaceRoot?: string | null;
8691 readonly feed: ReadonlyArray<ThreadFeedEntry>;
8792 readonly contentPresentation: ThreadContentPresentation;
8893 readonly agentLabel: string;
@@ -120,32 +125,6 @@ function MessageAttachmentImage(props: { · 31 unmodified lines
120125 );
121126 }
122127
123−function stripShellWrapper(value: string): string {
124− const trimmed = value.trim();
125− const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/);
126− return (match?.[1] ?? trimmed).trim();
127−}
128−
129−function compactActivityDetail(detail: string | null): string | null {
130− if (!detail) {
131− return null;
132− }
133−
134− const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim();
135− return cleaned.length > 0 ? cleaned : null;
136−}
137−
138−function buildActivityRows(
139− activities: Extract<ThreadFeedEntry, { type: "activity-group" }>["activities"],
140−) {
141− return activities.map((activity) => ({
142− ...activity,
143− detail: compactActivityDetail(activity.detail),
144− }));
145−}
146−
147−const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
148−
149128 const MARKDOWN_COLORS = {
150129 light: {
151130 body: "#111111",
@@ -155,10 +134,12 @@ const MARKDOWN_COLORS = { · 3 unmodified lines
155134 blockquoteBackground: "rgba(0, 0, 0, 0.02)",
156135 codeBackground: "rgba(0, 0, 0, 0.04)",
157136 codeText: "#262626",
137+ inlineCodeText: "#5f6368",
158138 horizontalRule: "rgba(0, 0, 0, 0.08)",
159139 userBody: "#ffffff",
160140 userCodeBackground: "rgba(255, 255, 255, 0.22)",
161141 userCodeText: "#ffffff",
142+ userInlineCodeText: "rgba(255, 255, 255, 0.82)",
162143 userFenceBackground: "rgba(0, 0, 0, 0.16)",
163144 userFenceText: "#ffffff",
164145 },
@@ -170,10 +151,12 @@ const MARKDOWN_COLORS = { · 5 unmodified lines
170151 blockquoteBackground: "rgba(255, 255, 255, 0.03)",
171152 codeBackground: "rgba(255, 255, 255, 0.06)",
172153 codeText: "#e5e5e5",
154+ inlineCodeText: "#b8bcc2",
173155 horizontalRule: "rgba(255, 255, 255, 0.08)",
174156 userBody: "#ffffff",
175157 userCodeBackground: "rgba(255, 255, 255, 0.18)",
176158 userCodeText: "#ffffff",
159+ userInlineCodeText: "rgba(255, 255, 255, 0.82)",
177160 userFenceBackground: "rgba(0, 0, 0, 0.28)",
178161 userFenceText: "#ffffff",
179162 },
@@ -202,27 +185,18 @@ interface ReviewCommentColors { · 22 unmodified lines
202185
203186 const failedMarkdownFaviconHosts = new Set<string>();
204187 const markdownLinkStyles = StyleSheet.create({
205− favicon: {
188+ inlineIcon: {
206189 width: 14,
207190 height: 14,
208− borderRadius: 3,
209191 marginHorizontal: 3,
210192 transform: [{ translateY: 2 }],
211193 },
212− file: {
213− borderRadius: 5,
214− borderWidth: StyleSheet.hairlineWidth,
215− fontFamily: "DMSans_500Medium",
216− fontSize: 13,
217− lineHeight: 20,
218− paddingHorizontal: 6,
219− paddingVertical: 2,
194+ favicon: {
195+ borderRadius: 3,
220196 },
221− fileIcon: {
222− width: 15,
223− height: 15,
224− marginRight: 4,
225− transform: [{ translateY: 2 }],
197+ file: {
198+ fontFamily: "DMSans_700Bold",
199+ fontWeight: "700",
226200 },
227201 });
228202
@@ -250,7 +224,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { · 21 unmodified lines
250224 source={{
251225 uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`,
252226 }}
253− style={markdownLinkStyles.favicon}
227+ style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]}
254228 onError={() => {
255229 failedMarkdownFaviconHosts.add(props.host);
256230 setFailed(true);
@@ -287,11 +261,9 @@ function useReviewCommentColors(): ReviewCommentColors { · 30 unmodified lines
287261 );
288262 }
289263
290−function useMarkdownStyles(): MarkdownStyleSets {
264+function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
291265 const colorScheme = useColorScheme();
292266 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];
293− const inlineChipBackground = String(useThemeColor("--color-subtle"));
294− const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background"));
295267 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
296268
297269 return useMemo(() => {
@@ -302,10 +274,12 @@ function useMarkdownStyles(): MarkdownStyleSets { · 4 unmodified lines
302274 const markdownBlockquoteBorder = colors.blockquoteBorder;
303275 const markdownCodeBg = colors.codeBackground;
304276 const markdownCodeText = colors.codeText;
277+ const markdownInlineCodeText = colors.inlineCodeText;
305278 const markdownHrColor = colors.horizontalRule;
306279 const markdownUserBodyColor = colors.userBody;
307280 const markdownUserCodeBg = colors.userCodeBackground;
308281 const markdownUserCodeText = colors.userCodeText;
282+ const markdownUserInlineCodeText = colors.userInlineCodeText;
309283 const markdownUserFenceBg = colors.userFenceBackground;
310284 const markdownUserFenceText = colors.userFenceText;
311285
@@ -394,28 +368,23 @@ function useMarkdownStyles(): MarkdownStyleSets { · 82 unmodified lines
394368 };
395369
396370 const createMarkdownRenderers = (
397− inlineBackgroundColor: string,
398371 inlineTextColor: string,
372+ inlineCodeTextColor: string,
399373 blockBackgroundColor: string,
400374 blockTextColor: string,
375+ preserveSoftBreaks: boolean,
401376 ): CustomRenderers => ({
402377 link: ({ children, href = "" }) => {
403378 const presentation = resolveMarkdownLinkPresentation(href);
404379 if (presentation.kind === "file") {
405380 return (
406381 <NativeText
407− style={[
408− markdownLinkStyles.file,
409− {
410− backgroundColor: inlineBackgroundColor,
411− borderColor: markdownHrColor,
412− color: inlineTextColor,
413− },
414− ]}
382+ onPress={() => onLinkPress(href)}
383+ style={[markdownLinkStyles.file, { color: inlineTextColor }]}
415384 >
416385 <Image
417386 source={markdownFileIconSource(presentation.icon)}
418− style={markdownLinkStyles.fileIcon}
387+ style={markdownLinkStyles.inlineIcon}
419388 />
420389 {presentation.label}
421390 </NativeText>
@@ -492,28 +461,24 @@ function useMarkdownStyles(): MarkdownStyleSets { · 70 unmodified lines
492461 ),
493462 code_inline: ({ content }) => {
494463 const value = content ?? "";
495− const wrapsPoorly =
496− value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":");
497464 return (
498465 <NativeText
499466 style={{
500− color: inlineTextColor,
467+ color: inlineCodeTextColor,
501468 fontFamily: "ui-monospace",
502469 fontSize: 12,
503− lineHeight: 18,
504− ...(wrapsPoorly
505− ? { opacity: 0.82 }
506− : {
507− backgroundColor: inlineBackgroundColor,
508− borderRadius: 4,
509− paddingHorizontal: 3,
510− }),
470+ lineHeight: 22,
511471 }}
512472 >
513473 {value}
514474 </NativeText>
515475 );
516476 },
477+ ...(preserveSoftBreaks
478+ ? {
479+ soft_break: () => <NativeText>{"\n"}</NativeText>,
480+ }
481+ : {}),
517482 code_block: ({ content, language }) => (
518483 <View
519484 style={{
@@ -619,23 +584,23 @@ function useMarkdownStyles(): MarkdownStyleSets { · 99 unmodified lines
619584 theme: userTheme,
620585 styles: userStyles,
621586 renderers: createMarkdownRenderers(
622− markdownUserCodeBg,
623587 markdownUserCodeText,
588+ markdownUserInlineCodeText,
624589 markdownUserFenceBg,
625590 markdownUserFenceText,
591+ true,
626592 ),
627593 nativeTextStyle: {
628594 color: markdownUserBodyColor,
629595 strongColor: markdownUserBodyColor,
630596 mutedColor: markdownUserBodyColor,
631597 linkColor: markdownUserBodyColor,
598+ inlineCodeColor: markdownUserInlineCodeText,
632599 codeColor: markdownUserCodeText,
633600 codeBackgroundColor: markdownUserCodeBg,
634601 codeBlockBackgroundColor: markdownUserFenceBg,
635− fileBackgroundColor: "rgba(255, 255, 255, 0.12)",
636602 fileTextColor: "#ffffff",
637− skillBackgroundColor: "rgba(217, 70, 239, 0.24)",
638− skillTextColor: "#ffffff",
603+ skillTextColor: "#f0abfc",
639604 quoteMarkerColor: markdownUserBodyColor,
640605 dividerColor: markdownUserBodyColor,
641606 fontSize: 15,
@@ -649,22 +614,22 @@ function useMarkdownStyles(): MarkdownStyleSets { · 7 unmodified lines
649614 theme: assistantTheme,
650615 styles: assistantStyles,
651616 renderers: createMarkdownRenderers(
652− markdownCodeBg,
653617 markdownCodeText,
618+ markdownInlineCodeText,
654619 markdownCodeBg,
655620 markdownCodeText,
621+ false,
656622 ),
657623 nativeTextStyle: {
658624 color: markdownBodyColor,
659625 strongColor: markdownStrongColor,
660626 mutedColor: markdownBodyColor,
661627 linkColor: markdownLinkColor,
628+ inlineCodeColor: markdownInlineCodeText,
662629 codeColor: markdownCodeText,
663630 codeBackgroundColor: markdownCodeBg,
664631 codeBlockBackgroundColor: markdownCodeBg,
665− fileBackgroundColor: inlineChipBackground,
666632 fileTextColor: markdownCodeText,
667− skillBackgroundColor: inlineSkillBackground,
668633 skillTextColor: inlineSkillForeground,
669634 quoteMarkerColor: markdownBlockquoteBorder,
670635 dividerColor: markdownHrColor,
@@ -676,7 +641,7 @@ function useMarkdownStyles(): MarkdownStyleSets { · 5 unmodified lines
676641 },
677642 },
678643 };
679− }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]);
644+ }, [colors, inlineSkillForeground, onLinkPress]);
680645 }
681646
682647 function renderFeedEntry(
@@ -692,11 +657,13 @@ function renderFeedEntry( · 9 unmodified lines
692657 readonly onToggleWorkRow: (rowId: string) => void;
693658 readonly onToggleTurnFold: (turnId: TurnId) => void;
694659 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
660+ readonly onMarkdownLinkPress: (href: string) => void;
695661 readonly iconSubtleColor: string | import("react-native").ColorValue;
696662 readonly userBubbleColor: string | import("react-native").ColorValue;
697663 readonly markdownStyles: MarkdownStyleSets;
698664 readonly reviewCommentColors: ReviewCommentColors;
699665 readonly reviewCommentBubbleWidth: number;
666+ readonly userBubbleMaxWidth: number;
700667 },
701668 ) {
702669 const entry = info.item;
@@ -745,9 +712,10 @@ function renderFeedEntry( · 42 unmodified lines
745712 return (
746713 <View className="mb-5 items-end">
747714 <View
748− className="max-w-[85%] gap-2 rounded-[20px] px-3.5 py-2.5"
715+ className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
749716 style={{
750717 backgroundColor: userBubbleColor,
718+ maxWidth: props.userBubbleMaxWidth,
751719 ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),
752720 }}
753721 >
@@ -757,6 +725,7 @@ function renderFeedEntry( · 3 unmodified lines
757725 markdownStyles={styles}
758726 reviewCommentColors={props.reviewCommentColors}
759727 skills={props.skills}
728+ onLinkPress={props.onMarkdownLinkPress}
760729 />
761730 ) : null}
762731 {attachments.map((attachment) => {
@@ -803,6 +772,7 @@ function renderFeedEntry( · 40 unmodified lines
803772 markdown={message.text}
804773 skills={props.skills}
805774 textStyle={styles.nativeTextStyle}
775+ onLinkPress={props.onMarkdownLinkPress}
806776 />
807777 ) : (
808778 <Markdown
@@ -868,123 +838,17 @@ function renderFeedEntry( · 59 unmodified lines
868838 );
869839 }
870840
871− const rows = buildActivityRows(entry.activities).filter(
872− (activity) => !(activity.toolLike && activity.status === "neutral"),
873− );
874− if (rows.length === 0) {
875− return null;
876− }
877− const isExpanded = props.expandedWorkGroups[entry.id] ?? false;
878− const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
879− const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows;
880− const hiddenCount = rows.length - visibleRows.length;
881− const onlyToolRows = rows.every((row) => row.toolLike);
882− const headerTitle = onlyToolRows
883− ? rows.length === 1
884− ? "1 tool call"
885− : `${rows.length} tool calls`
886− : "Work log";
887−
888841 return (
889− <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]">
890− <View className="mb-1.5 flex-row items-center justify-between gap-3 px-2">
891− <Text className="font-t3-medium text-xs text-foreground">{headerTitle}</Text>
892− {hasOverflow ? (
893− <Pressable
894− accessibilityRole="button"
895− accessibilityState={{ expanded: isExpanded }}
896− onPress={() => props.onToggleWorkGroup(entry.id)}
897− className="flex-row items-center gap-1"
898− >
899− <Text className="font-t3-medium text-xs text-foreground-muted">
900− {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
901− </Text>
902− <SymbolView
903− name={isExpanded ? "chevron.up" : "chevron.down"}
904− size={12}
905− tintColor={iconSubtleColor}
906− type="monochrome"
907− />
908− </Pressable>
909− ) : null}
910− </View>
911− {visibleRows.map((row, index) => (
912− <Pressable
913− key={row.id}
914− accessibilityRole={row.fullDetail ? "button" : undefined}
915− accessibilityState={
916− row.fullDetail ? { expanded: props.expandedWorkRows[row.id] ?? false } : undefined
917− }
918− onPress={() => {
919− if (row.fullDetail) {
920− props.onToggleWorkRow(row.id);
921− }
922− }}
923− onLongPress={() => props.onCopyWorkRow(row.id, row.copyText)}
924− className={cn(
925− "rounded-lg px-2 py-1.5",
926− index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]",
927− )}
928− >
929− <View className="flex-row items-center gap-2">
930− <View className="w-4 items-center justify-center">
931− <SymbolView
932− name={
933− row.status === "failure"
934− ? "xmark"
935− : row.status === "success"
936− ? "checkmark"
937− : row.status === "neutral"
938− ? "minus"
939− : "terminal"
940− }
941− size={row.status ? 11 : 13}
942− tintColor={row.status === "failure" ? "#e11d48" : iconSubtleColor}
943− type="monochrome"
944− />
945− </View>
946− <Text
947− className="min-w-0 flex-1 text-[12px] leading-[18px] text-neutral-700 dark:text-neutral-300"
948− numberOfLines={props.expandedWorkRows[row.id] ? undefined : 1}
949− >
950− {row.detail ? `${row.summary} - ${row.detail}` : row.summary}
951− </Text>
952− {row.fullDetail ? (
953− <SymbolView
954− name={props.expandedWorkRows[row.id] ? "chevron.up" : "chevron.down"}
955− size={11}
956− tintColor={iconSubtleColor}
957− type="monochrome"
958− />
959− ) : null}
960− {props.copiedRowId === row.id ? (
961− <Text className="shrink-0 font-t3-medium text-[10px] text-emerald-600 dark:text-emerald-400">
962− Copied
963− </Text>
964− ) : null}
965− </View>
966− {row.fullDetail && props.expandedWorkRows[row.id] ? (
967− <ScrollView
968− horizontal
969− nestedScrollEnabled
970− directionalLockEnabled
971− showsHorizontalScrollIndicator
972− bounces={false}
973− className="mt-2 rounded-lg bg-neutral-100 dark:bg-black/20"
974− contentContainerStyle={{ paddingHorizontal: 10, paddingVertical: 8 }}
975− >
976− <Text
977− selectable
978− className="text-[11px] leading-[17px] text-neutral-600 dark:text-neutral-400"
979− style={{ fontFamily: "ui-monospace" }}
980− >
981− {row.fullDetail}
982− </Text>
983− </ScrollView>
984− ) : null}
985− </Pressable>
986− ))}
987− </View>
842+ <ThreadWorkLog
843+ activities={entry.activities}
844+ copiedRowId={props.copiedRowId}
845+ expanded={props.expandedWorkGroups[entry.id] ?? false}
846+ expandedRows={props.expandedWorkRows}
847+ iconSubtleColor={iconSubtleColor}
848+ onCopyRow={props.onCopyWorkRow}
849+ onToggleGroup={() => props.onToggleWorkGroup(entry.id)}
850+ onToggleRow={props.onToggleWorkRow}
851+ />
988852 );
989853 }
990854
@@ -993,6 +857,7 @@ function UserMessageContent(props: { · 2 unmodified lines
993857 readonly markdownStyles: MarkdownStyleSet;
994858 readonly reviewCommentColors: ReviewCommentColors;
995859 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
860+ readonly onLinkPress: (href: string) => void;
996861 }) {
997862 const segments = parseReviewCommentMessageSegments(props.text);
998863 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
@@ -1003,6 +868,8 @@ function UserMessageContent(props: { · 4 unmodified lines
1003868 markdown={props.text}
1004869 skills={props.skills}
1005870 textStyle={props.markdownStyles.nativeTextStyle}
871+ preserveSoftBreaks
872+ onLinkPress={props.onLinkPress}
1006873 />
1007874 );
1008875 }
@@ -1042,6 +909,8 @@ function UserMessageContent(props: { · 33 unmodified lines
1042909 markdown={text}
1043910 skills={props.skills}
1044911 textStyle={props.markdownStyles.nativeTextStyle}
912+ preserveSoftBreaks
913+ onLinkPress={props.onLinkPress}
1045914 />
1046915 ) : (
1047916 <Markdown
@@ -1254,6 +1123,7 @@ function ThreadFeedPlaceholder(props: { · 206 unmodified lines
12541123 }
12551124
12561125 export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1126+ const router = useRouter();
12571127 const listRef = useRef<LegendListRef>(null);
12581128 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
12591129 const scrollFrameRef = useRef<number | null>(null);
@@ -1283,6 +1153,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 23 unmodified lines
12831153 } | null>(null);
12841154 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
12851155 const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2);
1156+ const userBubbleMaxWidth = contentWidth * 0.85;
12861157 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
12871158 const insets = useSafeAreaInsets();
12881159 const topContentInset = props.contentTopInset ?? insets.top + 44;
@@ -1290,16 +1161,56 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line
12901161
12911162 const iconSubtleColor = useThemeColor("--color-icon-subtle");
12921163 const userBubbleColor = useThemeColor("--color-user-bubble");
1293− const markdownStyles = useMarkdownStyles();
1164+ const onMarkdownLinkPress = useCallback(
1165+ (href: string) => {
1166+ const presentation = resolveMarkdownLinkPresentation(href);
1167+ if (presentation.kind === "file") {
1168+ const relativePath = resolveWorkspaceRelativeFilePath(
1169+ props.workspaceRoot,
1170+ presentation.path,
1171+ );
1172+ if (relativePath) {
1173+ void Haptics.selectionAsync();
1174+ router.push(
1175+ buildThreadFilesNavigation(
1176+ { environmentId: props.environmentId, threadId: props.threadId },
1177+ relativePath,
1178+ presentation.line,
1179+ ),
1180+ );
1181+ }
1182+ return;
1183+ }
1184+
1185+ if (presentation.href) {
1186+ void Linking.openURL(presentation.href);
1187+ }
1188+ },
1189+ [props.environmentId, props.threadId, props.workspaceRoot, router],
1190+ );
1191+ const markdownStyles = useMarkdownStyles(onMarkdownLinkPress);
12941192 const reviewCommentColors = useReviewCommentColors();
1193+ // LegendList does not invalidate visible rows when only the renderItem closure changes.
1194+ // Keep row-local interaction props in extraData so disclosures and copy feedback repaint.
12951195 const listAppearanceData = useMemo(
12961196 () => ({
1197+ copiedRowId,
1198+ expandedWorkGroups,
1199+ expandedWorkRows,
12971200 iconSubtleColor,
12981201 markdownStyles,
12991202 reviewCommentColors,
13001203 userBubbleColor,
13011204 }),
1302− [iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor],
1205+ [
1206+ copiedRowId,
1207+ expandedWorkGroups,
1208+ expandedWorkRows,
1209+ iconSubtleColor,
1210+ markdownStyles,
1211+ reviewCommentColors,
1212+ userBubbleColor,
1213+ ],
13031214 );
13041215 const presentedFeed = useMemo(
13051216 () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds),
@@ -1490,11 +1401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 184 unmodified lines
14901401 onToggleWorkRow,
14911402 onToggleTurnFold,
14921403 onPressImage,
1404+ onMarkdownLinkPress,
14931405 iconSubtleColor,
14941406 userBubbleColor,
14951407 markdownStyles,
14961408 reviewCommentColors,
14971409 reviewCommentBubbleWidth,
1410+ userBubbleMaxWidth,
14981411 skills: props.skills,
14991412 }),
15001413 [
@@ -1508,7 +1421,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 7 unmodified lines
15081421 markdownStyles,
15091422 reviewCommentColors,
15101423 reviewCommentBubbleWidth,
1424+ userBubbleMaxWidth,
15111425 onCopyWorkRow,
1426+ onMarkdownLinkPress,
15121427 onPressImage,
15131428 onToggleTurnFold,
15141429 onToggleWorkGroup,
apps/mobile/src/features/threads/ThreadGitControls.tsx+10−1
@@ -14,7 +14,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; · 13 unmodified lines
1414 import Stack from "expo-router/stack";
1515 import { useCallback, useMemo } from "react";
1616 import { Alert, Linking } from "react-native";
17−import { buildThreadReviewRoutePath } from "../../lib/routes";
17+import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";
1818 import {
1919 basename,
2020 getTerminalStatusLabel,
@@ -69,6 +69,7 @@ export function ThreadGitControls(props: { · 48 unmodified lines
6969 readonly gitStatus: VcsStatusResult | null;
7070 readonly gitOperationLabel: string | null;
7171 readonly canOpenTerminal: boolean;
72+ readonly canOpenFiles: boolean;
7273 readonly projectScripts: ReadonlyArray<ProjectScript>;
7374 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
7475 readonly onOpenTerminal: (terminalId?: string | null) => void;
@@ -259,6 +260,14 @@ export function ThreadGitControls(props: { · 184 unmodified lines
259260 >
260261 <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label>
261262 </Stack.Toolbar.MenuAction>
263+ <Stack.Toolbar.MenuAction
264+ icon="folder"
265+ disabled={!props.canOpenFiles}
266+ onPress={() => router.push(buildThreadFilesNavigation({ environmentId, threadId }))}
267+ subtitle="Browse this workspace"
268+ >
269+ <Stack.Toolbar.Label>Files</Stack.Toolbar.Label>
270+ </Stack.Toolbar.MenuAction>
262271 <Stack.Toolbar.MenuAction
263272 icon="ellipsis.circle"
264273 onPress={() =>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2−0
@@ -420,6 +420,7 @@ export function ThreadRouteScreen() { · 419 unmodified lines
420420 gitStatus={gitStatus.data}
421421 gitOperationLabel={gitState.gitOperationLabel}
422422 canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)}
423+ canOpenFiles={Boolean(selectedThreadProject?.workspaceRoot)}
423424 projectScripts={selectedThreadProject?.scripts ?? []}
424425 terminalSessions={terminalMenuSessions}
425426 onOpenTerminal={handleOpenTerminal}
@@ -452,6 +453,7 @@ export function ThreadRouteScreen() { · 26 unmodified lines
452453 activeThreadBusy={composer.activeThreadBusy}
453454 environmentId={selectedThread.environmentId}
454455 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
456+ threadCwd={selectedThreadCwd}
455457 selectedThreadQueueCount={composer.selectedThreadQueueCount}
456458 onOpenDrawer={handleOpenDrawer}
457459 onOpenConnectionEditor={handleOpenConnectionEditor}
apps/mobile/src/features/threads/thread-work-log.tsx+261−0
@@ -0,0 +1,261 @@
1+import * as Haptics from "expo-haptics";
2+import { SymbolView, type SFSymbol } from "expo-symbols";
3+import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";
4+
5+import { AppText as Text } from "../../components/AppText";
6+import { cn } from "../../lib/cn";
7+import type { ThreadFeedActivity } from "../../lib/threadActivity";
8+
9+const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
10+const WORK_LOG_LAYOUT_ANIMATION = {
11+ duration: 180,
12+ create: {
13+ type: LayoutAnimation.Types.easeInEaseOut,
14+ property: LayoutAnimation.Properties.opacity,
15+ },
16+ update: { type: LayoutAnimation.Types.easeInEaseOut },
17+ delete: {
18+ type: LayoutAnimation.Types.easeInEaseOut,
19+ property: LayoutAnimation.Properties.opacity,
20+ },
21+} as const;
22+
23+function triggerDisclosureFeedback() {
24+ LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION);
25+ void Haptics.selectionAsync();
26+}
27+
28+function stripShellWrapper(value: string): string {
29+ const trimmed = value.trim();
30+ const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/);
31+ return (match?.[1] ?? trimmed).trim();
32+}
33+
34+function compactActivityDetail(detail: string | null): string | null {
35+ if (!detail) {
36+ return null;
37+ }
38+
39+ const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim();
40+ return cleaned.length > 0 ? cleaned : null;
41+}
42+
43+function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol {
44+ switch (icon) {
45+ case "agent":
46+ return "sparkles";
47+ case "alert":
48+ return "exclamationmark.triangle";
49+ case "check":
50+ return "checkmark";
51+ case "command":
52+ return "terminal";
53+ case "edit":
54+ return "square.and.pencil";
55+ case "eye":
56+ return "eye";
57+ case "globe":
58+ return "globe";
59+ case "hammer":
60+ return "hammer";
61+ case "message":
62+ return "bubble.left";
63+ case "warning":
64+ return "xmark";
65+ case "wrench":
66+ return "wrench";
67+ case "zap":
68+ return "bolt";
69+ }
70+}
71+
72+export function ThreadWorkLog(props: {
73+ readonly activities: ReadonlyArray<ThreadFeedActivity>;
74+ readonly copiedRowId: string | null;
75+ readonly expanded: boolean;
76+ readonly expandedRows: Readonly<Record<string, boolean>>;
77+ readonly iconSubtleColor: import("react-native").ColorValue;
78+ readonly onCopyRow: (rowId: string, value: string) => void;
79+ readonly onToggleGroup: () => void;
80+ readonly onToggleRow: (rowId: string) => void;
81+}) {
82+ const colorScheme = useColorScheme();
83+ const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
84+ const rows = props.activities
85+ .filter((activity) => !(activity.toolLike && activity.status === "neutral"))
86+ .map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) }));
87+
88+ if (rows.length === 0) {
89+ return null;
90+ }
91+
92+ const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
93+ const visibleRows =
94+ hasOverflow && !props.expanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows;
95+ const hiddenCount = rows.length - visibleRows.length;
96+ const onlyToolRows = rows.every((row) => row.toolLike);
97+
98+ return (
99+ <View className="-mx-1 mb-3 px-1 py-0.5">
100+ {!onlyToolRows ? (
101+ <Text className="px-0.5 pb-0.5 font-t3-medium text-[11px] text-foreground-muted opacity-60">
102+ work log
103+ </Text>
104+ ) : null}
105+
106+ <View className="gap-px">
107+ {visibleRows.map((row) => {
108+ const expanded = props.expandedRows[row.id] ?? false;
109+ const canExpand = row.fullDetail !== null;
110+ const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary;
111+ const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
112+
113+ return (
114+ <View key={row.id}>
115+ <Pressable
116+ accessibilityRole={canExpand ? "button" : undefined}
117+ accessibilityLabel={displayText}
118+ accessibilityHint={
119+ canExpand
120+ ? "Double tap to show full details. Long press to copy."
121+ : "Long press to copy."
122+ }
123+ accessibilityState={canExpand ? { expanded } : undefined}
124+ hitSlop={4}
125+ onPress={() => {
126+ if (canExpand) {
127+ triggerDisclosureFeedback();
128+ props.onToggleRow(row.id);
129+ }
130+ }}
131+ onLongPress={() => props.onCopyRow(row.id, row.copyText)}
132+ style={({ pressed }) => ({
133+ backgroundColor: pressed ? pressedBackground : "transparent",
134+ })}
135+ className="rounded-md px-0.5 py-0.5"
136+ >
137+ <View className="min-h-9 flex-row items-center gap-1.5">
138+ <View className="h-5 w-5 shrink-0 items-center justify-center">
139+ <SymbolView
140+ name={workRowSymbolName(row.icon)}
141+ size={14}
142+ weight="medium"
143+ tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor}
144+ type="monochrome"
145+ />
146+ </View>
147+
148+ <Text
149+ className="min-w-0 flex-1 text-xs leading-5 text-foreground"
150+ numberOfLines={1}
151+ >
152+ <Text
153+ className={cn(
154+ "font-t3-medium text-foreground",
155+ iconIsDestructive && "text-rose-600 dark:text-rose-400",
156+ )}
157+ >
158+ {row.summary}
159+ </Text>
160+ {row.detail ? (
161+ <Text className="text-foreground-muted opacity-60"> {row.detail}</Text>
162+ ) : null}
163+ </Text>
164+
165+ <View className="shrink-0 flex-row items-center gap-px">
166+ {props.copiedRowId === row.id ? (
167+ <Text className="pr-1 font-t3-medium text-[10px] text-emerald-600 dark:text-emerald-400">
168+ Copied
169+ </Text>
170+ ) : null}
171+ <View className="h-4 w-4 items-center justify-center">
172+ {canExpand ? (
173+ <SymbolView
174+ name={expanded ? "chevron.up" : "chevron.down"}
175+ size={11}
176+ tintColor={props.iconSubtleColor}
177+ type="monochrome"
178+ />
179+ ) : null}
180+ </View>
181+ <View className="h-4 w-4 items-center justify-center">
182+ {row.status ? (
183+ <SymbolView
184+ name={
185+ row.status === "failure"
186+ ? "xmark"
187+ : row.status === "success"
188+ ? "checkmark"
189+ : "minus"
190+ }
191+ size={11}
192+ tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
193+ type="monochrome"
194+ />
195+ ) : null}
196+ </View>
197+ </View>
198+ </View>
199+ </Pressable>
200+
201+ {expanded && row.fullDetail ? (
202+ <View className="ml-7 border-l border-neutral-300/60 pb-1.5 pl-3 pt-0.5 dark:border-white/[0.12]">
203+ <ScrollView
204+ nestedScrollEnabled
205+ directionalLockEnabled
206+ showsVerticalScrollIndicator
207+ style={{ maxHeight: 240 }}
208+ contentContainerStyle={{ paddingRight: 8 }}
209+ >
210+ <Text
211+ selectable
212+ className="text-[11px] leading-[17px] text-foreground-muted"
213+ style={{ fontFamily: "ui-monospace" }}
214+ >
215+ {row.fullDetail}
216+ </Text>
217+ </ScrollView>
218+ </View>
219+ ) : null}
220+ </View>
221+ );
222+ })}
223+ </View>
224+
225+ {hasOverflow ? (
226+ <Pressable
227+ accessibilityRole="button"
228+ accessibilityState={{ expanded: props.expanded }}
229+ accessibilityLabel={
230+ props.expanded
231+ ? "Show fewer tool calls"
232+ : `Show ${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`
233+ }
234+ hitSlop={4}
235+ onPress={() => {
236+ triggerDisclosureFeedback();
237+ props.onToggleGroup();
238+ }}
239+ style={({ pressed }) => ({
240+ backgroundColor: pressed ? pressedBackground : "transparent",
241+ })}
242+ className="min-h-9 flex-row items-center gap-1.5 rounded-md px-0.5 py-0.5"
243+ >
244+ <View className="h-5 w-5 items-center justify-center">
245+ <SymbolView
246+ name={props.expanded ? "chevron.up" : "chevron.down"}
247+ size={13}
248+ tintColor={props.iconSubtleColor}
249+ type="monochrome"
250+ />
251+ </View>
252+ <Text className="font-t3-medium text-xs text-foreground opacity-80">
253+ {props.expanded
254+ ? "Show fewer tool calls"
255+ : `+${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`}
256+ </Text>
257+ </Pressable>
258+ ) : null}
259+ </View>
260+ );
261+}
apps/mobile/src/lib/markdownLinks.test.ts+21−0
@@ -16,26 +16,47 @@ describe("resolveMarkdownLinkPresentation", () => { · 15 unmodified lines
1616 resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"),
1717 ).toEqual({
1818 kind: "file",
19+ href: "file:///Users/julius/project/src/main.ts#L42C7",
1920 icon: "typescript",
2021 label: "main.ts:42:7",
22+ path: "/Users/julius/project/src/main.ts",
23+ line: 42,
24+ column: 7,
2125 });
2226 });
2327
2428 it("recognizes relative source paths and bare filenames", () => {
2529 expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({
2630 kind: "file",
31+ href: "apps/mobile/src/index.ts:10",
2732 icon: "typescript",
2833 label: "index.ts:10",
34+ path: "apps/mobile/src/index.ts",
35+ line: 10,
2936 });
3037 expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({
3138 kind: "file",
39+ href: "AGENTS.md",
3240 icon: "agents",
3341 label: "AGENTS.md",
42+ path: "AGENTS.md",
3443 });
3544 expect(resolveMarkdownLinkPresentation("package.json")).toEqual({
3645 kind: "file",
46+ href: "package.json",
3747 icon: "package",
3848 label: "package.json",
49+ path: "package.json",
50+ });
51+ });
52+
53+ it("extracts line fragments from relative file links", () => {
54+ expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({
55+ kind: "file",
56+ path: "src/main.ts",
57+ line: 18,
58+ column: 2,
59+ label: "main.ts:18:2",
3960 });
4061 });
4162
apps/mobile/src/lib/nativeMarkdownText.test.ts+22−2
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 nativeMarkdownDocumentRuns,
88 nativeMarkdownListItemBlocks,
99 nativeMarkdownTextRuns,
10+ nativeMarkdownWithPreservedSoftBreaks,
1011 } from "@t3tools/mobile-markdown-text/markdown";
1112
1213 describe("nativeMarkdownTextRuns", () => {
@@ -54,7 +55,11 @@ describe("nativeMarkdownTextRuns", () => { · 41 unmodified lines
5455 externalHost: "example.com",
5556 },
5657 { text: " " },
57− { text: "README.md:12", fileIcon: "readme" },
58+ {
59+ text: "README.md:12",
60+ href: "file:///repo/README.md#L12",
61+ fileIcon: "readme",
62+ },
5863 ]);
5964 });
6065
@@ -73,6 +78,21 @@ describe("nativeMarkdownTextRuns", () => { · 12 unmodified lines
7378 expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]);
7479 });
7580
81+ it("can preserve soft breaks for authored user messages", () => {
82+ const node: MarkdownNode = {
83+ type: "paragraph",
84+ children: [
85+ { type: "text", content: "first" },
86+ { type: "soft_break" },
87+ { type: "text", content: "second" },
88+ ],
89+ };
90+
91+ expect(nativeMarkdownTextRuns(nativeMarkdownWithPreservedSoftBreaks(node))).toEqual([
92+ { text: "first\nsecond" },
93+ ]);
94+ });
95+
7696 it("normalizes common inline HTML and entities", () => {
7797 const node: MarkdownNode = {
7898 type: "paragraph",
@@ -130,7 +150,7 @@ describe("nativeMarkdownTextRuns", () => { · 51 unmodified lines
130150 });
131151
132152 describe("nativeMarkdownDocumentRuns", () => {
133− it("decorates known skill references as selectable skill chips", () => {
153+ it("decorates known skill references as selectable skill links", () => {
134154 const node: MarkdownNode = {
135155 type: "document",
136156 children: [
apps/mobile/src/lib/routes.test.ts+45−0
@@ -0,0 +1,45 @@
1+import { describe, expect, it } from "vite-plus/test";
2+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3+
4+import { buildThreadFilesNavigation, buildThreadFilesRoutePath } from "./routes";
5+
6+const thread = {
7+ environmentId: EnvironmentId.make("environment-1"),
8+ threadId: ThreadId.make("thread-1"),
9+};
10+
11+describe("thread file routes", () => {
12+ it("includes an optional source line in string routes", () => {
13+ expect(buildThreadFilesRoutePath(thread, "src/main.ts", 42)).toBe(
14+ "/threads/environment-1/thread-1/files/src/main.ts?line=42",
15+ );
16+ });
17+
18+ it("encodes each file path segment without encoding separators", () => {
19+ expect(buildThreadFilesRoutePath(thread, "docs/My File#1.md")).toBe(
20+ "/threads/environment-1/thread-1/files/docs/My%20File%231.md",
21+ );
22+ });
23+
24+ it("builds typed navigation params for a file and source line", () => {
25+ expect(buildThreadFilesNavigation(thread, "src/main.ts", 42)).toEqual({
26+ pathname: "/threads/[environmentId]/[threadId]/files/[...path]",
27+ params: {
28+ environmentId: "environment-1",
29+ threadId: "thread-1",
30+ path: ["src", "main.ts"],
31+ line: "42",
32+ },
33+ });
34+ });
35+
36+ it("targets the files index when no file path is provided", () => {
37+ expect(buildThreadFilesNavigation(thread)).toEqual({
38+ pathname: "/threads/[environmentId]/[threadId]/files",
39+ params: {
40+ environmentId: "environment-1",
41+ threadId: "thread-1",
42+ },
43+ });
44+ });
45+});
apps/mobile/src/lib/routes.ts+53−0
@@ -32,6 +32,27 @@ export function buildThreadReviewRoutePath( · 31 unmodified lines
3232 return `${buildThreadRoutePath(input)}/review`;
3333 }
3434
35+export function buildThreadFilesRoutePath(
36+ input: ThreadRouteInput | PlainThreadRouteInput,
37+ relativePath?: string | null,
38+ line?: number | null,
39+): string {
40+ const basePath = `${buildThreadRoutePath(input)}/files`;
41+ if (!relativePath) {
42+ return basePath;
43+ }
44+
45+ const pathSegments = relativePath.split("/").filter((segment) => segment.length > 0);
46+ if (pathSegments.length === 0) {
47+ return basePath;
48+ }
49+
50+ const encodedPath = pathSegments.map(encodeURIComponent).join("/");
51+ const lineParam =
52+ Number.isFinite(line) && Number(line) > 0 ? `?line=${Math.floor(Number(line))}` : "";
53+ return `${basePath}/${encodedPath}${lineParam}`;
54+}
55+
3556 export function buildThreadTerminalRoutePath(
3657 input: ThreadRouteInput | PlainThreadRouteInput,
3758 terminalId?: string | null,
@@ -71,6 +92,38 @@ export function buildThreadTerminalNavigation( · 33 unmodified lines
7192 };
7293 }
7394
95+export function buildThreadFilesNavigation(
96+ input: ThreadRouteInput | PlainThreadRouteInput,
97+ relativePath?: string | null,
98+ line?: number | null,
99+): Href {
100+ const environmentId = String(input.environmentId);
101+ const threadId = String("threadId" in input ? input.threadId : input.id);
102+ const path = relativePath?.split("/").filter((segment) => segment.length > 0) ?? [];
103+
104+ if (path.length === 0) {
105+ return {
106+ pathname: "/threads/[environmentId]/[threadId]/files",
107+ params: { environmentId, threadId },
108+ };
109+ }
110+
111+ const params: {
112+ environmentId: string;
113+ threadId: string;
114+ path: string[];
115+ line?: string;
116+ } = { environmentId, threadId, path };
117+ if (Number.isFinite(line) && Number(line) > 0) {
118+ params.line = String(Math.floor(Number(line)));
119+ }
120+
121+ return {
122+ pathname: "/threads/[environmentId]/[threadId]/files/[...path]",
123+ params,
124+ };
125+}
126+
74127 export function dismissRoute(router: Router) {
75128 if (router.canGoBack()) {
76129 router.back();
apps/mobile/src/lib/threadActivity.test.ts+53−2
@@ -161,14 +161,65 @@ describe("buildThreadFeed", () => { · 160 unmodified lines
161161 turnId: "turn-1",
162162 summary: "Run tests",
163163 detail: "bun run test",
164− fullDetail: null,
165− copyText: "Run tests\nbun run test",
164+ fullDetail: "/bin/zsh -lc 'bun run test'",
165+ copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
166+ icon: "command",
166167 toolLike: true,
167168 status: "success",
168169 },
169170 ]);
170171 });
171172
173+ it("keeps MCP inputs available to expanded mobile work rows", () => {
174+ const turnId = TurnId.make("turn-mcp");
175+ const thread = makeThread({
176+ id: ThreadId.make("thread-mcp"),
177+ projectId: ProjectId.make("project-1"),
178+ title: "Expandable MCP call",
179+ latestTurn: {
180+ turnId,
181+ state: "completed",
182+ requestedAt: "2026-04-01T00:00:00.000Z",
183+ startedAt: "2026-04-01T00:00:01.000Z",
184+ completedAt: "2026-04-01T00:00:03.000Z",
185+ assistantMessageId: null,
186+ },
187+ activities: [
188+ makeActivity({
189+ id: EventId.make("mcp-completed"),
190+ kind: "tool.completed",
191+ tone: "tool",
192+ summary: "Call repository tool",
193+ createdAt: "2026-04-01T00:00:02.000Z",
194+ turnId,
195+ payload: {
196+ title: "Call repository tool",
197+ itemType: "mcp_tool_call",
198+ detail: "repository.search",
199+ status: "completed",
200+ data: {
201+ item: {
202+ server: "repository",
203+ tool: "search",
204+ arguments: { query: "work log" },
205+ },
206+ },
207+ },
208+ }),
209+ ],
210+ });
211+
212+ const group = buildThreadFeed(thread, [], null)[0];
213+ expect(group).toMatchObject({ type: "activity-group" });
214+ if (!group || group.type !== "activity-group") {
215+ return;
216+ }
217+
218+ expect(group.activities[0]?.icon).toBe("wrench");
219+ expect(group.activities[0]?.fullDetail).toContain('"query": "work log"');
220+ expect(group.activities[0]?.fullDetail).toContain("repository.search");
221+ });
222+
172223 it("folds settled turn work while leaving the terminal answer visible", () => {
173224 const turnId = TurnId.make("turn-1");
174225 const thread = makeThread({
apps/mobile/src/lib/threadActivity.ts+72−9
@@ -40,6 +40,19 @@ export interface ThreadFeedActivity { · 39 unmodified lines
4040 readonly detail: string | null;
4141 readonly fullDetail: string | null;
4242 readonly copyText: string;
43+ readonly icon:
44+ | "agent"
45+ | "alert"
46+ | "check"
47+ | "command"
48+ | "edit"
49+ | "eye"
50+ | "globe"
51+ | "hammer"
52+ | "message"
53+ | "warning"
54+ | "wrench"
55+ | "zap";
4356 readonly toolLike: boolean;
4457 readonly status: "success" | "failure" | "neutral" | null;
4558 }
@@ -60,6 +73,7 @@ interface WorkLogEntry { · 14 unmodified lines
6073 itemType?: ToolLifecycleItemType;
6174 requestKind?: PendingApproval["requestKind"];
6275 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
76+ toolData?: unknown;
6377 }
6478
6579 interface DerivedWorkLogEntry extends WorkLogEntry {
@@ -214,7 +228,7 @@ function resolvePendingUserInputAnswer( · 148 unmodified lines
214228
215229 function deriveWorkLogEntries(
216230 activities: ReadonlyArray<OrchestrationThreadActivity>,
217−): WorkLogEntry[] {
231+): DerivedWorkLogEntry[] {
218232 const ordered = Arr.sort(activities, activityOrder);
219233 const entries: DerivedWorkLogEntry[] = [];
220234 for (const activity of ordered) {
@@ -225,9 +239,7 @@ function deriveWorkLogEntries( · 4 unmodified lines
225239 if (isPlanBoundaryToolActivity(activity)) continue;
226240 entries.push(toDerivedWorkLogEntry(activity));
227241 }
228− return collapseDerivedWorkLogEntries(entries).map(
229− ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry,
230− );
242+ return collapseDerivedWorkLogEntries(entries);
231243 }
232244
233245 function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
@@ -301,6 +313,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 67 unmodified lines
301313 if (title) {
302314 entry.toolTitle = title;
303315 }
316+ if (itemType === "mcp_tool_call") {
317+ const data = asRecord(payload?.data);
318+ if (data?.item !== undefined) {
319+ entry.toolData = data.item;
320+ }
321+ }
304322 if (itemType) {
305323 entry.itemType = itemType;
306324 }
@@ -365,6 +383,7 @@ function mergeDerivedWorkLogEntries( · 58 unmodified lines
365383 const requestKind = next.requestKind ?? previous.requestKind;
366384 const collapseKey = next.collapseKey ?? previous.collapseKey;
367385 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
386+ const toolData = next.toolData ?? previous.toolData;
368387 return {
369388 ...previous,
370389 ...next,
@@ -377,6 +396,7 @@ function mergeDerivedWorkLogEntries( · 6 unmodified lines
377396 ...(requestKind ? { requestKind } : {}),
378397 ...(collapseKey ? { collapseKey } : {}),
379398 ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
399+ ...(toolData !== undefined ? { toolData } : {}),
380400 };
381401 }
382402
@@ -480,6 +500,52 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { · 97 unmodified lines
480500 return "neutral";
481501 }
482502
503+function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
504+ if (
505+ entry.activityKind === "user-input.requested" ||
506+ entry.activityKind === "user-input.resolved"
507+ ) {
508+ return "message";
509+ }
510+ if (entry.activityKind === "runtime.warning") return "warning";
511+ if (entry.requestKind === "command") return "command";
512+ if (entry.requestKind === "file-read") return "eye";
513+ if (entry.requestKind === "file-change") return "edit";
514+ if (entry.itemType === "command_execution" || entry.command) return "command";
515+ if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit";
516+ if (entry.itemType === "web_search") return "globe";
517+ if (entry.itemType === "image_view") return "eye";
518+ if (entry.itemType === "mcp_tool_call") return "wrench";
519+ if (entry.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") {
520+ return "hammer";
521+ }
522+ if (entry.tone === "error") return "alert";
523+ if (entry.tone === "thinking") return "agent";
524+ if (entry.tone === "info") return "check";
525+ return "zap";
526+}
527+
528+function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null {
529+ const blocks: string[] = [];
530+ const appendUniqueBlock = (value: string | null | undefined) => {
531+ const trimmed = value?.trim();
532+ if (trimmed && !blocks.includes(trimmed)) {
533+ blocks.push(trimmed);
534+ }
535+ };
536+
537+ if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) {
538+ appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`);
539+ }
540+ appendUniqueBlock(entry.rawCommand ?? entry.command);
541+ appendUniqueBlock(entry.detail);
542+ if ((entry.changedFiles?.length ?? 0) > 0) {
543+ appendUniqueBlock(entry.changedFiles!.join("\n"));
544+ }
545+
546+ return blocks.length > 0 ? blocks.join("\n\n") : null;
547+}
548+
483549 function workEntryPreview(
484550 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,
485551 ): string | null {
@@ -1226,11 +1292,7 @@ export function buildThreadFeed( · 740 unmodified lines
12261292 .map<RawThreadFeedEntry>((entry) => {
12271293 const summary = workEntryHeading(entry);
12281294 const detail = workEntryPreview(entry);
1229− const normalizedFullDetail = entry.detail
1230− ? unwrapKnownShellCommandWrapper(entry.detail)
1231− : null;
1232− const fullDetail =
1233− normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null;
1295+ const fullDetail = buildWorkEntryExpandedBody(entry);
12341296 return {
12351297 type: "activity",
12361298 id: entry.id,
@@ -1243,6 +1305,7 @@ export function buildThreadFeed( · 6 unmodified lines
12431305 summary,
12441306 detail,
12451307 fullDetail,
1308+ icon: workEntryIcon(entry),
12461309 copyText: [summary, detail, fullDetail]
12471310 .filter((value, index, values): value is string => {
12481311 return Boolean(value) && values.indexOf(value) === index;
apps/server/src/assets/AssetAccess.test.ts+36−0
@@ -89,6 +89,42 @@ describe("AssetAccess", () => { · 88 unmodified lines
8989 }).pipe(Effect.provide(testLayer)),
9090 );
9191
92+ it.effect("issues exact workspace URLs for image previews", () =>
93+ Effect.gen(function* () {
94+ const fileSystem = yield* FileSystem.FileSystem;
95+ const path = yield* Path.Path;
96+ const root = yield* fileSystem.makeTempDirectoryScoped({
97+ prefix: "t3-asset-image-workspace-",
98+ });
99+ const assetsDirectory = path.join(root, "assets");
100+ const imagePath = path.join(assetsDirectory, "icon.png");
101+ const siblingPath = path.join(assetsDirectory, "other.png");
102+ yield* fileSystem.makeDirectory(assetsDirectory, { recursive: true });
103+ yield* fileSystem.writeFile(imagePath, new Uint8Array([137, 80, 78, 71]));
104+ yield* fileSystem.writeFile(siblingPath, new Uint8Array([137, 80, 78, 71]));
105+ const canonicalImagePath = yield* fileSystem.realPath(imagePath);
106+
107+ const result = yield* issueAssetUrl({
108+ resource: {
109+ _tag: "workspace-file",
110+ threadId: ThreadId.make("thread-1"),
111+ path: imagePath,
112+ },
113+ workspaceRoot: root,
114+ });
115+ const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
116+ const separatorIndex = suffix.indexOf("/");
117+ const token = suffix.slice(0, separatorIndex);
118+
119+ expect(yield* resolveAsset(token, "icon.png")).toEqual({
120+ kind: "file",
121+ path: canonicalImagePath,
122+ });
123+ expect(yield* resolveAsset(token, "other.png")).toBeNull();
124+ expect(yield* resolveAsset(token, "../icon.png")).toBeNull();
125+ }).pipe(Effect.provide(testLayer)),
126+ );
127+
92128 it.effect("issues exact attachment capabilities by attachment id", () =>
93129 Effect.gen(function* () {
94130 const config = yield* ServerConfig;
apps/server/src/assets/AssetAccess.ts+45−21
@@ -1,5 +1,11 @@
11 import type { AssetResource } from "@t3tools/contracts";
22 import { AssetAccessError } from "@t3tools/contracts";
3+import {
4+ isWorkspaceImagePreviewPath,
5+ isWorkspacePreviewEntryPath,
6+ WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
7+ WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
8+} from "@t3tools/shared/filePreview";
39 import * as Clock from "effect/Clock";
410 import * as Effect from "effect/Effect";
511 import * as FileSystem from "effect/FileSystem";
@@ -24,22 +30,14 @@ export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/ · 18 unmodified lines
2430
2531 const SIGNING_SECRET_NAME = "asset-access-signing-key";
2632 const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
27−const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]);
2833 const PREVIEW_ASSET_EXTENSIONS = new Set([
29− ...PREVIEWABLE_EXTENSIONS,
30− ".avif",
34+ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
35+ ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
3136 ".css",
32− ".gif",
33− ".ico",
34− ".jpeg",
35− ".jpg",
3637 ".js",
3738 ".mjs",
3839 ".otf",
39− ".png",
40− ".svg",
4140 ".ttf",
42− ".webp",
4341 ".woff",
4442 ".woff2",
4543 ]);
@@ -52,6 +50,13 @@ const AssetClaimsSchema = Schema.Union([ · 6 unmodified lines
5250 baseRelativePath: Schema.String,
5351 expiresAt: Schema.Number,
5452 }),
53+ Schema.Struct({
54+ version: Schema.Literal(1),
55+ kind: Schema.Literal("workspace-file-exact"),
56+ workspaceRoot: Schema.String,
57+ relativePath: Schema.String,
58+ expiresAt: Schema.Number,
59+ }),
5560 Schema.Struct({
5661 version: Schema.Literal(1),
5762 kind: Schema.Literal("attachment"),
@@ -144,8 +149,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i · 86 unmodified lines
144149 const resolved = yield* workspacePaths
145150 .resolveRelativePathWithinRoot({ workspaceRoot, relativePath })
146151 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
147− if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) {
148− return yield* failAccess("Only HTML and PDF files can open in the browser.");
152+ if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {
153+ return yield* failAccess("Only browser documents and images can be previewed.");
149154 }
150155 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
151156 workspaceRoot,
@@ -154,15 +159,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i · 2 unmodified lines
154159 if (!canonicalFile) {
155160 return yield* failAccess("Workspace asset was not found.");
156161 }
157− claims = {
158− version: 1,
159− kind: "workspace-file",
160− workspaceRoot: yield* fileSystem
161− .realPath(workspaceRoot)
162− .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),
163− baseRelativePath: path.dirname(resolved.relativePath),
164− expiresAt,
165− };
162+ const canonicalWorkspaceRoot = yield* fileSystem
163+ .realPath(workspaceRoot)
164+ .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause)));
165+ claims = isWorkspaceImagePreviewPath(resolved.relativePath)
166+ ? {
167+ version: 1,
168+ kind: "workspace-file-exact",
169+ workspaceRoot: canonicalWorkspaceRoot,
170+ relativePath: resolved.relativePath,
171+ expiresAt,
172+ }
173+ : {
174+ version: 1,
175+ kind: "workspace-file",
176+ workspaceRoot: canonicalWorkspaceRoot,
177+ baseRelativePath: path.dirname(resolved.relativePath),
178+ expiresAt,
179+ };
166180 fileName = path.basename(resolved.relativePath);
167181 break;
168182 }
@@ -268,6 +282,16 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( · 99 unmodified lines
268282 const decodedPath = decodeRelativePath(relativePath);
269283 if (decodedPath === null) return null;
270284 const path = yield* Path.Path;
285+ if (claims.kind === "workspace-file-exact") {
286+ if (decodedPath !== path.basename(claims.relativePath)) return null;
287+ const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFile({
288+ workspaceRoot: claims.workspaceRoot,
289+ relativePath: claims.relativePath,
290+ });
291+ return exactWorkspaceFile
292+ ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset)
293+ : null;
294+ }
271295 const segments = decodedPath.split(/[\\/]/);
272296 if (
273297 decodedPath.length === 0 ||
apps/web/src/AppRoot.test.tsx+22−0
@@ -0,0 +1,22 @@
1+import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
2+import { RouterProvider } from "@tanstack/react-router";
3+import { describe, expect, it } from "vite-plus/test";
4+
5+import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
6+import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
7+import type { AppRouter } from "./router";
8+import { AppRoot } from "./AppRoot";
9+
10+describe("AppRoot", () => {
11+ it("shares the application atom registry with routed UI and the Electron browser host", () => {
12+ const root = AppRoot({ router: {} as AppRouter });
13+
14+ expect(root.type).toBe(AppAtomRegistryProvider);
15+ const children = Children.toArray(
16+ (root as ReactElement<{ readonly children: ReactNode }>).props.children,
17+ );
18+ expect(children).toHaveLength(2);
19+ expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);
20+ expect(isValidElement(children[1]) && children[1].type).toBe(ElectronBrowserHost);
21+ });
22+});
apps/web/src/AppRoot.tsx+19−0
@@ -0,0 +1,19 @@
1+import { RouterProvider } from "@tanstack/react-router";
2+
3+import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
4+import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
5+import type { AppRouter } from "./router";
6+
7+/**
8+ * Owns renderer-wide providers. The Electron browser host intentionally sits
9+ * outside the router so its webviews survive route transitions, but it must
10+ * share the same atom registry as routed UI.
11+ */
12+export function AppRoot({ router }: { readonly router: AppRouter }) {
13+ return (
14+ <AppAtomRegistryProvider>
15+ <RouterProvider router={router} />
16+ <ElectronBrowserHost />
17+ </AppAtomRegistryProvider>
18+ );
19+}
apps/web/src/browser/HostedBrowserWebview.tsx+33−10
@@ -9,7 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; · 8 unmodified lines
99
1010 import { useActiveBrowserRecordingTabId } from "./browserRecording";
1111 import { useBrowserSurfaceStore } from "./browserSurfaceStore";
12−import { acquireDesktopTab } from "./desktopTabLifetime";
12+import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
1313 import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
1414
1515 interface ElectronWebview extends HTMLElement {
@@ -34,13 +34,21 @@ export function HostedBrowserWebview(props: { · 18 unmodified lines
3434 const { threadRef, tabId, initialUrl } = props;
3535 const config = usePreviewWebviewConfig(threadRef.environmentId);
3636 const initialSrcRef = useRef(initialUrl ?? "about:blank");
37+ const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
3738 const webviewRef = useRef<ElectronWebview | null>(null);
3839 const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null));
3940 const recording = useActiveBrowserRecordingTabId() === tabId;
4041
4142 usePreviewBridge({ threadRef, tabId });
4243
43− useEffect(() => acquireDesktopTab(tabId), [tabId]);
44+ useEffect(() => {
45+ const lease = acquireDesktopTab(tabId);
46+ tabLeaseRef.current = lease;
47+ return () => {
48+ if (tabLeaseRef.current === lease) tabLeaseRef.current = null;
49+ lease.release();
50+ };
51+ }, [tabId]);
4452
4553 const setWebviewRef = useCallback((node: HTMLElement | null) => {
4654 webviewRef.current = node as ElectronWebview | null;
@@ -51,19 +59,34 @@ export function HostedBrowserWebview(props: { · 4 unmodified lines
5159 const webview = webviewRef.current;
5260 const bridge = previewBridge;
5361 if (!webview || !config || !bridge) return;
62+ let disposed = false;
5463 const register = () => {
55− try {
56− const webContentsId = webview.getWebContentsId();
57− if (Number.isInteger(webContentsId) && webContentsId > 0) {
58− void bridge.registerWebview(tabId, webContentsId);
64+ const lease = tabLeaseRef.current;
65+ if (!lease) return;
66+ void (async () => {
67+ try {
68+ // The main-process tab and the DOM webview are created by separate
69+ // effects. Wait for the former so registration cannot race and fail
70+ // with PreviewTabNotFoundError on a fast about:blank attachment.
71+ await lease.ready;
72+ if (disposed || webviewRef.current !== webview) return;
73+ const webContentsId = webview.getWebContentsId();
74+ if (Number.isInteger(webContentsId) && webContentsId > 0) {
75+ await bridge.registerWebview(tabId, webContentsId);
76+ }
77+ } catch {
78+ // did-attach/dom-ready will retry if the guest was not ready yet.
5979 }
60− } catch {
61− // A later dom-ready will retry registration.
62− }
80+ })();
6381 };
82+ webview.addEventListener("did-attach", register);
6483 webview.addEventListener("dom-ready", register);
6584 register();
66− return () => webview.removeEventListener("dom-ready", register);
85+ return () => {
86+ disposed = true;
87+ webview.removeEventListener("did-attach", register);
88+ webview.removeEventListener("dom-ready", register);
89+ };
6790 }, [config, tabId]);
6891
6992 if (!config) return null;
apps/web/src/browser/desktopTabLifetime.test.ts+45−0
@@ -0,0 +1,45 @@
1+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
3+const { closeTab, createTab } = vi.hoisted(() => ({
4+ closeTab: vi.fn(async () => undefined),
5+ createTab: vi.fn<() => Promise<void>>(),
6+}));
7+
8+vi.mock("~/components/preview/previewBridge", () => ({
9+ previewBridge: { closeTab, createTab },
10+}));
11+
12+import { acquireDesktopTab } from "./desktopTabLifetime";
13+
14+describe("desktopTabLifetime", () => {
15+ beforeEach(() => {
16+ closeTab.mockClear();
17+ createTab.mockClear();
18+ });
19+
20+ it("shares tab creation readiness across concurrent leases", async () => {
21+ let resolveCreation: (() => void) | undefined;
22+ createTab.mockReturnValueOnce(
23+ new Promise<void>((resolve) => {
24+ resolveCreation = resolve;
25+ }),
26+ );
27+
28+ const first = acquireDesktopTab("tab_readiness");
29+ const second = acquireDesktopTab("tab_readiness");
30+
31+ expect(createTab).toHaveBeenCalledOnce();
32+ expect(first.ready).toBe(second.ready);
33+
34+ let ready = false;
35+ void first.ready.then(() => {
36+ ready = true;
37+ });
38+ await Promise.resolve();
39+ expect(ready).toBe(false);
40+
41+ resolveCreation?.();
42+ await first.ready;
43+ expect(ready).toBe(true);
44+ });
45+});
apps/web/src/browser/desktopTabLifetime.ts+28−14
@@ -3,28 +3,42 @@ import { previewBridge } from "~/components/preview/previewBridge"; · 2 unmodified lines
33 interface DesktopTabLease {
44 references: number;
55 closeTimer: number | null;
6+ ready: Promise<void>;
67 }
78
89 const leases = new Map<string, DesktopTabLease>();
910
10−export function acquireDesktopTab(tabId: string): () => void {
11− const current = leases.get(tabId) ?? { references: 0, closeTimer: null };
11+export interface AcquiredDesktopTab {
12+ readonly ready: Promise<void>;
13+ readonly release: () => void;
14+}
15+
16+export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {
17+ const current =
18+ leases.get(tabId) ??
19+ ({
20+ references: 0,
21+ closeTimer: null,
22+ ready: previewBridge?.createTab(tabId) ?? Promise.resolve(),
23+ } satisfies DesktopTabLease);
1224 if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);
1325 current.references += 1;
1426 current.closeTimer = null;
1527 leases.set(tabId, current);
16− if (current.references === 1) void previewBridge?.createTab(tabId);
1728
18− return () => {
19− const lease = leases.get(tabId);
20− if (!lease) return;
21− lease.references = Math.max(0, lease.references - 1);
22− if (lease.references > 0) return;
23− lease.closeTimer = window.setTimeout(() => {
24− const latest = leases.get(tabId);
25− if (!latest || latest.references > 0) return;
26− leases.delete(tabId);
27− void previewBridge?.closeTab(tabId);
28− }, 0);
29+ return {
30+ ready: current.ready,
31+ release: () => {
32+ const lease = leases.get(tabId);
33+ if (!lease) return;
34+ lease.references = Math.max(0, lease.references - 1);
35+ if (lease.references > 0) return;
36+ lease.closeTimer = window.setTimeout(() => {
37+ const latest = leases.get(tabId);
38+ if (!latest || latest.references > 0) return;
39+ leases.delete(tabId);
40+ void previewBridge?.closeTab(tabId);
41+ }, 0);
42+ },
2943 };
3044 }
apps/web/src/components/ChatView.tsx+17−24
@@ -111,12 +111,12 @@ import { · 110 unmodified lines
111111 useRightPanelStore,
112112 } from "../rightPanelStore";
113113 import {
114− applyPreviewServerSnapshot,
115114 isPreviewSupportedInRuntime,
116− removePreviewSession,
117115 setActivePreviewTab,
118116 useThreadPreviewState,
119117 } from "../previewStateStore";
118+import { addBrowserSurface } from "./preview/addBrowserSurface";
119+import { closePreviewSession } from "./preview/closePreviewSession";
120120 import { subscribePreviewAction } from "./preview/previewActionBus";
121121 import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
122122 import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
@@ -2746,23 +2746,8 @@ function ChatViewContent(props: ChatViewProps) { · 2623 unmodified lines
27462746 }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]);
27472747 const createBrowserSurface = useCallback(() => {
27482748 if (!activeThreadRef) return;
2749− const activeTabId = activePreviewState.activeTabId;
2750− if (activeTabId) {
2751− useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);
2752− return;
2753− }
2754− void (async () => {
2755− const result = await openPreview({
2756− environmentId: activeThreadRef.environmentId,
2757− input: { threadId: activeThreadRef.threadId },
2758− });
2759− if (result._tag === "Failure") {
2760− return;
2761− }
2762− applyPreviewServerSnapshot(activeThreadRef, result.value);
2763− useRightPanelStore.getState().openBrowser(activeThreadRef, result.value.tabId);
2764− })();
2765− }, [activePreviewState.activeTabId, activeThreadRef, openPreview]);
2749+ void addBrowserSurface({ threadRef: activeThreadRef, openPreview });
2750+ }, [activeThreadRef, openPreview]);
27662751 const addDiffSurface = useCallback(() => {
27672752 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
27682753 useRightPanelStore.getState().open(activeThreadRef, "diff");
@@ -2807,8 +2792,14 @@ function ChatViewContent(props: ChatViewProps) { · 38 unmodified lines
28072792 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
28082793 });
28092794 }
2810− createBrowserSurface();
2795+ const activeTabId = activePreviewState.activeTabId;
2796+ if (activeTabId) {
2797+ useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);
2798+ } else {
2799+ createBrowserSurface();
2800+ }
28112801 }, [
2802+ activePreviewState.activeTabId,
28122803 activeThreadRef,
28132804 createBrowserSurface,
28142805 diffOpen,
@@ -2993,10 +2984,11 @@ function ChatViewContent(props: ChatViewProps) { · 178 unmodified lines
29932984
29942985 for (const surface of surfaces) {
29952986 if (surface.kind === "preview" && surface.resourceId) {
2996− removePreviewSession(activeThreadRef, surface.resourceId);
2997− void closePreview({
2998− environmentId: activeThreadRef.environmentId,
2999− input: { threadId: activeThreadRef.threadId, tabId: surface.resourceId },
2987+ void closePreviewSession({
2988+ closePreview,
2989+ snapshot: activePreviewState.sessions[surface.resourceId] ?? null,
2990+ tabId: surface.resourceId,
2991+ threadRef: activeThreadRef,
30002992 });
30012993 }
30022994 if (surface.kind === "terminal") {
@@ -3020,6 +3012,7 @@ function ChatViewContent(props: ChatViewProps) { · 17 unmodified lines
30203012 },
30213013 [
30223014 activeThreadRef,
3015+ activePreviewState.sessions,
30233016 closePreview,
30243017 closeTerminalMutation,
30253018 diffOpen,
apps/web/src/components/preview/addBrowserSurface.test.ts+52−0
@@ -0,0 +1,52 @@
1+import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
2+import { AsyncResult } from "effect/unstable/reactivity";
3+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4+
5+import {
6+ applyPreviewServerSnapshot,
7+ readThreadPreviewState,
8+ resetPreviewStateForTests,
9+} from "~/previewStateStore";
10+import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore";
11+
12+import { addBrowserSurface } from "./addBrowserSurface";
13+
14+const threadRef = {
15+ environmentId: "local" as ScopedThreadRef["environmentId"],
16+ threadId: "thread-1" as ScopedThreadRef["threadId"],
17+};
18+
19+const snapshot = (tabId: string): PreviewSessionSnapshot => ({
20+ threadId: threadRef.threadId,
21+ tabId,
22+ navStatus: { _tag: "Idle" },
23+ canGoBack: false,
24+ canGoForward: false,
25+ updatedAt: `2026-06-18T19:00:0${tabId.at(-1) ?? "0"}.000Z`,
26+});
27+
28+beforeEach(() => {
29+ resetPreviewStateForTests();
30+ useRightPanelStore.setState({ byThreadKey: {} });
31+});
32+
33+describe("addBrowserSurface", () => {
34+ it("creates another preview session when a browser tab is already active", async () => {
35+ const first = snapshot("tab-1");
36+ const second = snapshot("tab-2");
37+ applyPreviewServerSnapshot(threadRef, first);
38+ useRightPanelStore.getState().openBrowser(threadRef, first.tabId);
39+ const openPreview = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(second));
40+
41+ await addBrowserSurface({ threadRef, openPreview: ({ input }) => openPreview(input) });
42+
43+ expect(openPreview).toHaveBeenCalledWith({ threadId: "thread-1" });
44+ expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]);
45+ expect(
46+ selectThreadRightPanelState(
47+ useRightPanelStore.getState().byThreadKey,
48+ threadRef,
49+ ).surfaces.map((surface) => surface.id),
50+ ).toEqual(["browser:tab-1", "browser:tab-2"]);
51+ });
52+});
apps/web/src/components/preview/addBrowserSurface.ts+24−0
@@ -0,0 +1,24 @@
1+import {
2+ mapAtomCommandResult,
3+ type AtomCommandResult,
4+} from "@t3tools/client-runtime/state/runtime";
5+import type { ScopedThreadRef } from "@t3tools/contracts";
6+
7+import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
8+import { useRightPanelStore } from "~/rightPanelStore";
9+
10+import { openPreviewSession } from "./openPreviewSession";
11+
12+/** Creates a new browser tab. Reopening an existing tab is a separate UI action. */
13+export async function addBrowserSurface<E>(input: {
14+ readonly threadRef: ScopedThreadRef;
15+ readonly openPreview: OpenPreviewMutation<E>;
16+}): Promise<AtomCommandResult<void, E>> {
17+ const result = await openPreviewSession({
18+ openPreview: input.openPreview,
19+ threadRef: input.threadRef,
20+ });
21+ return mapAtomCommandResult(result, (snapshot) => {
22+ useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
23+ });
24+}
apps/web/src/components/preview/closePreviewSession.test.ts+79−0
@@ -0,0 +1,79 @@
1+import type {
2+ PreviewCloseInput,
3+ PreviewSessionSnapshot,
4+ ScopedThreadRef,
5+} from "@t3tools/contracts";
6+import * as Cause from "effect/Cause";
7+import { AsyncResult } from "effect/unstable/reactivity";
8+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
9+
10+import {
11+ applyPreviewServerSnapshot,
12+ readThreadPreviewState,
13+ resetPreviewStateForTests,
14+} from "~/previewStateStore";
15+
16+import { closePreviewSession } from "./closePreviewSession";
17+
18+const threadRef = {
19+ environmentId: "local" as ScopedThreadRef["environmentId"],
20+ threadId: "thread-1" as ScopedThreadRef["threadId"],
21+};
22+
23+const snapshot: PreviewSessionSnapshot = {
24+ threadId: threadRef.threadId,
25+ tabId: "tab-1",
26+ navStatus: {
27+ _tag: "Success",
28+ url: "http://localhost:3000/",
29+ title: "Local app",
30+ },
31+ canGoBack: false,
32+ canGoForward: false,
33+ updatedAt: "2026-06-18T19:00:00.000Z",
34+};
35+
36+beforeEach(resetPreviewStateForTests);
37+
38+describe("closePreviewSession", () => {
39+ it("suppresses stale server snapshots while the close is in flight", async () => {
40+ applyPreviewServerSnapshot(threadRef, snapshot);
41+ let finishClose: (() => void) | undefined;
42+ const closePreview = vi.fn(
43+ (_input: PreviewCloseInput) =>
44+ new Promise<ReturnType<typeof AsyncResult.success<void>>>((resolve) => {
45+ finishClose = () => resolve(AsyncResult.success(undefined));
46+ }),
47+ );
48+
49+ const closing = closePreviewSession({
50+ closePreview: ({ input }) => closePreview(input),
51+ snapshot,
52+ tabId: snapshot.tabId,
53+ threadRef,
54+ });
55+
56+ expect(readThreadPreviewState(threadRef).sessions).toEqual({});
57+ applyPreviewServerSnapshot(threadRef, snapshot);
58+ expect(readThreadPreviewState(threadRef).sessions).toEqual({});
59+
60+ finishClose?.();
61+ await closing;
62+ expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" });
63+ });
64+
65+ it("restores the last snapshot when the server close fails", async () => {
66+ applyPreviewServerSnapshot(threadRef, snapshot);
67+
68+ const result = await closePreviewSession({
69+ closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))),
70+ snapshot,
71+ tabId: snapshot.tabId,
72+ threadRef,
73+ });
74+
75+ expect(result._tag).toBe("Failure");
76+ expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
77+ expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot });
78+ });
79+});
apps/web/src/components/preview/closePreviewSession.ts+37−0
@@ -0,0 +1,37 @@
1+import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
2+import type {
3+ EnvironmentId,
4+ PreviewCloseInput,
5+ PreviewSessionSnapshot,
6+ ScopedThreadRef,
7+} from "@t3tools/contracts";
8+
9+import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore";
10+
11+interface ClosePreviewSessionInput<E> {
12+ readonly closePreview: (input: {
13+ readonly environmentId: EnvironmentId;
14+ readonly input: PreviewCloseInput;
15+ }) => Promise<AtomCommandResult<void, E>>;
16+ readonly snapshot: PreviewSessionSnapshot | null;
17+ readonly tabId: string;
18+ readonly threadRef: ScopedThreadRef;
19+}
20+
21+/**
22+ * Optimistically closes a preview while suppressing stale list responses for
23+ * the same tab. A failed close restores the last known snapshot.
24+ */
25+export async function closePreviewSession<E>(
26+ input: ClosePreviewSessionInput<E>,
27+): Promise<AtomCommandResult<void, E>> {
28+ beginPreviewSessionClose(input.threadRef, input.tabId);
29+ const result = await input.closePreview({
30+ environmentId: input.threadRef.environmentId,
31+ input: { threadId: input.threadRef.threadId, tabId: input.tabId },
32+ });
33+ if (result._tag === "Failure") {
34+ cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId);
35+ }
36+ return result;
37+}
apps/web/src/components/preview/openPreviewSession.test.ts+18−0
@@ -28,6 +28,24 @@ const snapshot: PreviewSessionSnapshot = { · 27 unmodified lines
2828 beforeEach(resetPreviewStateForTests);
2929
3030 describe("openPreviewSession", () => {
31+ it("creates an idle tab without recording a recently visited URL", async () => {
32+ const idleSnapshot: PreviewSessionSnapshot = {
33+ ...snapshot,
34+ tabId: "tab-blank",
35+ navStatus: { _tag: "Idle" },
36+ };
37+ const open = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(idleSnapshot));
38+
39+ await openPreviewSession({
40+ openPreview: ({ input }) => open(input),
41+ threadRef,
42+ });
43+
44+ expect(open).toHaveBeenCalledWith({ threadId: "thread-1" });
45+ expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot);
46+ expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);
47+ });
48+
3149 it("applies the RPC response without waiting for a preview event", async () => {
3250 const open = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(snapshot));
3351
apps/web/src/components/preview/openPreviewSession.ts+8−6
@@ -14,7 +14,7 @@ interface OpenPreviewSessionInput<E> { · 13 unmodified lines
1414 readonly input: PreviewOpenInput;
1515 }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;
1616 threadRef: ScopedThreadRef;
17− url: string;
17+ url?: string;
1818 }
1919
2020 export async function openPreviewSession<E>(
@@ -24,7 +24,7 @@ export async function openPreviewSession<E>( · 3 unmodified lines
2424 environmentId: input.threadRef.environmentId,
2525 input: {
2626 threadId: input.threadRef.threadId,
27− url: input.url,
27+ ...(input.url === undefined ? {} : { url: input.url }),
2828 },
2929 });
3030 if (result._tag === "Failure") {
@@ -32,9 +32,11 @@ export async function openPreviewSession<E>( · 1 unmodified line
3232 }
3333 const snapshot = result.value;
3434 applyPreviewServerSnapshot(input.threadRef, snapshot);
35− rememberPreviewUrl(
36− input.threadRef,
37− snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url,
38− );
35+ if (input.url !== undefined) {
36+ rememberPreviewUrl(
37+ input.threadRef,
38+ snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url,
39+ );
40+ }
3941 return result;
4042 }
apps/web/src/lib/archivedThreadsState.ts+6−39
@@ -1,53 +1,15 @@
11 import { useAtomValue } from "@effect/atom-react";
22 import {
33 type ArchivedSnapshotEntry,
4+ createArchivedThreadSnapshotsAtomFamily,
45 makeArchivedThreadsEnvironmentKey,
5− parseArchivedThreadsEnvironmentKey,
66 } from "@t3tools/client-runtime/state/threads";
77 import type { EnvironmentId } from "@t3tools/contracts";
8−import * as Cause from "effect/Cause";
9−import * as Option from "effect/Option";
10−import { AsyncResult, Atom } from "effect/unstable/reactivity";
118 import { useCallback, useMemo } from "react";
129
1310 import { orchestrationEnvironment } from "../state/orchestration";
1411 import { appAtomRegistry } from "../rpc/atomRegistry";
1512
16−const archivedSnapshotsAtom = Atom.family((environmentKey: string) =>
17− Atom.make((get) => {
18− const snapshots: ArchivedSnapshotEntry[] = [];
19− let error: string | null = null;
20− let isLoading = false;
21−
22− for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) {
23− const result = get(
24− orchestrationEnvironment.archivedShellSnapshot({
25− environmentId,
26− input: {},
27− }),
28− );
29− isLoading ||= result.waiting;
30− const snapshot = Option.getOrNull(AsyncResult.value(result));
31− if (snapshot !== null) {
32− snapshots.push({ environmentId, snapshot });
33− }
34− if (error === null && result._tag === "Failure") {
35− const cause = Cause.squash(result.cause);
36− error =
37− cause instanceof Error && cause.message.trim().length > 0
38− ? cause.message
39− : "Failed to load archived threads.";
40− }
41− }
42−
43− return {
44− snapshots,
45− error,
46− isLoading,
47− };
48− }).pipe(Atom.withLabel(`web:archived-thread-snapshots:${environmentKey}`)),
49−);
50−
5113 function archivedSnapshotAtom(environmentId: EnvironmentId) {
5214 return orchestrationEnvironment.archivedShellSnapshot({
5315 environmentId,
@@ -55,6 +17,11 @@ function archivedSnapshotAtom(environmentId: EnvironmentId) { · 1 unmodified line
5517 });
5618 }
5719
20+const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({
21+ getSnapshotAtom: archivedSnapshotAtom,
22+ labelPrefix: "web:archived-thread-snapshots",
23+});
24+
5825 export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {
5926 appAtomRegistry.refresh(archivedSnapshotAtom(environmentId));
6027 }
apps/web/src/lib/threadSort.ts+7−86
@@ -1,86 +1,7 @@
1−import type { ProjectId } from "@t3tools/contracts";
2−import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3−import type { Thread } from "../types";
4−
5−export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & {
6− latestUserMessageAt?: string | null;
7− messages?: ReadonlyArray<Pick<Thread["messages"][number], "createdAt" | "role">>;
8−};
9−
10−export function toSortableTimestamp(iso: string | undefined): number | null {
11− if (!iso) return null;
12− const ms = Date.parse(iso);
13− return Number.isFinite(ms) ? ms : null;
14−}
15−
16−function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
17− for (const value of values) {
18− const timestamp = toSortableTimestamp(value ?? undefined);
19− if (timestamp !== null) {
20− return timestamp;
21− }
22− }
23−
24− return null;
25−}
26−
27−function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
28− if (thread.latestUserMessageAt) {
29− return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
30− }
31−
32− let latestUserMessageTimestamp: number | null = null;
33−
34− for (const message of thread.messages ?? []) {
35− if (message.role !== "user") continue;
36− const messageTimestamp = toSortableTimestamp(message.createdAt);
37− if (messageTimestamp === null) continue;
38− latestUserMessageTimestamp =
39− latestUserMessageTimestamp === null
40− ? messageTimestamp
41− : Math.max(latestUserMessageTimestamp, messageTimestamp);
42− }
43−
44− if (latestUserMessageTimestamp !== null) {
45− return latestUserMessageTimestamp;
46− }
47−
48− return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY;
49−}
50−
51−export function getThreadSortTimestamp(
52− thread: ThreadSortInput,
53− sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
54−): number {
55− if (sortOrder === "created_at") {
56− return (
57− getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
58− );
59− }
60− return getLatestUserMessageTimestamp(thread);
61−}
62−
63−export function sortThreads<T extends Pick<Thread, "id"> & ThreadSortInput>(
64− threads: readonly T[],
65− sortOrder: SidebarThreadSortOrder,
66−): T[] {
67− return threads.toSorted((left, right) => {
68− const rightTimestamp = getThreadSortTimestamp(right, sortOrder);
69− const leftTimestamp = getThreadSortTimestamp(left, sortOrder);
70− const byTimestamp =
71− rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
72− if (byTimestamp !== 0) return byTimestamp;
73− return right.id.localeCompare(left.id);
74− });
75−}
76−
77−export function getLatestThreadForProject<
78− T extends Pick<Thread, "id" | "projectId" | "archivedAt"> & ThreadSortInput,
79−>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null {
80− return (
81− sortThreads(
82− threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null),
83− sortOrder,
84− )[0] ?? null
85− );
86−}
1+export {
2+ getLatestThreadForProject,
3+ getThreadSortTimestamp,
4+ sortThreads,
5+ toSortableTimestamp,
6+ type ThreadSortInput,
7+} from "@t3tools/client-runtime/state/thread-sort";
apps/web/src/logicalProject.ts+14−184
@@ -1,184 +1,14 @@
1−import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
2−import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
3−import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
4−import type { UnifiedSettings } from "@t3tools/contracts/settings";
5−import { normalizeProjectPathForComparison } from "./lib/projectPaths";
6−
7−export interface ProjectGroupingSettings {
8− sidebarProjectGroupingMode: SidebarProjectGroupingMode;
9− sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>;
10−}
11−
12−export type ProjectGroupingMode = SidebarProjectGroupingMode;
13−
14−export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings {
15− return {
16− sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
17− sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
18− };
19−}
20−
21−function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): string[] {
22− const seen = new Set<string>();
23− const unique: string[] = [];
24− for (const value of values) {
25− const trimmed = value?.trim();
26− if (!trimmed || seen.has(trimmed)) {
27− continue;
28− }
29− seen.add(trimmed);
30− unique.push(trimmed);
31− }
32− return unique;
33−}
34−
35−function deriveRepositoryRelativeProjectPath(
36− project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
37−): string | null {
38− const rootPath = project.repositoryIdentity?.rootPath?.trim();
39− if (!rootPath) {
40− return null;
41− }
42−
43− const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot);
44− const normalizedRootPath = normalizeProjectPathForComparison(rootPath);
45− if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) {
46− return null;
47− }
48−
49− if (normalizedProjectPath === normalizedRootPath) {
50− return "";
51− }
52−
53− const separator = normalizedRootPath.includes("\\") ? "\\" : "/";
54− const rootPrefix = `${normalizedRootPath}${separator}`;
55− if (!normalizedProjectPath.startsWith(rootPrefix)) {
56− return null;
57− }
58−
59− return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/");
60−}
61−
62−export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string {
63− return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`;
64−}
65−
66−export function derivePhysicalProjectKey(
67− project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
68−): string {
69− return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot);
70−}
71−
72−export function deriveProjectGroupingOverrideKey(
73− project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
74−): string {
75− return derivePhysicalProjectKey(project);
76−}
77−
78−// Key under which a project's manual sort order (projectOrder) is stored.
79−// Must stay aligned with the drag handlers and readers in `Sidebar`.
80−export function getProjectOrderKey(
81− project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
82−): string {
83− return derivePhysicalProjectKey(project);
84−}
85−
86−export function resolveProjectGroupingMode(
87− project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
88− settings: ProjectGroupingSettings,
89−): SidebarProjectGroupingMode {
90− return (
91− settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ??
92− settings.sidebarProjectGroupingMode
93− );
94−}
95−
96−function deriveRepositoryScopedKey(
97− project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
98− groupingMode: SidebarProjectGroupingMode,
99−): string | null {
100− const canonicalKey = project.repositoryIdentity?.canonicalKey;
101− if (!canonicalKey) {
102− return null;
103− }
104−
105− if (groupingMode === "repository") {
106− return canonicalKey;
107− }
108−
109− const relativeProjectPath = deriveRepositoryRelativeProjectPath(project);
110− if (relativeProjectPath === null) {
111− return canonicalKey;
112− }
113−
114− return relativeProjectPath.length === 0
115− ? canonicalKey
116− : `${canonicalKey}::${relativeProjectPath}`;
117−}
118−
119−export function deriveLogicalProjectKey(
120− project: Pick<
121− EnvironmentProject,
122− "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"
123− >,
124− options?: {
125− groupingMode?: SidebarProjectGroupingMode;
126− },
127−): string {
128− const groupingMode = options?.groupingMode ?? "repository";
129− if (groupingMode === "separate") {
130− return derivePhysicalProjectKey(project);
131− }
132−
133− return (
134− deriveRepositoryScopedKey(project, groupingMode) ??
135− derivePhysicalProjectKey(project) ??
136− scopedProjectKey(scopeProjectRef(project.environmentId, project.id))
137− );
138−}
139−
140−export function deriveLogicalProjectKeyFromSettings(
141− project: Pick<
142− EnvironmentProject,
143− "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"
144− >,
145− settings: ProjectGroupingSettings,
146−): string {
147− return deriveLogicalProjectKey(project, {
148− groupingMode: resolveProjectGroupingMode(project, settings),
149− });
150−}
151−
152−export function deriveLogicalProjectKeyFromRef(
153− projectRef: ScopedProjectRef,
154− project:
155− | Pick<EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity">
156− | null
157− | undefined,
158− options?: {
159− groupingMode?: SidebarProjectGroupingMode;
160− },
161−): string {
162− return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef);
163−}
164−
165−export function deriveProjectGroupLabel(input: {
166− representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;
167− members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;
168−}): string {
169− const sharedDisplayNames = uniqueNonEmptyValues(
170− input.members.map((member) => member.repositoryIdentity?.displayName),
171− );
172− if (sharedDisplayNames.length === 1) {
173− return sharedDisplayNames[0]!;
174− }
175−
176− const sharedRepositoryNames = uniqueNonEmptyValues(
177− input.members.map((member) => member.repositoryIdentity?.name),
178− );
179− if (sharedRepositoryNames.length === 1) {
180− return sharedRepositoryNames[0]!;
181− }
182−
183− return input.representative.title;
184−}
1+export {
2+ deriveLogicalProjectKey,
3+ deriveLogicalProjectKeyFromRef,
4+ deriveLogicalProjectKeyFromSettings,
5+ derivePhysicalProjectKey,
6+ derivePhysicalProjectKeyFromPath,
7+ deriveProjectGroupLabel,
8+ deriveProjectGroupingOverrideKey,
9+ getProjectOrderKey,
10+ resolveProjectGroupingMode,
11+ selectProjectGroupingSettings,
12+ type ProjectGroupingMode,
13+ type ProjectGroupingSettings,
14+} from "@t3tools/client-runtime/state/project-grouping";
apps/web/src/main.tsx+2−8
@@ -1,7 +1,6 @@
11 import React from "react";
22 import ReactDOM from "react-dom/client";
33 import { ClerkProvider } from "@clerk/react";
4−import { RouterProvider } from "@tanstack/react-router";
54 import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
65
76 import "@fontsource-variable/dm-sans/index.css";
@@ -17,7 +16,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; · 9 unmodified lines
1716 import { getRouter } from "./router";
1817 import { APP_DISPLAY_NAME } from "./branding";
1918 import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
20−import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
19+import { AppRoot } from "./AppRoot";
2120
2221 // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
2322 const history = isElectron ? createHashHistory() : createBrowserHistory();
@@ -32,12 +31,7 @@ document.title = APP_DISPLAY_NAME; · 8 unmodified lines
3231
3332 const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
3433
35−const app = (
36− <>
37− <RouterProvider router={router} />
38− <ElectronBrowserHost />
39− </>
40−);
34+const app = <AppRoot router={router} />;
4135
4236 ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
4337 <React.StrictMode>
apps/web/src/previewStateStore.test.ts+28−3
@@ -7,10 +7,11 @@ import { · 6 unmodified lines
77 applyPreviewDesktopState,
88 applyPreviewServerEvent,
99 applyPreviewServerSnapshot,
10+ beginPreviewSessionClose,
11+ cancelPreviewSessionClose,
1012 previewStateAtom,
1113 readThreadPreviewState,
1214 rememberPreviewUrl,
13− removePreviewSession,
1415 removePreviewThread,
1516 resetPreviewStateForTests,
1617 setActivePreviewTab,
@@ -182,7 +183,7 @@ describe("previewStateStore (single-tab)", () => { · 165 unmodified lines
182183 applyPreviewServerSnapshot(ref, first);
183184 applyPreviewServerSnapshot(ref, second);
184185
185− removePreviewSession(ref, second.tabId);
186+ beginPreviewSessionClose(ref, second.tabId);
186187
187188 const state = readThreadPreviewState(ref);
188189 expect(Object.keys(state.sessions)).toEqual([first.tabId]);
@@ -193,7 +194,7 @@ describe("previewStateStore (single-tab)", () => { · 4 unmodified lines
193194 it("treats a late server close event after optimistic removal as a no-op", () => {
194195 const snapshot = makeSnapshot();
195196 applyPreviewServerSnapshot(ref, snapshot);
196− removePreviewSession(ref, snapshot.tabId);
197+ beginPreviewSessionClose(ref, snapshot.tabId);
197198
198199 applyPreviewServerEvent(ref, {
199200 type: "closed",
@@ -207,6 +208,30 @@ describe("previewStateStore (single-tab)", () => { · 7 unmodified lines
207208 expect(state.snapshot).toBeNull();
208209 });
209210
211+ it("does not resurrect an intentionally closed tab from a stale list snapshot", () => {
212+ const snapshot = makeSnapshot();
213+ applyPreviewServerSnapshot(ref, snapshot);
214+ beginPreviewSessionClose(ref, snapshot.tabId);
215+
216+ applyPreviewServerSnapshot(ref, snapshot);
217+
218+ const state = readThreadPreviewState(ref);
219+ expect(state.sessions).toEqual({});
220+ expect(state.snapshot).toBeNull();
221+ });
222+
223+ it("can restore a suppressed tab after a failed close", () => {
224+ const snapshot = makeSnapshot();
225+ applyPreviewServerSnapshot(ref, snapshot);
226+ beginPreviewSessionClose(ref, snapshot.tabId);
227+
228+ cancelPreviewSessionClose(ref, snapshot, snapshot.tabId);
229+
230+ const state = readThreadPreviewState(ref);
231+ expect(state.sessions).toEqual({ [snapshot.tabId]: snapshot });
232+ expect(state.snapshot).toEqual(snapshot);
233+ });
234+
210235 it("closed event for a different tab is a no-op", () => {
211236 const snapshot = makeSnapshot({ tabId: "tab_a" });
212237 applyPreviewServerEvent(ref, {
apps/web/src/previewStateStore.ts+42−2
@@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay { · 27 unmodified lines
2828 export interface ThreadPreviewState {
2929 snapshot: PreviewSessionSnapshot | null;
3030 sessions: Record<string, PreviewSessionSnapshot>;
31+ /** Tabs intentionally closed by this client. Stale list snapshots must not resurrect them. */
32+ suppressedTabIds: ReadonlySet<string>;
3133 activeTabId: string | null;
3234 desktopOverlay: DesktopPreviewOverlay | null;
3335 desktopByTabId: Record<string, DesktopPreviewOverlay>;
@@ -37,6 +39,7 @@ export interface ThreadPreviewState { · 3 unmodified lines
3739 const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
3840 snapshot: null,
3941 sessions: {},
42+ suppressedTabIds: new Set<string>(),
4043 activeTabId: null,
4144 desktopOverlay: null,
4245 desktopByTabId: {},
@@ -162,6 +165,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven · 119 unmodified lines
162165 case "opened":
163166 case "navigated": {
164167 const snapshot = event.snapshot;
168+ if (current.suppressedTabIds.has(snapshot.tabId)) return current;
165169 const recentlySeenUrls =
166170 snapshot.navStatus._tag === "Idle"
167171 ? current.recentlySeenUrls
@@ -221,6 +225,7 @@ export function applyPreviewServerSnapshot( · 53 unmodified lines
221225 desktopByTabId: {},
222226 };
223227 }
228+ if (current.suppressedTabIds.has(snapshot.tabId)) return current;
224229 const existing = current.sessions[snapshot.tabId];
225230 if (existing && existing.updatedAt > snapshot.updatedAt) return current;
226231 const recentlySeenUrls =
@@ -255,8 +260,43 @@ export function applyPreviewDesktopState( · 28 unmodified lines
255260 });
256261 }
257262
258−export function removePreviewSession(ref: ScopedThreadRef, tabId: string): void {
259− updateThreadPreviewState(ref, (current) => removeSession(current, tabId));
263+export function beginPreviewSessionClose(ref: ScopedThreadRef, tabId: string): void {
264+ updateThreadPreviewState(ref, (current) => {
265+ const suppressedTabIds = new Set(current.suppressedTabIds);
266+ suppressedTabIds.add(tabId);
267+ return {
268+ ...removeSession(current, tabId),
269+ suppressedTabIds,
270+ };
271+ });
272+}
273+
274+export function cancelPreviewSessionClose(
275+ ref: ScopedThreadRef,
276+ snapshot: PreviewSessionSnapshot | null,
277+ tabId: string,
278+): void {
279+ updateThreadPreviewState(ref, (current) => {
280+ if (!current.suppressedTabIds.has(tabId)) return current;
281+ const suppressedTabIds = new Set(current.suppressedTabIds);
282+ suppressedTabIds.delete(tabId);
283+ if (!snapshot) {
284+ return { ...current, suppressedTabIds };
285+ }
286+ const recentlySeenUrls =
287+ snapshot.navStatus._tag !== "Idle"
288+ ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
289+ : current.recentlySeenUrls;
290+ return {
291+ ...current,
292+ snapshot,
293+ sessions: { ...current.sessions, [snapshot.tabId]: snapshot },
294+ suppressedTabIds,
295+ activeTabId: snapshot.tabId,
296+ desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null,
297+ recentlySeenUrls,
298+ };
299+ });
260300 }
261301
262302 export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void {
apps/web/src/router.ts+0−3
@@ -1,7 +1,5 @@
1−import { createElement } from "react";
21 import { createRouter, RouterHistory } from "@tanstack/react-router";
32
4−import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
53 import { routeTree } from "./routeTree.gen";
64
75 export function getRouter(history: RouterHistory) {
@@ -9,7 +7,6 @@ export function getRouter(history: RouterHistory) { · 1 unmodified line
97 routeTree,
108 history,
119 context: {},
12− Wrap: ({ children }) => createElement(AppAtomRegistryProvider, undefined, children),
1310 });
1411 }
1512
packages/client-runtime/package.json+8−0
@@ -83,6 +83,10 @@ · 82 unmodified lines
8383 "types": "./src/state/projects.ts",
8484 "default": "./src/state/projects.ts"
8585 },
86+ "./state/project-grouping": {
87+ "types": "./src/state/projectGrouping.ts",
88+ "default": "./src/state/projectGrouping.ts"
89+ },
8690 "./state/relay": {
8791 "types": "./src/state/relayDiscovery.ts",
8892 "default": "./src/state/relayDiscovery.ts"
@@ -119,6 +123,10 @@ · 30 unmodified lines
119123 "types": "./src/state/threads.ts",
120124 "default": "./src/state/threads.ts"
121125 },
126+ "./state/thread-sort": {
127+ "types": "./src/state/threadSort.ts",
128+ "default": "./src/state/threadSort.ts"
129+ },
122130 "./state/vcs": {
123131 "types": "./src/state/vcs.ts",
124132 "default": "./src/state/vcs.ts"
packages/client-runtime/src/state/archivedThreads.ts+44−0
@@ -1,13 +1,22 @@
11 import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
22 import * as Arr from "effect/Array";
3+import * as Cause from "effect/Cause";
34 import { pipe } from "effect/Function";
5+import * as Option from "effect/Option";
46 import * as Order from "effect/Order";
7+import { AsyncResult, Atom } from "effect/unstable/reactivity";
58
69 export interface ArchivedSnapshotEntry {
710 readonly environmentId: EnvironmentId;
811 readonly snapshot: OrchestrationShellSnapshot;
912 }
1013
14+export interface ArchivedThreadSnapshotsState {
15+ readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
16+ readonly error: string | null;
17+ readonly isLoading: boolean;
18+}
19+
1120 const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f";
1221 const environmentIdOrder = Order.String as Order.Order<EnvironmentId>;
1322
@@ -28,3 +37,38 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<E · 14 unmodified lines
2837 Arr.map((environmentId) => EnvironmentId.make(environmentId)),
2938 );
3039 }
40+
41+export function createArchivedThreadSnapshotsAtomFamily<E>(options: {
42+ readonly getSnapshotAtom: (
43+ environmentId: EnvironmentId,
44+ ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationShellSnapshot, E>>;
45+ readonly labelPrefix: string;
46+}) {
47+ return Atom.family((environmentKey: string) =>
48+ Atom.make((get): ArchivedThreadSnapshotsState => {
49+ const snapshots: ArchivedSnapshotEntry[] = [];
50+ let error: string | null = null;
51+ let isLoading = false;
52+
53+ for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) {
54+ const result = get(options.getSnapshotAtom(environmentId));
55+ isLoading ||= result.waiting;
56+
57+ const snapshot = Option.getOrNull(AsyncResult.value(result));
58+ if (snapshot !== null) {
59+ snapshots.push({ environmentId, snapshot });
60+ }
61+
62+ if (error === null && result._tag === "Failure") {
63+ const cause = Cause.squash(result.cause);
64+ error =
65+ cause instanceof Error && cause.message.trim().length > 0
66+ ? cause.message
67+ : "Failed to load archived threads.";
68+ }
69+ }
70+
71+ return { snapshots, error, isLoading };
72+ }).pipe(Atom.withLabel(`${options.labelPrefix}:${environmentKey}`)),
73+ );
74+}
packages/client-runtime/src/state/projectGrouping.ts+183−0
@@ -0,0 +1,183 @@
1+import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";
2+import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
3+import type { UnifiedSettings } from "@t3tools/contracts/settings";
4+
5+import type { EnvironmentProject } from "./models.ts";
6+import { normalizeProjectPathForComparison } from "./projects.ts";
7+
8+export interface ProjectGroupingSettings {
9+ readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode;
10+ readonly sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>;
11+}
12+
13+export type ProjectGroupingMode = SidebarProjectGroupingMode;
14+
15+export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings {
16+ return {
17+ sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
18+ sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
19+ };
20+}
21+
22+function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): string[] {
23+ const seen = new Set<string>();
24+ const unique: string[] = [];
25+ for (const value of values) {
26+ const trimmed = value?.trim();
27+ if (!trimmed || seen.has(trimmed)) {
28+ continue;
29+ }
30+ seen.add(trimmed);
31+ unique.push(trimmed);
32+ }
33+ return unique;
34+}
35+
36+function deriveRepositoryRelativeProjectPath(
37+ project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
38+): string | null {
39+ const rootPath = project.repositoryIdentity?.rootPath?.trim();
40+ if (!rootPath) {
41+ return null;
42+ }
43+
44+ const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot);
45+ const normalizedRootPath = normalizeProjectPathForComparison(rootPath);
46+ if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) {
47+ return null;
48+ }
49+
50+ if (normalizedProjectPath === normalizedRootPath) {
51+ return "";
52+ }
53+
54+ const separator = normalizedRootPath.includes("\\") ? "\\" : "/";
55+ const rootPrefix = `${normalizedRootPath}${separator}`;
56+ if (!normalizedProjectPath.startsWith(rootPrefix)) {
57+ return null;
58+ }
59+
60+ return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/");
61+}
62+
63+export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string {
64+ return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`;
65+}
66+
67+export function derivePhysicalProjectKey(
68+ project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
69+): string {
70+ return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot);
71+}
72+
73+export function deriveProjectGroupingOverrideKey(
74+ project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
75+): string {
76+ return derivePhysicalProjectKey(project);
77+}
78+
79+export function getProjectOrderKey(
80+ project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
81+): string {
82+ return derivePhysicalProjectKey(project);
83+}
84+
85+export function resolveProjectGroupingMode(
86+ project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
87+ settings: ProjectGroupingSettings,
88+): SidebarProjectGroupingMode {
89+ return (
90+ settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ??
91+ settings.sidebarProjectGroupingMode
92+ );
93+}
94+
95+function deriveRepositoryScopedKey(
96+ project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
97+ groupingMode: SidebarProjectGroupingMode,
98+): string | null {
99+ const canonicalKey = project.repositoryIdentity?.canonicalKey;
100+ if (!canonicalKey) {
101+ return null;
102+ }
103+
104+ if (groupingMode === "repository") {
105+ return canonicalKey;
106+ }
107+
108+ const relativeProjectPath = deriveRepositoryRelativeProjectPath(project);
109+ if (relativeProjectPath === null) {
110+ return canonicalKey;
111+ }
112+
113+ return relativeProjectPath.length === 0
114+ ? canonicalKey
115+ : `${canonicalKey}::${relativeProjectPath}`;
116+}
117+
118+export function deriveLogicalProjectKey(
119+ project: Pick<
120+ EnvironmentProject,
121+ "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"
122+ >,
123+ options?: {
124+ readonly groupingMode?: SidebarProjectGroupingMode;
125+ },
126+): string {
127+ const groupingMode = options?.groupingMode ?? "repository";
128+ if (groupingMode === "separate") {
129+ return derivePhysicalProjectKey(project);
130+ }
131+
132+ return (
133+ deriveRepositoryScopedKey(project, groupingMode) ??
134+ derivePhysicalProjectKey(project) ??
135+ scopedProjectKey(scopeProjectRef(project.environmentId, project.id))
136+ );
137+}
138+
139+export function deriveLogicalProjectKeyFromSettings(
140+ project: Pick<
141+ EnvironmentProject,
142+ "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"
143+ >,
144+ settings: ProjectGroupingSettings,
145+): string {
146+ return deriveLogicalProjectKey(project, {
147+ groupingMode: resolveProjectGroupingMode(project, settings),
148+ });
149+}
150+
151+export function deriveLogicalProjectKeyFromRef(
152+ projectRef: ScopedProjectRef,
153+ project:
154+ | Pick<EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity">
155+ | null
156+ | undefined,
157+ options?: {
158+ readonly groupingMode?: SidebarProjectGroupingMode;
159+ },
160+): string {
161+ return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef);
162+}
163+
164+export function deriveProjectGroupLabel(input: {
165+ readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">;
166+ readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>;
167+}): string {
168+ const sharedDisplayNames = uniqueNonEmptyValues(
169+ input.members.map((member) => member.repositoryIdentity?.displayName),
170+ );
171+ if (sharedDisplayNames.length === 1) {
172+ return sharedDisplayNames[0]!;
173+ }
174+
175+ const sharedRepositoryNames = uniqueNonEmptyValues(
176+ input.members.map((member) => member.repositoryIdentity?.name),
177+ );
178+ if (sharedRepositoryNames.length === 1) {
179+ return sharedRepositoryNames[0]!;
180+ }
181+
182+ return input.representative.title;
183+}
packages/client-runtime/src/state/threadSort.ts+101−0
@@ -0,0 +1,101 @@
1+import type { ProjectId } from "@t3tools/contracts";
2+import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3+import * as Arr from "effect/Array";
4+import * as Order from "effect/Order";
5+
6+export interface ThreadSortInput {
7+ readonly createdAt: string;
8+ readonly updatedAt: string;
9+ readonly latestUserMessageAt?: string | null;
10+ readonly messages?: ReadonlyArray<{
11+ readonly createdAt: string;
12+ readonly role: string;
13+ }>;
14+}
15+
16+export function toSortableTimestamp(iso: string | undefined): number | null {
17+ if (!iso) return null;
18+ const ms = Date.parse(iso);
19+ return Number.isFinite(ms) ? ms : null;
20+}
21+
22+function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null {
23+ for (const value of values) {
24+ const timestamp = toSortableTimestamp(value ?? undefined);
25+ if (timestamp !== null) {
26+ return timestamp;
27+ }
28+ }
29+
30+ return null;
31+}
32+
33+function getLatestUserMessageTimestamp(thread: ThreadSortInput): number {
34+ if (thread.latestUserMessageAt) {
35+ return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY;
36+ }
37+
38+ let latestUserMessageTimestamp: number | null = null;
39+
40+ for (const message of thread.messages ?? []) {
41+ if (message.role !== "user") continue;
42+ const messageTimestamp = toSortableTimestamp(message.createdAt);
43+ if (messageTimestamp === null) continue;
44+ latestUserMessageTimestamp =
45+ latestUserMessageTimestamp === null
46+ ? messageTimestamp
47+ : Math.max(latestUserMessageTimestamp, messageTimestamp);
48+ }
49+
50+ if (latestUserMessageTimestamp !== null) {
51+ return latestUserMessageTimestamp;
52+ }
53+
54+ return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY;
55+}
56+
57+export function getThreadSortTimestamp(
58+ thread: ThreadSortInput,
59+ sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">,
60+): number {
61+ if (sortOrder === "created_at") {
62+ return (
63+ getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY
64+ );
65+ }
66+ return getLatestUserMessageTimestamp(thread);
67+}
68+
69+export function sortThreads<T extends { readonly id: string } & ThreadSortInput>(
70+ threads: readonly T[],
71+ sortOrder: SidebarThreadSortOrder,
72+): T[] {
73+ return Arr.sort(
74+ threads,
75+ Order.mapInput(
76+ Order.Struct({
77+ timestamp: Order.flip(Order.Number),
78+ id: Order.flip(Order.String),
79+ }),
80+ (thread: T) => ({
81+ timestamp: getThreadSortTimestamp(thread, sortOrder),
82+ id: thread.id,
83+ }),
84+ ),
85+ );
86+}
87+
88+export function getLatestThreadForProject<
89+ T extends {
90+ readonly id: string;
91+ readonly projectId: ProjectId;
92+ readonly archivedAt: string | null;
93+ } & ThreadSortInput,
94+>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null {
95+ return (
96+ sortThreads(
97+ threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null),
98+ sortOrder,
99+ )[0] ?? null
100+ );
101+}
packages/shared/package.json+4−0
@@ -163,6 +163,10 @@ · 162 unmodified lines
163163 "types": "./src/preview.ts",
164164 "import": "./src/preview.ts"
165165 },
166+ "./filePreview": {
167+ "types": "./src/filePreview.ts",
168+ "import": "./src/filePreview.ts"
169+ },
166170 "./hostProcess": {
167171 "types": "./src/hostProcess.ts",
168172 "import": "./src/hostProcess.ts"
packages/shared/src/filePreview.test.ts+36−0
@@ -0,0 +1,36 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ isWorkspaceBrowserPreviewPath,
5+ isWorkspaceImagePreviewPath,
6+ isWorkspacePreviewEntryPath,
7+} from "./filePreview.ts";
8+
9+describe("workspace file previews", () => {
10+ it.each(["report.html", "report.HTM", "document.pdf?download=1"])(
11+ "recognizes browser preview path %s",
12+ (path) => {
13+ expect(isWorkspaceBrowserPreviewPath(path)).toBe(true);
14+ expect(isWorkspacePreviewEntryPath(path)).toBe(true);
15+ },
16+ );
17+
18+ it.each([
19+ "icon.png",
20+ "photo.JPEG",
21+ "animation.gif",
22+ "vector.svg#mark",
23+ "texture.webp",
24+ "image.avif",
25+ ])("recognizes image preview path %s", (path) => {
26+ expect(isWorkspaceImagePreviewPath(path)).toBe(true);
27+ expect(isWorkspacePreviewEntryPath(path)).toBe(true);
28+ });
29+
30+ it.each(["README.md", "src/index.ts", "image.png.ts", "png"])(
31+ "rejects non-preview path %s",
32+ (path) => {
33+ expect(isWorkspacePreviewEntryPath(path)).toBe(false);
34+ },
35+ );
36+});
packages/shared/src/filePreview.ts+29−0
@@ -0,0 +1,29 @@
1+export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const;
2+
3+export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [
4+ ".avif",
5+ ".gif",
6+ ".ico",
7+ ".jpeg",
8+ ".jpg",
9+ ".png",
10+ ".svg",
11+ ".webp",
12+] as const;
13+
14+function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean {
15+ const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
16+ return extensions.some((extension) => pathWithoutQuery.endsWith(extension));
17+}
18+
19+export function isWorkspaceBrowserPreviewPath(path: string): boolean {
20+ return hasPreviewExtension(path, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS);
21+}
22+
23+export function isWorkspaceImagePreviewPath(path: string): boolean {
24+ return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS);
25+}
26+
27+export function isWorkspacePreviewEntryPath(path: string): boolean {
28+ return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path);
29+}
pnpm-lock.yaml+58−41
@@ -247,7 +247,7 @@ importers: · 246 unmodified lines
247247 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
248248 expo:
249249 specifier: ^56.0.0
250− 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+ 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-webview@13.16.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))(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)
251251 expo-asset:
252252 specifier: ~56.0.15
253253 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)
@@ -362,6 +362,9 @@ importers: · 108 unmodified lines
362362 react-native-svg:
363363 specifier: 15.15.4
364364 version: 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)
365+ react-native-webview:
366+ specifier: ^13.16.1
367+ version: 13.16.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)
365368 react-native-worklets:
366369 specifier: 0.8.3
367370 version: 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)
@@ -8453,6 +8456,12 @@ packages: · 8085 unmodified lines
84538456 peerDependencies:
84548457 react-native: '*'
84558458
8459+ react-native-webview@13.16.1:
8460+ resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==}
8461+ peerDependencies:
8462+ react: '*'
8463+ react-native: '*'
8464+
84568465 react-native-worklets@0.8.3:
84578466 resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==}
84588467 peerDependencies:
@@ -10784,7 +10793,7 @@ snapshots: · 2325 unmodified lines
1078410793 '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
1078510794 '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
1078610795 base-64: 1.0.0
10787− 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)
10796+ 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-webview@13.16.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))(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)
1078810797 react: 19.2.3
1078910798 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)
1079010799 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))
@@ -11396,7 +11405,7 @@ snapshots: · 605 unmodified lines
1139611405 connect: 3.7.0
1139711406 debug: 4.4.3
1139811407 dnssd-advertise: 1.1.4
11399− 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)
11408+ 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-webview@13.16.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))(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)
1140011409 expo-server: 56.0.4
1140111410 fetch-nodeshim: 0.4.10
1140211411 getenv: 2.0.0
@@ -11494,7 +11503,7 @@ snapshots: · 91 unmodified lines
1149411503
1149511504 '@expo/dom-webview@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)':
1149611505 dependencies:
11497− 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)
11506+ 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-webview@13.16.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))(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)
1149811507 react: 19.2.3
1149911508 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)
1150011509
@@ -11561,7 +11570,7 @@ snapshots: · 60 unmodified lines
1156111570 dependencies:
1156211571 '@expo/dom-webview': 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)
1156311572 anser: 1.4.10
11564− 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)
11573+ 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-webview@13.16.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))(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)
1156511574 react: 19.2.3
1156611575 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)
1156711576 stacktrace-parser: 0.1.11
@@ -11593,7 +11602,7 @@ snapshots: · 25 unmodified lines
1159311602 postcss: 8.5.15
1159411603 resolve-from: 5.0.0
1159511604 optionalDependencies:
11596− 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)
11605+ 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-webview@13.16.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))(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)
1159711606 transitivePeerDependencies:
1159811607 - bufferutil
1159911608 - supports-color
@@ -11615,7 +11624,7 @@ snapshots: · 15 unmodified lines
1161511624 dependencies:
1161611625 '@expo/log-box': 56.0.12(@expo/dom-webview@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)
1161711626 anser: 1.4.10
11618− 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)
11627+ 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-webview@13.16.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))(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)
1161911628 pretty-format: 29.7.0
1162011629 react: 19.2.3
1162111630 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)
@@ -11693,7 +11702,7 @@ snapshots: · 71 unmodified lines
1169311702 '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
1169411703 dependencies:
1169511704 debug: 4.4.3
11696− 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)
11705+ 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-webview@13.16.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))(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)
1169711706 expo-constants: 56.0.16(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))
1169811707 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)
1169911708 expo-server: 56.0.4
@@ -11717,7 +11726,7 @@ snapshots: · 17 unmodified lines
1171711726
1171811727 '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)':
1171911728 dependencies:
11720− 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)
11729+ 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-webview@13.16.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))(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)
1172111730 react: 19.2.3
1172211731 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)
1172311732 sf-symbols-typescript: 2.2.0
@@ -14450,7 +14459,7 @@ snapshots: · 2726 unmodified lines
1445014459 react-refresh: 0.14.2
1445114460 optionalDependencies:
1445214461 '@babel/runtime': 7.29.7
14453− 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)
14462+ 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-webview@13.16.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))(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)
1445414463 expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584)
1445514464 transitivePeerDependencies:
1445614465 - '@babel/core'
@@ -15316,12 +15325,12 @@ snapshots: · 859 unmodified lines
1531615325
1531715326 expo-application@56.0.3(expo@56.0.8):
1531815327 dependencies:
15319− 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)
15328+ 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-webview@13.16.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))(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)
1532015329
1532115330 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):
1532215331 dependencies:
1532315332 '@expo/image-utils': 0.10.1(typescript@6.0.3)
15324− 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)
15333+ 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-webview@13.16.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))(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)
1532515334 expo-constants: 56.0.16(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))
1532615335 react: 19.2.3
1532715336 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)
@@ -15346,14 +15355,14 @@ snapshots: · 18 unmodified lines
1534615355 expo-build-properties@56.0.16(expo@56.0.8):
1534715356 dependencies:
1534815357 '@expo/schema-utils': 56.0.1
15349− 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)
15358+ 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-webview@13.16.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))(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)
1535015359 resolve-from: 5.0.0
1535115360 semver: 7.8.1
1535215361
1535315362 expo-camera@56.0.7(@types/emscripten@1.41.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):
1535415363 dependencies:
1535515364 barcode-detector: 3.2.0(@types/emscripten@1.41.5)
15356− 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)
15365+ 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-webview@13.16.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))(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)
1535715366 react: 19.2.3
1535815367 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)
1535915368 transitivePeerDependencies:
@@ -15361,25 +15370,25 @@ snapshots: · 1 unmodified line
1536115370
1536215371 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):
1536315372 dependencies:
15364− 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)
15373+ 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-webview@13.16.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))(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)
1536515374 react: 19.2.3
1536615375 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)
1536715376
1536815377 expo-constants@56.0.16(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)):
1536915378 dependencies:
1537015379 '@expo/env': 2.3.0
15371− 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)
15380+ 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-webview@13.16.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))(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)
1537215381 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)
1537315382 transitivePeerDependencies:
1537415383 - supports-color
1537515384
1537615385 expo-crypto@56.0.4(expo@56.0.8):
1537715386 dependencies:
15378− 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)
15387+ 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-webview@13.16.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))(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)
1537915388
1538015389 expo-dev-client@56.0.18(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)):
1538115390 dependencies:
15382− 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)
15391+ 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-webview@13.16.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))(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)
1538315392 expo-dev-launcher: 56.0.18(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))
1538415393 expo-dev-menu: 56.0.16(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))
1538515394 expo-dev-menu-interface: 56.0.1(expo@56.0.8)
@@ -15391,18 +15400,18 @@ snapshots: · 5 unmodified lines
1539115400 expo-dev-launcher@56.0.18(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)):
1539215401 dependencies:
1539315402 '@expo/schema-utils': 56.0.1
15394− 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)
15403+ 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-webview@13.16.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))(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)
1539515404 expo-dev-menu: 56.0.16(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))
1539615405 expo-manifests: 56.0.4(expo@56.0.8)
1539715406 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)
1539815407
1539915408 expo-dev-menu-interface@56.0.1(expo@56.0.8):
1540015409 dependencies:
15401− 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)
15410+ 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-webview@13.16.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))(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)
1540215411
1540315412 expo-dev-menu@56.0.16(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)):
1540415413 dependencies:
15405− 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)
15414+ 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-webview@13.16.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))(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)
1540615415 expo-dev-menu-interface: 56.0.1(expo@56.0.8)
1540715416 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)
1540815417
@@ -15410,40 +15419,40 @@ snapshots: · 1 unmodified line
1541015419
1541115420 expo-file-system@56.0.7(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)):
1541215421 dependencies:
15413− 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)
15422+ 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-webview@13.16.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))(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)
1541415423 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)
1541515424
1541615425 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):
1541715426 dependencies:
15418− 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)
15427+ 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-webview@13.16.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))(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)
1541915428 fontfaceobserver: 2.3.0
1542015429 react: 19.2.3
1542115430 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)
1542215431
1542315432 expo-glass-effect@56.0.4(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):
1542415433 dependencies:
15425− 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)
15434+ 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-webview@13.16.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))(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)
1542615435 react: 19.2.3
1542715436 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)
1542815437
1542915438 expo-haptics@56.0.3(expo@56.0.8):
1543015439 dependencies:
15431− 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)
15440+ 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-webview@13.16.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))(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)
1543215441
1543315442 expo-image-loader@56.0.3(expo@56.0.8):
1543415443 dependencies:
15435− 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)
15444+ 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-webview@13.16.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))(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)
1543615445
1543715446 expo-image-picker@56.0.15(expo@56.0.8):
1543815447 dependencies:
15439− 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)
15448+ 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-webview@13.16.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))(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)
1544015449 expo-image-loader: 56.0.3(expo@56.0.8)
1544115450
1544215451 expo-json-utils@56.0.0: {}
1544315452
1544415453 expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3):
1544515454 dependencies:
15446− 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)
15455+ 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-webview@13.16.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))(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)
1544715456 react: 19.2.3
1544815457
1544915458 expo-linking@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):
@@ -15458,7 +15467,7 @@ snapshots: · 8 unmodified lines
1545815467
1545915468 expo-manifests@56.0.4(expo@56.0.8):
1546015469 dependencies:
15461− 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)
15470+ 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-webview@13.16.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))(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)
1546215471 expo-json-utils: 56.0.0
1546315472
1546415473 expo-modules-autolinking@56.0.14(typescript@6.0.3):
@@ -15487,7 +15496,7 @@ snapshots: · 22 unmodified lines
1548715496
1548815497 expo-network@56.0.5(expo@56.0.8)(react@19.2.3):
1548915498 dependencies:
15490− 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)
15499+ 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-webview@13.16.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))(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)
1549115500 react: 19.2.3
1549215501
1549315502 expo-notifications@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):
@@ -15495,7 +15504,7 @@ snapshots: · 1 unmodified line
1549515504 '@expo/image-utils': 0.10.1(typescript@6.0.3)
1549615505 abort-controller: 3.0.0
1549715506 badgin: 1.2.3
15498− 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)
15507+ 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-webview@13.16.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))(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)
1549915508 expo-application: 56.0.3(expo@56.0.8)
1550015509 expo-constants: 56.0.16(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))
1550115510 react: 19.2.3
@@ -15506,7 +15515,7 @@ snapshots: · 4 unmodified lines
1550615515
1550715516 expo-paste-input@0.1.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):
1550815517 dependencies:
15509− 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)
15518+ 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-webview@13.16.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))(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)
1551015519 react: 19.2.3
1551115520 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)
1551215521
@@ -15525,7 +15534,7 @@ snapshots: · 12 unmodified lines
1552515534 color: 4.2.3
1552615535 debug: 4.4.3
1552715536 escape-string-regexp: 4.0.0
15528− 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)
15537+ 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-webview@13.16.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))(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)
1552915538 expo-constants: 56.0.16(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))
1553015539 expo-glass-effect: 56.0.4(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)
1553115540 expo-linking: 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)
@@ -15561,7 +15570,7 @@ snapshots: · 29 unmodified lines
1556115570
1556215571 expo-secure-store@56.0.4(expo@56.0.8):
1556315572 dependencies:
15564− 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)
15573+ 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-webview@13.16.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))(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)
1556515574
1556615575 expo-server@56.0.4: {}
1556715576
@@ -15569,7 +15578,7 @@ snapshots: · 1 unmodified line
1556915578 dependencies:
1557015579 '@expo/config-plugins': 56.0.8(typescript@6.0.3)
1557115580 '@expo/image-utils': 0.10.1(typescript@6.0.3)
15572− 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)
15581+ 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-webview@13.16.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))(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)
1557315582 xml2js: 0.6.0
1557415583 transitivePeerDependencies:
1557515584 - supports-color
@@ -15580,7 +15589,7 @@ snapshots: · 4 unmodified lines
1558015589 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):
1558115590 dependencies:
1558215591 '@expo-google-fonts/material-symbols': 0.4.38
15583− 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)
15592+ 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-webview@13.16.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))(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)
1558415593 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)
1558515594 react: 19.2.3
1558615595 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)
@@ -15588,7 +15597,7 @@ snapshots: · 1 unmodified line
1558815597
1558915598 expo-updates-interface@56.0.2(expo@56.0.8):
1559015599 dependencies:
15591− 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)
15600+ 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-webview@13.16.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))(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)
1559215601
1559315602 expo-updates@56.0.17(expo-dev-client@56.0.18(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-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):
1559415603 dependencies:
@@ -15598,7 +15607,7 @@ snapshots: · 3 unmodified lines
1559815607 arg: 4.1.3
1559915608 chalk: 4.1.2
1560015609 debug: 4.4.3
15601− 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)
15610+ 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-webview@13.16.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))(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)
1560215611 expo-eas-client: 56.0.1
1560315612 expo-manifests: 56.0.4(expo@56.0.8)
1560415613 expo-structured-headers: 56.0.0
@@ -15617,14 +15626,14 @@ snapshots: · 12 unmodified lines
1561715626
1561815627 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)):
1561915628 dependencies:
15620− 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)
15629+ 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-webview@13.16.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))(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)
1562115630 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)
1562215631
1562315632 expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584):
1562415633 dependencies:
1562515634 '@expo/plist': 0.7.0
1562615635 '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)
15627− 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)
15636+ 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-webview@13.16.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))(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)
1562815637 react: 19.2.3
1562915638 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)
1563015639 transitivePeerDependencies:
@@ -15635,7 +15644,7 @@ snapshots: · 4 unmodified lines
1563515644 - react-native-reanimated
1563615645 - react-native-worklets
1563715646
15638− 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):
15647+ 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-webview@13.16.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))(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):
1563915648 dependencies:
1564015649 '@babel/runtime': 7.29.7
1564115650 '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(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)(typescript@6.0.3)(utf-8-validate@6.0.6)
@@ -15665,6 +15674,7 @@ snapshots: · 23 unmodified lines
1566515674 '@expo/dom-webview': 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)
1566615675 '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(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)
1566715676 react-dom: 19.2.3(react@19.2.3)
15677+ react-native-webview: 13.16.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)
1566815678 transitivePeerDependencies:
1566915679 - '@babel/core'
1567015680 - bufferutil
@@ -18139,6 +18149,13 @@ snapshots: · 2468 unmodified lines
1813918149 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)
1814018150 whatwg-url-without-unicode: 8.0.0-3
1814118151
18152+ react-native-webview@13.16.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):
18153+ dependencies:
18154+ escape-string-regexp: 4.0.0
18155+ invariant: 2.2.4
18156+ react: 19.2.3
18157+ 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)
18158+
1814218159 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):
1814318160 dependencies:
1814418161 '@babel/core': 7.29.7