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", () => {...@@ -128,6 +128,58 @@ describe("PreviewManager", () => {
128 ),128 ),
129 );129 );
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
131 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>183 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>
132 withManager((manager) =>184 withManager((manager) =>
133 Effect.gen(function* () {185 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+89-12
...@@ -1195,26 +1195,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1195,26 +1195,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1195 }1195 }
1196 yield* attachListeners(tabId, wc);1196 yield* attachListeners(tabId, wc);
1197 runFork(ensureControlSession(wc).pipe(Effect.ignore));1197 runFork(ensureControlSession(wc).pipe(Effect.ignore));
1198 if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {1198 const registeredAt = yield* currentIso;
1199 yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe(1199 const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1200 Effect.ignore,1200 const current = tabs.get(tabId);
1201 );1201 if (!current) {
1202 }1202 return [
1203 yield* update(tabId, {1203 Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(),
1204 webContentsId,1204 tabs,
1205 navStatus: computeNavStatus(wc),1205 ] as const;
1206 canGoBack: wc.navigationHistory.canGoBack(),1206 }
1207 canGoForward: wc.navigationHistory.canGoForward(),1207 const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
1208 zoomFactor: tab.zoomFactor,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;
1209 });1225 });
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 }
1210 yield* attempt("registerWebview.sendTheme", () =>1236 yield* attempt("registerWebview.sendTheme", () =>
1211 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),1237 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1212 );1238 );
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 }
1213 });1252 });
12141253
1215 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {1254 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {
1216 const wc = yield* requireWebContents(tabId);
1217 const url = yield* attempt("navigate.normalizeUrl", () => normalizePreviewUrl(rawUrl));1255 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 }
1218 if (wc.getURL() === url) {1295 if (wc.getURL() === url) {
1219 yield* attempt("navigate.reload", () => wc.reload());1296 yield* attempt("navigate.reload", () => wc.reload());
1220 return;1297 return;
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+8-104
...@@ -70,7 +70,12 @@ static void T3MarkdownTextApplyAttachments(...@@ -70,7 +70,12 @@ static void T3MarkdownTextApplyAttachments(
70 renderingMode:UIImageRenderingModeAlwaysOriginal];70 renderingMode:UIImageRenderingModeAlwaysOriginal];
71 }71 }
72 attachment.image = image ?: [[UIImage alloc] init];72 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);
74 const NSRange range = NSMakeRange(79 const NSRange range = NSMakeRange(
75 attachmentRange.location,80 attachmentRange.location,
76 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));81 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
...@@ -80,104 +85,6 @@ static void T3MarkdownTextApplyAttachments(...@@ -80,104 +85,6 @@ static void T3MarkdownTextApplyAttachments(
80 }85 }
81}86}
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
181@protocol T3MarkdownOutsideTapTarget <NSObject>88@protocol T3MarkdownOutsideTapTarget <NSObject>
182- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView;89- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView;
183@end90@end
...@@ -285,7 +192,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -285,7 +192,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
285192
286@implementation T3MarkdownText {193@implementation T3MarkdownText {
287 UIView * _view;194 UIView * _view;
288 T3MarkdownTextBackingView * _textView;195 UITextView * _textView;
289 T3MarkdownTextShadowNode::ConcreteState::Shared _state;196 T3MarkdownTextShadowNode::ConcreteState::Shared _state;
290 __weak UIWindow * _outsideTapWindow;197 __weak UIWindow * _outsideTapWindow;
291 BOOL _suppressSelectionChange;198 BOOL _suppressSelectionChange;
...@@ -308,7 +215,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -308,7 +215,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
308 self.contentView = _view;215 self.contentView = _view;
309 self.clipsToBounds = true;216 self.clipsToBounds = true;
310217
311 _textView = [[T3MarkdownTextBackingView alloc] init];218 _textView = [[UITextView alloc] init];
312 _attachmentImages = [[NSMutableDictionary alloc] init];219 _attachmentImages = [[NSMutableDictionary alloc] init];
313 _pendingAttachmentUris = [[NSMutableSet alloc] init];220 _pendingAttachmentUris = [[NSMutableSet alloc] init];
314 _textView.scrollEnabled = false;221 _textView.scrollEnabled = false;
...@@ -405,9 +312,6 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -405,9 +312,6 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
405 convertedAttrString,312 convertedAttrString,
406 _state->getData().attachmentRanges,313 _state->getData().attachmentRanges,
407 _attachmentImages);314 _attachmentImages);
408 _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds(
409 convertedAttrString,
410 _state->getData().chipRanges);
411 [self loadAttachmentImages:_state->getData().attachmentRanges];315 [self loadAttachmentImages:_state->getData().attachmentRanges];
412316
413 // Setting attributedText clears any active text selection, and re-assigning317 // 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 {...@@ -28,18 +28,20 @@ struct T3MarkdownTextAttachmentRange {
28 std::string imageUri;28 std::string imageUri;
29};29};
3030
31struct T3MarkdownTextChipRange {31inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {
32 size_t location;32 return 14;
33 size_t length;33}
34 bool isSkill;34
35};35inline Float T3MarkdownTextAttachmentBaselineOffset(
36 const T3MarkdownTextAttachmentRange &) {
37 return -2;
38}
3639
37class T3MarkdownTextStateReal final {40class T3MarkdownTextStateReal final {
38 public:41 public:
39 AttributedString attributedString;42 AttributedString attributedString;
40 std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges;43 std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges;
41 std::vector<T3MarkdownTextAttachmentRange> attachmentRanges;44 std::vector<T3MarkdownTextAttachmentRange> attachmentRanges;
42 std::vector<T3MarkdownTextChipRange> chipRanges;
43};45};
4446
45class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode<47class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode<
...@@ -72,6 +74,5 @@ private:...@@ -72,6 +74,5 @@ private:
72 mutable AttributedString _attributedString;74 mutable AttributedString _attributedString;
73 mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges;75 mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges;
74 mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges;76 mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges;
75 mutable std::vector<T3MarkdownTextChipRange> _chipRanges;
76};77};
77} // namespace facebook::React78} // namespace facebook::React
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm+15-20
...@@ -9,9 +9,8 @@...@@ -9,9 +9,8 @@
9namespace facebook::react {9namespace facebook::react {
1010
11static constexpr Float ParagraphStyleEncodingOffset = 1000;11static constexpr Float ParagraphStyleEncodingOffset = 1000;
12static constexpr auto ChipNativeIdPrefix = "t3-chip-";12static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:";
13static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:";13static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:";
14static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:";
1514
16static void applyParagraphStyles(15static void applyParagraphStyles(
17 NSMutableAttributedString *attributedString,16 NSMutableAttributedString *attributedString,
...@@ -58,7 +57,12 @@ static void applyAttachments(...@@ -58,7 +57,12 @@ static void applyAttachments(
5857
59 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];58 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
60 attachment.image = [[UIImage alloc] init];59 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);
62 const NSRange range = NSMakeRange(66 const NSRange range = NSMakeRange(
63 attachmentRange.location,67 attachmentRange.location,
64 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));68 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
...@@ -91,7 +95,6 @@ Size T3MarkdownTextShadowNode::measureContent(...@@ -91,7 +95,6 @@ Size T3MarkdownTextShadowNode::measureContent(
91 auto baseAttributedString = AttributedString{};95 auto baseAttributedString = AttributedString{};
92 auto paragraphStyleRanges = std::vector<T3MarkdownTextParagraphStyleRange>{};96 auto paragraphStyleRanges = std::vector<T3MarkdownTextParagraphStyleRange>{};
93 auto attachmentRanges = std::vector<T3MarkdownTextAttachmentRange>{};97 auto attachmentRanges = std::vector<T3MarkdownTextAttachmentRange>{};
94 auto chipRanges = std::vector<T3MarkdownTextChipRange>{};
95 size_t utf16Offset = 0;98 size_t utf16Offset = 0;
96 const auto &children = getChildren();99 const auto &children = getChildren();
97 for (size_t i = 0; i < children.size(); i++) {100 for (size_t i = 0; i < children.size(); i++) {
...@@ -184,25 +187,19 @@ Size T3MarkdownTextShadowNode::measureContent(...@@ -184,25 +187,19 @@ Size T3MarkdownTextShadowNode::measureContent(
184 props.shadowRadius - ParagraphStyleEncodingOffset,187 props.shadowRadius - ParagraphStyleEncodingOffset,
185 });188 });
186 }189 }
187 if (props.nativeId.rfind(ChipNativeIdPrefix, 0) == 0 && fragmentLength > 0) {190 if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
188 chipRanges.push_back(T3MarkdownTextChipRange{
189 utf16Offset,
190 fragmentLength,
191 props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0,
192 });
193 }
194 if (props.nativeId.rfind(FileChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) {
195 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{191 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
196 utf16Offset + 1,192 utf16Offset,
197 1,193 1,
198 props.nativeId.substr(std::char_traits<char>::length(FileChipNativeIdPrefix)),194 props.nativeId.substr(std::char_traits<char>::length(FileAttachmentNativeIdPrefix)),
199 });195 });
200 } else if (196 } else if (
201 props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) {197 props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
202 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{198 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
203 utf16Offset + 1,199 utf16Offset,
204 1,200 1,
205 props.nativeId.substr(std::char_traits<char>::length(SkillChipNativeIdPrefix)),201 props.nativeId.substr(
202 std::char_traits<char>::length(SkillAttachmentNativeIdPrefix)),
206 });203 });
207 }204 }
208 utf16Offset += fragmentLength;205 utf16Offset += fragmentLength;
...@@ -213,7 +210,6 @@ Size T3MarkdownTextShadowNode::measureContent(...@@ -213,7 +210,6 @@ Size T3MarkdownTextShadowNode::measureContent(
213 _attributedString = baseAttributedString;210 _attributedString = baseAttributedString;
214 _paragraphStyleRanges = paragraphStyleRanges;211 _paragraphStyleRanges = paragraphStyleRanges;
215 _attachmentRanges = attachmentRanges;212 _attachmentRanges = attachmentRanges;
216 _chipRanges = chipRanges;
217213
218 NSMutableAttributedString *convertedAttributedString =214 NSMutableAttributedString *convertedAttributedString =
219 [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy];215 [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy];
...@@ -263,7 +259,6 @@ void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) {...@@ -263,7 +259,6 @@ void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) {
263 _attributedString,259 _attributedString,
264 _paragraphStyleRanges,260 _paragraphStyleRanges,
265 _attachmentRanges,261 _attachmentRanges,
266 _chipRanges,
267 });262 });
268}263}
269}264}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+63-8
...@@ -40,11 +40,13 @@ function documentFor(node: MarkdownNode): MarkdownNode {...@@ -40,11 +40,13 @@ function documentFor(node: MarkdownNode): MarkdownNode {
40function SelectableNode(props: {40function SelectableNode(props: {
41 readonly node: MarkdownNode;41 readonly node: MarkdownNode;
42 readonly textStyle: NativeMarkdownTextStyle;42 readonly textStyle: NativeMarkdownTextStyle;
43 readonly onLinkPress?: (href: string) => void;
43}) {44}) {
44 return (45 return (
45 <NativeMarkdownSelectableText46 <NativeMarkdownSelectableText
46 runs={nativeMarkdownDocumentRuns(documentFor(props.node))}47 runs={nativeMarkdownDocumentRuns(documentFor(props.node))}
47 textStyle={props.textStyle}48 textStyle={props.textStyle}
49 onLinkPress={props.onLinkPress}
48 />50 />
49 );51 );
50}52}
...@@ -312,6 +314,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] {...@@ -312,6 +314,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] {
312function NativeTable(props: {314function NativeTable(props: {
313 readonly node: MarkdownNode;315 readonly node: MarkdownNode;
314 readonly textStyle: NativeMarkdownTextStyle;316 readonly textStyle: NativeMarkdownTextStyle;
317 readonly onLinkPress?: (href: string) => void;
315}) {318}) {
316 const rows = collectTableRows(props.node);319 const rows = collectTableRows(props.node);
317 return (320 return (
...@@ -351,6 +354,7 @@ function NativeTable(props: {...@@ -351,6 +354,7 @@ function NativeTable(props: {
351 rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run,354 rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run,
352 )}355 )}
353 textStyle={props.textStyle}356 textStyle={props.textStyle}
357 onLinkPress={props.onLinkPress}
354 />358 />
355 </View>359 </View>
356 ))}360 ))}
...@@ -364,10 +368,17 @@ function NativeTable(props: {...@@ -364,10 +368,17 @@ function NativeTable(props: {
364function NativeMarkdownImage(props: {368function NativeMarkdownImage(props: {
365 readonly node: MarkdownNode;369 readonly node: MarkdownNode;
366 readonly textStyle: NativeMarkdownTextStyle;370 readonly textStyle: NativeMarkdownTextStyle;
371 readonly onLinkPress?: (href: string) => void;
367}) {372}) {
368 const href = props.node.href;373 const href = props.node.href;
369 if (!href) {374 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 );
371 }382 }
372383
373 return (384 return (
...@@ -426,6 +437,7 @@ function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] {...@@ -426,6 +437,7 @@ function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] {
426function NativeMixedParagraph(props: {437function NativeMixedParagraph(props: {
427 readonly node: MarkdownNode;438 readonly node: MarkdownNode;
428 readonly textStyle: NativeMarkdownTextStyle;439 readonly textStyle: NativeMarkdownTextStyle;
440 readonly onLinkPress?: (href: string) => void;
429}) {441}) {
430 return (442 return (
431 <View style={{ gap: 8 }}>443 <View style={{ gap: 8 }}>
...@@ -435,9 +447,15 @@ function NativeMixedParagraph(props: {...@@ -435,9 +447,15 @@ function NativeMixedParagraph(props: {
435 key={nodeKey(child, index)}447 key={nodeKey(child, index)}
436 node={child}448 node={child}
437 textStyle={props.textStyle}449 textStyle={props.textStyle}
450 onLinkPress={props.onLinkPress}
438 />451 />
439 ) : (452 ) : (
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 />
441 ),459 ),
442 )}460 )}
443 </View>461 </View>
...@@ -448,6 +466,7 @@ function NativeList(props: {...@@ -448,6 +466,7 @@ function NativeList(props: {
448 readonly node: MarkdownNode;466 readonly node: MarkdownNode;
449 readonly textStyle: NativeMarkdownTextStyle;467 readonly textStyle: NativeMarkdownTextStyle;
450 readonly highlightCode: MarkdownCodeHighlighter;468 readonly highlightCode: MarkdownCodeHighlighter;
469 readonly onLinkPress?: (href: string) => void;
451 readonly depth: number;470 readonly depth: number;
452}) {471}) {
453 const ordered = props.node.ordered ?? false;472 const ordered = props.node.ordered ?? false;
...@@ -508,6 +527,7 @@ function NativeList(props: {...@@ -508,6 +527,7 @@ function NativeList(props: {
508 node={child}527 node={child}
509 textStyle={props.textStyle}528 textStyle={props.textStyle}
510 highlightCode={props.highlightCode}529 highlightCode={props.highlightCode}
530 onLinkPress={props.onLinkPress}
511 depth={props.depth + 1}531 depth={props.depth + 1}
512 compact532 compact
513 />533 />
...@@ -524,6 +544,7 @@ export function NativeMarkdownBlock(props: {...@@ -524,6 +544,7 @@ export function NativeMarkdownBlock(props: {
524 readonly node: MarkdownNode;544 readonly node: MarkdownNode;
525 readonly textStyle: NativeMarkdownTextStyle;545 readonly textStyle: NativeMarkdownTextStyle;
526 readonly highlightCode: MarkdownCodeHighlighter;546 readonly highlightCode: MarkdownCodeHighlighter;
547 readonly onLinkPress?: (href: string) => void;
527 readonly depth?: number;548 readonly depth?: number;
528 readonly compact?: boolean;549 readonly compact?: boolean;
529}) {550}) {
...@@ -538,6 +559,7 @@ export function NativeMarkdownBlock(props: {...@@ -538,6 +559,7 @@ export function NativeMarkdownBlock(props: {
538 node={child}559 node={child}
539 textStyle={props.textStyle}560 textStyle={props.textStyle}
540 highlightCode={props.highlightCode}561 highlightCode={props.highlightCode}
562 onLinkPress={props.onLinkPress}
541 depth={depth}563 depth={depth}
542 />564 />
543 ))}565 ))}
...@@ -553,9 +575,21 @@ export function NativeMarkdownBlock(props: {...@@ -553,9 +575,21 @@ export function NativeMarkdownBlock(props: {
553 />575 />
554 );576 );
555 case "table":577 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 );
557 case "image":585 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 );
559 case "horizontal_rule":593 case "horizontal_rule":
560 return (594 return (
561 <View595 <View
...@@ -583,6 +617,7 @@ export function NativeMarkdownBlock(props: {...@@ -583,6 +617,7 @@ export function NativeMarkdownBlock(props: {
583 node={child}617 node={child}
584 textStyle={props.textStyle}618 textStyle={props.textStyle}
585 highlightCode={props.highlightCode}619 highlightCode={props.highlightCode}
620 onLinkPress={props.onLinkPress}
586 depth={depth}621 depth={depth}
587 compact622 compact
588 />623 />
...@@ -595,14 +630,23 @@ export function NativeMarkdownBlock(props: {...@@ -595,14 +630,23 @@ export function NativeMarkdownBlock(props: {
595 node={props.node}630 node={props.node}
596 textStyle={props.textStyle}631 textStyle={props.textStyle}
597 highlightCode={props.highlightCode}632 highlightCode={props.highlightCode}
633 onLinkPress={props.onLinkPress}
598 depth={depth}634 depth={depth}
599 />635 />
600 );636 );
601 case "paragraph":637 case "paragraph":
602 return (props.node.children ?? []).some((child) => child.type === "image") ? (638 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 />
604 ) : (644 ) : (
605 <SelectableNode node={props.node} textStyle={props.textStyle} />645 <SelectableNode
646 node={props.node}
647 textStyle={props.textStyle}
648 onLinkPress={props.onLinkPress}
649 />
606 );650 );
607 case "html_block":651 case "html_block":
608 case "math_block":652 case "math_block":
...@@ -618,7 +662,11 @@ export function NativeMarkdownBlock(props: {...@@ -618,7 +662,11 @@ export function NativeMarkdownBlock(props: {
618 : "transparent",662 : "transparent",
619 }}663 }}
620 >664 >
621 <SelectableNode node={props.node} textStyle={props.textStyle} />665 <SelectableNode
666 node={props.node}
667 textStyle={props.textStyle}
668 onLinkPress={props.onLinkPress}
669 />
622 </View>670 </View>
623 );671 );
624 case "table_head":672 case "table_head":
...@@ -635,6 +683,7 @@ export function NativeMarkdownBlock(props: {...@@ -635,6 +683,7 @@ export function NativeMarkdownBlock(props: {
635 node={child}683 node={child}
636 textStyle={props.textStyle}684 textStyle={props.textStyle}
637 highlightCode={props.highlightCode}685 highlightCode={props.highlightCode}
686 onLinkPress={props.onLinkPress}
638 depth={depth}687 depth={depth}
639 compact688 compact
640 />689 />
...@@ -642,6 +691,12 @@ export function NativeMarkdownBlock(props: {...@@ -642,6 +691,12 @@ export function NativeMarkdownBlock(props: {
642 </View>691 </View>
643 );692 );
644 default:693 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 );
646 }701 }
647}702}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+38-85
...@@ -1,61 +1,15 @@...@@ -1,61 +1,15 @@
1import { useEffect, useMemo, useState } from "react";
2import { Asset } from "expo-asset";
3import { Image, Linking, type TextStyle, useColorScheme } from "react-native";1import { Image, Linking, type TextStyle, useColorScheme } from "react-native";
42
5import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";3import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
6import { markdownFileIconSource } from "./markdownFileIcons";4import { markdownFileIconSource } from "./markdownFileIcons";
7import type { MarkdownFileIcon } from "./markdownLinks";
8import type { NativeMarkdownTextRun } from "./nativeMarkdownText";5import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
9import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types";6import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types";
107
11const EXTERNAL_LINK_PREFIX = "â—‰ ";8const EXTERNAL_LINK_PREFIX = "â—‰ ";
12const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0";9const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
13const CHIP_SUFFIX = "\u00A0";
14const SKILL_ICON_PLACEHOLDER = "\uFFFC";10const SKILL_ICON_PLACEHOLDER = "\uFFFC";
15const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;11const 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
59function runKeySignature(run: NativeMarkdownTextRun): string {13function runKeySignature(run: NativeMarkdownTextRun): string {
60 return [14 return [
61 run.text,15 run.text,
...@@ -81,13 +35,16 @@ function runKeySignature(run: NativeMarkdownTextRun): string {...@@ -81,13 +35,16 @@ function runKeySignature(run: NativeMarkdownTextRun): string {
81function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {35function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
82 const isFile = run.fileIcon != null;36 const isFile = run.fileIcon != null;
83 const isSkill = run.skillName != null;37 const isSkill = run.skillName != null;
84 const isChip = isFile || isSkill;
85 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));38 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
86 const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15;39 const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15;
87 const isHeading = run.role === "heading";40 const isHeading = run.role === "heading";
88 const isCodeBlock = run.role === "code-block" || run.role === "code-language";41 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
89 const hasParagraphStyle = run.headIndent !== undefined;42 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
92 return {49 return {
93 color: isFile50 color: isFile
...@@ -106,20 +63,23 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle...@@ -106,20 +63,23 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
106 ? textStyle.mutedColor63 ? textStyle.mutedColor
107 : run.role === "list-marker"64 : run.role === "list-marker"
108 ? textStyle.mutedColor65 ? textStyle.mutedColor
109 : run.code || isFile66 : isCodeBlock
110 ? textStyle.codeColor67 ? textStyle.codeColor
111 : run.bold68 : run.code
112 ? textStyle.strongColor69 ? textStyle.inlineCodeColor
113 : textStyle.color,70 : run.bold
114 fontFamily: isChip71 ? textStyle.strongColor
115 ? "DMSans_500Medium"72 : textStyle.color,
116 : run.code || isCodeBlock73 fontFamily:
117 ? "ui-monospace"74 isFile || isSkill
118 : isHeading75 ? textStyle.boldFontFamily
119 ? textStyle.headingFontFamily76 : run.code || isCodeBlock
120 : run.bold77 ? "ui-monospace"
121 ? textStyle.boldFontFamily78 : isHeading
122 : textStyle.fontFamily,79 ? textStyle.headingFontFamily
80 : run.bold
81 ? textStyle.boldFontFamily
82 : textStyle.fontFamily,
123 fontSize:83 fontSize:
124 run.role === "spacer"84 run.role === "spacer"
125 ? (run.spacing ?? 10)85 ? (run.spacing ?? 10)
...@@ -129,7 +89,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle...@@ -129,7 +89,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
129 ? headingFontSize89 ? headingFontSize
130 : run.role === "code-language"90 : run.role === "code-language"
131 ? 1191 ? 11
132 : run.code || isChip || isCodeBlock92 : run.code || isCodeBlock
133 ? Math.max(12, textStyle.fontSize - 2)93 ? Math.max(12, textStyle.fontSize - 2)
134 : textStyle.fontSize,94 : textStyle.fontSize,
135 lineHeight:95 lineHeight:
...@@ -143,17 +103,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle...@@ -143,17 +103,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
143 ? 18103 ? 18
144 : textStyle.lineHeight,104 : textStyle.lineHeight,
145 fontStyle: run.italic ? "italic" : "normal",105 fontStyle: run.italic ? "italic" : "normal",
146 fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400",106 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
147 textDecorationLine,107 textDecorationLine,
148 backgroundColor: isCodeBlock108 backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined,
149 ? textStyle.codeBlockBackgroundColor
150 : isSkill
151 ? textStyle.skillBackgroundColor
152 : run.code
153 ? textStyle.codeBackgroundColor
154 : isFile
155 ? textStyle.fileBackgroundColor
156 : undefined,
157 ...(hasParagraphStyle109 ...(hasParagraphStyle
158 ? {110 ? {
159 shadowColor: "transparent",111 shadowColor: "transparent",
...@@ -170,9 +122,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle...@@ -170,9 +122,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
170export function NativeMarkdownSelectableText(props: {122export function NativeMarkdownSelectableText(props: {
171 readonly runs: ReadonlyArray<NativeMarkdownTextRun>;123 readonly runs: ReadonlyArray<NativeMarkdownTextRun>;
172 readonly textStyle: NativeMarkdownTextStyle;124 readonly textStyle: NativeMarkdownTextStyle;
125 readonly onLinkPress?: (href: string) => void;
173}) {126}) {
174 const colorScheme = useColorScheme();127 const colorScheme = useColorScheme();
175 const fileIconUris = useFileIconUris(props.runs);
176 const occurrences = new Map<string, number>();128 const occurrences = new Map<string, number>();
177 const prefixedExternalLinks = new Set<string>();129 const prefixedExternalLinks = new Set<string>();
178 const keyedRuns = props.runs.map((run) => {130 const keyedRuns = props.runs.map((run) => {
...@@ -182,9 +134,9 @@ export function NativeMarkdownSelectableText(props: {...@@ -182,9 +134,9 @@ export function NativeMarkdownSelectableText(props: {
182134
183 let text = run.text;135 let text = run.text;
184 if (run.fileIcon) {136 if (run.fileIcon) {
185 text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`;137 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
186 } else if (run.skillName && run.skillLabel) {138 } 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}`;
188 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {140 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
189 prefixedExternalLinks.add(run.href);141 prefixedExternalLinks.add(run.href);
190 text = `${EXTERNAL_LINK_PREFIX}${text}`;142 text = `${EXTERNAL_LINK_PREFIX}${text}`;
...@@ -200,12 +152,11 @@ export function NativeMarkdownSelectableText(props: {...@@ -200,12 +152,11 @@ export function NativeMarkdownSelectableText(props: {
200 props.textStyle.strongColor,152 props.textStyle.strongColor,
201 props.textStyle.mutedColor,153 props.textStyle.mutedColor,
202 props.textStyle.linkColor,154 props.textStyle.linkColor,
155 props.textStyle.inlineCodeColor,
203 props.textStyle.codeColor,156 props.textStyle.codeColor,
204 props.textStyle.codeBackgroundColor,157 props.textStyle.codeBackgroundColor,
205 props.textStyle.codeBlockBackgroundColor,158 props.textStyle.codeBlockBackgroundColor,
206 props.textStyle.fileBackgroundColor,
207 props.textStyle.fileTextColor,159 props.textStyle.fileTextColor,
208 props.textStyle.skillBackgroundColor,
209 props.textStyle.skillTextColor,160 props.textStyle.skillTextColor,
210 props.textStyle.quoteMarkerColor,161 props.textStyle.quoteMarkerColor,
211 props.textStyle.dividerColor,162 props.textStyle.dividerColor,
...@@ -217,7 +168,8 @@ export function NativeMarkdownSelectableText(props: {...@@ -217,7 +168,8 @@ export function NativeMarkdownSelectableText(props: {
217 uiTextView168 uiTextView
218 selectable169 selectable
219 style={{170 style={{
220 width: "100%",171 flexShrink: 1,
172 minWidth: 0,
221 color: props.textStyle.color,173 color: props.textStyle.color,
222 fontFamily: props.textStyle.fontFamily,174 fontFamily: props.textStyle.fontFamily,
223 fontSize: props.textStyle.fontSize,175 fontSize: props.textStyle.fontSize,
...@@ -231,19 +183,20 @@ export function NativeMarkdownSelectableText(props: {...@@ -231,19 +183,20 @@ export function NativeMarkdownSelectableText(props: {
231 key={key}183 key={key}
232 nativeID={184 nativeID={
233 run.fileIcon185 run.fileIcon
234 ? `t3-chip-file:${186 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
235 fileIconUris.get(run.fileIcon) ??
236 Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri
237 }`
238 : run.skillName187 : run.skillName
239 ? "t3-chip-skill:sf:cube"188 ? "t3-skill:sf:cube"
240 : undefined189 : undefined
241 }190 }
242 style={runStyle(run, props.textStyle)}191 style={runStyle(run, props.textStyle)}
243 onPress={192 onPress={
244 href193 href
245 ? () => {194 ? () => {
246 void Linking.openURL(href);195 if (props.onLinkPress) {
196 props.onLinkPress(href);
197 } else {
198 void Linking.openURL(href);
199 }
247 }200 }
248 : undefined201 : undefined
249 }202 }
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+19-4
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 nativeMarkdownChunkSpacing,6 nativeMarkdownChunkSpacing,
7 nativeMarkdownDocumentChunks,7 nativeMarkdownDocumentChunks,
8 nativeMarkdownDocumentRuns,8 nativeMarkdownDocumentRuns,
9 nativeMarkdownWithPreservedSoftBreaks,
9} from "./nativeMarkdownText";10} from "./nativeMarkdownText";
10import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";11import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
11import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";12import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
...@@ -33,15 +34,20 @@ export function SelectableMarkdownText({...@@ -33,15 +34,20 @@ export function SelectableMarkdownText({
33 skills = EMPTY_SKILLS,34 skills = EMPTY_SKILLS,
34 textStyle,35 textStyle,
35 highlightCode,36 highlightCode,
37 preserveSoftBreaks = false,
38 onLinkPress,
36 marginTop = 0,39 marginTop = 0,
37 marginBottom = 0,40 marginBottom = 0,
38}: SelectableMarkdownTextProps) {41}: SelectableMarkdownTextProps) {
39 const chunks = useMemo(() => {42 const chunks = useMemo(() => {
40 const document = parseMarkdownWithOptions(markdown, {43 const parsedDocument = parseMarkdownWithOptions(markdown, {
41 gfm: true,44 gfm: true,
42 html: true,45 html: true,
43 math: false,46 math: false,
44 });47 });
48 const document = preserveSoftBreaks
49 ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
50 : parsedDocument;
45 return nativeMarkdownDocumentChunks(document).map((chunk) =>51 return nativeMarkdownDocumentChunks(document).map((chunk) =>
46 chunk.kind === "selectable"52 chunk.kind === "selectable"
47 ? {53 ? {
...@@ -50,10 +56,14 @@ export function SelectableMarkdownText({...@@ -50,10 +56,14 @@ export function SelectableMarkdownText({
50 }56 }
51 : chunk,57 : chunk,
52 );58 );
53 }, [markdown, skills]);59 }, [markdown, preserveSoftBreaks, skills]);
5460
55 return (61 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 }}>
57 {chunks.map((chunk, index) => {67 {chunks.map((chunk, index) => {
58 const content =68 const content =
59 chunk.kind === "rich" ? (69 chunk.kind === "rich" ? (
...@@ -61,9 +71,14 @@ export function SelectableMarkdownText({...@@ -61,9 +71,14 @@ export function SelectableMarkdownText({
61 node={chunk.node}71 node={chunk.node}
62 textStyle={textStyle}72 textStyle={textStyle}
63 highlightCode={highlightCode}73 highlightCode={highlightCode}
74 onLinkPress={onLinkPress}
64 />75 />
65 ) : (76 ) : (
66 <NativeMarkdownSelectableText runs={chunk.runs} textStyle={textStyle} />77 <NativeMarkdownSelectableText
78 runs={chunk.runs}
79 textStyle={textStyle}
80 onLinkPress={onLinkPress}
81 />
67 );82 );
6883
69 return (84 return (
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+3-2
...@@ -3,12 +3,11 @@ export interface NativeMarkdownTextStyle {...@@ -3,12 +3,11 @@ export interface NativeMarkdownTextStyle {
3 readonly strongColor: string;3 readonly strongColor: string;
4 readonly mutedColor: string;4 readonly mutedColor: string;
5 readonly linkColor: string;5 readonly linkColor: string;
6 readonly inlineCodeColor: string;
6 readonly codeColor: string;7 readonly codeColor: string;
7 readonly codeBackgroundColor: string;8 readonly codeBackgroundColor: string;
8 readonly codeBlockBackgroundColor: string;9 readonly codeBlockBackgroundColor: string;
9 readonly fileBackgroundColor: string;
10 readonly fileTextColor: string;10 readonly fileTextColor: string;
11 readonly skillBackgroundColor: string;
12 readonly skillTextColor: string;11 readonly skillTextColor: string;
13 readonly quoteMarkerColor: string;12 readonly quoteMarkerColor: string;
14 readonly dividerColor: string;13 readonly dividerColor: string;
...@@ -41,6 +40,8 @@ export interface SelectableMarkdownTextProps {...@@ -41,6 +40,8 @@ export interface SelectableMarkdownTextProps {
41 readonly textStyle: NativeMarkdownTextStyle;40 readonly textStyle: NativeMarkdownTextStyle;
42 readonly highlightCode: MarkdownCodeHighlighter;41 readonly highlightCode: MarkdownCodeHighlighter;
43 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;42 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
43 readonly preserveSoftBreaks?: boolean;
44 readonly onLinkPress?: (href: string) => void;
44 readonly marginTop?: number;45 readonly marginTop?: number;
45 readonly marginBottom?: number;46 readonly marginBottom?: number;
46}47}
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+61-11
...@@ -27,8 +27,12 @@ export type MarkdownLinkPresentation =...@@ -27,8 +27,12 @@ export type MarkdownLinkPresentation =
27 }27 }
28 | {28 | {
29 readonly kind: "file";29 readonly kind: "file";
30 readonly href: string;
30 readonly icon: MarkdownFileIcon;31 readonly icon: MarkdownFileIcon;
31 readonly label: string;32 readonly label: string;
33 readonly path: string;
34 readonly line?: number;
35 readonly column?: number;
32 }36 }
33 | {37 | {
34 readonly kind: "link";38 readonly kind: "link";
...@@ -247,7 +251,7 @@ function normalizeDestination(value: string): string {...@@ -247,7 +251,7 @@ function normalizeDestination(value: string): string {
247 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;251 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
248}252}
249253
250function fileUrlPath(href: string): string | null {254function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null {
251 try {255 try {
252 const parsed = new URL(href);256 const parsed = new URL(href);
253 if (parsed.protocol.toLowerCase() !== "file:") {257 if (parsed.protocol.toLowerCase() !== "file:") {
...@@ -256,15 +260,44 @@ function fileUrlPath(href: string): string | null {...@@ -256,15 +260,44 @@ function fileUrlPath(href: string): string | null {
256 const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname)260 const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname)
257 ? parsed.pathname.slice(1)261 ? parsed.pathname.slice(1)
258 : parsed.pathname;262 : parsed.pathname;
259 const lineMatch = parsed.hash.match(/^#L(\d+)(?:C(\d+))?$/i);263 return { path, hash: parsed.hash };
260 return `${safeDecode(path)}${
261 lineMatch?.[1] ? `:${lineMatch[1]}${lineMatch[2] ? `:${lineMatch[2]}` : ""}` : ""
262 }`;
263 } catch {264 } catch {
264 return null;265 return null;
265 }266 }
266}267}
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
268function looksLikePosixFilesystemPath(path: string): boolean {301function looksLikePosixFilesystemPath(path: string): boolean {
269 if (!path.startsWith("/")) {302 if (!path.startsWith("/")) {
270 return false;303 return false;
...@@ -331,14 +364,31 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese...@@ -331,14 +364,31 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese
331 // Relative paths and non-URL link destinations are handled below.364 // Relative paths and non-URL link destinations are handled below.
332 }365 }
333366
334 const fileTarget = normalized.toLowerCase().startsWith("file:")367 const source = normalized.toLowerCase().startsWith("file:")
335 ? fileUrlPath(normalized)368 ? fileUrlTarget(normalized)
336 : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized);369 : stripSearchAndHash(normalized);
337 if (fileTarget && looksLikeFilePath(fileTarget)) {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)) {
338 return {384 return {
339 kind: "file",385 kind: "file",
340 icon: resolveMarkdownFileIcon(fileTarget),386 href: normalized,
341 label: fileLabel(fileTarget),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 } : {}),
342 };392 };
343 }393 }
344394
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+10
...@@ -293,6 +293,7 @@ function appendNode(...@@ -293,6 +293,7 @@ function appendNode(
293 if (presentation.kind === "file") {293 if (presentation.kind === "file") {
294 return appendRun(runs, presentation.label, {294 return appendRun(runs, presentation.label, {
295 ...context,295 ...context,
296 href: presentation.href,
296 fileIcon: presentation.icon,297 fileIcon: presentation.icon,
297 });298 });
298 }299 }
...@@ -319,6 +320,15 @@ export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray<Native...@@ -319,6 +320,15 @@ export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray<Native
319 return appendChildren([], node, EMPTY_CONTEXT);320 return appendChildren([], node, EMPTY_CONTEXT);
320}321}
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
322function appendBlockTerminator(332function appendBlockTerminator(
323 runs: NativeMarkdownTextRun[],333 runs: NativeMarkdownTextRun[],
324 context: RunContext,334 context: RunContext,
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+4
...@@ -57,6 +57,10 @@ public class T3ReviewDiffModule: Module {...@@ -57,6 +57,10 @@ public class T3ReviewDiffModule: Module {
57 view.setContentWidth(CGFloat(contentWidth))57 view.setContentWidth(CGFloat(contentWidth))
58 }58 }
5959
60 Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in
61 view.setInitialRowIndex(initialRowIndex)
62 }
63
60 Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")64 Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")
61 }65 }
62 }66 }
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+55-1
...@@ -205,7 +205,7 @@ private struct ReviewDiffNativeStyle {...@@ -205,7 +205,7 @@ private struct ReviewDiffNativeStyle {
205 ReviewDiffNativeStyle(205 ReviewDiffNativeStyle(
206 rowHeight: metric(payload?.rowHeight, fallback: 24),206 rowHeight: metric(payload?.rowHeight, fallback: 24),
207 contentWidth: metric(payload?.contentWidth, fallback: 2800),207 contentWidth: metric(payload?.contentWidth, fallback: 2800),
208 changeBarWidth: metric(payload?.changeBarWidth, fallback: 4),208 changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4),
209 gutterWidth: metric(payload?.gutterWidth, fallback: 50),209 gutterWidth: metric(payload?.gutterWidth, fallback: 50),
210 codePadding: metric(payload?.codePadding, fallback: 8),210 codePadding: metric(payload?.codePadding, fallback: 8),
211 textVerticalInset: metric(payload?.textVerticalInset, fallback: 3),211 textVerticalInset: metric(payload?.textVerticalInset, fallback: 3),
...@@ -243,6 +243,13 @@ private struct ReviewDiffNativeStyle {...@@ -243,6 +243,13 @@ private struct ReviewDiffNativeStyle {
243 return CGFloat(value)243 return CGFloat(value)
244 }244 }
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
246 private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight {253 private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight {
247 switch value?.lowercased() {254 switch value?.lowercased() {
248 case "ultralight", "ultra-light":255 case "ultralight", "ultra-light":
...@@ -316,6 +323,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -316,6 +323,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
316 private var lastMetricsDebugKey = ""323 private var lastMetricsDebugKey = ""
317 private var lastVisibleRangeDebugKey = ""324 private var lastVisibleRangeDebugKey = ""
318 private var tokensResetKey = ""325 private var tokensResetKey = ""
326 private var initialRowIndex: Int?
327 private var hasAppliedInitialRowIndex = false
319328
320 let onDebug = EventDispatcher()329 let onDebug = EventDispatcher()
321 let onToggleFile = EventDispatcher()330 let onToggleFile = EventDispatcher()
...@@ -394,6 +403,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -394,6 +403,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
394 do {403 do {
395 rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)404 rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
396 contentView.rows = rows405 contentView.rows = rows
406 hasAppliedInitialRowIndex = false
397 emitDebug("rows-decoded", [407 emitDebug("rows-decoded", [
398 "rows": rows.count,408 "rows": rows.count,
399 "firstKind": rows.first?.kind ?? "none",409 "firstKind": rows.first?.kind ?? "none",
...@@ -402,6 +412,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -402,6 +412,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
402 } catch {412 } catch {
403 rows = []413 rows = []
404 contentView.rows = []414 contentView.rows = []
415 hasAppliedInitialRowIndex = false
405 updateContentMetrics()416 updateContentMetrics()
406 emitDebug("rows-decode-failed", [417 emitDebug("rows-decode-failed", [
407 "error": error.localizedDescription,418 "error": error.localizedDescription,
...@@ -561,6 +572,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -561,6 +572,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
561 contentView.verticalOffset = scrollView.contentOffset.y572 contentView.verticalOffset = scrollView.contentOffset.y
562 contentView.invalidateVisibleViewport()573 contentView.invalidateVisibleViewport()
563 contentView.setNeedsDisplay()574 contentView.setNeedsDisplay()
575 applyInitialRowIndexIfNeeded()
564576
565 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"577 let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
566 if debugKey != lastMetricsDebugKey {578 if debugKey != lastMetricsDebugKey {
...@@ -645,6 +657,19 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -645,6 +657,19 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
645 applyStyle()657 applyStyle()
646 }658 }
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
648 private func applyStyle() {673 private func applyStyle() {
649 contentView.style = ReviewDiffNativeStyle674 contentView.style = ReviewDiffNativeStyle
650 .resolve(stylePayload)675 .resolve(stylePayload)
...@@ -662,6 +687,22 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -662,6 +687,22 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
662 contentView.verticalOffset = scrollView.contentOffset.y687 contentView.verticalOffset = scrollView.contentOffset.y
663 contentView.invalidateVisibleViewport()688 contentView.invalidateVisibleViewport()
664 }689 }
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 }
665}706}
666707
667private enum ReviewDiffHorizontalPanKind {708private enum ReviewDiffHorizontalPanKind {
...@@ -820,6 +861,19 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -820,6 +861,19 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
820 return style.rowHeight861 return style.rowHeight
821 }862 }
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
823 private func rebuildRowLayout() {877 private func rebuildRowLayout() {
824 var nextOffsets: [CGFloat] = []878 var nextOffsets: [CGFloat] = []
825 var nextFileHeaderRowIndices: [Int] = []879 var nextFileHeaderRowIndices: [Int] = []
apps/mobile/package.json+1
...@@ -101,6 +101,7 @@...@@ -101,6 +101,7 @@
101 "react-native-screens": "4.25.2",101 "react-native-screens": "4.25.2",
102 "react-native-shiki-engine": "^0.3.12",102 "react-native-shiki-engine": "^0.3.12",
103 "react-native-svg": "15.15.4",103 "react-native-svg": "15.15.4",
104 "react-native-webview": "^13.16.1",
104 "react-native-worklets": "0.8.3",105 "react-native-worklets": "0.8.3",
105 "shiki": "4.2.0",106 "shiki": "4.2.0",
106 "tailwind-merge": "^3.5.0",107 "tailwind-merge": "^3.5.0",
apps/mobile/src/app/_layout.tsx+3-2
...@@ -30,10 +30,11 @@ import { useThemeColor } from "../lib/useThemeColor";...@@ -30,10 +30,11 @@ import { useThemeColor } from "../lib/useThemeColor";
3030
31function AppNavigator() {31function AppNavigator() {
32 const pathname = usePathname();32 const pathname = usePathname();
33 const clerkRouteIsActive = pathname === "/settings/auth";33 const expandedSettingsRouteIsActive =
34 pathname === "/settings/archive" || pathname === "/settings/auth";
3435
35 return (36 return (
36 <ClerkSettingsSheetDetentProvider initiallyExpanded={clerkRouteIsActive}>37 <ClerkSettingsSheetDetentProvider initiallyExpanded={expandedSettingsRouteIsActive}>
37 <AppNavigatorContent />38 <AppNavigatorContent />
38 </ClerkSettingsSheetDetentProvider>39 </ClerkSettingsSheetDetentProvider>
39 );40 );
apps/mobile/src/app/index.tsx+88-89
...@@ -1,13 +1,33 @@...@@ -1,13 +1,33 @@
1import { Stack, useRouter } from "expo-router";1import type {
2import { useState } from "react";2 EnvironmentId,
3import { Text as RNText, View } from "react-native";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
5import { useProjects, useThreadShells } from "../state/entities";16import { useProjects, useThreadShells } from "../state/entities";
6import { useWorkspaceState } from "../state/workspace";17import { useWorkspaceState } from "../state/workspace";
7import { buildThreadRoutePath } from "../lib/routes";18import { buildThreadRoutePath } from "../lib/routes";
8import { useSavedRemoteConnections } from "../state/use-remote-environment-registry";19import { useSavedRemoteConnections } from "../state/use-remote-environment-registry";
9import { HomeScreen } from "../features/home/HomeScreen";20import { 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
12/* ─── Route screen ───────────────────────────────────────────────────── */32/* ─── Route screen ───────────────────────────────────────────────────── */
1333
...@@ -18,103 +38,82 @@ export default function HomeRouteScreen() {...@@ -18,103 +38,82 @@ export default function HomeRouteScreen() {
18 const { savedConnectionsById } = useSavedRemoteConnections();38 const { savedConnectionsById } = useSavedRemoteConnections();
19 const router = useRouter();39 const router = useRouter();
20 const [searchQuery, setSearchQuery] = useState("");40 const [searchQuery, setSearchQuery] = useState("");
2141 const [listOptions, setListOptions] = useState<HomeListOptions>({
22 const iconColor = useThemeColor("--color-icon");42 selectedEnvironmentId: null,
23 const mutedColor = useThemeColor("--color-foreground-muted");43 projectSortOrder:
24 const subtleColor = useThemeColor("--color-subtle");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
26 return (83 return (
27 <>84 <>
28 <Stack.Screen85 <HomeHeader
29 options={{86 environments={environments}
30 headerShown: true,87 selectedEnvironmentId={selectedEnvironmentId}
31 headerTransparent: true,88 projectSortOrder={listOptions.projectSortOrder}
32 headerStyle: { backgroundColor: "transparent" },89 threadSortOrder={listOptions.threadSortOrder}
33 headerShadowVisible: false,90 projectGroupingMode={listOptions.projectGroupingMode}
34 headerTintColor: iconColor,91 onEnvironmentChange={setSelectedEnvironmentId}
35 headerTitle: "",92 onOpenSettings={() => router.push("/settings")}
36 headerSearchBarOptions: {93 onProjectGroupingModeChange={setProjectGroupingMode}
37 placeholder: "Search threads",94 onProjectSortOrderChange={setProjectSortOrder}
38 hideNavigationBar: false,95 onSearchQueryChange={setSearchQuery}
39 onChangeText: (event) => {96 onStartNewTask={() => router.push("/new")}
40 setSearchQuery(event.nativeEvent.text);97 onThreadSortOrderChange={setThreadSortOrder}
41 },
42 onCancelButtonPress: () => {
43 setSearchQuery("");
44 },
45 allowToolbarIntegration: true,
46 },
47 }}
48 />98 />
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
107 <HomeScreen100 <HomeScreen
108 projects={projects}
109 threads={threads}
110 catalogState={catalogState}101 catalogState={catalogState}
111 savedConnectionsById={savedConnectionsById}
112 searchQuery={searchQuery}
113 onAddConnection={() => router.push("/connections/new")}102 onAddConnection={() => router.push("/connections/new")}
103 onArchiveThread={archiveThread}
104 onDeleteThread={confirmDeleteThread}
114 onOpenEnvironments={() => router.push("/settings/environments")}105 onOpenEnvironments={() => router.push("/settings/environments")}
115 onSelectThread={(thread) => {106 onSelectThread={(thread) => {
116 router.push(buildThreadRoutePath(thread));107 router.push(buildThreadRoutePath(thread));
117 }}108 }}
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}
118 />117 />
119 </>118 </>
120 );119 );
apps/mobile/src/app/settings/_layout.tsx+7-2
...@@ -14,7 +14,7 @@ export default function SettingsLayout() {...@@ -14,7 +14,7 @@ export default function SettingsLayout() {
14 const contentStyle = useResolveClassNames("bg-sheet");14 const contentStyle = useResolveClassNames("bg-sheet");
15 const sheetBg = useThemeColor("--color-sheet");15 const sheetBg = useThemeColor("--color-sheet");
16 const headerTint = useThemeColor("--color-foreground");16 const headerTint = useThemeColor("--color-foreground");
17 const handleClerkRouteTransitionEnd = useCallback(17 const handleExpandedRouteTransitionEnd = useCallback(
18 (event: { data: { closing: boolean } }) => {18 (event: { data: { closing: boolean } }) => {
19 if (event.data.closing) {19 if (event.data.closing) {
20 collapse();20 collapse();
...@@ -47,9 +47,14 @@ export default function SettingsLayout() {...@@ -47,9 +47,14 @@ export default function SettingsLayout() {
47 name="waitlist"47 name="waitlist"
48 options={{ animation: "slide_from_right", title: "Join the waitlist" }}48 options={{ animation: "slide_from_right", title: "Join the waitlist" }}
49 />49 />
50 <Stack.Screen
51 name="archive"
52 listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
53 options={{ animation: "slide_from_right", title: "Archived Threads" }}
54 />
50 <Stack.Screen55 <Stack.Screen
51 name="auth"56 name="auth"
52 listeners={{ transitionEnd: handleClerkRouteTransitionEnd }}57 listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }}
53 options={{ animation: "slide_from_right", title: "Sign in" }}58 options={{ animation: "slide_from_right", title: "Sign in" }}
54 />59 />
55 </Stack>60 </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() {...@@ -65,6 +65,8 @@ function LocalSettingsRouteScreen() {
65 />65 />
66 </SettingsSection>66 </SettingsSection>
6767
68 <ArchivedThreadsSettingsSection />
69
68 <AppSettingsSection />70 <AppSettingsSection />
69 </ScrollView>71 </ScrollView>
70 </View>72 </View>
...@@ -374,6 +376,8 @@ function ConfiguredSettingsRouteScreen() {...@@ -374,6 +376,8 @@ function ConfiguredSettingsRouteScreen() {
374 />376 />
375 </SettingsSection>377 </SettingsSection>
376378
379 <ArchivedThreadsSettingsSection />
380
377 <AppSettingsSection />381 <AppSettingsSection />
378 </ScrollView>382 </ScrollView>
379 </View>383 </View>
...@@ -416,12 +420,20 @@ function AppSettingsSection() {...@@ -416,12 +420,20 @@ function AppSettingsSection() {
416 );420 );
417}421}
418422
423function ArchivedThreadsSettingsSection() {
424 return (
425 <SettingsSection title="Threads">
426 <SettingsRow icon="archivebox" label="Archived Threads" href="/settings/archive" />
427 </SettingsSection>
428 );
429}
430
419function SettingsRow(props: {431function SettingsRow(props: {
420 readonly disabled?: boolean;432 readonly disabled?: boolean;
421 readonly icon: SymbolName;433 readonly icon: SymbolName;
422 readonly label: string;434 readonly label: string;
423 readonly value?: string;435 readonly value?: string;
424 readonly href?: "/settings/environments";436 readonly href?: "/settings/archive" | "/settings/environments";
425 readonly onPress?: () => void;437 readonly onPress?: () => void;
426}) {438}) {
427 const icon = useThemeColor("--color-icon");439 const icon = useThemeColor("--color-icon");
...@@ -459,7 +471,9 @@ function SettingsRow(props: {...@@ -459,7 +471,9 @@ function SettingsRow(props: {
459 if (props.href) {471 if (props.href) {
460 return (472 return (
461 <Link href={props.href} asChild>473 <Link href={props.href} asChild>
462 <Pressable>{content}</Pressable>474 <Pressable accessibilityLabel={props.label} accessibilityRole="button">
475 {content}
476 </Pressable>
463 </Link>477 </Link>
464 );478 );
465 }479 }
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx+26
...@@ -55,6 +55,32 @@ export default function ThreadLayout() {...@@ -55,6 +55,32 @@ export default function ThreadLayout() {
55 headerStyle: headerBg,55 headerStyle: headerBg,
56 }}56 }}
57 />57 />
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 />
58 <Stack.Screen84 <Stack.Screen
59 name="review-comment"85 name="review-comment"
60 options={{86 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 {...@@ -110,6 +110,7 @@ export interface NativeReviewDiffViewProps extends ViewProps {
110 readonly styleJson?: string;110 readonly styleJson?: string;
111 readonly rowHeight: number;111 readonly rowHeight: number;
112 readonly contentWidth: number;112 readonly contentWidth: number;
113 readonly initialRowIndex?: number;
113 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;114 readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
114 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;115 readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
115 readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;116 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 {...@@ -2,12 +2,26 @@ import {
2 type EnvironmentProject,2 type EnvironmentProject,
3 type EnvironmentThreadShell,3 type EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import type {
6 EnvironmentId,
7 SidebarProjectGroupingMode,
8 SidebarThreadSortOrder,
9} from "@t3tools/contracts";
10import * as Haptics from "expo-haptics";
5import { SymbolView } from "expo-symbols";11import { SymbolView } from "expo-symbols";
6import { useCallback, useMemo, useState } from "react";12import { useCallback, useMemo, useRef, useState } from "react";
7import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";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";
8import { useSafeAreaInsets } from "react-native-safe-area-context";24import { useSafeAreaInsets } from "react-native-safe-area-context";
9import * as Arr from "effect/Array";
10import * as Order from "effect/Order";
11import { useThemeColor } from "../../lib/useThemeColor";25import { useThemeColor } from "../../lib/useThemeColor";
1226
13import { AppText as Text } from "../../components/AppText";27import { AppText as Text } from "../../components/AppText";
...@@ -15,9 +29,14 @@ import { EmptyState } from "../../components/EmptyState";...@@ -15,9 +29,14 @@ import { EmptyState } from "../../components/EmptyState";
15import { ProjectFavicon } from "../../components/ProjectFavicon";29import { ProjectFavicon } from "../../components/ProjectFavicon";
16import type { WorkspaceState } from "../../state/workspaceModel";30import type { WorkspaceState } from "../../state/workspaceModel";
17import type { SavedRemoteConnection } from "../../lib/connection";31import type { SavedRemoteConnection } from "../../lib/connection";
18import { scopedProjectKey } from "../../lib/scopedEntities";
19import { relativeTime } from "../../lib/time";32import { relativeTime } from "../../lib/time";
20import { threadStatusTone } from "../threads/threadPresentation";33import { 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
22/* ─── Types ──────────────────────────────────────────────────────────── */41/* ─── Types ──────────────────────────────────────────────────────────── */
2342
...@@ -27,26 +46,17 @@ interface HomeScreenProps {...@@ -27,26 +46,17 @@ interface HomeScreenProps {
27 readonly catalogState: WorkspaceState;46 readonly catalogState: WorkspaceState;
28 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;47 readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>;
29 readonly searchQuery: string;48 readonly searchQuery: string;
49 readonly selectedEnvironmentId: EnvironmentId | null;
50 readonly projectSortOrder: HomeProjectSortOrder;
51 readonly threadSortOrder: SidebarThreadSortOrder;
52 readonly projectGroupingMode: SidebarProjectGroupingMode;
30 readonly onAddConnection: () => void;53 readonly onAddConnection: () => void;
31 readonly onOpenEnvironments: () => void;54 readonly onOpenEnvironments: () => void;
32 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;55 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
56 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
57 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
33}58}
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
50/* ─── Status indicator colors ────────────────────────────────────────── */60/* ─── Status indicator colors ────────────────────────────────────────── */
5161
52function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } {62function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } {
...@@ -65,6 +75,33 @@ function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string...@@ -65,6 +75,33 @@ function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string
65}75}
6676
67const COLLAPSED_THREAD_LIMIT = 6;77const 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
69function deriveEmptyState(props: {106function deriveEmptyState(props: {
70 readonly catalogState: WorkspaceState;107 readonly catalogState: WorkspaceState;
...@@ -133,6 +170,7 @@ function deriveEmptyState(props: {...@@ -133,6 +170,7 @@ function deriveEmptyState(props: {
133170
134function ProjectGroupLabel(props: {171function ProjectGroupLabel(props: {
135 readonly project: EnvironmentProject;172 readonly project: EnvironmentProject;
173 readonly title: string;
136 readonly totalThreadCount: number;174 readonly totalThreadCount: number;
137 readonly isExpanded: boolean;175 readonly isExpanded: boolean;
138 readonly onToggleExpand: () => void;176 readonly onToggleExpand: () => void;
...@@ -152,7 +190,7 @@ function ProjectGroupLabel(props: {...@@ -152,7 +190,7 @@ function ProjectGroupLabel(props: {
152 style={{ letterSpacing: 0.5 }}190 style={{ letterSpacing: 0.5 }}
153 numberOfLines={1}191 numberOfLines={1}
154 >192 >
155 {props.project.title}193 {props.title}
156 </Text>194 </Text>
157195
158 {hiddenCount > 0 ? (196 {hiddenCount > 0 ? (
...@@ -175,95 +213,172 @@ function ThreadRow(props: {...@@ -175,95 +213,172 @@ function ThreadRow(props: {
175 readonly thread: EnvironmentThreadShell;213 readonly thread: EnvironmentThreadShell;
176 readonly environmentLabel: string | null;214 readonly environmentLabel: string | null;
177 readonly onPress: () => void;215 readonly onPress: () => void;
216 readonly onArchive: () => void;
217 readonly onDelete: () => void;
218 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
219 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
178 readonly isLast: boolean;220 readonly isLast: boolean;
179}) {221}) {
222 const swipeableRef = useRef<SwipeableMethods | null>(null);
223 const fullSwipeArmedRef = useRef(false);
224 const { width: windowWidth } = useWindowDimensions();
180 const separatorColor = useThemeColor("--color-separator");225 const separatorColor = useThemeColor("--color-separator");
181 const iconSubtleColor = useThemeColor("--color-icon-subtle");226 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);
182 const { bg, fg } = statusColors(props.thread);229 const { bg, fg } = statusColors(props.thread);
183 const tone = threadStatusTone(props.thread);230 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 );
185 const branch = props.thread.branch;234 const branch = props.thread.branch;
186 const subtitleParts = [props.environmentLabel, branch].filter((part): part is string =>235 const subtitleParts = [props.environmentLabel, branch].filter((part): part is string =>
187 Boolean(part),236 Boolean(part),
188 );237 );
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
190 return (245 return (
191 <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}>246 <ReanimatedSwipeable
192 <View247 ref={swipeableRef}
193 style={{248 animationOptions={THREAD_SWIPE_SPRING}
194 flexDirection: "row",249 childrenContainerStyle={{ backgroundColor: cardColor }}
195 paddingLeft: 16,250 containerStyle={{ backgroundColor: cardColor }}
196 paddingRight: 16,251 dragOffsetFromRightEdge={8}
197 paddingVertical: 10,252 enableTrackpadTwoFingerGesture
198 gap: 12,253 friction={1}
199 borderBottomWidth: props.isLast ? 0 : 1,254 onSwipeableClose={() => {
200 borderBottomColor: separatorColor,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();
201 }}307 }}
308 style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
202 >309 >
203 {/* Git status indicator */}
204 <View310 <View
205 style={{311 style={{
206 width: 30,312 flexDirection: "row",
207 height: 30,313 paddingLeft: 16,
208 borderRadius: 9,314 paddingRight: 16,
209 backgroundColor: bg,315 paddingVertical: 10,
210 alignItems: "center",316 gap: 12,
211 justifyContent: "center",317 borderBottomWidth: props.isLast ? 0 : 1,
212 marginTop: 2,318 borderBottomColor: separatorColor,
213 }}319 }}
214 >320 >
215 <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" />321 <View
216 </View>322 style={{
217323 width: 30,
218 {/* Content */}324 height: 30,
219 <View style={{ flex: 1, gap: 3 }}>325 borderRadius: 9,
220 {/* Title + Status + Timestamp */}326 backgroundColor: bg,
221 <View className="flex-row items-center justify-between gap-2">327 alignItems: "center",
222 <Text328 justifyContent: "center",
223 className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground"329 marginTop: 2,
224 numberOfLines={1}330 }}
225 >331 >
226 {props.thread.title}332 <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" />
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>
244 </View>333 </View>
245334
246 {/* Environment + branch */}335 <View style={{ flex: 1, gap: 3 }}>
247 {subtitleParts.length > 0 ? (336 <View className="flex-row items-center justify-between gap-2">
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 />
255 <Text337 <Text
256 className="text-[11px] text-foreground-tertiary"338 className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground"
257 numberOfLines={1}339 numberOfLines={1}
258 style={{ fontFamily: "monospace" }}
259 >340 >
260 {subtitleParts.join(" · ")}341 {props.thread.title}
261 </Text>342 </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>
262 </View>359 </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>
264 </View>379 </View>
265 </View>380 </Pressable>
266 </Pressable>381 </ReanimatedSwipeable>
267 );382 );
268}383}
269384
...@@ -323,6 +438,7 @@ function StaleCatalogStatusPill(props: {...@@ -323,6 +438,7 @@ function StaleCatalogStatusPill(props: {
323438
324export function HomeScreen(props: HomeScreenProps) {439export function HomeScreen(props: HomeScreenProps) {
325 const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set());440 const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set());
441 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
326 const insets = useSafeAreaInsets();442 const insets = useSafeAreaInsets();
327 const accentColor = useThemeColor("--color-icon-muted");443 const accentColor = useThemeColor("--color-icon-muted");
328444
...@@ -335,51 +451,50 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -335,51 +451,50 @@ export function HomeScreen(props: HomeScreenProps) {
335 });451 });
336 }, []);452 }, []);
337453
338 /* Build project title lookup for search */454 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
339 const projectTitleByKey = useMemo(() => {455 if (openSwipeableRef.current !== methods) {
340 const map = new Map<string, string>();456 openSwipeableRef.current?.close();
341 for (const p of props.projects) {457 openSwipeableRef.current = methods;
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]);
366 }458 }
459 }, []);
367460
368 const groups: ProjectGroup[] = [];461 const handleSwipeableClose = useCallback((methods: SwipeableMethods) => {
369 for (const project of props.projects) {462 if (openSwipeableRef.current === methods) {
370 const key = scopedProjectKey(project.environmentId, project.id);463 openSwipeableRef.current = null;
371 const threads = byProject.get(key);
372 if (threads && threads.length > 0) {
373 groups.push({ key, project, threads });
374 }
375 }464 }
465 }, []);
376466
377 return Arr.sort(groups, projectGroupActivityOrder);467 const projectGroups = useMemo(
378 }, [props.projects, filteredThreads]);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
380 /* Empty states */489 /* Empty states */
381 const hasAnyThreads = props.threads.length > 0;490 const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null);
382 const hasResults = filteredThreads.length > 0;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;
383 const shouldShowConnectionStatus =498 const shouldShowConnectionStatus =
384 props.catalogState.networkStatus === "offline" ||499 props.catalogState.networkStatus === "offline" ||
385 props.catalogState.hasConnectingEnvironment ||500 props.catalogState.hasConnectingEnvironment ||
...@@ -396,6 +511,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -396,6 +511,7 @@ export function HomeScreen(props: HomeScreenProps) {
396 showsVerticalScrollIndicator={false}511 showsVerticalScrollIndicator={false}
397 keyboardDismissMode="on-drag"512 keyboardDismissMode="on-drag"
398 keyboardShouldPersistTaps="handled"513 keyboardShouldPersistTaps="handled"
514 onScrollBeginDrag={() => openSwipeableRef.current?.close()}
399 className="flex-1"515 className="flex-1"
400 contentContainerStyle={{516 contentContainerStyle={{
401 paddingHorizontal: 16,517 paddingHorizontal: 16,
...@@ -418,8 +534,18 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -418,8 +534,18 @@ export function HomeScreen(props: HomeScreenProps) {
418 </View>534 </View>
419 ) : null}535 ) : null}
420 </View>536 </View>
421 ) : !hasResults ? (537 ) : !hasResults && hasSearchQuery ? (
422 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />538 <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 />
423 ) : (549 ) : (
424 projectGroups.map((group) => {550 projectGroups.map((group) => {
425 const isExpanded = expandedProjects.has(group.key);551 const isExpanded = expandedProjects.has(group.key);
...@@ -428,30 +554,52 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -428,30 +554,52 @@ export function HomeScreen(props: HomeScreenProps) {
428 : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);554 : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
429555
430 return (556 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 >
432 <ProjectGroupLabel564 <ProjectGroupLabel
433 project={group.project}
434 totalThreadCount={group.threads.length}
435 isExpanded={isExpanded}565 isExpanded={isExpanded}
436 onToggleExpand={() => toggleExpanded(group.key)}566 onToggleExpand={() => toggleExpanded(group.key)}
567 project={group.representative}
568 title={group.title}
569 totalThreadCount={group.threads.length}
437 />570 />
438 <View571 <View
439 className="overflow-hidden rounded-[20px] bg-card"572 className="overflow-hidden rounded-[20px] bg-card"
440 style={{ borderCurve: "continuous" }}573 style={{ borderCurve: "continuous" }}
441 >574 >
442 {visibleThreads.map((thread, i) => (575 {visibleThreads.map((thread, i) => {
443 <ThreadRow576 const threadKey = `${thread.environmentId}:${thread.id}`;
444 key={`${thread.environmentId}:${thread.id}`}577 return (
445 thread={thread}578 <Animated.View
446 environmentLabel={579 key={threadKey}
447 props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null580 collapsable={false}
448 }581 exiting={threadRowExit}
449 onPress={() => props.onSelectThread(thread)}582 layout={THREAD_LAYOUT_TRANSITION}
450 isLast={i === visibleThreads.length - 1}583 style={{ overflow: "hidden" }}
451 />584 >
452 ))}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 })}
453 </View>601 </View>
454 </View>602 </Animated.View>
455 );603 );
456 })604 })
457 )}605 )}
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: {...@@ -695,6 +695,15 @@ export async function highlightCodeSnippet(input: {
695 return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]);695 return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]);
696}696}
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
698async function highlightPatchLinesInChunks(input: {707async function highlightPatchLinesInChunks(input: {
699 readonly lines: ReadonlyArray<string>;708 readonly lines: ReadonlyArray<string>;
700 readonly language: string;709 readonly language: string;
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2
...@@ -62,6 +62,7 @@ export interface ThreadDetailScreenProps {...@@ -62,6 +62,7 @@ export interface ThreadDetailScreenProps {
62 readonly activeThreadBusy: boolean;62 readonly activeThreadBusy: boolean;
63 readonly environmentId: EnvironmentId;63 readonly environmentId: EnvironmentId;
64 readonly projectWorkspaceRoot: string | null;64 readonly projectWorkspaceRoot: string | null;
65 readonly threadCwd: string | null;
65 readonly selectedThreadQueueCount: number;66 readonly selectedThreadQueueCount: number;
66 readonly serverConfig: T3ServerConfig | null;67 readonly serverConfig: T3ServerConfig | null;
67 readonly layoutVariant?: LayoutVariant;68 readonly layoutVariant?: LayoutVariant;
...@@ -309,6 +310,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -309,6 +310,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
309 key={props.selectedThread.id}310 key={props.selectedThread.id}
310 environmentId={props.environmentId}311 environmentId={props.environmentId}
311 threadId={props.selectedThread.id}312 threadId={props.selectedThread.id}
313 workspaceRoot={props.threadCwd}
312 feed={props.selectedThreadFeed}314 feed={props.selectedThreadFeed}
313 contentPresentation={props.contentPresentation}315 contentPresentation={props.contentPresentation}
314 agentLabel={agentLabel}316 agentLabel={agentLabel}
apps/mobile/src/features/threads/ThreadFeed.tsx+108-193
...@@ -4,6 +4,7 @@ import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";...@@ -4,6 +4,7 @@ import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";
4import { type LegendListRef } from "@legendapp/list/react-native";4import { type LegendListRef } from "@legendapp/list/react-native";
5import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";5import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";
6import { SymbolView } from "expo-symbols";6import { SymbolView } from "expo-symbols";
7import { useRouter } from "expo-router";
7import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";8import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
8import {9import {
9 Markdown,10 Markdown,
...@@ -54,6 +55,7 @@ import {...@@ -54,6 +55,7 @@ import {
54import { buildReviewParsedDiff } from "../review/reviewModel";55import { buildReviewParsedDiff } from "../review/reviewModel";
55import { cn } from "../../lib/cn";56import { cn } from "../../lib/cn";
56import type { LayoutVariant } from "../../lib/layout";57import type { LayoutVariant } from "../../lib/layout";
58import { buildThreadFilesNavigation } from "../../lib/routes";
57import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";59import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
58import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";60import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
59import {61import {
...@@ -64,7 +66,9 @@ import {...@@ -64,7 +66,9 @@ import {
64import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout";66import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout";
65import { relativeTime } from "../../lib/time";67import { relativeTime } from "../../lib/time";
66import type { ThreadContentPresentation } from "./threadContentPresentation";68import type { ThreadContentPresentation } from "./threadContentPresentation";
69import { ThreadWorkLog } from "./thread-work-log";
67import { useAssetUrl } from "../../state/assets";70import { useAssetUrl } from "../../state/assets";
71import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
6872
69const THREAD_FEED_END_THRESHOLD = 80;73const THREAD_FEED_END_THRESHOLD = 80;
70const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {74const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
...@@ -83,6 +87,7 @@ function formatMessageTime(input: string): string {...@@ -83,6 +87,7 @@ function formatMessageTime(input: string): string {
83export interface ThreadFeedProps {87export interface ThreadFeedProps {
84 readonly environmentId: EnvironmentId;88 readonly environmentId: EnvironmentId;
85 readonly threadId: ThreadId;89 readonly threadId: ThreadId;
90 readonly workspaceRoot?: string | null;
86 readonly feed: ReadonlyArray<ThreadFeedEntry>;91 readonly feed: ReadonlyArray<ThreadFeedEntry>;
87 readonly contentPresentation: ThreadContentPresentation;92 readonly contentPresentation: ThreadContentPresentation;
88 readonly agentLabel: string;93 readonly agentLabel: string;
...@@ -120,32 +125,6 @@ function MessageAttachmentImage(props: {...@@ -120,32 +125,6 @@ function MessageAttachmentImage(props: {
120 );125 );
121}126}
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
149const MARKDOWN_COLORS = {128const MARKDOWN_COLORS = {
150 light: {129 light: {
151 body: "#111111",130 body: "#111111",
...@@ -155,10 +134,12 @@ const MARKDOWN_COLORS = {...@@ -155,10 +134,12 @@ const MARKDOWN_COLORS = {
155 blockquoteBackground: "rgba(0, 0, 0, 0.02)",134 blockquoteBackground: "rgba(0, 0, 0, 0.02)",
156 codeBackground: "rgba(0, 0, 0, 0.04)",135 codeBackground: "rgba(0, 0, 0, 0.04)",
157 codeText: "#262626",136 codeText: "#262626",
137 inlineCodeText: "#5f6368",
158 horizontalRule: "rgba(0, 0, 0, 0.08)",138 horizontalRule: "rgba(0, 0, 0, 0.08)",
159 userBody: "#ffffff",139 userBody: "#ffffff",
160 userCodeBackground: "rgba(255, 255, 255, 0.22)",140 userCodeBackground: "rgba(255, 255, 255, 0.22)",
161 userCodeText: "#ffffff",141 userCodeText: "#ffffff",
142 userInlineCodeText: "rgba(255, 255, 255, 0.82)",
162 userFenceBackground: "rgba(0, 0, 0, 0.16)",143 userFenceBackground: "rgba(0, 0, 0, 0.16)",
163 userFenceText: "#ffffff",144 userFenceText: "#ffffff",
164 },145 },
...@@ -170,10 +151,12 @@ const MARKDOWN_COLORS = {...@@ -170,10 +151,12 @@ const MARKDOWN_COLORS = {
170 blockquoteBackground: "rgba(255, 255, 255, 0.03)",151 blockquoteBackground: "rgba(255, 255, 255, 0.03)",
171 codeBackground: "rgba(255, 255, 255, 0.06)",152 codeBackground: "rgba(255, 255, 255, 0.06)",
172 codeText: "#e5e5e5",153 codeText: "#e5e5e5",
154 inlineCodeText: "#b8bcc2",
173 horizontalRule: "rgba(255, 255, 255, 0.08)",155 horizontalRule: "rgba(255, 255, 255, 0.08)",
174 userBody: "#ffffff",156 userBody: "#ffffff",
175 userCodeBackground: "rgba(255, 255, 255, 0.18)",157 userCodeBackground: "rgba(255, 255, 255, 0.18)",
176 userCodeText: "#ffffff",158 userCodeText: "#ffffff",
159 userInlineCodeText: "rgba(255, 255, 255, 0.82)",
177 userFenceBackground: "rgba(0, 0, 0, 0.28)",160 userFenceBackground: "rgba(0, 0, 0, 0.28)",
178 userFenceText: "#ffffff",161 userFenceText: "#ffffff",
179 },162 },
...@@ -202,27 +185,18 @@ interface ReviewCommentColors {...@@ -202,27 +185,18 @@ interface ReviewCommentColors {
202185
203const failedMarkdownFaviconHosts = new Set<string>();186const failedMarkdownFaviconHosts = new Set<string>();
204const markdownLinkStyles = StyleSheet.create({187const markdownLinkStyles = StyleSheet.create({
205 favicon: {188 inlineIcon: {
206 width: 14,189 width: 14,
207 height: 14,190 height: 14,
208 borderRadius: 3,
209 marginHorizontal: 3,191 marginHorizontal: 3,
210 transform: [{ translateY: 2 }],192 transform: [{ translateY: 2 }],
211 },193 },
212 file: {194 favicon: {
213 borderRadius: 5,195 borderRadius: 3,
214 borderWidth: StyleSheet.hairlineWidth,
215 fontFamily: "DMSans_500Medium",
216 fontSize: 13,
217 lineHeight: 20,
218 paddingHorizontal: 6,
219 paddingVertical: 2,
220 },196 },
221 fileIcon: {197 file: {
222 width: 15,198 fontFamily: "DMSans_700Bold",
223 height: 15,199 fontWeight: "700",
224 marginRight: 4,
225 transform: [{ translateY: 2 }],
226 },200 },
227});201});
228202
...@@ -250,7 +224,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {...@@ -250,7 +224,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
250 source={{224 source={{
251 uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`,225 uri: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(props.host)}&sz=32`,
252 }}226 }}
253 style={markdownLinkStyles.favicon}227 style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]}
254 onError={() => {228 onError={() => {
255 failedMarkdownFaviconHosts.add(props.host);229 failedMarkdownFaviconHosts.add(props.host);
256 setFailed(true);230 setFailed(true);
...@@ -287,11 +261,9 @@ function useReviewCommentColors(): ReviewCommentColors {...@@ -287,11 +261,9 @@ function useReviewCommentColors(): ReviewCommentColors {
287 );261 );
288}262}
289263
290function useMarkdownStyles(): MarkdownStyleSets {264function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets {
291 const colorScheme = useColorScheme();265 const colorScheme = useColorScheme();
292 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];266 const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"];
293 const inlineChipBackground = String(useThemeColor("--color-subtle"));
294 const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background"));
295 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));267 const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
296268
297 return useMemo(() => {269 return useMemo(() => {
...@@ -302,10 +274,12 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -302,10 +274,12 @@ function useMarkdownStyles(): MarkdownStyleSets {
302 const markdownBlockquoteBorder = colors.blockquoteBorder;274 const markdownBlockquoteBorder = colors.blockquoteBorder;
303 const markdownCodeBg = colors.codeBackground;275 const markdownCodeBg = colors.codeBackground;
304 const markdownCodeText = colors.codeText;276 const markdownCodeText = colors.codeText;
277 const markdownInlineCodeText = colors.inlineCodeText;
305 const markdownHrColor = colors.horizontalRule;278 const markdownHrColor = colors.horizontalRule;
306 const markdownUserBodyColor = colors.userBody;279 const markdownUserBodyColor = colors.userBody;
307 const markdownUserCodeBg = colors.userCodeBackground;280 const markdownUserCodeBg = colors.userCodeBackground;
308 const markdownUserCodeText = colors.userCodeText;281 const markdownUserCodeText = colors.userCodeText;
282 const markdownUserInlineCodeText = colors.userInlineCodeText;
309 const markdownUserFenceBg = colors.userFenceBackground;283 const markdownUserFenceBg = colors.userFenceBackground;
310 const markdownUserFenceText = colors.userFenceText;284 const markdownUserFenceText = colors.userFenceText;
311285
...@@ -394,28 +368,23 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -394,28 +368,23 @@ function useMarkdownStyles(): MarkdownStyleSets {
394 };368 };
395369
396 const createMarkdownRenderers = (370 const createMarkdownRenderers = (
397 inlineBackgroundColor: string,
398 inlineTextColor: string,371 inlineTextColor: string,
372 inlineCodeTextColor: string,
399 blockBackgroundColor: string,373 blockBackgroundColor: string,
400 blockTextColor: string,374 blockTextColor: string,
375 preserveSoftBreaks: boolean,
401 ): CustomRenderers => ({376 ): CustomRenderers => ({
402 link: ({ children, href = "" }) => {377 link: ({ children, href = "" }) => {
403 const presentation = resolveMarkdownLinkPresentation(href);378 const presentation = resolveMarkdownLinkPresentation(href);
404 if (presentation.kind === "file") {379 if (presentation.kind === "file") {
405 return (380 return (
406 <NativeText381 <NativeText
407 style={[382 onPress={() => onLinkPress(href)}
408 markdownLinkStyles.file,383 style={[markdownLinkStyles.file, { color: inlineTextColor }]}
409 {
410 backgroundColor: inlineBackgroundColor,
411 borderColor: markdownHrColor,
412 color: inlineTextColor,
413 },
414 ]}
415 >384 >
416 <Image385 <Image
417 source={markdownFileIconSource(presentation.icon)}386 source={markdownFileIconSource(presentation.icon)}
418 style={markdownLinkStyles.fileIcon}387 style={markdownLinkStyles.inlineIcon}
419 />388 />
420 {presentation.label}389 {presentation.label}
421 </NativeText>390 </NativeText>
...@@ -492,28 +461,24 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -492,28 +461,24 @@ function useMarkdownStyles(): MarkdownStyleSets {
492 ),461 ),
493 code_inline: ({ content }) => {462 code_inline: ({ content }) => {
494 const value = content ?? "";463 const value = content ?? "";
495 const wrapsPoorly =
496 value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":");
497 return (464 return (
498 <NativeText465 <NativeText
499 style={{466 style={{
500 color: inlineTextColor,467 color: inlineCodeTextColor,
501 fontFamily: "ui-monospace",468 fontFamily: "ui-monospace",
502 fontSize: 12,469 fontSize: 12,
503 lineHeight: 18,470 lineHeight: 22,
504 ...(wrapsPoorly
505 ? { opacity: 0.82 }
506 : {
507 backgroundColor: inlineBackgroundColor,
508 borderRadius: 4,
509 paddingHorizontal: 3,
510 }),
511 }}471 }}
512 >472 >
513 {value}473 {value}
514 </NativeText>474 </NativeText>
515 );475 );
516 },476 },
477 ...(preserveSoftBreaks
478 ? {
479 soft_break: () => <NativeText>{"\n"}</NativeText>,
480 }
481 : {}),
517 code_block: ({ content, language }) => (482 code_block: ({ content, language }) => (
518 <View483 <View
519 style={{484 style={{
...@@ -619,23 +584,23 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -619,23 +584,23 @@ function useMarkdownStyles(): MarkdownStyleSets {
619 theme: userTheme,584 theme: userTheme,
620 styles: userStyles,585 styles: userStyles,
621 renderers: createMarkdownRenderers(586 renderers: createMarkdownRenderers(
622 markdownUserCodeBg,
623 markdownUserCodeText,587 markdownUserCodeText,
588 markdownUserInlineCodeText,
624 markdownUserFenceBg,589 markdownUserFenceBg,
625 markdownUserFenceText,590 markdownUserFenceText,
591 true,
626 ),592 ),
627 nativeTextStyle: {593 nativeTextStyle: {
628 color: markdownUserBodyColor,594 color: markdownUserBodyColor,
629 strongColor: markdownUserBodyColor,595 strongColor: markdownUserBodyColor,
630 mutedColor: markdownUserBodyColor,596 mutedColor: markdownUserBodyColor,
631 linkColor: markdownUserBodyColor,597 linkColor: markdownUserBodyColor,
598 inlineCodeColor: markdownUserInlineCodeText,
632 codeColor: markdownUserCodeText,599 codeColor: markdownUserCodeText,
633 codeBackgroundColor: markdownUserCodeBg,600 codeBackgroundColor: markdownUserCodeBg,
634 codeBlockBackgroundColor: markdownUserFenceBg,601 codeBlockBackgroundColor: markdownUserFenceBg,
635 fileBackgroundColor: "rgba(255, 255, 255, 0.12)",
636 fileTextColor: "#ffffff",602 fileTextColor: "#ffffff",
637 skillBackgroundColor: "rgba(217, 70, 239, 0.24)",603 skillTextColor: "#f0abfc",
638 skillTextColor: "#ffffff",
639 quoteMarkerColor: markdownUserBodyColor,604 quoteMarkerColor: markdownUserBodyColor,
640 dividerColor: markdownUserBodyColor,605 dividerColor: markdownUserBodyColor,
641 fontSize: 15,606 fontSize: 15,
...@@ -649,22 +614,22 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -649,22 +614,22 @@ function useMarkdownStyles(): MarkdownStyleSets {
649 theme: assistantTheme,614 theme: assistantTheme,
650 styles: assistantStyles,615 styles: assistantStyles,
651 renderers: createMarkdownRenderers(616 renderers: createMarkdownRenderers(
652 markdownCodeBg,
653 markdownCodeText,617 markdownCodeText,
618 markdownInlineCodeText,
654 markdownCodeBg,619 markdownCodeBg,
655 markdownCodeText,620 markdownCodeText,
621 false,
656 ),622 ),
657 nativeTextStyle: {623 nativeTextStyle: {
658 color: markdownBodyColor,624 color: markdownBodyColor,
659 strongColor: markdownStrongColor,625 strongColor: markdownStrongColor,
660 mutedColor: markdownBodyColor,626 mutedColor: markdownBodyColor,
661 linkColor: markdownLinkColor,627 linkColor: markdownLinkColor,
628 inlineCodeColor: markdownInlineCodeText,
662 codeColor: markdownCodeText,629 codeColor: markdownCodeText,
663 codeBackgroundColor: markdownCodeBg,630 codeBackgroundColor: markdownCodeBg,
664 codeBlockBackgroundColor: markdownCodeBg,631 codeBlockBackgroundColor: markdownCodeBg,
665 fileBackgroundColor: inlineChipBackground,
666 fileTextColor: markdownCodeText,632 fileTextColor: markdownCodeText,
667 skillBackgroundColor: inlineSkillBackground,
668 skillTextColor: inlineSkillForeground,633 skillTextColor: inlineSkillForeground,
669 quoteMarkerColor: markdownBlockquoteBorder,634 quoteMarkerColor: markdownBlockquoteBorder,
670 dividerColor: markdownHrColor,635 dividerColor: markdownHrColor,
...@@ -676,7 +641,7 @@ function useMarkdownStyles(): MarkdownStyleSets {...@@ -676,7 +641,7 @@ function useMarkdownStyles(): MarkdownStyleSets {
676 },641 },
677 },642 },
678 };643 };
679 }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]);644 }, [colors, inlineSkillForeground, onLinkPress]);
680}645}
681646
682function renderFeedEntry(647function renderFeedEntry(
...@@ -692,11 +657,13 @@ function renderFeedEntry(...@@ -692,11 +657,13 @@ function renderFeedEntry(
692 readonly onToggleWorkRow: (rowId: string) => void;657 readonly onToggleWorkRow: (rowId: string) => void;
693 readonly onToggleTurnFold: (turnId: TurnId) => void;658 readonly onToggleTurnFold: (turnId: TurnId) => void;
694 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;659 readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
660 readonly onMarkdownLinkPress: (href: string) => void;
695 readonly iconSubtleColor: string | import("react-native").ColorValue;661 readonly iconSubtleColor: string | import("react-native").ColorValue;
696 readonly userBubbleColor: string | import("react-native").ColorValue;662 readonly userBubbleColor: string | import("react-native").ColorValue;
697 readonly markdownStyles: MarkdownStyleSets;663 readonly markdownStyles: MarkdownStyleSets;
698 readonly reviewCommentColors: ReviewCommentColors;664 readonly reviewCommentColors: ReviewCommentColors;
699 readonly reviewCommentBubbleWidth: number;665 readonly reviewCommentBubbleWidth: number;
666 readonly userBubbleMaxWidth: number;
700 },667 },
701) {668) {
702 const entry = info.item;669 const entry = info.item;
...@@ -745,9 +712,10 @@ function renderFeedEntry(...@@ -745,9 +712,10 @@ function renderFeedEntry(
745 return (712 return (
746 <View className="mb-5 items-end">713 <View className="mb-5 items-end">
747 <View714 <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"
749 style={{716 style={{
750 backgroundColor: userBubbleColor,717 backgroundColor: userBubbleColor,
718 maxWidth: props.userBubbleMaxWidth,
751 ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),719 ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),
752 }}720 }}
753 >721 >
...@@ -757,6 +725,7 @@ function renderFeedEntry(...@@ -757,6 +725,7 @@ function renderFeedEntry(
757 markdownStyles={styles}725 markdownStyles={styles}
758 reviewCommentColors={props.reviewCommentColors}726 reviewCommentColors={props.reviewCommentColors}
759 skills={props.skills}727 skills={props.skills}
728 onLinkPress={props.onMarkdownLinkPress}
760 />729 />
761 ) : null}730 ) : null}
762 {attachments.map((attachment) => {731 {attachments.map((attachment) => {
...@@ -803,6 +772,7 @@ function renderFeedEntry(...@@ -803,6 +772,7 @@ function renderFeedEntry(
803 markdown={message.text}772 markdown={message.text}
804 skills={props.skills}773 skills={props.skills}
805 textStyle={styles.nativeTextStyle}774 textStyle={styles.nativeTextStyle}
775 onLinkPress={props.onMarkdownLinkPress}
806 />776 />
807 ) : (777 ) : (
808 <Markdown778 <Markdown
...@@ -868,123 +838,17 @@ function renderFeedEntry(...@@ -868,123 +838,17 @@ function renderFeedEntry(
868 );838 );
869 }839 }
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
888 return (841 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]">842 <ThreadWorkLog
890 <View className="mb-1.5 flex-row items-center justify-between gap-3 px-2">843 activities={entry.activities}
891 <Text className="font-t3-medium text-xs text-foreground">{headerTitle}</Text>844 copiedRowId={props.copiedRowId}
892 {hasOverflow ? (845 expanded={props.expandedWorkGroups[entry.id] ?? false}
893 <Pressable846 expandedRows={props.expandedWorkRows}
894 accessibilityRole="button"847 iconSubtleColor={iconSubtleColor}
895 accessibilityState={{ expanded: isExpanded }}848 onCopyRow={props.onCopyWorkRow}
896 onPress={() => props.onToggleWorkGroup(entry.id)}849 onToggleGroup={() => props.onToggleWorkGroup(entry.id)}
897 className="flex-row items-center gap-1"850 onToggleRow={props.onToggleWorkRow}
898 >851 />
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>
988 );852 );
989}853}
990854
...@@ -993,6 +857,7 @@ function UserMessageContent(props: {...@@ -993,6 +857,7 @@ function UserMessageContent(props: {
993 readonly markdownStyles: MarkdownStyleSet;857 readonly markdownStyles: MarkdownStyleSet;
994 readonly reviewCommentColors: ReviewCommentColors;858 readonly reviewCommentColors: ReviewCommentColors;
995 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;859 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
860 readonly onLinkPress: (href: string) => void;
996}) {861}) {
997 const segments = parseReviewCommentMessageSegments(props.text);862 const segments = parseReviewCommentMessageSegments(props.text);
998 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");863 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
...@@ -1003,6 +868,8 @@ function UserMessageContent(props: {...@@ -1003,6 +868,8 @@ function UserMessageContent(props: {
1003 markdown={props.text}868 markdown={props.text}
1004 skills={props.skills}869 skills={props.skills}
1005 textStyle={props.markdownStyles.nativeTextStyle}870 textStyle={props.markdownStyles.nativeTextStyle}
871 preserveSoftBreaks
872 onLinkPress={props.onLinkPress}
1006 />873 />
1007 );874 );
1008 }875 }
...@@ -1042,6 +909,8 @@ function UserMessageContent(props: {...@@ -1042,6 +909,8 @@ function UserMessageContent(props: {
1042 markdown={text}909 markdown={text}
1043 skills={props.skills}910 skills={props.skills}
1044 textStyle={props.markdownStyles.nativeTextStyle}911 textStyle={props.markdownStyles.nativeTextStyle}
912 preserveSoftBreaks
913 onLinkPress={props.onLinkPress}
1045 />914 />
1046 ) : (915 ) : (
1047 <Markdown916 <Markdown
...@@ -1254,6 +1123,7 @@ function ThreadFeedPlaceholder(props: {...@@ -1254,6 +1123,7 @@ function ThreadFeedPlaceholder(props: {
1254}1123}
12551124
1256export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {1125export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1126 const router = useRouter();
1257 const listRef = useRef<LegendListRef>(null);1127 const listRef = useRef<LegendListRef>(null);
1258 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);1128 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1259 const scrollFrameRef = useRef<number | null>(null);1129 const scrollFrameRef = useRef<number | null>(null);
...@@ -1283,6 +1153,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1283,6 +1153,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1283 } | null>(null);1153 } | null>(null);
1284 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;1154 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
1285 const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2);1155 const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2);
1156 const userBubbleMaxWidth = contentWidth * 0.85;
1286 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);1157 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
1287 const insets = useSafeAreaInsets();1158 const insets = useSafeAreaInsets();
1288 const topContentInset = props.contentTopInset ?? insets.top + 44;1159 const topContentInset = props.contentTopInset ?? insets.top + 44;
...@@ -1290,16 +1161,56 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1290,16 +1161,56 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
12901161
1291 const iconSubtleColor = useThemeColor("--color-icon-subtle");1162 const iconSubtleColor = useThemeColor("--color-icon-subtle");
1292 const userBubbleColor = useThemeColor("--color-user-bubble");1163 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);
1294 const reviewCommentColors = useReviewCommentColors();1192 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.
1295 const listAppearanceData = useMemo(1195 const listAppearanceData = useMemo(
1296 () => ({1196 () => ({
1197 copiedRowId,
1198 expandedWorkGroups,
1199 expandedWorkRows,
1297 iconSubtleColor,1200 iconSubtleColor,
1298 markdownStyles,1201 markdownStyles,
1299 reviewCommentColors,1202 reviewCommentColors,
1300 userBubbleColor,1203 userBubbleColor,
1301 }),1204 }),
1302 [iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor],1205 [
1206 copiedRowId,
1207 expandedWorkGroups,
1208 expandedWorkRows,
1209 iconSubtleColor,
1210 markdownStyles,
1211 reviewCommentColors,
1212 userBubbleColor,
1213 ],
1303 );1214 );
1304 const presentedFeed = useMemo(1215 const presentedFeed = useMemo(
1305 () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds),1216 () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds),
...@@ -1490,11 +1401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1490,11 +1401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1490 onToggleWorkRow,1401 onToggleWorkRow,
1491 onToggleTurnFold,1402 onToggleTurnFold,
1492 onPressImage,1403 onPressImage,
1404 onMarkdownLinkPress,
1493 iconSubtleColor,1405 iconSubtleColor,
1494 userBubbleColor,1406 userBubbleColor,
1495 markdownStyles,1407 markdownStyles,
1496 reviewCommentColors,1408 reviewCommentColors,
1497 reviewCommentBubbleWidth,1409 reviewCommentBubbleWidth,
1410 userBubbleMaxWidth,
1498 skills: props.skills,1411 skills: props.skills,
1499 }),1412 }),
1500 [1413 [
...@@ -1508,7 +1421,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1508,7 +1421,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1508 markdownStyles,1421 markdownStyles,
1509 reviewCommentColors,1422 reviewCommentColors,
1510 reviewCommentBubbleWidth,1423 reviewCommentBubbleWidth,
1424 userBubbleMaxWidth,
1511 onCopyWorkRow,1425 onCopyWorkRow,
1426 onMarkdownLinkPress,
1512 onPressImage,1427 onPressImage,
1513 onToggleTurnFold,1428 onToggleTurnFold,
1514 onToggleWorkGroup,1429 onToggleWorkGroup,
apps/mobile/src/features/threads/ThreadGitControls.tsx+10-1
...@@ -14,7 +14,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";...@@ -14,7 +14,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
14import Stack from "expo-router/stack";14import Stack from "expo-router/stack";
15import { useCallback, useMemo } from "react";15import { useCallback, useMemo } from "react";
16import { Alert, Linking } from "react-native";16import { Alert, Linking } from "react-native";
17import { buildThreadReviewRoutePath } from "../../lib/routes";17import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";
18import {18import {
19 basename,19 basename,
20 getTerminalStatusLabel,20 getTerminalStatusLabel,
...@@ -69,6 +69,7 @@ export function ThreadGitControls(props: {...@@ -69,6 +69,7 @@ export function ThreadGitControls(props: {
69 readonly gitStatus: VcsStatusResult | null;69 readonly gitStatus: VcsStatusResult | null;
70 readonly gitOperationLabel: string | null;70 readonly gitOperationLabel: string | null;
71 readonly canOpenTerminal: boolean;71 readonly canOpenTerminal: boolean;
72 readonly canOpenFiles: boolean;
72 readonly projectScripts: ReadonlyArray<ProjectScript>;73 readonly projectScripts: ReadonlyArray<ProjectScript>;
73 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;74 readonly terminalSessions: ReadonlyArray<TerminalMenuSession>;
74 readonly onOpenTerminal: (terminalId?: string | null) => void;75 readonly onOpenTerminal: (terminalId?: string | null) => void;
...@@ -259,6 +260,14 @@ export function ThreadGitControls(props: {...@@ -259,6 +260,14 @@ export function ThreadGitControls(props: {
259 >260 >
260 <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label>261 <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label>
261 </Stack.Toolbar.MenuAction>262 </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>
262 <Stack.Toolbar.MenuAction271 <Stack.Toolbar.MenuAction
263 icon="ellipsis.circle"272 icon="ellipsis.circle"
264 onPress={() =>273 onPress={() =>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2
...@@ -420,6 +420,7 @@ export function ThreadRouteScreen() {...@@ -420,6 +420,7 @@ export function ThreadRouteScreen() {
420 gitStatus={gitStatus.data}420 gitStatus={gitStatus.data}
421 gitOperationLabel={gitState.gitOperationLabel}421 gitOperationLabel={gitState.gitOperationLabel}
422 canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)}422 canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)}
423 canOpenFiles={Boolean(selectedThreadProject?.workspaceRoot)}
423 projectScripts={selectedThreadProject?.scripts ?? []}424 projectScripts={selectedThreadProject?.scripts ?? []}
424 terminalSessions={terminalMenuSessions}425 terminalSessions={terminalMenuSessions}
425 onOpenTerminal={handleOpenTerminal}426 onOpenTerminal={handleOpenTerminal}
...@@ -452,6 +453,7 @@ export function ThreadRouteScreen() {...@@ -452,6 +453,7 @@ export function ThreadRouteScreen() {
452 activeThreadBusy={composer.activeThreadBusy}453 activeThreadBusy={composer.activeThreadBusy}
453 environmentId={selectedThread.environmentId}454 environmentId={selectedThread.environmentId}
454 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}455 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
456 threadCwd={selectedThreadCwd}
455 selectedThreadQueueCount={composer.selectedThreadQueueCount}457 selectedThreadQueueCount={composer.selectedThreadQueueCount}
456 onOpenDrawer={handleOpenDrawer}458 onOpenDrawer={handleOpenDrawer}
457 onOpenConnectionEditor={handleOpenConnectionEditor}459 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", () => {...@@ -16,26 +16,47 @@ describe("resolveMarkdownLinkPresentation", () => {
16 resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"),16 resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"),
17 ).toEqual({17 ).toEqual({
18 kind: "file",18 kind: "file",
19 href: "file:///Users/julius/project/src/main.ts#L42C7",
19 icon: "typescript",20 icon: "typescript",
20 label: "main.ts:42:7",21 label: "main.ts:42:7",
22 path: "/Users/julius/project/src/main.ts",
23 line: 42,
24 column: 7,
21 });25 });
22 });26 });
2327
24 it("recognizes relative source paths and bare filenames", () => {28 it("recognizes relative source paths and bare filenames", () => {
25 expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({29 expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({
26 kind: "file",30 kind: "file",
31 href: "apps/mobile/src/index.ts:10",
27 icon: "typescript",32 icon: "typescript",
28 label: "index.ts:10",33 label: "index.ts:10",
34 path: "apps/mobile/src/index.ts",
35 line: 10,
29 });36 });
30 expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({37 expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({
31 kind: "file",38 kind: "file",
39 href: "AGENTS.md",
32 icon: "agents",40 icon: "agents",
33 label: "AGENTS.md",41 label: "AGENTS.md",
42 path: "AGENTS.md",
34 });43 });
35 expect(resolveMarkdownLinkPresentation("package.json")).toEqual({44 expect(resolveMarkdownLinkPresentation("package.json")).toEqual({
36 kind: "file",45 kind: "file",
46 href: "package.json",
37 icon: "package",47 icon: "package",
38 label: "package.json",48 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",
39 });60 });
40 });61 });
4162
apps/mobile/src/lib/nativeMarkdownText.test.ts+22-2
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 nativeMarkdownDocumentRuns,7 nativeMarkdownDocumentRuns,
8 nativeMarkdownListItemBlocks,8 nativeMarkdownListItemBlocks,
9 nativeMarkdownTextRuns,9 nativeMarkdownTextRuns,
10 nativeMarkdownWithPreservedSoftBreaks,
10} from "@t3tools/mobile-markdown-text/markdown";11} from "@t3tools/mobile-markdown-text/markdown";
1112
12describe("nativeMarkdownTextRuns", () => {13describe("nativeMarkdownTextRuns", () => {
...@@ -54,7 +55,11 @@ describe("nativeMarkdownTextRuns", () => {...@@ -54,7 +55,11 @@ describe("nativeMarkdownTextRuns", () => {
54 externalHost: "example.com",55 externalHost: "example.com",
55 },56 },
56 { text: " " },57 { 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 },
58 ]);63 ]);
59 });64 });
6065
...@@ -73,6 +78,21 @@ describe("nativeMarkdownTextRuns", () => {...@@ -73,6 +78,21 @@ describe("nativeMarkdownTextRuns", () => {
73 expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]);78 expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]);
74 });79 });
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
76 it("normalizes common inline HTML and entities", () => {96 it("normalizes common inline HTML and entities", () => {
77 const node: MarkdownNode = {97 const node: MarkdownNode = {
78 type: "paragraph",98 type: "paragraph",
...@@ -130,7 +150,7 @@ describe("nativeMarkdownTextRuns", () => {...@@ -130,7 +150,7 @@ describe("nativeMarkdownTextRuns", () => {
130});150});
131151
132describe("nativeMarkdownDocumentRuns", () => {152describe("nativeMarkdownDocumentRuns", () => {
133 it("decorates known skill references as selectable skill chips", () => {153 it("decorates known skill references as selectable skill links", () => {
134 const node: MarkdownNode = {154 const node: MarkdownNode = {
135 type: "document",155 type: "document",
136 children: [156 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(...@@ -32,6 +32,27 @@ export function buildThreadReviewRoutePath(
32 return `${buildThreadRoutePath(input)}/review`;32 return `${buildThreadRoutePath(input)}/review`;
33}33}
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
35export function buildThreadTerminalRoutePath(56export function buildThreadTerminalRoutePath(
36 input: ThreadRouteInput | PlainThreadRouteInput,57 input: ThreadRouteInput | PlainThreadRouteInput,
37 terminalId?: string | null,58 terminalId?: string | null,
...@@ -71,6 +92,38 @@ export function buildThreadTerminalNavigation(...@@ -71,6 +92,38 @@ export function buildThreadTerminalNavigation(
71 };92 };
72}93}
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
74export function dismissRoute(router: Router) {127export function dismissRoute(router: Router) {
75 if (router.canGoBack()) {128 if (router.canGoBack()) {
76 router.back();129 router.back();
apps/mobile/src/lib/threadActivity.test.ts+53-2
...@@ -161,14 +161,65 @@ describe("buildThreadFeed", () => {...@@ -161,14 +161,65 @@ describe("buildThreadFeed", () => {
161 turnId: "turn-1",161 turnId: "turn-1",
162 summary: "Run tests",162 summary: "Run tests",
163 detail: "bun run test",163 detail: "bun run test",
164 fullDetail: null,164 fullDetail: "/bin/zsh -lc 'bun run test'",
165 copyText: "Run tests\nbun run test",165 copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'",
166 icon: "command",
166 toolLike: true,167 toolLike: true,
167 status: "success",168 status: "success",
168 },169 },
169 ]);170 ]);
170 });171 });
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
172 it("folds settled turn work while leaving the terminal answer visible", () => {223 it("folds settled turn work while leaving the terminal answer visible", () => {
173 const turnId = TurnId.make("turn-1");224 const turnId = TurnId.make("turn-1");
174 const thread = makeThread({225 const thread = makeThread({
apps/mobile/src/lib/threadActivity.ts+72-9
...@@ -40,6 +40,19 @@ export interface ThreadFeedActivity {...@@ -40,6 +40,19 @@ export interface ThreadFeedActivity {
40 readonly detail: string | null;40 readonly detail: string | null;
41 readonly fullDetail: string | null;41 readonly fullDetail: string | null;
42 readonly copyText: string;42 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";
43 readonly toolLike: boolean;56 readonly toolLike: boolean;
44 readonly status: "success" | "failure" | "neutral" | null;57 readonly status: "success" | "failure" | "neutral" | null;
45}58}
...@@ -60,6 +73,7 @@ interface WorkLogEntry {...@@ -60,6 +73,7 @@ interface WorkLogEntry {
60 itemType?: ToolLifecycleItemType;73 itemType?: ToolLifecycleItemType;
61 requestKind?: PendingApproval["requestKind"];74 requestKind?: PendingApproval["requestKind"];
62 toolLifecycleStatus?: WorkLogToolLifecycleStatus;75 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
76 toolData?: unknown;
63}77}
6478
65interface DerivedWorkLogEntry extends WorkLogEntry {79interface DerivedWorkLogEntry extends WorkLogEntry {
...@@ -214,7 +228,7 @@ function resolvePendingUserInputAnswer(...@@ -214,7 +228,7 @@ function resolvePendingUserInputAnswer(
214228
215function deriveWorkLogEntries(229function deriveWorkLogEntries(
216 activities: ReadonlyArray<OrchestrationThreadActivity>,230 activities: ReadonlyArray<OrchestrationThreadActivity>,
217): WorkLogEntry[] {231): DerivedWorkLogEntry[] {
218 const ordered = Arr.sort(activities, activityOrder);232 const ordered = Arr.sort(activities, activityOrder);
219 const entries: DerivedWorkLogEntry[] = [];233 const entries: DerivedWorkLogEntry[] = [];
220 for (const activity of ordered) {234 for (const activity of ordered) {
...@@ -225,9 +239,7 @@ function deriveWorkLogEntries(...@@ -225,9 +239,7 @@ function deriveWorkLogEntries(
225 if (isPlanBoundaryToolActivity(activity)) continue;239 if (isPlanBoundaryToolActivity(activity)) continue;
226 entries.push(toDerivedWorkLogEntry(activity));240 entries.push(toDerivedWorkLogEntry(activity));
227 }241 }
228 return collapseDerivedWorkLogEntries(entries).map(242 return collapseDerivedWorkLogEntries(entries);
229 ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry,
230 );
231}243}
232244
233function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {245function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
...@@ -301,6 +313,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -301,6 +313,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
301 if (title) {313 if (title) {
302 entry.toolTitle = title;314 entry.toolTitle = title;
303 }315 }
316 if (itemType === "mcp_tool_call") {
317 const data = asRecord(payload?.data);
318 if (data?.item !== undefined) {
319 entry.toolData = data.item;
320 }
321 }
304 if (itemType) {322 if (itemType) {
305 entry.itemType = itemType;323 entry.itemType = itemType;
306 }324 }
...@@ -365,6 +383,7 @@ function mergeDerivedWorkLogEntries(...@@ -365,6 +383,7 @@ function mergeDerivedWorkLogEntries(
365 const requestKind = next.requestKind ?? previous.requestKind;383 const requestKind = next.requestKind ?? previous.requestKind;
366 const collapseKey = next.collapseKey ?? previous.collapseKey;384 const collapseKey = next.collapseKey ?? previous.collapseKey;
367 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;385 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
386 const toolData = next.toolData ?? previous.toolData;
368 return {387 return {
369 ...previous,388 ...previous,
370 ...next,389 ...next,
...@@ -377,6 +396,7 @@ function mergeDerivedWorkLogEntries(...@@ -377,6 +396,7 @@ function mergeDerivedWorkLogEntries(
377 ...(requestKind ? { requestKind } : {}),396 ...(requestKind ? { requestKind } : {}),
378 ...(collapseKey ? { collapseKey } : {}),397 ...(collapseKey ? { collapseKey } : {}),
379 ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),398 ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
399 ...(toolData !== undefined ? { toolData } : {}),
380 };400 };
381}401}
382402
...@@ -480,6 +500,52 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {...@@ -480,6 +500,52 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] {
480 return "neutral";500 return "neutral";
481}501}
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
483function workEntryPreview(549function workEntryPreview(
484 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,550 workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">,
485): string | null {551): string | null {
...@@ -1226,11 +1292,7 @@ export function buildThreadFeed(...@@ -1226,11 +1292,7 @@ export function buildThreadFeed(
1226 .map<RawThreadFeedEntry>((entry) => {1292 .map<RawThreadFeedEntry>((entry) => {
1227 const summary = workEntryHeading(entry);1293 const summary = workEntryHeading(entry);
1228 const detail = workEntryPreview(entry);1294 const detail = workEntryPreview(entry);
1229 const normalizedFullDetail = entry.detail1295 const fullDetail = buildWorkEntryExpandedBody(entry);
1230 ? unwrapKnownShellCommandWrapper(entry.detail)
1231 : null;
1232 const fullDetail =
1233 normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null;
1234 return {1296 return {
1235 type: "activity",1297 type: "activity",
1236 id: entry.id,1298 id: entry.id,
...@@ -1243,6 +1305,7 @@ export function buildThreadFeed(...@@ -1243,6 +1305,7 @@ export function buildThreadFeed(
1243 summary,1305 summary,
1244 detail,1306 detail,
1245 fullDetail,1307 fullDetail,
1308 icon: workEntryIcon(entry),
1246 copyText: [summary, detail, fullDetail]1309 copyText: [summary, detail, fullDetail]
1247 .filter((value, index, values): value is string => {1310 .filter((value, index, values): value is string => {
1248 return Boolean(value) && values.indexOf(value) === index;1311 return Boolean(value) && values.indexOf(value) === index;
apps/server/src/assets/AssetAccess.test.ts+36
...@@ -89,6 +89,42 @@ describe("AssetAccess", () => {...@@ -89,6 +89,42 @@ describe("AssetAccess", () => {
89 }).pipe(Effect.provide(testLayer)),89 }).pipe(Effect.provide(testLayer)),
90 );90 );
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
92 it.effect("issues exact attachment capabilities by attachment id", () =>128 it.effect("issues exact attachment capabilities by attachment id", () =>
93 Effect.gen(function* () {129 Effect.gen(function* () {
94 const config = yield* ServerConfig;130 const config = yield* ServerConfig;
apps/server/src/assets/AssetAccess.ts+45-21
...@@ -1,5 +1,11 @@...@@ -1,5 +1,11 @@
1import type { AssetResource } from "@t3tools/contracts";1import type { AssetResource } from "@t3tools/contracts";
2import { AssetAccessError } from "@t3tools/contracts";2import { AssetAccessError } from "@t3tools/contracts";
3import {
4 isWorkspaceImagePreviewPath,
5 isWorkspacePreviewEntryPath,
6 WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
7 WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
8} from "@t3tools/shared/filePreview";
3import * as Clock from "effect/Clock";9import * as Clock from "effect/Clock";
4import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
...@@ -24,22 +30,14 @@ export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/...@@ -24,22 +30,14 @@ export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/
2430
25const SIGNING_SECRET_NAME = "asset-access-signing-key";31const SIGNING_SECRET_NAME = "asset-access-signing-key";
26const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;32const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
27const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]);
28const PREVIEW_ASSET_EXTENSIONS = new Set([33const PREVIEW_ASSET_EXTENSIONS = new Set([
29 ...PREVIEWABLE_EXTENSIONS,34 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
30 ".avif",35 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
31 ".css",36 ".css",
32 ".gif",
33 ".ico",
34 ".jpeg",
35 ".jpg",
36 ".js",37 ".js",
37 ".mjs",38 ".mjs",
38 ".otf",39 ".otf",
39 ".png",
40 ".svg",
41 ".ttf",40 ".ttf",
42 ".webp",
43 ".woff",41 ".woff",
44 ".woff2",42 ".woff2",
45]);43]);
...@@ -52,6 +50,13 @@ const AssetClaimsSchema = Schema.Union([...@@ -52,6 +50,13 @@ const AssetClaimsSchema = Schema.Union([
52 baseRelativePath: Schema.String,50 baseRelativePath: Schema.String,
53 expiresAt: Schema.Number,51 expiresAt: Schema.Number,
54 }),52 }),
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 }),
55 Schema.Struct({60 Schema.Struct({
56 version: Schema.Literal(1),61 version: Schema.Literal(1),
57 kind: Schema.Literal("attachment"),62 kind: Schema.Literal("attachment"),
...@@ -144,8 +149,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -144,8 +149,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
144 const resolved = yield* workspacePaths149 const resolved = yield* workspacePaths
145 .resolveRelativePathWithinRoot({ workspaceRoot, relativePath })150 .resolveRelativePathWithinRoot({ workspaceRoot, relativePath })
146 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));151 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
147 if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) {152 if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {
148 return yield* failAccess("Only HTML and PDF files can open in the browser.");153 return yield* failAccess("Only browser documents and images can be previewed.");
149 }154 }
150 const canonicalFile = yield* resolveCanonicalWorkspaceFile({155 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
151 workspaceRoot,156 workspaceRoot,
...@@ -154,15 +159,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -154,15 +159,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
154 if (!canonicalFile) {159 if (!canonicalFile) {
155 return yield* failAccess("Workspace asset was not found.");160 return yield* failAccess("Workspace asset was not found.");
156 }161 }
157 claims = {162 const canonicalWorkspaceRoot = yield* fileSystem
158 version: 1,163 .realPath(workspaceRoot)
159 kind: "workspace-file",164 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause)));
160 workspaceRoot: yield* fileSystem165 claims = isWorkspaceImagePreviewPath(resolved.relativePath)
161 .realPath(workspaceRoot)166 ? {
162 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),167 version: 1,
163 baseRelativePath: path.dirname(resolved.relativePath),168 kind: "workspace-file-exact",
164 expiresAt,169 workspaceRoot: canonicalWorkspaceRoot,
165 };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 };
166 fileName = path.basename(resolved.relativePath);180 fileName = path.basename(resolved.relativePath);
167 break;181 break;
168 }182 }
...@@ -268,6 +282,16 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -268,6 +282,16 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
268 const decodedPath = decodeRelativePath(relativePath);282 const decodedPath = decodeRelativePath(relativePath);
269 if (decodedPath === null) return null;283 if (decodedPath === null) return null;
270 const path = yield* Path.Path;284 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 }
271 const segments = decodedPath.split(/[\\/]/);295 const segments = decodedPath.split(/[\\/]/);
272 if (296 if (
273 decodedPath.length === 0 ||297 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";...@@ -9,7 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
99
10import { useActiveBrowserRecordingTabId } from "./browserRecording";10import { useActiveBrowserRecordingTabId } from "./browserRecording";
11import { useBrowserSurfaceStore } from "./browserSurfaceStore";11import { useBrowserSurfaceStore } from "./browserSurfaceStore";
12import { acquireDesktopTab } from "./desktopTabLifetime";12import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime";
13import { usePreviewWebviewConfig } from "./previewWebviewConfigState";13import { usePreviewWebviewConfig } from "./previewWebviewConfigState";
1414
15interface ElectronWebview extends HTMLElement {15interface ElectronWebview extends HTMLElement {
...@@ -34,13 +34,21 @@ export function HostedBrowserWebview(props: {...@@ -34,13 +34,21 @@ export function HostedBrowserWebview(props: {
34 const { threadRef, tabId, initialUrl } = props;34 const { threadRef, tabId, initialUrl } = props;
35 const config = usePreviewWebviewConfig(threadRef.environmentId);35 const config = usePreviewWebviewConfig(threadRef.environmentId);
36 const initialSrcRef = useRef(initialUrl ?? "about:blank");36 const initialSrcRef = useRef(initialUrl ?? "about:blank");
37 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
37 const webviewRef = useRef<ElectronWebview | null>(null);38 const webviewRef = useRef<ElectronWebview | null>(null);
38 const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null));39 const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null));
39 const recording = useActiveBrowserRecordingTabId() === tabId;40 const recording = useActiveBrowserRecordingTabId() === tabId;
4041
41 usePreviewBridge({ threadRef, tabId });42 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
45 const setWebviewRef = useCallback((node: HTMLElement | null) => {53 const setWebviewRef = useCallback((node: HTMLElement | null) => {
46 webviewRef.current = node as ElectronWebview | null;54 webviewRef.current = node as ElectronWebview | null;
...@@ -51,19 +59,34 @@ export function HostedBrowserWebview(props: {...@@ -51,19 +59,34 @@ export function HostedBrowserWebview(props: {
51 const webview = webviewRef.current;59 const webview = webviewRef.current;
52 const bridge = previewBridge;60 const bridge = previewBridge;
53 if (!webview || !config || !bridge) return;61 if (!webview || !config || !bridge) return;
62 let disposed = false;
54 const register = () => {63 const register = () => {
55 try {64 const lease = tabLeaseRef.current;
56 const webContentsId = webview.getWebContentsId();65 if (!lease) return;
57 if (Number.isInteger(webContentsId) && webContentsId > 0) {66 void (async () => {
58 void bridge.registerWebview(tabId, webContentsId);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.
59 }79 }
60 } catch {80 })();
61 // A later dom-ready will retry registration.
62 }
63 };81 };
82 webview.addEventListener("did-attach", register);
64 webview.addEventListener("dom-ready", register);83 webview.addEventListener("dom-ready", register);
65 register();84 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 };
67 }, [config, tabId]);90 }, [config, tabId]);
6891
69 if (!config) return null;92 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";...@@ -3,28 +3,42 @@ import { previewBridge } from "~/components/preview/previewBridge";
3interface DesktopTabLease {3interface DesktopTabLease {
4 references: number;4 references: number;
5 closeTimer: number | null;5 closeTimer: number | null;
6 ready: Promise<void>;
6}7}
78
8const leases = new Map<string, DesktopTabLease>();9const leases = new Map<string, DesktopTabLease>();
910
10export function acquireDesktopTab(tabId: string): () => void {11export interface AcquiredDesktopTab {
11 const current = leases.get(tabId) ?? { references: 0, closeTimer: null };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);
12 if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);24 if (current.closeTimer !== null) window.clearTimeout(current.closeTimer);
13 current.references += 1;25 current.references += 1;
14 current.closeTimer = null;26 current.closeTimer = null;
15 leases.set(tabId, current);27 leases.set(tabId, current);
16 if (current.references === 1) void previewBridge?.createTab(tabId);
1728
18 return () => {29 return {
19 const lease = leases.get(tabId);30 ready: current.ready,
20 if (!lease) return;31 release: () => {
21 lease.references = Math.max(0, lease.references - 1);32 const lease = leases.get(tabId);
22 if (lease.references > 0) return;33 if (!lease) return;
23 lease.closeTimer = window.setTimeout(() => {34 lease.references = Math.max(0, lease.references - 1);
24 const latest = leases.get(tabId);35 if (lease.references > 0) return;
25 if (!latest || latest.references > 0) return;36 lease.closeTimer = window.setTimeout(() => {
26 leases.delete(tabId);37 const latest = leases.get(tabId);
27 void previewBridge?.closeTab(tabId);38 if (!latest || latest.references > 0) return;
28 }, 0);39 leases.delete(tabId);
40 void previewBridge?.closeTab(tabId);
41 }, 0);
42 },
29 };43 };
30}44}
apps/web/src/components/ChatView.tsx+17-24
...@@ -111,12 +111,12 @@ import {...@@ -111,12 +111,12 @@ import {
111 useRightPanelStore,111 useRightPanelStore,
112} from "../rightPanelStore";112} from "../rightPanelStore";
113import {113import {
114 applyPreviewServerSnapshot,
115 isPreviewSupportedInRuntime,114 isPreviewSupportedInRuntime,
116 removePreviewSession,
117 setActivePreviewTab,115 setActivePreviewTab,
118 useThreadPreviewState,116 useThreadPreviewState,
119} from "../previewStateStore";117} from "../previewStateStore";
118import { addBrowserSurface } from "./preview/addBrowserSurface";
119import { closePreviewSession } from "./preview/closePreviewSession";
120import { subscribePreviewAction } from "./preview/previewActionBus";120import { subscribePreviewAction } from "./preview/previewActionBus";
121import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";121import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
122import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";122import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
...@@ -2746,23 +2746,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2746,23 +2746,8 @@ function ChatViewContent(props: ChatViewProps) {
2746 }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]);2746 }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]);
2747 const createBrowserSurface = useCallback(() => {2747 const createBrowserSurface = useCallback(() => {
2748 if (!activeThreadRef) return;2748 if (!activeThreadRef) return;
2749 const activeTabId = activePreviewState.activeTabId;2749 void addBrowserSurface({ threadRef: activeThreadRef, openPreview });
2750 if (activeTabId) {2750 }, [activeThreadRef, openPreview]);
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]);
2766 const addDiffSurface = useCallback(() => {2751 const addDiffSurface = useCallback(() => {
2767 if (!activeThreadRef || !isServerThread || !isGitRepo) return;2752 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
2768 useRightPanelStore.getState().open(activeThreadRef, "diff");2753 useRightPanelStore.getState().open(activeThreadRef, "diff");
...@@ -2807,8 +2792,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2807,8 +2792,14 @@ function ChatViewContent(props: ChatViewProps) {
2807 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),2792 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
2808 });2793 });
2809 }2794 }
2810 createBrowserSurface();2795 const activeTabId = activePreviewState.activeTabId;
2796 if (activeTabId) {
2797 useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);
2798 } else {
2799 createBrowserSurface();
2800 }
2811 }, [2801 }, [
2802 activePreviewState.activeTabId,
2812 activeThreadRef,2803 activeThreadRef,
2813 createBrowserSurface,2804 createBrowserSurface,
2814 diffOpen,2805 diffOpen,
...@@ -2993,10 +2984,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2993,10 +2984,11 @@ function ChatViewContent(props: ChatViewProps) {
29932984
2994 for (const surface of surfaces) {2985 for (const surface of surfaces) {
2995 if (surface.kind === "preview" && surface.resourceId) {2986 if (surface.kind === "preview" && surface.resourceId) {
2996 removePreviewSession(activeThreadRef, surface.resourceId);2987 void closePreviewSession({
2997 void closePreview({2988 closePreview,
2998 environmentId: activeThreadRef.environmentId,2989 snapshot: activePreviewState.sessions[surface.resourceId] ?? null,
2999 input: { threadId: activeThreadRef.threadId, tabId: surface.resourceId },2990 tabId: surface.resourceId,
2991 threadRef: activeThreadRef,
3000 });2992 });
3001 }2993 }
3002 if (surface.kind === "terminal") {2994 if (surface.kind === "terminal") {
...@@ -3020,6 +3012,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3020,6 +3012,7 @@ function ChatViewContent(props: ChatViewProps) {
3020 },3012 },
3021 [3013 [
3022 activeThreadRef,3014 activeThreadRef,
3015 activePreviewState.sessions,
3023 closePreview,3016 closePreview,
3024 closeTerminalMutation,3017 closeTerminalMutation,
3025 diffOpen,3018 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 = {...@@ -28,6 +28,24 @@ const snapshot: PreviewSessionSnapshot = {
28beforeEach(resetPreviewStateForTests);28beforeEach(resetPreviewStateForTests);
2929
30describe("openPreviewSession", () => {30describe("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
31 it("applies the RPC response without waiting for a preview event", async () => {49 it("applies the RPC response without waiting for a preview event", async () => {
32 const open = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(snapshot));50 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> {...@@ -14,7 +14,7 @@ interface OpenPreviewSessionInput<E> {
14 readonly input: PreviewOpenInput;14 readonly input: PreviewOpenInput;
15 }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;15 }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>;
16 threadRef: ScopedThreadRef;16 threadRef: ScopedThreadRef;
17 url: string;17 url?: string;
18}18}
1919
20export async function openPreviewSession<E>(20export async function openPreviewSession<E>(
...@@ -24,7 +24,7 @@ export async function openPreviewSession<E>(...@@ -24,7 +24,7 @@ export async function openPreviewSession<E>(
24 environmentId: input.threadRef.environmentId,24 environmentId: input.threadRef.environmentId,
25 input: {25 input: {
26 threadId: input.threadRef.threadId,26 threadId: input.threadRef.threadId,
27 url: input.url,27 ...(input.url === undefined ? {} : { url: input.url }),
28 },28 },
29 });29 });
30 if (result._tag === "Failure") {30 if (result._tag === "Failure") {
...@@ -32,9 +32,11 @@ export async function openPreviewSession<E>(...@@ -32,9 +32,11 @@ export async function openPreviewSession<E>(
32 }32 }
33 const snapshot = result.value;33 const snapshot = result.value;
34 applyPreviewServerSnapshot(input.threadRef, snapshot);34 applyPreviewServerSnapshot(input.threadRef, snapshot);
35 rememberPreviewUrl(35 if (input.url !== undefined) {
36 input.threadRef,36 rememberPreviewUrl(
37 snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url,37 input.threadRef,
38 );38 snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url,
39 );
40 }
39 return result;41 return result;
40}42}
apps/web/src/lib/archivedThreadsState.ts+6-39
...@@ -1,53 +1,15 @@...@@ -1,53 +1,15 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import {2import {
3 type ArchivedSnapshotEntry,3 type ArchivedSnapshotEntry,
4 createArchivedThreadSnapshotsAtomFamily,
4 makeArchivedThreadsEnvironmentKey,5 makeArchivedThreadsEnvironmentKey,
5 parseArchivedThreadsEnvironmentKey,
6} from "@t3tools/client-runtime/state/threads";6} from "@t3tools/client-runtime/state/threads";
7import type { EnvironmentId } from "@t3tools/contracts";7import type { EnvironmentId } from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Option from "effect/Option";
10import { AsyncResult, Atom } from "effect/unstable/reactivity";
11import { useCallback, useMemo } from "react";8import { useCallback, useMemo } from "react";
129
13import { orchestrationEnvironment } from "../state/orchestration";10import { orchestrationEnvironment } from "../state/orchestration";
14import { appAtomRegistry } from "../rpc/atomRegistry";11import { 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
51function archivedSnapshotAtom(environmentId: EnvironmentId) {13function archivedSnapshotAtom(environmentId: EnvironmentId) {
52 return orchestrationEnvironment.archivedShellSnapshot({14 return orchestrationEnvironment.archivedShellSnapshot({
53 environmentId,15 environmentId,
...@@ -55,6 +17,11 @@ function archivedSnapshotAtom(environmentId: EnvironmentId) {...@@ -55,6 +17,11 @@ function archivedSnapshotAtom(environmentId: EnvironmentId) {
55 });17 });
56}18}
5719
20const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({
21 getSnapshotAtom: archivedSnapshotAtom,
22 labelPrefix: "web:archived-thread-snapshots",
23});
24
58export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {25export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {
59 appAtomRegistry.refresh(archivedSnapshotAtom(environmentId));26 appAtomRegistry.refresh(archivedSnapshotAtom(environmentId));
60}27}
apps/web/src/lib/threadSort.ts+7-86
...@@ -1,86 +1,7 @@...@@ -1,86 +1,7 @@
1import type { ProjectId } from "@t3tools/contracts";1export {
2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";2 getLatestThreadForProject,
3import type { Thread } from "../types";3 getThreadSortTimestamp,
44 sortThreads,
5export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & {5 toSortableTimestamp,
6 latestUserMessageAt?: string | null;6 type ThreadSortInput,
7 messages?: ReadonlyArray<Pick<Thread["messages"][number], "createdAt" | "role">>;7} from "@t3tools/client-runtime/state/thread-sort";
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}
apps/web/src/logicalProject.ts+14-184
...@@ -1,184 +1,14 @@...@@ -1,184 +1,14 @@
1import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";1export {
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";2 deriveLogicalProjectKey,
3import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";3 deriveLogicalProjectKeyFromRef,
4import type { UnifiedSettings } from "@t3tools/contracts/settings";4 deriveLogicalProjectKeyFromSettings,
5import { normalizeProjectPathForComparison } from "./lib/projectPaths";5 derivePhysicalProjectKey,
66 derivePhysicalProjectKeyFromPath,
7export interface ProjectGroupingSettings {7 deriveProjectGroupLabel,
8 sidebarProjectGroupingMode: SidebarProjectGroupingMode;8 deriveProjectGroupingOverrideKey,
9 sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>;9 getProjectOrderKey,
10}10 resolveProjectGroupingMode,
1111 selectProjectGroupingSettings,
12export type ProjectGroupingMode = SidebarProjectGroupingMode;12 type ProjectGroupingMode,
1313 type ProjectGroupingSettings,
14export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings {14} from "@t3tools/client-runtime/state/project-grouping";
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}
apps/web/src/main.tsx+2-8
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import React from "react";1import React from "react";
2import ReactDOM from "react-dom/client";2import ReactDOM from "react-dom/client";
3import { ClerkProvider } from "@clerk/react";3import { ClerkProvider } from "@clerk/react";
4import { RouterProvider } from "@tanstack/react-router";
5import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";4import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
65
7import "@fontsource-variable/dm-sans/index.css";6import "@fontsource-variable/dm-sans/index.css";
...@@ -17,7 +16,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig";...@@ -17,7 +16,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig";
17import { getRouter } from "./router";16import { getRouter } from "./router";
18import { APP_DISPLAY_NAME } from "./branding";17import { APP_DISPLAY_NAME } from "./branding";
19import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";18import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
20import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";19import { AppRoot } from "./AppRoot";
2120
22// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.21// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
23const history = isElectron ? createHashHistory() : createBrowserHistory();22const history = isElectron ? createHashHistory() : createBrowserHistory();
...@@ -32,12 +31,7 @@ document.title = APP_DISPLAY_NAME;...@@ -32,12 +31,7 @@ document.title = APP_DISPLAY_NAME;
3231
33const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;32const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
3433
35const app = (34const app = <AppRoot router={router} />;
36 <>
37 <RouterProvider router={router} />
38 <ElectronBrowserHost />
39 </>
40);
4135
42ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(36ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
43 <React.StrictMode>37 <React.StrictMode>
apps/web/src/previewStateStore.test.ts+28-3
...@@ -7,10 +7,11 @@ import {...@@ -7,10 +7,11 @@ import {
7 applyPreviewDesktopState,7 applyPreviewDesktopState,
8 applyPreviewServerEvent,8 applyPreviewServerEvent,
9 applyPreviewServerSnapshot,9 applyPreviewServerSnapshot,
10 beginPreviewSessionClose,
11 cancelPreviewSessionClose,
10 previewStateAtom,12 previewStateAtom,
11 readThreadPreviewState,13 readThreadPreviewState,
12 rememberPreviewUrl,14 rememberPreviewUrl,
13 removePreviewSession,
14 removePreviewThread,15 removePreviewThread,
15 resetPreviewStateForTests,16 resetPreviewStateForTests,
16 setActivePreviewTab,17 setActivePreviewTab,
...@@ -182,7 +183,7 @@ describe("previewStateStore (single-tab)", () => {...@@ -182,7 +183,7 @@ describe("previewStateStore (single-tab)", () => {
182 applyPreviewServerSnapshot(ref, first);183 applyPreviewServerSnapshot(ref, first);
183 applyPreviewServerSnapshot(ref, second);184 applyPreviewServerSnapshot(ref, second);
184185
185 removePreviewSession(ref, second.tabId);186 beginPreviewSessionClose(ref, second.tabId);
186187
187 const state = readThreadPreviewState(ref);188 const state = readThreadPreviewState(ref);
188 expect(Object.keys(state.sessions)).toEqual([first.tabId]);189 expect(Object.keys(state.sessions)).toEqual([first.tabId]);
...@@ -193,7 +194,7 @@ describe("previewStateStore (single-tab)", () => {...@@ -193,7 +194,7 @@ describe("previewStateStore (single-tab)", () => {
193 it("treats a late server close event after optimistic removal as a no-op", () => {194 it("treats a late server close event after optimistic removal as a no-op", () => {
194 const snapshot = makeSnapshot();195 const snapshot = makeSnapshot();
195 applyPreviewServerSnapshot(ref, snapshot);196 applyPreviewServerSnapshot(ref, snapshot);
196 removePreviewSession(ref, snapshot.tabId);197 beginPreviewSessionClose(ref, snapshot.tabId);
197198
198 applyPreviewServerEvent(ref, {199 applyPreviewServerEvent(ref, {
199 type: "closed",200 type: "closed",
...@@ -207,6 +208,30 @@ describe("previewStateStore (single-tab)", () => {...@@ -207,6 +208,30 @@ describe("previewStateStore (single-tab)", () => {
207 expect(state.snapshot).toBeNull();208 expect(state.snapshot).toBeNull();
208 });209 });
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
210 it("closed event for a different tab is a no-op", () => {235 it("closed event for a different tab is a no-op", () => {
211 const snapshot = makeSnapshot({ tabId: "tab_a" });236 const snapshot = makeSnapshot({ tabId: "tab_a" });
212 applyPreviewServerEvent(ref, {237 applyPreviewServerEvent(ref, {
apps/web/src/previewStateStore.ts+42-2
...@@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay {...@@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay {
28export interface ThreadPreviewState {28export interface ThreadPreviewState {
29 snapshot: PreviewSessionSnapshot | null;29 snapshot: PreviewSessionSnapshot | null;
30 sessions: Record<string, PreviewSessionSnapshot>;30 sessions: Record<string, PreviewSessionSnapshot>;
31 /** Tabs intentionally closed by this client. Stale list snapshots must not resurrect them. */
32 suppressedTabIds: ReadonlySet<string>;
31 activeTabId: string | null;33 activeTabId: string | null;
32 desktopOverlay: DesktopPreviewOverlay | null;34 desktopOverlay: DesktopPreviewOverlay | null;
33 desktopByTabId: Record<string, DesktopPreviewOverlay>;35 desktopByTabId: Record<string, DesktopPreviewOverlay>;
...@@ -37,6 +39,7 @@ export interface ThreadPreviewState {...@@ -37,6 +39,7 @@ export interface ThreadPreviewState {
37const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({39const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
38 snapshot: null,40 snapshot: null,
39 sessions: {},41 sessions: {},
42 suppressedTabIds: new Set<string>(),
40 activeTabId: null,43 activeTabId: null,
41 desktopOverlay: null,44 desktopOverlay: null,
42 desktopByTabId: {},45 desktopByTabId: {},
...@@ -162,6 +165,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven...@@ -162,6 +165,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven
162 case "opened":165 case "opened":
163 case "navigated": {166 case "navigated": {
164 const snapshot = event.snapshot;167 const snapshot = event.snapshot;
168 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
165 const recentlySeenUrls =169 const recentlySeenUrls =
166 snapshot.navStatus._tag === "Idle"170 snapshot.navStatus._tag === "Idle"
167 ? current.recentlySeenUrls171 ? current.recentlySeenUrls
...@@ -221,6 +225,7 @@ export function applyPreviewServerSnapshot(...@@ -221,6 +225,7 @@ export function applyPreviewServerSnapshot(
221 desktopByTabId: {},225 desktopByTabId: {},
222 };226 };
223 }227 }
228 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
224 const existing = current.sessions[snapshot.tabId];229 const existing = current.sessions[snapshot.tabId];
225 if (existing && existing.updatedAt > snapshot.updatedAt) return current;230 if (existing && existing.updatedAt > snapshot.updatedAt) return current;
226 const recentlySeenUrls =231 const recentlySeenUrls =
...@@ -255,8 +260,43 @@ export function applyPreviewDesktopState(...@@ -255,8 +260,43 @@ export function applyPreviewDesktopState(
255 });260 });
256}261}
257262
258export function removePreviewSession(ref: ScopedThreadRef, tabId: string): void {263export function beginPreviewSessionClose(ref: ScopedThreadRef, tabId: string): void {
259 updateThreadPreviewState(ref, (current) => removeSession(current, tabId));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 });
260}300}
261301
262export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void {302export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void {
apps/web/src/router.ts-3
...@@ -1,7 +1,5 @@...@@ -1,7 +1,5 @@
1import { createElement } from "react";
2import { createRouter, RouterHistory } from "@tanstack/react-router";1import { createRouter, RouterHistory } from "@tanstack/react-router";
32
4import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
5import { routeTree } from "./routeTree.gen";3import { routeTree } from "./routeTree.gen";
64
7export function getRouter(history: RouterHistory) {5export function getRouter(history: RouterHistory) {
...@@ -9,7 +7,6 @@ export function getRouter(history: RouterHistory) {...@@ -9,7 +7,6 @@ export function getRouter(history: RouterHistory) {
9 routeTree,7 routeTree,
10 history,8 history,
11 context: {},9 context: {},
12 Wrap: ({ children }) => createElement(AppAtomRegistryProvider, undefined, children),
13 });10 });
14}11}
1512
packages/client-runtime/package.json+8
...@@ -83,6 +83,10 @@...@@ -83,6 +83,10 @@
83 "types": "./src/state/projects.ts",83 "types": "./src/state/projects.ts",
84 "default": "./src/state/projects.ts"84 "default": "./src/state/projects.ts"
85 },85 },
86 "./state/project-grouping": {
87 "types": "./src/state/projectGrouping.ts",
88 "default": "./src/state/projectGrouping.ts"
89 },
86 "./state/relay": {90 "./state/relay": {
87 "types": "./src/state/relayDiscovery.ts",91 "types": "./src/state/relayDiscovery.ts",
88 "default": "./src/state/relayDiscovery.ts"92 "default": "./src/state/relayDiscovery.ts"
...@@ -119,6 +123,10 @@...@@ -119,6 +123,10 @@
119 "types": "./src/state/threads.ts",123 "types": "./src/state/threads.ts",
120 "default": "./src/state/threads.ts"124 "default": "./src/state/threads.ts"
121 },125 },
126 "./state/thread-sort": {
127 "types": "./src/state/threadSort.ts",
128 "default": "./src/state/threadSort.ts"
129 },
122 "./state/vcs": {130 "./state/vcs": {
123 "types": "./src/state/vcs.ts",131 "types": "./src/state/vcs.ts",
124 "default": "./src/state/vcs.ts"132 "default": "./src/state/vcs.ts"
packages/client-runtime/src/state/archivedThreads.ts+44
...@@ -1,13 +1,22 @@...@@ -1,13 +1,22 @@
1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
2import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
3import * as Cause from "effect/Cause";
3import { pipe } from "effect/Function";4import { pipe } from "effect/Function";
5import * as Option from "effect/Option";
4import * as Order from "effect/Order";6import * as Order from "effect/Order";
7import { AsyncResult, Atom } from "effect/unstable/reactivity";
58
6export interface ArchivedSnapshotEntry {9export interface ArchivedSnapshotEntry {
7 readonly environmentId: EnvironmentId;10 readonly environmentId: EnvironmentId;
8 readonly snapshot: OrchestrationShellSnapshot;11 readonly snapshot: OrchestrationShellSnapshot;
9}12}
1013
14export interface ArchivedThreadSnapshotsState {
15 readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
16 readonly error: string | null;
17 readonly isLoading: boolean;
18}
19
11const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f";20const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f";
12const environmentIdOrder = Order.String as Order.Order<EnvironmentId>;21const environmentIdOrder = Order.String as Order.Order<EnvironmentId>;
1322
...@@ -28,3 +37,38 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<E...@@ -28,3 +37,38 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<E
28 Arr.map((environmentId) => EnvironmentId.make(environmentId)),37 Arr.map((environmentId) => EnvironmentId.make(environmentId)),
29 );38 );
30}39}
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 @@...@@ -163,6 +163,10 @@
163 "types": "./src/preview.ts",163 "types": "./src/preview.ts",
164 "import": "./src/preview.ts"164 "import": "./src/preview.ts"
165 },165 },
166 "./filePreview": {
167 "types": "./src/filePreview.ts",
168 "import": "./src/filePreview.ts"
169 },
166 "./hostProcess": {170 "./hostProcess": {
167 "types": "./src/hostProcess.ts",171 "types": "./src/hostProcess.ts",
168 "import": "./src/hostProcess.ts"172 "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:...@@ -247,7 +247,7 @@ importers:
247 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)247 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
248 expo:248 expo:
249 specifier: ^56.0.0249 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)
251 expo-asset:251 expo-asset:
252 specifier: ~56.0.15252 specifier: ~56.0.15
253 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)253 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:...@@ -362,6 +362,9 @@ importers:
362 react-native-svg:362 react-native-svg:
363 specifier: 15.15.4363 specifier: 15.15.4
364 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)364 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)
365 react-native-worklets:368 react-native-worklets:
366 specifier: 0.8.3369 specifier: 0.8.3
367 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)370 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:...@@ -8453,6 +8456,12 @@ packages:
8453 peerDependencies:8456 peerDependencies:
8454 react-native: '*'8457 react-native: '*'
84558458
8459 react-native-webview@13.16.1:
8460 resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==}
8461 peerDependencies:
8462 react: '*'
8463 react-native: '*'
8464
8456 react-native-worklets@0.8.3:8465 react-native-worklets@0.8.3:
8457 resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==}8466 resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==}
8458 peerDependencies:8467 peerDependencies:
...@@ -10784,7 +10793,7 @@ snapshots:...@@ -10784,7 +10793,7 @@ snapshots:
10784 '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)10793 '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10785 '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)10794 '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10786 base-64: 1.0.010795 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)
10788 react: 19.2.310797 react: 19.2.3
10789 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)10798 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
10790 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))10799 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:...@@ -11396,7 +11405,7 @@ snapshots:
11396 connect: 3.7.011405 connect: 3.7.0
11397 debug: 4.4.311406 debug: 4.4.3
11398 dnssd-advertise: 1.1.411407 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)
11400 expo-server: 56.0.411409 expo-server: 56.0.4
11401 fetch-nodeshim: 0.4.1011410 fetch-nodeshim: 0.4.10
11402 getenv: 2.0.011411 getenv: 2.0.0
...@@ -11494,7 +11503,7 @@ snapshots:...@@ -11494,7 +11503,7 @@ snapshots:
1149411503
11495 '@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)':11504 '@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)':
11496 dependencies:11505 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)
11498 react: 19.2.311507 react: 19.2.3
11499 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)11508 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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:...@@ -11561,7 +11570,7 @@ snapshots:
11561 dependencies:11570 dependencies:
11562 '@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)11571 '@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)
11563 anser: 1.4.1011572 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)
11565 react: 19.2.311574 react: 19.2.3
11566 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)11575 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
11567 stacktrace-parser: 0.1.1111576 stacktrace-parser: 0.1.11
...@@ -11593,7 +11602,7 @@ snapshots:...@@ -11593,7 +11602,7 @@ snapshots:
11593 postcss: 8.5.1511602 postcss: 8.5.15
11594 resolve-from: 5.0.011603 resolve-from: 5.0.0
11595 optionalDependencies:11604 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)
11597 transitivePeerDependencies:11606 transitivePeerDependencies:
11598 - bufferutil11607 - bufferutil
11599 - supports-color11608 - supports-color
...@@ -11615,7 +11624,7 @@ snapshots:...@@ -11615,7 +11624,7 @@ snapshots:
11615 dependencies:11624 dependencies:
11616 '@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)11625 '@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)
11617 anser: 1.4.1011626 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)
11619 pretty-format: 29.7.011628 pretty-format: 29.7.0
11620 react: 19.2.311629 react: 19.2.3
11621 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)11630 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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:...@@ -11693,7 +11702,7 @@ snapshots:
11693 '@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)':11702 '@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)':
11694 dependencies:11703 dependencies:
11695 debug: 4.4.311704 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)
11697 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))11706 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))
11698 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)11707 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)
11699 expo-server: 56.0.411708 expo-server: 56.0.4
...@@ -11717,7 +11726,7 @@ snapshots:...@@ -11717,7 +11726,7 @@ snapshots:
1171711726
11718 '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)':11727 '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)':
11719 dependencies:11728 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)
11721 react: 19.2.311730 react: 19.2.3
11722 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)11731 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
11723 sf-symbols-typescript: 2.2.011732 sf-symbols-typescript: 2.2.0
...@@ -14450,7 +14459,7 @@ snapshots:...@@ -14450,7 +14459,7 @@ snapshots:
14450 react-refresh: 0.14.214459 react-refresh: 0.14.2
14451 optionalDependencies:14460 optionalDependencies:
14452 '@babel/runtime': 7.29.714461 '@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)
14454 expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584)14463 expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584)
14455 transitivePeerDependencies:14464 transitivePeerDependencies:
14456 - '@babel/core'14465 - '@babel/core'
...@@ -15316,12 +15325,12 @@ snapshots:...@@ -15316,12 +15325,12 @@ snapshots:
1531615325
15317 expo-application@56.0.3(expo@56.0.8):15326 expo-application@56.0.3(expo@56.0.8):
15318 dependencies:15327 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
15321 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):15330 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):
15322 dependencies:15331 dependencies:
15323 '@expo/image-utils': 0.10.1(typescript@6.0.3)15332 '@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)
15325 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))15334 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))
15326 react: 19.2.315335 react: 19.2.3
15327 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15336 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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:...@@ -15346,14 +15355,14 @@ snapshots:
15346 expo-build-properties@56.0.16(expo@56.0.8):15355 expo-build-properties@56.0.16(expo@56.0.8):
15347 dependencies:15356 dependencies:
15348 '@expo/schema-utils': 56.0.115357 '@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)
15350 resolve-from: 5.0.015359 resolve-from: 5.0.0
15351 semver: 7.8.115360 semver: 7.8.1
1535215361
15353 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):15362 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):
15354 dependencies:15363 dependencies:
15355 barcode-detector: 3.2.0(@types/emscripten@1.41.5)15364 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)
15357 react: 19.2.315366 react: 19.2.3
15358 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15367 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
15359 transitivePeerDependencies:15368 transitivePeerDependencies:
...@@ -15361,25 +15370,25 @@ snapshots:...@@ -15361,25 +15370,25 @@ snapshots:
1536115370
15362 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):15371 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):
15363 dependencies:15372 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)
15365 react: 19.2.315374 react: 19.2.3
15366 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15375 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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
15368 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)):15377 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)):
15369 dependencies:15378 dependencies:
15370 '@expo/env': 2.3.015379 '@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)
15372 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15381 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
15373 transitivePeerDependencies:15382 transitivePeerDependencies:
15374 - supports-color15383 - supports-color
1537515384
15376 expo-crypto@56.0.4(expo@56.0.8):15385 expo-crypto@56.0.4(expo@56.0.8):
15377 dependencies:15386 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
15380 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)):15389 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)):
15381 dependencies:15390 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)
15383 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))15392 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))
15384 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))15393 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))
15385 expo-dev-menu-interface: 56.0.1(expo@56.0.8)15394 expo-dev-menu-interface: 56.0.1(expo@56.0.8)
...@@ -15391,18 +15400,18 @@ snapshots:...@@ -15391,18 +15400,18 @@ snapshots:
15391 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)):15400 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)):
15392 dependencies:15401 dependencies:
15393 '@expo/schema-utils': 56.0.115402 '@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)
15395 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))15404 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))
15396 expo-manifests: 56.0.4(expo@56.0.8)15405 expo-manifests: 56.0.4(expo@56.0.8)
15397 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15406 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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
15399 expo-dev-menu-interface@56.0.1(expo@56.0.8):15408 expo-dev-menu-interface@56.0.1(expo@56.0.8):
15400 dependencies:15409 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
15403 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)):15412 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)):
15404 dependencies:15413 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)
15406 expo-dev-menu-interface: 56.0.1(expo@56.0.8)15415 expo-dev-menu-interface: 56.0.1(expo@56.0.8)
15407 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15416 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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:...@@ -15410,40 +15419,40 @@ snapshots:
1541015419
15411 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)):15420 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)):
15412 dependencies:15421 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)
15414 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15423 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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
15416 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):15425 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):
15417 dependencies:15426 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)
15419 fontfaceobserver: 2.3.015428 fontfaceobserver: 2.3.0
15420 react: 19.2.315429 react: 19.2.3
15421 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15430 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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
15423 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):15432 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):
15424 dependencies:15433 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)
15426 react: 19.2.315435 react: 19.2.3
15427 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15436 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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
15429 expo-haptics@56.0.3(expo@56.0.8):15438 expo-haptics@56.0.3(expo@56.0.8):
15430 dependencies:15439 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
15433 expo-image-loader@56.0.3(expo@56.0.8):15442 expo-image-loader@56.0.3(expo@56.0.8):
15434 dependencies:15443 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
15437 expo-image-picker@56.0.15(expo@56.0.8):15446 expo-image-picker@56.0.15(expo@56.0.8):
15438 dependencies:15447 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)
15440 expo-image-loader: 56.0.3(expo@56.0.8)15449 expo-image-loader: 56.0.3(expo@56.0.8)
1544115450
15442 expo-json-utils@56.0.0: {}15451 expo-json-utils@56.0.0: {}
1544315452
15444 expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3):15453 expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3):
15445 dependencies:15454 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)
15447 react: 19.2.315456 react: 19.2.3
1544815457
15449 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 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:...@@ -15458,7 +15467,7 @@ snapshots:
1545815467
15459 expo-manifests@56.0.4(expo@56.0.8):15468 expo-manifests@56.0.4(expo@56.0.8):
15460 dependencies:15469 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)
15462 expo-json-utils: 56.0.015471 expo-json-utils: 56.0.0
1546315472
15464 expo-modules-autolinking@56.0.14(typescript@6.0.3):15473 expo-modules-autolinking@56.0.14(typescript@6.0.3):
...@@ -15487,7 +15496,7 @@ snapshots:...@@ -15487,7 +15496,7 @@ snapshots:
1548715496
15488 expo-network@56.0.5(expo@56.0.8)(react@19.2.3):15497 expo-network@56.0.5(expo@56.0.8)(react@19.2.3):
15489 dependencies:15498 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)
15491 react: 19.2.315500 react: 19.2.3
1549215501
15493 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):15502 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:...@@ -15495,7 +15504,7 @@ snapshots:
15495 '@expo/image-utils': 0.10.1(typescript@6.0.3)15504 '@expo/image-utils': 0.10.1(typescript@6.0.3)
15496 abort-controller: 3.0.015505 abort-controller: 3.0.0
15497 badgin: 1.2.315506 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)
15499 expo-application: 56.0.3(expo@56.0.8)15508 expo-application: 56.0.3(expo@56.0.8)
15500 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))15509 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))
15501 react: 19.2.315510 react: 19.2.3
...@@ -15506,7 +15515,7 @@ snapshots:...@@ -15506,7 +15515,7 @@ snapshots:
1550615515
15507 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):15516 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):
15508 dependencies:15517 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)
15510 react: 19.2.315519 react: 19.2.3
15511 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15520 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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:...@@ -15525,7 +15534,7 @@ snapshots:
15525 color: 4.2.315534 color: 4.2.3
15526 debug: 4.4.315535 debug: 4.4.3
15527 escape-string-regexp: 4.0.015536 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)
15529 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))15538 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))
15530 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)15539 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)
15531 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)15540 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:...@@ -15561,7 +15570,7 @@ snapshots:
1556115570
15562 expo-secure-store@56.0.4(expo@56.0.8):15571 expo-secure-store@56.0.4(expo@56.0.8):
15563 dependencies:15572 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
15566 expo-server@56.0.4: {}15575 expo-server@56.0.4: {}
1556715576
...@@ -15569,7 +15578,7 @@ snapshots:...@@ -15569,7 +15578,7 @@ snapshots:
15569 dependencies:15578 dependencies:
15570 '@expo/config-plugins': 56.0.8(typescript@6.0.3)15579 '@expo/config-plugins': 56.0.8(typescript@6.0.3)
15571 '@expo/image-utils': 0.10.1(typescript@6.0.3)15580 '@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)
15573 xml2js: 0.6.015582 xml2js: 0.6.0
15574 transitivePeerDependencies:15583 transitivePeerDependencies:
15575 - supports-color15584 - supports-color
...@@ -15580,7 +15589,7 @@ snapshots:...@@ -15580,7 +15589,7 @@ snapshots:
15580 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):15589 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):
15581 dependencies:15590 dependencies:
15582 '@expo-google-fonts/material-symbols': 0.4.3815591 '@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)
15584 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)15593 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)
15585 react: 19.2.315594 react: 19.2.3
15586 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15595 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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:...@@ -15588,7 +15597,7 @@ snapshots:
1558815597
15589 expo-updates-interface@56.0.2(expo@56.0.8):15598 expo-updates-interface@56.0.2(expo@56.0.8):
15590 dependencies:15599 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
15593 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):15602 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):
15594 dependencies:15603 dependencies:
...@@ -15598,7 +15607,7 @@ snapshots:...@@ -15598,7 +15607,7 @@ snapshots:
15598 arg: 4.1.315607 arg: 4.1.3
15599 chalk: 4.1.215608 chalk: 4.1.2
15600 debug: 4.4.315609 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)
15602 expo-eas-client: 56.0.115611 expo-eas-client: 56.0.1
15603 expo-manifests: 56.0.4(expo@56.0.8)15612 expo-manifests: 56.0.4(expo@56.0.8)
15604 expo-structured-headers: 56.0.015613 expo-structured-headers: 56.0.0
...@@ -15617,14 +15626,14 @@ snapshots:...@@ -15617,14 +15626,14 @@ snapshots:
1561715626
15618 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)):15627 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)):
15619 dependencies:15628 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)
15621 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15630 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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
15623 expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584):15632 expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584):
15624 dependencies:15633 dependencies:
15625 '@expo/plist': 0.7.015634 '@expo/plist': 0.7.0
15626 '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)15635 '@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)
15628 react: 19.2.315637 react: 19.2.3
15629 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)15638 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
15630 transitivePeerDependencies:15639 transitivePeerDependencies:
...@@ -15635,7 +15644,7 @@ snapshots:...@@ -15635,7 +15644,7 @@ snapshots:
15635 - react-native-reanimated15644 - react-native-reanimated
15636 - react-native-worklets15645 - 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):
15639 dependencies:15648 dependencies:
15640 '@babel/runtime': 7.29.715649 '@babel/runtime': 7.29.7
15641 '@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)15650 '@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:...@@ -15665,6 +15674,7 @@ snapshots:
15665 '@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)15674 '@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)
15666 '@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)15675 '@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)
15667 react-dom: 19.2.3(react@19.2.3)15676 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)
15668 transitivePeerDependencies:15678 transitivePeerDependencies:
15669 - '@babel/core'15679 - '@babel/core'
15670 - bufferutil15680 - bufferutil
...@@ -18139,6 +18149,13 @@ snapshots:...@@ -18139,6 +18149,13 @@ snapshots:
18139 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)18149 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
18140 whatwg-url-without-unicode: 8.0.0-318150 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
18142 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):18159 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):
18143 dependencies:18160 dependencies:
18144 '@babel/core': 7.29.718161 '@babel/core': 7.29.7