authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-18 20:24:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-18 20:24:48-07:00
log30034ecedc467ea2e00075f8cd2abd11ef8dd990
tree63b2b9ff40c844de412aad688e19977c07548c25
parent1fcc57af136e00633c5d149c3d8564e98f7823c3
signature Signed by PGP key B5690EEEBB952194

Add archived threads and mobile file viewer (#3155)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: codex <codex@users.noreply.github.com>

93 files changed, 6791 insertions(+), 1136 deletions(-)

apps/desktop/src/preview/Manager.test.ts+52
......@@ -128,6 +128,58 @@ describe("PreviewManager", () => {
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
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(
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(
8085 }
8186}
8287
83static NSArray<NSDictionary<NSString *, id> *> *T3MarkdownTextExtractChipBackgrounds(
84 NSMutableAttributedString *attributedString,
85 const std::vector<T3MarkdownTextChipRange> &chipRanges)
86{
87 NSMutableArray<NSDictionary<NSString *, id> *> *backgrounds = [NSMutableArray array];
88 for (const auto &chipRange : chipRanges) {
89 if (chipRange.length == 0 || chipRange.location >= attributedString.length) {
90 continue;
91 }
92
93 const NSRange range = NSMakeRange(
94 chipRange.location,
95 MIN(chipRange.length, attributedString.length - chipRange.location));
96 UIColor *color = [attributedString attribute:NSBackgroundColorAttributeName
97 atIndex:range.location
98 effectiveRange:nil];
99 UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName
100 atIndex:range.location
101 effectiveRange:nil];
102 if (color == nil) {
103 continue;
104 }
105 [backgrounds addObject:@{
106 @"range": [NSValue valueWithRange:range],
107 @"color": color,
108 @"strokeColor": [foregroundColor
109 colorWithAlphaComponent:chipRange.isSkill ? 0.25 : 0.1] ?: UIColor.clearColor,
110 }];
111 [attributedString removeAttribute:NSBackgroundColorAttributeName range:range];
112 }
113 return backgrounds;
114}
115
116@interface T3MarkdownTextBackingView : UITextView
117@property(nonatomic, copy) NSArray<NSDictionary<NSString *, id> *> *chipBackgrounds;
118@end
119
120@implementation T3MarkdownTextBackingView
121
122- (void)drawRect:(CGRect)rect
123{
124 [self.layoutManager ensureLayoutForTextContainer:self.textContainer];
125 CGContextRef context = UIGraphicsGetCurrentContext();
126 if (context != nil) {
127 CGContextSaveGState(context);
128 CGContextResetClip(context);
129 CGContextClipToRect(context, self.bounds);
130 }
131 for (NSDictionary<NSString *, id> *background in self.chipBackgrounds) {
132 const NSRange characterRange = [background[@"range"] rangeValue];
133 UIColor *color = background[@"color"];
134 UIColor *strokeColor = background[@"strokeColor"];
135 if (characterRange.length == 0 || NSMaxRange(characterRange) > self.textStorage.length) {
136 continue;
137 }
138
139 const NSRange glyphRange =
140 [self.layoutManager glyphRangeForCharacterRange:characterRange actualCharacterRange:nil];
141 [color setFill];
142 [self.layoutManager
143 enumerateEnclosingRectsForGlyphRange:glyphRange
144 withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0)
145 inTextContainer:self.textContainer
146 usingBlock:^(CGRect glyphRect, BOOL *stop) {
147 const CGFloat chipHeight = 22;
148 CGRect chipRect = CGRectMake(
149 glyphRect.origin.x - 4,
150 CGRectGetMidY(glyphRect) - chipHeight / 2,
151 glyphRect.size.width + 8,
152 chipHeight);
153 chipRect.origin.x += self.textContainerInset.left;
154 chipRect.origin.y += self.textContainerInset.top;
155 const CGFloat minimumX = self.textContainerInset.left + 0.5;
156 const CGFloat maximumX =
157 CGRectGetWidth(self.bounds) - self.textContainerInset.right - 0.5;
158 if (chipRect.origin.x < minimumX) {
159 chipRect.size.width -= minimumX - chipRect.origin.x;
160 chipRect.origin.x = minimumX;
161 }
162 if (CGRectGetMaxX(chipRect) > maximumX) {
163 chipRect.size.width = MAX(0, maximumX - chipRect.origin.x);
164 }
165 UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect cornerRadius:6];
166 [path fill];
167 [strokeColor setStroke];
168 path.lineWidth = 1;
169 [path stroke];
170 }];
171 }
172 if (context != nil) {
173 CGContextRestoreGState(context);
174 }
175
176 [super drawRect:rect];
177}
178
179@end
180
18188@protocol T3MarkdownOutsideTapTarget <NSObject>
18289- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView;
18390@end
......@@ -285,7 +192,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
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)
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)
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 {
2828 std::string imageUri;
2929};
3030
31struct T3MarkdownTextChipRange {
32 size_t location;
33 size_t length;
34 bool isSkill;
35};
31inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {
32 return 14;
33}
34
35inline Float T3MarkdownTextAttachmentBaselineOffset(
36 const T3MarkdownTextAttachmentRange &) {
37 return -2;
38}
3639
3740class T3MarkdownTextStateReal final {
3841 public:
3942 AttributedString attributedString;
4043 std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges;
4144 std::vector<T3MarkdownTextAttachmentRange> attachmentRanges;
42 std::vector<T3MarkdownTextChipRange> chipRanges;
4345};
4446
4547class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode<
......@@ -72,6 +74,5 @@ private:
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 @@
99namespace facebook::react {
1010
1111static constexpr Float ParagraphStyleEncodingOffset = 1000;
12static constexpr auto ChipNativeIdPrefix = "t3-chip-";
13static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:";
14static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:";
12static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:";
13static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:";
1514
1615static void applyParagraphStyles(
1716 NSMutableAttributedString *attributedString,
......@@ -58,7 +57,12 @@ static void applyAttachments(
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(
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(
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(
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) {
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 {
4040function 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[] {
312314function 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: {
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: {
364368function 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[] {
426437function 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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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 @@
1import { useEffect, useMemo, useState } from "react";
2import { Asset } from "expo-asset";
31import { Image, Linking, type TextStyle, useColorScheme } from "react-native";
42
53import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
64import { markdownFileIconSource } from "./markdownFileIcons";
7import type { MarkdownFileIcon } from "./markdownLinks";
85import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
96import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types";
107
118const EXTERNAL_LINK_PREFIX = "â—‰ ";
12const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0";
13const CHIP_SUFFIX = "\u00A0";
9const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
1410const SKILL_ICON_PLACEHOLDER = "\uFFFC";
1511const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;
1612
17function useFileIconUris(runs: ReadonlyArray<NativeMarkdownTextRun>) {
18 const iconSignature = JSON.stringify(
19 [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(),
20 );
21 const icons = useMemo(
22 () => JSON.parse(iconSignature) as ReadonlyArray<MarkdownFileIcon>,
23 [iconSignature],
24 );
25 const [uris, setUris] = useState<ReadonlyMap<string, string>>(() => new Map());
26
27 useEffect(() => {
28 let cancelled = false;
29
30 void Promise.all(
31 icons.map(async (icon) => {
32 const source = markdownFileIconSource(icon);
33 const fallbackUri = Image.resolveAssetSource(source).uri;
34 if (typeof source !== "number" && typeof source !== "string") {
35 return [icon, fallbackUri] as const;
36 }
37 try {
38 const asset = Asset.fromModule(source);
39 await asset.downloadAsync();
40 return [icon, asset.localUri ?? fallbackUri] as const;
41 } catch {
42 return [icon, fallbackUri] as const;
43 }
44 }),
45 ).then((entries) => {
46 if (!cancelled) {
47 setUris(new Map(entries));
48 }
49 });
50
51 return () => {
52 cancelled = true;
53 };
54 }, [icons]);
55
56 return uris;
57}
58
5913function runKeySignature(run: NativeMarkdownTextRun): string {
6014 return [
6115 run.text,
......@@ -81,13 +35,16 @@ function runKeySignature(run: NativeMarkdownTextRun): string {
8135function 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
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
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
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
170122export 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: {
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: {
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: {
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: {
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 {
66 nativeMarkdownChunkSpacing,
77 nativeMarkdownDocumentChunks,
88 nativeMarkdownDocumentRuns,
9 nativeMarkdownWithPreservedSoftBreaks,
910} from "./nativeMarkdownText";
1011import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
1112import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
......@@ -33,15 +34,20 @@ export function SelectableMarkdownText({
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({
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({
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 {
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 {
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 =
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 {
247251 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
248252}
249253
250function fileUrlPath(href: string): string | null {
254function 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 {
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
269function 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
280function 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
268301function looksLikePosixFilesystemPath(path: string): boolean {
269302 if (!path.startsWith("/")) {
270303 return false;
......@@ -331,14 +364,31 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
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
......@@ -293,6 +293,7 @@ function appendNode(
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
319320 return appendChildren([], node, EMPTY_CONTEXT);
320321}
321322
323export 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
322332function appendBlockTerminator(
323333 runs: NativeMarkdownTextRun[],
324334 context: RunContext,
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+4
......@@ -57,6 +57,10 @@ public class T3ReviewDiffModule: Module {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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
667708private enum ReviewDiffHorizontalPanKind {
......@@ -820,6 +861,19 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
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
......@@ -101,6 +101,7 @@
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";
3030
3131function 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 @@
1import { Stack, useRouter } from "expo-router";
2import { useState } from "react";
3import { Text as RNText, View } from "react-native";
1import type {
2 EnvironmentId,
3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";
6import {
7 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10} from "@t3tools/contracts";
11import * as Arr from "effect/Array";
12import * as Order from "effect/Order";
13import { useRouter } from "expo-router";
14import { useCallback, useMemo, useState } from "react";
415
516import { useProjects, useThreadShells } from "../state/entities";
617import { useWorkspaceState } from "../state/workspace";
718import { buildThreadRoutePath } from "../lib/routes";
819import { useSavedRemoteConnections } from "../state/use-remote-environment-registry";
920import { HomeScreen } from "../features/home/HomeScreen";
10import { useThemeColor } from "../lib/useThemeColor";
21import { HomeHeader } from "../features/home/HomeHeader";
22import type { HomeProjectSortOrder } from "../features/home/homeThreadList";
23import { useThreadListActions } from "../features/home/useThreadListActions";
24
25interface 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() {
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() {
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() {
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 created+3
......@@ -0,0 +1,3 @@
1import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen";
2
3export default ArchivedThreadsRouteScreen;
apps/mobile/src/app/settings/index.tsx+16-2
......@@ -65,6 +65,8 @@ function LocalSettingsRouteScreen() {
6565 />
6666 </SettingsSection>
6767
68 <ArchivedThreadsSettingsSection />
69
6870 <AppSettingsSection />
6971 </ScrollView>
7072 </View>
......@@ -374,6 +376,8 @@ function ConfiguredSettingsRouteScreen() {
374376 />
375377 </SettingsSection>
376378
379 <ArchivedThreadsSettingsSection />
380
377381 <AppSettingsSection />
378382 </ScrollView>
379383 </View>
......@@ -416,12 +420,20 @@ function AppSettingsSection() {
416420 );
417421}
418422
423function ArchivedThreadsSettingsSection() {
424 return (
425 <SettingsSection title="Threads">
426 <SettingsRow icon="archivebox" label="Archived Threads" href="/settings/archive" />
427 </SettingsSection>
428 );
429}
430
419431function 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: {
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
......@@ -55,6 +55,32 @@ export default function ThreadLayout() {
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 created+5
......@@ -0,0 +1,5 @@
1import { ThreadFileScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2
3export default function ThreadFileRoute() {
4 return <ThreadFileScreen />;
5}
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx created+5
......@@ -0,0 +1,5 @@
1import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen";
2
3export default function ThreadFilesIndexRoute() {
4 return <ThreadFilesTreeScreen />;
5}
apps/mobile/src/components/LoadingStrip.tsx created+93
......@@ -0,0 +1,93 @@
1import { useEffect, useState } from "react";
2import { View } from "react-native";
3import Animated, {
4 cancelAnimation,
5 Easing,
6 useAnimatedStyle,
7 useSharedValue,
8 withRepeat,
9 withTiming,
10} from "react-native-reanimated";
11
12const INDICATOR_WIDTH_FRACTION = 0.3;
13const MIN_INDICATOR_WIDTH = 48;
14
15function 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
38function 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
78export 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 created+95
......@@ -0,0 +1,95 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import * as Arr from "effect/Array";
3import * as Order from "effect/Order";
4import { useFocusEffect } from "expo-router";
5import { useCallback, useMemo, useState } from "react";
6
7import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
8import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
9import { useArchivedThreadListActions } from "../home/useThreadListActions";
10import {
11 ArchivedThreadsScreen,
12 type ArchivedThreadsHeaderEnvironment,
13} from "./ArchivedThreadsScreen";
14import { buildArchivedThreadGroups, type ArchivedThreadSortOrder } from "./archivedThreadList";
15import {
16 refreshArchivedThreadsForEnvironment,
17 useArchivedThreadSnapshots,
18} from "./useArchivedThreadSnapshots";
19
20export 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 created+434
......@@ -0,0 +1,434 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import type { EnvironmentId } from "@t3tools/contracts";
6import type { MenuAction } from "@react-native-menu/menu";
7import * as Haptics from "expo-haptics";
8import { Stack } from "expo-router";
9import { SymbolView } from "expo-symbols";
10import { useCallback, useRef } from "react";
11import {
12 ActivityIndicator,
13 Pressable,
14 RefreshControl,
15 ScrollView,
16 useWindowDimensions,
17 View,
18} from "react-native";
19import ReanimatedSwipeable, {
20 type SwipeableMethods,
21} from "react-native-gesture-handler/ReanimatedSwipeable";
22
23import { AppText as Text } from "../../components/AppText";
24import { ControlPillMenu } from "../../components/ControlPill";
25import { EmptyState } from "../../components/EmptyState";
26import { ProjectFavicon } from "../../components/ProjectFavicon";
27import { relativeTime } from "../../lib/time";
28import { useThemeColor } from "../../lib/useThemeColor";
29import {
30 THREAD_SWIPE_ACTIONS_WIDTH,
31 THREAD_SWIPE_SPRING,
32 ThreadSwipeActions,
33} from "../home/thread-swipe-actions";
34import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
35
36export interface ArchivedThreadsHeaderEnvironment {
37 readonly environmentId: EnvironmentId;
38 readonly label: string;
39}
40
41const 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
55function 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
137function 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
165function 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
314function 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
326export 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 created+144
......@@ -0,0 +1,144 @@
1import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads";
2import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3tools/contracts";
3import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import { describe, expect, it } from "vite-plus/test";
5
6import { buildArchivedThreadGroups } from "./archivedThreadList";
7
8const environmentId = EnvironmentId.make("environment-1");
9
10function 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
24function 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
47function 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
63describe("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 created+106
......@@ -0,0 +1,106 @@
1import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads";
2import {
3 scopeProject,
4 scopeThreadShell,
5 type EnvironmentProject,
6 type EnvironmentThreadShell,
7} from "@t3tools/client-runtime/state/shell";
8import type { EnvironmentId } from "@t3tools/contracts";
9import * as Arr from "effect/Array";
10import * as Order from "effect/Order";
11
12import { scopedProjectKey } from "../../lib/scopedEntities";
13
14export type ArchivedThreadSortOrder = "newest" | "oldest";
15
16export interface ArchivedThreadGroup {
17 readonly key: string;
18 readonly project: EnvironmentProject;
19 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
20}
21
22function archiveTimestamp(thread: EnvironmentThreadShell): number {
23 const timestamp = Date.parse(thread.archivedAt ?? thread.updatedAt);
24 return Number.isNaN(timestamp) ? 0 : timestamp;
25}
26
27function matchesQuery(value: string | null, query: string): boolean {
28 return value?.toLocaleLowerCase().includes(query) ?? false;
29}
30
31export 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 created+47
......@@ -0,0 +1,47 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type ArchivedSnapshotEntry,
4 createArchivedThreadSnapshotsAtomFamily,
5 makeArchivedThreadsEnvironmentKey,
6} from "@t3tools/client-runtime/state/threads";
7import type { EnvironmentId } from "@t3tools/contracts";
8import { useCallback, useMemo } from "react";
9
10import { appAtomRegistry } from "../../state/atom-registry";
11import { orchestrationEnvironment } from "../../state/orchestration";
12
13function archivedSnapshotAtom(environmentId: EnvironmentId) {
14 return orchestrationEnvironment.archivedShellSnapshot({
15 environmentId,
16 input: {},
17 });
18}
19
20const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({
21 getSnapshotAtom: archivedSnapshotAtom,
22 labelPrefix: "mobile:archived-thread-snapshots",
23});
24
25export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {
26 appAtomRegistry.refresh(archivedSnapshotAtom(environmentId));
27}
28
29export 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
......@@ -110,6 +110,7 @@ export interface NativeReviewDiffViewProps extends ViewProps {
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 created+166
......@@ -0,0 +1,166 @@
1import { useMemo } from "react";
2import {
3 Markdown,
4 type CustomRenderers,
5 type NodeStyleOverrides,
6 type PartialMarkdownTheme,
7} from "react-native-nitro-markdown";
8import { Linking, ScrollView, Text as NativeText, View } from "react-native";
9
10import { useThemeColor } from "../../lib/useThemeColor";
11import {
12 hasNativeSelectableMarkdownText,
13 SelectableMarkdownText,
14 type NativeMarkdownTextStyle,
15} from "../../native/SelectableMarkdownText";
16
17interface MarkdownPreviewStyles {
18 readonly theme: PartialMarkdownTheme;
19 readonly styles: NodeStyleOverrides;
20 readonly renderers: CustomRenderers;
21 readonly nativeTextStyle: NativeMarkdownTextStyle;
22}
23
24function 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
145export 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 created+191
......@@ -0,0 +1,191 @@
1import type { ProjectEntry } from "@t3tools/contracts";
2import { SymbolView } from "expo-symbols";
3import { memo, useCallback, useEffect, useMemo, useState } from "react";
4import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native";
5
6import { AppText as Text } from "../../components/AppText";
7import { PierreEntryIcon } from "../../components/PierreEntryIcon";
8import { cn } from "../../lib/cn";
9import { useThemeColor } from "../../lib/useThemeColor";
10import {
11 buildFileTree,
12 defaultExpandedTreePaths,
13 flattenFileTree,
14 type VisibleFileTreeNode,
15} from "./fileTree";
16
17function 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
26const 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
83export 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 created+252
......@@ -0,0 +1,252 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";
3import type { ComponentType } from "react";
4import { memo, useCallback, useEffect, useMemo, useRef } from "react";
5import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native";
6
7import { AppText as Text } from "../../components/AppText";
8import { LoadingStrip } from "../../components/LoadingStrip";
9import {
10 type NativeReviewDiffViewProps,
11 resolveNativeReviewDiffView,
12} from "../diffs/nativeReviewDiffSurface";
13import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";
14import {
15 REVIEW_DIFF_LINE_HEIGHT,
16 REVIEW_MONO_FONT_FAMILY,
17 renderVisibleWhitespace,
18} from "../review/reviewDiffRendering";
19import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
20import { cn } from "../../lib/cn";
21import {
22 buildNativeSourceRows,
23 buildNativeSourceTokens,
24 NATIVE_SOURCE_CONTENT_WIDTH,
25 NATIVE_SOURCE_ROW_HEIGHT,
26 NATIVE_SOURCE_STYLE,
27 nativeSourceRowId,
28} from "./nativeSourceFileAdapter";
29import { sourceHighlightAtom } from "./sourceHighlightingState";
30
31const SOURCE_LINE_HEIGHT = 24;
32const SOURCE_LINE_NUMBER_WIDTH = 58;
33const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE);
34
35interface SourceFileSurfaceProps {
36 readonly contents: string;
37 readonly path: string;
38 readonly initialLine?: number | null;
39}
40
41type SourceHighlightStatus = "highlighting" | "ready" | "error";
42
43function splitSourceLines(contents: string): ReadonlyArray<string> {
44 return contents.replace(/\r\n?/g, "\n").split("\n");
45}
46
47const 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
111function 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
138function 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
154function 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
191function 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
245export 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 created+631
......@@ -0,0 +1,631 @@
1import Stack from "expo-router/stack";
2import { SymbolView } from "expo-symbols";
3import { useLocalSearchParams, useRouter } from "expo-router";
4import { useCallback, useMemo, useRef, useState } from "react";
5import {
6 ActivityIndicator,
7 Linking,
8 Pressable,
9 ScrollView,
10 Text as RNText,
11 View,
12} from "react-native";
13import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
14import {
15 EnvironmentId,
16 type ProjectListEntriesResult,
17 type ProjectReadFileResult,
18 ThreadId,
19} from "@t3tools/contracts";
20
21import { AppText as Text } from "../../components/AppText";
22import { CopyTextButton } from "../../components/CopyTextButton";
23import { EmptyState } from "../../components/EmptyState";
24import { LoadingScreen } from "../../components/LoadingScreen";
25import { cn } from "../../lib/cn";
26import { buildThreadFilesNavigation } from "../../lib/routes";
27import { useThemeColor } from "../../lib/useThemeColor";
28import { useThreadSelection } from "../../state/use-thread-selection";
29import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
30import { useEnvironmentQuery } from "../../state/query";
31import { projectEnvironment } from "../../state/projects";
32import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
33import { FileMarkdownPreview } from "./FileMarkdownPreview";
34import { FileTreeBrowser } from "./FileTreeBrowser";
35import { SourceFileSurface } from "./SourceFileSurface";
36import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
37import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
38import {
39 basename,
40 fileBreadcrumbs,
41 isBrowserPreviewFile,
42 isImagePreviewFile,
43 isMarkdownPreviewFile,
44 isSvgImagePreviewFile,
45} from "./filePath";
46import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl";
47
48type FileViewMode = "preview" | "source";
49
50function 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
58function 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
66function 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
74function defaultViewMode(path: string | null): FileViewMode {
75 return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path))
76 ? "preview"
77 : "source";
78}
79
80function 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
113function 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
135function 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
209function 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
270function 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
341function 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
369function 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
381function 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
415function 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
450export 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
530export 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 created+118
......@@ -0,0 +1,118 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useMemo, useState } from "react";
3import { ActivityIndicator, Image, Pressable, View } from "react-native";
4import ImageViewing from "react-native-image-viewing";
5import { AsyncResult } from "effect/unstable/reactivity";
6
7import { AppText as Text } from "../../components/AppText";
8import { EmptyState } from "../../components/EmptyState";
9import { workspaceFileImageAtom } from "./workspace-file-image-cache";
10
11function 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
62function 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
97export 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 created+62
......@@ -0,0 +1,62 @@
1import { useState } from "react";
2import { ActivityIndicator, View } from "react-native";
3import { WebView } from "react-native-webview";
4
5import { AppText as Text } from "../../components/AppText";
6import { LoadingStrip } from "../../components/LoadingStrip";
7
8export 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 created+43
......@@ -0,0 +1,43 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 isBrowserPreviewFile,
5 isImagePreviewFile,
6 isSvgImagePreviewFile,
7 resolveWorkspaceRelativeFilePath,
8} from "./filePath";
9
10describe("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
31describe("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 created+116
......@@ -0,0 +1,116 @@
1import {
2 isWorkspaceBrowserPreviewPath,
3 isWorkspaceImagePreviewPath,
4} from "@t3tools/shared/filePreview";
5
6export interface FileBreadcrumb {
7 readonly label: string;
8 readonly path: string;
9 readonly kind: "project" | "directory" | "file";
10}
11
12function isWindowsAbsolutePath(value: string): boolean {
13 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
14}
15
16function isAbsolutePath(value: string): boolean {
17 return value.startsWith("/") || isWindowsAbsolutePath(value);
18}
19
20function isWindowsPathStyle(value: string): boolean {
21 return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value);
22}
23
24function 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
32export function basename(path: string): string {
33 const parts = path.split(/[\\/]/).filter(Boolean);
34 return parts.at(-1) ?? path;
35}
36
37export 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
46function 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
64export 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
90export function isBrowserPreviewFile(path: string): boolean {
91 return isWorkspaceBrowserPreviewPath(path);
92}
93
94export function isImagePreviewFile(path: string): boolean {
95 return isWorkspaceImagePreviewPath(path);
96}
97
98export function isSvgImagePreviewFile(path: string): boolean {
99 return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? "");
100}
101
102export function isMarkdownPreviewFile(path: string): boolean {
103 return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
104}
105
106export 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 created+109
......@@ -0,0 +1,109 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { ProjectEntry } from "@t3tools/contracts";
3
4import {
5 buildFileTree,
6 countFileNodes,
7 defaultExpandedTreePaths,
8 firstFilePath,
9 flattenFileTree,
10} from "./fileTree";
11
12const 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
20describe("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 created+220
......@@ -0,0 +1,220 @@
1import type { ProjectEntry } from "@t3tools/contracts";
2import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";
3
4export 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
13export interface VisibleFileTreeNode {
14 readonly node: FileTreeNode;
15 readonly depth: number;
16}
17
18interface MutableFileTreeNode {
19 path: string;
20 name: string;
21 kind: ProjectEntry["kind"];
22 children: Map<string, MutableFileTreeNode>;
23}
24
25function 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
38function 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
47function 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
65function 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
77function 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
87export 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
120export 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
132export 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
142function 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
157function 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
165function 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
195export 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
209export 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 created+46
......@@ -0,0 +1,46 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 buildNativeSourceRows,
5 buildNativeSourceTokens,
6 nativeSourceRowId,
7} from "./nativeSourceFileAdapter";
8
9describe("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 created+65
......@@ -0,0 +1,65 @@
1import type {
2 NativeReviewDiffRow,
3 NativeReviewDiffStyle,
4 NativeReviewDiffToken,
5} from "../diffs/nativeReviewDiffSurface";
6import type { SourceHighlightTokens } from "./sourceHighlightingState";
7
8export const NATIVE_SOURCE_ROW_HEIGHT = 24;
9export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000;
10
11export 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
25const SOURCE_FILE_ID = "source-file";
26
27function expandTabs(value: string): string {
28 return value.replace(/\t/g, " ");
29}
30
31export function nativeSourceRowId(index: number): string {
32 return `source-line:${index}`;
33}
34
35export 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
48export 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 created+123
......@@ -0,0 +1,123 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 createSourceHighlightAtomFamily,
7 type SourceHighlightTokens,
8} from "./sourceHighlightingState";
9
10const highlightedTokens: SourceHighlightTokens = [
11 [{ content: "const", color: "#0000ff", fontStyle: null }],
12];
13
14afterEach(() => {
15 vi.restoreAllMocks();
16});
17
18describe("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 created+50
......@@ -0,0 +1,50 @@
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import { Atom } from "effect/unstable/reactivity";
4
5import {
6 highlightSourceFile,
7 type ReviewDiffTheme,
8 type ReviewHighlightedToken,
9} from "../review/shikiReviewHighlighter";
10
11const SOURCE_HIGHLIGHT_IDLE_TTL_MS = 5 * 60_000;
12
13export interface SourceHighlightInput {
14 readonly path: string;
15 readonly contents: string;
16 readonly theme: ReviewDiffTheme;
17}
18
19export type SourceHighlightTokens = ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>;
20
21type SourceHighlighter = (input: SourceHighlightInput) => Promise<SourceHighlightTokens>;
22
23class SourceHighlightCacheKey extends Data.Class<SourceHighlightInput> {}
24
25class SourceHighlightError extends Data.TaggedError("SourceHighlightError")<{
26 readonly cause: unknown;
27}> {}
28
29export 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
50export const sourceHighlightAtom = createSourceHighlightAtomFamily();
apps/mobile/src/features/files/workspace-file-image-cache.test.ts created+64
......@@ -0,0 +1,64 @@
1import { AtomRegistry } from "effect/unstable/reactivity";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { describe, expect, it, vi } from "vite-plus/test";
4
5import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache";
6
7describe("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 created+48
......@@ -0,0 +1,48 @@
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import { Atom } from "effect/unstable/reactivity";
4
5const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000;
6
7type ImagePrefetch = (uri: string) => Promise<boolean>;
8
9class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {}
10
11export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{
12 readonly cause?: unknown;
13 readonly uri: string;
14}> {}
15
16async function prefetchWithNativeImage(uri: string): Promise<boolean> {
17 const { Image } = await import("react-native");
18 return Image.prefetch(uri);
19}
20
21export 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
48export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily();
apps/mobile/src/features/files/workspaceFileAssetUrl.ts created+31
......@@ -0,0 +1,31 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useMemo } from "react";
3
4import { useAssetUrl } from "../../state/assets";
5import { resolveWorkspaceFilePath } from "./filePath";
6
7export 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 created+244
......@@ -0,0 +1,244 @@
1import type {
2 EnvironmentId,
3 SidebarProjectGroupingMode,
4 SidebarThreadSortOrder,
5} from "@t3tools/contracts";
6import {
7 DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE,
8 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
9 DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
10} from "@t3tools/contracts";
11import { Stack } from "expo-router";
12import { Text as RNText, View } from "react-native";
13
14import { useThemeColor } from "../../lib/useThemeColor";
15import type { HomeProjectSortOrder } from "./homeThreadList";
16
17export interface HomeHeaderEnvironment {
18 readonly environmentId: EnvironmentId;
19 readonly label: string;
20}
21
22const 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
30const 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
38const 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
60export 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 {
22 type EnvironmentProject,
33 type EnvironmentThreadShell,
44} from "@t3tools/client-runtime/state/shell";
5import type {
6 EnvironmentId,
7 SidebarProjectGroupingMode,
8 SidebarThreadSortOrder,
9} from "@t3tools/contracts";
10import * as Haptics from "expo-haptics";
511import { SymbolView } from "expo-symbols";
6import { useCallback, useMemo, useState } from "react";
7import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
12import { useCallback, useMemo, useRef, useState } from "react";
13import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native";
14import ReanimatedSwipeable, {
15 type SwipeableMethods,
16} from "react-native-gesture-handler/ReanimatedSwipeable";
17import Animated, {
18 Easing,
19 LinearTransition,
20 type ExitAnimationsValues,
21 withDelay,
22 withTiming,
23} from "react-native-reanimated";
824import { useSafeAreaInsets } from "react-native-safe-area-context";
9import * as Arr from "effect/Array";
10import * as Order from "effect/Order";
1125import { useThemeColor } from "../../lib/useThemeColor";
1226
1327import { AppText as Text } from "../../components/AppText";
......@@ -15,9 +29,14 @@ import { EmptyState } from "../../components/EmptyState";
1529import { ProjectFavicon } from "../../components/ProjectFavicon";
1630import type { WorkspaceState } from "../../state/workspaceModel";
1731import type { SavedRemoteConnection } from "../../lib/connection";
18import { scopedProjectKey } from "../../lib/scopedEntities";
1932import { relativeTime } from "../../lib/time";
2033import { threadStatusTone } from "../threads/threadPresentation";
34import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList";
35import {
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 {
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
35interface ProjectGroup {
36 readonly key: string;
37 readonly project: EnvironmentProject;
38 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
39}
40
41const 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
5262function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } {
......@@ -65,6 +75,33 @@ function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string
6575}
6676
6777const COLLAPSED_THREAD_LIMIT = 6;
78const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic));
79
80function 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
69106function deriveEmptyState(props: {
70107 readonly catalogState: WorkspaceState;
......@@ -133,6 +170,7 @@ function deriveEmptyState(props: {
133170
134171function 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: {
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: {
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: {
323438
324439export 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) {
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) {
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) {
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) {
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 created+223
......@@ -0,0 +1,223 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import { buildHomeThreadGroups } from "./homeThreadList";
9
10function 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
24function 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
47function 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
64describe("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 created+140
......@@ -0,0 +1,140 @@
1import {
2 deriveLogicalProjectKey,
3 deriveProjectGroupLabel,
4} from "@t3tools/client-runtime/state/project-grouping";
5import type {
6 EnvironmentProject,
7 EnvironmentThreadShell,
8} from "@t3tools/client-runtime/state/shell";
9import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort";
10import type {
11 EnvironmentId,
12 SidebarProjectGroupingMode,
13 SidebarProjectSortOrder,
14 SidebarThreadSortOrder,
15} from "@t3tools/contracts";
16import * as Arr from "effect/Array";
17import * as Order from "effect/Order";
18
19import { scopedProjectKey } from "../../lib/scopedEntities";
20
21export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
22
23export 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
31interface MutableHomeThreadGroup {
32 readonly key: string;
33 readonly projects: EnvironmentProject[];
34 readonly threads: EnvironmentThreadShell[];
35}
36
37function 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
44export 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 created+238
......@@ -0,0 +1,238 @@
1import { SymbolView } from "expo-symbols";
2import type { ComponentProps } from "react";
3import type { ColorValue } from "react-native";
4import { Pressable, View } from "react-native";
5import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
6import Animated, {
7 Extrapolation,
8 interpolate,
9 runOnJS,
10 type SharedValue,
11 useAnimatedReaction,
12 useAnimatedStyle,
13} from "react-native-reanimated";
14
15import { AppText as Text } from "../../components/AppText";
16
17const ACTION_ITEM_WIDTH = 50;
18const ACTION_CIRCLE_SIZE = 36;
19const ACTION_ICON_SIZE = 15;
20
21export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2;
22export const THREAD_SWIPE_SPRING = {
23 damping: 26,
24 mass: 0.7,
25 overshootClamping: true,
26 stiffness: 330,
27};
28
29function 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
177export 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 created+142
......@@ -0,0 +1,142 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import * as Cause from "effect/Cause";
3import * as Haptics from "expo-haptics";
4import { useCallback, useRef } from "react";
5import { Alert } from "react-native";
6
7import { scopedThreadKey } from "../../lib/scopedEntities";
8import { threadEnvironment } from "../../state/threads";
9import { useAtomCommand } from "../../state/use-atom-command";
10
11type ThreadListAction = "archive" | "unarchive" | "delete";
12
13function 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
23function selectionHaptic(): void {
24 if (process.env.EXPO_OS === "ios") {
25 void Haptics.selectionAsync();
26 }
27}
28
29function 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
35function 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
78function 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
102export 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
120export 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
......@@ -695,6 +695,15 @@ export async function highlightCodeSnippet(input: {
695695 return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]);
696696}
697697
698export 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
698707async function highlightPatchLinesInChunks(input: {
699708 readonly lines: ReadonlyArray<string>;
700709 readonly language: string;
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2
......@@ -62,6 +62,7 @@ export interface ThreadDetailScreenProps {
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
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";
44import { type LegendListRef } from "@legendapp/list/react-native";
55import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";
66import { SymbolView } from "expo-symbols";
7import { useRouter } from "expo-router";
78import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
89import {
910 Markdown,
......@@ -54,6 +55,7 @@ import {
5455import { buildReviewParsedDiff } from "../review/reviewModel";
5556import { cn } from "../../lib/cn";
5657import type { LayoutVariant } from "../../lib/layout";
58import { buildThreadFilesNavigation } from "../../lib/routes";
5759import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
5860import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
5961import {
......@@ -64,7 +66,9 @@ import {
6466import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout";
6567import { relativeTime } from "../../lib/time";
6668import type { ThreadContentPresentation } from "./threadContentPresentation";
69import { ThreadWorkLog } from "./thread-work-log";
6770import { useAssetUrl } from "../../state/assets";
71import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
6872
6973const THREAD_FEED_END_THRESHOLD = 80;
7074const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
......@@ -83,6 +87,7 @@ function formatMessageTime(input: string): string {
8387export 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: {
120125 );
121126}
122127
123function 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
129function 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
138function 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
147const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
148
149128const MARKDOWN_COLORS = {
150129 light: {
151130 body: "#111111",
......@@ -155,10 +134,12 @@ const MARKDOWN_COLORS = {
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 = {
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 {
202185
203186const failedMarkdownFaviconHosts = new Set<string>();
204187const 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: {
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 {
287261 );
288262}
289263
290function useMarkdownStyles(): MarkdownStyleSets {
264function 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 {
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 {
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 {
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 {
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 {
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 {
676641 },
677642 },
678643 };
679 }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]);
644 }, [colors, inlineSkillForeground, onLinkPress]);
680645}
681646
682647function renderFeedEntry(
......@@ -692,11 +657,13 @@ function renderFeedEntry(
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(
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(
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(
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(
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: {
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: {
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: {
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: {
12541123}
12551124
12561125export 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) {
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) {
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) {
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) {
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";
1414import Stack from "expo-router/stack";
1515import { useCallback, useMemo } from "react";
1616import { Alert, Linking } from "react-native";
17import { buildThreadReviewRoutePath } from "../../lib/routes";
17import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";
1818import {
1919 basename,
2020 getTerminalStatusLabel,
......@@ -69,6 +69,7 @@ export function ThreadGitControls(props: {
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: {
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
......@@ -420,6 +420,7 @@ export function ThreadRouteScreen() {
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() {
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 created+261
......@@ -0,0 +1,261 @@
1import * as Haptics from "expo-haptics";
2import { SymbolView, type SFSymbol } from "expo-symbols";
3import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";
4
5import { AppText as Text } from "../../components/AppText";
6import { cn } from "../../lib/cn";
7import type { ThreadFeedActivity } from "../../lib/threadActivity";
8
9const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
10const 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
23function triggerDisclosureFeedback() {
24 LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION);
25 void Haptics.selectionAsync();
26}
27
28function 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
34function 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
43function 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
72export 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
......@@ -16,26 +16,47 @@ describe("resolveMarkdownLinkPresentation", () => {
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 {
77 nativeMarkdownDocumentRuns,
88 nativeMarkdownListItemBlocks,
99 nativeMarkdownTextRuns,
10 nativeMarkdownWithPreservedSoftBreaks,
1011} from "@t3tools/mobile-markdown-text/markdown";
1112
1213describe("nativeMarkdownTextRuns", () => {
......@@ -54,7 +55,11 @@ describe("nativeMarkdownTextRuns", () => {
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", () => {
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", () => {
130150});
131151
132152describe("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 created+45
......@@ -0,0 +1,45 @@
1import { describe, expect, it } from "vite-plus/test";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3
4import { buildThreadFilesNavigation, buildThreadFilesRoutePath } from "./routes";
5
6const thread = {
7 environmentId: EnvironmentId.make("environment-1"),
8 threadId: ThreadId.make("thread-1"),
9};
10
11describe("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
......@@ -32,6 +32,27 @@ export function buildThreadReviewRoutePath(
3232 return `${buildThreadRoutePath(input)}/review`;
3333}
3434
35export 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
3556export function buildThreadTerminalRoutePath(
3657 input: ThreadRouteInput | PlainThreadRouteInput,
3758 terminalId?: string | null,
......@@ -71,6 +92,38 @@ export function buildThreadTerminalNavigation(
7192 };
7293}
7394
95export 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
74127export 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", () => {
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 {
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 {
6073 itemType?: ToolLifecycleItemType;
6174 requestKind?: PendingApproval["requestKind"];
6275 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
76 toolData?: unknown;
6377}
6478
6579interface DerivedWorkLogEntry extends WorkLogEntry {
......@@ -214,7 +228,7 @@ function resolvePendingUserInputAnswer(
214228
215229function 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(
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
233245function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
......@@ -301,6 +313,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
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(
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(
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"] {
480500 return "neutral";
481501}
482502
503function 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
528function 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
483549function workEntryPreview(
484550 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,
485551): string | null {
......@@ -1226,11 +1292,7 @@ export function buildThreadFeed(
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(
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
......@@ -89,6 +89,42 @@ describe("AssetAccess", () => {
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 @@
11import type { AssetResource } from "@t3tools/contracts";
22import { AssetAccessError } from "@t3tools/contracts";
3import {
4 isWorkspaceImagePreviewPath,
5 isWorkspacePreviewEntryPath,
6 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
7 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
8} from "@t3tools/shared/filePreview";
39import * as Clock from "effect/Clock";
410import * as Effect from "effect/Effect";
511import * as FileSystem from "effect/FileSystem";
......@@ -24,22 +30,14 @@ export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/
2430
2531const SIGNING_SECRET_NAME = "asset-access-signing-key";
2632const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
27const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]);
2833const 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([
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
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
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* (
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 created+22
......@@ -0,0 +1,22 @@
1import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
2import { RouterProvider } from "@tanstack/react-router";
3import { describe, expect, it } from "vite-plus/test";
4
5import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
6import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
7import type { AppRouter } from "./router";
8import { AppRoot } from "./AppRoot";
9
10describe("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 created+19
......@@ -0,0 +1,19 @@
1import { RouterProvider } from "@tanstack/react-router";
2
3import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
4import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
5import 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 */
12export 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";
99
1010import { useActiveBrowserRecordingTabId } from "./browserRecording";
1111import { useBrowserSurfaceStore } from "./browserSurfaceStore";
12import { acquireDesktopTab } from "./desktopTabLifetime";
12import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
1313import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
1414
1515interface ElectronWebview extends HTMLElement {
......@@ -34,13 +34,21 @@ export function HostedBrowserWebview(props: {
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: {
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 created+45
......@@ -0,0 +1,45 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const { closeTab, createTab } = vi.hoisted(() => ({
4 closeTab: vi.fn(async () => undefined),
5 createTab: vi.fn<() => Promise<void>>(),
6}));
7
8vi.mock("~/components/preview/previewBridge", () => ({
9 previewBridge: { closeTab, createTab },
10}));
11
12import { acquireDesktopTab } from "./desktopTabLifetime";
13
14describe("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";
33interface DesktopTabLease {
44 references: number;
55 closeTimer: number | null;
6 ready: Promise<void>;
67}
78
89const leases = new Map<string, DesktopTabLease>();
910
10export function acquireDesktopTab(tabId: string): () => void {
11 const current = leases.get(tabId) ?? { references: 0, closeTimer: null };
11export interface AcquiredDesktopTab {
12 readonly ready: Promise<void>;
13 readonly release: () => void;
14}
15
16export 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 {
111111 useRightPanelStore,
112112} from "../rightPanelStore";
113113import {
114 applyPreviewServerSnapshot,
115114 isPreviewSupportedInRuntime,
116 removePreviewSession,
117115 setActivePreviewTab,
118116 useThreadPreviewState,
119117} from "../previewStateStore";
118import { addBrowserSurface } from "./preview/addBrowserSurface";
119import { closePreviewSession } from "./preview/closePreviewSession";
120120import { subscribePreviewAction } from "./preview/previewActionBus";
121121import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
122122import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
......@@ -2746,23 +2746,8 @@ function ChatViewContent(props: ChatViewProps) {
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) {
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) {
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) {
30203012 },
30213013 [
30223014 activeThreadRef,
3015 activePreviewState.sessions,
30233016 closePreview,
30243017 closeTerminalMutation,
30253018 diffOpen,
apps/web/src/components/preview/addBrowserSurface.test.ts created+52
......@@ -0,0 +1,52 @@
1import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
2import { AsyncResult } from "effect/unstable/reactivity";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 applyPreviewServerSnapshot,
7 readThreadPreviewState,
8 resetPreviewStateForTests,
9} from "~/previewStateStore";
10import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore";
11
12import { addBrowserSurface } from "./addBrowserSurface";
13
14const threadRef = {
15 environmentId: "local" as ScopedThreadRef["environmentId"],
16 threadId: "thread-1" as ScopedThreadRef["threadId"],
17};
18
19const 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
28beforeEach(() => {
29 resetPreviewStateForTests();
30 useRightPanelStore.setState({ byThreadKey: {} });
31});
32
33describe("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 created+24
......@@ -0,0 +1,24 @@
1import {
2 mapAtomCommandResult,
3 type AtomCommandResult,
4} from "@t3tools/client-runtime/state/runtime";
5import type { ScopedThreadRef } from "@t3tools/contracts";
6
7import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
8import { useRightPanelStore } from "~/rightPanelStore";
9
10import { openPreviewSession } from "./openPreviewSession";
11
12/** Creates a new browser tab. Reopening an existing tab is a separate UI action. */
13export 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 created+79
......@@ -0,0 +1,79 @@
1import type {
2 PreviewCloseInput,
3 PreviewSessionSnapshot,
4 ScopedThreadRef,
5} from "@t3tools/contracts";
6import * as Cause from "effect/Cause";
7import { AsyncResult } from "effect/unstable/reactivity";
8import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10import {
11 applyPreviewServerSnapshot,
12 readThreadPreviewState,
13 resetPreviewStateForTests,
14} from "~/previewStateStore";
15
16import { closePreviewSession } from "./closePreviewSession";
17
18const threadRef = {
19 environmentId: "local" as ScopedThreadRef["environmentId"],
20 threadId: "thread-1" as ScopedThreadRef["threadId"],
21};
22
23const 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
36beforeEach(resetPreviewStateForTests);
37
38describe("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 created+37
......@@ -0,0 +1,37 @@
1import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
2import type {
3 EnvironmentId,
4 PreviewCloseInput,
5 PreviewSessionSnapshot,
6 ScopedThreadRef,
7} from "@t3tools/contracts";
8
9import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore";
10
11interface 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 */
25export 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
......@@ -28,6 +28,24 @@ const snapshot: PreviewSessionSnapshot = {
2828beforeEach(resetPreviewStateForTests);
2929
3030describe("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> {
1414 readonly input: PreviewOpenInput;
1515 }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;
1616 threadRef: ScopedThreadRef;
17 url: string;
17 url?: string;
1818}
1919
2020export async function openPreviewSession<E>(
......@@ -24,7 +24,7 @@ export async function openPreviewSession<E>(
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>(
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 @@
11import { useAtomValue } from "@effect/atom-react";
22import {
33 type ArchivedSnapshotEntry,
4 createArchivedThreadSnapshotsAtomFamily,
45 makeArchivedThreadsEnvironmentKey,
5 parseArchivedThreadsEnvironmentKey,
66} from "@t3tools/client-runtime/state/threads";
77import type { EnvironmentId } from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Option from "effect/Option";
10import { AsyncResult, Atom } from "effect/unstable/reactivity";
118import { useCallback, useMemo } from "react";
129
1310import { orchestrationEnvironment } from "../state/orchestration";
1411import { appAtomRegistry } from "../rpc/atomRegistry";
1512
16const 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
5113function archivedSnapshotAtom(environmentId: EnvironmentId) {
5214 return orchestrationEnvironment.archivedShellSnapshot({
5315 environmentId,
......@@ -55,6 +17,11 @@ function archivedSnapshotAtom(environmentId: EnvironmentId) {
5517 });
5618}
5719
20const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({
21 getSnapshotAtom: archivedSnapshotAtom,
22 labelPrefix: "web:archived-thread-snapshots",
23});
24
5825export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {
5926 appAtomRegistry.refresh(archivedSnapshotAtom(environmentId));
6027}
apps/web/src/lib/threadSort.ts+7-86
......@@ -1,86 +1,7 @@
1import type { ProjectId } from "@t3tools/contracts";
2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import type { Thread } from "../types";
4
5export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & {
6 latestUserMessageAt?: string | null;
7 messages?: ReadonlyArray<Pick<Thread["messages"][number], "createdAt" | "role">>;
8};
9
10export 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
16function 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
27function 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
51export 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
63export 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
77export 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}
1export {
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 @@
1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
3import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
4import type { UnifiedSettings } from "@t3tools/contracts/settings";
5import { normalizeProjectPathForComparison } from "./lib/projectPaths";
6
7export interface ProjectGroupingSettings {
8 sidebarProjectGroupingMode: SidebarProjectGroupingMode;
9 sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>;
10}
11
12export type ProjectGroupingMode = SidebarProjectGroupingMode;
13
14export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings {
15 return {
16 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
17 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
18 };
19}
20
21function 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
35function 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
62export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string {
63 return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`;
64}
65
66export function derivePhysicalProjectKey(
67 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
68): string {
69 return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot);
70}
71
72export 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`.
80export function getProjectOrderKey(
81 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
82): string {
83 return derivePhysicalProjectKey(project);
84}
85
86export 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
96function 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
119export 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
140export 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
152export 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
165export 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}
1export {
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 @@
11import React from "react";
22import ReactDOM from "react-dom/client";
33import { ClerkProvider } from "@clerk/react";
4import { RouterProvider } from "@tanstack/react-router";
54import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
65
76import "@fontsource-variable/dm-sans/index.css";
......@@ -17,7 +16,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig";
1716import { getRouter } from "./router";
1817import { APP_DISPLAY_NAME } from "./branding";
1918import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
20import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
19import { AppRoot } from "./AppRoot";
2120
2221// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
2322const history = isElectron ? createHashHistory() : createBrowserHistory();
......@@ -32,12 +31,7 @@ document.title = APP_DISPLAY_NAME;
3231
3332const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
3433
35const app = (
36 <>
37 <RouterProvider router={router} />
38 <ElectronBrowserHost />
39 </>
40);
34const app = <AppRoot router={router} />;
4135
4236ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
4337 <React.StrictMode>
apps/web/src/previewStateStore.test.ts+28-3
......@@ -7,10 +7,11 @@ import {
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)", () => {
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)", () => {
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)", () => {
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 {
2828export 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 {
3739const 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
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(
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(
255260 });
256261}
257262
258export function removePreviewSession(ref: ScopedThreadRef, tabId: string): void {
259 updateThreadPreviewState(ref, (current) => removeSession(current, tabId));
263export 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
274export 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
262302export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void {
apps/web/src/router.ts-3
......@@ -1,7 +1,5 @@
1import { createElement } from "react";
21import { createRouter, RouterHistory } from "@tanstack/react-router";
32
4import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
53import { routeTree } from "./routeTree.gen";
64
75export function getRouter(history: RouterHistory) {
......@@ -9,7 +7,6 @@ export function getRouter(history: RouterHistory) {
97 routeTree,
108 history,
119 context: {},
12 Wrap: ({ children }) => createElement(AppAtomRegistryProvider, undefined, children),
1310 });
1411}
1512
packages/client-runtime/package.json+8
......@@ -83,6 +83,10 @@
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 @@
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
......@@ -1,13 +1,22 @@
11import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
22import * as Arr from "effect/Array";
3import * as Cause from "effect/Cause";
34import { pipe } from "effect/Function";
5import * as Option from "effect/Option";
46import * as Order from "effect/Order";
7import { AsyncResult, Atom } from "effect/unstable/reactivity";
58
69export interface ArchivedSnapshotEntry {
710 readonly environmentId: EnvironmentId;
811 readonly snapshot: OrchestrationShellSnapshot;
912}
1013
14export interface ArchivedThreadSnapshotsState {
15 readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
16 readonly error: string | null;
17 readonly isLoading: boolean;
18}
19
1120const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f";
1221const environmentIdOrder = Order.String as Order.Order<EnvironmentId>;
1322
......@@ -28,3 +37,38 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<E
2837 Arr.map((environmentId) => EnvironmentId.make(environmentId)),
2938 );
3039}
40
41export 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 created+183
......@@ -0,0 +1,183 @@
1import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";
2import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
3import type { UnifiedSettings } from "@t3tools/contracts/settings";
4
5import type { EnvironmentProject } from "./models.ts";
6import { normalizeProjectPathForComparison } from "./projects.ts";
7
8export interface ProjectGroupingSettings {
9 readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode;
10 readonly sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>;
11}
12
13export type ProjectGroupingMode = SidebarProjectGroupingMode;
14
15export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings {
16 return {
17 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
18 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
19 };
20}
21
22function 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
36function 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
63export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string {
64 return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`;
65}
66
67export function derivePhysicalProjectKey(
68 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
69): string {
70 return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot);
71}
72
73export function deriveProjectGroupingOverrideKey(
74 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
75): string {
76 return derivePhysicalProjectKey(project);
77}
78
79export function getProjectOrderKey(
80 project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">,
81): string {
82 return derivePhysicalProjectKey(project);
83}
84
85export 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
95function 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
118export 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
139export 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
151export 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
164export 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 created+101
......@@ -0,0 +1,101 @@
1import type { ProjectId } from "@t3tools/contracts";
2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import * as Arr from "effect/Array";
4import * as Order from "effect/Order";
5
6export 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
16export 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
22function 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
33function 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
57export 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
69export 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
88export 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
......@@ -163,6 +163,10 @@
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 created+36
......@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 isWorkspaceBrowserPreviewPath,
5 isWorkspaceImagePreviewPath,
6 isWorkspacePreviewEntryPath,
7} from "./filePreview.ts";
8
9describe("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 created+29
......@@ -0,0 +1,29 @@
1export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const;
2
3export 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
14function 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
19export function isWorkspaceBrowserPreviewPath(path: string): boolean {
20 return hasPreviewExtension(path, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS);
21}
22
23export function isWorkspaceImagePreviewPath(path: string): boolean {
24 return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS);
25}
26
27export function isWorkspacePreviewEntryPath(path: string): boolean {
28 return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path);
29}
pnpm-lock.yaml+58-41
......@@ -247,7 +247,7 @@ importers:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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