| author | |
| committer | |
| log | 30034ecedc467ea2e00075f8cd2abd11ef8dd990 |
| tree | 63b2b9ff40c844de412aad688e19977c07548c25 |
| parent | 1fcc57af136e00633c5d149c3d8564e98f7823c3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 130 | 130 | ||
| 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 | }); |
| 1214 | 1253 | ||
| 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 | } |
| 82 | 87 | ||
| 83 | static NSArray<NSDictionary<NSString *, id> *> *T3MarkdownTextExtractChipBackgrounds( | ||
| 84 | NSMutableAttributedString *attributedString, | ||
| 85 | const std::vector<T3MarkdownTextChipRange> &chipRanges) | ||
| 86 | { | ||
| 87 | NSMutableArray<NSDictionary<NSString *, id> *> *backgrounds = [NSMutableArray array]; | ||
| 88 | for (const auto &chipRange : chipRanges) { | ||
| 89 | if (chipRange.length == 0 || chipRange.location >= attributedString.length) { | ||
| 90 | continue; | ||
| 91 | } | ||
| 92 | |||
| 93 | const NSRange range = NSMakeRange( | ||
| 94 | chipRange.location, | ||
| 95 | MIN(chipRange.length, attributedString.length - chipRange.location)); | ||
| 96 | UIColor *color = [attributedString attribute:NSBackgroundColorAttributeName | ||
| 97 | atIndex:range.location | ||
| 98 | effectiveRange:nil]; | ||
| 99 | UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName | ||
| 100 | atIndex:range.location | ||
| 101 | effectiveRange:nil]; | ||
| 102 | if (color == nil) { | ||
| 103 | continue; | ||
| 104 | } | ||
| 105 | [backgrounds addObject:@{ | ||
| 106 | @"range": [NSValue valueWithRange:range], | ||
| 107 | @"color": color, | ||
| 108 | @"strokeColor": [foregroundColor | ||
| 109 | colorWithAlphaComponent:chipRange.isSkill ? 0.25 : 0.1] ?: UIColor.clearColor, | ||
| 110 | }]; | ||
| 111 | [attributedString removeAttribute:NSBackgroundColorAttributeName range:range]; | ||
| 112 | } | ||
| 113 | return backgrounds; | ||
| 114 | } | ||
| 115 | |||
| 116 | @interface T3MarkdownTextBackingView : UITextView | ||
| 117 | @property(nonatomic, copy) NSArray<NSDictionary<NSString *, id> *> *chipBackgrounds; | ||
| 118 | @end | ||
| 119 | |||
| 120 | @implementation T3MarkdownTextBackingView | ||
| 121 | |||
| 122 | - (void)drawRect:(CGRect)rect | ||
| 123 | { | ||
| 124 | [self.layoutManager ensureLayoutForTextContainer:self.textContainer]; | ||
| 125 | CGContextRef context = UIGraphicsGetCurrentContext(); | ||
| 126 | if (context != nil) { | ||
| 127 | CGContextSaveGState(context); | ||
| 128 | CGContextResetClip(context); | ||
| 129 | CGContextClipToRect(context, self.bounds); | ||
| 130 | } | ||
| 131 | for (NSDictionary<NSString *, id> *background in self.chipBackgrounds) { | ||
| 132 | const NSRange characterRange = [background[@"range"] rangeValue]; | ||
| 133 | UIColor *color = background[@"color"]; | ||
| 134 | UIColor *strokeColor = background[@"strokeColor"]; | ||
| 135 | if (characterRange.length == 0 || NSMaxRange(characterRange) > self.textStorage.length) { | ||
| 136 | continue; | ||
| 137 | } | ||
| 138 | |||
| 139 | const NSRange glyphRange = | ||
| 140 | [self.layoutManager glyphRangeForCharacterRange:characterRange actualCharacterRange:nil]; | ||
| 141 | [color setFill]; | ||
| 142 | [self.layoutManager | ||
| 143 | enumerateEnclosingRectsForGlyphRange:glyphRange | ||
| 144 | withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0) | ||
| 145 | inTextContainer:self.textContainer | ||
| 146 | usingBlock:^(CGRect glyphRect, BOOL *stop) { | ||
| 147 | const CGFloat chipHeight = 22; | ||
| 148 | CGRect chipRect = CGRectMake( | ||
| 149 | glyphRect.origin.x - 4, | ||
| 150 | CGRectGetMidY(glyphRect) - chipHeight / 2, | ||
| 151 | glyphRect.size.width + 8, | ||
| 152 | chipHeight); | ||
| 153 | chipRect.origin.x += self.textContainerInset.left; | ||
| 154 | chipRect.origin.y += self.textContainerInset.top; | ||
| 155 | const CGFloat minimumX = self.textContainerInset.left + 0.5; | ||
| 156 | const CGFloat maximumX = | ||
| 157 | CGRectGetWidth(self.bounds) - self.textContainerInset.right - 0.5; | ||
| 158 | if (chipRect.origin.x < minimumX) { | ||
| 159 | chipRect.size.width -= minimumX - chipRect.origin.x; | ||
| 160 | chipRect.origin.x = minimumX; | ||
| 161 | } | ||
| 162 | if (CGRectGetMaxX(chipRect) > maximumX) { | ||
| 163 | chipRect.size.width = MAX(0, maximumX - chipRect.origin.x); | ||
| 164 | } | ||
| 165 | UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect cornerRadius:6]; | ||
| 166 | [path fill]; | ||
| 167 | [strokeColor setStroke]; | ||
| 168 | path.lineWidth = 1; | ||
| 169 | [path stroke]; | ||
| 170 | }]; | ||
| 171 | } | ||
| 172 | if (context != nil) { | ||
| 173 | CGContextRestoreGState(context); | ||
| 174 | } | ||
| 175 | |||
| 176 | [super drawRect:rect]; | ||
| 177 | } | ||
| 178 | |||
| 179 | @end | ||
| 180 | |||
| 181 | @protocol T3MarkdownOutsideTapTarget <NSObject> | 88 | @protocol T3MarkdownOutsideTapTarget <NSObject> |
| 182 | - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; | 89 | - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; |
| 183 | @end | 90 | @end |
| ... | @@ -285,7 +192,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) | ... | @@ -285,7 +192,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 285 | 192 | ||
| 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; |
| 310 | 217 | ||
| 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]; |
| 412 | 316 | ||
| 413 | // Setting attributedText clears any active text selection, and re-assigning | 317 | // 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 | }; |
| 30 | 30 | ||
| 31 | struct T3MarkdownTextChipRange { | 31 | inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { |
| 32 | size_t location; | 32 | return 14; |
| 33 | size_t length; | 33 | } |
| 34 | bool isSkill; | 34 | |
| 35 | }; | 35 | inline Float T3MarkdownTextAttachmentBaselineOffset( |
| 36 | const T3MarkdownTextAttachmentRange &) { | ||
| 37 | return -2; | ||
| 38 | } | ||
| 36 | 39 | ||
| 37 | class T3MarkdownTextStateReal final { | 40 | class 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 | }; |
| 44 | 46 | ||
| 45 | class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< | 47 | class 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::React | 78 | } // namespace facebook::React |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm+15-20| ... | @@ -9,9 +9,8 @@ | ... | @@ -9,9 +9,8 @@ |
| 9 | namespace facebook::react { | 9 | namespace facebook::react { |
| 10 | 10 | ||
| 11 | static constexpr Float ParagraphStyleEncodingOffset = 1000; | 11 | static constexpr Float ParagraphStyleEncodingOffset = 1000; |
| 12 | static constexpr auto ChipNativeIdPrefix = "t3-chip-"; | 12 | static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; |
| 13 | static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:"; | 13 | static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; |
| 14 | static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:"; | ||
| 15 | 14 | ||
| 16 | static void applyParagraphStyles( | 15 | static void applyParagraphStyles( |
| 17 | NSMutableAttributedString *attributedString, | 16 | NSMutableAttributedString *attributedString, |
| ... | @@ -58,7 +57,12 @@ static void applyAttachments( | ... | @@ -58,7 +57,12 @@ static void applyAttachments( |
| 58 | 57 | ||
| 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; | ||
| 217 | 213 | ||
| 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 { |
| 40 | function SelectableNode(props: { | 40 | function 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 | <NativeMarkdownSelectableText | 46 | <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[] { |
| 312 | function NativeTable(props: { | 314 | function 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: { |
| 364 | function NativeMarkdownImage(props: { | 368 | function 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 | } |
| 372 | 383 | ||
| 373 | return ( | 384 | return ( |
| ... | @@ -426,6 +437,7 @@ function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] { | ... | @@ -426,6 +437,7 @@ function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] { |
| 426 | function NativeMixedParagraph(props: { | 437 | function 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 | compact | 532 | 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 | <View | 595 | <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 | compact | 622 | 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 | compact | 688 | 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 @@ |
| 1 | import { useEffect, useMemo, useState } from "react"; | ||
| 2 | import { Asset } from "expo-asset"; | ||
| 3 | import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; | 1 | import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; |
| 4 | 2 | ||
| 5 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; | 3 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; |
| 6 | import { markdownFileIconSource } from "./markdownFileIcons"; | 4 | import { markdownFileIconSource } from "./markdownFileIcons"; |
| 7 | import type { MarkdownFileIcon } from "./markdownLinks"; | ||
| 8 | import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; | 5 | import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; |
| 9 | import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; | 6 | import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; |
| 10 | 7 | ||
| 11 | const EXTERNAL_LINK_PREFIX = "â—‰ "; | 8 | const EXTERNAL_LINK_PREFIX = "â—‰ "; |
| 12 | const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0"; | 9 | const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; |
| 13 | const CHIP_SUFFIX = "\u00A0"; | ||
| 14 | const SKILL_ICON_PLACEHOLDER = "\uFFFC"; | 10 | const SKILL_ICON_PLACEHOLDER = "\uFFFC"; |
| 15 | const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; | 11 | const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; |
| 16 | 12 | ||
| 17 | function useFileIconUris(runs: ReadonlyArray<NativeMarkdownTextRun>) { | ||
| 18 | const iconSignature = JSON.stringify( | ||
| 19 | [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(), | ||
| 20 | ); | ||
| 21 | const icons = useMemo( | ||
| 22 | () => JSON.parse(iconSignature) as ReadonlyArray<MarkdownFileIcon>, | ||
| 23 | [iconSignature], | ||
| 24 | ); | ||
| 25 | const [uris, setUris] = useState<ReadonlyMap<string, string>>(() => new Map()); | ||
| 26 | |||
| 27 | useEffect(() => { | ||
| 28 | let cancelled = false; | ||
| 29 | |||
| 30 | void Promise.all( | ||
| 31 | icons.map(async (icon) => { | ||
| 32 | const source = markdownFileIconSource(icon); | ||
| 33 | const fallbackUri = Image.resolveAssetSource(source).uri; | ||
| 34 | if (typeof source !== "number" && typeof source !== "string") { | ||
| 35 | return [icon, fallbackUri] as const; | ||
| 36 | } | ||
| 37 | try { | ||
| 38 | const asset = Asset.fromModule(source); | ||
| 39 | await asset.downloadAsync(); | ||
| 40 | return [icon, asset.localUri ?? fallbackUri] as const; | ||
| 41 | } catch { | ||
| 42 | return [icon, fallbackUri] as const; | ||
| 43 | } | ||
| 44 | }), | ||
| 45 | ).then((entries) => { | ||
| 46 | if (!cancelled) { | ||
| 47 | setUris(new Map(entries)); | ||
| 48 | } | ||
| 49 | }); | ||
| 50 | |||
| 51 | return () => { | ||
| 52 | cancelled = true; | ||
| 53 | }; | ||
| 54 | }, [icons]); | ||
| 55 | |||
| 56 | return uris; | ||
| 57 | } | ||
| 58 | |||
| 59 | function runKeySignature(run: NativeMarkdownTextRun): string { | 13 | function 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 { |
| 81 | function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { | 35 | function 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"; | ||
| 91 | 48 | ||
| 92 | return { | 49 | return { |
| 93 | color: isFile | 50 | color: isFile |
| ... | @@ -106,20 +63,23 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle | ... | @@ -106,20 +63,23 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle |
| 106 | ? textStyle.mutedColor | 63 | ? textStyle.mutedColor |
| 107 | : run.role === "list-marker" | 64 | : run.role === "list-marker" |
| 108 | ? textStyle.mutedColor | 65 | ? textStyle.mutedColor |
| 109 | : run.code || isFile | 66 | : isCodeBlock |
| 110 | ? textStyle.codeColor | 67 | ? textStyle.codeColor |
| 111 | : run.bold | 68 | : run.code |
| 112 | ? textStyle.strongColor | 69 | ? textStyle.inlineCodeColor |
| 113 | : textStyle.color, | 70 | : run.bold |
| 114 | fontFamily: isChip | 71 | ? textStyle.strongColor |
| 115 | ? "DMSans_500Medium" | 72 | : textStyle.color, |
| 116 | : run.code || isCodeBlock | 73 | fontFamily: |
| 117 | ? "ui-monospace" | 74 | isFile || isSkill |
| 118 | : isHeading | 75 | ? textStyle.boldFontFamily |
| 119 | ? textStyle.headingFontFamily | 76 | : run.code || isCodeBlock |
| 120 | : run.bold | 77 | ? "ui-monospace" |
| 121 | ? textStyle.boldFontFamily | 78 | : 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 | ? headingFontSize | 89 | ? headingFontSize |
| 130 | : run.role === "code-language" | 90 | : run.role === "code-language" |
| 131 | ? 11 | 91 | ? 11 |
| 132 | : run.code || isChip || isCodeBlock | 92 | : 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 | ? 18 | 103 | ? 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: isCodeBlock | 108 | 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 | ...(hasParagraphStyle | 109 | ...(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 |
| 170 | export function NativeMarkdownSelectableText(props: { | 122 | export 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: { |
| 182 | 134 | ||
| 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 | uiTextView | 168 | uiTextView |
| 218 | selectable | 169 | 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.fileIcon | 185 | 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.skillName | 187 | : run.skillName |
| 239 | ? "t3-chip-skill:sf:cube" | 188 | ? "t3-skill:sf:cube" |
| 240 | : undefined | 189 | : undefined |
| 241 | } | 190 | } |
| 242 | style={runStyle(run, props.textStyle)} | 191 | style={runStyle(run, props.textStyle)} |
| 243 | onPress={ | 192 | onPress={ |
| 244 | href | 193 | 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 | : undefined | 201 | : 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"; |
| 10 | import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; | 11 | import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; |
| 11 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; | 12 | import { 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]); |
| 54 | 60 | ||
| 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 | ); |
| 68 | 83 | ||
| 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 | } |
| 249 | 253 | ||
| 250 | function fileUrlPath(href: string): string | null { | 254 | function 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 | } |
| 267 | 268 | ||
| 269 | function stripSearchAndHash(value: string): { readonly path: string; readonly hash: string } { | ||
| 270 | const hashIndex = value.indexOf("#"); | ||
| 271 | const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; | ||
| 272 | const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; | ||
| 273 | const queryIndex = pathWithSearch.indexOf("?"); | ||
| 274 | return { | ||
| 275 | path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, | ||
| 276 | hash, | ||
| 277 | }; | ||
| 278 | } | ||
| 279 | |||
| 280 | function splitFilePosition( | ||
| 281 | path: string, | ||
| 282 | hash: string, | ||
| 283 | ): { readonly path: string; readonly line?: number; readonly column?: number } { | ||
| 284 | const suffixMatch = path.match(/:(\d+)(?::(\d+))?$/); | ||
| 285 | const hashMatch = suffixMatch ? null : hash.match(/^#L(\d+)(?:C(\d+))?$/i); | ||
| 286 | const match = suffixMatch ?? hashMatch; | ||
| 287 | if (!match?.[1]) { | ||
| 288 | return { path }; | ||
| 289 | } | ||
| 290 | |||
| 291 | const line = Number.parseInt(match[1], 10); | ||
| 292 | const column = match[2] ? Number.parseInt(match[2], 10) : undefined; | ||
| 293 | const pathWithoutPosition = suffixMatch ? path.slice(0, -suffixMatch[0].length) : path; | ||
| 294 | return { | ||
| 295 | path: pathWithoutPosition, | ||
| 296 | ...(line > 0 ? { line } : {}), | ||
| 297 | ...(column !== undefined && column > 0 ? { column } : {}), | ||
| 298 | }; | ||
| 299 | } | ||
| 300 | |||
| 268 | function looksLikePosixFilesystemPath(path: string): boolean { | 301 | function 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 | } |
| 333 | 366 | ||
| 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 | } |
| 344 | 394 |
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 | } |
| 321 | 322 | ||
| 323 | export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): MarkdownNode { | ||
| 324 | const children = node.children?.map(nativeMarkdownWithPreservedSoftBreaks); | ||
| 325 | return { | ||
| 326 | ...node, | ||
| 327 | ...(node.type === "soft_break" ? { type: "line_break" as const } : {}), | ||
| 328 | ...(children ? { children } : {}), | ||
| 329 | }; | ||
| 330 | } | ||
| 331 | |||
| 322 | function appendBlockTerminator( | 332 | function 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 | } |
| 59 | 59 | ||
| 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 | } |
| 245 | 245 | ||
| 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 | ||
| 319 | 328 | ||
| 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 = rows | 405 | 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.y | 572 | contentView.verticalOffset = scrollView.contentOffset.y |
| 562 | contentView.invalidateVisibleViewport() | 573 | contentView.invalidateVisibleViewport() |
| 563 | contentView.setNeedsDisplay() | 574 | contentView.setNeedsDisplay() |
| 575 | applyInitialRowIndexIfNeeded() | ||
| 564 | 576 | ||
| 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 | } |
| 647 | 659 | ||
| 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 = ReviewDiffNativeStyle | 674 | 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.y | 687 | 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 | } |
| 666 | 707 | ||
| 667 | private enum ReviewDiffHorizontalPanKind { | 708 | private 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.rowHeight | 861 | return style.rowHeight |
| 821 | } | 862 | } |
| 822 | 863 | ||
| 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"; |
| 30 | 30 | ||
| 31 | function AppNavigator() { | 31 | function 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"; | ||
| 34 | 35 | ||
| 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 @@ |
| 1 | import { Stack, useRouter } from "expo-router"; | 1 | import type { |
| 2 | import { useState } from "react"; | 2 | EnvironmentId, |
| 3 | import { Text as RNText, View } from "react-native"; | 3 | SidebarProjectGroupingMode, |
| 4 | SidebarThreadSortOrder, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | ||
| 8 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | ||
| 9 | DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Arr from "effect/Array"; | ||
| 12 | import * as Order from "effect/Order"; | ||
| 13 | import { useRouter } from "expo-router"; | ||
| 14 | import { useCallback, useMemo, useState } from "react"; | ||
| 4 | 15 | ||
| 5 | import { useProjects, useThreadShells } from "../state/entities"; | 16 | import { useProjects, useThreadShells } from "../state/entities"; |
| 6 | import { useWorkspaceState } from "../state/workspace"; | 17 | import { useWorkspaceState } from "../state/workspace"; |
| 7 | import { buildThreadRoutePath } from "../lib/routes"; | 18 | import { buildThreadRoutePath } from "../lib/routes"; |
| 8 | import { useSavedRemoteConnections } from "../state/use-remote-environment-registry"; | 19 | import { useSavedRemoteConnections } from "../state/use-remote-environment-registry"; |
| 9 | import { HomeScreen } from "../features/home/HomeScreen"; | 20 | import { HomeScreen } from "../features/home/HomeScreen"; |
| 10 | import { useThemeColor } from "../lib/useThemeColor"; | 21 | import { HomeHeader } from "../features/home/HomeHeader"; |
| 22 | import type { HomeProjectSortOrder } from "../features/home/homeThreadList"; | ||
| 23 | import { useThreadListActions } from "../features/home/useThreadListActions"; | ||
| 24 | |||
| 25 | interface HomeListOptions { | ||
| 26 | readonly selectedEnvironmentId: EnvironmentId | null; | ||
| 27 | readonly projectSortOrder: HomeProjectSortOrder; | ||
| 28 | readonly threadSortOrder: SidebarThreadSortOrder; | ||
| 29 | readonly projectGroupingMode: SidebarProjectGroupingMode; | ||
| 30 | } | ||
| 11 | 31 | ||
| 12 | /* ─── Route screen ───────────────────────────────────────────────────── */ | 32 | /* ─── Route screen ───────────────────────────────────────────────────── */ |
| 13 | 33 | ||
| ... | @@ -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(""); |
| 21 | 41 | 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 | }, []); | ||
| 25 | 82 | ||
| 26 | return ( | 83 | return ( |
| 27 | <> | 84 | <> |
| 28 | <Stack.Screen | 85 | <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 | /> |
| 49 | 99 | ||
| 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 | <HomeScreen | 100 | <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.Screen | 55 | <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 @@ | ||
| 1 | import { ArchivedThreadsRouteScreen } from "../../features/archive/ArchivedThreadsRouteScreen"; | ||
| 2 | |||
| 3 | export default ArchivedThreadsRouteScreen; | ||
apps/mobile/src/app/settings/index.tsx+16-2| ... | @@ -65,6 +65,8 @@ function LocalSettingsRouteScreen() { | ... | @@ -65,6 +65,8 @@ function LocalSettingsRouteScreen() { |
| 65 | /> | 65 | /> |
| 66 | </SettingsSection> | 66 | </SettingsSection> |
| 67 | 67 | ||
| 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> |
| 376 | 378 | ||
| 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 | } |
| 418 | 422 | ||
| 423 | function ArchivedThreadsSettingsSection() { | ||
| 424 | return ( | ||
| 425 | <SettingsSection title="Threads"> | ||
| 426 | <SettingsRow icon="archivebox" label="Archived Threads" href="/settings/archive" /> | ||
| 427 | </SettingsSection> | ||
| 428 | ); | ||
| 429 | } | ||
| 430 | |||
| 419 | function SettingsRow(props: { | 431 | function 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.Screen | 84 | <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 @@ | ||
| 1 | import { ThreadFileScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; | ||
| 2 | |||
| 3 | export default function ThreadFileRoute() { | ||
| 4 | return <ThreadFileScreen />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/files/index.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { ThreadFilesTreeScreen } from "../../../../../features/files/ThreadFilesRouteScreen"; | ||
| 2 | |||
| 3 | export default function ThreadFilesIndexRoute() { | ||
| 4 | return <ThreadFilesTreeScreen />; | ||
| 5 | } | ||
apps/mobile/src/components/LoadingStrip.tsx created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | import { useEffect, useState } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | import Animated, { | ||
| 4 | cancelAnimation, | ||
| 5 | Easing, | ||
| 6 | useAnimatedStyle, | ||
| 7 | useSharedValue, | ||
| 8 | withRepeat, | ||
| 9 | withTiming, | ||
| 10 | } from "react-native-reanimated"; | ||
| 11 | |||
| 12 | const INDICATOR_WIDTH_FRACTION = 0.3; | ||
| 13 | const MIN_INDICATOR_WIDTH = 48; | ||
| 14 | |||
| 15 | function LoadingStripFrame(props: { | ||
| 16 | readonly children: React.ReactNode; | ||
| 17 | readonly onLayout?: (width: number) => void; | ||
| 18 | }) { | ||
| 19 | return ( | ||
| 20 | <View | ||
| 21 | pointerEvents="none" | ||
| 22 | accessibilityElementsHidden | ||
| 23 | importantForAccessibility="no-hide-descendants" | ||
| 24 | className="absolute inset-x-0 top-0 z-10 h-0.5 overflow-hidden" | ||
| 25 | onLayout={ | ||
| 26 | props.onLayout | ||
| 27 | ? (event) => { | ||
| 28 | props.onLayout?.(event.nativeEvent.layout.width); | ||
| 29 | } | ||
| 30 | : undefined | ||
| 31 | } | ||
| 32 | > | ||
| 33 | {props.children} | ||
| 34 | </View> | ||
| 35 | ); | ||
| 36 | } | ||
| 37 | |||
| 38 | function IndeterminateLoadingStrip() { | ||
| 39 | const [containerWidth, setContainerWidth] = useState(0); | ||
| 40 | const travelProgress = useSharedValue(0); | ||
| 41 | const indicatorWidth = Math.max(MIN_INDICATOR_WIDTH, containerWidth * INDICATOR_WIDTH_FRACTION); | ||
| 42 | |||
| 43 | useEffect(() => { | ||
| 44 | travelProgress.value = 0; | ||
| 45 | travelProgress.value = withRepeat( | ||
| 46 | withTiming(1, { | ||
| 47 | duration: 1100, | ||
| 48 | easing: Easing.inOut(Easing.quad), | ||
| 49 | }), | ||
| 50 | -1, | ||
| 51 | false, | ||
| 52 | ); | ||
| 53 | |||
| 54 | return () => { | ||
| 55 | cancelAnimation(travelProgress); | ||
| 56 | }; | ||
| 57 | }, [travelProgress]); | ||
| 58 | |||
| 59 | const indicatorStyle = useAnimatedStyle( | ||
| 60 | () => ({ | ||
| 61 | transform: [ | ||
| 62 | { | ||
| 63 | translateX: (containerWidth + indicatorWidth) * travelProgress.value - indicatorWidth, | ||
| 64 | }, | ||
| 65 | ], | ||
| 66 | width: indicatorWidth, | ||
| 67 | }), | ||
| 68 | [containerWidth, indicatorWidth], | ||
| 69 | ); | ||
| 70 | |||
| 71 | return ( | ||
| 72 | <LoadingStripFrame onLayout={setContainerWidth}> | ||
| 73 | <Animated.View className="h-full rounded-full bg-primary" style={indicatorStyle} /> | ||
| 74 | </LoadingStripFrame> | ||
| 75 | ); | ||
| 76 | } | ||
| 77 | |||
| 78 | export function LoadingStrip(props: { readonly progress?: number }) { | ||
| 79 | if (props.progress === undefined) { | ||
| 80 | return <IndeterminateLoadingStrip />; | ||
| 81 | } | ||
| 82 | |||
| 83 | const clampedProgress = Math.min(1, Math.max(0, props.progress)); | ||
| 84 | |||
| 85 | return ( | ||
| 86 | <LoadingStripFrame> | ||
| 87 | <View | ||
| 88 | className="h-full rounded-r-full bg-primary" | ||
| 89 | style={{ width: `${clampedProgress * 100}%` }} | ||
| 90 | /> | ||
| 91 | </LoadingStripFrame> | ||
| 92 | ); | ||
| 93 | } | ||
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import * as Order from "effect/Order"; | ||
| 4 | import { useFocusEffect } from "expo-router"; | ||
| 5 | import { useCallback, useMemo, useState } from "react"; | ||
| 6 | |||
| 7 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 8 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | ||
| 9 | import { useArchivedThreadListActions } from "../home/useThreadListActions"; | ||
| 10 | import { | ||
| 11 | ArchivedThreadsScreen, | ||
| 12 | type ArchivedThreadsHeaderEnvironment, | ||
| 13 | } from "./ArchivedThreadsScreen"; | ||
| 14 | import { buildArchivedThreadGroups, type ArchivedThreadSortOrder } from "./archivedThreadList"; | ||
| 15 | import { | ||
| 16 | refreshArchivedThreadsForEnvironment, | ||
| 17 | useArchivedThreadSnapshots, | ||
| 18 | } from "./useArchivedThreadSnapshots"; | ||
| 19 | |||
| 20 | export function ArchivedThreadsRouteScreen() { | ||
| 21 | const { expand } = useClerkSettingsSheetDetent(); | ||
| 22 | const { savedConnectionsById } = useSavedRemoteConnections(); | ||
| 23 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 24 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null); | ||
| 25 | const [sortOrder, setSortOrder] = useState<ArchivedThreadSortOrder>("newest"); | ||
| 26 | const environments = useMemo<ReadonlyArray<ArchivedThreadsHeaderEnvironment>>( | ||
| 27 | () => | ||
| 28 | Arr.sort( | ||
| 29 | Object.values(savedConnectionsById).map((connection) => ({ | ||
| 30 | environmentId: connection.environmentId, | ||
| 31 | label: connection.environmentLabel, | ||
| 32 | })), | ||
| 33 | Order.mapInput(Order.String, (environment: ArchivedThreadsHeaderEnvironment) => | ||
| 34 | environment.label.toLocaleLowerCase(), | ||
| 35 | ), | ||
| 36 | ), | ||
| 37 | [savedConnectionsById], | ||
| 38 | ); | ||
| 39 | const environmentIds = useMemo( | ||
| 40 | () => environments.map((environment) => environment.environmentId), | ||
| 41 | [environments], | ||
| 42 | ); | ||
| 43 | const environmentLabels = useMemo( | ||
| 44 | () => | ||
| 45 | Object.fromEntries( | ||
| 46 | environments.map((environment) => [environment.environmentId, environment.label]), | ||
| 47 | ), | ||
| 48 | [environments], | ||
| 49 | ); | ||
| 50 | const { error, isLoading, refresh, snapshots } = useArchivedThreadSnapshots(environmentIds); | ||
| 51 | const groups = useMemo( | ||
| 52 | () => | ||
| 53 | buildArchivedThreadGroups({ | ||
| 54 | snapshots, | ||
| 55 | environmentLabels, | ||
| 56 | environmentId: selectedEnvironmentId, | ||
| 57 | searchQuery, | ||
| 58 | sortOrder, | ||
| 59 | }), | ||
| 60 | [environmentLabels, searchQuery, selectedEnvironmentId, snapshots, sortOrder], | ||
| 61 | ); | ||
| 62 | const refreshChangedEnvironment = useCallback( | ||
| 63 | (thread: { readonly environmentId: EnvironmentId }) => { | ||
| 64 | refreshArchivedThreadsForEnvironment(thread.environmentId); | ||
| 65 | }, | ||
| 66 | [], | ||
| 67 | ); | ||
| 68 | const { unarchiveThread, confirmDeleteThread } = | ||
| 69 | useArchivedThreadListActions(refreshChangedEnvironment); | ||
| 70 | |||
| 71 | useFocusEffect( | ||
| 72 | useCallback(() => { | ||
| 73 | expand(); | ||
| 74 | refresh(); | ||
| 75 | }, [expand, refresh]), | ||
| 76 | ); | ||
| 77 | |||
| 78 | return ( | ||
| 79 | <ArchivedThreadsScreen | ||
| 80 | environments={environments} | ||
| 81 | error={error} | ||
| 82 | groups={groups} | ||
| 83 | isLoading={isLoading} | ||
| 84 | onDeleteThread={confirmDeleteThread} | ||
| 85 | onEnvironmentChange={setSelectedEnvironmentId} | ||
| 86 | onRefresh={refresh} | ||
| 87 | onSearchQueryChange={setSearchQuery} | ||
| 88 | onSortOrderChange={setSortOrder} | ||
| 89 | onUnarchiveThread={unarchiveThread} | ||
| 90 | searchQuery={searchQuery} | ||
| 91 | selectedEnvironmentId={selectedEnvironmentId} | ||
| 92 | sortOrder={sortOrder} | ||
| 93 | /> | ||
| 94 | ); | ||
| 95 | } | ||
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx created+434| ... | @@ -0,0 +1,434 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentProject, | ||
| 3 | EnvironmentThreadShell, | ||
| 4 | } from "@t3tools/client-runtime/state/shell"; | ||
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 6 | import type { MenuAction } from "@react-native-menu/menu"; | ||
| 7 | import * as Haptics from "expo-haptics"; | ||
| 8 | import { Stack } from "expo-router"; | ||
| 9 | import { SymbolView } from "expo-symbols"; | ||
| 10 | import { useCallback, useRef } from "react"; | ||
| 11 | import { | ||
| 12 | ActivityIndicator, | ||
| 13 | Pressable, | ||
| 14 | RefreshControl, | ||
| 15 | ScrollView, | ||
| 16 | useWindowDimensions, | ||
| 17 | View, | ||
| 18 | } from "react-native"; | ||
| 19 | import ReanimatedSwipeable, { | ||
| 20 | type SwipeableMethods, | ||
| 21 | } from "react-native-gesture-handler/ReanimatedSwipeable"; | ||
| 22 | |||
| 23 | import { AppText as Text } from "../../components/AppText"; | ||
| 24 | import { ControlPillMenu } from "../../components/ControlPill"; | ||
| 25 | import { EmptyState } from "../../components/EmptyState"; | ||
| 26 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | ||
| 27 | import { relativeTime } from "../../lib/time"; | ||
| 28 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 29 | import { | ||
| 30 | THREAD_SWIPE_ACTIONS_WIDTH, | ||
| 31 | THREAD_SWIPE_SPRING, | ||
| 32 | ThreadSwipeActions, | ||
| 33 | } from "../home/thread-swipe-actions"; | ||
| 34 | import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; | ||
| 35 | |||
| 36 | export interface ArchivedThreadsHeaderEnvironment { | ||
| 37 | readonly environmentId: EnvironmentId; | ||
| 38 | readonly label: string; | ||
| 39 | } | ||
| 40 | |||
| 41 | const THREAD_ACTIONS: MenuAction[] = [ | ||
| 42 | { | ||
| 43 | id: "unarchive", | ||
| 44 | title: "Unarchive", | ||
| 45 | image: "arrow.uturn.backward", | ||
| 46 | }, | ||
| 47 | { | ||
| 48 | id: "delete", | ||
| 49 | title: "Delete", | ||
| 50 | image: "trash", | ||
| 51 | attributes: { destructive: true }, | ||
| 52 | }, | ||
| 53 | ]; | ||
| 54 | |||
| 55 | function ArchivedThreadsHeader(props: { | ||
| 56 | readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>; | ||
| 57 | readonly selectedEnvironmentId: EnvironmentId | null; | ||
| 58 | readonly sortOrder: ArchivedThreadSortOrder; | ||
| 59 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | ||
| 60 | readonly onSearchQueryChange: (query: string) => void; | ||
| 61 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; | ||
| 62 | }) { | ||
| 63 | const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; | ||
| 64 | |||
| 65 | return ( | ||
| 66 | <> | ||
| 67 | <Stack.Screen | ||
| 68 | options={{ | ||
| 69 | title: "Archived Threads", | ||
| 70 | headerSearchBarOptions: { | ||
| 71 | autoCapitalize: "none", | ||
| 72 | hideNavigationBar: false, | ||
| 73 | obscureBackground: false, | ||
| 74 | placeholder: "Search archived threads", | ||
| 75 | placement: "stacked", | ||
| 76 | onChangeText: (event) => { | ||
| 77 | props.onSearchQueryChange(event.nativeEvent.text); | ||
| 78 | }, | ||
| 79 | onCancelButtonPress: () => { | ||
| 80 | props.onSearchQueryChange(""); | ||
| 81 | }, | ||
| 82 | }, | ||
| 83 | }} | ||
| 84 | /> | ||
| 85 | |||
| 86 | <Stack.Toolbar placement="right"> | ||
| 87 | <Stack.Toolbar.Menu | ||
| 88 | accessibilityLabel="Filter and sort archived threads" | ||
| 89 | icon={ | ||
| 90 | hasCustomFilter | ||
| 91 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 92 | : "line.3.horizontal.decrease.circle" | ||
| 93 | } | ||
| 94 | separateBackground | ||
| 95 | title="Archived thread options" | ||
| 96 | > | ||
| 97 | <Stack.Toolbar.Menu title="Environment"> | ||
| 98 | <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label> | ||
| 99 | <Stack.Toolbar.MenuAction | ||
| 100 | isOn={props.selectedEnvironmentId === null} | ||
| 101 | onPress={() => props.onEnvironmentChange(null)} | ||
| 102 | > | ||
| 103 | <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label> | ||
| 104 | </Stack.Toolbar.MenuAction> | ||
| 105 | {props.environments.map((environment) => ( | ||
| 106 | <Stack.Toolbar.MenuAction | ||
| 107 | key={environment.environmentId} | ||
| 108 | isOn={props.selectedEnvironmentId === environment.environmentId} | ||
| 109 | onPress={() => props.onEnvironmentChange(environment.environmentId)} | ||
| 110 | > | ||
| 111 | <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label> | ||
| 112 | </Stack.Toolbar.MenuAction> | ||
| 113 | ))} | ||
| 114 | </Stack.Toolbar.Menu> | ||
| 115 | |||
| 116 | <Stack.Toolbar.Menu title="Sort by archived date"> | ||
| 117 | <Stack.Toolbar.Label>Sort by archived date</Stack.Toolbar.Label> | ||
| 118 | <Stack.Toolbar.MenuAction | ||
| 119 | isOn={props.sortOrder === "newest"} | ||
| 120 | onPress={() => props.onSortOrderChange("newest")} | ||
| 121 | > | ||
| 122 | <Stack.Toolbar.Label>Newest first</Stack.Toolbar.Label> | ||
| 123 | </Stack.Toolbar.MenuAction> | ||
| 124 | <Stack.Toolbar.MenuAction | ||
| 125 | isOn={props.sortOrder === "oldest"} | ||
| 126 | onPress={() => props.onSortOrderChange("oldest")} | ||
| 127 | > | ||
| 128 | <Stack.Toolbar.Label>Oldest first</Stack.Toolbar.Label> | ||
| 129 | </Stack.Toolbar.MenuAction> | ||
| 130 | </Stack.Toolbar.Menu> | ||
| 131 | </Stack.Toolbar.Menu> | ||
| 132 | </Stack.Toolbar> | ||
| 133 | </> | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | |||
| 137 | function ProjectGroupLabel(props: { | ||
| 138 | readonly environmentLabel: string | null; | ||
| 139 | readonly project: EnvironmentProject; | ||
| 140 | }) { | ||
| 141 | return ( | ||
| 142 | <View className="flex-row items-center gap-2.5 px-1 pb-2"> | ||
| 143 | <ProjectFavicon | ||
| 144 | environmentId={props.project.environmentId} | ||
| 145 | projectTitle={props.project.title} | ||
| 146 | size={18} | ||
| 147 | workspaceRoot={props.project.workspaceRoot} | ||
| 148 | /> | ||
| 149 | <Text | ||
| 150 | className="flex-1 text-[12px] font-t3-medium uppercase text-foreground-muted" | ||
| 151 | numberOfLines={1} | ||
| 152 | style={{ letterSpacing: 0.5 }} | ||
| 153 | > | ||
| 154 | {props.project.title} | ||
| 155 | </Text> | ||
| 156 | {props.environmentLabel ? ( | ||
| 157 | <Text className="max-w-[42%] text-[11px] text-foreground-tertiary" numberOfLines={1}> | ||
| 158 | {props.environmentLabel} | ||
| 159 | </Text> | ||
| 160 | ) : null} | ||
| 161 | </View> | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | |||
| 165 | function ArchivedThreadRow(props: { | ||
| 166 | readonly environmentLabel: string | null; | ||
| 167 | readonly isLast: boolean; | ||
| 168 | readonly onDelete: () => void; | ||
| 169 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | ||
| 170 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | ||
| 171 | readonly onUnarchive: () => void; | ||
| 172 | readonly thread: EnvironmentThreadShell; | ||
| 173 | }) { | ||
| 174 | const swipeableRef = useRef<SwipeableMethods | null>(null); | ||
| 175 | const fullSwipeArmedRef = useRef(false); | ||
| 176 | const { width: windowWidth } = useWindowDimensions(); | ||
| 177 | const cardColor = useThemeColor("--color-card"); | ||
| 178 | const iconColor = useThemeColor("--color-icon-subtle"); | ||
| 179 | const separatorColor = useThemeColor("--color-separator"); | ||
| 180 | const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); | ||
| 181 | const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); | ||
| 182 | const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => | ||
| 183 | Boolean(part), | ||
| 184 | ); | ||
| 185 | const handleFullSwipeArmedChange = useCallback((armed: boolean) => { | ||
| 186 | if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { | ||
| 187 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); | ||
| 188 | } | ||
| 189 | fullSwipeArmedRef.current = armed; | ||
| 190 | }, []); | ||
| 191 | const handleMenuAction = useCallback( | ||
| 192 | (event: { nativeEvent: { event: string } }) => { | ||
| 193 | if (event.nativeEvent.event === "unarchive") { | ||
| 194 | props.onUnarchive(); | ||
| 195 | } else if (event.nativeEvent.event === "delete") { | ||
| 196 | props.onDelete(); | ||
| 197 | } | ||
| 198 | }, | ||
| 199 | [props.onDelete, props.onUnarchive], | ||
| 200 | ); | ||
| 201 | |||
| 202 | return ( | ||
| 203 | <ReanimatedSwipeable | ||
| 204 | ref={swipeableRef} | ||
| 205 | animationOptions={THREAD_SWIPE_SPRING} | ||
| 206 | childrenContainerStyle={{ backgroundColor: cardColor }} | ||
| 207 | containerStyle={{ backgroundColor: cardColor }} | ||
| 208 | dragOffsetFromRightEdge={8} | ||
| 209 | enableTrackpadTwoFingerGesture | ||
| 210 | friction={1} | ||
| 211 | onSwipeableClose={() => { | ||
| 212 | fullSwipeArmedRef.current = false; | ||
| 213 | if (swipeableRef.current) { | ||
| 214 | props.onSwipeableClose(swipeableRef.current); | ||
| 215 | } | ||
| 216 | }} | ||
| 217 | onSwipeableOpenStartDrag={() => { | ||
| 218 | if (swipeableRef.current) { | ||
| 219 | props.onSwipeableWillOpen(swipeableRef.current); | ||
| 220 | } | ||
| 221 | }} | ||
| 222 | onSwipeableWillOpen={() => { | ||
| 223 | const methods = swipeableRef.current; | ||
| 224 | if (!methods) return; | ||
| 225 | |||
| 226 | props.onSwipeableWillOpen(methods); | ||
| 227 | if (fullSwipeArmedRef.current) { | ||
| 228 | fullSwipeArmedRef.current = false; | ||
| 229 | methods.close(); | ||
| 230 | props.onDelete(); | ||
| 231 | } | ||
| 232 | }} | ||
| 233 | overshootFriction={1} | ||
| 234 | overshootRight | ||
| 235 | renderRightActions={(_progress, translation, methods) => ( | ||
| 236 | <ThreadSwipeActions | ||
| 237 | backgroundColor={cardColor} | ||
| 238 | fullSwipeThreshold={fullSwipeThreshold} | ||
| 239 | onDelete={props.onDelete} | ||
| 240 | onFullSwipeArmedChange={handleFullSwipeArmedChange} | ||
| 241 | primaryAction={{ | ||
| 242 | accessibilityLabel: `Unarchive ${props.thread.title}`, | ||
| 243 | icon: "arrow.uturn.backward", | ||
| 244 | label: "Unarchive", | ||
| 245 | onPress: props.onUnarchive, | ||
| 246 | }} | ||
| 247 | swipeableMethods={methods} | ||
| 248 | threadTitle={props.thread.title} | ||
| 249 | translation={translation} | ||
| 250 | /> | ||
| 251 | )} | ||
| 252 | rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} | ||
| 253 | > | ||
| 254 | <View | ||
| 255 | className="flex-row items-center gap-3 bg-card px-4 py-3" | ||
| 256 | style={{ | ||
| 257 | borderBottomColor: separatorColor, | ||
| 258 | borderBottomWidth: props.isLast ? 0 : 1, | ||
| 259 | }} | ||
| 260 | > | ||
| 261 | <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle"> | ||
| 262 | <SymbolView name="archivebox.fill" size={15} tintColor={iconColor} type="monochrome" /> | ||
| 263 | </View> | ||
| 264 | |||
| 265 | <View className="min-w-0 flex-1 gap-1"> | ||
| 266 | <View className="flex-row items-center gap-2"> | ||
| 267 | <Text | ||
| 268 | className="min-w-0 flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground" | ||
| 269 | numberOfLines={1} | ||
| 270 | > | ||
| 271 | {props.thread.title} | ||
| 272 | </Text> | ||
| 273 | <Text | ||
| 274 | className="text-[12px] text-foreground-tertiary" | ||
| 275 | style={{ fontVariant: ["tabular-nums"] }} | ||
| 276 | > | ||
| 277 | {timestamp} | ||
| 278 | </Text> | ||
| 279 | </View> | ||
| 280 | {subtitle.length > 0 ? ( | ||
| 281 | <View className="flex-row items-center gap-1.5"> | ||
| 282 | <SymbolView | ||
| 283 | name="arrow.triangle.branch" | ||
| 284 | size={10} | ||
| 285 | tintColor={iconColor} | ||
| 286 | type="monochrome" | ||
| 287 | /> | ||
| 288 | <Text | ||
| 289 | className="min-w-0 flex-1 text-[11px] text-foreground-tertiary" | ||
| 290 | numberOfLines={1} | ||
| 291 | style={{ fontFamily: "monospace" }} | ||
| 292 | > | ||
| 293 | {subtitle.join(" · ")} | ||
| 294 | </Text> | ||
| 295 | </View> | ||
| 296 | ) : null} | ||
| 297 | </View> | ||
| 298 | |||
| 299 | <ControlPillMenu actions={THREAD_ACTIONS} onPressAction={handleMenuAction}> | ||
| 300 | <Pressable | ||
| 301 | accessibilityLabel={`Actions for ${props.thread.title}`} | ||
| 302 | accessibilityRole="button" | ||
| 303 | className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" | ||
| 304 | hitSlop={6} | ||
| 305 | > | ||
| 306 | <SymbolView name="ellipsis" size={16} tintColor={iconColor} type="monochrome" /> | ||
| 307 | </Pressable> | ||
| 308 | </ControlPillMenu> | ||
| 309 | </View> | ||
| 310 | </ReanimatedSwipeable> | ||
| 311 | ); | ||
| 312 | } | ||
| 313 | |||
| 314 | function ArchiveError(props: { readonly message: string; readonly onRetry: () => void }) { | ||
| 315 | return ( | ||
| 316 | <View className="rounded-[20px] border border-danger-border bg-danger p-4"> | ||
| 317 | <Text className="font-t3-bold text-danger-foreground">Could not load every archive</Text> | ||
| 318 | <Text className="mt-1 text-[13px] leading-[18px] text-foreground-muted">{props.message}</Text> | ||
| 319 | <Pressable className="mt-3 self-start active:opacity-60" onPress={props.onRetry}> | ||
| 320 | <Text className="text-[13px] font-t3-bold text-danger-foreground">Try again</Text> | ||
| 321 | </Pressable> | ||
| 322 | </View> | ||
| 323 | ); | ||
| 324 | } | ||
| 325 | |||
| 326 | export function ArchivedThreadsScreen(props: { | ||
| 327 | readonly environments: ReadonlyArray<ArchivedThreadsHeaderEnvironment>; | ||
| 328 | readonly error: string | null; | ||
| 329 | readonly groups: ReadonlyArray<ArchivedThreadGroup>; | ||
| 330 | readonly isLoading: boolean; | ||
| 331 | readonly searchQuery: string; | ||
| 332 | readonly selectedEnvironmentId: EnvironmentId | null; | ||
| 333 | readonly sortOrder: ArchivedThreadSortOrder; | ||
| 334 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; | ||
| 335 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | ||
| 336 | readonly onRefresh: () => void; | ||
| 337 | readonly onSearchQueryChange: (query: string) => void; | ||
| 338 | readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; | ||
| 339 | readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; | ||
| 340 | }) { | ||
| 341 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | ||
| 342 | const refreshTint = useThemeColor("--color-icon"); | ||
| 343 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { | ||
| 344 | if (openSwipeableRef.current && openSwipeableRef.current !== methods) { | ||
| 345 | openSwipeableRef.current.close(); | ||
| 346 | } | ||
| 347 | openSwipeableRef.current = methods; | ||
| 348 | }, []); | ||
| 349 | const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { | ||
| 350 | if (openSwipeableRef.current === methods) { | ||
| 351 | openSwipeableRef.current = null; | ||
| 352 | } | ||
| 353 | }, []); | ||
| 354 | const isInitialLoad = props.isLoading && props.groups.length === 0 && props.error === null; | ||
| 355 | const isFiltered = props.searchQuery.trim().length > 0 || props.selectedEnvironmentId !== null; | ||
| 356 | |||
| 357 | return ( | ||
| 358 | <View className="flex-1 bg-sheet"> | ||
| 359 | <ArchivedThreadsHeader | ||
| 360 | environments={props.environments} | ||
| 361 | onEnvironmentChange={props.onEnvironmentChange} | ||
| 362 | onSearchQueryChange={props.onSearchQueryChange} | ||
| 363 | onSortOrderChange={props.onSortOrderChange} | ||
| 364 | selectedEnvironmentId={props.selectedEnvironmentId} | ||
| 365 | sortOrder={props.sortOrder} | ||
| 366 | /> | ||
| 367 | |||
| 368 | <ScrollView | ||
| 369 | className="flex-1" | ||
| 370 | contentContainerStyle={{ gap: 20, paddingBottom: 32, paddingHorizontal: 16, paddingTop: 8 }} | ||
| 371 | contentInsetAdjustmentBehavior="automatic" | ||
| 372 | keyboardDismissMode="on-drag" | ||
| 373 | keyboardShouldPersistTaps="handled" | ||
| 374 | onScrollBeginDrag={() => openSwipeableRef.current?.close()} | ||
| 375 | refreshControl={ | ||
| 376 | <RefreshControl | ||
| 377 | onRefresh={props.onRefresh} | ||
| 378 | refreshing={props.isLoading && !isInitialLoad} | ||
| 379 | tintColor={String(refreshTint)} | ||
| 380 | /> | ||
| 381 | } | ||
| 382 | showsVerticalScrollIndicator={false} | ||
| 383 | > | ||
| 384 | {props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null} | ||
| 385 | |||
| 386 | {isInitialLoad ? ( | ||
| 387 | <View className="items-center py-16"> | ||
| 388 | <ActivityIndicator color={refreshTint} /> | ||
| 389 | <Text className="mt-3 text-[13px] text-foreground-muted">Loading archive…</Text> | ||
| 390 | </View> | ||
| 391 | ) : props.groups.length === 0 ? ( | ||
| 392 | <EmptyState | ||
| 393 | detail={ | ||
| 394 | isFiltered | ||
| 395 | ? "Try another search or environment." | ||
| 396 | : "Threads you archive will appear here." | ||
| 397 | } | ||
| 398 | title={isFiltered ? "No matching threads" : "No archived threads"} | ||
| 399 | /> | ||
| 400 | ) : ( | ||
| 401 | props.groups.map((group) => { | ||
| 402 | const environmentLabel = | ||
| 403 | props.environments.find( | ||
| 404 | (environment) => environment.environmentId === group.project.environmentId, | ||
| 405 | )?.label ?? null; | ||
| 406 | |||
| 407 | return ( | ||
| 408 | <View key={group.key} collapsable={false}> | ||
| 409 | <ProjectGroupLabel environmentLabel={environmentLabel} project={group.project} /> | ||
| 410 | <View | ||
| 411 | className="overflow-hidden rounded-[20px] bg-card" | ||
| 412 | style={{ borderCurve: "continuous" }} | ||
| 413 | > | ||
| 414 | {group.threads.map((thread, index) => ( | ||
| 415 | <ArchivedThreadRow | ||
| 416 | key={`${thread.environmentId}:${thread.id}`} | ||
| 417 | environmentLabel={environmentLabel} | ||
| 418 | isLast={index === group.threads.length - 1} | ||
| 419 | onDelete={() => props.onDeleteThread(thread)} | ||
| 420 | onSwipeableClose={handleSwipeableClose} | ||
| 421 | onSwipeableWillOpen={handleSwipeableWillOpen} | ||
| 422 | onUnarchive={() => props.onUnarchiveThread(thread)} | ||
| 423 | thread={thread} | ||
| 424 | /> | ||
| 425 | ))} | ||
| 426 | </View> | ||
| 427 | </View> | ||
| 428 | ); | ||
| 429 | }) | ||
| 430 | )} | ||
| 431 | </ScrollView> | ||
| 432 | </View> | ||
| 433 | ); | ||
| 434 | } | ||
apps/mobile/src/features/archive/archivedThreadList.test.ts created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; | ||
| 2 | import type { OrchestrationProjectShell, OrchestrationThreadShell } from "@t3tools/contracts"; | ||
| 3 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { buildArchivedThreadGroups } from "./archivedThreadList"; | ||
| 7 | |||
| 8 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 9 | |||
| 10 | function makeProject( | ||
| 11 | input: Partial<OrchestrationProjectShell> & Pick<OrchestrationProjectShell, "id" | "title">, | ||
| 12 | ): OrchestrationProjectShell { | ||
| 13 | return { | ||
| 14 | workspaceRoot: `/workspaces/${input.id}`, | ||
| 15 | repositoryIdentity: null, | ||
| 16 | defaultModelSelection: null, | ||
| 17 | scripts: [], | ||
| 18 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 19 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 20 | ...input, | ||
| 21 | }; | ||
| 22 | } | ||
| 23 | |||
| 24 | function makeThread( | ||
| 25 | input: Partial<OrchestrationThreadShell> & | ||
| 26 | Pick<OrchestrationThreadShell, "id" | "projectId" | "title">, | ||
| 27 | ): OrchestrationThreadShell { | ||
| 28 | return { | ||
| 29 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 30 | runtimeMode: "full-access", | ||
| 31 | interactionMode: "default", | ||
| 32 | branch: null, | ||
| 33 | worktreePath: null, | ||
| 34 | latestTurn: null, | ||
| 35 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 36 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 37 | archivedAt: "2026-06-02T00:00:00.000Z", | ||
| 38 | session: null, | ||
| 39 | latestUserMessageAt: null, | ||
| 40 | hasPendingApprovals: false, | ||
| 41 | hasPendingUserInput: false, | ||
| 42 | hasActionableProposedPlan: false, | ||
| 43 | ...input, | ||
| 44 | }; | ||
| 45 | } | ||
| 46 | |||
| 47 | function makeSnapshot( | ||
| 48 | projects: ReadonlyArray<OrchestrationProjectShell>, | ||
| 49 | threads: ReadonlyArray<OrchestrationThreadShell>, | ||
| 50 | targetEnvironmentId = environmentId, | ||
| 51 | ): ArchivedSnapshotEntry { | ||
| 52 | return { | ||
| 53 | environmentId: targetEnvironmentId, | ||
| 54 | snapshot: { | ||
| 55 | snapshotSequence: 1, | ||
| 56 | projects, | ||
| 57 | threads, | ||
| 58 | updatedAt: "2026-06-04T00:00:00.000Z", | ||
| 59 | }, | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | describe("buildArchivedThreadGroups", () => { | ||
| 64 | it("groups archived threads by project and sorts newest first", () => { | ||
| 65 | const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); | ||
| 66 | const older = makeThread({ | ||
| 67 | id: ThreadId.make("thread-older"), | ||
| 68 | projectId: project.id, | ||
| 69 | title: "Older", | ||
| 70 | }); | ||
| 71 | const newer = makeThread({ | ||
| 72 | archivedAt: "2026-06-03T00:00:00.000Z", | ||
| 73 | id: ThreadId.make("thread-newer"), | ||
| 74 | projectId: project.id, | ||
| 75 | title: "Newer", | ||
| 76 | }); | ||
| 77 | |||
| 78 | const result = buildArchivedThreadGroups({ | ||
| 79 | snapshots: [makeSnapshot([project], [older, newer])], | ||
| 80 | environmentLabels: { [environmentId]: "Julius's MacBook Pro" }, | ||
| 81 | environmentId: null, | ||
| 82 | searchQuery: "", | ||
| 83 | sortOrder: "newest", | ||
| 84 | }); | ||
| 85 | |||
| 86 | expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-newer", "thread-older"]); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("filters by environment and matches project, thread, and branch text", () => { | ||
| 90 | const secondEnvironmentId = EnvironmentId.make("environment-2"); | ||
| 91 | const firstProject = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); | ||
| 92 | const secondProject = makeProject({ id: ProjectId.make("project-2"), title: "Website" }); | ||
| 93 | const firstThread = makeThread({ | ||
| 94 | branch: "fix/archive-screen", | ||
| 95 | id: ThreadId.make("thread-1"), | ||
| 96 | projectId: firstProject.id, | ||
| 97 | title: "Build settings route", | ||
| 98 | }); | ||
| 99 | const secondThread = makeThread({ | ||
| 100 | id: ThreadId.make("thread-2"), | ||
| 101 | projectId: secondProject.id, | ||
| 102 | title: "Unrelated", | ||
| 103 | }); | ||
| 104 | const snapshots = [ | ||
| 105 | makeSnapshot([firstProject], [firstThread]), | ||
| 106 | makeSnapshot([secondProject], [secondThread], secondEnvironmentId), | ||
| 107 | ]; | ||
| 108 | |||
| 109 | const result = buildArchivedThreadGroups({ | ||
| 110 | snapshots, | ||
| 111 | environmentLabels: { | ||
| 112 | [environmentId]: "Local", | ||
| 113 | [secondEnvironmentId]: "Remote", | ||
| 114 | }, | ||
| 115 | environmentId, | ||
| 116 | searchQuery: "archive-screen", | ||
| 117 | sortOrder: "oldest", | ||
| 118 | }); | ||
| 119 | |||
| 120 | expect(result).toHaveLength(1); | ||
| 121 | expect(result[0]?.project.environmentId).toBe(environmentId); | ||
| 122 | expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-1"]); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("ignores non-archived entries returned in a snapshot", () => { | ||
| 126 | const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); | ||
| 127 | const active = makeThread({ | ||
| 128 | archivedAt: null, | ||
| 129 | id: ThreadId.make("thread-active"), | ||
| 130 | projectId: project.id, | ||
| 131 | title: "Active", | ||
| 132 | }); | ||
| 133 | |||
| 134 | const result = buildArchivedThreadGroups({ | ||
| 135 | snapshots: [makeSnapshot([project], [active])], | ||
| 136 | environmentLabels: {}, | ||
| 137 | environmentId: null, | ||
| 138 | searchQuery: "", | ||
| 139 | sortOrder: "newest", | ||
| 140 | }); | ||
| 141 | |||
| 142 | expect(result).toEqual([]); | ||
| 143 | }); | ||
| 144 | }); | ||
apps/mobile/src/features/archive/archivedThreadList.ts created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; | ||
| 2 | import { | ||
| 3 | scopeProject, | ||
| 4 | scopeThreadShell, | ||
| 5 | type EnvironmentProject, | ||
| 6 | type EnvironmentThreadShell, | ||
| 7 | } from "@t3tools/client-runtime/state/shell"; | ||
| 8 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 9 | import * as Arr from "effect/Array"; | ||
| 10 | import * as Order from "effect/Order"; | ||
| 11 | |||
| 12 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 13 | |||
| 14 | export type ArchivedThreadSortOrder = "newest" | "oldest"; | ||
| 15 | |||
| 16 | export interface ArchivedThreadGroup { | ||
| 17 | readonly key: string; | ||
| 18 | readonly project: EnvironmentProject; | ||
| 19 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | ||
| 20 | } | ||
| 21 | |||
| 22 | function archiveTimestamp(thread: EnvironmentThreadShell): number { | ||
| 23 | const timestamp = Date.parse(thread.archivedAt ?? thread.updatedAt); | ||
| 24 | return Number.isNaN(timestamp) ? 0 : timestamp; | ||
| 25 | } | ||
| 26 | |||
| 27 | function matchesQuery(value: string | null, query: string): boolean { | ||
| 28 | return value?.toLocaleLowerCase().includes(query) ?? false; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function buildArchivedThreadGroups(input: { | ||
| 32 | readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>; | ||
| 33 | readonly environmentLabels: Readonly<Record<string, string>>; | ||
| 34 | readonly environmentId: EnvironmentId | null; | ||
| 35 | readonly searchQuery: string; | ||
| 36 | readonly sortOrder: ArchivedThreadSortOrder; | ||
| 37 | }): ReadonlyArray<ArchivedThreadGroup> { | ||
| 38 | const query = input.searchQuery.trim().toLocaleLowerCase(); | ||
| 39 | const groups: ArchivedThreadGroup[] = []; | ||
| 40 | |||
| 41 | for (const entry of input.snapshots) { | ||
| 42 | if (input.environmentId !== null && input.environmentId !== entry.environmentId) { | ||
| 43 | continue; | ||
| 44 | } | ||
| 45 | |||
| 46 | const environmentLabel = input.environmentLabels[entry.environmentId] ?? null; | ||
| 47 | const threadsByProjectId = new Map<string, EnvironmentThreadShell[]>(); | ||
| 48 | for (const thread of entry.snapshot.threads) { | ||
| 49 | if (thread.archivedAt === null) { | ||
| 50 | continue; | ||
| 51 | } | ||
| 52 | const threads = threadsByProjectId.get(thread.projectId) ?? []; | ||
| 53 | threads.push(scopeThreadShell(entry.environmentId, thread)); | ||
| 54 | threadsByProjectId.set(thread.projectId, threads); | ||
| 55 | } | ||
| 56 | |||
| 57 | for (const rawProject of entry.snapshot.projects) { | ||
| 58 | const project = scopeProject(entry.environmentId, rawProject); | ||
| 59 | const projectThreads = threadsByProjectId.get(project.id) ?? []; | ||
| 60 | const groupMatches = | ||
| 61 | query.length === 0 || | ||
| 62 | matchesQuery(project.title, query) || | ||
| 63 | matchesQuery(project.workspaceRoot, query) || | ||
| 64 | matchesQuery(environmentLabel, query); | ||
| 65 | const matchingThreads = groupMatches | ||
| 66 | ? projectThreads | ||
| 67 | : projectThreads.filter( | ||
| 68 | (thread) => matchesQuery(thread.title, query) || matchesQuery(thread.branch, query), | ||
| 69 | ); | ||
| 70 | |||
| 71 | if (matchingThreads.length === 0) { | ||
| 72 | continue; | ||
| 73 | } | ||
| 74 | |||
| 75 | const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; | ||
| 76 | groups.push({ | ||
| 77 | key: scopedProjectKey(project.environmentId, project.id), | ||
| 78 | project, | ||
| 79 | threads: Arr.sort( | ||
| 80 | matchingThreads, | ||
| 81 | Order.mapInput( | ||
| 82 | Order.Struct({ timestamp: timestampOrder, title: Order.String, id: Order.String }), | ||
| 83 | (thread: EnvironmentThreadShell) => ({ | ||
| 84 | timestamp: archiveTimestamp(thread), | ||
| 85 | title: thread.title, | ||
| 86 | id: thread.id, | ||
| 87 | }), | ||
| 88 | ), | ||
| 89 | ), | ||
| 90 | }); | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; | ||
| 95 | return Arr.sort( | ||
| 96 | groups, | ||
| 97 | Order.mapInput( | ||
| 98 | Order.Struct({ timestamp: timestampOrder, title: Order.String, key: Order.String }), | ||
| 99 | (group: ArchivedThreadGroup) => ({ | ||
| 100 | timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : 0, | ||
| 101 | title: group.project.title, | ||
| 102 | key: group.key, | ||
| 103 | }), | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | } | ||
apps/mobile/src/features/archive/useArchivedThreadSnapshots.ts created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type ArchivedSnapshotEntry, | ||
| 4 | createArchivedThreadSnapshotsAtomFamily, | ||
| 5 | makeArchivedThreadsEnvironmentKey, | ||
| 6 | } from "@t3tools/client-runtime/state/threads"; | ||
| 7 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 8 | import { useCallback, useMemo } from "react"; | ||
| 9 | |||
| 10 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 11 | import { orchestrationEnvironment } from "../../state/orchestration"; | ||
| 12 | |||
| 13 | function archivedSnapshotAtom(environmentId: EnvironmentId) { | ||
| 14 | return orchestrationEnvironment.archivedShellSnapshot({ | ||
| 15 | environmentId, | ||
| 16 | input: {}, | ||
| 17 | }); | ||
| 18 | } | ||
| 19 | |||
| 20 | const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ | ||
| 21 | getSnapshotAtom: archivedSnapshotAtom, | ||
| 22 | labelPrefix: "mobile:archived-thread-snapshots", | ||
| 23 | }); | ||
| 24 | |||
| 25 | export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { | ||
| 26 | appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); | ||
| 27 | } | ||
| 28 | |||
| 29 | export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): { | ||
| 30 | readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>; | ||
| 31 | readonly error: string | null; | ||
| 32 | readonly isLoading: boolean; | ||
| 33 | readonly refresh: () => void; | ||
| 34 | } { | ||
| 35 | const environmentKey = useMemo( | ||
| 36 | () => makeArchivedThreadsEnvironmentKey(environmentIds), | ||
| 37 | [environmentIds], | ||
| 38 | ); | ||
| 39 | const result = useAtomValue(archivedSnapshotsAtom(environmentKey)); | ||
| 40 | const refresh = useCallback(() => { | ||
| 41 | for (const environmentId of environmentIds) { | ||
| 42 | appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); | ||
| 43 | } | ||
| 44 | }, [environmentIds]); | ||
| 45 | |||
| 46 | return { ...result, refresh }; | ||
| 47 | } | ||
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+1| ... | @@ -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 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | import { | ||
| 3 | Markdown, | ||
| 4 | type CustomRenderers, | ||
| 5 | type NodeStyleOverrides, | ||
| 6 | type PartialMarkdownTheme, | ||
| 7 | } from "react-native-nitro-markdown"; | ||
| 8 | import { Linking, ScrollView, Text as NativeText, View } from "react-native"; | ||
| 9 | |||
| 10 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 11 | import { | ||
| 12 | hasNativeSelectableMarkdownText, | ||
| 13 | SelectableMarkdownText, | ||
| 14 | type NativeMarkdownTextStyle, | ||
| 15 | } from "../../native/SelectableMarkdownText"; | ||
| 16 | |||
| 17 | interface MarkdownPreviewStyles { | ||
| 18 | readonly theme: PartialMarkdownTheme; | ||
| 19 | readonly styles: NodeStyleOverrides; | ||
| 20 | readonly renderers: CustomRenderers; | ||
| 21 | readonly nativeTextStyle: NativeMarkdownTextStyle; | ||
| 22 | } | ||
| 23 | |||
| 24 | function useMarkdownPreviewStyles(): MarkdownPreviewStyles { | ||
| 25 | const body = String(useThemeColor("--color-md-body")); | ||
| 26 | const strong = String(useThemeColor("--color-md-strong")); | ||
| 27 | const link = String(useThemeColor("--color-md-link")); | ||
| 28 | const blockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); | ||
| 29 | const blockquoteBackground = String(useThemeColor("--color-md-blockquote-bg")); | ||
| 30 | const codeBackground = String(useThemeColor("--color-md-code-bg")); | ||
| 31 | const codeText = String(useThemeColor("--color-md-code-text")); | ||
| 32 | const horizontalRule = String(useThemeColor("--color-md-hr")); | ||
| 33 | |||
| 34 | return useMemo(() => { | ||
| 35 | const renderers: CustomRenderers = { | ||
| 36 | link: ({ href, children }) => ( | ||
| 37 | <NativeText | ||
| 38 | onPress={() => { | ||
| 39 | if (href) { | ||
| 40 | void Linking.openURL(href); | ||
| 41 | } | ||
| 42 | }} | ||
| 43 | style={{ | ||
| 44 | color: link, | ||
| 45 | fontFamily: "DMSans_500Medium", | ||
| 46 | textDecorationLine: "none", | ||
| 47 | }} | ||
| 48 | > | ||
| 49 | {children} | ||
| 50 | </NativeText> | ||
| 51 | ), | ||
| 52 | }; | ||
| 53 | |||
| 54 | return { | ||
| 55 | theme: { | ||
| 56 | colors: { | ||
| 57 | text: body, | ||
| 58 | heading: strong, | ||
| 59 | link, | ||
| 60 | blockquote: blockquoteBorder, | ||
| 61 | border: horizontalRule, | ||
| 62 | surfaceLight: blockquoteBackground, | ||
| 63 | accent: link, | ||
| 64 | tableBorder: horizontalRule, | ||
| 65 | tableHeader: blockquoteBackground, | ||
| 66 | tableHeaderText: strong, | ||
| 67 | code: codeText, | ||
| 68 | codeBackground, | ||
| 69 | }, | ||
| 70 | }, | ||
| 71 | styles: { | ||
| 72 | text: { | ||
| 73 | color: body, | ||
| 74 | fontFamily: "DMSans_400Regular", | ||
| 75 | fontSize: 15, | ||
| 76 | lineHeight: 22, | ||
| 77 | }, | ||
| 78 | heading: { | ||
| 79 | color: strong, | ||
| 80 | fontFamily: "DMSans_700Bold", | ||
| 81 | }, | ||
| 82 | strong: { | ||
| 83 | color: strong, | ||
| 84 | fontFamily: "DMSans_700Bold", | ||
| 85 | }, | ||
| 86 | link: { | ||
| 87 | color: link, | ||
| 88 | fontFamily: "DMSans_500Medium", | ||
| 89 | }, | ||
| 90 | blockquote: { | ||
| 91 | backgroundColor: blockquoteBackground, | ||
| 92 | borderLeftColor: blockquoteBorder, | ||
| 93 | borderLeftWidth: 3, | ||
| 94 | paddingLeft: 12, | ||
| 95 | }, | ||
| 96 | code: { | ||
| 97 | backgroundColor: codeBackground, | ||
| 98 | color: codeText, | ||
| 99 | fontFamily: "ui-monospace", | ||
| 100 | }, | ||
| 101 | codeBlock: { | ||
| 102 | backgroundColor: codeBackground, | ||
| 103 | borderRadius: 12, | ||
| 104 | color: codeText, | ||
| 105 | fontFamily: "ui-monospace", | ||
| 106 | padding: 12, | ||
| 107 | }, | ||
| 108 | hr: { | ||
| 109 | backgroundColor: horizontalRule, | ||
| 110 | }, | ||
| 111 | }, | ||
| 112 | renderers, | ||
| 113 | nativeTextStyle: { | ||
| 114 | color: body, | ||
| 115 | strongColor: strong, | ||
| 116 | mutedColor: body, | ||
| 117 | linkColor: link, | ||
| 118 | inlineCodeColor: codeText, | ||
| 119 | codeColor: codeText, | ||
| 120 | codeBackgroundColor: codeBackground, | ||
| 121 | codeBlockBackgroundColor: codeBackground, | ||
| 122 | fileTextColor: codeText, | ||
| 123 | skillTextColor: codeText, | ||
| 124 | quoteMarkerColor: blockquoteBorder, | ||
| 125 | dividerColor: horizontalRule, | ||
| 126 | fontSize: 15, | ||
| 127 | lineHeight: 22, | ||
| 128 | fontFamily: "DMSans_400Regular", | ||
| 129 | headingFontFamily: "DMSans_700Bold", | ||
| 130 | boldFontFamily: "DMSans_700Bold", | ||
| 131 | }, | ||
| 132 | }; | ||
| 133 | }, [ | ||
| 134 | blockquoteBackground, | ||
| 135 | blockquoteBorder, | ||
| 136 | body, | ||
| 137 | codeBackground, | ||
| 138 | codeText, | ||
| 139 | horizontalRule, | ||
| 140 | link, | ||
| 141 | strong, | ||
| 142 | ]); | ||
| 143 | } | ||
| 144 | |||
| 145 | export function FileMarkdownPreview(props: { readonly markdown: string }) { | ||
| 146 | const styles = useMarkdownPreviewStyles(); | ||
| 147 | |||
| 148 | return ( | ||
| 149 | <ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}> | ||
| 150 | <View className="mx-auto w-full max-w-[760px]"> | ||
| 151 | {hasNativeSelectableMarkdownText() ? ( | ||
| 152 | <SelectableMarkdownText markdown={props.markdown} textStyle={styles.nativeTextStyle} /> | ||
| 153 | ) : ( | ||
| 154 | <Markdown | ||
| 155 | options={{ gfm: true }} | ||
| 156 | renderers={styles.renderers} | ||
| 157 | styles={styles.styles} | ||
| 158 | theme={styles.theme} | ||
| 159 | > | ||
| 160 | {props.markdown} | ||
| 161 | </Markdown> | ||
| 162 | )} | ||
| 163 | </View> | ||
| 164 | </ScrollView> | ||
| 165 | ); | ||
| 166 | } | ||
apps/mobile/src/features/files/FileTreeBrowser.tsx created+191| ... | @@ -0,0 +1,191 @@ | ||
| 1 | import type { ProjectEntry } from "@t3tools/contracts"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; | ||
| 4 | import { ActivityIndicator, FlatList, Pressable, RefreshControl, View } from "react-native"; | ||
| 5 | |||
| 6 | import { AppText as Text } from "../../components/AppText"; | ||
| 7 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; | ||
| 8 | import { cn } from "../../lib/cn"; | ||
| 9 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 10 | import { | ||
| 11 | buildFileTree, | ||
| 12 | defaultExpandedTreePaths, | ||
| 13 | flattenFileTree, | ||
| 14 | type VisibleFileTreeNode, | ||
| 15 | } from "./fileTree"; | ||
| 16 | |||
| 17 | function ancestorPaths(path: string): ReadonlyArray<string> { | ||
| 18 | const parts = path.split("/").filter(Boolean); | ||
| 19 | const ancestors: string[] = []; | ||
| 20 | for (let index = 1; index < parts.length; index += 1) { | ||
| 21 | ancestors.push(parts.slice(0, index).join("/")); | ||
| 22 | } | ||
| 23 | return ancestors; | ||
| 24 | } | ||
| 25 | |||
| 26 | const FileTreeRow = memo(function FileTreeRow(props: { | ||
| 27 | readonly item: VisibleFileTreeNode; | ||
| 28 | readonly selectedPath: string | null; | ||
| 29 | readonly expanded: boolean; | ||
| 30 | readonly iconColor: string; | ||
| 31 | readonly onPressDirectory: (path: string) => void; | ||
| 32 | readonly onPressFile: (path: string) => void; | ||
| 33 | }) { | ||
| 34 | const { node, depth } = props.item; | ||
| 35 | const selected = node.kind === "file" && node.path === props.selectedPath; | ||
| 36 | |||
| 37 | return ( | ||
| 38 | <Pressable | ||
| 39 | accessibilityRole="button" | ||
| 40 | accessibilityLabel={node.path} | ||
| 41 | onPress={() => { | ||
| 42 | if (node.kind === "directory") { | ||
| 43 | props.onPressDirectory(node.path); | ||
| 44 | return; | ||
| 45 | } | ||
| 46 | props.onPressFile(node.path); | ||
| 47 | }} | ||
| 48 | className={cn( | ||
| 49 | "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle", | ||
| 50 | selected && "bg-subtle-strong", | ||
| 51 | )} | ||
| 52 | style={{ paddingLeft: 8 + depth * 18 }} | ||
| 53 | > | ||
| 54 | {node.kind === "directory" ? ( | ||
| 55 | <SymbolView | ||
| 56 | name={props.expanded ? "chevron.down" : "chevron.right"} | ||
| 57 | size={12} | ||
| 58 | tintColor={props.iconColor} | ||
| 59 | type="monochrome" | ||
| 60 | /> | ||
| 61 | ) : ( | ||
| 62 | <View className="w-3" /> | ||
| 63 | )} | ||
| 64 | <PierreEntryIcon path={node.path} kind={node.kind} size={17} /> | ||
| 65 | <Text | ||
| 66 | className={cn( | ||
| 67 | "min-w-0 flex-1 text-[14px] leading-[19px]", | ||
| 68 | selected ? "font-t3-bold text-foreground" : "font-t3-medium text-foreground-secondary", | ||
| 69 | )} | ||
| 70 | numberOfLines={1} | ||
| 71 | > | ||
| 72 | {node.name} | ||
| 73 | </Text> | ||
| 74 | {node.kind === "directory" ? ( | ||
| 75 | <Text className="text-[11px] font-t3-medium text-foreground-tertiary"> | ||
| 76 | {node.children.length} | ||
| 77 | </Text> | ||
| 78 | ) : null} | ||
| 79 | </Pressable> | ||
| 80 | ); | ||
| 81 | }); | ||
| 82 | |||
| 83 | export function FileTreeBrowser(props: { | ||
| 84 | readonly entries: ReadonlyArray<ProjectEntry>; | ||
| 85 | readonly error: string | null; | ||
| 86 | readonly isPending: boolean; | ||
| 87 | readonly searchQuery: string; | ||
| 88 | readonly selectedPath: string | null; | ||
| 89 | readonly onRefresh: () => void; | ||
| 90 | readonly onSelectFile: (path: string) => void; | ||
| 91 | }) { | ||
| 92 | const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(() => new Set()); | ||
| 93 | const iconColor = String(useThemeColor("--color-icon-muted")); | ||
| 94 | |||
| 95 | const tree = useMemo(() => buildFileTree(props.entries), [props.entries]); | ||
| 96 | const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); | ||
| 97 | const visibleNodes = useMemo( | ||
| 98 | () => | ||
| 99 | flattenFileTree({ | ||
| 100 | nodes: tree, | ||
| 101 | expanded: expandedPaths, | ||
| 102 | searchQuery: props.searchQuery, | ||
| 103 | }), | ||
| 104 | [expandedPaths, props.searchQuery, tree], | ||
| 105 | ); | ||
| 106 | |||
| 107 | useEffect(() => { | ||
| 108 | setExpandedPaths((current) => { | ||
| 109 | if (current.size > 0 || defaultExpanded.size === 0) { | ||
| 110 | return current; | ||
| 111 | } | ||
| 112 | return new Set(defaultExpanded); | ||
| 113 | }); | ||
| 114 | }, [defaultExpanded]); | ||
| 115 | |||
| 116 | useEffect(() => { | ||
| 117 | if (!props.selectedPath) { | ||
| 118 | return; | ||
| 119 | } | ||
| 120 | setExpandedPaths((current) => { | ||
| 121 | const next = new Set(current); | ||
| 122 | for (const ancestor of ancestorPaths(props.selectedPath ?? "")) { | ||
| 123 | next.add(ancestor); | ||
| 124 | } | ||
| 125 | return next; | ||
| 126 | }); | ||
| 127 | }, [props.selectedPath]); | ||
| 128 | |||
| 129 | const toggleDirectory = useCallback((path: string) => { | ||
| 130 | setExpandedPaths((current) => { | ||
| 131 | const next = new Set(current); | ||
| 132 | if (next.has(path)) { | ||
| 133 | next.delete(path); | ||
| 134 | } else { | ||
| 135 | next.add(path); | ||
| 136 | } | ||
| 137 | return next; | ||
| 138 | }); | ||
| 139 | }, []); | ||
| 140 | |||
| 141 | return ( | ||
| 142 | <View className="flex-1 bg-sheet"> | ||
| 143 | {props.error && props.entries.length === 0 ? ( | ||
| 144 | <View className="px-4 py-5"> | ||
| 145 | <Text className="text-[13px] font-t3-bold text-foreground">Files unavailable</Text> | ||
| 146 | <Text className="mt-1 text-[12px] leading-[18px] text-foreground-muted"> | ||
| 147 | {props.error} | ||
| 148 | </Text> | ||
| 149 | </View> | ||
| 150 | ) : ( | ||
| 151 | <FlatList | ||
| 152 | data={visibleNodes} | ||
| 153 | keyExtractor={(item) => item.node.path} | ||
| 154 | contentInsetAdjustmentBehavior="automatic" | ||
| 155 | keyboardDismissMode="on-drag" | ||
| 156 | keyboardShouldPersistTaps="handled" | ||
| 157 | contentContainerStyle={{ paddingVertical: 8 }} | ||
| 158 | refreshControl={ | ||
| 159 | <RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} /> | ||
| 160 | } | ||
| 161 | renderItem={({ item }) => ( | ||
| 162 | <FileTreeRow | ||
| 163 | item={item} | ||
| 164 | selectedPath={props.selectedPath} | ||
| 165 | expanded={expandedPaths.has(item.node.path)} | ||
| 166 | iconColor={iconColor} | ||
| 167 | onPressDirectory={toggleDirectory} | ||
| 168 | onPressFile={props.onSelectFile} | ||
| 169 | /> | ||
| 170 | )} | ||
| 171 | ListEmptyComponent={ | ||
| 172 | <View className="px-4 py-5"> | ||
| 173 | {props.isPending ? ( | ||
| 174 | <ActivityIndicator size="small" /> | ||
| 175 | ) : ( | ||
| 176 | <> | ||
| 177 | <Text className="text-[13px] font-t3-bold text-foreground">No files found</Text> | ||
| 178 | <Text className="mt-1 text-[12px] leading-[18px] text-foreground-muted"> | ||
| 179 | {props.searchQuery.trim().length > 0 | ||
| 180 | ? "Try a different search." | ||
| 181 | : "The workspace file index is empty."} | ||
| 182 | </Text> | ||
| 183 | </> | ||
| 184 | )} | ||
| 185 | </View> | ||
| 186 | } | ||
| 187 | /> | ||
| 188 | )} | ||
| 189 | </View> | ||
| 190 | ); | ||
| 191 | } | ||
apps/mobile/src/features/files/SourceFileSurface.tsx created+252| ... | @@ -0,0 +1,252 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | import type { ComponentType } from "react"; | ||
| 4 | import { memo, useCallback, useEffect, useMemo, useRef } from "react"; | ||
| 5 | import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native"; | ||
| 6 | |||
| 7 | import { AppText as Text } from "../../components/AppText"; | ||
| 8 | import { LoadingStrip } from "../../components/LoadingStrip"; | ||
| 9 | import { | ||
| 10 | type NativeReviewDiffViewProps, | ||
| 11 | resolveNativeReviewDiffView, | ||
| 12 | } from "../diffs/nativeReviewDiffSurface"; | ||
| 13 | import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; | ||
| 14 | import { | ||
| 15 | REVIEW_DIFF_LINE_HEIGHT, | ||
| 16 | REVIEW_MONO_FONT_FAMILY, | ||
| 17 | renderVisibleWhitespace, | ||
| 18 | } from "../review/reviewDiffRendering"; | ||
| 19 | import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; | ||
| 20 | import { cn } from "../../lib/cn"; | ||
| 21 | import { | ||
| 22 | buildNativeSourceRows, | ||
| 23 | buildNativeSourceTokens, | ||
| 24 | NATIVE_SOURCE_CONTENT_WIDTH, | ||
| 25 | NATIVE_SOURCE_ROW_HEIGHT, | ||
| 26 | NATIVE_SOURCE_STYLE, | ||
| 27 | nativeSourceRowId, | ||
| 28 | } from "./nativeSourceFileAdapter"; | ||
| 29 | import { sourceHighlightAtom } from "./sourceHighlightingState"; | ||
| 30 | |||
| 31 | const SOURCE_LINE_HEIGHT = 24; | ||
| 32 | const SOURCE_LINE_NUMBER_WIDTH = 58; | ||
| 33 | const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE); | ||
| 34 | |||
| 35 | interface SourceFileSurfaceProps { | ||
| 36 | readonly contents: string; | ||
| 37 | readonly path: string; | ||
| 38 | readonly initialLine?: number | null; | ||
| 39 | } | ||
| 40 | |||
| 41 | type SourceHighlightStatus = "highlighting" | "ready" | "error"; | ||
| 42 | |||
| 43 | function splitSourceLines(contents: string): ReadonlyArray<string> { | ||
| 44 | return contents.replace(/\r\n?/g, "\n").split("\n"); | ||
| 45 | } | ||
| 46 | |||
| 47 | const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { | ||
| 48 | readonly index: number; | ||
| 49 | readonly line: string; | ||
| 50 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; | ||
| 51 | readonly highlighted: boolean; | ||
| 52 | }) { | ||
| 53 | return ( | ||
| 54 | <View | ||
| 55 | className={cn("flex-row", props.highlighted && "bg-primary/10")} | ||
| 56 | style={{ minHeight: SOURCE_LINE_HEIGHT }} | ||
| 57 | > | ||
| 58 | <NativeText | ||
| 59 | className="select-none pr-3 text-right text-[11px] leading-[24px] text-foreground-tertiary" | ||
| 60 | style={{ | ||
| 61 | width: SOURCE_LINE_NUMBER_WIDTH, | ||
| 62 | fontFamily: REVIEW_MONO_FONT_FAMILY, | ||
| 63 | }} | ||
| 64 | > | ||
| 65 | {props.index + 1} | ||
| 66 | </NativeText> | ||
| 67 | <NativeText | ||
| 68 | selectable | ||
| 69 | numberOfLines={1} | ||
| 70 | className="text-[13px] font-medium leading-[24px] text-foreground" | ||
| 71 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY, minWidth: 320 }} | ||
| 72 | > | ||
| 73 | {props.tokens && props.tokens.length > 0 | ||
| 74 | ? (() => { | ||
| 75 | let offset = 0; | ||
| 76 | return props.tokens.map((token) => { | ||
| 77 | const start = offset; | ||
| 78 | offset += token.content.length; | ||
| 79 | |||
| 80 | const fontWeight = | ||
| 81 | token.fontStyle !== null && (token.fontStyle & 2) === 2 | ||
| 82 | ? ("700" as const) | ||
| 83 | : ("500" as const); | ||
| 84 | const fontStyle = | ||
| 85 | token.fontStyle !== null && (token.fontStyle & 1) === 1 | ||
| 86 | ? ("italic" as const) | ||
| 87 | : ("normal" as const); | ||
| 88 | |||
| 89 | return ( | ||
| 90 | <NativeText | ||
| 91 | key={`${start}:${token.content.length}:${token.color ?? ""}`} | ||
| 92 | selectable | ||
| 93 | style={{ | ||
| 94 | color: token.color ?? undefined, | ||
| 95 | fontFamily: REVIEW_MONO_FONT_FAMILY, | ||
| 96 | fontWeight, | ||
| 97 | fontStyle, | ||
| 98 | }} | ||
| 99 | > | ||
| 100 | {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} | ||
| 101 | </NativeText> | ||
| 102 | ); | ||
| 103 | }); | ||
| 104 | })() | ||
| 105 | : renderVisibleWhitespace(props.line || " ")} | ||
| 106 | </NativeText> | ||
| 107 | </View> | ||
| 108 | ); | ||
| 109 | }); | ||
| 110 | |||
| 111 | function useSourceFileModel(props: SourceFileSurfaceProps) { | ||
| 112 | const colorScheme = useColorScheme(); | ||
| 113 | const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; | ||
| 114 | const normalizedContents = useMemo( | ||
| 115 | () => props.contents.replace(/\r\n?/g, "\n"), | ||
| 116 | [props.contents], | ||
| 117 | ); | ||
| 118 | const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]); | ||
| 119 | const targetIndex = | ||
| 120 | props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 | ||
| 121 | ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) | ||
| 122 | : null; | ||
| 123 | const highlightAtom = useMemo( | ||
| 124 | () => sourceHighlightAtom({ path: props.path, contents: normalizedContents, theme }), | ||
| 125 | [normalizedContents, props.path, theme], | ||
| 126 | ); | ||
| 127 | const highlightResult = useAtomValue(highlightAtom); | ||
| 128 | const tokens = AsyncResult.isSuccess(highlightResult) ? highlightResult.value : null; | ||
| 129 | const status: SourceHighlightStatus = AsyncResult.isFailure(highlightResult) | ||
| 130 | ? "error" | ||
| 131 | : AsyncResult.isSuccess(highlightResult) | ||
| 132 | ? "ready" | ||
| 133 | : "highlighting"; | ||
| 134 | |||
| 135 | return { lines, status, targetIndex, theme, tokens }; | ||
| 136 | } | ||
| 137 | |||
| 138 | function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { | ||
| 139 | if (props.status === "highlighting") { | ||
| 140 | return <LoadingStrip />; | ||
| 141 | } | ||
| 142 | if (props.status === "error") { | ||
| 143 | return ( | ||
| 144 | <View className="border-b border-border bg-card px-4 py-2"> | ||
| 145 | <Text className="text-[11px] font-t3-medium uppercase text-foreground-muted"> | ||
| 146 | Plain text | ||
| 147 | </Text> | ||
| 148 | </View> | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | return null; | ||
| 152 | } | ||
| 153 | |||
| 154 | function NativeSourceFileSurface( | ||
| 155 | props: SourceFileSurfaceProps & { | ||
| 156 | readonly NativeView: ComponentType<NativeReviewDiffViewProps>; | ||
| 157 | }, | ||
| 158 | ) { | ||
| 159 | const { NativeView } = props; | ||
| 160 | const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props); | ||
| 161 | const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]); | ||
| 162 | const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); | ||
| 163 | const selectedRowIdsJson = useMemo( | ||
| 164 | () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), | ||
| 165 | [targetIndex], | ||
| 166 | ); | ||
| 167 | const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); | ||
| 168 | |||
| 169 | return ( | ||
| 170 | <View className="relative flex-1 bg-card"> | ||
| 171 | <SourceHighlightStatusView status={status} /> | ||
| 172 | <NativeView | ||
| 173 | key={props.path} | ||
| 174 | collapsable={false} | ||
| 175 | testID="source-native-code-view" | ||
| 176 | style={{ flex: 1 }} | ||
| 177 | appearanceScheme={theme} | ||
| 178 | contentWidth={NATIVE_SOURCE_CONTENT_WIDTH} | ||
| 179 | initialRowIndex={targetIndex ?? -1} | ||
| 180 | rowHeight={NATIVE_SOURCE_ROW_HEIGHT} | ||
| 181 | rowsJson={rowsJson} | ||
| 182 | selectedRowIdsJson={selectedRowIdsJson} | ||
| 183 | styleJson={NATIVE_SOURCE_STYLE_JSON} | ||
| 184 | themeJson={themeJson} | ||
| 185 | tokensJson={tokensJson} | ||
| 186 | /> | ||
| 187 | </View> | ||
| 188 | ); | ||
| 189 | } | ||
| 190 | |||
| 191 | function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { | ||
| 192 | const { lines, status, targetIndex, tokens } = useSourceFileModel(props); | ||
| 193 | const listRef = useRef<FlatList<string>>(null); | ||
| 194 | |||
| 195 | useEffect(() => { | ||
| 196 | if (targetIndex === null) { | ||
| 197 | return; | ||
| 198 | } | ||
| 199 | const frame = requestAnimationFrame(() => { | ||
| 200 | listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 }); | ||
| 201 | }); | ||
| 202 | return () => cancelAnimationFrame(frame); | ||
| 203 | }, [props.path, targetIndex]); | ||
| 204 | |||
| 205 | const renderLine = useCallback( | ||
| 206 | ({ item, index }: { item: string; index: number }) => ( | ||
| 207 | <HighlightedSourceLine | ||
| 208 | index={index} | ||
| 209 | line={item} | ||
| 210 | tokens={tokens?.[index] ?? null} | ||
| 211 | highlighted={index === targetIndex} | ||
| 212 | /> | ||
| 213 | ), | ||
| 214 | [targetIndex, tokens], | ||
| 215 | ); | ||
| 216 | |||
| 217 | return ( | ||
| 218 | <View className="relative flex-1 bg-card"> | ||
| 219 | <SourceHighlightStatusView status={status} /> | ||
| 220 | <ScrollView horizontal bounces={false} className="flex-1"> | ||
| 221 | <FlatList | ||
| 222 | ref={listRef} | ||
| 223 | data={lines} | ||
| 224 | keyExtractor={(_line, index) => String(index)} | ||
| 225 | initialNumToRender={80} | ||
| 226 | maxToRenderPerBatch={80} | ||
| 227 | windowSize={12} | ||
| 228 | getItemLayout={(_data, index) => ({ | ||
| 229 | length: SOURCE_LINE_HEIGHT, | ||
| 230 | offset: SOURCE_LINE_HEIGHT * index, | ||
| 231 | index, | ||
| 232 | })} | ||
| 233 | contentContainerStyle={{ | ||
| 234 | minWidth: "100%", | ||
| 235 | paddingBottom: REVIEW_DIFF_LINE_HEIGHT, | ||
| 236 | paddingTop: 8, | ||
| 237 | }} | ||
| 238 | renderItem={renderLine} | ||
| 239 | /> | ||
| 240 | </ScrollView> | ||
| 241 | </View> | ||
| 242 | ); | ||
| 243 | } | ||
| 244 | |||
| 245 | export function SourceFileSurface(props: SourceFileSurfaceProps) { | ||
| 246 | const NativeView = resolveNativeReviewDiffView(); | ||
| 247 | return NativeView ? ( | ||
| 248 | <NativeSourceFileSurface {...props} NativeView={NativeView} /> | ||
| 249 | ) : ( | ||
| 250 | <JavaScriptSourceFileSurface {...props} /> | ||
| 251 | ); | ||
| 252 | } | ||
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx created+631| ... | @@ -0,0 +1,631 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import { useLocalSearchParams, useRouter } from "expo-router"; | ||
| 4 | import { useCallback, useMemo, useRef, useState } from "react"; | ||
| 5 | import { | ||
| 6 | ActivityIndicator, | ||
| 7 | Linking, | ||
| 8 | Pressable, | ||
| 9 | ScrollView, | ||
| 10 | Text as RNText, | ||
| 11 | View, | ||
| 12 | } from "react-native"; | ||
| 13 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | ||
| 14 | import { | ||
| 15 | EnvironmentId, | ||
| 16 | type ProjectListEntriesResult, | ||
| 17 | type ProjectReadFileResult, | ||
| 18 | ThreadId, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | |||
| 21 | import { AppText as Text } from "../../components/AppText"; | ||
| 22 | import { CopyTextButton } from "../../components/CopyTextButton"; | ||
| 23 | import { EmptyState } from "../../components/EmptyState"; | ||
| 24 | import { LoadingScreen } from "../../components/LoadingScreen"; | ||
| 25 | import { cn } from "../../lib/cn"; | ||
| 26 | import { buildThreadFilesNavigation } from "../../lib/routes"; | ||
| 27 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 28 | import { useThreadSelection } from "../../state/use-thread-selection"; | ||
| 29 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | ||
| 30 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 31 | import { projectEnvironment } from "../../state/projects"; | ||
| 32 | import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; | ||
| 33 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | ||
| 34 | import { FileTreeBrowser } from "./FileTreeBrowser"; | ||
| 35 | import { SourceFileSurface } from "./SourceFileSurface"; | ||
| 36 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; | ||
| 37 | import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; | ||
| 38 | import { | ||
| 39 | basename, | ||
| 40 | fileBreadcrumbs, | ||
| 41 | isBrowserPreviewFile, | ||
| 42 | isImagePreviewFile, | ||
| 43 | isMarkdownPreviewFile, | ||
| 44 | isSvgImagePreviewFile, | ||
| 45 | } from "./filePath"; | ||
| 46 | import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl"; | ||
| 47 | |||
| 48 | type FileViewMode = "preview" | "source"; | ||
| 49 | |||
| 50 | function firstRouteParam(value: string | string[] | undefined): string | null { | ||
| 51 | if (Array.isArray(value)) { | ||
| 52 | return value[0] ?? null; | ||
| 53 | } | ||
| 54 | |||
| 55 | return value ?? null; | ||
| 56 | } | ||
| 57 | |||
| 58 | function normalizeRoutePath(value: string | string[] | undefined): string | null { | ||
| 59 | const path = Array.isArray(value) ? value.join("/") : value; | ||
| 60 | if (path === undefined || path.trim().length === 0) { | ||
| 61 | return null; | ||
| 62 | } | ||
| 63 | return path; | ||
| 64 | } | ||
| 65 | |||
| 66 | function normalizeRouteLine(value: string | null): number | null { | ||
| 67 | if (value === null) { | ||
| 68 | return null; | ||
| 69 | } | ||
| 70 | const parsed = Number(value); | ||
| 71 | return Number.isInteger(parsed) && parsed > 0 ? parsed : null; | ||
| 72 | } | ||
| 73 | |||
| 74 | function defaultViewMode(path: string | null): FileViewMode { | ||
| 75 | return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path)) | ||
| 76 | ? "preview" | ||
| 77 | : "source"; | ||
| 78 | } | ||
| 79 | |||
| 80 | function ModeButton(props: { | ||
| 81 | readonly active: boolean; | ||
| 82 | readonly icon: "doc.text" | "eye"; | ||
| 83 | readonly label: string; | ||
| 84 | readonly onPress: () => void; | ||
| 85 | }) { | ||
| 86 | const iconColor = String( | ||
| 87 | useThemeColor(props.active ? "--color-primary-foreground" : "--color-icon-muted"), | ||
| 88 | ); | ||
| 89 | |||
| 90 | return ( | ||
| 91 | <Pressable | ||
| 92 | accessibilityRole="button" | ||
| 93 | accessibilityState={{ selected: props.active }} | ||
| 94 | className={cn( | ||
| 95 | "h-8 flex-row items-center justify-center gap-1.5 rounded-full px-3 active:opacity-70", | ||
| 96 | props.active ? "bg-primary" : "bg-subtle", | ||
| 97 | )} | ||
| 98 | onPress={props.onPress} | ||
| 99 | > | ||
| 100 | <SymbolView name={props.icon} size={13} tintColor={iconColor} type="monochrome" /> | ||
| 101 | <Text | ||
| 102 | className={cn( | ||
| 103 | "text-[12px] font-t3-bold", | ||
| 104 | props.active ? "text-primary-foreground" : "text-foreground-muted", | ||
| 105 | )} | ||
| 106 | > | ||
| 107 | {props.label} | ||
| 108 | </Text> | ||
| 109 | </Pressable> | ||
| 110 | ); | ||
| 111 | } | ||
| 112 | |||
| 113 | function BreadcrumbFade(props: { readonly color: string; readonly side: "left" | "right" }) { | ||
| 114 | const gradientId = `file-breadcrumb-${props.side}-fade`; | ||
| 115 | const isLeft = props.side === "left"; | ||
| 116 | |||
| 117 | return ( | ||
| 118 | <View | ||
| 119 | pointerEvents="none" | ||
| 120 | className={cn("absolute inset-y-0 w-7", isLeft ? "left-0" : "right-0")} | ||
| 121 | > | ||
| 122 | <Svg width="100%" height="100%"> | ||
| 123 | <Defs> | ||
| 124 | <LinearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%"> | ||
| 125 | <Stop offset="0%" stopColor={props.color} stopOpacity={isLeft ? 1 : 0} /> | ||
| 126 | <Stop offset="100%" stopColor={props.color} stopOpacity={isLeft ? 0 : 1} /> | ||
| 127 | </LinearGradient> | ||
| 128 | </Defs> | ||
| 129 | <Rect width="100%" height="100%" fill={`url(#${gradientId})`} /> | ||
| 130 | </Svg> | ||
| 131 | </View> | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | |||
| 135 | function FileBreadcrumbs(props: { readonly projectName: string; readonly relativePath: string }) { | ||
| 136 | const iconColor = String(useThemeColor("--color-icon-muted")); | ||
| 137 | const cardColor = String(useThemeColor("--color-card")); | ||
| 138 | const scrollMetrics = useRef({ contentWidth: 0, offsetX: 0, viewportWidth: 0 }); | ||
| 139 | const [fadeVisibility, setFadeVisibility] = useState({ left: false, right: false }); | ||
| 140 | const breadcrumbs = useMemo( | ||
| 141 | () => fileBreadcrumbs(props.projectName, props.relativePath), | ||
| 142 | [props.projectName, props.relativePath], | ||
| 143 | ); | ||
| 144 | const updateFadeVisibility = useCallback( | ||
| 145 | (metrics: Partial<(typeof scrollMetrics)["current"]>) => { | ||
| 146 | Object.assign(scrollMetrics.current, metrics); | ||
| 147 | const { contentWidth, offsetX, viewportWidth } = scrollMetrics.current; | ||
| 148 | const maxOffset = Math.max(0, contentWidth - viewportWidth); | ||
| 149 | const next = { | ||
| 150 | left: maxOffset > 1 && offsetX > 1, | ||
| 151 | right: maxOffset > 1 && offsetX < maxOffset - 1, | ||
| 152 | }; | ||
| 153 | |||
| 154 | setFadeVisibility((current) => | ||
| 155 | current.left === next.left && current.right === next.right ? current : next, | ||
| 156 | ); | ||
| 157 | }, | ||
| 158 | [], | ||
| 159 | ); | ||
| 160 | |||
| 161 | return ( | ||
| 162 | <View className="min-w-0 flex-1"> | ||
| 163 | <ScrollView | ||
| 164 | horizontal | ||
| 165 | showsHorizontalScrollIndicator={false} | ||
| 166 | onContentSizeChange={(contentWidth) => { | ||
| 167 | updateFadeVisibility({ contentWidth }); | ||
| 168 | }} | ||
| 169 | onLayout={(event) => { | ||
| 170 | updateFadeVisibility({ viewportWidth: event.nativeEvent.layout.width }); | ||
| 171 | }} | ||
| 172 | onScroll={(event) => { | ||
| 173 | updateFadeVisibility({ offsetX: event.nativeEvent.contentOffset.x }); | ||
| 174 | }} | ||
| 175 | scrollEventThrottle={16} | ||
| 176 | > | ||
| 177 | <View className="h-8 flex-row items-center"> | ||
| 178 | {breadcrumbs.map((crumb, index) => ( | ||
| 179 | <View key={crumb.path || "project"} className="flex-row items-center"> | ||
| 180 | {index > 0 ? ( | ||
| 181 | <SymbolView | ||
| 182 | name="chevron.right" | ||
| 183 | size={10} | ||
| 184 | tintColor={iconColor} | ||
| 185 | type="monochrome" | ||
| 186 | /> | ||
| 187 | ) : null} | ||
| 188 | <Text | ||
| 189 | className={cn( | ||
| 190 | "max-w-[180px] px-1 text-[12px]", | ||
| 191 | crumb.kind === "file" | ||
| 192 | ? "font-t3-bold text-foreground" | ||
| 193 | : "font-t3-medium text-foreground-muted", | ||
| 194 | )} | ||
| 195 | numberOfLines={1} | ||
| 196 | > | ||
| 197 | {crumb.label} | ||
| 198 | </Text> | ||
| 199 | </View> | ||
| 200 | ))} | ||
| 201 | </View> | ||
| 202 | </ScrollView> | ||
| 203 | {fadeVisibility.left ? <BreadcrumbFade color={cardColor} side="left" /> : null} | ||
| 204 | {fadeVisibility.right ? <BreadcrumbFade color={cardColor} side="right" /> : null} | ||
| 205 | </View> | ||
| 206 | ); | ||
| 207 | } | ||
| 208 | |||
| 209 | function FilePreviewHeader(props: { | ||
| 210 | readonly activeMode: FileViewMode; | ||
| 211 | readonly showModeSelector: boolean; | ||
| 212 | readonly externalPreviewUri?: string | null; | ||
| 213 | readonly projectName: string; | ||
| 214 | readonly relativePath: string; | ||
| 215 | readonly onSetMode: (mode: FileViewMode) => void; | ||
| 216 | }) { | ||
| 217 | const iconColor = String(useThemeColor("--color-icon-muted")); | ||
| 218 | |||
| 219 | return ( | ||
| 220 | <View className="border-b border-border bg-card px-3 py-2"> | ||
| 221 | <View className="flex-row items-center gap-2"> | ||
| 222 | <FileBreadcrumbs projectName={props.projectName} relativePath={props.relativePath} /> | ||
| 223 | <CopyTextButton | ||
| 224 | accessibilityLabel="Copy file path" | ||
| 225 | text={props.relativePath} | ||
| 226 | tintColor={iconColor} | ||
| 227 | buttonSize={32} | ||
| 228 | iconSize={13} | ||
| 229 | /> | ||
| 230 | </View> | ||
| 231 | {props.showModeSelector ? ( | ||
| 232 | <View className="mt-2 flex-row items-center gap-2"> | ||
| 233 | <ModeButton | ||
| 234 | active={props.activeMode === "preview"} | ||
| 235 | icon="eye" | ||
| 236 | label="Preview" | ||
| 237 | onPress={() => props.onSetMode("preview")} | ||
| 238 | /> | ||
| 239 | <ModeButton | ||
| 240 | active={props.activeMode === "source"} | ||
| 241 | icon="doc.text" | ||
| 242 | label="Source" | ||
| 243 | onPress={() => props.onSetMode("source")} | ||
| 244 | /> | ||
| 245 | {props.externalPreviewUri !== undefined ? ( | ||
| 246 | <Pressable | ||
| 247 | accessibilityRole="button" | ||
| 248 | accessibilityLabel="Open preview in Safari" | ||
| 249 | disabled={props.externalPreviewUri === null} | ||
| 250 | hitSlop={8} | ||
| 251 | className={cn( | ||
| 252 | "ml-auto h-8 w-8 items-center justify-center rounded-full bg-subtle active:opacity-70", | ||
| 253 | props.externalPreviewUri === null && "opacity-40", | ||
| 254 | )} | ||
| 255 | onPress={() => { | ||
| 256 | if (typeof props.externalPreviewUri === "string") { | ||
| 257 | void Linking.openURL(props.externalPreviewUri); | ||
| 258 | } | ||
| 259 | }} | ||
| 260 | > | ||
| 261 | <SymbolView name="safari" size={15} tintColor={iconColor} type="monochrome" /> | ||
| 262 | </Pressable> | ||
| 263 | ) : null} | ||
| 264 | </View> | ||
| 265 | ) : null} | ||
| 266 | </View> | ||
| 267 | ); | ||
| 268 | } | ||
| 269 | |||
| 270 | function FileContent(props: { | ||
| 271 | readonly activeMode: FileViewMode; | ||
| 272 | readonly previewUri: string | null; | ||
| 273 | readonly fileContents: string | null; | ||
| 274 | readonly fileError: string | null; | ||
| 275 | readonly relativePath: string; | ||
| 276 | readonly initialLine: number | null; | ||
| 277 | readonly truncated: boolean; | ||
| 278 | }) { | ||
| 279 | const isMarkdown = isMarkdownPreviewFile(props.relativePath); | ||
| 280 | const isBrowserFile = isBrowserPreviewFile(props.relativePath); | ||
| 281 | const isImageFile = isImagePreviewFile(props.relativePath); | ||
| 282 | |||
| 283 | if (props.activeMode === "preview" && isImageFile) { | ||
| 284 | if (isSvgImagePreviewFile(props.relativePath)) { | ||
| 285 | return <WorkspaceFileWebPreview uri={props.previewUri} />; | ||
| 286 | } | ||
| 287 | return ( | ||
| 288 | <WorkspaceFileImagePreview | ||
| 289 | accessibilityLabel={basename(props.relativePath)} | ||
| 290 | uri={props.previewUri} | ||
| 291 | /> | ||
| 292 | ); | ||
| 293 | } | ||
| 294 | |||
| 295 | if (props.activeMode === "preview" && isBrowserFile) { | ||
| 296 | return <WorkspaceFileWebPreview uri={props.previewUri} />; | ||
| 297 | } | ||
| 298 | |||
| 299 | if (props.fileError && props.fileContents === null) { | ||
| 300 | return ( | ||
| 301 | <View className="flex-1 items-center justify-center bg-card px-6"> | ||
| 302 | <EmptyState title="File unavailable" detail={props.fileError} /> | ||
| 303 | </View> | ||
| 304 | ); | ||
| 305 | } | ||
| 306 | |||
| 307 | if (props.fileContents === null) { | ||
| 308 | return ( | ||
| 309 | <View className="flex-1 items-center justify-center gap-3 bg-card px-6"> | ||
| 310 | <ActivityIndicator /> | ||
| 311 | <Text className="text-center text-[13px] text-foreground-muted">Loading file...</Text> | ||
| 312 | </View> | ||
| 313 | ); | ||
| 314 | } | ||
| 315 | |||
| 316 | return ( | ||
| 317 | <View className="flex-1 bg-card"> | ||
| 318 | {props.truncated ? ( | ||
| 319 | <View className="border-b border-amber-200 bg-amber-50 px-4 py-2 dark:border-amber-900/60 dark:bg-amber-950/40"> | ||
| 320 | <Text className="text-[11px] font-t3-bold uppercase text-amber-700 dark:text-amber-300"> | ||
| 321 | Partial file | ||
| 322 | </Text> | ||
| 323 | <Text className="text-[12px] leading-[17px] text-amber-800 dark:text-amber-200"> | ||
| 324 | Preview limited to the first 1 MB of a truncated file. | ||
| 325 | </Text> | ||
| 326 | </View> | ||
| 327 | ) : null} | ||
| 328 | {props.activeMode === "preview" && isMarkdown ? ( | ||
| 329 | <FileMarkdownPreview markdown={props.fileContents} /> | ||
| 330 | ) : ( | ||
| 331 | <SourceFileSurface | ||
| 332 | contents={props.fileContents} | ||
| 333 | path={props.relativePath} | ||
| 334 | initialLine={props.initialLine} | ||
| 335 | /> | ||
| 336 | )} | ||
| 337 | </View> | ||
| 338 | ); | ||
| 339 | } | ||
| 340 | |||
| 341 | function useThreadFilesWorkspace() { | ||
| 342 | const params = useLocalSearchParams<{ | ||
| 343 | environmentId?: string | string[]; | ||
| 344 | threadId?: string | string[]; | ||
| 345 | }>(); | ||
| 346 | const routeEnvironmentId = firstRouteParam(params.environmentId); | ||
| 347 | const routeThreadId = firstRouteParam(params.threadId); | ||
| 348 | const { selectedThread, selectedThreadProject } = useThreadSelection(); | ||
| 349 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | ||
| 350 | const environmentId = | ||
| 351 | routeEnvironmentId !== null | ||
| 352 | ? EnvironmentId.make(routeEnvironmentId) | ||
| 353 | : (selectedThread?.environmentId ?? null); | ||
| 354 | const threadId = routeThreadId !== null ? ThreadId.make(routeThreadId) : null; | ||
| 355 | const project = selectedThreadProject as { | ||
| 356 | readonly title?: string; | ||
| 357 | readonly workspaceRoot?: string; | ||
| 358 | } | null; | ||
| 359 | |||
| 360 | return { | ||
| 361 | cwd: selectedThreadCwd ?? project?.workspaceRoot ?? null, | ||
| 362 | environmentId, | ||
| 363 | projectName: project?.title ?? "Files", | ||
| 364 | selectedThread, | ||
| 365 | threadId, | ||
| 366 | }; | ||
| 367 | } | ||
| 368 | |||
| 369 | function FilesUnavailable() { | ||
| 370 | return ( | ||
| 371 | <View className="flex-1 items-center justify-center bg-sheet px-6"> | ||
| 372 | <Stack.Screen options={{ title: "Files" }} /> | ||
| 373 | <EmptyState | ||
| 374 | title="Files unavailable" | ||
| 375 | detail="This thread does not have an active workspace path." | ||
| 376 | /> | ||
| 377 | </View> | ||
| 378 | ); | ||
| 379 | } | ||
| 380 | |||
| 381 | function FilesHeaderTitle(props: { readonly projectName: string }) { | ||
| 382 | const foregroundColor = String(useThemeColor("--color-foreground")); | ||
| 383 | const secondaryForegroundColor = String(useThemeColor("--color-foreground-secondary")); | ||
| 384 | |||
| 385 | return ( | ||
| 386 | <View style={{ alignItems: "center", maxWidth: 220 }}> | ||
| 387 | <RNText | ||
| 388 | numberOfLines={1} | ||
| 389 | style={{ | ||
| 390 | color: foregroundColor, | ||
| 391 | fontFamily: "DMSans_700Bold", | ||
| 392 | fontSize: 18, | ||
| 393 | fontWeight: "900", | ||
| 394 | letterSpacing: -0.4, | ||
| 395 | }} | ||
| 396 | > | ||
| 397 | Files | ||
| 398 | </RNText> | ||
| 399 | <RNText | ||
| 400 | numberOfLines={1} | ||
| 401 | style={{ | ||
| 402 | color: secondaryForegroundColor, | ||
| 403 | fontFamily: "DMSans_500Medium", | ||
| 404 | fontSize: 12, | ||
| 405 | fontWeight: "500", | ||
| 406 | letterSpacing: 0.2, | ||
| 407 | }} | ||
| 408 | > | ||
| 409 | {props.projectName} | ||
| 410 | </RNText> | ||
| 411 | </View> | ||
| 412 | ); | ||
| 413 | } | ||
| 414 | |||
| 415 | function FilesToolbarBottomFade() { | ||
| 416 | const sheetColor = String(useThemeColor("--color-sheet")); | ||
| 417 | |||
| 418 | if (process.env.EXPO_OS !== "ios") { | ||
| 419 | return null; | ||
| 420 | } | ||
| 421 | |||
| 422 | return ( | ||
| 423 | <View | ||
| 424 | pointerEvents="none" | ||
| 425 | accessibilityElementsHidden | ||
| 426 | importantForAccessibility="no-hide-descendants" | ||
| 427 | style={{ | ||
| 428 | bottom: 0, | ||
| 429 | height: 112, | ||
| 430 | left: 0, | ||
| 431 | position: "absolute", | ||
| 432 | right: 0, | ||
| 433 | zIndex: 1, | ||
| 434 | }} | ||
| 435 | > | ||
| 436 | <Svg width="100%" height="100%"> | ||
| 437 | <Defs> | ||
| 438 | <LinearGradient id="files-toolbar-bottom-fade" x1="0%" x2="0%" y1="0%" y2="100%"> | ||
| 439 | <Stop offset="0%" stopColor={sheetColor} stopOpacity={0} /> | ||
| 440 | <Stop offset="58%" stopColor={sheetColor} stopOpacity={0.72} /> | ||
| 441 | <Stop offset="100%" stopColor={sheetColor} stopOpacity={0.96} /> | ||
| 442 | </LinearGradient> | ||
| 443 | </Defs> | ||
| 444 | <Rect width="100%" height="100%" fill="url(#files-toolbar-bottom-fade)" /> | ||
| 445 | </Svg> | ||
| 446 | </View> | ||
| 447 | ); | ||
| 448 | } | ||
| 449 | |||
| 450 | export function ThreadFilesTreeScreen() { | ||
| 451 | const router = useRouter(); | ||
| 452 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 453 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); | ||
| 454 | const entriesQuery = useEnvironmentQuery( | ||
| 455 | environmentId !== null && cwd !== null | ||
| 456 | ? projectEnvironment.listEntries({ | ||
| 457 | environmentId, | ||
| 458 | input: { cwd }, | ||
| 459 | }) | ||
| 460 | : null, | ||
| 461 | ); | ||
| 462 | const entriesData = entriesQuery.data as ProjectListEntriesResult | null; | ||
| 463 | |||
| 464 | const handleSelectFile = useCallback( | ||
| 465 | (path: string) => { | ||
| 466 | if (environmentId === null || threadId === null) { | ||
| 467 | return; | ||
| 468 | } | ||
| 469 | router.push(buildThreadFilesNavigation({ environmentId, threadId }, path)); | ||
| 470 | }, | ||
| 471 | [environmentId, router, threadId], | ||
| 472 | ); | ||
| 473 | |||
| 474 | if (selectedThread === null || environmentId === null || threadId === null) { | ||
| 475 | return <LoadingScreen message="Opening files..." messagePlacement="above-spinner" />; | ||
| 476 | } | ||
| 477 | |||
| 478 | if (cwd === null) { | ||
| 479 | return <FilesUnavailable />; | ||
| 480 | } | ||
| 481 | |||
| 482 | return ( | ||
| 483 | <View className="flex-1 bg-sheet"> | ||
| 484 | <Stack.Screen | ||
| 485 | options={{ | ||
| 486 | title: "Files", | ||
| 487 | headerShown: true, | ||
| 488 | headerTransparent: true, | ||
| 489 | headerStyle: { backgroundColor: "transparent" }, | ||
| 490 | headerShadowVisible: false, | ||
| 491 | headerTitle: () => <FilesHeaderTitle projectName={projectName} />, | ||
| 492 | headerSearchBarOptions: { | ||
| 493 | allowToolbarIntegration: true, | ||
| 494 | autoCapitalize: "none", | ||
| 495 | hideNavigationBar: false, | ||
| 496 | placeholder: "Search files", | ||
| 497 | onChangeText: (event) => { | ||
| 498 | setSearchQuery(event.nativeEvent.text); | ||
| 499 | }, | ||
| 500 | onCancelButtonPress: () => { | ||
| 501 | setSearchQuery(""); | ||
| 502 | }, | ||
| 503 | }, | ||
| 504 | }} | ||
| 505 | /> | ||
| 506 | <Stack.Toolbar placement="right"> | ||
| 507 | <Stack.Toolbar.Button | ||
| 508 | accessibilityLabel="Refresh files" | ||
| 509 | icon="arrow.clockwise" | ||
| 510 | onPress={entriesQuery.refresh} | ||
| 511 | /> | ||
| 512 | </Stack.Toolbar> | ||
| 513 | <Stack.Toolbar placement="bottom"> | ||
| 514 | <Stack.Toolbar.SearchBarSlot /> | ||
| 515 | </Stack.Toolbar> | ||
| 516 | <FileTreeBrowser | ||
| 517 | entries={entriesData?.entries ?? []} | ||
| 518 | error={entriesQuery.error} | ||
| 519 | isPending={entriesQuery.isPending} | ||
| 520 | searchQuery={searchQuery} | ||
| 521 | selectedPath={null} | ||
| 522 | onRefresh={entriesQuery.refresh} | ||
| 523 | onSelectFile={handleSelectFile} | ||
| 524 | /> | ||
| 525 | <FilesToolbarBottomFade /> | ||
| 526 | </View> | ||
| 527 | ); | ||
| 528 | } | ||
| 529 | |||
| 530 | export function ThreadFileScreen() { | ||
| 531 | const params = useLocalSearchParams<{ | ||
| 532 | line?: string | string[]; | ||
| 533 | path?: string | string[]; | ||
| 534 | }>(); | ||
| 535 | const relativePath = normalizeRoutePath(params.path); | ||
| 536 | const targetLine = normalizeRouteLine(firstRouteParam(params.line)); | ||
| 537 | const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(); | ||
| 538 | const [modeOverride, setModeOverride] = useState<{ | ||
| 539 | readonly path: string; | ||
| 540 | readonly mode: FileViewMode; | ||
| 541 | } | null>(null); | ||
| 542 | const [previewRevision, setPreviewRevision] = useState(0); | ||
| 543 | const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); | ||
| 544 | const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); | ||
| 545 | const canPreview = | ||
| 546 | relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile); | ||
| 547 | const activeMode = | ||
| 548 | relativePath !== null && modeOverride?.path === relativePath | ||
| 549 | ? modeOverride.mode | ||
| 550 | : defaultViewMode(relativePath); | ||
| 551 | const resolvedActiveMode = canPreview ? activeMode : "source"; | ||
| 552 | const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; | ||
| 553 | const assetPreviewUri = useWorkspaceFileAssetUrl({ | ||
| 554 | cwd, | ||
| 555 | environmentId, | ||
| 556 | relativePath: assetPreviewPath, | ||
| 557 | threadId, | ||
| 558 | }); | ||
| 559 | const previewUri = | ||
| 560 | assetPreviewUri === null || previewRevision === 0 | ||
| 561 | ? assetPreviewUri | ||
| 562 | : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; | ||
| 563 | const needsFileContents = | ||
| 564 | relativePath !== null && | ||
| 565 | (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); | ||
| 566 | const fileQuery = useEnvironmentQuery( | ||
| 567 | environmentId !== null && cwd !== null && relativePath !== null && needsFileContents | ||
| 568 | ? projectEnvironment.readFile({ | ||
| 569 | environmentId, | ||
| 570 | input: { cwd, relativePath }, | ||
| 571 | }) | ||
| 572 | : null, | ||
| 573 | ); | ||
| 574 | const fileData = fileQuery.data as ProjectReadFileResult | null; | ||
| 575 | |||
| 576 | if (selectedThread === null || environmentId === null || threadId === null) { | ||
| 577 | return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />; | ||
| 578 | } | ||
| 579 | |||
| 580 | if (cwd === null) { | ||
| 581 | return <FilesUnavailable />; | ||
| 582 | } | ||
| 583 | |||
| 584 | if (relativePath === null) { | ||
| 585 | return ( | ||
| 586 | <View className="flex-1 items-center justify-center bg-sheet px-6"> | ||
| 587 | <Stack.Screen options={{ title: "Files" }} /> | ||
| 588 | <EmptyState title="File unavailable" detail="This file path is invalid." /> | ||
| 589 | </View> | ||
| 590 | ); | ||
| 591 | } | ||
| 592 | |||
| 593 | return ( | ||
| 594 | <ReviewHighlighterProvider> | ||
| 595 | <View className="flex-1 bg-sheet"> | ||
| 596 | <Stack.Screen options={{ title: basename(relativePath) }} /> | ||
| 597 | <Stack.Toolbar placement="right"> | ||
| 598 | <Stack.Toolbar.Button | ||
| 599 | icon="arrow.clockwise" | ||
| 600 | onPress={() => { | ||
| 601 | if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) { | ||
| 602 | setPreviewRevision((current) => current + 1); | ||
| 603 | return; | ||
| 604 | } | ||
| 605 | fileQuery.refresh(); | ||
| 606 | }} | ||
| 607 | /> | ||
| 608 | </Stack.Toolbar> | ||
| 609 | <FilePreviewHeader | ||
| 610 | activeMode={resolvedActiveMode} | ||
| 611 | showModeSelector={canPreview && !isImageFile} | ||
| 612 | externalPreviewUri={isBrowserFile ? assetPreviewUri : undefined} | ||
| 613 | projectName={projectName} | ||
| 614 | relativePath={relativePath} | ||
| 615 | onSetMode={(mode) => { | ||
| 616 | setModeOverride({ path: relativePath, mode }); | ||
| 617 | }} | ||
| 618 | /> | ||
| 619 | <FileContent | ||
| 620 | activeMode={resolvedActiveMode} | ||
| 621 | previewUri={previewUri} | ||
| 622 | fileContents={fileData?.contents ?? null} | ||
| 623 | fileError={fileQuery.error} | ||
| 624 | initialLine={targetLine} | ||
| 625 | relativePath={relativePath} | ||
| 626 | truncated={fileData?.truncated ?? false} | ||
| 627 | /> | ||
| 628 | </View> | ||
| 629 | </ReviewHighlighterProvider> | ||
| 630 | ); | ||
| 631 | } | ||
apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx created+118| ... | @@ -0,0 +1,118 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useMemo, useState } from "react"; | ||
| 3 | import { ActivityIndicator, Image, Pressable, View } from "react-native"; | ||
| 4 | import ImageViewing from "react-native-image-viewing"; | ||
| 5 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 6 | |||
| 7 | import { AppText as Text } from "../../components/AppText"; | ||
| 8 | import { EmptyState } from "../../components/EmptyState"; | ||
| 9 | import { workspaceFileImageAtom } from "./workspace-file-image-cache"; | ||
| 10 | |||
| 11 | function ResolvedWorkspaceFileImagePreview(props: { | ||
| 12 | readonly accessibilityLabel: string; | ||
| 13 | readonly uri: string; | ||
| 14 | }) { | ||
| 15 | const [loadError, setLoadError] = useState<string | null>(null); | ||
| 16 | const [fullScreenVisible, setFullScreenVisible] = useState(false); | ||
| 17 | const imageSource = useMemo( | ||
| 18 | () => ({ uri: props.uri, cache: "force-cache" as const }), | ||
| 19 | [props.uri], | ||
| 20 | ); | ||
| 21 | const fullScreenImages = useMemo(() => [imageSource], [imageSource]); | ||
| 22 | |||
| 23 | return ( | ||
| 24 | <View className="relative flex-1 bg-subtle"> | ||
| 25 | <Pressable | ||
| 26 | accessibilityRole="button" | ||
| 27 | accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} | ||
| 28 | disabled={loadError !== null} | ||
| 29 | className="flex-1 p-4 active:bg-subtle-strong" | ||
| 30 | onPress={() => setFullScreenVisible(true)} | ||
| 31 | > | ||
| 32 | <Image | ||
| 33 | accessible={false} | ||
| 34 | source={imageSource} | ||
| 35 | className="h-full w-full" | ||
| 36 | resizeMode="contain" | ||
| 37 | onLoadStart={() => setLoadError(null)} | ||
| 38 | onError={(event) => { | ||
| 39 | setLoadError(event.nativeEvent.error || "The image could not be rendered."); | ||
| 40 | }} | ||
| 41 | /> | ||
| 42 | </Pressable> | ||
| 43 | |||
| 44 | {loadError !== null ? ( | ||
| 45 | <View className="absolute inset-0 items-center justify-center bg-card px-6"> | ||
| 46 | <EmptyState title="Image unavailable" detail={loadError} /> | ||
| 47 | </View> | ||
| 48 | ) : null} | ||
| 49 | |||
| 50 | <ImageViewing | ||
| 51 | images={fullScreenImages} | ||
| 52 | imageIndex={0} | ||
| 53 | visible={fullScreenVisible} | ||
| 54 | onRequestClose={() => setFullScreenVisible(false)} | ||
| 55 | swipeToCloseEnabled | ||
| 56 | doubleTapToZoomEnabled | ||
| 57 | /> | ||
| 58 | </View> | ||
| 59 | ); | ||
| 60 | } | ||
| 61 | |||
| 62 | function CachedWorkspaceFileImagePreview(props: { | ||
| 63 | readonly accessibilityLabel: string; | ||
| 64 | readonly uri: string; | ||
| 65 | }) { | ||
| 66 | const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); | ||
| 67 | const imageResult = useAtomValue(imageAtom); | ||
| 68 | |||
| 69 | if (AsyncResult.isFailure(imageResult)) { | ||
| 70 | return ( | ||
| 71 | <View className="flex-1 items-center justify-center bg-card px-6"> | ||
| 72 | <EmptyState | ||
| 73 | title="Image unavailable" | ||
| 74 | detail="The image could not be loaded into the local cache." | ||
| 75 | /> | ||
| 76 | </View> | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | |||
| 80 | if (!AsyncResult.isSuccess(imageResult)) { | ||
| 81 | return ( | ||
| 82 | <View className="flex-1 items-center justify-center gap-3 bg-card px-6"> | ||
| 83 | <ActivityIndicator /> | ||
| 84 | <Text className="text-center text-[13px] text-foreground-muted">Loading image...</Text> | ||
| 85 | </View> | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | |||
| 89 | return ( | ||
| 90 | <ResolvedWorkspaceFileImagePreview | ||
| 91 | accessibilityLabel={props.accessibilityLabel} | ||
| 92 | uri={imageResult.value} | ||
| 93 | /> | ||
| 94 | ); | ||
| 95 | } | ||
| 96 | |||
| 97 | export function WorkspaceFileImagePreview(props: { | ||
| 98 | readonly accessibilityLabel: string; | ||
| 99 | readonly uri: string | null; | ||
| 100 | }) { | ||
| 101 | if (props.uri === null) { | ||
| 102 | return ( | ||
| 103 | <View className="flex-1 items-center justify-center gap-3 bg-card px-6"> | ||
| 104 | <ActivityIndicator /> | ||
| 105 | <Text className="text-center text-[13px] text-foreground-muted"> | ||
| 106 | Preparing image preview... | ||
| 107 | </Text> | ||
| 108 | </View> | ||
| 109 | ); | ||
| 110 | } | ||
| 111 | |||
| 112 | return ( | ||
| 113 | <CachedWorkspaceFileImagePreview | ||
| 114 | accessibilityLabel={props.accessibilityLabel} | ||
| 115 | uri={props.uri} | ||
| 116 | /> | ||
| 117 | ); | ||
| 118 | } | ||
apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import { useState } from "react"; | ||
| 2 | import { ActivityIndicator, View } from "react-native"; | ||
| 3 | import { WebView } from "react-native-webview"; | ||
| 4 | |||
| 5 | import { AppText as Text } from "../../components/AppText"; | ||
| 6 | import { LoadingStrip } from "../../components/LoadingStrip"; | ||
| 7 | |||
| 8 | export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) { | ||
| 9 | const [loadProgress, setLoadProgress] = useState(0); | ||
| 10 | const [loadError, setLoadError] = useState<string | null>(null); | ||
| 11 | |||
| 12 | if (props.uri === null) { | ||
| 13 | return ( | ||
| 14 | <View className="flex-1 items-center justify-center gap-3 bg-card px-6"> | ||
| 15 | <ActivityIndicator /> | ||
| 16 | <Text className="text-center text-[13px] text-foreground-muted">Preparing preview...</Text> | ||
| 17 | </View> | ||
| 18 | ); | ||
| 19 | } | ||
| 20 | |||
| 21 | return ( | ||
| 22 | <View className="relative flex-1 bg-card"> | ||
| 23 | {loadProgress > 0 && loadProgress < 1 ? <LoadingStrip progress={loadProgress} /> : null} | ||
| 24 | {loadError ? ( | ||
| 25 | <View className="border-b border-border bg-card px-4 py-2"> | ||
| 26 | <Text className="text-[12px] font-t3-bold text-foreground">Preview failed</Text> | ||
| 27 | <Text className="mt-0.5 text-[12px] leading-[17px] text-foreground-muted"> | ||
| 28 | {loadError} | ||
| 29 | </Text> | ||
| 30 | </View> | ||
| 31 | ) : null} | ||
| 32 | <WebView | ||
| 33 | source={{ uri: props.uri }} | ||
| 34 | originWhitelist={["*"]} | ||
| 35 | allowsBackForwardNavigationGestures | ||
| 36 | allowsFullscreenVideo | ||
| 37 | setSupportMultipleWindows={false} | ||
| 38 | startInLoadingState | ||
| 39 | onLoadProgress={(event) => { | ||
| 40 | setLoadProgress(event.nativeEvent.progress); | ||
| 41 | }} | ||
| 42 | onLoadStart={() => { | ||
| 43 | setLoadProgress(0.05); | ||
| 44 | setLoadError(null); | ||
| 45 | }} | ||
| 46 | onLoadEnd={() => { | ||
| 47 | setLoadProgress(0); | ||
| 48 | }} | ||
| 49 | onError={(event) => { | ||
| 50 | setLoadProgress(0); | ||
| 51 | setLoadError(event.nativeEvent.description || "The file could not be rendered."); | ||
| 52 | }} | ||
| 53 | renderLoading={() => ( | ||
| 54 | <View className="absolute inset-0 items-center justify-center bg-card"> | ||
| 55 | <ActivityIndicator /> | ||
| 56 | </View> | ||
| 57 | )} | ||
| 58 | style={{ flex: 1, backgroundColor: "transparent" }} | ||
| 59 | /> | ||
| 60 | </View> | ||
| 61 | ); | ||
| 62 | } | ||
apps/mobile/src/features/files/filePath.test.ts created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | isBrowserPreviewFile, | ||
| 5 | isImagePreviewFile, | ||
| 6 | isSvgImagePreviewFile, | ||
| 7 | resolveWorkspaceRelativeFilePath, | ||
| 8 | } from "./filePath"; | ||
| 9 | |||
| 10 | describe("resolveWorkspaceRelativeFilePath", () => { | ||
| 11 | it("keeps normalized workspace-relative paths", () => { | ||
| 12 | expect(resolveWorkspaceRelativeFilePath("/repo", "./src/../src/main.ts")).toBe("src/main.ts"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("converts absolute paths inside the workspace", () => { | ||
| 16 | expect( | ||
| 17 | resolveWorkspaceRelativeFilePath("/Users/julius/repo", "/Users/julius/repo/src/main.ts"), | ||
| 18 | ).toBe("src/main.ts"); | ||
| 19 | expect(resolveWorkspaceRelativeFilePath("C:\\repo", "c:\\repo\\src\\main.ts")).toBe( | ||
| 20 | "src/main.ts", | ||
| 21 | ); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("rejects paths outside the workspace", () => { | ||
| 25 | expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull(); | ||
| 26 | expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull(); | ||
| 27 | expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull(); | ||
| 28 | }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | describe("file preview types", () => { | ||
| 32 | it("recognizes browser and image previews", () => { | ||
| 33 | expect(isBrowserPreviewFile("reports/summary.html")).toBe(true); | ||
| 34 | expect(isImagePreviewFile("assets/icon.png")).toBe(true); | ||
| 35 | expect(isImagePreviewFile("assets/diagram.SVG?raw=1")).toBe(true); | ||
| 36 | expect(isImagePreviewFile("src/image.ts")).toBe(false); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("identifies SVG images that need web rendering", () => { | ||
| 40 | expect(isSvgImagePreviewFile("assets/diagram.svg#icon")).toBe(true); | ||
| 41 | expect(isSvgImagePreviewFile("assets/photo.png")).toBe(false); | ||
| 42 | }); | ||
| 43 | }); | ||
apps/mobile/src/features/files/filePath.ts created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | import { | ||
| 2 | isWorkspaceBrowserPreviewPath, | ||
| 3 | isWorkspaceImagePreviewPath, | ||
| 4 | } from "@t3tools/shared/filePreview"; | ||
| 5 | |||
| 6 | export interface FileBreadcrumb { | ||
| 7 | readonly label: string; | ||
| 8 | readonly path: string; | ||
| 9 | readonly kind: "project" | "directory" | "file"; | ||
| 10 | } | ||
| 11 | |||
| 12 | function isWindowsAbsolutePath(value: string): boolean { | ||
| 13 | return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); | ||
| 14 | } | ||
| 15 | |||
| 16 | function isAbsolutePath(value: string): boolean { | ||
| 17 | return value.startsWith("/") || isWindowsAbsolutePath(value); | ||
| 18 | } | ||
| 19 | |||
| 20 | function isWindowsPathStyle(value: string): boolean { | ||
| 21 | return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value); | ||
| 22 | } | ||
| 23 | |||
| 24 | function joinPath(base: string, next: string, separator: "/" | "\\"): string { | ||
| 25 | const cleanBase = base.replace(/[\\/]+$/, ""); | ||
| 26 | if (separator === "\\") { | ||
| 27 | return `${cleanBase}\\${next.replaceAll("/", "\\")}`; | ||
| 28 | } | ||
| 29 | return `${cleanBase}/${next.replace(/^\/+/, "")}`; | ||
| 30 | } | ||
| 31 | |||
| 32 | export function basename(path: string): string { | ||
| 33 | const parts = path.split(/[\\/]/).filter(Boolean); | ||
| 34 | return parts.at(-1) ?? path; | ||
| 35 | } | ||
| 36 | |||
| 37 | export function resolveWorkspaceFilePath(cwd: string, relativePath: string): string { | ||
| 38 | if (isAbsolutePath(relativePath)) { | ||
| 39 | return relativePath; | ||
| 40 | } | ||
| 41 | |||
| 42 | const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; | ||
| 43 | return joinPath(cwd, relativePath, separator); | ||
| 44 | } | ||
| 45 | |||
| 46 | function normalizeRelativePath(value: string): string | null { | ||
| 47 | const segments: string[] = []; | ||
| 48 | for (const segment of value.replaceAll("\\", "/").split("/")) { | ||
| 49 | if (segment.length === 0 || segment === ".") { | ||
| 50 | continue; | ||
| 51 | } | ||
| 52 | if (segment === "..") { | ||
| 53 | if (segments.length === 0) { | ||
| 54 | return null; | ||
| 55 | } | ||
| 56 | segments.pop(); | ||
| 57 | continue; | ||
| 58 | } | ||
| 59 | segments.push(segment); | ||
| 60 | } | ||
| 61 | return segments.length > 0 ? segments.join("/") : null; | ||
| 62 | } | ||
| 63 | |||
| 64 | export function resolveWorkspaceRelativeFilePath( | ||
| 65 | workspaceRoot: string | null | undefined, | ||
| 66 | targetPath: string, | ||
| 67 | ): string | null { | ||
| 68 | if (!isAbsolutePath(targetPath)) { | ||
| 69 | if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) { | ||
| 70 | return null; | ||
| 71 | } | ||
| 72 | return normalizeRelativePath(targetPath); | ||
| 73 | } | ||
| 74 | if (!workspaceRoot) { | ||
| 75 | return null; | ||
| 76 | } | ||
| 77 | |||
| 78 | const normalizedTarget = targetPath.replaceAll("\\", "/"); | ||
| 79 | const normalizedRoot = workspaceRoot.replaceAll("\\", "/").replace(/\/+$/, ""); | ||
| 80 | const caseInsensitive = isWindowsAbsolutePath(targetPath) || isWindowsAbsolutePath(workspaceRoot); | ||
| 81 | const comparableTarget = caseInsensitive ? normalizedTarget.toLowerCase() : normalizedTarget; | ||
| 82 | const comparableRoot = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; | ||
| 83 | if (!comparableTarget.startsWith(`${comparableRoot}/`)) { | ||
| 84 | return null; | ||
| 85 | } | ||
| 86 | |||
| 87 | return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1)); | ||
| 88 | } | ||
| 89 | |||
| 90 | export function isBrowserPreviewFile(path: string): boolean { | ||
| 91 | return isWorkspaceBrowserPreviewPath(path); | ||
| 92 | } | ||
| 93 | |||
| 94 | export function isImagePreviewFile(path: string): boolean { | ||
| 95 | return isWorkspaceImagePreviewPath(path); | ||
| 96 | } | ||
| 97 | |||
| 98 | export function isSvgImagePreviewFile(path: string): boolean { | ||
| 99 | return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? ""); | ||
| 100 | } | ||
| 101 | |||
| 102 | export function isMarkdownPreviewFile(path: string): boolean { | ||
| 103 | return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); | ||
| 104 | } | ||
| 105 | |||
| 106 | export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] { | ||
| 107 | const parts = relativePath.split("/").filter(Boolean); | ||
| 108 | return [ | ||
| 109 | { label: projectName, path: "", kind: "project" }, | ||
| 110 | ...parts.map((part, index) => ({ | ||
| 111 | label: part, | ||
| 112 | path: parts.slice(0, index + 1).join("/"), | ||
| 113 | kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), | ||
| 114 | })), | ||
| 115 | ]; | ||
| 116 | } | ||
apps/mobile/src/features/files/fileTree.test.ts created+109| ... | @@ -0,0 +1,109 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import type { ProjectEntry } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | buildFileTree, | ||
| 6 | countFileNodes, | ||
| 7 | defaultExpandedTreePaths, | ||
| 8 | firstFilePath, | ||
| 9 | flattenFileTree, | ||
| 10 | } from "./fileTree"; | ||
| 11 | |||
| 12 | const entries = [ | ||
| 13 | { kind: "file", path: "README.md" }, | ||
| 14 | { kind: "directory", path: "src" }, | ||
| 15 | { kind: "file", path: "src/index.ts" }, | ||
| 16 | { kind: "file", path: "src/components/App.tsx" }, | ||
| 17 | { kind: "file", path: "package.json" }, | ||
| 18 | ] satisfies ReadonlyArray<ProjectEntry>; | ||
| 19 | |||
| 20 | describe("mobile file tree helpers", () => { | ||
| 21 | it("builds a deterministic hierarchy with directories before files", () => { | ||
| 22 | const tree = buildFileTree(entries); | ||
| 23 | |||
| 24 | expect(tree.map((node) => `${node.kind}:${node.path}`)).toEqual([ | ||
| 25 | "directory:src", | ||
| 26 | "file:package.json", | ||
| 27 | "file:README.md", | ||
| 28 | ]); | ||
| 29 | expect(tree[0]?.children.map((node) => `${node.kind}:${node.path}`)).toEqual([ | ||
| 30 | "directory:src/components", | ||
| 31 | "file:src/index.ts", | ||
| 32 | ]); | ||
| 33 | expect(countFileNodes(tree)).toBe(4); | ||
| 34 | expect(firstFilePath(tree)).toBe("src/components/App.tsx"); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("flattens expanded directories and hides collapsed descendants", () => { | ||
| 38 | const tree = buildFileTree(entries); | ||
| 39 | |||
| 40 | expect( | ||
| 41 | flattenFileTree({ | ||
| 42 | nodes: tree, | ||
| 43 | expanded: new Set(["src"]), | ||
| 44 | }).map((item) => `${item.depth}:${item.node.path}`), | ||
| 45 | ).toEqual(["0:src", "1:src/components", "1:src/index.ts", "0:package.json", "0:README.md"]); | ||
| 46 | |||
| 47 | expect( | ||
| 48 | flattenFileTree({ | ||
| 49 | nodes: tree, | ||
| 50 | expanded: new Set(), | ||
| 51 | }).map((item) => item.node.path), | ||
| 52 | ).toEqual(["src", "package.json", "README.md"]); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("includes matching descendants and their ancestors during search", () => { | ||
| 56 | const tree = buildFileTree(entries); | ||
| 57 | |||
| 58 | expect( | ||
| 59 | flattenFileTree({ | ||
| 60 | nodes: tree, | ||
| 61 | expanded: new Set(), | ||
| 62 | searchQuery: "app", | ||
| 63 | }).map((item) => item.node.path), | ||
| 64 | ).toEqual(["src", "src/components", "src/components/App.tsx"]); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("supports fuzzy, whitespace-separated path queries", () => { | ||
| 68 | const tree = buildFileTree([ | ||
| 69 | { | ||
| 70 | kind: "file", | ||
| 71 | path: ".plans/19-version-control-phase-1-vcs-driver-foundation.md", | ||
| 72 | }, | ||
| 73 | { | ||
| 74 | kind: "file", | ||
| 75 | path: ".repos/alchemy-effect/examples/aws-lambda/src/JobNotifications.ts", | ||
| 76 | }, | ||
| 77 | { kind: "directory", path: "apps/web/src/components/chat" }, | ||
| 78 | { kind: "file", path: "apps/web/src/components/chat/ChatHeader.test.ts" }, | ||
| 79 | { kind: "file", path: "apps/web/src/components/chat/ChatHeader.tsx" }, | ||
| 80 | { kind: "file", path: "apps/web/src/components/chat/Composer.tsx" }, | ||
| 81 | ]); | ||
| 82 | |||
| 83 | const expectedPaths = [ | ||
| 84 | "apps", | ||
| 85 | "apps/web", | ||
| 86 | "apps/web/src", | ||
| 87 | "apps/web/src/components", | ||
| 88 | "apps/web/src/components/chat", | ||
| 89 | "apps/web/src/components/chat/ChatHeader.test.ts", | ||
| 90 | "apps/web/src/components/chat/ChatHeader.tsx", | ||
| 91 | ]; | ||
| 92 | |||
| 93 | for (const searchQuery of ["chat hea", "cht hdr"]) { | ||
| 94 | expect( | ||
| 95 | flattenFileTree({ | ||
| 96 | nodes: tree, | ||
| 97 | expanded: new Set(), | ||
| 98 | searchQuery, | ||
| 99 | }).map((item) => item.node.path), | ||
| 100 | ).toEqual(expectedPaths); | ||
| 101 | } | ||
| 102 | }); | ||
| 103 | |||
| 104 | it("expands top-level directories by default", () => { | ||
| 105 | const tree = buildFileTree(entries); | ||
| 106 | |||
| 107 | expect([...defaultExpandedTreePaths(tree)]).toEqual(["src"]); | ||
| 108 | }); | ||
| 109 | }); | ||
apps/mobile/src/features/files/fileTree.ts created+220| ... | @@ -0,0 +1,220 @@ | ||
| 1 | import type { ProjectEntry } from "@t3tools/contracts"; | ||
| 2 | import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; | ||
| 3 | |||
| 4 | export interface FileTreeNode { | ||
| 5 | readonly path: string; | ||
| 6 | readonly name: string; | ||
| 7 | readonly kind: ProjectEntry["kind"]; | ||
| 8 | readonly children: ReadonlyArray<FileTreeNode>; | ||
| 9 | readonly searchSegments: ReadonlyArray<string>; | ||
| 10 | readonly searchWords: ReadonlyArray<string>; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface VisibleFileTreeNode { | ||
| 14 | readonly node: FileTreeNode; | ||
| 15 | readonly depth: number; | ||
| 16 | } | ||
| 17 | |||
| 18 | interface MutableFileTreeNode { | ||
| 19 | path: string; | ||
| 20 | name: string; | ||
| 21 | kind: ProjectEntry["kind"]; | ||
| 22 | children: Map<string, MutableFileTreeNode>; | ||
| 23 | } | ||
| 24 | |||
| 25 | function createMutableNode( | ||
| 26 | path: string, | ||
| 27 | name: string, | ||
| 28 | kind: ProjectEntry["kind"], | ||
| 29 | ): MutableFileTreeNode { | ||
| 30 | return { | ||
| 31 | path, | ||
| 32 | name, | ||
| 33 | kind, | ||
| 34 | children: new Map(), | ||
| 35 | }; | ||
| 36 | } | ||
| 37 | |||
| 38 | function splitSearchWords(value: string): ReadonlyArray<string> { | ||
| 39 | return value | ||
| 40 | .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") | ||
| 41 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | ||
| 42 | .split(/[^A-Za-z0-9]+/) | ||
| 43 | .filter(Boolean) | ||
| 44 | .map((word) => word.toLowerCase()); | ||
| 45 | } | ||
| 46 | |||
| 47 | function buildNodeSearchTerms(path: string): { | ||
| 48 | readonly segments: ReadonlyArray<string>; | ||
| 49 | readonly words: ReadonlyArray<string>; | ||
| 50 | } { | ||
| 51 | const segments: string[] = []; | ||
| 52 | const words: string[] = []; | ||
| 53 | |||
| 54 | for (const segment of path.split("/")) { | ||
| 55 | if (!segment) { | ||
| 56 | continue; | ||
| 57 | } | ||
| 58 | segments.push(segment.toLowerCase()); | ||
| 59 | words.push(...splitSearchWords(segment)); | ||
| 60 | } | ||
| 61 | |||
| 62 | return { segments, words }; | ||
| 63 | } | ||
| 64 | |||
| 65 | function freezeNode(node: MutableFileTreeNode): FileTreeNode { | ||
| 66 | const searchTerms = buildNodeSearchTerms(node.path); | ||
| 67 | return { | ||
| 68 | path: node.path, | ||
| 69 | name: node.name, | ||
| 70 | kind: node.kind, | ||
| 71 | children: [...node.children.values()].sort(compareNodes).map(freezeNode), | ||
| 72 | searchSegments: searchTerms.segments, | ||
| 73 | searchWords: searchTerms.words, | ||
| 74 | }; | ||
| 75 | } | ||
| 76 | |||
| 77 | function compareNodes( | ||
| 78 | left: Pick<FileTreeNode, "kind" | "name">, | ||
| 79 | right: Pick<FileTreeNode, "kind" | "name">, | ||
| 80 | ): number { | ||
| 81 | if (left.kind !== right.kind) { | ||
| 82 | return left.kind === "directory" ? -1 : 1; | ||
| 83 | } | ||
| 84 | return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }); | ||
| 85 | } | ||
| 86 | |||
| 87 | export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArray<FileTreeNode> { | ||
| 88 | const root = createMutableNode("", "", "directory"); | ||
| 89 | |||
| 90 | for (const entry of entries) { | ||
| 91 | const parts = entry.path.split("/").filter(Boolean); | ||
| 92 | if (parts.length === 0) { | ||
| 93 | continue; | ||
| 94 | } | ||
| 95 | |||
| 96 | let current = root; | ||
| 97 | for (let index = 0; index < parts.length; index += 1) { | ||
| 98 | const part = parts[index]; | ||
| 99 | if (!part) { | ||
| 100 | continue; | ||
| 101 | } | ||
| 102 | |||
| 103 | const path = parts.slice(0, index + 1).join("/"); | ||
| 104 | const isLeaf = index === parts.length - 1; | ||
| 105 | const kind = isLeaf ? entry.kind : "directory"; | ||
| 106 | let child = current.children.get(part); | ||
| 107 | if (!child) { | ||
| 108 | child = createMutableNode(path, part, kind); | ||
| 109 | current.children.set(part, child); | ||
| 110 | } else if (isLeaf) { | ||
| 111 | child.kind = entry.kind; | ||
| 112 | } | ||
| 113 | current = child; | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | return [...root.children.values()].sort(compareNodes).map(freezeNode); | ||
| 118 | } | ||
| 119 | |||
| 120 | export function countFileNodes(nodes: ReadonlyArray<FileTreeNode>): number { | ||
| 121 | let count = 0; | ||
| 122 | for (const node of nodes) { | ||
| 123 | if (node.kind === "file") { | ||
| 124 | count += 1; | ||
| 125 | } else { | ||
| 126 | count += countFileNodes(node.children); | ||
| 127 | } | ||
| 128 | } | ||
| 129 | return count; | ||
| 130 | } | ||
| 131 | |||
| 132 | export function defaultExpandedTreePaths(nodes: ReadonlyArray<FileTreeNode>): ReadonlySet<string> { | ||
| 133 | const expanded = new Set<string>(); | ||
| 134 | for (const node of nodes) { | ||
| 135 | if (node.kind === "directory") { | ||
| 136 | expanded.add(node.path); | ||
| 137 | } | ||
| 138 | } | ||
| 139 | return expanded; | ||
| 140 | } | ||
| 141 | |||
| 142 | function valueMatchesSearchToken(value: string, token: string, fuzzy: boolean): boolean { | ||
| 143 | return ( | ||
| 144 | scoreQueryMatch({ | ||
| 145 | value, | ||
| 146 | query: token, | ||
| 147 | exactBase: 0, | ||
| 148 | prefixBase: 2, | ||
| 149 | boundaryBase: 4, | ||
| 150 | includesBase: 6, | ||
| 151 | ...(fuzzy ? { fuzzyBase: 100 } : {}), | ||
| 152 | boundaryMarkers: ["/", "-", "_", "."], | ||
| 153 | }) !== null | ||
| 154 | ); | ||
| 155 | } | ||
| 156 | |||
| 157 | function nodeMatchesSearch(node: FileTreeNode, tokens: ReadonlyArray<string>): boolean { | ||
| 158 | return tokens.every( | ||
| 159 | (token) => | ||
| 160 | node.searchSegments.some((segment) => valueMatchesSearchToken(segment, token, false)) || | ||
| 161 | node.searchWords.some((word) => valueMatchesSearchToken(word, token, true)), | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | |||
| 165 | function flattenNode( | ||
| 166 | output: VisibleFileTreeNode[], | ||
| 167 | node: FileTreeNode, | ||
| 168 | depth: number, | ||
| 169 | expanded: ReadonlySet<string>, | ||
| 170 | searchTokens: ReadonlyArray<string>, | ||
| 171 | ): boolean { | ||
| 172 | const isSearching = searchTokens.length > 0; | ||
| 173 | const matches = isSearching && nodeMatchesSearch(node, searchTokens); | ||
| 174 | let descendantMatches = false; | ||
| 175 | const childOutput: VisibleFileTreeNode[] = []; | ||
| 176 | |||
| 177 | if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) { | ||
| 178 | for (const child of node.children) { | ||
| 179 | if (flattenNode(childOutput, child, depth + 1, expanded, searchTokens)) { | ||
| 180 | descendantMatches = true; | ||
| 181 | } | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | const visible = !isSearching || matches || descendantMatches; | ||
| 186 | if (!visible) { | ||
| 187 | return false; | ||
| 188 | } | ||
| 189 | |||
| 190 | output.push({ node, depth }); | ||
| 191 | output.push(...childOutput); | ||
| 192 | return matches || descendantMatches; | ||
| 193 | } | ||
| 194 | |||
| 195 | export function flattenFileTree(input: { | ||
| 196 | readonly nodes: ReadonlyArray<FileTreeNode>; | ||
| 197 | readonly expanded: ReadonlySet<string>; | ||
| 198 | readonly searchQuery?: string; | ||
| 199 | }): ReadonlyArray<VisibleFileTreeNode> { | ||
| 200 | const output: VisibleFileTreeNode[] = []; | ||
| 201 | const normalizedSearch = normalizeSearchQuery(input.searchQuery ?? ""); | ||
| 202 | const searchTokens = normalizedSearch.split(/[\s/\\._-]+/).filter(Boolean); | ||
| 203 | for (const node of input.nodes) { | ||
| 204 | flattenNode(output, node, 0, input.expanded, searchTokens); | ||
| 205 | } | ||
| 206 | return output; | ||
| 207 | } | ||
| 208 | |||
| 209 | export function firstFilePath(nodes: ReadonlyArray<FileTreeNode>): string | null { | ||
| 210 | for (const node of nodes) { | ||
| 211 | if (node.kind === "file") { | ||
| 212 | return node.path; | ||
| 213 | } | ||
| 214 | const child = firstFilePath(node.children); | ||
| 215 | if (child !== null) { | ||
| 216 | return child; | ||
| 217 | } | ||
| 218 | } | ||
| 219 | return null; | ||
| 220 | } | ||
apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | buildNativeSourceRows, | ||
| 5 | buildNativeSourceTokens, | ||
| 6 | nativeSourceRowId, | ||
| 7 | } from "./nativeSourceFileAdapter"; | ||
| 8 | |||
| 9 | describe("nativeSourceFileAdapter", () => { | ||
| 10 | it("maps plain source lines onto context rows with stable line numbers", () => { | ||
| 11 | expect(buildNativeSourceRows(["const value = 1;", "\treturn value;"])).toEqual([ | ||
| 12 | { | ||
| 13 | kind: "line", | ||
| 14 | id: nativeSourceRowId(0), | ||
| 15 | fileId: "source-file", | ||
| 16 | content: "const value = 1;", | ||
| 17 | change: "context", | ||
| 18 | newLineNumber: 1, | ||
| 19 | }, | ||
| 20 | { | ||
| 21 | kind: "line", | ||
| 22 | id: nativeSourceRowId(1), | ||
| 23 | fileId: "source-file", | ||
| 24 | content: " return value;", | ||
| 25 | change: "context", | ||
| 26 | newLineNumber: 2, | ||
| 27 | }, | ||
| 28 | ]); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("maps cached source tokens to the same row identifiers", () => { | ||
| 32 | expect( | ||
| 33 | buildNativeSourceTokens([ | ||
| 34 | [{ content: "const", color: "#ff0000", fontStyle: 2 }], | ||
| 35 | [{ content: "\tvalue", color: null, fontStyle: null }], | ||
| 36 | ]), | ||
| 37 | ).toEqual({ | ||
| 38 | [nativeSourceRowId(0)]: [{ content: "const", color: "#ff0000", fontStyle: 2 }], | ||
| 39 | [nativeSourceRowId(1)]: [{ content: " value", color: null, fontStyle: null }], | ||
| 40 | }); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("clears native tokens while highlighting is unavailable", () => { | ||
| 44 | expect(buildNativeSourceTokens(null)).toEqual({}); | ||
| 45 | }); | ||
| 46 | }); | ||
apps/mobile/src/features/files/nativeSourceFileAdapter.ts created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | import type { | ||
| 2 | NativeReviewDiffRow, | ||
| 3 | NativeReviewDiffStyle, | ||
| 4 | NativeReviewDiffToken, | ||
| 5 | } from "../diffs/nativeReviewDiffSurface"; | ||
| 6 | import type { SourceHighlightTokens } from "./sourceHighlightingState"; | ||
| 7 | |||
| 8 | export const NATIVE_SOURCE_ROW_HEIGHT = 24; | ||
| 9 | export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; | ||
| 10 | |||
| 11 | export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = { | ||
| 12 | rowHeight: NATIVE_SOURCE_ROW_HEIGHT, | ||
| 13 | contentWidth: NATIVE_SOURCE_CONTENT_WIDTH, | ||
| 14 | changeBarWidth: 0, | ||
| 15 | gutterWidth: 58, | ||
| 16 | codePadding: 8, | ||
| 17 | codeFontSize: 13, | ||
| 18 | codeFontWeight: "medium", | ||
| 19 | lineNumberFontSize: 11, | ||
| 20 | lineNumberFontWeight: "medium", | ||
| 21 | emptyStateFontSize: 12, | ||
| 22 | emptyStateFontWeight: "medium", | ||
| 23 | }; | ||
| 24 | |||
| 25 | const SOURCE_FILE_ID = "source-file"; | ||
| 26 | |||
| 27 | function expandTabs(value: string): string { | ||
| 28 | return value.replace(/\t/g, " "); | ||
| 29 | } | ||
| 30 | |||
| 31 | export function nativeSourceRowId(index: number): string { | ||
| 32 | return `source-line:${index}`; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function buildNativeSourceRows( | ||
| 36 | lines: ReadonlyArray<string>, | ||
| 37 | ): ReadonlyArray<NativeReviewDiffRow> { | ||
| 38 | return lines.map((line, index) => ({ | ||
| 39 | kind: "line", | ||
| 40 | id: nativeSourceRowId(index), | ||
| 41 | fileId: SOURCE_FILE_ID, | ||
| 42 | content: expandTabs(line), | ||
| 43 | change: "context", | ||
| 44 | newLineNumber: index + 1, | ||
| 45 | })); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function buildNativeSourceTokens( | ||
| 49 | tokenLines: SourceHighlightTokens | null, | ||
| 50 | ): Readonly<Record<string, ReadonlyArray<NativeReviewDiffToken>>> { | ||
| 51 | if (tokenLines === null) { | ||
| 52 | return {}; | ||
| 53 | } | ||
| 54 | |||
| 55 | return Object.fromEntries( | ||
| 56 | tokenLines.map((tokens, index) => [ | ||
| 57 | nativeSourceRowId(index), | ||
| 58 | tokens.map((token) => ({ | ||
| 59 | content: expandTabs(token.content), | ||
| 60 | color: token.color, | ||
| 61 | fontStyle: token.fontStyle, | ||
| 62 | })), | ||
| 63 | ]), | ||
| 64 | ); | ||
| 65 | } | ||
apps/mobile/src/features/files/sourceHighlightingState.test.ts created+123| ... | @@ -0,0 +1,123 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | createSourceHighlightAtomFamily, | ||
| 7 | type SourceHighlightTokens, | ||
| 8 | } from "./sourceHighlightingState"; | ||
| 9 | |||
| 10 | const highlightedTokens: SourceHighlightTokens = [ | ||
| 11 | [{ content: "const", color: "#0000ff", fontStyle: null }], | ||
| 12 | ]; | ||
| 13 | |||
| 14 | afterEach(() => { | ||
| 15 | vi.restoreAllMocks(); | ||
| 16 | }); | ||
| 17 | |||
| 18 | describe("sourceHighlightingState", () => { | ||
| 19 | it("reuses completed highlighting across equivalent route remounts", async () => { | ||
| 20 | const highlight = vi.fn(async () => highlightedTokens); | ||
| 21 | const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight, idleTtlMs: 1_000 }); | ||
| 22 | const registry = AtomRegistry.make({ timeoutResolution: 1 }); | ||
| 23 | const input = { | ||
| 24 | path: "src/example.ts", | ||
| 25 | contents: "const value = 1;", | ||
| 26 | theme: "light" as const, | ||
| 27 | }; | ||
| 28 | const firstAtom = sourceHighlightAtom(input); | ||
| 29 | const firstUnmount = registry.mount(firstAtom); | ||
| 30 | |||
| 31 | await vi.waitFor(() => { | ||
| 32 | expect(AsyncResult.isSuccess(registry.get(firstAtom))).toBe(true); | ||
| 33 | }); | ||
| 34 | firstUnmount(); | ||
| 35 | |||
| 36 | const remountedAtom = sourceHighlightAtom({ ...input }); | ||
| 37 | const secondUnmount = registry.mount(remountedAtom); | ||
| 38 | |||
| 39 | expect(remountedAtom).toBe(firstAtom); | ||
| 40 | expect(AsyncResult.isSuccess(registry.get(remountedAtom))).toBe(true); | ||
| 41 | expect(highlight).toHaveBeenCalledTimes(1); | ||
| 42 | |||
| 43 | secondUnmount(); | ||
| 44 | registry.dispose(); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("does not reuse highlighting when the source contents change", async () => { | ||
| 48 | const highlight = vi.fn(async () => highlightedTokens); | ||
| 49 | const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight }); | ||
| 50 | const registry = AtomRegistry.make(); | ||
| 51 | const firstAtom = sourceHighlightAtom({ | ||
| 52 | path: "src/example.ts", | ||
| 53 | contents: "const value = 1;", | ||
| 54 | theme: "light", | ||
| 55 | }); | ||
| 56 | const secondAtom = sourceHighlightAtom({ | ||
| 57 | path: "src/example.ts", | ||
| 58 | contents: "const value = 2;", | ||
| 59 | theme: "light", | ||
| 60 | }); | ||
| 61 | const firstUnmount = registry.mount(firstAtom); | ||
| 62 | const secondUnmount = registry.mount(secondAtom); | ||
| 63 | |||
| 64 | await vi.waitFor(() => { | ||
| 65 | expect(AsyncResult.isSuccess(registry.get(firstAtom))).toBe(true); | ||
| 66 | expect(AsyncResult.isSuccess(registry.get(secondAtom))).toBe(true); | ||
| 67 | }); | ||
| 68 | expect(secondAtom).not.toBe(firstAtom); | ||
| 69 | expect(highlight).toHaveBeenCalledTimes(2); | ||
| 70 | |||
| 71 | firstUnmount(); | ||
| 72 | secondUnmount(); | ||
| 73 | registry.dispose(); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("recomputes highlighting after the idle cache entry expires", async () => { | ||
| 77 | const highlight = vi.fn(async () => highlightedTokens); | ||
| 78 | const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight, idleTtlMs: 5 }); | ||
| 79 | const registry = AtomRegistry.make({ timeoutResolution: 1 }); | ||
| 80 | const atom = sourceHighlightAtom({ | ||
| 81 | path: "src/example.ts", | ||
| 82 | contents: "const value = 1;", | ||
| 83 | theme: "light", | ||
| 84 | }); | ||
| 85 | const firstUnmount = registry.mount(atom); | ||
| 86 | |||
| 87 | await vi.waitFor(() => { | ||
| 88 | expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); | ||
| 89 | }); | ||
| 90 | firstUnmount(); | ||
| 91 | await new Promise((resolve) => setTimeout(resolve, 25)); | ||
| 92 | |||
| 93 | const secondUnmount = registry.mount(atom); | ||
| 94 | await vi.waitFor(() => { | ||
| 95 | expect(highlight).toHaveBeenCalledTimes(2); | ||
| 96 | expect(AsyncResult.isSuccess(registry.get(atom))).toBe(true); | ||
| 97 | }); | ||
| 98 | |||
| 99 | secondUnmount(); | ||
| 100 | registry.dispose(); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("exposes highlighter errors as a failed async result", async () => { | ||
| 104 | const highlight = vi.fn(async () => { | ||
| 105 | throw new Error("highlight failed"); | ||
| 106 | }); | ||
| 107 | const sourceHighlightAtom = createSourceHighlightAtomFamily({ highlight }); | ||
| 108 | const registry = AtomRegistry.make(); | ||
| 109 | const atom = sourceHighlightAtom({ | ||
| 110 | path: "src/example.ts", | ||
| 111 | contents: "const value = 1;", | ||
| 112 | theme: "light", | ||
| 113 | }); | ||
| 114 | const unmount = registry.mount(atom); | ||
| 115 | |||
| 116 | await vi.waitFor(() => { | ||
| 117 | expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); | ||
| 118 | }); | ||
| 119 | |||
| 120 | unmount(); | ||
| 121 | registry.dispose(); | ||
| 122 | }); | ||
| 123 | }); | ||
apps/mobile/src/features/files/sourceHighlightingState.ts created+50| ... | @@ -0,0 +1,50 @@ | ||
| 1 | import * as Data from "effect/Data"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | highlightSourceFile, | ||
| 7 | type ReviewDiffTheme, | ||
| 8 | type ReviewHighlightedToken, | ||
| 9 | } from "../review/shikiReviewHighlighter"; | ||
| 10 | |||
| 11 | const SOURCE_HIGHLIGHT_IDLE_TTL_MS = 5 * 60_000; | ||
| 12 | |||
| 13 | export interface SourceHighlightInput { | ||
| 14 | readonly path: string; | ||
| 15 | readonly contents: string; | ||
| 16 | readonly theme: ReviewDiffTheme; | ||
| 17 | } | ||
| 18 | |||
| 19 | export type SourceHighlightTokens = ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>; | ||
| 20 | |||
| 21 | type SourceHighlighter = (input: SourceHighlightInput) => Promise<SourceHighlightTokens>; | ||
| 22 | |||
| 23 | class SourceHighlightCacheKey extends Data.Class<SourceHighlightInput> {} | ||
| 24 | |||
| 25 | class SourceHighlightError extends Data.TaggedError("SourceHighlightError")<{ | ||
| 26 | readonly cause: unknown; | ||
| 27 | }> {} | ||
| 28 | |||
| 29 | export function createSourceHighlightAtomFamily(options?: { | ||
| 30 | readonly highlight?: SourceHighlighter; | ||
| 31 | readonly idleTtlMs?: number; | ||
| 32 | }) { | ||
| 33 | const highlight = options?.highlight ?? highlightSourceFile; | ||
| 34 | const idleTtlMs = options?.idleTtlMs ?? SOURCE_HIGHLIGHT_IDLE_TTL_MS; | ||
| 35 | const family = Atom.family((request: SourceHighlightCacheKey) => | ||
| 36 | Atom.make( | ||
| 37 | Effect.tryPromise({ | ||
| 38 | try: () => highlight(request), | ||
| 39 | catch: (cause) => new SourceHighlightError({ cause }), | ||
| 40 | }), | ||
| 41 | ).pipe( | ||
| 42 | Atom.setIdleTTL(idleTtlMs), | ||
| 43 | Atom.withLabel(`mobile:source-highlight:${request.theme}:${request.path}`), | ||
| 44 | ), | ||
| 45 | ); | ||
| 46 | |||
| 47 | return (input: SourceHighlightInput) => family(new SourceHighlightCacheKey(input)); | ||
| 48 | } | ||
| 49 | |||
| 50 | export const sourceHighlightAtom = createSourceHighlightAtomFamily(); | ||
apps/mobile/src/features/files/workspace-file-image-cache.test.ts created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { createWorkspaceFileImageAtomFamily } from "./workspace-file-image-cache"; | ||
| 6 | |||
| 7 | describe("workspaceFileImageAtom", () => { | ||
| 8 | it("reuses a prefetched image across route remounts", async () => { | ||
| 9 | const prefetch = vi.fn(async () => true); | ||
| 10 | const imageAtom = createWorkspaceFileImageAtomFamily({ idleTtlMs: 1_000, prefetch }); | ||
| 11 | const registry = AtomRegistry.make({ timeoutResolution: 1 }); | ||
| 12 | const first = imageAtom("https://example.test/image.png"); | ||
| 13 | const firstUnmount = registry.mount(first); | ||
| 14 | |||
| 15 | await vi.waitFor(() => { | ||
| 16 | expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); | ||
| 17 | }); | ||
| 18 | firstUnmount(); | ||
| 19 | |||
| 20 | const remounted = imageAtom("https://example.test/image.png"); | ||
| 21 | const secondUnmount = registry.mount(remounted); | ||
| 22 | |||
| 23 | expect(remounted).toBe(first); | ||
| 24 | expect(AsyncResult.isSuccess(registry.get(remounted))).toBe(true); | ||
| 25 | expect(prefetch).toHaveBeenCalledTimes(1); | ||
| 26 | |||
| 27 | secondUnmount(); | ||
| 28 | registry.dispose(); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("prefetches different asset URLs independently", async () => { | ||
| 32 | const prefetch = vi.fn(async () => true); | ||
| 33 | const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch }); | ||
| 34 | const registry = AtomRegistry.make(); | ||
| 35 | const first = imageAtom("https://example.test/first.png"); | ||
| 36 | const second = imageAtom("https://example.test/second.png"); | ||
| 37 | const firstUnmount = registry.mount(first); | ||
| 38 | const secondUnmount = registry.mount(second); | ||
| 39 | |||
| 40 | await vi.waitFor(() => { | ||
| 41 | expect(AsyncResult.isSuccess(registry.get(first))).toBe(true); | ||
| 42 | expect(AsyncResult.isSuccess(registry.get(second))).toBe(true); | ||
| 43 | }); | ||
| 44 | expect(prefetch).toHaveBeenCalledTimes(2); | ||
| 45 | |||
| 46 | firstUnmount(); | ||
| 47 | secondUnmount(); | ||
| 48 | registry.dispose(); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("exposes prefetch failures", async () => { | ||
| 52 | const imageAtom = createWorkspaceFileImageAtomFamily({ prefetch: async () => false }); | ||
| 53 | const registry = AtomRegistry.make(); | ||
| 54 | const atom = imageAtom("https://example.test/missing.png"); | ||
| 55 | const unmount = registry.mount(atom); | ||
| 56 | |||
| 57 | await vi.waitFor(() => { | ||
| 58 | expect(AsyncResult.isFailure(registry.get(atom))).toBe(true); | ||
| 59 | }); | ||
| 60 | |||
| 61 | unmount(); | ||
| 62 | registry.dispose(); | ||
| 63 | }); | ||
| 64 | }); | ||
apps/mobile/src/features/files/workspace-file-image-cache.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import * as Data from "effect/Data"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | const WORKSPACE_IMAGE_IDLE_TTL_MS = 30 * 60_000; | ||
| 6 | |||
| 7 | type ImagePrefetch = (uri: string) => Promise<boolean>; | ||
| 8 | |||
| 9 | class WorkspaceImageCacheKey extends Data.Class<{ readonly uri: string }> {} | ||
| 10 | |||
| 11 | export class WorkspaceImagePrefetchError extends Data.TaggedError("WorkspaceImagePrefetchError")<{ | ||
| 12 | readonly cause?: unknown; | ||
| 13 | readonly uri: string; | ||
| 14 | }> {} | ||
| 15 | |||
| 16 | async function prefetchWithNativeImage(uri: string): Promise<boolean> { | ||
| 17 | const { Image } = await import("react-native"); | ||
| 18 | return Image.prefetch(uri); | ||
| 19 | } | ||
| 20 | |||
| 21 | export function createWorkspaceFileImageAtomFamily(options?: { | ||
| 22 | readonly idleTtlMs?: number; | ||
| 23 | readonly prefetch?: ImagePrefetch; | ||
| 24 | }) { | ||
| 25 | const idleTtlMs = options?.idleTtlMs ?? WORKSPACE_IMAGE_IDLE_TTL_MS; | ||
| 26 | const prefetch = options?.prefetch ?? prefetchWithNativeImage; | ||
| 27 | const family = Atom.family((key: WorkspaceImageCacheKey) => | ||
| 28 | Atom.make( | ||
| 29 | Effect.tryPromise({ | ||
| 30 | try: async () => { | ||
| 31 | const cached = await prefetch(key.uri); | ||
| 32 | if (!cached) { | ||
| 33 | throw new WorkspaceImagePrefetchError({ uri: key.uri }); | ||
| 34 | } | ||
| 35 | return key.uri; | ||
| 36 | }, | ||
| 37 | catch: (cause) => | ||
| 38 | cause instanceof WorkspaceImagePrefetchError | ||
| 39 | ? cause | ||
| 40 | : new WorkspaceImagePrefetchError({ uri: key.uri, cause }), | ||
| 41 | }), | ||
| 42 | ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`mobile:workspace-image:${key.uri}`)), | ||
| 43 | ); | ||
| 44 | |||
| 45 | return (uri: string) => family(new WorkspaceImageCacheKey({ uri })); | ||
| 46 | } | ||
| 47 | |||
| 48 | export const workspaceFileImageAtom = createWorkspaceFileImageAtomFamily(); | ||
apps/mobile/src/features/files/workspaceFileAssetUrl.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { useMemo } from "react"; | ||
| 3 | |||
| 4 | import { useAssetUrl } from "../../state/assets"; | ||
| 5 | import { resolveWorkspaceFilePath } from "./filePath"; | ||
| 6 | |||
| 7 | export function useWorkspaceFileAssetUrl(props: { | ||
| 8 | readonly cwd: string | null; | ||
| 9 | readonly environmentId: EnvironmentId | null; | ||
| 10 | readonly relativePath: string | null; | ||
| 11 | readonly threadId: ThreadId | null; | ||
| 12 | }) { | ||
| 13 | const absolutePath = useMemo( | ||
| 14 | () => | ||
| 15 | props.cwd !== null && props.relativePath !== null | ||
| 16 | ? resolveWorkspaceFilePath(props.cwd, props.relativePath) | ||
| 17 | : null, | ||
| 18 | [props.cwd, props.relativePath], | ||
| 19 | ); | ||
| 20 | |||
| 21 | return useAssetUrl( | ||
| 22 | props.environmentId, | ||
| 23 | absolutePath !== null && props.threadId !== null | ||
| 24 | ? { | ||
| 25 | _tag: "workspace-file", | ||
| 26 | threadId: props.threadId, | ||
| 27 | path: absolutePath, | ||
| 28 | } | ||
| 29 | : null, | ||
| 30 | ); | ||
| 31 | } | ||
apps/mobile/src/features/home/HomeHeader.tsx created+244| ... | @@ -0,0 +1,244 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | SidebarProjectGroupingMode, | ||
| 4 | SidebarThreadSortOrder, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | ||
| 8 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | ||
| 9 | DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { Stack } from "expo-router"; | ||
| 12 | import { Text as RNText, View } from "react-native"; | ||
| 13 | |||
| 14 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 15 | import type { HomeProjectSortOrder } from "./homeThreadList"; | ||
| 16 | |||
| 17 | export interface HomeHeaderEnvironment { | ||
| 18 | readonly environmentId: EnvironmentId; | ||
| 19 | readonly label: string; | ||
| 20 | } | ||
| 21 | |||
| 22 | const PROJECT_SORT_OPTIONS: ReadonlyArray<{ | ||
| 23 | readonly value: HomeProjectSortOrder; | ||
| 24 | readonly label: string; | ||
| 25 | }> = [ | ||
| 26 | { value: "updated_at", label: "Last user message" }, | ||
| 27 | { value: "created_at", label: "Created at" }, | ||
| 28 | ]; | ||
| 29 | |||
| 30 | const THREAD_SORT_OPTIONS: ReadonlyArray<{ | ||
| 31 | readonly value: SidebarThreadSortOrder; | ||
| 32 | readonly label: string; | ||
| 33 | }> = [ | ||
| 34 | { value: "updated_at", label: "Last user message" }, | ||
| 35 | { value: "created_at", label: "Created at" }, | ||
| 36 | ]; | ||
| 37 | |||
| 38 | const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{ | ||
| 39 | readonly value: SidebarProjectGroupingMode; | ||
| 40 | readonly label: string; | ||
| 41 | readonly subtitle: string; | ||
| 42 | }> = [ | ||
| 43 | { | ||
| 44 | value: "repository", | ||
| 45 | label: "Group by repository", | ||
| 46 | subtitle: "Combine matching repositories across environments", | ||
| 47 | }, | ||
| 48 | { | ||
| 49 | value: "repository_path", | ||
| 50 | label: "Group by repository path", | ||
| 51 | subtitle: "Combine only matching paths within a repository", | ||
| 52 | }, | ||
| 53 | { | ||
| 54 | value: "separate", | ||
| 55 | label: "Keep separate", | ||
| 56 | subtitle: "Show every project path separately", | ||
| 57 | }, | ||
| 58 | ]; | ||
| 59 | |||
| 60 | export function HomeHeader(props: { | ||
| 61 | readonly environments: ReadonlyArray<HomeHeaderEnvironment>; | ||
| 62 | readonly selectedEnvironmentId: EnvironmentId | null; | ||
| 63 | readonly projectSortOrder: HomeProjectSortOrder; | ||
| 64 | readonly threadSortOrder: SidebarThreadSortOrder; | ||
| 65 | readonly projectGroupingMode: SidebarProjectGroupingMode; | ||
| 66 | readonly onSearchQueryChange: (query: string) => void; | ||
| 67 | readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; | ||
| 68 | readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; | ||
| 69 | readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; | ||
| 70 | readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; | ||
| 71 | readonly onOpenSettings: () => void; | ||
| 72 | readonly onStartNewTask: () => void; | ||
| 73 | }) { | ||
| 74 | const iconColor = useThemeColor("--color-icon"); | ||
| 75 | const mutedColor = useThemeColor("--color-foreground-muted"); | ||
| 76 | const subtleColor = useThemeColor("--color-subtle"); | ||
| 77 | const hasCustomListOptions = | ||
| 78 | props.selectedEnvironmentId !== null || | ||
| 79 | props.projectSortOrder !== DEFAULT_SIDEBAR_PROJECT_SORT_ORDER || | ||
| 80 | props.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || | ||
| 81 | props.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE; | ||
| 82 | |||
| 83 | return ( | ||
| 84 | <> | ||
| 85 | <Stack.Screen | ||
| 86 | options={{ | ||
| 87 | headerShown: true, | ||
| 88 | headerTransparent: true, | ||
| 89 | headerStyle: { backgroundColor: "transparent" }, | ||
| 90 | headerShadowVisible: false, | ||
| 91 | headerTintColor: iconColor, | ||
| 92 | headerTitle: "", | ||
| 93 | headerSearchBarOptions: { | ||
| 94 | placeholder: "Search threads", | ||
| 95 | hideNavigationBar: false, | ||
| 96 | onChangeText: (event) => { | ||
| 97 | props.onSearchQueryChange(event.nativeEvent.text); | ||
| 98 | }, | ||
| 99 | onCancelButtonPress: () => { | ||
| 100 | props.onSearchQueryChange(""); | ||
| 101 | }, | ||
| 102 | allowToolbarIntegration: true, | ||
| 103 | }, | ||
| 104 | }} | ||
| 105 | /> | ||
| 106 | |||
| 107 | <Stack.Toolbar placement="left"> | ||
| 108 | <Stack.Toolbar.View hidesSharedBackground> | ||
| 109 | <View | ||
| 110 | style={{ | ||
| 111 | width: 128, | ||
| 112 | height: 32, | ||
| 113 | flexDirection: "row", | ||
| 114 | alignItems: "center", | ||
| 115 | gap: 8, | ||
| 116 | }} | ||
| 117 | > | ||
| 118 | <RNText | ||
| 119 | style={{ | ||
| 120 | fontFamily: "DMSans_700Bold", | ||
| 121 | fontSize: 17, | ||
| 122 | color: iconColor, | ||
| 123 | letterSpacing: -0.4, | ||
| 124 | }} | ||
| 125 | > | ||
| 126 | T3 Code | ||
| 127 | </RNText> | ||
| 128 | <View | ||
| 129 | style={{ | ||
| 130 | backgroundColor: subtleColor, | ||
| 131 | borderRadius: 99, | ||
| 132 | paddingHorizontal: 8, | ||
| 133 | paddingVertical: 3, | ||
| 134 | }} | ||
| 135 | > | ||
| 136 | <RNText | ||
| 137 | style={{ | ||
| 138 | fontFamily: "DMSans_700Bold", | ||
| 139 | fontSize: 10, | ||
| 140 | color: mutedColor, | ||
| 141 | letterSpacing: 1.1, | ||
| 142 | textTransform: "uppercase", | ||
| 143 | }} | ||
| 144 | > | ||
| 145 | Alpha | ||
| 146 | </RNText> | ||
| 147 | </View> | ||
| 148 | </View> | ||
| 149 | </Stack.Toolbar.View> | ||
| 150 | </Stack.Toolbar> | ||
| 151 | |||
| 152 | <Stack.Toolbar placement="right"> | ||
| 153 | <Stack.Toolbar.Menu | ||
| 154 | accessibilityLabel="Filter and sort threads" | ||
| 155 | icon={ | ||
| 156 | hasCustomListOptions | ||
| 157 | ? "line.3.horizontal.decrease.circle.fill" | ||
| 158 | : "line.3.horizontal.decrease.circle" | ||
| 159 | } | ||
| 160 | separateBackground | ||
| 161 | title="Thread list options" | ||
| 162 | > | ||
| 163 | <Stack.Toolbar.Menu title="Environment"> | ||
| 164 | <Stack.Toolbar.Label>Environment</Stack.Toolbar.Label> | ||
| 165 | <Stack.Toolbar.MenuAction | ||
| 166 | isOn={props.selectedEnvironmentId === null} | ||
| 167 | onPress={() => props.onEnvironmentChange(null)} | ||
| 168 | subtitle="Show threads from every environment" | ||
| 169 | > | ||
| 170 | <Stack.Toolbar.Label>All environments</Stack.Toolbar.Label> | ||
| 171 | </Stack.Toolbar.MenuAction> | ||
| 172 | {props.environments.map((environment) => ( | ||
| 173 | <Stack.Toolbar.MenuAction | ||
| 174 | key={environment.environmentId} | ||
| 175 | isOn={props.selectedEnvironmentId === environment.environmentId} | ||
| 176 | onPress={() => props.onEnvironmentChange(environment.environmentId)} | ||
| 177 | > | ||
| 178 | <Stack.Toolbar.Label>{environment.label}</Stack.Toolbar.Label> | ||
| 179 | </Stack.Toolbar.MenuAction> | ||
| 180 | ))} | ||
| 181 | </Stack.Toolbar.Menu> | ||
| 182 | |||
| 183 | <Stack.Toolbar.Menu title="Sort projects"> | ||
| 184 | <Stack.Toolbar.Label>Sort projects</Stack.Toolbar.Label> | ||
| 185 | {PROJECT_SORT_OPTIONS.map((option) => ( | ||
| 186 | <Stack.Toolbar.MenuAction | ||
| 187 | key={option.value} | ||
| 188 | isOn={props.projectSortOrder === option.value} | ||
| 189 | onPress={() => props.onProjectSortOrderChange(option.value)} | ||
| 190 | > | ||
| 191 | <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label> | ||
| 192 | </Stack.Toolbar.MenuAction> | ||
| 193 | ))} | ||
| 194 | </Stack.Toolbar.Menu> | ||
| 195 | |||
| 196 | <Stack.Toolbar.Menu title="Sort threads"> | ||
| 197 | <Stack.Toolbar.Label>Sort threads</Stack.Toolbar.Label> | ||
| 198 | {THREAD_SORT_OPTIONS.map((option) => ( | ||
| 199 | <Stack.Toolbar.MenuAction | ||
| 200 | key={option.value} | ||
| 201 | isOn={props.threadSortOrder === option.value} | ||
| 202 | onPress={() => props.onThreadSortOrderChange(option.value)} | ||
| 203 | > | ||
| 204 | <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label> | ||
| 205 | </Stack.Toolbar.MenuAction> | ||
| 206 | ))} | ||
| 207 | </Stack.Toolbar.Menu> | ||
| 208 | |||
| 209 | <Stack.Toolbar.Menu title="Group projects"> | ||
| 210 | <Stack.Toolbar.Label>Group projects</Stack.Toolbar.Label> | ||
| 211 | {PROJECT_GROUPING_OPTIONS.map((option) => ( | ||
| 212 | <Stack.Toolbar.MenuAction | ||
| 213 | key={option.value} | ||
| 214 | isOn={props.projectGroupingMode === option.value} | ||
| 215 | onPress={() => props.onProjectGroupingModeChange(option.value)} | ||
| 216 | subtitle={option.subtitle} | ||
| 217 | > | ||
| 218 | <Stack.Toolbar.Label>{option.label}</Stack.Toolbar.Label> | ||
| 219 | </Stack.Toolbar.MenuAction> | ||
| 220 | ))} | ||
| 221 | </Stack.Toolbar.Menu> | ||
| 222 | </Stack.Toolbar.Menu> | ||
| 223 | |||
| 224 | <Stack.Toolbar.Button | ||
| 225 | accessibilityLabel="Open settings" | ||
| 226 | icon="gearshape" | ||
| 227 | onPress={props.onOpenSettings} | ||
| 228 | separateBackground | ||
| 229 | /> | ||
| 230 | </Stack.Toolbar> | ||
| 231 | |||
| 232 | <Stack.Toolbar placement="bottom"> | ||
| 233 | <Stack.Toolbar.SearchBarSlot /> | ||
| 234 | <Stack.Toolbar.Spacer width={8} sharesBackground={false} /> | ||
| 235 | <Stack.Toolbar.Button | ||
| 236 | accessibilityLabel="New task" | ||
| 237 | icon="square.and.pencil" | ||
| 238 | onPress={props.onStartNewTask} | ||
| 239 | separateBackground | ||
| 240 | /> | ||
| 241 | </Stack.Toolbar> | ||
| 242 | </> | ||
| 243 | ); | ||
| 244 | } | ||
apps/mobile/src/features/home/HomeScreen.tsx+287-139| ... | @@ -2,12 +2,26 @@ import { | ... | @@ -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"; |
| 5 | import type { | ||
| 6 | EnvironmentId, | ||
| 7 | SidebarProjectGroupingMode, | ||
| 8 | SidebarThreadSortOrder, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Haptics from "expo-haptics"; | ||
| 5 | import { SymbolView } from "expo-symbols"; | 11 | import { SymbolView } from "expo-symbols"; |
| 6 | import { useCallback, useMemo, useState } from "react"; | 12 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 7 | import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; | 13 | import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; |
| 14 | import ReanimatedSwipeable, { | ||
| 15 | type SwipeableMethods, | ||
| 16 | } from "react-native-gesture-handler/ReanimatedSwipeable"; | ||
| 17 | import Animated, { | ||
| 18 | Easing, | ||
| 19 | LinearTransition, | ||
| 20 | type ExitAnimationsValues, | ||
| 21 | withDelay, | ||
| 22 | withTiming, | ||
| 23 | } from "react-native-reanimated"; | ||
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 24 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 9 | import * as Arr from "effect/Array"; | ||
| 10 | import * as Order from "effect/Order"; | ||
| 11 | import { useThemeColor } from "../../lib/useThemeColor"; | 25 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 12 | 26 | ||
| 13 | import { AppText as Text } from "../../components/AppText"; | 27 | import { AppText as Text } from "../../components/AppText"; |
| ... | @@ -15,9 +29,14 @@ import { EmptyState } from "../../components/EmptyState"; | ... | @@ -15,9 +29,14 @@ import { EmptyState } from "../../components/EmptyState"; |
| 15 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 29 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 16 | import type { WorkspaceState } from "../../state/workspaceModel"; | 30 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 17 | import type { SavedRemoteConnection } from "../../lib/connection"; | 31 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 18 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 19 | import { relativeTime } from "../../lib/time"; | 32 | import { relativeTime } from "../../lib/time"; |
| 20 | import { threadStatusTone } from "../threads/threadPresentation"; | 33 | import { threadStatusTone } from "../threads/threadPresentation"; |
| 34 | import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; | ||
| 35 | import { | ||
| 36 | THREAD_SWIPE_ACTIONS_WIDTH, | ||
| 37 | THREAD_SWIPE_SPRING, | ||
| 38 | ThreadSwipeActions, | ||
| 39 | } from "./thread-swipe-actions"; | ||
| 21 | 40 | ||
| 22 | /* ─── Types ──────────────────────────────────────────────────────────── */ | 41 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 23 | 42 | ||
| ... | @@ -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 | } |
| 34 | 59 | ||
| 35 | interface ProjectGroup { | ||
| 36 | readonly key: string; | ||
| 37 | readonly project: EnvironmentProject; | ||
| 38 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | ||
| 39 | } | ||
| 40 | |||
| 41 | const projectGroupActivityOrder = Order.mapInput( | ||
| 42 | Order.Struct({ | ||
| 43 | activityAt: Order.flip(Order.Number), | ||
| 44 | }), | ||
| 45 | (group: ProjectGroup) => ({ | ||
| 46 | activityAt: new Date(group.threads[0]!.updatedAt ?? group.threads[0]!.createdAt).getTime(), | ||
| 47 | }), | ||
| 48 | ); | ||
| 49 | |||
| 50 | /* ─── Status indicator colors ────────────────────────────────────────── */ | 60 | /* ─── Status indicator colors ────────────────────────────────────────── */ |
| 51 | 61 | ||
| 52 | function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { | 62 | function 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 | } |
| 66 | 76 | ||
| 67 | const COLLAPSED_THREAD_LIMIT = 6; | 77 | const COLLAPSED_THREAD_LIMIT = 6; |
| 78 | const THREAD_LAYOUT_TRANSITION = LinearTransition.duration(220).easing(Easing.out(Easing.cubic)); | ||
| 79 | |||
| 80 | function threadRowExit(values: ExitAnimationsValues) { | ||
| 81 | "worklet"; | ||
| 82 | |||
| 83 | return { | ||
| 84 | initialValues: { | ||
| 85 | height: values.currentHeight, | ||
| 86 | opacity: 1, | ||
| 87 | originX: values.currentOriginX, | ||
| 88 | }, | ||
| 89 | animations: { | ||
| 90 | height: withDelay( | ||
| 91 | 90, | ||
| 92 | withTiming(0, { | ||
| 93 | duration: 170, | ||
| 94 | easing: Easing.inOut(Easing.cubic), | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | opacity: withDelay(80, withTiming(0, { duration: 100 })), | ||
| 98 | originX: withTiming(values.currentOriginX - values.windowWidth, { | ||
| 99 | duration: 190, | ||
| 100 | easing: Easing.out(Easing.cubic), | ||
| 101 | }), | ||
| 102 | }, | ||
| 103 | }; | ||
| 104 | } | ||
| 68 | 105 | ||
| 69 | function deriveEmptyState(props: { | 106 | function deriveEmptyState(props: { |
| 70 | readonly catalogState: WorkspaceState; | 107 | readonly catalogState: WorkspaceState; |
| ... | @@ -133,6 +170,7 @@ function deriveEmptyState(props: { | ... | @@ -133,6 +170,7 @@ function deriveEmptyState(props: { |
| 133 | 170 | ||
| 134 | function ProjectGroupLabel(props: { | 171 | function 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> |
| 157 | 195 | ||
| 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 | }, []); | ||
| 189 | 244 | ||
| 190 | return ( | 245 | return ( |
| 191 | <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> | 246 | <ReanimatedSwipeable |
| 192 | <View | 247 | 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 | <View | 310 | <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={{ |
| 217 | 323 | 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 | <Text | 328 | 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> |
| 245 | 334 | ||
| 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 | <Text | 337 | <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 | } |
| 269 | 384 | ||
| ... | @@ -323,6 +438,7 @@ function StaleCatalogStatusPill(props: { | ... | @@ -323,6 +438,7 @@ function StaleCatalogStatusPill(props: { |
| 323 | 438 | ||
| 324 | export function HomeScreen(props: HomeScreenProps) { | 439 | export 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"); |
| 328 | 444 | ||
| ... | @@ -335,51 +451,50 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -335,51 +451,50 @@ export function HomeScreen(props: HomeScreenProps) { |
| 335 | }); | 451 | }); |
| 336 | }, []); | 452 | }, []); |
| 337 | 453 | ||
| 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 | }, []); | ||
| 367 | 460 | ||
| 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 | }, []); | ||
| 376 | 466 | ||
| 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 | ); | ||
| 379 | 488 | ||
| 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); |
| 429 | 555 | ||
| 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 | <ProjectGroupLabel | 564 | <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 | <View | 571 | <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 | <ThreadRow | 576 | 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 ?? null | 580 | 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 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentProject, | ||
| 3 | EnvironmentThreadShell, | ||
| 4 | } from "@t3tools/client-runtime/state/shell"; | ||
| 5 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import { buildHomeThreadGroups } from "./homeThreadList"; | ||
| 9 | |||
| 10 | function makeProject( | ||
| 11 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, | ||
| 12 | ): EnvironmentProject { | ||
| 13 | return { | ||
| 14 | workspaceRoot: `/workspaces/${input.id}`, | ||
| 15 | repositoryIdentity: null, | ||
| 16 | defaultModelSelection: null, | ||
| 17 | scripts: [], | ||
| 18 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 19 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 20 | ...input, | ||
| 21 | }; | ||
| 22 | } | ||
| 23 | |||
| 24 | function makeThread( | ||
| 25 | input: Partial<EnvironmentThreadShell> & | ||
| 26 | Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title">, | ||
| 27 | ): EnvironmentThreadShell { | ||
| 28 | return { | ||
| 29 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 30 | runtimeMode: "full-access", | ||
| 31 | interactionMode: "default", | ||
| 32 | branch: null, | ||
| 33 | worktreePath: null, | ||
| 34 | latestTurn: null, | ||
| 35 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 36 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 37 | archivedAt: null, | ||
| 38 | session: null, | ||
| 39 | latestUserMessageAt: null, | ||
| 40 | hasPendingApprovals: false, | ||
| 41 | hasPendingUserInput: false, | ||
| 42 | hasActionableProposedPlan: false, | ||
| 43 | ...input, | ||
| 44 | }; | ||
| 45 | } | ||
| 46 | |||
| 47 | function buildGroups( | ||
| 48 | projects: ReadonlyArray<EnvironmentProject>, | ||
| 49 | threads: ReadonlyArray<EnvironmentThreadShell>, | ||
| 50 | overrides: Partial<Parameters<typeof buildHomeThreadGroups>[0]> = {}, | ||
| 51 | ) { | ||
| 52 | return buildHomeThreadGroups({ | ||
| 53 | projects, | ||
| 54 | threads, | ||
| 55 | environmentId: null, | ||
| 56 | searchQuery: "", | ||
| 57 | projectSortOrder: "updated_at", | ||
| 58 | threadSortOrder: "updated_at", | ||
| 59 | projectGroupingMode: "repository", | ||
| 60 | ...overrides, | ||
| 61 | }); | ||
| 62 | } | ||
| 63 | |||
| 64 | describe("buildHomeThreadGroups", () => { | ||
| 65 | it("sorts the newest thread first regardless of snapshot order", () => { | ||
| 66 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 67 | const project = makeProject({ | ||
| 68 | environmentId, | ||
| 69 | id: ProjectId.make("project-1"), | ||
| 70 | title: "T3 Code", | ||
| 71 | }); | ||
| 72 | const threads = [ | ||
| 73 | makeThread({ | ||
| 74 | environmentId, | ||
| 75 | id: ThreadId.make("thread-old"), | ||
| 76 | projectId: project.id, | ||
| 77 | title: "Older thread", | ||
| 78 | updatedAt: "2026-06-02T00:00:00.000Z", | ||
| 79 | }), | ||
| 80 | makeThread({ | ||
| 81 | environmentId, | ||
| 82 | id: ThreadId.make("thread-new"), | ||
| 83 | projectId: project.id, | ||
| 84 | title: "Newer thread", | ||
| 85 | updatedAt: "2026-06-03T00:00:00.000Z", | ||
| 86 | }), | ||
| 87 | ]; | ||
| 88 | |||
| 89 | expect(buildGroups([project], threads)[0]?.threads.map((thread) => thread.id)).toEqual([ | ||
| 90 | "thread-new", | ||
| 91 | "thread-old", | ||
| 92 | ]); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("supports independent project and thread creation-time sorting", () => { | ||
| 96 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 97 | const olderProject = makeProject({ | ||
| 98 | environmentId, | ||
| 99 | id: ProjectId.make("project-older"), | ||
| 100 | title: "Older project", | ||
| 101 | }); | ||
| 102 | const newerProject = makeProject({ | ||
| 103 | environmentId, | ||
| 104 | id: ProjectId.make("project-newer"), | ||
| 105 | title: "Newer project", | ||
| 106 | }); | ||
| 107 | const threads = [ | ||
| 108 | makeThread({ | ||
| 109 | environmentId, | ||
| 110 | id: ThreadId.make("old-created"), | ||
| 111 | projectId: olderProject.id, | ||
| 112 | title: "Updated recently", | ||
| 113 | createdAt: "2026-06-01T00:00:00.000Z", | ||
| 114 | updatedAt: "2026-06-05T00:00:00.000Z", | ||
| 115 | }), | ||
| 116 | makeThread({ | ||
| 117 | environmentId, | ||
| 118 | id: ThreadId.make("new-created"), | ||
| 119 | projectId: olderProject.id, | ||
| 120 | title: "Created recently", | ||
| 121 | createdAt: "2026-06-04T00:00:00.000Z", | ||
| 122 | updatedAt: "2026-06-04T00:00:00.000Z", | ||
| 123 | }), | ||
| 124 | makeThread({ | ||
| 125 | environmentId, | ||
| 126 | id: ThreadId.make("newest-project-thread"), | ||
| 127 | projectId: newerProject.id, | ||
| 128 | title: "Newest project", | ||
| 129 | createdAt: "2026-06-06T00:00:00.000Z", | ||
| 130 | }), | ||
| 131 | ]; | ||
| 132 | |||
| 133 | const groups = buildGroups([olderProject, newerProject], threads, { | ||
| 134 | projectSortOrder: "created_at", | ||
| 135 | threadSortOrder: "created_at", | ||
| 136 | projectGroupingMode: "separate", | ||
| 137 | }); | ||
| 138 | |||
| 139 | expect(groups.map((group) => group.representative.id)).toEqual([ | ||
| 140 | "project-newer", | ||
| 141 | "project-older", | ||
| 142 | ]); | ||
| 143 | expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["new-created", "old-created"]); | ||
| 144 | }); | ||
| 145 | |||
| 146 | it("filters both projects and threads to one environment", () => { | ||
| 147 | const localEnvironmentId = EnvironmentId.make("environment-local"); | ||
| 148 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); | ||
| 149 | const projects = [ | ||
| 150 | makeProject({ | ||
| 151 | environmentId: localEnvironmentId, | ||
| 152 | id: ProjectId.make("project-local"), | ||
| 153 | title: "Local", | ||
| 154 | }), | ||
| 155 | makeProject({ | ||
| 156 | environmentId: remoteEnvironmentId, | ||
| 157 | id: ProjectId.make("project-remote"), | ||
| 158 | title: "Remote", | ||
| 159 | }), | ||
| 160 | ]; | ||
| 161 | const threads = projects.map((project) => | ||
| 162 | makeThread({ | ||
| 163 | environmentId: project.environmentId, | ||
| 164 | id: ThreadId.make(`thread-${project.id}`), | ||
| 165 | projectId: project.id, | ||
| 166 | title: project.title, | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | |||
| 170 | const groups = buildGroups(projects, threads, { environmentId: remoteEnvironmentId }); | ||
| 171 | |||
| 172 | expect(groups).toHaveLength(1); | ||
| 173 | expect(groups[0]?.representative.environmentId).toBe(remoteEnvironmentId); | ||
| 174 | expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); | ||
| 175 | }); | ||
| 176 | |||
| 177 | it("matches web repository, repository-path, and separate grouping modes", () => { | ||
| 178 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 179 | const repositoryIdentity = { | ||
| 180 | canonicalKey: "github.com/t3tools/t3code", | ||
| 181 | locator: { | ||
| 182 | source: "git-remote" as const, | ||
| 183 | remoteName: "origin", | ||
| 184 | remoteUrl: "git@github.com:t3tools/t3code.git", | ||
| 185 | }, | ||
| 186 | provider: "github", | ||
| 187 | owner: "t3tools", | ||
| 188 | name: "t3code", | ||
| 189 | displayName: "T3 Code", | ||
| 190 | rootPath: "/workspaces/t3code", | ||
| 191 | }; | ||
| 192 | const projects = [ | ||
| 193 | makeProject({ | ||
| 194 | environmentId, | ||
| 195 | id: ProjectId.make("project-web"), | ||
| 196 | title: "Web", | ||
| 197 | workspaceRoot: "/workspaces/t3code/apps/web", | ||
| 198 | repositoryIdentity, | ||
| 199 | }), | ||
| 200 | makeProject({ | ||
| 201 | environmentId, | ||
| 202 | id: ProjectId.make("project-mobile"), | ||
| 203 | title: "Mobile", | ||
| 204 | workspaceRoot: "/workspaces/t3code/apps/mobile", | ||
| 205 | repositoryIdentity, | ||
| 206 | }), | ||
| 207 | ]; | ||
| 208 | const threads = projects.map((project) => | ||
| 209 | makeThread({ | ||
| 210 | environmentId, | ||
| 211 | id: ThreadId.make(`thread-${project.id}`), | ||
| 212 | projectId: project.id, | ||
| 213 | title: project.title, | ||
| 214 | }), | ||
| 215 | ); | ||
| 216 | |||
| 217 | expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); | ||
| 218 | expect(buildGroups(projects, threads, { projectGroupingMode: "repository_path" })).toHaveLength( | ||
| 219 | 2, | ||
| 220 | ); | ||
| 221 | expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2); | ||
| 222 | }); | ||
| 223 | }); | ||
apps/mobile/src/features/home/homeThreadList.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import { | ||
| 2 | deriveLogicalProjectKey, | ||
| 3 | deriveProjectGroupLabel, | ||
| 4 | } from "@t3tools/client-runtime/state/project-grouping"; | ||
| 5 | import type { | ||
| 6 | EnvironmentProject, | ||
| 7 | EnvironmentThreadShell, | ||
| 8 | } from "@t3tools/client-runtime/state/shell"; | ||
| 9 | import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort"; | ||
| 10 | import type { | ||
| 11 | EnvironmentId, | ||
| 12 | SidebarProjectGroupingMode, | ||
| 13 | SidebarProjectSortOrder, | ||
| 14 | SidebarThreadSortOrder, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | import * as Arr from "effect/Array"; | ||
| 17 | import * as Order from "effect/Order"; | ||
| 18 | |||
| 19 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 20 | |||
| 21 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; | ||
| 22 | |||
| 23 | export interface HomeThreadGroup { | ||
| 24 | readonly key: string; | ||
| 25 | readonly title: string; | ||
| 26 | readonly representative: EnvironmentProject; | ||
| 27 | readonly projects: ReadonlyArray<EnvironmentProject>; | ||
| 28 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | ||
| 29 | } | ||
| 30 | |||
| 31 | interface MutableHomeThreadGroup { | ||
| 32 | readonly key: string; | ||
| 33 | readonly projects: EnvironmentProject[]; | ||
| 34 | readonly threads: EnvironmentThreadShell[]; | ||
| 35 | } | ||
| 36 | |||
| 37 | function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { | ||
| 38 | return group.threads.reduce( | ||
| 39 | (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), | ||
| 40 | Number.NEGATIVE_INFINITY, | ||
| 41 | ); | ||
| 42 | } | ||
| 43 | |||
| 44 | export function buildHomeThreadGroups(input: { | ||
| 45 | readonly projects: ReadonlyArray<EnvironmentProject>; | ||
| 46 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | ||
| 47 | readonly environmentId: EnvironmentId | null; | ||
| 48 | readonly searchQuery: string; | ||
| 49 | readonly projectSortOrder: HomeProjectSortOrder; | ||
| 50 | readonly threadSortOrder: SidebarThreadSortOrder; | ||
| 51 | readonly projectGroupingMode: SidebarProjectGroupingMode; | ||
| 52 | }): ReadonlyArray<HomeThreadGroup> { | ||
| 53 | const groups = new Map<string, MutableHomeThreadGroup>(); | ||
| 54 | const groupKeyByProjectKey = new Map<string, string>(); | ||
| 55 | |||
| 56 | for (const project of input.projects) { | ||
| 57 | if (input.environmentId !== null && project.environmentId !== input.environmentId) { | ||
| 58 | continue; | ||
| 59 | } | ||
| 60 | |||
| 61 | const groupKey = deriveLogicalProjectKey(project, { | ||
| 62 | groupingMode: input.projectGroupingMode, | ||
| 63 | }); | ||
| 64 | const physicalKey = scopedProjectKey(project.environmentId, project.id); | ||
| 65 | groupKeyByProjectKey.set(physicalKey, groupKey); | ||
| 66 | |||
| 67 | const existing = groups.get(groupKey); | ||
| 68 | if (existing) { | ||
| 69 | existing.projects.push(project); | ||
| 70 | } else { | ||
| 71 | groups.set(groupKey, { key: groupKey, projects: [project], threads: [] }); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | for (const thread of input.threads) { | ||
| 76 | if (thread.archivedAt !== null) { | ||
| 77 | continue; | ||
| 78 | } | ||
| 79 | if (input.environmentId !== null && thread.environmentId !== input.environmentId) { | ||
| 80 | continue; | ||
| 81 | } | ||
| 82 | |||
| 83 | const physicalKey = scopedProjectKey(thread.environmentId, thread.projectId); | ||
| 84 | const groupKey = groupKeyByProjectKey.get(physicalKey); | ||
| 85 | if (!groupKey) { | ||
| 86 | continue; | ||
| 87 | } | ||
| 88 | groups.get(groupKey)?.threads.push(thread); | ||
| 89 | } | ||
| 90 | |||
| 91 | const query = input.searchQuery.trim().toLocaleLowerCase(); | ||
| 92 | const result: HomeThreadGroup[] = []; | ||
| 93 | |||
| 94 | for (const group of groups.values()) { | ||
| 95 | const representative = group.projects[0]; | ||
| 96 | if (!representative || group.threads.length === 0) { | ||
| 97 | continue; | ||
| 98 | } | ||
| 99 | |||
| 100 | const title = | ||
| 101 | group.projects.length > 1 | ||
| 102 | ? deriveProjectGroupLabel({ representative, members: group.projects }) | ||
| 103 | : representative.title; | ||
| 104 | const groupMatches = | ||
| 105 | query.length === 0 || | ||
| 106 | title.toLocaleLowerCase().includes(query) || | ||
| 107 | group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); | ||
| 108 | const matchingThreads = groupMatches | ||
| 109 | ? group.threads | ||
| 110 | : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); | ||
| 111 | |||
| 112 | if (matchingThreads.length === 0) { | ||
| 113 | continue; | ||
| 114 | } | ||
| 115 | |||
| 116 | result.push({ | ||
| 117 | key: group.key, | ||
| 118 | title, | ||
| 119 | representative, | ||
| 120 | projects: group.projects, | ||
| 121 | threads: sortThreads(matchingThreads, input.threadSortOrder), | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | |||
| 125 | return Arr.sort( | ||
| 126 | result, | ||
| 127 | Order.mapInput( | ||
| 128 | Order.Struct({ | ||
| 129 | timestamp: Order.flip(Order.Number), | ||
| 130 | title: Order.String, | ||
| 131 | key: Order.String, | ||
| 132 | }), | ||
| 133 | (group: HomeThreadGroup) => ({ | ||
| 134 | timestamp: groupSortTimestamp(group, input.projectSortOrder), | ||
| 135 | title: group.title, | ||
| 136 | key: group.key, | ||
| 137 | }), | ||
| 138 | ), | ||
| 139 | ); | ||
| 140 | } | ||
apps/mobile/src/features/home/thread-swipe-actions.tsx created+238| ... | @@ -0,0 +1,238 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import type { ComponentProps } from "react"; | ||
| 3 | import type { ColorValue } from "react-native"; | ||
| 4 | import { Pressable, View } from "react-native"; | ||
| 5 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | ||
| 6 | import Animated, { | ||
| 7 | Extrapolation, | ||
| 8 | interpolate, | ||
| 9 | runOnJS, | ||
| 10 | type SharedValue, | ||
| 11 | useAnimatedReaction, | ||
| 12 | useAnimatedStyle, | ||
| 13 | } from "react-native-reanimated"; | ||
| 14 | |||
| 15 | import { AppText as Text } from "../../components/AppText"; | ||
| 16 | |||
| 17 | const ACTION_ITEM_WIDTH = 50; | ||
| 18 | const ACTION_CIRCLE_SIZE = 36; | ||
| 19 | const ACTION_ICON_SIZE = 15; | ||
| 20 | |||
| 21 | export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; | ||
| 22 | export const THREAD_SWIPE_SPRING = { | ||
| 23 | damping: 26, | ||
| 24 | mass: 0.7, | ||
| 25 | overshootClamping: true, | ||
| 26 | stiffness: 330, | ||
| 27 | }; | ||
| 28 | |||
| 29 | function SwipeActionButton(props: { | ||
| 30 | readonly accessibilityLabel: string; | ||
| 31 | readonly backgroundColor: string; | ||
| 32 | readonly entryRange: readonly [number, number]; | ||
| 33 | readonly fullSwipeThreshold: number; | ||
| 34 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | ||
| 35 | readonly label: string; | ||
| 36 | readonly onPress: () => void; | ||
| 37 | readonly stretchesOnFullSwipe: boolean; | ||
| 38 | readonly translation: SharedValue<number>; | ||
| 39 | }) { | ||
| 40 | const actionStyle = useAnimatedStyle(() => { | ||
| 41 | const reveal = Math.max(-props.translation.value, 0); | ||
| 42 | const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); | ||
| 43 | const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); | ||
| 44 | const fullSwipeProgress = interpolate( | ||
| 45 | reveal, | ||
| 46 | [THREAD_SWIPE_ACTIONS_WIDTH, props.fullSwipeThreshold + 20], | ||
| 47 | [0, 1], | ||
| 48 | Extrapolation.CLAMP, | ||
| 49 | ); | ||
| 50 | |||
| 51 | return { | ||
| 52 | opacity: props.stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress), | ||
| 53 | transform: [ | ||
| 54 | { | ||
| 55 | translateX: | ||
| 56 | interpolate(entryProgress, [0, 1], [22, 0]) - | ||
| 57 | (props.stretchesOnFullSwipe ? 0 : stretch), | ||
| 58 | }, | ||
| 59 | { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) }, | ||
| 60 | ], | ||
| 61 | }; | ||
| 62 | }); | ||
| 63 | const circleStyle = useAnimatedStyle(() => { | ||
| 64 | const reveal = Math.max(-props.translation.value, 0); | ||
| 65 | const stretch = props.stretchesOnFullSwipe | ||
| 66 | ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) | ||
| 67 | : 0; | ||
| 68 | |||
| 69 | return { | ||
| 70 | transform: [{ translateX: -stretch }], | ||
| 71 | width: ACTION_CIRCLE_SIZE + stretch, | ||
| 72 | }; | ||
| 73 | }); | ||
| 74 | const iconStyle = useAnimatedStyle(() => { | ||
| 75 | const reveal = Math.max(-props.translation.value, 0); | ||
| 76 | const stretch = props.stretchesOnFullSwipe | ||
| 77 | ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) | ||
| 78 | : 0; | ||
| 79 | const armedProgress = interpolate( | ||
| 80 | reveal, | ||
| 81 | [props.fullSwipeThreshold, props.fullSwipeThreshold + 20], | ||
| 82 | [0, 1], | ||
| 83 | Extrapolation.CLAMP, | ||
| 84 | ); | ||
| 85 | |||
| 86 | return { | ||
| 87 | transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.5) }], | ||
| 88 | }; | ||
| 89 | }); | ||
| 90 | const labelStyle = useAnimatedStyle(() => { | ||
| 91 | if (!props.stretchesOnFullSwipe) { | ||
| 92 | return { opacity: 1 }; | ||
| 93 | } | ||
| 94 | |||
| 95 | const reveal = Math.max(-props.translation.value, 0); | ||
| 96 | const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); | ||
| 97 | return { | ||
| 98 | opacity: interpolate( | ||
| 99 | reveal, | ||
| 100 | [props.fullSwipeThreshold - 24, props.fullSwipeThreshold], | ||
| 101 | [1, 0], | ||
| 102 | Extrapolation.CLAMP, | ||
| 103 | ), | ||
| 104 | transform: [{ translateX: -stretch * 0.5 }], | ||
| 105 | }; | ||
| 106 | }); | ||
| 107 | |||
| 108 | return ( | ||
| 109 | <Animated.View | ||
| 110 | style={[ | ||
| 111 | { | ||
| 112 | alignItems: "center", | ||
| 113 | height: "100%", | ||
| 114 | justifyContent: "center", | ||
| 115 | width: ACTION_ITEM_WIDTH, | ||
| 116 | zIndex: props.stretchesOnFullSwipe ? 2 : 1, | ||
| 117 | }, | ||
| 118 | actionStyle, | ||
| 119 | ]} | ||
| 120 | > | ||
| 121 | <Pressable | ||
| 122 | accessibilityLabel={props.accessibilityLabel} | ||
| 123 | accessibilityRole="button" | ||
| 124 | onPress={props.onPress} | ||
| 125 | style={({ pressed }) => ({ | ||
| 126 | alignItems: "center", | ||
| 127 | height: "100%", | ||
| 128 | justifyContent: "center", | ||
| 129 | opacity: pressed ? 0.72 : 1, | ||
| 130 | width: "100%", | ||
| 131 | })} | ||
| 132 | > | ||
| 133 | <View style={{ height: ACTION_CIRCLE_SIZE, width: ACTION_CIRCLE_SIZE }}> | ||
| 134 | <Animated.View | ||
| 135 | style={[ | ||
| 136 | { | ||
| 137 | backgroundColor: props.backgroundColor, | ||
| 138 | borderRadius: 999, | ||
| 139 | height: ACTION_CIRCLE_SIZE, | ||
| 140 | left: 0, | ||
| 141 | position: "absolute", | ||
| 142 | top: 0, | ||
| 143 | }, | ||
| 144 | circleStyle, | ||
| 145 | ]} | ||
| 146 | /> | ||
| 147 | <Animated.View | ||
| 148 | style={[ | ||
| 149 | { | ||
| 150 | alignItems: "center", | ||
| 151 | height: ACTION_CIRCLE_SIZE, | ||
| 152 | justifyContent: "center", | ||
| 153 | left: 0, | ||
| 154 | position: "absolute", | ||
| 155 | top: 0, | ||
| 156 | width: ACTION_CIRCLE_SIZE, | ||
| 157 | }, | ||
| 158 | iconStyle, | ||
| 159 | ]} | ||
| 160 | > | ||
| 161 | <SymbolView | ||
| 162 | name={props.icon} | ||
| 163 | size={ACTION_ICON_SIZE} | ||
| 164 | tintColor="#ffffff" | ||
| 165 | type="monochrome" | ||
| 166 | /> | ||
| 167 | </Animated.View> | ||
| 168 | </View> | ||
| 169 | <Animated.View style={[{ paddingTop: 2 }, labelStyle]}> | ||
| 170 | <Text className="text-[10px] font-t3-medium text-foreground-muted">{props.label}</Text> | ||
| 171 | </Animated.View> | ||
| 172 | </Pressable> | ||
| 173 | </Animated.View> | ||
| 174 | ); | ||
| 175 | } | ||
| 176 | |||
| 177 | export function ThreadSwipeActions(props: { | ||
| 178 | readonly backgroundColor: ColorValue; | ||
| 179 | readonly fullSwipeThreshold: number; | ||
| 180 | readonly onDelete: () => void; | ||
| 181 | readonly onFullSwipeArmedChange: (armed: boolean) => void; | ||
| 182 | readonly primaryAction: { | ||
| 183 | readonly accessibilityLabel: string; | ||
| 184 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | ||
| 185 | readonly label: string; | ||
| 186 | readonly onPress: () => void; | ||
| 187 | }; | ||
| 188 | readonly swipeableMethods: SwipeableMethods; | ||
| 189 | readonly threadTitle: string; | ||
| 190 | readonly translation: SharedValue<number>; | ||
| 191 | }) { | ||
| 192 | useAnimatedReaction( | ||
| 193 | () => -props.translation.value >= props.fullSwipeThreshold, | ||
| 194 | (armed, previous) => { | ||
| 195 | if (armed !== previous) { | ||
| 196 | runOnJS(props.onFullSwipeArmedChange)(armed); | ||
| 197 | } | ||
| 198 | }, | ||
| 199 | [props.fullSwipeThreshold, props.onFullSwipeArmedChange], | ||
| 200 | ); | ||
| 201 | |||
| 202 | return ( | ||
| 203 | <View | ||
| 204 | style={{ | ||
| 205 | backgroundColor: props.backgroundColor, | ||
| 206 | flexDirection: "row", | ||
| 207 | height: "100%", | ||
| 208 | width: THREAD_SWIPE_ACTIONS_WIDTH, | ||
| 209 | }} | ||
| 210 | > | ||
| 211 | <SwipeActionButton | ||
| 212 | accessibilityLabel={props.primaryAction.accessibilityLabel} | ||
| 213 | backgroundColor="#007aff" | ||
| 214 | entryRange={[ACTION_ITEM_WIDTH * 0.55, THREAD_SWIPE_ACTIONS_WIDTH * 0.85]} | ||
| 215 | fullSwipeThreshold={props.fullSwipeThreshold} | ||
| 216 | icon={props.primaryAction.icon} | ||
| 217 | label={props.primaryAction.label} | ||
| 218 | onPress={props.primaryAction.onPress} | ||
| 219 | stretchesOnFullSwipe={false} | ||
| 220 | translation={props.translation} | ||
| 221 | /> | ||
| 222 | <SwipeActionButton | ||
| 223 | accessibilityLabel={`Delete ${props.threadTitle}`} | ||
| 224 | backgroundColor="#ff2d55" | ||
| 225 | entryRange={[8, ACTION_ITEM_WIDTH * 0.72]} | ||
| 226 | fullSwipeThreshold={props.fullSwipeThreshold} | ||
| 227 | icon="trash" | ||
| 228 | label="Delete" | ||
| 229 | onPress={() => { | ||
| 230 | props.swipeableMethods.close(); | ||
| 231 | props.onDelete(); | ||
| 232 | }} | ||
| 233 | stretchesOnFullSwipe | ||
| 234 | translation={props.translation} | ||
| 235 | /> | ||
| 236 | </View> | ||
| 237 | ); | ||
| 238 | } | ||
apps/mobile/src/features/home/useThreadListActions.ts created+142| ... | @@ -0,0 +1,142 @@ | ||
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Haptics from "expo-haptics"; | ||
| 4 | import { useCallback, useRef } from "react"; | ||
| 5 | import { Alert } from "react-native"; | ||
| 6 | |||
| 7 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 8 | import { threadEnvironment } from "../../state/threads"; | ||
| 9 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 10 | |||
| 11 | type ThreadListAction = "archive" | "unarchive" | "delete"; | ||
| 12 | |||
| 13 | function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string { | ||
| 14 | const error = Cause.squash(cause); | ||
| 15 | if (error instanceof Error && error.message.trim().length > 0) { | ||
| 16 | return error.message; | ||
| 17 | } | ||
| 18 | const verb = | ||
| 19 | action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted"; | ||
| 20 | return `The thread could not be ${verb}.`; | ||
| 21 | } | ||
| 22 | |||
| 23 | function selectionHaptic(): void { | ||
| 24 | if (process.env.EXPO_OS === "ios") { | ||
| 25 | void Haptics.selectionAsync(); | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | function actionFailureTitle(action: ThreadListAction): string { | ||
| 30 | if (action === "archive") return "Could not archive thread"; | ||
| 31 | if (action === "unarchive") return "Could not unarchive thread"; | ||
| 32 | return "Could not delete thread"; | ||
| 33 | } | ||
| 34 | |||
| 35 | function useThreadActionExecutor( | ||
| 36 | onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, | ||
| 37 | ) { | ||
| 38 | const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); | ||
| 39 | const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); | ||
| 40 | const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); | ||
| 41 | const inFlightThreadKeys = useRef(new Set<string>()); | ||
| 42 | |||
| 43 | const executeAction = useCallback( | ||
| 44 | async (action: ThreadListAction, thread: EnvironmentThreadShell) => { | ||
| 45 | const key = scopedThreadKey(thread.environmentId, thread.id); | ||
| 46 | if (inFlightThreadKeys.current.has(key)) { | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | |||
| 50 | inFlightThreadKeys.current.add(key); | ||
| 51 | selectionHaptic(); | ||
| 52 | try { | ||
| 53 | const mutation = | ||
| 54 | action === "archive" | ||
| 55 | ? archiveMutation | ||
| 56 | : action === "unarchive" | ||
| 57 | ? unarchiveMutation | ||
| 58 | : deleteMutation; | ||
| 59 | const result = await mutation({ | ||
| 60 | environmentId: thread.environmentId, | ||
| 61 | input: { threadId: thread.id }, | ||
| 62 | }); | ||
| 63 | if (result._tag === "Failure") { | ||
| 64 | Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); | ||
| 65 | return; | ||
| 66 | } | ||
| 67 | onCompleted?.(action, thread); | ||
| 68 | } finally { | ||
| 69 | inFlightThreadKeys.current.delete(key); | ||
| 70 | } | ||
| 71 | }, | ||
| 72 | [archiveMutation, deleteMutation, onCompleted, unarchiveMutation], | ||
| 73 | ); | ||
| 74 | |||
| 75 | return executeAction; | ||
| 76 | } | ||
| 77 | |||
| 78 | function useConfirmDeleteThread( | ||
| 79 | executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise<void>, | ||
| 80 | ) { | ||
| 81 | return useCallback( | ||
| 82 | (thread: EnvironmentThreadShell) => { | ||
| 83 | Alert.alert( | ||
| 84 | "Delete thread?", | ||
| 85 | `“${thread.title}” will be permanently deleted, including its terminal history.`, | ||
| 86 | [ | ||
| 87 | { text: "Cancel", style: "cancel" }, | ||
| 88 | { | ||
| 89 | text: "Delete", | ||
| 90 | style: "destructive", | ||
| 91 | onPress: () => { | ||
| 92 | void executeAction("delete", thread); | ||
| 93 | }, | ||
| 94 | }, | ||
| 95 | ], | ||
| 96 | ); | ||
| 97 | }, | ||
| 98 | [executeAction], | ||
| 99 | ); | ||
| 100 | } | ||
| 101 | |||
| 102 | export function useThreadListActions(): { | ||
| 103 | readonly archiveThread: (thread: EnvironmentThreadShell) => void; | ||
| 104 | readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; | ||
| 105 | } { | ||
| 106 | const executeAction = useThreadActionExecutor(); | ||
| 107 | |||
| 108 | const archiveThread = useCallback( | ||
| 109 | (thread: EnvironmentThreadShell) => { | ||
| 110 | void executeAction("archive", thread); | ||
| 111 | }, | ||
| 112 | [executeAction], | ||
| 113 | ); | ||
| 114 | |||
| 115 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); | ||
| 116 | |||
| 117 | return { archiveThread, confirmDeleteThread }; | ||
| 118 | } | ||
| 119 | |||
| 120 | export function useArchivedThreadListActions( | ||
| 121 | onCompleted: (thread: EnvironmentThreadShell) => void, | ||
| 122 | ): { | ||
| 123 | readonly unarchiveThread: (thread: EnvironmentThreadShell) => void; | ||
| 124 | readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; | ||
| 125 | } { | ||
| 126 | const handleCompleted = useCallback( | ||
| 127 | (_action: ThreadListAction, thread: EnvironmentThreadShell) => { | ||
| 128 | onCompleted(thread); | ||
| 129 | }, | ||
| 130 | [onCompleted], | ||
| 131 | ); | ||
| 132 | const executeAction = useThreadActionExecutor(handleCompleted); | ||
| 133 | const unarchiveThread = useCallback( | ||
| 134 | (thread: EnvironmentThreadShell) => { | ||
| 135 | void executeAction("unarchive", thread); | ||
| 136 | }, | ||
| 137 | [executeAction], | ||
| 138 | ); | ||
| 139 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); | ||
| 140 | |||
| 141 | return { unarchiveThread, confirmDeleteThread }; | ||
| 142 | } | ||
apps/mobile/src/features/review/shikiReviewHighlighter.ts+9| ... | @@ -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 | } |
| 697 | 697 | ||
| 698 | export async function highlightSourceFile(input: { | ||
| 699 | readonly path: string; | ||
| 700 | readonly contents: string; | ||
| 701 | readonly theme: ReviewDiffTheme; | ||
| 702 | }): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> { | ||
| 703 | const language = await resolveLanguageFromPath(input.path); | ||
| 704 | return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); | ||
| 705 | } | ||
| 706 | |||
| 698 | async function highlightPatchLinesInChunks(input: { | 707 | async 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"; |
| 4 | import { type LegendListRef } from "@legendapp/list/react-native"; | 4 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 5 | import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; | 5 | import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 6 | import { SymbolView } from "expo-symbols"; | 6 | import { SymbolView } from "expo-symbols"; |
| 7 | import { useRouter } from "expo-router"; | ||
| 7 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 8 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 8 | import { | 9 | import { |
| 9 | Markdown, | 10 | Markdown, |
| ... | @@ -54,6 +55,7 @@ import { | ... | @@ -54,6 +55,7 @@ import { |
| 54 | import { buildReviewParsedDiff } from "../review/reviewModel"; | 55 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 55 | import { cn } from "../../lib/cn"; | 56 | import { cn } from "../../lib/cn"; |
| 56 | import type { LayoutVariant } from "../../lib/layout"; | 57 | import type { LayoutVariant } from "../../lib/layout"; |
| 58 | import { buildThreadFilesNavigation } from "../../lib/routes"; | ||
| 57 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 59 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 58 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | 60 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 59 | import { | 61 | import { |
| ... | @@ -64,7 +66,9 @@ import { | ... | @@ -64,7 +66,9 @@ import { |
| 64 | import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout"; | 66 | import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout"; |
| 65 | import { relativeTime } from "../../lib/time"; | 67 | import { relativeTime } from "../../lib/time"; |
| 66 | import type { ThreadContentPresentation } from "./threadContentPresentation"; | 68 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 69 | import { ThreadWorkLog } from "./thread-work-log"; | ||
| 67 | import { useAssetUrl } from "../../state/assets"; | 70 | import { useAssetUrl } from "../../state/assets"; |
| 71 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | ||
| 68 | 72 | ||
| 69 | const THREAD_FEED_END_THRESHOLD = 80; | 73 | const THREAD_FEED_END_THRESHOLD = 80; |
| 70 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { | 74 | const 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 { |
| 83 | export interface ThreadFeedProps { | 87 | export 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 | } |
| 122 | 127 | ||
| 123 | function stripShellWrapper(value: string): string { | ||
| 124 | const trimmed = value.trim(); | ||
| 125 | const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); | ||
| 126 | return (match?.[1] ?? trimmed).trim(); | ||
| 127 | } | ||
| 128 | |||
| 129 | function compactActivityDetail(detail: string | null): string | null { | ||
| 130 | if (!detail) { | ||
| 131 | return null; | ||
| 132 | } | ||
| 133 | |||
| 134 | const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); | ||
| 135 | return cleaned.length > 0 ? cleaned : null; | ||
| 136 | } | ||
| 137 | |||
| 138 | function buildActivityRows( | ||
| 139 | activities: Extract<ThreadFeedEntry, { type: "activity-group" }>["activities"], | ||
| 140 | ) { | ||
| 141 | return activities.map((activity) => ({ | ||
| 142 | ...activity, | ||
| 143 | detail: compactActivityDetail(activity.detail), | ||
| 144 | })); | ||
| 145 | } | ||
| 146 | |||
| 147 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; | ||
| 148 | |||
| 149 | const MARKDOWN_COLORS = { | 128 | const 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 { |
| 202 | 185 | ||
| 203 | const failedMarkdownFaviconHosts = new Set<string>(); | 186 | const failedMarkdownFaviconHosts = new Set<string>(); |
| 204 | const markdownLinkStyles = StyleSheet.create({ | 187 | const 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 | }); |
| 228 | 202 | ||
| ... | @@ -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 | } |
| 289 | 263 | ||
| 290 | function useMarkdownStyles(): MarkdownStyleSets { | 264 | function 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")); |
| 296 | 268 | ||
| 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; |
| 311 | 285 | ||
| ... | @@ -394,28 +368,23 @@ function useMarkdownStyles(): MarkdownStyleSets { | ... | @@ -394,28 +368,23 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 394 | }; | 368 | }; |
| 395 | 369 | ||
| 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 | <NativeText | 381 | <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 | <Image | 385 | <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 | <NativeText | 465 | <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 | <View | 483 | <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 | } |
| 681 | 646 | ||
| 682 | function renderFeedEntry( | 647 | function 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 | <View | 714 | <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 | <Markdown | 778 | <Markdown |
| ... | @@ -868,123 +838,17 @@ function renderFeedEntry( | ... | @@ -868,123 +838,17 @@ function renderFeedEntry( |
| 868 | ); | 838 | ); |
| 869 | } | 839 | } |
| 870 | 840 | ||
| 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 | <Pressable | 846 | 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 | } |
| 990 | 854 | ||
| ... | @@ -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 | <Markdown | 916 | <Markdown |
| ... | @@ -1254,6 +1123,7 @@ function ThreadFeedPlaceholder(props: { | ... | @@ -1254,6 +1123,7 @@ function ThreadFeedPlaceholder(props: { |
| 1254 | } | 1123 | } |
| 1255 | 1124 | ||
| 1256 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | 1125 | export 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) { |
| 1290 | 1161 | ||
| 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"; |
| 14 | import Stack from "expo-router/stack"; | 14 | import Stack from "expo-router/stack"; |
| 15 | import { useCallback, useMemo } from "react"; | 15 | import { useCallback, useMemo } from "react"; |
| 16 | import { Alert, Linking } from "react-native"; | 16 | import { Alert, Linking } from "react-native"; |
| 17 | import { buildThreadReviewRoutePath } from "../../lib/routes"; | 17 | import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; |
| 18 | import { | 18 | import { |
| 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.MenuAction | 271 | <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 @@ | ||
| 1 | import * as Haptics from "expo-haptics"; | ||
| 2 | import { SymbolView, type SFSymbol } from "expo-symbols"; | ||
| 3 | import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native"; | ||
| 4 | |||
| 5 | import { AppText as Text } from "../../components/AppText"; | ||
| 6 | import { cn } from "../../lib/cn"; | ||
| 7 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; | ||
| 8 | |||
| 9 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; | ||
| 10 | const WORK_LOG_LAYOUT_ANIMATION = { | ||
| 11 | duration: 180, | ||
| 12 | create: { | ||
| 13 | type: LayoutAnimation.Types.easeInEaseOut, | ||
| 14 | property: LayoutAnimation.Properties.opacity, | ||
| 15 | }, | ||
| 16 | update: { type: LayoutAnimation.Types.easeInEaseOut }, | ||
| 17 | delete: { | ||
| 18 | type: LayoutAnimation.Types.easeInEaseOut, | ||
| 19 | property: LayoutAnimation.Properties.opacity, | ||
| 20 | }, | ||
| 21 | } as const; | ||
| 22 | |||
| 23 | function triggerDisclosureFeedback() { | ||
| 24 | LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION); | ||
| 25 | void Haptics.selectionAsync(); | ||
| 26 | } | ||
| 27 | |||
| 28 | function stripShellWrapper(value: string): string { | ||
| 29 | const trimmed = value.trim(); | ||
| 30 | const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); | ||
| 31 | return (match?.[1] ?? trimmed).trim(); | ||
| 32 | } | ||
| 33 | |||
| 34 | function compactActivityDetail(detail: string | null): string | null { | ||
| 35 | if (!detail) { | ||
| 36 | return null; | ||
| 37 | } | ||
| 38 | |||
| 39 | const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); | ||
| 40 | return cleaned.length > 0 ? cleaned : null; | ||
| 41 | } | ||
| 42 | |||
| 43 | function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { | ||
| 44 | switch (icon) { | ||
| 45 | case "agent": | ||
| 46 | return "sparkles"; | ||
| 47 | case "alert": | ||
| 48 | return "exclamationmark.triangle"; | ||
| 49 | case "check": | ||
| 50 | return "checkmark"; | ||
| 51 | case "command": | ||
| 52 | return "terminal"; | ||
| 53 | case "edit": | ||
| 54 | return "square.and.pencil"; | ||
| 55 | case "eye": | ||
| 56 | return "eye"; | ||
| 57 | case "globe": | ||
| 58 | return "globe"; | ||
| 59 | case "hammer": | ||
| 60 | return "hammer"; | ||
| 61 | case "message": | ||
| 62 | return "bubble.left"; | ||
| 63 | case "warning": | ||
| 64 | return "xmark"; | ||
| 65 | case "wrench": | ||
| 66 | return "wrench"; | ||
| 67 | case "zap": | ||
| 68 | return "bolt"; | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | export function ThreadWorkLog(props: { | ||
| 73 | readonly activities: ReadonlyArray<ThreadFeedActivity>; | ||
| 74 | readonly copiedRowId: string | null; | ||
| 75 | readonly expanded: boolean; | ||
| 76 | readonly expandedRows: Readonly<Record<string, boolean>>; | ||
| 77 | readonly iconSubtleColor: import("react-native").ColorValue; | ||
| 78 | readonly onCopyRow: (rowId: string, value: string) => void; | ||
| 79 | readonly onToggleGroup: () => void; | ||
| 80 | readonly onToggleRow: (rowId: string) => void; | ||
| 81 | }) { | ||
| 82 | const colorScheme = useColorScheme(); | ||
| 83 | const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; | ||
| 84 | const rows = props.activities | ||
| 85 | .filter((activity) => !(activity.toolLike && activity.status === "neutral")) | ||
| 86 | .map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) })); | ||
| 87 | |||
| 88 | if (rows.length === 0) { | ||
| 89 | return null; | ||
| 90 | } | ||
| 91 | |||
| 92 | const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | ||
| 93 | const visibleRows = | ||
| 94 | hasOverflow && !props.expanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; | ||
| 95 | const hiddenCount = rows.length - visibleRows.length; | ||
| 96 | const onlyToolRows = rows.every((row) => row.toolLike); | ||
| 97 | |||
| 98 | return ( | ||
| 99 | <View className="-mx-1 mb-3 px-1 py-0.5"> | ||
| 100 | {!onlyToolRows ? ( | ||
| 101 | <Text className="px-0.5 pb-0.5 font-t3-medium text-[11px] text-foreground-muted opacity-60"> | ||
| 102 | work log | ||
| 103 | </Text> | ||
| 104 | ) : null} | ||
| 105 | |||
| 106 | <View className="gap-px"> | ||
| 107 | {visibleRows.map((row) => { | ||
| 108 | const expanded = props.expandedRows[row.id] ?? false; | ||
| 109 | const canExpand = row.fullDetail !== null; | ||
| 110 | const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; | ||
| 111 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; | ||
| 112 | |||
| 113 | return ( | ||
| 114 | <View key={row.id}> | ||
| 115 | <Pressable | ||
| 116 | accessibilityRole={canExpand ? "button" : undefined} | ||
| 117 | accessibilityLabel={displayText} | ||
| 118 | accessibilityHint={ | ||
| 119 | canExpand | ||
| 120 | ? "Double tap to show full details. Long press to copy." | ||
| 121 | : "Long press to copy." | ||
| 122 | } | ||
| 123 | accessibilityState={canExpand ? { expanded } : undefined} | ||
| 124 | hitSlop={4} | ||
| 125 | onPress={() => { | ||
| 126 | if (canExpand) { | ||
| 127 | triggerDisclosureFeedback(); | ||
| 128 | props.onToggleRow(row.id); | ||
| 129 | } | ||
| 130 | }} | ||
| 131 | onLongPress={() => props.onCopyRow(row.id, row.copyText)} | ||
| 132 | style={({ pressed }) => ({ | ||
| 133 | backgroundColor: pressed ? pressedBackground : "transparent", | ||
| 134 | })} | ||
| 135 | className="rounded-md px-0.5 py-0.5" | ||
| 136 | > | ||
| 137 | <View className="min-h-9 flex-row items-center gap-1.5"> | ||
| 138 | <View className="h-5 w-5 shrink-0 items-center justify-center"> | ||
| 139 | <SymbolView | ||
| 140 | name={workRowSymbolName(row.icon)} | ||
| 141 | size={14} | ||
| 142 | weight="medium" | ||
| 143 | tintColor={iconIsDestructive ? "#e11d48" : props.iconSubtleColor} | ||
| 144 | type="monochrome" | ||
| 145 | /> | ||
| 146 | </View> | ||
| 147 | |||
| 148 | <Text | ||
| 149 | className="min-w-0 flex-1 text-xs leading-5 text-foreground" | ||
| 150 | numberOfLines={1} | ||
| 151 | > | ||
| 152 | <Text | ||
| 153 | className={cn( | ||
| 154 | "font-t3-medium text-foreground", | ||
| 155 | iconIsDestructive && "text-rose-600 dark:text-rose-400", | ||
| 156 | )} | ||
| 157 | > | ||
| 158 | {row.summary} | ||
| 159 | </Text> | ||
| 160 | {row.detail ? ( | ||
| 161 | <Text className="text-foreground-muted opacity-60"> {row.detail}</Text> | ||
| 162 | ) : null} | ||
| 163 | </Text> | ||
| 164 | |||
| 165 | <View className="shrink-0 flex-row items-center gap-px"> | ||
| 166 | {props.copiedRowId === row.id ? ( | ||
| 167 | <Text className="pr-1 font-t3-medium text-[10px] text-emerald-600 dark:text-emerald-400"> | ||
| 168 | Copied | ||
| 169 | </Text> | ||
| 170 | ) : null} | ||
| 171 | <View className="h-4 w-4 items-center justify-center"> | ||
| 172 | {canExpand ? ( | ||
| 173 | <SymbolView | ||
| 174 | name={expanded ? "chevron.up" : "chevron.down"} | ||
| 175 | size={11} | ||
| 176 | tintColor={props.iconSubtleColor} | ||
| 177 | type="monochrome" | ||
| 178 | /> | ||
| 179 | ) : null} | ||
| 180 | </View> | ||
| 181 | <View className="h-4 w-4 items-center justify-center"> | ||
| 182 | {row.status ? ( | ||
| 183 | <SymbolView | ||
| 184 | name={ | ||
| 185 | row.status === "failure" | ||
| 186 | ? "xmark" | ||
| 187 | : row.status === "success" | ||
| 188 | ? "checkmark" | ||
| 189 | : "minus" | ||
| 190 | } | ||
| 191 | size={11} | ||
| 192 | tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor} | ||
| 193 | type="monochrome" | ||
| 194 | /> | ||
| 195 | ) : null} | ||
| 196 | </View> | ||
| 197 | </View> | ||
| 198 | </View> | ||
| 199 | </Pressable> | ||
| 200 | |||
| 201 | {expanded && row.fullDetail ? ( | ||
| 202 | <View className="ml-7 border-l border-neutral-300/60 pb-1.5 pl-3 pt-0.5 dark:border-white/[0.12]"> | ||
| 203 | <ScrollView | ||
| 204 | nestedScrollEnabled | ||
| 205 | directionalLockEnabled | ||
| 206 | showsVerticalScrollIndicator | ||
| 207 | style={{ maxHeight: 240 }} | ||
| 208 | contentContainerStyle={{ paddingRight: 8 }} | ||
| 209 | > | ||
| 210 | <Text | ||
| 211 | selectable | ||
| 212 | className="text-[11px] leading-[17px] text-foreground-muted" | ||
| 213 | style={{ fontFamily: "ui-monospace" }} | ||
| 214 | > | ||
| 215 | {row.fullDetail} | ||
| 216 | </Text> | ||
| 217 | </ScrollView> | ||
| 218 | </View> | ||
| 219 | ) : null} | ||
| 220 | </View> | ||
| 221 | ); | ||
| 222 | })} | ||
| 223 | </View> | ||
| 224 | |||
| 225 | {hasOverflow ? ( | ||
| 226 | <Pressable | ||
| 227 | accessibilityRole="button" | ||
| 228 | accessibilityState={{ expanded: props.expanded }} | ||
| 229 | accessibilityLabel={ | ||
| 230 | props.expanded | ||
| 231 | ? "Show fewer tool calls" | ||
| 232 | : `Show ${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}` | ||
| 233 | } | ||
| 234 | hitSlop={4} | ||
| 235 | onPress={() => { | ||
| 236 | triggerDisclosureFeedback(); | ||
| 237 | props.onToggleGroup(); | ||
| 238 | }} | ||
| 239 | style={({ pressed }) => ({ | ||
| 240 | backgroundColor: pressed ? pressedBackground : "transparent", | ||
| 241 | })} | ||
| 242 | className="min-h-9 flex-row items-center gap-1.5 rounded-md px-0.5 py-0.5" | ||
| 243 | > | ||
| 244 | <View className="h-5 w-5 items-center justify-center"> | ||
| 245 | <SymbolView | ||
| 246 | name={props.expanded ? "chevron.up" : "chevron.down"} | ||
| 247 | size={13} | ||
| 248 | tintColor={props.iconSubtleColor} | ||
| 249 | type="monochrome" | ||
| 250 | /> | ||
| 251 | </View> | ||
| 252 | <Text className="font-t3-medium text-xs text-foreground opacity-80"> | ||
| 253 | {props.expanded | ||
| 254 | ? "Show fewer tool calls" | ||
| 255 | : `+${hiddenCount} previous tool ${hiddenCount === 1 ? "call" : "calls"}`} | ||
| 256 | </Text> | ||
| 257 | </Pressable> | ||
| 258 | ) : null} | ||
| 259 | </View> | ||
| 260 | ); | ||
| 261 | } | ||
apps/mobile/src/lib/markdownLinks.test.ts+21| ... | @@ -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 | }); |
| 23 | 27 | ||
| 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 | }); |
| 41 | 62 |
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"; |
| 11 | 12 | ||
| 12 | describe("nativeMarkdownTextRuns", () => { | 13 | describe("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 | }); |
| 60 | 65 | ||
| ... | @@ -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 | }); |
| 75 | 80 | ||
| 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 | }); |
| 131 | 151 | ||
| 132 | describe("nativeMarkdownDocumentRuns", () => { | 152 | describe("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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { buildThreadFilesNavigation, buildThreadFilesRoutePath } from "./routes"; | ||
| 5 | |||
| 6 | const thread = { | ||
| 7 | environmentId: EnvironmentId.make("environment-1"), | ||
| 8 | threadId: ThreadId.make("thread-1"), | ||
| 9 | }; | ||
| 10 | |||
| 11 | describe("thread file routes", () => { | ||
| 12 | it("includes an optional source line in string routes", () => { | ||
| 13 | expect(buildThreadFilesRoutePath(thread, "src/main.ts", 42)).toBe( | ||
| 14 | "/threads/environment-1/thread-1/files/src/main.ts?line=42", | ||
| 15 | ); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("encodes each file path segment without encoding separators", () => { | ||
| 19 | expect(buildThreadFilesRoutePath(thread, "docs/My File#1.md")).toBe( | ||
| 20 | "/threads/environment-1/thread-1/files/docs/My%20File%231.md", | ||
| 21 | ); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("builds typed navigation params for a file and source line", () => { | ||
| 25 | expect(buildThreadFilesNavigation(thread, "src/main.ts", 42)).toEqual({ | ||
| 26 | pathname: "/threads/[environmentId]/[threadId]/files/[...path]", | ||
| 27 | params: { | ||
| 28 | environmentId: "environment-1", | ||
| 29 | threadId: "thread-1", | ||
| 30 | path: ["src", "main.ts"], | ||
| 31 | line: "42", | ||
| 32 | }, | ||
| 33 | }); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("targets the files index when no file path is provided", () => { | ||
| 37 | expect(buildThreadFilesNavigation(thread)).toEqual({ | ||
| 38 | pathname: "/threads/[environmentId]/[threadId]/files", | ||
| 39 | params: { | ||
| 40 | environmentId: "environment-1", | ||
| 41 | threadId: "thread-1", | ||
| 42 | }, | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | }); | ||
apps/mobile/src/lib/routes.ts+53| ... | @@ -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 | } |
| 34 | 34 | ||
| 35 | export function buildThreadFilesRoutePath( | ||
| 36 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 37 | relativePath?: string | null, | ||
| 38 | line?: number | null, | ||
| 39 | ): string { | ||
| 40 | const basePath = `${buildThreadRoutePath(input)}/files`; | ||
| 41 | if (!relativePath) { | ||
| 42 | return basePath; | ||
| 43 | } | ||
| 44 | |||
| 45 | const pathSegments = relativePath.split("/").filter((segment) => segment.length > 0); | ||
| 46 | if (pathSegments.length === 0) { | ||
| 47 | return basePath; | ||
| 48 | } | ||
| 49 | |||
| 50 | const encodedPath = pathSegments.map(encodeURIComponent).join("/"); | ||
| 51 | const lineParam = | ||
| 52 | Number.isFinite(line) && Number(line) > 0 ? `?line=${Math.floor(Number(line))}` : ""; | ||
| 53 | return `${basePath}/${encodedPath}${lineParam}`; | ||
| 54 | } | ||
| 55 | |||
| 35 | export function buildThreadTerminalRoutePath( | 56 | export 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 | } |
| 73 | 94 | ||
| 95 | export function buildThreadFilesNavigation( | ||
| 96 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 97 | relativePath?: string | null, | ||
| 98 | line?: number | null, | ||
| 99 | ): Href { | ||
| 100 | const environmentId = String(input.environmentId); | ||
| 101 | const threadId = String("threadId" in input ? input.threadId : input.id); | ||
| 102 | const path = relativePath?.split("/").filter((segment) => segment.length > 0) ?? []; | ||
| 103 | |||
| 104 | if (path.length === 0) { | ||
| 105 | return { | ||
| 106 | pathname: "/threads/[environmentId]/[threadId]/files", | ||
| 107 | params: { environmentId, threadId }, | ||
| 108 | }; | ||
| 109 | } | ||
| 110 | |||
| 111 | const params: { | ||
| 112 | environmentId: string; | ||
| 113 | threadId: string; | ||
| 114 | path: string[]; | ||
| 115 | line?: string; | ||
| 116 | } = { environmentId, threadId, path }; | ||
| 117 | if (Number.isFinite(line) && Number(line) > 0) { | ||
| 118 | params.line = String(Math.floor(Number(line))); | ||
| 119 | } | ||
| 120 | |||
| 121 | return { | ||
| 122 | pathname: "/threads/[environmentId]/[threadId]/files/[...path]", | ||
| 123 | params, | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 74 | export function dismissRoute(router: Router) { | 127 | export 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 | }); |
| 171 | 172 | ||
| 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 | } |
| 64 | 78 | ||
| 65 | interface DerivedWorkLogEntry extends WorkLogEntry { | 79 | interface DerivedWorkLogEntry extends WorkLogEntry { |
| ... | @@ -214,7 +228,7 @@ function resolvePendingUserInputAnswer( | ... | @@ -214,7 +228,7 @@ function resolvePendingUserInputAnswer( |
| 214 | 228 | ||
| 215 | function deriveWorkLogEntries( | 229 | function 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 | } |
| 232 | 244 | ||
| 233 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { | 245 | function 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 | } |
| 382 | 402 | ||
| ... | @@ -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 | } |
| 482 | 502 | ||
| 503 | function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { | ||
| 504 | if ( | ||
| 505 | entry.activityKind === "user-input.requested" || | ||
| 506 | entry.activityKind === "user-input.resolved" | ||
| 507 | ) { | ||
| 508 | return "message"; | ||
| 509 | } | ||
| 510 | if (entry.activityKind === "runtime.warning") return "warning"; | ||
| 511 | if (entry.requestKind === "command") return "command"; | ||
| 512 | if (entry.requestKind === "file-read") return "eye"; | ||
| 513 | if (entry.requestKind === "file-change") return "edit"; | ||
| 514 | if (entry.itemType === "command_execution" || entry.command) return "command"; | ||
| 515 | if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; | ||
| 516 | if (entry.itemType === "web_search") return "globe"; | ||
| 517 | if (entry.itemType === "image_view") return "eye"; | ||
| 518 | if (entry.itemType === "mcp_tool_call") return "wrench"; | ||
| 519 | if (entry.itemType === "dynamic_tool_call" || entry.itemType === "collab_agent_tool_call") { | ||
| 520 | return "hammer"; | ||
| 521 | } | ||
| 522 | if (entry.tone === "error") return "alert"; | ||
| 523 | if (entry.tone === "thinking") return "agent"; | ||
| 524 | if (entry.tone === "info") return "check"; | ||
| 525 | return "zap"; | ||
| 526 | } | ||
| 527 | |||
| 528 | function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { | ||
| 529 | const blocks: string[] = []; | ||
| 530 | const appendUniqueBlock = (value: string | null | undefined) => { | ||
| 531 | const trimmed = value?.trim(); | ||
| 532 | if (trimmed && !blocks.includes(trimmed)) { | ||
| 533 | blocks.push(trimmed); | ||
| 534 | } | ||
| 535 | }; | ||
| 536 | |||
| 537 | if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) { | ||
| 538 | appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); | ||
| 539 | } | ||
| 540 | appendUniqueBlock(entry.rawCommand ?? entry.command); | ||
| 541 | appendUniqueBlock(entry.detail); | ||
| 542 | if ((entry.changedFiles?.length ?? 0) > 0) { | ||
| 543 | appendUniqueBlock(entry.changedFiles!.join("\n")); | ||
| 544 | } | ||
| 545 | |||
| 546 | return blocks.length > 0 ? blocks.join("\n\n") : null; | ||
| 547 | } | ||
| 548 | |||
| 483 | function workEntryPreview( | 549 | function 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.detail | 1295 | 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 | ); |
| 91 | 91 | ||
| 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 @@ |
| 1 | import type { AssetResource } from "@t3tools/contracts"; | 1 | import type { AssetResource } from "@t3tools/contracts"; |
| 2 | import { AssetAccessError } from "@t3tools/contracts"; | 2 | import { AssetAccessError } from "@t3tools/contracts"; |
| 3 | import { | ||
| 4 | isWorkspaceImagePreviewPath, | ||
| 5 | isWorkspacePreviewEntryPath, | ||
| 6 | WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, | ||
| 7 | WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, | ||
| 8 | } from "@t3tools/shared/filePreview"; | ||
| 3 | import * as Clock from "effect/Clock"; | 9 | import * as Clock from "effect/Clock"; |
| 4 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 11 | import * 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/ |
| 24 | 30 | ||
| 25 | const SIGNING_SECRET_NAME = "asset-access-signing-key"; | 31 | const SIGNING_SECRET_NAME = "asset-access-signing-key"; |
| 26 | const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; | 32 | const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; |
| 27 | const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]); | ||
| 28 | const PREVIEW_ASSET_EXTENSIONS = new Set([ | 33 | const 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* workspacePaths | 149 | 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* fileSystem | 165 | 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 @@ | ||
| 1 | import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; | ||
| 2 | import { RouterProvider } from "@tanstack/react-router"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; | ||
| 6 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; | ||
| 7 | import type { AppRouter } from "./router"; | ||
| 8 | import { AppRoot } from "./AppRoot"; | ||
| 9 | |||
| 10 | describe("AppRoot", () => { | ||
| 11 | it("shares the application atom registry with routed UI and the Electron browser host", () => { | ||
| 12 | const root = AppRoot({ router: {} as AppRouter }); | ||
| 13 | |||
| 14 | expect(root.type).toBe(AppAtomRegistryProvider); | ||
| 15 | const children = Children.toArray( | ||
| 16 | (root as ReactElement<{ readonly children: ReactNode }>).props.children, | ||
| 17 | ); | ||
| 18 | expect(children).toHaveLength(2); | ||
| 19 | expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); | ||
| 20 | expect(isValidElement(children[1]) && children[1].type).toBe(ElectronBrowserHost); | ||
| 21 | }); | ||
| 22 | }); | ||
apps/web/src/AppRoot.tsx created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | import { RouterProvider } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; | ||
| 4 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; | ||
| 5 | import type { AppRouter } from "./router"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * Owns renderer-wide providers. The Electron browser host intentionally sits | ||
| 9 | * outside the router so its webviews survive route transitions, but it must | ||
| 10 | * share the same atom registry as routed UI. | ||
| 11 | */ | ||
| 12 | export function AppRoot({ router }: { readonly router: AppRouter }) { | ||
| 13 | return ( | ||
| 14 | <AppAtomRegistryProvider> | ||
| 15 | <RouterProvider router={router} /> | ||
| 16 | <ElectronBrowserHost /> | ||
| 17 | </AppAtomRegistryProvider> | ||
| 18 | ); | ||
| 19 | } | ||
apps/web/src/browser/HostedBrowserWebview.tsx+33-10| ... | @@ -9,7 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; | ... | @@ -9,7 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; |
| 9 | 9 | ||
| 10 | import { useActiveBrowserRecordingTabId } from "./browserRecording"; | 10 | import { useActiveBrowserRecordingTabId } from "./browserRecording"; |
| 11 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | 11 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 12 | import { acquireDesktopTab } from "./desktopTabLifetime"; | 12 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; |
| 13 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; | 13 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 14 | 14 | ||
| 15 | interface ElectronWebview extends HTMLElement { | 15 | interface 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; |
| 40 | 41 | ||
| 41 | usePreviewBridge({ threadRef, tabId }); | 42 | usePreviewBridge({ threadRef, tabId }); |
| 42 | 43 | ||
| 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]); | ||
| 44 | 52 | ||
| 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]); |
| 68 | 91 | ||
| 69 | if (!config) return null; | 92 | if (!config) return null; |
apps/web/src/browser/desktopTabLifetime.test.ts created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | const { closeTab, createTab } = vi.hoisted(() => ({ | ||
| 4 | closeTab: vi.fn(async () => undefined), | ||
| 5 | createTab: vi.fn<() => Promise<void>>(), | ||
| 6 | })); | ||
| 7 | |||
| 8 | vi.mock("~/components/preview/previewBridge", () => ({ | ||
| 9 | previewBridge: { closeTab, createTab }, | ||
| 10 | })); | ||
| 11 | |||
| 12 | import { acquireDesktopTab } from "./desktopTabLifetime"; | ||
| 13 | |||
| 14 | describe("desktopTabLifetime", () => { | ||
| 15 | beforeEach(() => { | ||
| 16 | closeTab.mockClear(); | ||
| 17 | createTab.mockClear(); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("shares tab creation readiness across concurrent leases", async () => { | ||
| 21 | let resolveCreation: (() => void) | undefined; | ||
| 22 | createTab.mockReturnValueOnce( | ||
| 23 | new Promise<void>((resolve) => { | ||
| 24 | resolveCreation = resolve; | ||
| 25 | }), | ||
| 26 | ); | ||
| 27 | |||
| 28 | const first = acquireDesktopTab("tab_readiness"); | ||
| 29 | const second = acquireDesktopTab("tab_readiness"); | ||
| 30 | |||
| 31 | expect(createTab).toHaveBeenCalledOnce(); | ||
| 32 | expect(first.ready).toBe(second.ready); | ||
| 33 | |||
| 34 | let ready = false; | ||
| 35 | void first.ready.then(() => { | ||
| 36 | ready = true; | ||
| 37 | }); | ||
| 38 | await Promise.resolve(); | ||
| 39 | expect(ready).toBe(false); | ||
| 40 | |||
| 41 | resolveCreation?.(); | ||
| 42 | await first.ready; | ||
| 43 | expect(ready).toBe(true); | ||
| 44 | }); | ||
| 45 | }); | ||
apps/web/src/browser/desktopTabLifetime.ts+28-14| ... | @@ -3,28 +3,42 @@ import { previewBridge } from "~/components/preview/previewBridge"; | ... | @@ -3,28 +3,42 @@ import { previewBridge } from "~/components/preview/previewBridge"; |
| 3 | interface DesktopTabLease { | 3 | interface DesktopTabLease { |
| 4 | references: number; | 4 | references: number; |
| 5 | closeTimer: number | null; | 5 | closeTimer: number | null; |
| 6 | ready: Promise<void>; | ||
| 6 | } | 7 | } |
| 7 | 8 | ||
| 8 | const leases = new Map<string, DesktopTabLease>(); | 9 | const leases = new Map<string, DesktopTabLease>(); |
| 9 | 10 | ||
| 10 | export function acquireDesktopTab(tabId: string): () => void { | 11 | export interface AcquiredDesktopTab { |
| 11 | const current = leases.get(tabId) ?? { references: 0, closeTimer: null }; | 12 | readonly ready: Promise<void>; |
| 13 | readonly release: () => void; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { | ||
| 17 | const current = | ||
| 18 | leases.get(tabId) ?? | ||
| 19 | ({ | ||
| 20 | references: 0, | ||
| 21 | closeTimer: null, | ||
| 22 | ready: previewBridge?.createTab(tabId) ?? Promise.resolve(), | ||
| 23 | } satisfies DesktopTabLease); | ||
| 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); | ||
| 17 | 28 | ||
| 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"; |
| 113 | import { | 113 | import { |
| 114 | applyPreviewServerSnapshot, | ||
| 115 | isPreviewSupportedInRuntime, | 114 | isPreviewSupportedInRuntime, |
| 116 | removePreviewSession, | ||
| 117 | setActivePreviewTab, | 115 | setActivePreviewTab, |
| 118 | useThreadPreviewState, | 116 | useThreadPreviewState, |
| 119 | } from "../previewStateStore"; | 117 | } from "../previewStateStore"; |
| 118 | import { addBrowserSurface } from "./preview/addBrowserSurface"; | ||
| 119 | import { closePreviewSession } from "./preview/closePreviewSession"; | ||
| 120 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 120 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 121 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; | 121 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 122 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; | 122 | import { 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) { |
| 2993 | 2984 | ||
| 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 @@ | ||
| 1 | import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; | ||
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | applyPreviewServerSnapshot, | ||
| 7 | readThreadPreviewState, | ||
| 8 | resetPreviewStateForTests, | ||
| 9 | } from "~/previewStateStore"; | ||
| 10 | import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; | ||
| 11 | |||
| 12 | import { addBrowserSurface } from "./addBrowserSurface"; | ||
| 13 | |||
| 14 | const threadRef = { | ||
| 15 | environmentId: "local" as ScopedThreadRef["environmentId"], | ||
| 16 | threadId: "thread-1" as ScopedThreadRef["threadId"], | ||
| 17 | }; | ||
| 18 | |||
| 19 | const snapshot = (tabId: string): PreviewSessionSnapshot => ({ | ||
| 20 | threadId: threadRef.threadId, | ||
| 21 | tabId, | ||
| 22 | navStatus: { _tag: "Idle" }, | ||
| 23 | canGoBack: false, | ||
| 24 | canGoForward: false, | ||
| 25 | updatedAt: `2026-06-18T19:00:0${tabId.at(-1) ?? "0"}.000Z`, | ||
| 26 | }); | ||
| 27 | |||
| 28 | beforeEach(() => { | ||
| 29 | resetPreviewStateForTests(); | ||
| 30 | useRightPanelStore.setState({ byThreadKey: {} }); | ||
| 31 | }); | ||
| 32 | |||
| 33 | describe("addBrowserSurface", () => { | ||
| 34 | it("creates another preview session when a browser tab is already active", async () => { | ||
| 35 | const first = snapshot("tab-1"); | ||
| 36 | const second = snapshot("tab-2"); | ||
| 37 | applyPreviewServerSnapshot(threadRef, first); | ||
| 38 | useRightPanelStore.getState().openBrowser(threadRef, first.tabId); | ||
| 39 | const openPreview = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(second)); | ||
| 40 | |||
| 41 | await addBrowserSurface({ threadRef, openPreview: ({ input }) => openPreview(input) }); | ||
| 42 | |||
| 43 | expect(openPreview).toHaveBeenCalledWith({ threadId: "thread-1" }); | ||
| 44 | expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); | ||
| 45 | expect( | ||
| 46 | selectThreadRightPanelState( | ||
| 47 | useRightPanelStore.getState().byThreadKey, | ||
| 48 | threadRef, | ||
| 49 | ).surfaces.map((surface) => surface.id), | ||
| 50 | ).toEqual(["browser:tab-1", "browser:tab-2"]); | ||
| 51 | }); | ||
| 52 | }); | ||
apps/web/src/components/preview/addBrowserSurface.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import { | ||
| 2 | mapAtomCommandResult, | ||
| 3 | type AtomCommandResult, | ||
| 4 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 5 | import type { ScopedThreadRef } from "@t3tools/contracts"; | ||
| 6 | |||
| 7 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; | ||
| 8 | import { useRightPanelStore } from "~/rightPanelStore"; | ||
| 9 | |||
| 10 | import { openPreviewSession } from "./openPreviewSession"; | ||
| 11 | |||
| 12 | /** Creates a new browser tab. Reopening an existing tab is a separate UI action. */ | ||
| 13 | export async function addBrowserSurface<E>(input: { | ||
| 14 | readonly threadRef: ScopedThreadRef; | ||
| 15 | readonly openPreview: OpenPreviewMutation<E>; | ||
| 16 | }): Promise<AtomCommandResult<void, E>> { | ||
| 17 | const result = await openPreviewSession({ | ||
| 18 | openPreview: input.openPreview, | ||
| 19 | threadRef: input.threadRef, | ||
| 20 | }); | ||
| 21 | return mapAtomCommandResult(result, (snapshot) => { | ||
| 22 | useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); | ||
| 23 | }); | ||
| 24 | } | ||
apps/web/src/components/preview/closePreviewSession.test.ts created+79| ... | @@ -0,0 +1,79 @@ | ||
| 1 | import type { | ||
| 2 | PreviewCloseInput, | ||
| 3 | PreviewSessionSnapshot, | ||
| 4 | ScopedThreadRef, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Cause from "effect/Cause"; | ||
| 7 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 8 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | applyPreviewServerSnapshot, | ||
| 12 | readThreadPreviewState, | ||
| 13 | resetPreviewStateForTests, | ||
| 14 | } from "~/previewStateStore"; | ||
| 15 | |||
| 16 | import { closePreviewSession } from "./closePreviewSession"; | ||
| 17 | |||
| 18 | const threadRef = { | ||
| 19 | environmentId: "local" as ScopedThreadRef["environmentId"], | ||
| 20 | threadId: "thread-1" as ScopedThreadRef["threadId"], | ||
| 21 | }; | ||
| 22 | |||
| 23 | const snapshot: PreviewSessionSnapshot = { | ||
| 24 | threadId: threadRef.threadId, | ||
| 25 | tabId: "tab-1", | ||
| 26 | navStatus: { | ||
| 27 | _tag: "Success", | ||
| 28 | url: "http://localhost:3000/", | ||
| 29 | title: "Local app", | ||
| 30 | }, | ||
| 31 | canGoBack: false, | ||
| 32 | canGoForward: false, | ||
| 33 | updatedAt: "2026-06-18T19:00:00.000Z", | ||
| 34 | }; | ||
| 35 | |||
| 36 | beforeEach(resetPreviewStateForTests); | ||
| 37 | |||
| 38 | describe("closePreviewSession", () => { | ||
| 39 | it("suppresses stale server snapshots while the close is in flight", async () => { | ||
| 40 | applyPreviewServerSnapshot(threadRef, snapshot); | ||
| 41 | let finishClose: (() => void) | undefined; | ||
| 42 | const closePreview = vi.fn( | ||
| 43 | (_input: PreviewCloseInput) => | ||
| 44 | new Promise<ReturnType<typeof AsyncResult.success<void>>>((resolve) => { | ||
| 45 | finishClose = () => resolve(AsyncResult.success(undefined)); | ||
| 46 | }), | ||
| 47 | ); | ||
| 48 | |||
| 49 | const closing = closePreviewSession({ | ||
| 50 | closePreview: ({ input }) => closePreview(input), | ||
| 51 | snapshot, | ||
| 52 | tabId: snapshot.tabId, | ||
| 53 | threadRef, | ||
| 54 | }); | ||
| 55 | |||
| 56 | expect(readThreadPreviewState(threadRef).sessions).toEqual({}); | ||
| 57 | applyPreviewServerSnapshot(threadRef, snapshot); | ||
| 58 | expect(readThreadPreviewState(threadRef).sessions).toEqual({}); | ||
| 59 | |||
| 60 | finishClose?.(); | ||
| 61 | await closing; | ||
| 62 | expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" }); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("restores the last snapshot when the server close fails", async () => { | ||
| 66 | applyPreviewServerSnapshot(threadRef, snapshot); | ||
| 67 | |||
| 68 | const result = await closePreviewSession({ | ||
| 69 | closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))), | ||
| 70 | snapshot, | ||
| 71 | tabId: snapshot.tabId, | ||
| 72 | threadRef, | ||
| 73 | }); | ||
| 74 | |||
| 75 | expect(result._tag).toBe("Failure"); | ||
| 76 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); | ||
| 77 | expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot }); | ||
| 78 | }); | ||
| 79 | }); | ||
apps/web/src/components/preview/closePreviewSession.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | ||
| 2 | import type { | ||
| 3 | EnvironmentId, | ||
| 4 | PreviewCloseInput, | ||
| 5 | PreviewSessionSnapshot, | ||
| 6 | ScopedThreadRef, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore"; | ||
| 10 | |||
| 11 | interface ClosePreviewSessionInput<E> { | ||
| 12 | readonly closePreview: (input: { | ||
| 13 | readonly environmentId: EnvironmentId; | ||
| 14 | readonly input: PreviewCloseInput; | ||
| 15 | }) => Promise<AtomCommandResult<void, E>>; | ||
| 16 | readonly snapshot: PreviewSessionSnapshot | null; | ||
| 17 | readonly tabId: string; | ||
| 18 | readonly threadRef: ScopedThreadRef; | ||
| 19 | } | ||
| 20 | |||
| 21 | /** | ||
| 22 | * Optimistically closes a preview while suppressing stale list responses for | ||
| 23 | * the same tab. A failed close restores the last known snapshot. | ||
| 24 | */ | ||
| 25 | export async function closePreviewSession<E>( | ||
| 26 | input: ClosePreviewSessionInput<E>, | ||
| 27 | ): Promise<AtomCommandResult<void, E>> { | ||
| 28 | beginPreviewSessionClose(input.threadRef, input.tabId); | ||
| 29 | const result = await input.closePreview({ | ||
| 30 | environmentId: input.threadRef.environmentId, | ||
| 31 | input: { threadId: input.threadRef.threadId, tabId: input.tabId }, | ||
| 32 | }); | ||
| 33 | if (result._tag === "Failure") { | ||
| 34 | cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId); | ||
| 35 | } | ||
| 36 | return result; | ||
| 37 | } | ||
apps/web/src/components/preview/openPreviewSession.test.ts+18| ... | @@ -28,6 +28,24 @@ const snapshot: PreviewSessionSnapshot = { | ... | @@ -28,6 +28,24 @@ const snapshot: PreviewSessionSnapshot = { |
| 28 | beforeEach(resetPreviewStateForTests); | 28 | beforeEach(resetPreviewStateForTests); |
| 29 | 29 | ||
| 30 | describe("openPreviewSession", () => { | 30 | describe("openPreviewSession", () => { |
| 31 | it("creates an idle tab without recording a recently visited URL", async () => { | ||
| 32 | const idleSnapshot: PreviewSessionSnapshot = { | ||
| 33 | ...snapshot, | ||
| 34 | tabId: "tab-blank", | ||
| 35 | navStatus: { _tag: "Idle" }, | ||
| 36 | }; | ||
| 37 | const open = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(idleSnapshot)); | ||
| 38 | |||
| 39 | await openPreviewSession({ | ||
| 40 | openPreview: ({ input }) => open(input), | ||
| 41 | threadRef, | ||
| 42 | }); | ||
| 43 | |||
| 44 | expect(open).toHaveBeenCalledWith({ threadId: "thread-1" }); | ||
| 45 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); | ||
| 46 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); | ||
| 47 | }); | ||
| 48 | |||
| 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)); |
| 33 | 51 |
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 | } |
| 19 | 19 | ||
| 20 | export async function openPreviewSession<E>( | 20 | export 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 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"; |
| 7 | import type { EnvironmentId } from "@t3tools/contracts"; | 7 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 8 | import * as Cause from "effect/Cause"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 11 | import { useCallback, useMemo } from "react"; | 8 | import { useCallback, useMemo } from "react"; |
| 12 | 9 | ||
| 13 | import { orchestrationEnvironment } from "../state/orchestration"; | 10 | import { orchestrationEnvironment } from "../state/orchestration"; |
| 14 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 11 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 15 | 12 | ||
| 16 | const archivedSnapshotsAtom = Atom.family((environmentKey: string) => | ||
| 17 | Atom.make((get) => { | ||
| 18 | const snapshots: ArchivedSnapshotEntry[] = []; | ||
| 19 | let error: string | null = null; | ||
| 20 | let isLoading = false; | ||
| 21 | |||
| 22 | for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) { | ||
| 23 | const result = get( | ||
| 24 | orchestrationEnvironment.archivedShellSnapshot({ | ||
| 25 | environmentId, | ||
| 26 | input: {}, | ||
| 27 | }), | ||
| 28 | ); | ||
| 29 | isLoading ||= result.waiting; | ||
| 30 | const snapshot = Option.getOrNull(AsyncResult.value(result)); | ||
| 31 | if (snapshot !== null) { | ||
| 32 | snapshots.push({ environmentId, snapshot }); | ||
| 33 | } | ||
| 34 | if (error === null && result._tag === "Failure") { | ||
| 35 | const cause = Cause.squash(result.cause); | ||
| 36 | error = | ||
| 37 | cause instanceof Error && cause.message.trim().length > 0 | ||
| 38 | ? cause.message | ||
| 39 | : "Failed to load archived threads."; | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | return { | ||
| 44 | snapshots, | ||
| 45 | error, | ||
| 46 | isLoading, | ||
| 47 | }; | ||
| 48 | }).pipe(Atom.withLabel(`web:archived-thread-snapshots:${environmentKey}`)), | ||
| 49 | ); | ||
| 50 | |||
| 51 | function archivedSnapshotAtom(environmentId: EnvironmentId) { | 13 | function 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 | } |
| 57 | 19 | ||
| 20 | const archivedSnapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ | ||
| 21 | getSnapshotAtom: archivedSnapshotAtom, | ||
| 22 | labelPrefix: "web:archived-thread-snapshots", | ||
| 23 | }); | ||
| 24 | |||
| 58 | export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { | 25 | export 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 @@ |
| 1 | import type { ProjectId } from "@t3tools/contracts"; | 1 | export { |
| 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 2 | getLatestThreadForProject, |
| 3 | import type { Thread } from "../types"; | 3 | getThreadSortTimestamp, |
| 4 | 4 | sortThreads, | |
| 5 | export 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 | |||
| 10 | export function toSortableTimestamp(iso: string | undefined): number | null { | ||
| 11 | if (!iso) return null; | ||
| 12 | const ms = Date.parse(iso); | ||
| 13 | return Number.isFinite(ms) ? ms : null; | ||
| 14 | } | ||
| 15 | |||
| 16 | function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null { | ||
| 17 | for (const value of values) { | ||
| 18 | const timestamp = toSortableTimestamp(value ?? undefined); | ||
| 19 | if (timestamp !== null) { | ||
| 20 | return timestamp; | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | return null; | ||
| 25 | } | ||
| 26 | |||
| 27 | function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { | ||
| 28 | if (thread.latestUserMessageAt) { | ||
| 29 | return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; | ||
| 30 | } | ||
| 31 | |||
| 32 | let latestUserMessageTimestamp: number | null = null; | ||
| 33 | |||
| 34 | for (const message of thread.messages ?? []) { | ||
| 35 | if (message.role !== "user") continue; | ||
| 36 | const messageTimestamp = toSortableTimestamp(message.createdAt); | ||
| 37 | if (messageTimestamp === null) continue; | ||
| 38 | latestUserMessageTimestamp = | ||
| 39 | latestUserMessageTimestamp === null | ||
| 40 | ? messageTimestamp | ||
| 41 | : Math.max(latestUserMessageTimestamp, messageTimestamp); | ||
| 42 | } | ||
| 43 | |||
| 44 | if (latestUserMessageTimestamp !== null) { | ||
| 45 | return latestUserMessageTimestamp; | ||
| 46 | } | ||
| 47 | |||
| 48 | return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY; | ||
| 49 | } | ||
| 50 | |||
| 51 | export function getThreadSortTimestamp( | ||
| 52 | thread: ThreadSortInput, | ||
| 53 | sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">, | ||
| 54 | ): number { | ||
| 55 | if (sortOrder === "created_at") { | ||
| 56 | return ( | ||
| 57 | getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY | ||
| 58 | ); | ||
| 59 | } | ||
| 60 | return getLatestUserMessageTimestamp(thread); | ||
| 61 | } | ||
| 62 | |||
| 63 | export function sortThreads<T extends Pick<Thread, "id"> & ThreadSortInput>( | ||
| 64 | threads: readonly T[], | ||
| 65 | sortOrder: SidebarThreadSortOrder, | ||
| 66 | ): T[] { | ||
| 67 | return threads.toSorted((left, right) => { | ||
| 68 | const rightTimestamp = getThreadSortTimestamp(right, sortOrder); | ||
| 69 | const leftTimestamp = getThreadSortTimestamp(left, sortOrder); | ||
| 70 | const byTimestamp = | ||
| 71 | rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; | ||
| 72 | if (byTimestamp !== 0) return byTimestamp; | ||
| 73 | return right.id.localeCompare(left.id); | ||
| 74 | }); | ||
| 75 | } | ||
| 76 | |||
| 77 | export function getLatestThreadForProject< | ||
| 78 | T extends Pick<Thread, "id" | "projectId" | "archivedAt"> & ThreadSortInput, | ||
| 79 | >(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { | ||
| 80 | return ( | ||
| 81 | sortThreads( | ||
| 82 | threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), | ||
| 83 | sortOrder, | ||
| 84 | )[0] ?? null | ||
| 85 | ); | ||
| 86 | } |
apps/web/src/logicalProject.ts+14-184| ... | @@ -1,184 +1,14 @@ | ... | @@ -1,184 +1,14 @@ |
| 1 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; | 1 | export { |
| 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 2 | deriveLogicalProjectKey, |
| 3 | import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; | 3 | deriveLogicalProjectKeyFromRef, |
| 4 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; | 4 | deriveLogicalProjectKeyFromSettings, |
| 5 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; | 5 | derivePhysicalProjectKey, |
| 6 | 6 | derivePhysicalProjectKeyFromPath, | |
| 7 | export interface ProjectGroupingSettings { | 7 | deriveProjectGroupLabel, |
| 8 | sidebarProjectGroupingMode: SidebarProjectGroupingMode; | 8 | deriveProjectGroupingOverrideKey, |
| 9 | sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>; | 9 | getProjectOrderKey, |
| 10 | } | 10 | resolveProjectGroupingMode, |
| 11 | 11 | selectProjectGroupingSettings, | |
| 12 | export type ProjectGroupingMode = SidebarProjectGroupingMode; | 12 | type ProjectGroupingMode, |
| 13 | 13 | type ProjectGroupingSettings, | |
| 14 | export 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 | |||
| 21 | function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): string[] { | ||
| 22 | const seen = new Set<string>(); | ||
| 23 | const unique: string[] = []; | ||
| 24 | for (const value of values) { | ||
| 25 | const trimmed = value?.trim(); | ||
| 26 | if (!trimmed || seen.has(trimmed)) { | ||
| 27 | continue; | ||
| 28 | } | ||
| 29 | seen.add(trimmed); | ||
| 30 | unique.push(trimmed); | ||
| 31 | } | ||
| 32 | return unique; | ||
| 33 | } | ||
| 34 | |||
| 35 | function deriveRepositoryRelativeProjectPath( | ||
| 36 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, | ||
| 37 | ): string | null { | ||
| 38 | const rootPath = project.repositoryIdentity?.rootPath?.trim(); | ||
| 39 | if (!rootPath) { | ||
| 40 | return null; | ||
| 41 | } | ||
| 42 | |||
| 43 | const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); | ||
| 44 | const normalizedRootPath = normalizeProjectPathForComparison(rootPath); | ||
| 45 | if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { | ||
| 46 | return null; | ||
| 47 | } | ||
| 48 | |||
| 49 | if (normalizedProjectPath === normalizedRootPath) { | ||
| 50 | return ""; | ||
| 51 | } | ||
| 52 | |||
| 53 | const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; | ||
| 54 | const rootPrefix = `${normalizedRootPath}${separator}`; | ||
| 55 | if (!normalizedProjectPath.startsWith(rootPrefix)) { | ||
| 56 | return null; | ||
| 57 | } | ||
| 58 | |||
| 59 | return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); | ||
| 60 | } | ||
| 61 | |||
| 62 | export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { | ||
| 63 | return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; | ||
| 64 | } | ||
| 65 | |||
| 66 | export function derivePhysicalProjectKey( | ||
| 67 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | ||
| 68 | ): string { | ||
| 69 | return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); | ||
| 70 | } | ||
| 71 | |||
| 72 | export function deriveProjectGroupingOverrideKey( | ||
| 73 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | ||
| 74 | ): string { | ||
| 75 | return derivePhysicalProjectKey(project); | ||
| 76 | } | ||
| 77 | |||
| 78 | // Key under which a project's manual sort order (projectOrder) is stored. | ||
| 79 | // Must stay aligned with the drag handlers and readers in `Sidebar`. | ||
| 80 | export function getProjectOrderKey( | ||
| 81 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | ||
| 82 | ): string { | ||
| 83 | return derivePhysicalProjectKey(project); | ||
| 84 | } | ||
| 85 | |||
| 86 | export function resolveProjectGroupingMode( | ||
| 87 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | ||
| 88 | settings: ProjectGroupingSettings, | ||
| 89 | ): SidebarProjectGroupingMode { | ||
| 90 | return ( | ||
| 91 | settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? | ||
| 92 | settings.sidebarProjectGroupingMode | ||
| 93 | ); | ||
| 94 | } | ||
| 95 | |||
| 96 | function deriveRepositoryScopedKey( | ||
| 97 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, | ||
| 98 | groupingMode: SidebarProjectGroupingMode, | ||
| 99 | ): string | null { | ||
| 100 | const canonicalKey = project.repositoryIdentity?.canonicalKey; | ||
| 101 | if (!canonicalKey) { | ||
| 102 | return null; | ||
| 103 | } | ||
| 104 | |||
| 105 | if (groupingMode === "repository") { | ||
| 106 | return canonicalKey; | ||
| 107 | } | ||
| 108 | |||
| 109 | const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); | ||
| 110 | if (relativeProjectPath === null) { | ||
| 111 | return canonicalKey; | ||
| 112 | } | ||
| 113 | |||
| 114 | return relativeProjectPath.length === 0 | ||
| 115 | ? canonicalKey | ||
| 116 | : `${canonicalKey}::${relativeProjectPath}`; | ||
| 117 | } | ||
| 118 | |||
| 119 | export function deriveLogicalProjectKey( | ||
| 120 | project: Pick< | ||
| 121 | EnvironmentProject, | ||
| 122 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | ||
| 123 | >, | ||
| 124 | options?: { | ||
| 125 | groupingMode?: SidebarProjectGroupingMode; | ||
| 126 | }, | ||
| 127 | ): string { | ||
| 128 | const groupingMode = options?.groupingMode ?? "repository"; | ||
| 129 | if (groupingMode === "separate") { | ||
| 130 | return derivePhysicalProjectKey(project); | ||
| 131 | } | ||
| 132 | |||
| 133 | return ( | ||
| 134 | deriveRepositoryScopedKey(project, groupingMode) ?? | ||
| 135 | derivePhysicalProjectKey(project) ?? | ||
| 136 | scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | |||
| 140 | export function deriveLogicalProjectKeyFromSettings( | ||
| 141 | project: Pick< | ||
| 142 | EnvironmentProject, | ||
| 143 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | ||
| 144 | >, | ||
| 145 | settings: ProjectGroupingSettings, | ||
| 146 | ): string { | ||
| 147 | return deriveLogicalProjectKey(project, { | ||
| 148 | groupingMode: resolveProjectGroupingMode(project, settings), | ||
| 149 | }); | ||
| 150 | } | ||
| 151 | |||
| 152 | export function deriveLogicalProjectKeyFromRef( | ||
| 153 | projectRef: ScopedProjectRef, | ||
| 154 | project: | ||
| 155 | | Pick<EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"> | ||
| 156 | | null | ||
| 157 | | undefined, | ||
| 158 | options?: { | ||
| 159 | groupingMode?: SidebarProjectGroupingMode; | ||
| 160 | }, | ||
| 161 | ): string { | ||
| 162 | return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); | ||
| 163 | } | ||
| 164 | |||
| 165 | export function deriveProjectGroupLabel(input: { | ||
| 166 | representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">; | ||
| 167 | members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>; | ||
| 168 | }): string { | ||
| 169 | const sharedDisplayNames = uniqueNonEmptyValues( | ||
| 170 | input.members.map((member) => member.repositoryIdentity?.displayName), | ||
| 171 | ); | ||
| 172 | if (sharedDisplayNames.length === 1) { | ||
| 173 | return sharedDisplayNames[0]!; | ||
| 174 | } | ||
| 175 | |||
| 176 | const sharedRepositoryNames = uniqueNonEmptyValues( | ||
| 177 | input.members.map((member) => member.repositoryIdentity?.name), | ||
| 178 | ); | ||
| 179 | if (sharedRepositoryNames.length === 1) { | ||
| 180 | return sharedRepositoryNames[0]!; | ||
| 181 | } | ||
| 182 | |||
| 183 | return input.representative.title; | ||
| 184 | } |
apps/web/src/main.tsx+2-8| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import React from "react"; | 1 | import React from "react"; |
| 2 | import ReactDOM from "react-dom/client"; | 2 | import ReactDOM from "react-dom/client"; |
| 3 | import { ClerkProvider } from "@clerk/react"; | 3 | import { ClerkProvider } from "@clerk/react"; |
| 4 | import { RouterProvider } from "@tanstack/react-router"; | ||
| 5 | import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; | 4 | import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; |
| 6 | 5 | ||
| 7 | import "@fontsource-variable/dm-sans/index.css"; | 6 | import "@fontsource-variable/dm-sans/index.css"; |
| ... | @@ -17,7 +16,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; | ... | @@ -17,7 +16,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; |
| 17 | import { getRouter } from "./router"; | 16 | import { getRouter } from "./router"; |
| 18 | import { APP_DISPLAY_NAME } from "./branding"; | 17 | import { APP_DISPLAY_NAME } from "./branding"; |
| 19 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; | 18 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; |
| 20 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; | 19 | import { AppRoot } from "./AppRoot"; |
| 21 | 20 | ||
| 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. |
| 23 | const history = isElectron ? createHashHistory() : createBrowserHistory(); | 22 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| ... | @@ -32,12 +31,7 @@ document.title = APP_DISPLAY_NAME; | ... | @@ -32,12 +31,7 @@ document.title = APP_DISPLAY_NAME; |
| 32 | 31 | ||
| 33 | const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; | 32 | const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; |
| 34 | 33 | ||
| 35 | const app = ( | 34 | const app = <AppRoot router={router} />; |
| 36 | <> | ||
| 37 | <RouterProvider router={router} /> | ||
| 38 | <ElectronBrowserHost /> | ||
| 39 | </> | ||
| 40 | ); | ||
| 41 | 35 | ||
| 42 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | 36 | ReactDOM.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); |
| 184 | 185 | ||
| 185 | removePreviewSession(ref, second.tabId); | 186 | beginPreviewSessionClose(ref, second.tabId); |
| 186 | 187 | ||
| 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); |
| 197 | 198 | ||
| 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 | }); |
| 209 | 210 | ||
| 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 { |
| 28 | export interface ThreadPreviewState { | 28 | export 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 { |
| 37 | const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ | 39 | const 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.recentlySeenUrls | 171 | ? 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 | } |
| 257 | 262 | ||
| 258 | export function removePreviewSession(ref: ScopedThreadRef, tabId: string): void { | 263 | export 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 | |||
| 274 | export function cancelPreviewSessionClose( | ||
| 275 | ref: ScopedThreadRef, | ||
| 276 | snapshot: PreviewSessionSnapshot | null, | ||
| 277 | tabId: string, | ||
| 278 | ): void { | ||
| 279 | updateThreadPreviewState(ref, (current) => { | ||
| 280 | if (!current.suppressedTabIds.has(tabId)) return current; | ||
| 281 | const suppressedTabIds = new Set(current.suppressedTabIds); | ||
| 282 | suppressedTabIds.delete(tabId); | ||
| 283 | if (!snapshot) { | ||
| 284 | return { ...current, suppressedTabIds }; | ||
| 285 | } | ||
| 286 | const recentlySeenUrls = | ||
| 287 | snapshot.navStatus._tag !== "Idle" | ||
| 288 | ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) | ||
| 289 | : current.recentlySeenUrls; | ||
| 290 | return { | ||
| 291 | ...current, | ||
| 292 | snapshot, | ||
| 293 | sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, | ||
| 294 | suppressedTabIds, | ||
| 295 | activeTabId: snapshot.tabId, | ||
| 296 | desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, | ||
| 297 | recentlySeenUrls, | ||
| 298 | }; | ||
| 299 | }); | ||
| 260 | } | 300 | } |
| 261 | 301 | ||
| 262 | export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { | 302 | export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { |
apps/web/src/router.ts-3| ... | @@ -1,7 +1,5 @@ | ... | @@ -1,7 +1,5 @@ |
| 1 | import { createElement } from "react"; | ||
| 2 | import { createRouter, RouterHistory } from "@tanstack/react-router"; | 1 | import { createRouter, RouterHistory } from "@tanstack/react-router"; |
| 3 | 2 | ||
| 4 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; | ||
| 5 | import { routeTree } from "./routeTree.gen"; | 3 | import { routeTree } from "./routeTree.gen"; |
| 6 | 4 | ||
| 7 | export function getRouter(history: RouterHistory) { | 5 | export 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 | } |
| 15 | 12 |
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 @@ |
| 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; |
| 2 | import * as Arr from "effect/Array"; | 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Cause from "effect/Cause"; | ||
| 3 | import { pipe } from "effect/Function"; | 4 | import { pipe } from "effect/Function"; |
| 5 | import * as Option from "effect/Option"; | ||
| 4 | import * as Order from "effect/Order"; | 6 | import * as Order from "effect/Order"; |
| 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 5 | 8 | ||
| 6 | export interface ArchivedSnapshotEntry { | 9 | export interface ArchivedSnapshotEntry { |
| 7 | readonly environmentId: EnvironmentId; | 10 | readonly environmentId: EnvironmentId; |
| 8 | readonly snapshot: OrchestrationShellSnapshot; | 11 | readonly snapshot: OrchestrationShellSnapshot; |
| 9 | } | 12 | } |
| 10 | 13 | ||
| 14 | export interface ArchivedThreadSnapshotsState { | ||
| 15 | readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>; | ||
| 16 | readonly error: string | null; | ||
| 17 | readonly isLoading: boolean; | ||
| 18 | } | ||
| 19 | |||
| 11 | const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; | 20 | const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; |
| 12 | const environmentIdOrder = Order.String as Order.Order<EnvironmentId>; | 21 | const environmentIdOrder = Order.String as Order.Order<EnvironmentId>; |
| 13 | 22 | ||
| ... | @@ -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 | |||
| 41 | export function createArchivedThreadSnapshotsAtomFamily<E>(options: { | ||
| 42 | readonly getSnapshotAtom: ( | ||
| 43 | environmentId: EnvironmentId, | ||
| 44 | ) => Atom.Atom<AsyncResult.AsyncResult<OrchestrationShellSnapshot, E>>; | ||
| 45 | readonly labelPrefix: string; | ||
| 46 | }) { | ||
| 47 | return Atom.family((environmentKey: string) => | ||
| 48 | Atom.make((get): ArchivedThreadSnapshotsState => { | ||
| 49 | const snapshots: ArchivedSnapshotEntry[] = []; | ||
| 50 | let error: string | null = null; | ||
| 51 | let isLoading = false; | ||
| 52 | |||
| 53 | for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) { | ||
| 54 | const result = get(options.getSnapshotAtom(environmentId)); | ||
| 55 | isLoading ||= result.waiting; | ||
| 56 | |||
| 57 | const snapshot = Option.getOrNull(AsyncResult.value(result)); | ||
| 58 | if (snapshot !== null) { | ||
| 59 | snapshots.push({ environmentId, snapshot }); | ||
| 60 | } | ||
| 61 | |||
| 62 | if (error === null && result._tag === "Failure") { | ||
| 63 | const cause = Cause.squash(result.cause); | ||
| 64 | error = | ||
| 65 | cause instanceof Error && cause.message.trim().length > 0 | ||
| 66 | ? cause.message | ||
| 67 | : "Failed to load archived threads."; | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | return { snapshots, error, isLoading }; | ||
| 72 | }).pipe(Atom.withLabel(`${options.labelPrefix}:${environmentKey}`)), | ||
| 73 | ); | ||
| 74 | } |
packages/client-runtime/src/state/projectGrouping.ts created+183| ... | @@ -0,0 +1,183 @@ | ||
| 1 | import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; | ||
| 2 | import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; | ||
| 3 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; | ||
| 4 | |||
| 5 | import type { EnvironmentProject } from "./models.ts"; | ||
| 6 | import { normalizeProjectPathForComparison } from "./projects.ts"; | ||
| 7 | |||
| 8 | export interface ProjectGroupingSettings { | ||
| 9 | readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode; | ||
| 10 | readonly sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>; | ||
| 11 | } | ||
| 12 | |||
| 13 | export type ProjectGroupingMode = SidebarProjectGroupingMode; | ||
| 14 | |||
| 15 | export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings { | ||
| 16 | return { | ||
| 17 | sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, | ||
| 18 | sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, | ||
| 19 | }; | ||
| 20 | } | ||
| 21 | |||
| 22 | function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): string[] { | ||
| 23 | const seen = new Set<string>(); | ||
| 24 | const unique: string[] = []; | ||
| 25 | for (const value of values) { | ||
| 26 | const trimmed = value?.trim(); | ||
| 27 | if (!trimmed || seen.has(trimmed)) { | ||
| 28 | continue; | ||
| 29 | } | ||
| 30 | seen.add(trimmed); | ||
| 31 | unique.push(trimmed); | ||
| 32 | } | ||
| 33 | return unique; | ||
| 34 | } | ||
| 35 | |||
| 36 | function deriveRepositoryRelativeProjectPath( | ||
| 37 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, | ||
| 38 | ): string | null { | ||
| 39 | const rootPath = project.repositoryIdentity?.rootPath?.trim(); | ||
| 40 | if (!rootPath) { | ||
| 41 | return null; | ||
| 42 | } | ||
| 43 | |||
| 44 | const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); | ||
| 45 | const normalizedRootPath = normalizeProjectPathForComparison(rootPath); | ||
| 46 | if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { | ||
| 47 | return null; | ||
| 48 | } | ||
| 49 | |||
| 50 | if (normalizedProjectPath === normalizedRootPath) { | ||
| 51 | return ""; | ||
| 52 | } | ||
| 53 | |||
| 54 | const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; | ||
| 55 | const rootPrefix = `${normalizedRootPath}${separator}`; | ||
| 56 | if (!normalizedProjectPath.startsWith(rootPrefix)) { | ||
| 57 | return null; | ||
| 58 | } | ||
| 59 | |||
| 60 | return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); | ||
| 61 | } | ||
| 62 | |||
| 63 | export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { | ||
| 64 | return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; | ||
| 65 | } | ||
| 66 | |||
| 67 | export function derivePhysicalProjectKey( | ||
| 68 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | ||
| 69 | ): string { | ||
| 70 | return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); | ||
| 71 | } | ||
| 72 | |||
| 73 | export function deriveProjectGroupingOverrideKey( | ||
| 74 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | ||
| 75 | ): string { | ||
| 76 | return derivePhysicalProjectKey(project); | ||
| 77 | } | ||
| 78 | |||
| 79 | export function getProjectOrderKey( | ||
| 80 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | ||
| 81 | ): string { | ||
| 82 | return derivePhysicalProjectKey(project); | ||
| 83 | } | ||
| 84 | |||
| 85 | export function resolveProjectGroupingMode( | ||
| 86 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | ||
| 87 | settings: ProjectGroupingSettings, | ||
| 88 | ): SidebarProjectGroupingMode { | ||
| 89 | return ( | ||
| 90 | settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? | ||
| 91 | settings.sidebarProjectGroupingMode | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | |||
| 95 | function deriveRepositoryScopedKey( | ||
| 96 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, | ||
| 97 | groupingMode: SidebarProjectGroupingMode, | ||
| 98 | ): string | null { | ||
| 99 | const canonicalKey = project.repositoryIdentity?.canonicalKey; | ||
| 100 | if (!canonicalKey) { | ||
| 101 | return null; | ||
| 102 | } | ||
| 103 | |||
| 104 | if (groupingMode === "repository") { | ||
| 105 | return canonicalKey; | ||
| 106 | } | ||
| 107 | |||
| 108 | const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); | ||
| 109 | if (relativeProjectPath === null) { | ||
| 110 | return canonicalKey; | ||
| 111 | } | ||
| 112 | |||
| 113 | return relativeProjectPath.length === 0 | ||
| 114 | ? canonicalKey | ||
| 115 | : `${canonicalKey}::${relativeProjectPath}`; | ||
| 116 | } | ||
| 117 | |||
| 118 | export function deriveLogicalProjectKey( | ||
| 119 | project: Pick< | ||
| 120 | EnvironmentProject, | ||
| 121 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | ||
| 122 | >, | ||
| 123 | options?: { | ||
| 124 | readonly groupingMode?: SidebarProjectGroupingMode; | ||
| 125 | }, | ||
| 126 | ): string { | ||
| 127 | const groupingMode = options?.groupingMode ?? "repository"; | ||
| 128 | if (groupingMode === "separate") { | ||
| 129 | return derivePhysicalProjectKey(project); | ||
| 130 | } | ||
| 131 | |||
| 132 | return ( | ||
| 133 | deriveRepositoryScopedKey(project, groupingMode) ?? | ||
| 134 | derivePhysicalProjectKey(project) ?? | ||
| 135 | scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) | ||
| 136 | ); | ||
| 137 | } | ||
| 138 | |||
| 139 | export function deriveLogicalProjectKeyFromSettings( | ||
| 140 | project: Pick< | ||
| 141 | EnvironmentProject, | ||
| 142 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | ||
| 143 | >, | ||
| 144 | settings: ProjectGroupingSettings, | ||
| 145 | ): string { | ||
| 146 | return deriveLogicalProjectKey(project, { | ||
| 147 | groupingMode: resolveProjectGroupingMode(project, settings), | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | |||
| 151 | export function deriveLogicalProjectKeyFromRef( | ||
| 152 | projectRef: ScopedProjectRef, | ||
| 153 | project: | ||
| 154 | | Pick<EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"> | ||
| 155 | | null | ||
| 156 | | undefined, | ||
| 157 | options?: { | ||
| 158 | readonly groupingMode?: SidebarProjectGroupingMode; | ||
| 159 | }, | ||
| 160 | ): string { | ||
| 161 | return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); | ||
| 162 | } | ||
| 163 | |||
| 164 | export function deriveProjectGroupLabel(input: { | ||
| 165 | readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">; | ||
| 166 | readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>; | ||
| 167 | }): string { | ||
| 168 | const sharedDisplayNames = uniqueNonEmptyValues( | ||
| 169 | input.members.map((member) => member.repositoryIdentity?.displayName), | ||
| 170 | ); | ||
| 171 | if (sharedDisplayNames.length === 1) { | ||
| 172 | return sharedDisplayNames[0]!; | ||
| 173 | } | ||
| 174 | |||
| 175 | const sharedRepositoryNames = uniqueNonEmptyValues( | ||
| 176 | input.members.map((member) => member.repositoryIdentity?.name), | ||
| 177 | ); | ||
| 178 | if (sharedRepositoryNames.length === 1) { | ||
| 179 | return sharedRepositoryNames[0]!; | ||
| 180 | } | ||
| 181 | |||
| 182 | return input.representative.title; | ||
| 183 | } | ||
packages/client-runtime/src/state/threadSort.ts created+101| ... | @@ -0,0 +1,101 @@ | ||
| 1 | import type { ProjectId } from "@t3tools/contracts"; | ||
| 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | ||
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | import * as Order from "effect/Order"; | ||
| 5 | |||
| 6 | export interface ThreadSortInput { | ||
| 7 | readonly createdAt: string; | ||
| 8 | readonly updatedAt: string; | ||
| 9 | readonly latestUserMessageAt?: string | null; | ||
| 10 | readonly messages?: ReadonlyArray<{ | ||
| 11 | readonly createdAt: string; | ||
| 12 | readonly role: string; | ||
| 13 | }>; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function toSortableTimestamp(iso: string | undefined): number | null { | ||
| 17 | if (!iso) return null; | ||
| 18 | const ms = Date.parse(iso); | ||
| 19 | return Number.isFinite(ms) ? ms : null; | ||
| 20 | } | ||
| 21 | |||
| 22 | function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null { | ||
| 23 | for (const value of values) { | ||
| 24 | const timestamp = toSortableTimestamp(value ?? undefined); | ||
| 25 | if (timestamp !== null) { | ||
| 26 | return timestamp; | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | return null; | ||
| 31 | } | ||
| 32 | |||
| 33 | function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { | ||
| 34 | if (thread.latestUserMessageAt) { | ||
| 35 | return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; | ||
| 36 | } | ||
| 37 | |||
| 38 | let latestUserMessageTimestamp: number | null = null; | ||
| 39 | |||
| 40 | for (const message of thread.messages ?? []) { | ||
| 41 | if (message.role !== "user") continue; | ||
| 42 | const messageTimestamp = toSortableTimestamp(message.createdAt); | ||
| 43 | if (messageTimestamp === null) continue; | ||
| 44 | latestUserMessageTimestamp = | ||
| 45 | latestUserMessageTimestamp === null | ||
| 46 | ? messageTimestamp | ||
| 47 | : Math.max(latestUserMessageTimestamp, messageTimestamp); | ||
| 48 | } | ||
| 49 | |||
| 50 | if (latestUserMessageTimestamp !== null) { | ||
| 51 | return latestUserMessageTimestamp; | ||
| 52 | } | ||
| 53 | |||
| 54 | return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function getThreadSortTimestamp( | ||
| 58 | thread: ThreadSortInput, | ||
| 59 | sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">, | ||
| 60 | ): number { | ||
| 61 | if (sortOrder === "created_at") { | ||
| 62 | return ( | ||
| 63 | getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY | ||
| 64 | ); | ||
| 65 | } | ||
| 66 | return getLatestUserMessageTimestamp(thread); | ||
| 67 | } | ||
| 68 | |||
| 69 | export function sortThreads<T extends { readonly id: string } & ThreadSortInput>( | ||
| 70 | threads: readonly T[], | ||
| 71 | sortOrder: SidebarThreadSortOrder, | ||
| 72 | ): T[] { | ||
| 73 | return Arr.sort( | ||
| 74 | threads, | ||
| 75 | Order.mapInput( | ||
| 76 | Order.Struct({ | ||
| 77 | timestamp: Order.flip(Order.Number), | ||
| 78 | id: Order.flip(Order.String), | ||
| 79 | }), | ||
| 80 | (thread: T) => ({ | ||
| 81 | timestamp: getThreadSortTimestamp(thread, sortOrder), | ||
| 82 | id: thread.id, | ||
| 83 | }), | ||
| 84 | ), | ||
| 85 | ); | ||
| 86 | } | ||
| 87 | |||
| 88 | export function getLatestThreadForProject< | ||
| 89 | T extends { | ||
| 90 | readonly id: string; | ||
| 91 | readonly projectId: ProjectId; | ||
| 92 | readonly archivedAt: string | null; | ||
| 93 | } & ThreadSortInput, | ||
| 94 | >(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { | ||
| 95 | return ( | ||
| 96 | sortThreads( | ||
| 97 | threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), | ||
| 98 | sortOrder, | ||
| 99 | )[0] ?? null | ||
| 100 | ); | ||
| 101 | } | ||
packages/shared/package.json+4| ... | @@ -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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | isWorkspaceBrowserPreviewPath, | ||
| 5 | isWorkspaceImagePreviewPath, | ||
| 6 | isWorkspacePreviewEntryPath, | ||
| 7 | } from "./filePreview.ts"; | ||
| 8 | |||
| 9 | describe("workspace file previews", () => { | ||
| 10 | it.each(["report.html", "report.HTM", "document.pdf?download=1"])( | ||
| 11 | "recognizes browser preview path %s", | ||
| 12 | (path) => { | ||
| 13 | expect(isWorkspaceBrowserPreviewPath(path)).toBe(true); | ||
| 14 | expect(isWorkspacePreviewEntryPath(path)).toBe(true); | ||
| 15 | }, | ||
| 16 | ); | ||
| 17 | |||
| 18 | it.each([ | ||
| 19 | "icon.png", | ||
| 20 | "photo.JPEG", | ||
| 21 | "animation.gif", | ||
| 22 | "vector.svg#mark", | ||
| 23 | "texture.webp", | ||
| 24 | "image.avif", | ||
| 25 | ])("recognizes image preview path %s", (path) => { | ||
| 26 | expect(isWorkspaceImagePreviewPath(path)).toBe(true); | ||
| 27 | expect(isWorkspacePreviewEntryPath(path)).toBe(true); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it.each(["README.md", "src/index.ts", "image.png.ts", "png"])( | ||
| 31 | "rejects non-preview path %s", | ||
| 32 | (path) => { | ||
| 33 | expect(isWorkspacePreviewEntryPath(path)).toBe(false); | ||
| 34 | }, | ||
| 35 | ); | ||
| 36 | }); | ||
packages/shared/src/filePreview.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const; | ||
| 2 | |||
| 3 | export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ | ||
| 4 | ".avif", | ||
| 5 | ".gif", | ||
| 6 | ".ico", | ||
| 7 | ".jpeg", | ||
| 8 | ".jpg", | ||
| 9 | ".png", | ||
| 10 | ".svg", | ||
| 11 | ".webp", | ||
| 12 | ] as const; | ||
| 13 | |||
| 14 | function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean { | ||
| 15 | const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; | ||
| 16 | return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); | ||
| 17 | } | ||
| 18 | |||
| 19 | export function isWorkspaceBrowserPreviewPath(path: string): boolean { | ||
| 20 | return hasPreviewExtension(path, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS); | ||
| 21 | } | ||
| 22 | |||
| 23 | export function isWorkspaceImagePreviewPath(path: string): boolean { | ||
| 24 | return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS); | ||
| 25 | } | ||
| 26 | |||
| 27 | export function isWorkspacePreviewEntryPath(path: string): boolean { | ||
| 28 | return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path); | ||
| 29 | } | ||
pnpm-lock.yaml+58-41| ... | @@ -247,7 +247,7 @@ importers: | ... | @@ -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.0 | 249 | 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.15 | 252 | 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.4 | 363 | 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.3 | 369 | 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: '*' |
| 8455 | 8458 | ||
| 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.0 | 10795 | 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.3 | 10797 | 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.0 | 11405 | connect: 3.7.0 |
| 11397 | debug: 4.4.3 | 11406 | debug: 4.4.3 |
| 11398 | dnssd-advertise: 1.1.4 | 11407 | 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.4 | 11409 | expo-server: 56.0.4 |
| 11401 | fetch-nodeshim: 0.4.10 | 11410 | fetch-nodeshim: 0.4.10 |
| 11402 | getenv: 2.0.0 | 11411 | getenv: 2.0.0 |
| ... | @@ -11494,7 +11503,7 @@ snapshots: | ... | @@ -11494,7 +11503,7 @@ snapshots: |
| 11494 | 11503 | ||
| 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.3 | 11507 | 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) |
| 11500 | 11509 | ||
| ... | @@ -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.10 | 11572 | 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.3 | 11574 | 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.11 | 11576 | stacktrace-parser: 0.1.11 |
| ... | @@ -11593,7 +11602,7 @@ snapshots: | ... | @@ -11593,7 +11602,7 @@ snapshots: |
| 11593 | postcss: 8.5.15 | 11602 | postcss: 8.5.15 |
| 11594 | resolve-from: 5.0.0 | 11603 | 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 | - bufferutil | 11607 | - bufferutil |
| 11599 | - supports-color | 11608 | - 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.10 | 11626 | 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.0 | 11628 | pretty-format: 29.7.0 |
| 11620 | react: 19.2.3 | 11629 | 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.3 | 11704 | 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.4 | 11708 | expo-server: 56.0.4 |
| ... | @@ -11717,7 +11726,7 @@ snapshots: | ... | @@ -11717,7 +11726,7 @@ snapshots: |
| 11717 | 11726 | ||
| 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.3 | 11730 | 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.0 | 11732 | sf-symbols-typescript: 2.2.0 |
| ... | @@ -14450,7 +14459,7 @@ snapshots: | ... | @@ -14450,7 +14459,7 @@ snapshots: |
| 14450 | react-refresh: 0.14.2 | 14459 | react-refresh: 0.14.2 |
| 14451 | optionalDependencies: | 14460 | optionalDependencies: |
| 14452 | '@babel/runtime': 7.29.7 | 14461 | '@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: |
| 15316 | 15325 | ||
| 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) |
| 15320 | 15329 | ||
| 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.3 | 15335 | 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.1 | 15357 | '@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.0 | 15359 | resolve-from: 5.0.0 |
| 15351 | semver: 7.8.1 | 15360 | semver: 7.8.1 |
| 15352 | 15361 | ||
| 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.3 | 15366 | 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: |
| 15361 | 15370 | ||
| 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.3 | 15374 | 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) |
| 15367 | 15376 | ||
| 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.0 | 15379 | '@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-color | 15383 | - supports-color |
| 15375 | 15384 | ||
| 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) |
| 15379 | 15388 | ||
| 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.1 | 15402 | '@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) |
| 15398 | 15407 | ||
| 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) |
| 15402 | 15411 | ||
| 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) |
| 15408 | 15417 | ||
| ... | @@ -15410,40 +15419,40 @@ snapshots: | ... | @@ -15410,40 +15419,40 @@ snapshots: |
| 15410 | 15419 | ||
| 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) |
| 15415 | 15424 | ||
| 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.0 | 15428 | fontfaceobserver: 2.3.0 |
| 15420 | react: 19.2.3 | 15429 | 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) |
| 15422 | 15431 | ||
| 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.3 | 15435 | 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) |
| 15428 | 15437 | ||
| 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) |
| 15432 | 15441 | ||
| 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) |
| 15436 | 15445 | ||
| 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) |
| 15441 | 15450 | ||
| 15442 | expo-json-utils@56.0.0: {} | 15451 | expo-json-utils@56.0.0: {} |
| 15443 | 15452 | ||
| 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.3 | 15456 | react: 19.2.3 |
| 15448 | 15457 | ||
| 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: |
| 15458 | 15467 | ||
| 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.0 | 15471 | expo-json-utils: 56.0.0 |
| 15463 | 15472 | ||
| 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: |
| 15487 | 15496 | ||
| 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.3 | 15500 | react: 19.2.3 |
| 15492 | 15501 | ||
| 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.0 | 15505 | abort-controller: 3.0.0 |
| 15497 | badgin: 1.2.3 | 15506 | 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.3 | 15510 | react: 19.2.3 |
| ... | @@ -15506,7 +15515,7 @@ snapshots: | ... | @@ -15506,7 +15515,7 @@ snapshots: |
| 15506 | 15515 | ||
| 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.3 | 15519 | 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) |
| 15512 | 15521 | ||
| ... | @@ -15525,7 +15534,7 @@ snapshots: | ... | @@ -15525,7 +15534,7 @@ snapshots: |
| 15525 | color: 4.2.3 | 15534 | color: 4.2.3 |
| 15526 | debug: 4.4.3 | 15535 | debug: 4.4.3 |
| 15527 | escape-string-regexp: 4.0.0 | 15536 | 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: |
| 15561 | 15570 | ||
| 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) |
| 15565 | 15574 | ||
| 15566 | expo-server@56.0.4: {} | 15575 | expo-server@56.0.4: {} |
| 15567 | 15576 | ||
| ... | @@ -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.0 | 15582 | xml2js: 0.6.0 |
| 15574 | transitivePeerDependencies: | 15583 | transitivePeerDependencies: |
| 15575 | - supports-color | 15584 | - 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.38 | 15591 | '@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.3 | 15594 | 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: |
| 15588 | 15597 | ||
| 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) |
| 15592 | 15601 | ||
| 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.3 | 15607 | arg: 4.1.3 |
| 15599 | chalk: 4.1.2 | 15608 | chalk: 4.1.2 |
| 15600 | debug: 4.4.3 | 15609 | 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.1 | 15611 | 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.0 | 15613 | expo-structured-headers: 56.0.0 |
| ... | @@ -15617,14 +15626,14 @@ snapshots: | ... | @@ -15617,14 +15626,14 @@ snapshots: |
| 15617 | 15626 | ||
| 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) |
| 15622 | 15631 | ||
| 15623 | expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): | 15632 | expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): |
| 15624 | dependencies: | 15633 | dependencies: |
| 15625 | '@expo/plist': 0.7.0 | 15634 | '@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.3 | 15637 | 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-reanimated | 15644 | - react-native-reanimated |
| 15636 | - react-native-worklets | 15645 | - react-native-worklets |
| 15637 | 15646 | ||
| 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.7 | 15649 | '@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 | - bufferutil | 15680 | - 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-3 | 18150 | whatwg-url-without-unicode: 8.0.0-3 |
| 18141 | 18151 | ||
| 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.7 | 18161 | '@babel/core': 7.29.7 |