| 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 | 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 | 183 | effectIt.effect("captures a PNG screenshot into browser artifacts", () => |
| 132 | 184 | withManager((manager) => |
| 133 | 185 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+89-12| ... | ... | @@ -1195,26 +1195,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1195 | 1195 | } |
| 1196 | 1196 | yield* attachListeners(tabId, wc); |
| 1197 | 1197 | runFork(ensureControlSession(wc).pipe(Effect.ignore)); |
| 1198 | if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { | |
| 1199 | yield* attempt("registerWebview.restoreZoom", () => wc.setZoomFactor(tab.zoomFactor)).pipe( | |
| 1200 | Effect.ignore, | |
| 1201 | ); | |
| 1202 | } | |
| 1203 | yield* update(tabId, { | |
| 1204 | webContentsId, | |
| 1205 | navStatus: computeNavStatus(wc), | |
| 1206 | canGoBack: wc.navigationHistory.canGoBack(), | |
| 1207 | canGoForward: wc.navigationHistory.canGoForward(), | |
| 1208 | zoomFactor: tab.zoomFactor, | |
| 1198 | const registeredAt = yield* currentIso; | |
| 1199 | const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { | |
| 1200 | const current = tabs.get(tabId); | |
| 1201 | if (!current) { | |
| 1202 | return [ | |
| 1203 | Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), | |
| 1204 | tabs, | |
| 1205 | ] as const; | |
| 1206 | } | |
| 1207 | const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; | |
| 1208 | const next: PreviewTabState = { | |
| 1209 | ...current, | |
| 1210 | webContentsId, | |
| 1211 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, | |
| 1212 | canGoBack: wc.navigationHistory.canGoBack(), | |
| 1213 | canGoForward: wc.navigationHistory.canGoForward(), | |
| 1214 | updatedAt: registeredAt, | |
| 1215 | }; | |
| 1216 | return [ | |
| 1217 | Option.some({ | |
| 1218 | state: next, | |
| 1219 | pendingUrl, | |
| 1220 | }), | |
| 1221 | replaceMap(tabs, (copy) => { | |
| 1222 | copy.set(tabId, next); | |
| 1223 | }), | |
| 1224 | ] as const; | |
| 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 | 1236 | yield* attempt("registerWebview.sendTheme", () => |
| 1211 | 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 | 1254 | const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { |
| 1216 | const wc = yield* requireWebContents(tabId); | |
| 1217 | 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 | 1295 | if (wc.getURL() === url) { |
| 1219 | 1296 | yield* attempt("navigate.reload", () => wc.reload()); |
| 1220 | 1297 | return; |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+8-104| ... | ... | @@ -70,7 +70,12 @@ static void T3MarkdownTextApplyAttachments( |
| 70 | 70 | renderingMode:UIImageRenderingModeAlwaysOriginal]; |
| 71 | 71 | } |
| 72 | 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 | 79 | const NSRange range = NSMakeRange( |
| 75 | 80 | attachmentRange.location, |
| 76 | 81 | MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); |
| ... | ... | @@ -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 | 88 | @protocol T3MarkdownOutsideTapTarget <NSObject> |
| 182 | 89 | - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; |
| 183 | 90 | @end |
| ... | ... | @@ -285,7 +192,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 285 | 192 | |
| 286 | 193 | @implementation T3MarkdownText { |
| 287 | 194 | UIView * _view; |
| 288 | T3MarkdownTextBackingView * _textView; | |
| 195 | UITextView * _textView; | |
| 289 | 196 | T3MarkdownTextShadowNode::ConcreteState::Shared _state; |
| 290 | 197 | __weak UIWindow * _outsideTapWindow; |
| 291 | 198 | BOOL _suppressSelectionChange; |
| ... | ... | @@ -308,7 +215,7 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 308 | 215 | self.contentView = _view; |
| 309 | 216 | self.clipsToBounds = true; |
| 310 | 217 | |
| 311 | _textView = [[T3MarkdownTextBackingView alloc] init]; | |
| 218 | _textView = [[UITextView alloc] init]; | |
| 312 | 219 | _attachmentImages = [[NSMutableDictionary alloc] init]; |
| 313 | 220 | _pendingAttachmentUris = [[NSMutableSet alloc] init]; |
| 314 | 221 | _textView.scrollEnabled = false; |
| ... | ... | @@ -405,9 +312,6 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) |
| 405 | 312 | convertedAttrString, |
| 406 | 313 | _state->getData().attachmentRanges, |
| 407 | 314 | _attachmentImages); |
| 408 | _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds( | |
| 409 | convertedAttrString, | |
| 410 | _state->getData().chipRanges); | |
| 411 | 315 | [self loadAttachmentImages:_state->getData().attachmentRanges]; |
| 412 | 316 | |
| 413 | 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 | 28 | std::string imageUri; |
| 29 | 29 | }; |
| 30 | 30 | |
| 31 | struct T3MarkdownTextChipRange { | |
| 32 | size_t location; | |
| 33 | size_t length; | |
| 34 | bool isSkill; | |
| 35 | }; | |
| 31 | inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { | |
| 32 | return 14; | |
| 33 | } | |
| 34 | ||
| 35 | inline Float T3MarkdownTextAttachmentBaselineOffset( | |
| 36 | const T3MarkdownTextAttachmentRange &) { | |
| 37 | return -2; | |
| 38 | } | |
| 36 | 39 | |
| 37 | 40 | class T3MarkdownTextStateReal final { |
| 38 | 41 | public: |
| 39 | 42 | AttributedString attributedString; |
| 40 | 43 | std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges; |
| 41 | 44 | std::vector<T3MarkdownTextAttachmentRange> attachmentRanges; |
| 42 | std::vector<T3MarkdownTextChipRange> chipRanges; | |
| 43 | 45 | }; |
| 44 | 46 | |
| 45 | 47 | class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< |
| ... | ... | @@ -72,6 +74,5 @@ private: |
| 72 | 74 | mutable AttributedString _attributedString; |
| 73 | 75 | mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges; |
| 74 | 76 | mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges; |
| 75 | mutable std::vector<T3MarkdownTextChipRange> _chipRanges; | |
| 76 | 77 | }; |
| 77 | 78 | } // namespace facebook::React |
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm+15-20| ... | ... | @@ -9,9 +9,8 @@ |
| 9 | 9 | namespace facebook::react { |
| 10 | 10 | |
| 11 | 11 | static constexpr Float ParagraphStyleEncodingOffset = 1000; |
| 12 | static constexpr auto ChipNativeIdPrefix = "t3-chip-"; | |
| 13 | static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:"; | |
| 14 | static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:"; | |
| 12 | static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; | |
| 13 | static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; | |
| 15 | 14 | |
| 16 | 15 | static void applyParagraphStyles( |
| 17 | 16 | NSMutableAttributedString *attributedString, |
| ... | ... | @@ -58,7 +57,12 @@ static void applyAttachments( |
| 58 | 57 | |
| 59 | 58 | NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; |
| 60 | 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 | 66 | const NSRange range = NSMakeRange( |
| 63 | 67 | attachmentRange.location, |
| 64 | 68 | MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); |
| ... | ... | @@ -91,7 +95,6 @@ Size T3MarkdownTextShadowNode::measureContent( |
| 91 | 95 | auto baseAttributedString = AttributedString{}; |
| 92 | 96 | auto paragraphStyleRanges = std::vector<T3MarkdownTextParagraphStyleRange>{}; |
| 93 | 97 | auto attachmentRanges = std::vector<T3MarkdownTextAttachmentRange>{}; |
| 94 | auto chipRanges = std::vector<T3MarkdownTextChipRange>{}; | |
| 95 | 98 | size_t utf16Offset = 0; |
| 96 | 99 | const auto &children = getChildren(); |
| 97 | 100 | for (size_t i = 0; i < children.size(); i++) { |
| ... | ... | @@ -184,25 +187,19 @@ Size T3MarkdownTextShadowNode::measureContent( |
| 184 | 187 | props.shadowRadius - ParagraphStyleEncodingOffset, |
| 185 | 188 | }); |
| 186 | 189 | } |
| 187 | if (props.nativeId.rfind(ChipNativeIdPrefix, 0) == 0 && fragmentLength > 0) { | |
| 188 | chipRanges.push_back(T3MarkdownTextChipRange{ | |
| 189 | utf16Offset, | |
| 190 | fragmentLength, | |
| 191 | props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0, | |
| 192 | }); | |
| 193 | } | |
| 194 | if (props.nativeId.rfind(FileChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { | |
| 190 | if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { | |
| 195 | 191 | attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ |
| 196 | utf16Offset + 1, | |
| 192 | utf16Offset, | |
| 197 | 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 | 196 | } else if ( |
| 201 | props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { | |
| 197 | props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { | |
| 202 | 198 | attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ |
| 203 | utf16Offset + 1, | |
| 199 | utf16Offset, | |
| 204 | 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 | 205 | utf16Offset += fragmentLength; |
| ... | ... | @@ -213,7 +210,6 @@ Size T3MarkdownTextShadowNode::measureContent( |
| 213 | 210 | _attributedString = baseAttributedString; |
| 214 | 211 | _paragraphStyleRanges = paragraphStyleRanges; |
| 215 | 212 | _attachmentRanges = attachmentRanges; |
| 216 | _chipRanges = chipRanges; | |
| 217 | 213 | |
| 218 | 214 | NSMutableAttributedString *convertedAttributedString = |
| 219 | 215 | [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; |
| ... | ... | @@ -263,7 +259,6 @@ void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { |
| 263 | 259 | _attributedString, |
| 264 | 260 | _paragraphStyleRanges, |
| 265 | 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 | 40 | function SelectableNode(props: { |
| 41 | 41 | readonly node: MarkdownNode; |
| 42 | 42 | readonly textStyle: NativeMarkdownTextStyle; |
| 43 | readonly onLinkPress?: (href: string) => void; | |
| 43 | 44 | }) { |
| 44 | 45 | return ( |
| 45 | 46 | <NativeMarkdownSelectableText |
| 46 | 47 | runs={nativeMarkdownDocumentRuns(documentFor(props.node))} |
| 47 | 48 | textStyle={props.textStyle} |
| 49 | onLinkPress={props.onLinkPress} | |
| 48 | 50 | /> |
| 49 | 51 | ); |
| 50 | 52 | } |
| ... | ... | @@ -312,6 +314,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] { |
| 312 | 314 | function NativeTable(props: { |
| 313 | 315 | readonly node: MarkdownNode; |
| 314 | 316 | readonly textStyle: NativeMarkdownTextStyle; |
| 317 | readonly onLinkPress?: (href: string) => void; | |
| 315 | 318 | }) { |
| 316 | 319 | const rows = collectTableRows(props.node); |
| 317 | 320 | return ( |
| ... | ... | @@ -351,6 +354,7 @@ function NativeTable(props: { |
| 351 | 354 | rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, |
| 352 | 355 | )} |
| 353 | 356 | textStyle={props.textStyle} |
| 357 | onLinkPress={props.onLinkPress} | |
| 354 | 358 | /> |
| 355 | 359 | </View> |
| 356 | 360 | ))} |
| ... | ... | @@ -364,10 +368,17 @@ function NativeTable(props: { |
| 364 | 368 | function NativeMarkdownImage(props: { |
| 365 | 369 | readonly node: MarkdownNode; |
| 366 | 370 | readonly textStyle: NativeMarkdownTextStyle; |
| 371 | readonly onLinkPress?: (href: string) => void; | |
| 367 | 372 | }) { |
| 368 | 373 | const href = props.node.href; |
| 369 | 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 | 384 | return ( |
| ... | ... | @@ -426,6 +437,7 @@ function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] { |
| 426 | 437 | function NativeMixedParagraph(props: { |
| 427 | 438 | readonly node: MarkdownNode; |
| 428 | 439 | readonly textStyle: NativeMarkdownTextStyle; |
| 440 | readonly onLinkPress?: (href: string) => void; | |
| 429 | 441 | }) { |
| 430 | 442 | return ( |
| 431 | 443 | <View style={{ gap: 8 }}> |
| ... | ... | @@ -435,9 +447,15 @@ function NativeMixedParagraph(props: { |
| 435 | 447 | key={nodeKey(child, index)} |
| 436 | 448 | node={child} |
| 437 | 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 | 461 | </View> |
| ... | ... | @@ -448,6 +466,7 @@ function NativeList(props: { |
| 448 | 466 | readonly node: MarkdownNode; |
| 449 | 467 | readonly textStyle: NativeMarkdownTextStyle; |
| 450 | 468 | readonly highlightCode: MarkdownCodeHighlighter; |
| 469 | readonly onLinkPress?: (href: string) => void; | |
| 451 | 470 | readonly depth: number; |
| 452 | 471 | }) { |
| 453 | 472 | const ordered = props.node.ordered ?? false; |
| ... | ... | @@ -508,6 +527,7 @@ function NativeList(props: { |
| 508 | 527 | node={child} |
| 509 | 528 | textStyle={props.textStyle} |
| 510 | 529 | highlightCode={props.highlightCode} |
| 530 | onLinkPress={props.onLinkPress} | |
| 511 | 531 | depth={props.depth + 1} |
| 512 | 532 | compact |
| 513 | 533 | /> |
| ... | ... | @@ -524,6 +544,7 @@ export function NativeMarkdownBlock(props: { |
| 524 | 544 | readonly node: MarkdownNode; |
| 525 | 545 | readonly textStyle: NativeMarkdownTextStyle; |
| 526 | 546 | readonly highlightCode: MarkdownCodeHighlighter; |
| 547 | readonly onLinkPress?: (href: string) => void; | |
| 527 | 548 | readonly depth?: number; |
| 528 | 549 | readonly compact?: boolean; |
| 529 | 550 | }) { |
| ... | ... | @@ -538,6 +559,7 @@ export function NativeMarkdownBlock(props: { |
| 538 | 559 | node={child} |
| 539 | 560 | textStyle={props.textStyle} |
| 540 | 561 | highlightCode={props.highlightCode} |
| 562 | onLinkPress={props.onLinkPress} | |
| 541 | 563 | depth={depth} |
| 542 | 564 | /> |
| 543 | 565 | ))} |
| ... | ... | @@ -553,9 +575,21 @@ export function NativeMarkdownBlock(props: { |
| 553 | 575 | /> |
| 554 | 576 | ); |
| 555 | 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 | 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 | 593 | case "horizontal_rule": |
| 560 | 594 | return ( |
| 561 | 595 | <View |
| ... | ... | @@ -583,6 +617,7 @@ export function NativeMarkdownBlock(props: { |
| 583 | 617 | node={child} |
| 584 | 618 | textStyle={props.textStyle} |
| 585 | 619 | highlightCode={props.highlightCode} |
| 620 | onLinkPress={props.onLinkPress} | |
| 586 | 621 | depth={depth} |
| 587 | 622 | compact |
| 588 | 623 | /> |
| ... | ... | @@ -595,14 +630,23 @@ export function NativeMarkdownBlock(props: { |
| 595 | 630 | node={props.node} |
| 596 | 631 | textStyle={props.textStyle} |
| 597 | 632 | highlightCode={props.highlightCode} |
| 633 | onLinkPress={props.onLinkPress} | |
| 598 | 634 | depth={depth} |
| 599 | 635 | /> |
| 600 | 636 | ); |
| 601 | 637 | case "paragraph": |
| 602 | 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 | 651 | case "html_block": |
| 608 | 652 | case "math_block": |
| ... | ... | @@ -618,7 +662,11 @@ export function NativeMarkdownBlock(props: { |
| 618 | 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 | 670 | </View> |
| 623 | 671 | ); |
| 624 | 672 | case "table_head": |
| ... | ... | @@ -635,6 +683,7 @@ export function NativeMarkdownBlock(props: { |
| 635 | 683 | node={child} |
| 636 | 684 | textStyle={props.textStyle} |
| 637 | 685 | highlightCode={props.highlightCode} |
| 686 | onLinkPress={props.onLinkPress} | |
| 638 | 687 | depth={depth} |
| 639 | 688 | compact |
| 640 | 689 | /> |
| ... | ... | @@ -642,6 +691,12 @@ export function NativeMarkdownBlock(props: { |
| 642 | 691 | </View> |
| 643 | 692 | ); |
| 644 | 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 | import { useEffect, useMemo, useState } from "react"; | |
| 2 | import { Asset } from "expo-asset"; | |
| 3 | 1 | import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; |
| 4 | 2 | |
| 5 | 3 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; |
| 6 | 4 | import { markdownFileIconSource } from "./markdownFileIcons"; |
| 7 | import type { MarkdownFileIcon } from "./markdownLinks"; | |
| 8 | 5 | import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; |
| 9 | 6 | import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; |
| 10 | 7 | |
| 11 | 8 | const EXTERNAL_LINK_PREFIX = "â—‰ "; |
| 12 | const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0"; | |
| 13 | const CHIP_SUFFIX = "\u00A0"; | |
| 9 | const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; | |
| 14 | 10 | const SKILL_ICON_PLACEHOLDER = "\uFFFC"; |
| 15 | 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 | 13 | function runKeySignature(run: NativeMarkdownTextRun): string { |
| 60 | 14 | return [ |
| 61 | 15 | run.text, |
| ... | ... | @@ -81,13 +35,16 @@ function runKeySignature(run: NativeMarkdownTextRun): string { |
| 81 | 35 | function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { |
| 82 | 36 | const isFile = run.fileIcon != null; |
| 83 | 37 | const isSkill = run.skillName != null; |
| 84 | const isChip = isFile || isSkill; | |
| 85 | 38 | const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); |
| 86 | 39 | const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; |
| 87 | 40 | const isHeading = run.role === "heading"; |
| 88 | 41 | const isCodeBlock = run.role === "code-block" || run.role === "code-language"; |
| 89 | 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 | 49 | return { |
| 93 | 50 | color: isFile |
| ... | ... | @@ -106,20 +63,23 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle |
| 106 | 63 | ? textStyle.mutedColor |
| 107 | 64 | : run.role === "list-marker" |
| 108 | 65 | ? textStyle.mutedColor |
| 109 | : run.code || isFile | |
| 66 | : isCodeBlock | |
| 110 | 67 | ? textStyle.codeColor |
| 111 | : run.bold | |
| 112 | ? textStyle.strongColor | |
| 113 | : textStyle.color, | |
| 114 | fontFamily: isChip | |
| 115 | ? "DMSans_500Medium" | |
| 116 | : run.code || isCodeBlock | |
| 117 | ? "ui-monospace" | |
| 118 | : isHeading | |
| 119 | ? textStyle.headingFontFamily | |
| 120 | : run.bold | |
| 121 | ? textStyle.boldFontFamily | |
| 122 | : textStyle.fontFamily, | |
| 68 | : run.code | |
| 69 | ? textStyle.inlineCodeColor | |
| 70 | : run.bold | |
| 71 | ? textStyle.strongColor | |
| 72 | : textStyle.color, | |
| 73 | fontFamily: | |
| 74 | isFile || isSkill | |
| 75 | ? textStyle.boldFontFamily | |
| 76 | : run.code || isCodeBlock | |
| 77 | ? "ui-monospace" | |
| 78 | : isHeading | |
| 79 | ? textStyle.headingFontFamily | |
| 80 | : run.bold | |
| 81 | ? textStyle.boldFontFamily | |
| 82 | : textStyle.fontFamily, | |
| 123 | 83 | fontSize: |
| 124 | 84 | run.role === "spacer" |
| 125 | 85 | ? (run.spacing ?? 10) |
| ... | ... | @@ -129,7 +89,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle |
| 129 | 89 | ? headingFontSize |
| 130 | 90 | : run.role === "code-language" |
| 131 | 91 | ? 11 |
| 132 | : run.code || isChip || isCodeBlock | |
| 92 | : run.code || isCodeBlock | |
| 133 | 93 | ? Math.max(12, textStyle.fontSize - 2) |
| 134 | 94 | : textStyle.fontSize, |
| 135 | 95 | lineHeight: |
| ... | ... | @@ -143,17 +103,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle |
| 143 | 103 | ? 18 |
| 144 | 104 | : textStyle.lineHeight, |
| 145 | 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 | 107 | textDecorationLine, |
| 148 | backgroundColor: isCodeBlock | |
| 149 | ? textStyle.codeBlockBackgroundColor | |
| 150 | : isSkill | |
| 151 | ? textStyle.skillBackgroundColor | |
| 152 | : run.code | |
| 153 | ? textStyle.codeBackgroundColor | |
| 154 | : isFile | |
| 155 | ? textStyle.fileBackgroundColor | |
| 156 | : undefined, | |
| 108 | backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : undefined, | |
| 157 | 109 | ...(hasParagraphStyle |
| 158 | 110 | ? { |
| 159 | 111 | shadowColor: "transparent", |
| ... | ... | @@ -170,9 +122,9 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle |
| 170 | 122 | export function NativeMarkdownSelectableText(props: { |
| 171 | 123 | readonly runs: ReadonlyArray<NativeMarkdownTextRun>; |
| 172 | 124 | readonly textStyle: NativeMarkdownTextStyle; |
| 125 | readonly onLinkPress?: (href: string) => void; | |
| 173 | 126 | }) { |
| 174 | 127 | const colorScheme = useColorScheme(); |
| 175 | const fileIconUris = useFileIconUris(props.runs); | |
| 176 | 128 | const occurrences = new Map<string, number>(); |
| 177 | 129 | const prefixedExternalLinks = new Set<string>(); |
| 178 | 130 | const keyedRuns = props.runs.map((run) => { |
| ... | ... | @@ -182,9 +134,9 @@ export function NativeMarkdownSelectableText(props: { |
| 182 | 134 | |
| 183 | 135 | let text = run.text; |
| 184 | 136 | if (run.fileIcon) { |
| 185 | text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`; | |
| 137 | text = `${INLINE_ATTACHMENT_PREFIX}${text}`; | |
| 186 | 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 | 140 | } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { |
| 189 | 141 | prefixedExternalLinks.add(run.href); |
| 190 | 142 | text = `${EXTERNAL_LINK_PREFIX}${text}`; |
| ... | ... | @@ -200,12 +152,11 @@ export function NativeMarkdownSelectableText(props: { |
| 200 | 152 | props.textStyle.strongColor, |
| 201 | 153 | props.textStyle.mutedColor, |
| 202 | 154 | props.textStyle.linkColor, |
| 155 | props.textStyle.inlineCodeColor, | |
| 203 | 156 | props.textStyle.codeColor, |
| 204 | 157 | props.textStyle.codeBackgroundColor, |
| 205 | 158 | props.textStyle.codeBlockBackgroundColor, |
| 206 | props.textStyle.fileBackgroundColor, | |
| 207 | 159 | props.textStyle.fileTextColor, |
| 208 | props.textStyle.skillBackgroundColor, | |
| 209 | 160 | props.textStyle.skillTextColor, |
| 210 | 161 | props.textStyle.quoteMarkerColor, |
| 211 | 162 | props.textStyle.dividerColor, |
| ... | ... | @@ -217,7 +168,8 @@ export function NativeMarkdownSelectableText(props: { |
| 217 | 168 | uiTextView |
| 218 | 169 | selectable |
| 219 | 170 | style={{ |
| 220 | width: "100%", | |
| 171 | flexShrink: 1, | |
| 172 | minWidth: 0, | |
| 221 | 173 | color: props.textStyle.color, |
| 222 | 174 | fontFamily: props.textStyle.fontFamily, |
| 223 | 175 | fontSize: props.textStyle.fontSize, |
| ... | ... | @@ -231,19 +183,20 @@ export function NativeMarkdownSelectableText(props: { |
| 231 | 183 | key={key} |
| 232 | 184 | nativeID={ |
| 233 | 185 | run.fileIcon |
| 234 | ? `t3-chip-file:${ | |
| 235 | fileIconUris.get(run.fileIcon) ?? | |
| 236 | Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri | |
| 237 | }` | |
| 186 | ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` | |
| 238 | 187 | : run.skillName |
| 239 | ? "t3-chip-skill:sf:cube" | |
| 188 | ? "t3-skill:sf:cube" | |
| 240 | 189 | : undefined |
| 241 | 190 | } |
| 242 | 191 | style={runStyle(run, props.textStyle)} |
| 243 | 192 | onPress={ |
| 244 | 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 | 201 | : undefined |
| 249 | 202 | } |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+19-4| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | nativeMarkdownChunkSpacing, |
| 7 | 7 | nativeMarkdownDocumentChunks, |
| 8 | 8 | nativeMarkdownDocumentRuns, |
| 9 | nativeMarkdownWithPreservedSoftBreaks, | |
| 9 | 10 | } from "./nativeMarkdownText"; |
| 10 | 11 | import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; |
| 11 | 12 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; |
| ... | ... | @@ -33,15 +34,20 @@ export function SelectableMarkdownText({ |
| 33 | 34 | skills = EMPTY_SKILLS, |
| 34 | 35 | textStyle, |
| 35 | 36 | highlightCode, |
| 37 | preserveSoftBreaks = false, | |
| 38 | onLinkPress, | |
| 36 | 39 | marginTop = 0, |
| 37 | 40 | marginBottom = 0, |
| 38 | 41 | }: SelectableMarkdownTextProps) { |
| 39 | 42 | const chunks = useMemo(() => { |
| 40 | const document = parseMarkdownWithOptions(markdown, { | |
| 43 | const parsedDocument = parseMarkdownWithOptions(markdown, { | |
| 41 | 44 | gfm: true, |
| 42 | 45 | html: true, |
| 43 | 46 | math: false, |
| 44 | 47 | }); |
| 48 | const document = preserveSoftBreaks | |
| 49 | ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) | |
| 50 | : parsedDocument; | |
| 45 | 51 | return nativeMarkdownDocumentChunks(document).map((chunk) => |
| 46 | 52 | chunk.kind === "selectable" |
| 47 | 53 | ? { |
| ... | ... | @@ -50,10 +56,14 @@ export function SelectableMarkdownText({ |
| 50 | 56 | } |
| 51 | 57 | : chunk, |
| 52 | 58 | ); |
| 53 | }, [markdown, skills]); | |
| 59 | }, [markdown, preserveSoftBreaks, skills]); | |
| 54 | 60 | |
| 55 | 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 | 67 | {chunks.map((chunk, index) => { |
| 58 | 68 | const content = |
| 59 | 69 | chunk.kind === "rich" ? ( |
| ... | ... | @@ -61,9 +71,14 @@ export function SelectableMarkdownText({ |
| 61 | 71 | node={chunk.node} |
| 62 | 72 | textStyle={textStyle} |
| 63 | 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 | 84 | return ( |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+3-2| ... | ... | @@ -3,12 +3,11 @@ export interface NativeMarkdownTextStyle { |
| 3 | 3 | readonly strongColor: string; |
| 4 | 4 | readonly mutedColor: string; |
| 5 | 5 | readonly linkColor: string; |
| 6 | readonly inlineCodeColor: string; | |
| 6 | 7 | readonly codeColor: string; |
| 7 | 8 | readonly codeBackgroundColor: string; |
| 8 | 9 | readonly codeBlockBackgroundColor: string; |
| 9 | readonly fileBackgroundColor: string; | |
| 10 | 10 | readonly fileTextColor: string; |
| 11 | readonly skillBackgroundColor: string; | |
| 12 | 11 | readonly skillTextColor: string; |
| 13 | 12 | readonly quoteMarkerColor: string; |
| 14 | 13 | readonly dividerColor: string; |
| ... | ... | @@ -41,6 +40,8 @@ export interface SelectableMarkdownTextProps { |
| 41 | 40 | readonly textStyle: NativeMarkdownTextStyle; |
| 42 | 41 | readonly highlightCode: MarkdownCodeHighlighter; |
| 43 | 42 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 43 | readonly preserveSoftBreaks?: boolean; | |
| 44 | readonly onLinkPress?: (href: string) => void; | |
| 44 | 45 | readonly marginTop?: number; |
| 45 | 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 | 27 | } |
| 28 | 28 | | { |
| 29 | 29 | readonly kind: "file"; |
| 30 | readonly href: string; | |
| 30 | 31 | readonly icon: MarkdownFileIcon; |
| 31 | 32 | readonly label: string; |
| 33 | readonly path: string; | |
| 34 | readonly line?: number; | |
| 35 | readonly column?: number; | |
| 32 | 36 | } |
| 33 | 37 | | { |
| 34 | 38 | readonly kind: "link"; |
| ... | ... | @@ -247,7 +251,7 @@ function normalizeDestination(value: string): string { |
| 247 | 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 | 255 | try { |
| 252 | 256 | const parsed = new URL(href); |
| 253 | 257 | if (parsed.protocol.toLowerCase() !== "file:") { |
| ... | ... | @@ -256,15 +260,44 @@ function fileUrlPath(href: string): string | null { |
| 256 | 260 | const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) |
| 257 | 261 | ? parsed.pathname.slice(1) |
| 258 | 262 | : parsed.pathname; |
| 259 | const lineMatch = parsed.hash.match(/^#L(\d+)(?:C(\d+))?$/i); | |
| 260 | return `${safeDecode(path)}${ | |
| 261 | lineMatch?.[1] ? `:${lineMatch[1]}${lineMatch[2] ? `:${lineMatch[2]}` : ""}` : "" | |
| 262 | }`; | |
| 263 | return { path, hash: parsed.hash }; | |
| 263 | 264 | } catch { |
| 264 | 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 | 301 | function looksLikePosixFilesystemPath(path: string): boolean { |
| 269 | 302 | if (!path.startsWith("/")) { |
| 270 | 303 | return false; |
| ... | ... | @@ -331,14 +364,31 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese |
| 331 | 364 | // Relative paths and non-URL link destinations are handled below. |
| 332 | 365 | } |
| 333 | 366 | |
| 334 | const fileTarget = normalized.toLowerCase().startsWith("file:") | |
| 335 | ? fileUrlPath(normalized) | |
| 336 | : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized); | |
| 337 | if (fileTarget && looksLikeFilePath(fileTarget)) { | |
| 367 | const source = normalized.toLowerCase().startsWith("file:") | |
| 368 | ? fileUrlTarget(normalized) | |
| 369 | : stripSearchAndHash(normalized); | |
| 370 | const decodedSource = source | |
| 371 | ? { path: safeDecode(source.path.trim()), hash: safeDecode(source.hash.trim()) } | |
| 372 | : null; | |
| 373 | const fileTarget = decodedSource | |
| 374 | ? splitFilePosition(decodedSource.path, decodedSource.hash) | |
| 375 | : null; | |
| 376 | const targetWithPosition = fileTarget | |
| 377 | ? `${fileTarget.path}${ | |
| 378 | fileTarget.line | |
| 379 | ? `:${fileTarget.line}${fileTarget.column ? `:${fileTarget.column}` : ""}` | |
| 380 | : "" | |
| 381 | }` | |
| 382 | : null; | |
| 383 | if (fileTarget && targetWithPosition && looksLikeFilePath(targetWithPosition)) { | |
| 338 | 384 | return { |
| 339 | 385 | kind: "file", |
| 340 | icon: resolveMarkdownFileIcon(fileTarget), | |
| 341 | label: fileLabel(fileTarget), | |
| 386 | href: normalized, | |
| 387 | icon: resolveMarkdownFileIcon(fileTarget.path), | |
| 388 | label: fileLabel(targetWithPosition), | |
| 389 | path: fileTarget.path, | |
| 390 | ...(fileTarget.line ? { line: fileTarget.line } : {}), | |
| 391 | ...(fileTarget.column ? { column: fileTarget.column } : {}), | |
| 342 | 392 | }; |
| 343 | 393 | } |
| 344 | 394 |
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+10| ... | ... | @@ -293,6 +293,7 @@ function appendNode( |
| 293 | 293 | if (presentation.kind === "file") { |
| 294 | 294 | return appendRun(runs, presentation.label, { |
| 295 | 295 | ...context, |
| 296 | href: presentation.href, | |
| 296 | 297 | fileIcon: presentation.icon, |
| 297 | 298 | }); |
| 298 | 299 | } |
| ... | ... | @@ -319,6 +320,15 @@ export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray<Native |
| 319 | 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 | 332 | function appendBlockTerminator( |
| 323 | 333 | runs: NativeMarkdownTextRun[], |
| 324 | 334 | context: RunContext, |
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+4| ... | ... | @@ -57,6 +57,10 @@ public class T3ReviewDiffModule: Module { |
| 57 | 57 | view.setContentWidth(CGFloat(contentWidth)) |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in | |
| 61 | view.setInitialRowIndex(initialRowIndex) | |
| 62 | } | |
| 63 | ||
| 60 | 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 | 205 | ReviewDiffNativeStyle( |
| 206 | 206 | rowHeight: metric(payload?.rowHeight, fallback: 24), |
| 207 | 207 | contentWidth: metric(payload?.contentWidth, fallback: 2800), |
| 208 | changeBarWidth: metric(payload?.changeBarWidth, fallback: 4), | |
| 208 | changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), | |
| 209 | 209 | gutterWidth: metric(payload?.gutterWidth, fallback: 50), |
| 210 | 210 | codePadding: metric(payload?.codePadding, fallback: 8), |
| 211 | 211 | textVerticalInset: metric(payload?.textVerticalInset, fallback: 3), |
| ... | ... | @@ -243,6 +243,13 @@ private struct ReviewDiffNativeStyle { |
| 243 | 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 | 253 | private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight { |
| 247 | 254 | switch value?.lowercased() { |
| 248 | 255 | case "ultralight", "ultra-light": |
| ... | ... | @@ -316,6 +323,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 316 | 323 | private var lastMetricsDebugKey = "" |
| 317 | 324 | private var lastVisibleRangeDebugKey = "" |
| 318 | 325 | private var tokensResetKey = "" |
| 326 | private var initialRowIndex: Int? | |
| 327 | private var hasAppliedInitialRowIndex = false | |
| 319 | 328 | |
| 320 | 329 | let onDebug = EventDispatcher() |
| 321 | 330 | let onToggleFile = EventDispatcher() |
| ... | ... | @@ -394,6 +403,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 394 | 403 | do { |
| 395 | 404 | rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) |
| 396 | 405 | contentView.rows = rows |
| 406 | hasAppliedInitialRowIndex = false | |
| 397 | 407 | emitDebug("rows-decoded", [ |
| 398 | 408 | "rows": rows.count, |
| 399 | 409 | "firstKind": rows.first?.kind ?? "none", |
| ... | ... | @@ -402,6 +412,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 402 | 412 | } catch { |
| 403 | 413 | rows = [] |
| 404 | 414 | contentView.rows = [] |
| 415 | hasAppliedInitialRowIndex = false | |
| 405 | 416 | updateContentMetrics() |
| 406 | 417 | emitDebug("rows-decode-failed", [ |
| 407 | 418 | "error": error.localizedDescription, |
| ... | ... | @@ -561,6 +572,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 561 | 572 | contentView.verticalOffset = scrollView.contentOffset.y |
| 562 | 573 | contentView.invalidateVisibleViewport() |
| 563 | 574 | contentView.setNeedsDisplay() |
| 575 | applyInitialRowIndexIfNeeded() | |
| 564 | 576 | |
| 565 | 577 | let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" |
| 566 | 578 | if debugKey != lastMetricsDebugKey { |
| ... | ... | @@ -645,6 +657,19 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 645 | 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 | 673 | private func applyStyle() { |
| 649 | 674 | contentView.style = ReviewDiffNativeStyle |
| 650 | 675 | .resolve(stylePayload) |
| ... | ... | @@ -662,6 +687,22 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 662 | 687 | contentView.verticalOffset = scrollView.contentOffset.y |
| 663 | 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 | 708 | private enum ReviewDiffHorizontalPanKind { |
| ... | ... | @@ -820,6 +861,19 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 820 | 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 | 877 | private func rebuildRowLayout() { |
| 824 | 878 | var nextOffsets: [CGFloat] = [] |
| 825 | 879 | var nextFileHeaderRowIndices: [Int] = [] |
apps/mobile/package.json+1| ... | ... | @@ -101,6 +101,7 @@ |
| 101 | 101 | "react-native-screens": "4.25.2", |
| 102 | 102 | "react-native-shiki-engine": "^0.3.12", |
| 103 | 103 | "react-native-svg": "15.15.4", |
| 104 | "react-native-webview": "^13.16.1", | |
| 104 | 105 | "react-native-worklets": "0.8.3", |
| 105 | 106 | "shiki": "4.2.0", |
| 106 | 107 | "tailwind-merge": "^3.5.0", |
apps/mobile/src/app/_layout.tsx+3-2| ... | ... | @@ -30,10 +30,11 @@ import { useThemeColor } from "../lib/useThemeColor"; |
| 30 | 30 | |
| 31 | 31 | function AppNavigator() { |
| 32 | 32 | const pathname = usePathname(); |
| 33 | const clerkRouteIsActive = pathname === "/settings/auth"; | |
| 33 | const expandedSettingsRouteIsActive = | |
| 34 | pathname === "/settings/archive" || pathname === "/settings/auth"; | |
| 34 | 35 | |
| 35 | 36 | return ( |
| 36 | <ClerkSettingsSheetDetentProvider initiallyExpanded={clerkRouteIsActive}> | |
| 37 | <ClerkSettingsSheetDetentProvider initiallyExpanded={expandedSettingsRouteIsActive}> | |
| 37 | 38 | <AppNavigatorContent /> |
| 38 | 39 | </ClerkSettingsSheetDetentProvider> |
| 39 | 40 | ); |
apps/mobile/src/app/index.tsx+88-89| ... | ... | @@ -1,13 +1,33 @@ |
| 1 | import { Stack, useRouter } from "expo-router"; | |
| 2 | import { useState } from "react"; | |
| 3 | import { Text as RNText, View } from "react-native"; | |
| 1 | import type { | |
| 2 | EnvironmentId, | |
| 3 | SidebarProjectGroupingMode, | |
| 4 | SidebarThreadSortOrder, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { | |
| 7 | DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | |
| 8 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | |
| 9 | DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as Arr from "effect/Array"; | |
| 12 | import * as Order from "effect/Order"; | |
| 13 | import { useRouter } from "expo-router"; | |
| 14 | import { useCallback, useMemo, useState } from "react"; | |
| 4 | 15 | |
| 5 | 16 | import { useProjects, useThreadShells } from "../state/entities"; |
| 6 | 17 | import { useWorkspaceState } from "../state/workspace"; |
| 7 | 18 | import { buildThreadRoutePath } from "../lib/routes"; |
| 8 | 19 | import { useSavedRemoteConnections } from "../state/use-remote-environment-registry"; |
| 9 | 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 | 32 | /* ─── Route screen ───────────────────────────────────────────────────── */ |
| 13 | 33 | |
| ... | ... | @@ -18,103 +38,82 @@ export default function HomeRouteScreen() { |
| 18 | 38 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 19 | 39 | const router = useRouter(); |
| 20 | 40 | const [searchQuery, setSearchQuery] = useState(""); |
| 21 | ||
| 22 | const iconColor = useThemeColor("--color-icon"); | |
| 23 | const mutedColor = useThemeColor("--color-foreground-muted"); | |
| 24 | const subtleColor = useThemeColor("--color-subtle"); | |
| 41 | const [listOptions, setListOptions] = useState<HomeListOptions>({ | |
| 42 | selectedEnvironmentId: null, | |
| 43 | projectSortOrder: | |
| 44 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" | |
| 45 | ? "updated_at" | |
| 46 | : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, | |
| 47 | threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, | |
| 48 | projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, | |
| 49 | }); | |
| 50 | const { archiveThread, confirmDeleteThread } = useThreadListActions(); | |
| 51 | const environments = useMemo( | |
| 52 | () => | |
| 53 | Arr.sort( | |
| 54 | Object.values(savedConnectionsById).map((connection) => ({ | |
| 55 | environmentId: connection.environmentId, | |
| 56 | label: connection.environmentLabel, | |
| 57 | })), | |
| 58 | Order.mapInput( | |
| 59 | Order.String, | |
| 60 | (environment: { readonly label: string }) => environment.label, | |
| 61 | ), | |
| 62 | ), | |
| 63 | [savedConnectionsById], | |
| 64 | ); | |
| 65 | const selectedEnvironmentId = environments.some( | |
| 66 | (environment) => environment.environmentId === listOptions.selectedEnvironmentId, | |
| 67 | ) | |
| 68 | ? listOptions.selectedEnvironmentId | |
| 69 | : null; | |
| 70 | const setSelectedEnvironmentId = useCallback((environmentId: EnvironmentId | null) => { | |
| 71 | setListOptions((current) => ({ ...current, selectedEnvironmentId: environmentId })); | |
| 72 | }, []); | |
| 73 | const setProjectSortOrder = useCallback((projectSortOrder: HomeProjectSortOrder) => { | |
| 74 | setListOptions((current) => ({ ...current, projectSortOrder })); | |
| 75 | }, []); | |
| 76 | const setThreadSortOrder = useCallback((threadSortOrder: SidebarThreadSortOrder) => { | |
| 77 | setListOptions((current) => ({ ...current, threadSortOrder })); | |
| 78 | }, []); | |
| 79 | const setProjectGroupingMode = useCallback((projectGroupingMode: SidebarProjectGroupingMode) => { | |
| 80 | setListOptions((current) => ({ ...current, projectGroupingMode })); | |
| 81 | }, []); | |
| 25 | 82 | |
| 26 | 83 | return ( |
| 27 | 84 | <> |
| 28 | <Stack.Screen | |
| 29 | options={{ | |
| 30 | headerShown: true, | |
| 31 | headerTransparent: true, | |
| 32 | headerStyle: { backgroundColor: "transparent" }, | |
| 33 | headerShadowVisible: false, | |
| 34 | headerTintColor: iconColor, | |
| 35 | headerTitle: "", | |
| 36 | headerSearchBarOptions: { | |
| 37 | placeholder: "Search threads", | |
| 38 | hideNavigationBar: false, | |
| 39 | onChangeText: (event) => { | |
| 40 | setSearchQuery(event.nativeEvent.text); | |
| 41 | }, | |
| 42 | onCancelButtonPress: () => { | |
| 43 | setSearchQuery(""); | |
| 44 | }, | |
| 45 | allowToolbarIntegration: true, | |
| 46 | }, | |
| 47 | }} | |
| 85 | <HomeHeader | |
| 86 | environments={environments} | |
| 87 | selectedEnvironmentId={selectedEnvironmentId} | |
| 88 | projectSortOrder={listOptions.projectSortOrder} | |
| 89 | threadSortOrder={listOptions.threadSortOrder} | |
| 90 | projectGroupingMode={listOptions.projectGroupingMode} | |
| 91 | onEnvironmentChange={setSelectedEnvironmentId} | |
| 92 | onOpenSettings={() => router.push("/settings")} | |
| 93 | onProjectGroupingModeChange={setProjectGroupingMode} | |
| 94 | onProjectSortOrderChange={setProjectSortOrder} | |
| 95 | onSearchQueryChange={setSearchQuery} | |
| 96 | onStartNewTask={() => router.push("/new")} | |
| 97 | onThreadSortOrderChange={setThreadSortOrder} | |
| 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 | 100 | <HomeScreen |
| 108 | projects={projects} | |
| 109 | threads={threads} | |
| 110 | 101 | catalogState={catalogState} |
| 111 | savedConnectionsById={savedConnectionsById} | |
| 112 | searchQuery={searchQuery} | |
| 113 | 102 | onAddConnection={() => router.push("/connections/new")} |
| 103 | onArchiveThread={archiveThread} | |
| 104 | onDeleteThread={confirmDeleteThread} | |
| 114 | 105 | onOpenEnvironments={() => router.push("/settings/environments")} |
| 115 | 106 | onSelectThread={(thread) => { |
| 116 | 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 | 14 | const contentStyle = useResolveClassNames("bg-sheet"); |
| 15 | 15 | const sheetBg = useThemeColor("--color-sheet"); |
| 16 | 16 | const headerTint = useThemeColor("--color-foreground"); |
| 17 | const handleClerkRouteTransitionEnd = useCallback( | |
| 17 | const handleExpandedRouteTransitionEnd = useCallback( | |
| 18 | 18 | (event: { data: { closing: boolean } }) => { |
| 19 | 19 | if (event.data.closing) { |
| 20 | 20 | collapse(); |
| ... | ... | @@ -47,9 +47,14 @@ export default function SettingsLayout() { |
| 47 | 47 | name="waitlist" |
| 48 | 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 | 55 | <Stack.Screen |
| 51 | 56 | name="auth" |
| 52 | listeners={{ transitionEnd: handleClerkRouteTransitionEnd }} | |
| 57 | listeners={{ transitionEnd: handleExpandedRouteTransitionEnd }} | |
| 53 | 58 | options={{ animation: "slide_from_right", title: "Sign in" }} |
| 54 | 59 | /> |
| 55 | 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 | 65 | /> |
| 66 | 66 | </SettingsSection> |
| 67 | 67 | |
| 68 | <ArchivedThreadsSettingsSection /> | |
| 69 | ||
| 68 | 70 | <AppSettingsSection /> |
| 69 | 71 | </ScrollView> |
| 70 | 72 | </View> |
| ... | ... | @@ -374,6 +376,8 @@ function ConfiguredSettingsRouteScreen() { |
| 374 | 376 | /> |
| 375 | 377 | </SettingsSection> |
| 376 | 378 | |
| 379 | <ArchivedThreadsSettingsSection /> | |
| 380 | ||
| 377 | 381 | <AppSettingsSection /> |
| 378 | 382 | </ScrollView> |
| 379 | 383 | </View> |
| ... | ... | @@ -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 | 431 | function SettingsRow(props: { |
| 420 | 432 | readonly disabled?: boolean; |
| 421 | 433 | readonly icon: SymbolName; |
| 422 | 434 | readonly label: string; |
| 423 | 435 | readonly value?: string; |
| 424 | readonly href?: "/settings/environments"; | |
| 436 | readonly href?: "/settings/archive" | "/settings/environments"; | |
| 425 | 437 | readonly onPress?: () => void; |
| 426 | 438 | }) { |
| 427 | 439 | const icon = useThemeColor("--color-icon"); |
| ... | ... | @@ -459,7 +471,9 @@ function SettingsRow(props: { |
| 459 | 471 | if (props.href) { |
| 460 | 472 | return ( |
| 461 | 473 | <Link href={props.href} asChild> |
| 462 | <Pressable>{content}</Pressable> | |
| 474 | <Pressable accessibilityLabel={props.label} accessibilityRole="button"> | |
| 475 | {content} | |
| 476 | </Pressable> | |
| 463 | 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 | 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 | 84 | <Stack.Screen |
| 59 | 85 | name="review-comment" |
| 60 | 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 | 110 | readonly styleJson?: string; |
| 111 | 111 | readonly rowHeight: number; |
| 112 | 112 | readonly contentWidth: number; |
| 113 | readonly initialRowIndex?: number; | |
| 113 | 114 | readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void; |
| 114 | 115 | readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; |
| 115 | 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 | 2 | type EnvironmentProject, |
| 3 | 3 | type EnvironmentThreadShell, |
| 4 | 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 | 11 | import { SymbolView } from "expo-symbols"; |
| 6 | import { useCallback, useMemo, useState } from "react"; | |
| 7 | import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; | |
| 12 | import { useCallback, useMemo, useRef, useState } from "react"; | |
| 13 | import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; | |
| 14 | import ReanimatedSwipeable, { | |
| 15 | type SwipeableMethods, | |
| 16 | } from "react-native-gesture-handler/ReanimatedSwipeable"; | |
| 17 | import Animated, { | |
| 18 | Easing, | |
| 19 | LinearTransition, | |
| 20 | type ExitAnimationsValues, | |
| 21 | withDelay, | |
| 22 | withTiming, | |
| 23 | } from "react-native-reanimated"; | |
| 8 | 24 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 9 | import * as Arr from "effect/Array"; | |
| 10 | import * as Order from "effect/Order"; | |
| 11 | 25 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 12 | 26 | |
| 13 | 27 | import { AppText as Text } from "../../components/AppText"; |
| ... | ... | @@ -15,9 +29,14 @@ import { EmptyState } from "../../components/EmptyState"; |
| 15 | 29 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 16 | 30 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 17 | 31 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 18 | import { scopedProjectKey } from "../../lib/scopedEntities"; | |
| 19 | 32 | import { relativeTime } from "../../lib/time"; |
| 20 | 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 | 41 | /* ─── Types ──────────────────────────────────────────────────────────── */ |
| 23 | 42 | |
| ... | ... | @@ -27,26 +46,17 @@ interface HomeScreenProps { |
| 27 | 46 | readonly catalogState: WorkspaceState; |
| 28 | 47 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; |
| 29 | 48 | readonly searchQuery: string; |
| 49 | readonly selectedEnvironmentId: EnvironmentId | null; | |
| 50 | readonly projectSortOrder: HomeProjectSortOrder; | |
| 51 | readonly threadSortOrder: SidebarThreadSortOrder; | |
| 52 | readonly projectGroupingMode: SidebarProjectGroupingMode; | |
| 30 | 53 | readonly onAddConnection: () => void; |
| 31 | 54 | readonly onOpenEnvironments: () => void; |
| 32 | 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 | 60 | /* ─── Status indicator colors ────────────────────────────────────────── */ |
| 51 | 61 | |
| 52 | 62 | 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 | 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 | 106 | function deriveEmptyState(props: { |
| 70 | 107 | readonly catalogState: WorkspaceState; |
| ... | ... | @@ -133,6 +170,7 @@ function deriveEmptyState(props: { |
| 133 | 170 | |
| 134 | 171 | function ProjectGroupLabel(props: { |
| 135 | 172 | readonly project: EnvironmentProject; |
| 173 | readonly title: string; | |
| 136 | 174 | readonly totalThreadCount: number; |
| 137 | 175 | readonly isExpanded: boolean; |
| 138 | 176 | readonly onToggleExpand: () => void; |
| ... | ... | @@ -152,7 +190,7 @@ function ProjectGroupLabel(props: { |
| 152 | 190 | style={{ letterSpacing: 0.5 }} |
| 153 | 191 | numberOfLines={1} |
| 154 | 192 | > |
| 155 | {props.project.title} | |
| 193 | {props.title} | |
| 156 | 194 | </Text> |
| 157 | 195 | |
| 158 | 196 | {hiddenCount > 0 ? ( |
| ... | ... | @@ -175,95 +213,172 @@ function ThreadRow(props: { |
| 175 | 213 | readonly thread: EnvironmentThreadShell; |
| 176 | 214 | readonly environmentLabel: string | null; |
| 177 | 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 | 220 | readonly isLast: boolean; |
| 179 | 221 | }) { |
| 222 | const swipeableRef = useRef<SwipeableMethods | null>(null); | |
| 223 | const fullSwipeArmedRef = useRef(false); | |
| 224 | const { width: windowWidth } = useWindowDimensions(); | |
| 180 | 225 | const separatorColor = useThemeColor("--color-separator"); |
| 181 | 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 | 229 | const { bg, fg } = statusColors(props.thread); |
| 183 | 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 | 234 | const branch = props.thread.branch; |
| 186 | 235 | const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => |
| 187 | 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 | 245 | return ( |
| 191 | <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> | |
| 192 | <View | |
| 193 | style={{ | |
| 194 | flexDirection: "row", | |
| 195 | paddingLeft: 16, | |
| 196 | paddingRight: 16, | |
| 197 | paddingVertical: 10, | |
| 198 | gap: 12, | |
| 199 | borderBottomWidth: props.isLast ? 0 : 1, | |
| 200 | borderBottomColor: separatorColor, | |
| 246 | <ReanimatedSwipeable | |
| 247 | ref={swipeableRef} | |
| 248 | animationOptions={THREAD_SWIPE_SPRING} | |
| 249 | childrenContainerStyle={{ backgroundColor: cardColor }} | |
| 250 | containerStyle={{ backgroundColor: cardColor }} | |
| 251 | dragOffsetFromRightEdge={8} | |
| 252 | enableTrackpadTwoFingerGesture | |
| 253 | friction={1} | |
| 254 | onSwipeableClose={() => { | |
| 255 | fullSwipeArmedRef.current = false; | |
| 256 | if (swipeableRef.current) { | |
| 257 | props.onSwipeableClose(swipeableRef.current); | |
| 258 | } | |
| 259 | }} | |
| 260 | onSwipeableOpenStartDrag={() => { | |
| 261 | if (swipeableRef.current) { | |
| 262 | props.onSwipeableWillOpen(swipeableRef.current); | |
| 263 | } | |
| 264 | }} | |
| 265 | onSwipeableWillOpen={() => { | |
| 266 | const methods = swipeableRef.current; | |
| 267 | if (!methods) { | |
| 268 | return; | |
| 269 | } | |
| 270 | ||
| 271 | props.onSwipeableWillOpen(methods); | |
| 272 | if (fullSwipeArmedRef.current) { | |
| 273 | fullSwipeArmedRef.current = false; | |
| 274 | methods.close(); | |
| 275 | props.onDelete(); | |
| 276 | } | |
| 277 | }} | |
| 278 | overshootFriction={1} | |
| 279 | overshootRight | |
| 280 | renderRightActions={(_progress, translation, methods) => ( | |
| 281 | <ThreadSwipeActions | |
| 282 | backgroundColor={cardColor} | |
| 283 | fullSwipeThreshold={fullSwipeThreshold} | |
| 284 | onDelete={props.onDelete} | |
| 285 | onFullSwipeArmedChange={handleFullSwipeArmedChange} | |
| 286 | primaryAction={{ | |
| 287 | accessibilityLabel: `Archive ${props.thread.title}`, | |
| 288 | icon: "archivebox", | |
| 289 | label: "Archive", | |
| 290 | onPress: props.onArchive, | |
| 291 | }} | |
| 292 | swipeableMethods={methods} | |
| 293 | threadTitle={props.thread.title} | |
| 294 | translation={translation} | |
| 295 | /> | |
| 296 | )} | |
| 297 | rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} | |
| 298 | > | |
| 299 | <Pressable | |
| 300 | accessibilityHint="Swipe left for archive and delete actions" | |
| 301 | accessibilityLabel={props.thread.title} | |
| 302 | accessibilityRole="button" | |
| 303 | className="bg-card" | |
| 304 | onPress={() => { | |
| 305 | swipeableRef.current?.close(); | |
| 306 | props.onPress(); | |
| 201 | 307 | }} |
| 308 | style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} | |
| 202 | 309 | > |
| 203 | {/* Git status indicator */} | |
| 204 | 310 | <View |
| 205 | 311 | style={{ |
| 206 | width: 30, | |
| 207 | height: 30, | |
| 208 | borderRadius: 9, | |
| 209 | backgroundColor: bg, | |
| 210 | alignItems: "center", | |
| 211 | justifyContent: "center", | |
| 212 | marginTop: 2, | |
| 312 | flexDirection: "row", | |
| 313 | paddingLeft: 16, | |
| 314 | paddingRight: 16, | |
| 315 | paddingVertical: 10, | |
| 316 | gap: 12, | |
| 317 | borderBottomWidth: props.isLast ? 0 : 1, | |
| 318 | borderBottomColor: separatorColor, | |
| 213 | 319 | }} |
| 214 | 320 | > |
| 215 | <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" /> | |
| 216 | </View> | |
| 217 | ||
| 218 | {/* Content */} | |
| 219 | <View style={{ flex: 1, gap: 3 }}> | |
| 220 | {/* Title + Status + Timestamp */} | |
| 221 | <View className="flex-row items-center justify-between gap-2"> | |
| 222 | <Text | |
| 223 | className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground" | |
| 224 | numberOfLines={1} | |
| 225 | > | |
| 226 | {props.thread.title} | |
| 227 | </Text> | |
| 228 | <View className="flex-row items-center gap-2"> | |
| 229 | <View | |
| 230 | className={tone.pillClassName} | |
| 231 | style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }} | |
| 232 | > | |
| 233 | <Text className={`text-[10px] font-t3-bold ${tone.textClassName}`}> | |
| 234 | {tone.label} | |
| 235 | </Text> | |
| 236 | </View> | |
| 237 | <Text | |
| 238 | className="text-[12px] text-foreground-tertiary" | |
| 239 | style={{ fontVariant: ["tabular-nums"] }} | |
| 240 | > | |
| 241 | {timestamp} | |
| 242 | </Text> | |
| 243 | </View> | |
| 321 | <View | |
| 322 | style={{ | |
| 323 | width: 30, | |
| 324 | height: 30, | |
| 325 | borderRadius: 9, | |
| 326 | backgroundColor: bg, | |
| 327 | alignItems: "center", | |
| 328 | justifyContent: "center", | |
| 329 | marginTop: 2, | |
| 330 | }} | |
| 331 | > | |
| 332 | <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" /> | |
| 244 | 333 | </View> |
| 245 | 334 | |
| 246 | {/* Environment + branch */} | |
| 247 | {subtitleParts.length > 0 ? ( | |
| 248 | <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> | |
| 249 | <SymbolView | |
| 250 | name="arrow.triangle.branch" | |
| 251 | size={10} | |
| 252 | tintColor={iconSubtleColor} | |
| 253 | type="monochrome" | |
| 254 | /> | |
| 335 | <View style={{ flex: 1, gap: 3 }}> | |
| 336 | <View className="flex-row items-center justify-between gap-2"> | |
| 255 | 337 | <Text |
| 256 | className="text-[11px] text-foreground-tertiary" | |
| 338 | className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground" | |
| 257 | 339 | numberOfLines={1} |
| 258 | style={{ fontFamily: "monospace" }} | |
| 259 | 340 | > |
| 260 | {subtitleParts.join(" · ")} | |
| 341 | {props.thread.title} | |
| 261 | 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 | 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 | 379 | </View> |
| 265 | </View> | |
| 266 | </Pressable> | |
| 380 | </Pressable> | |
| 381 | </ReanimatedSwipeable> | |
| 267 | 382 | ); |
| 268 | 383 | } |
| 269 | 384 | |
| ... | ... | @@ -323,6 +438,7 @@ function StaleCatalogStatusPill(props: { |
| 323 | 438 | |
| 324 | 439 | export function HomeScreen(props: HomeScreenProps) { |
| 325 | 440 | const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set()); |
| 441 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | |
| 326 | 442 | const insets = useSafeAreaInsets(); |
| 327 | 443 | const accentColor = useThemeColor("--color-icon-muted"); |
| 328 | 444 | |
| ... | ... | @@ -335,51 +451,50 @@ export function HomeScreen(props: HomeScreenProps) { |
| 335 | 451 | }); |
| 336 | 452 | }, []); |
| 337 | 453 | |
| 338 | /* Build project title lookup for search */ | |
| 339 | const projectTitleByKey = useMemo(() => { | |
| 340 | const map = new Map<string, string>(); | |
| 341 | for (const p of props.projects) { | |
| 342 | map.set(scopedProjectKey(p.environmentId, p.id), p.title); | |
| 343 | } | |
| 344 | return map; | |
| 345 | }, [props.projects]); | |
| 346 | ||
| 347 | /* Filter threads by search query */ | |
| 348 | const filteredThreads = useMemo(() => { | |
| 349 | const q = props.searchQuery.trim().toLowerCase(); | |
| 350 | if (!q) return props.threads; | |
| 351 | return props.threads.filter((t) => { | |
| 352 | if (t.title.toLowerCase().includes(q)) return true; | |
| 353 | const key = scopedProjectKey(t.environmentId, t.projectId); | |
| 354 | return projectTitleByKey.get(key)?.toLowerCase().includes(q) ?? false; | |
| 355 | }); | |
| 356 | }, [props.threads, props.searchQuery, projectTitleByKey]); | |
| 357 | ||
| 358 | /* Group filtered threads by project */ | |
| 359 | const projectGroups = useMemo<ReadonlyArray<ProjectGroup>>(() => { | |
| 360 | const byProject = new Map<string, EnvironmentThreadShell[]>(); | |
| 361 | for (const thread of filteredThreads) { | |
| 362 | const key = scopedProjectKey(thread.environmentId, thread.projectId); | |
| 363 | const existing = byProject.get(key); | |
| 364 | if (existing) existing.push(thread); | |
| 365 | else byProject.set(key, [thread]); | |
| 454 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { | |
| 455 | if (openSwipeableRef.current !== methods) { | |
| 456 | openSwipeableRef.current?.close(); | |
| 457 | openSwipeableRef.current = methods; | |
| 366 | 458 | } |
| 459 | }, []); | |
| 367 | 460 | |
| 368 | const groups: ProjectGroup[] = []; | |
| 369 | for (const project of props.projects) { | |
| 370 | const key = scopedProjectKey(project.environmentId, project.id); | |
| 371 | const threads = byProject.get(key); | |
| 372 | if (threads && threads.length > 0) { | |
| 373 | groups.push({ key, project, threads }); | |
| 374 | } | |
| 461 | const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { | |
| 462 | if (openSwipeableRef.current === methods) { | |
| 463 | openSwipeableRef.current = null; | |
| 375 | 464 | } |
| 465 | }, []); | |
| 376 | 466 | |
| 377 | return Arr.sort(groups, projectGroupActivityOrder); | |
| 378 | }, [props.projects, filteredThreads]); | |
| 467 | const projectGroups = useMemo( | |
| 468 | () => | |
| 469 | buildHomeThreadGroups({ | |
| 470 | projects: props.projects, | |
| 471 | threads: props.threads, | |
| 472 | environmentId: props.selectedEnvironmentId, | |
| 473 | searchQuery: props.searchQuery, | |
| 474 | projectSortOrder: props.projectSortOrder, | |
| 475 | threadSortOrder: props.threadSortOrder, | |
| 476 | projectGroupingMode: props.projectGroupingMode, | |
| 477 | }), | |
| 478 | [ | |
| 479 | props.projectGroupingMode, | |
| 480 | props.projects, | |
| 481 | props.projectSortOrder, | |
| 482 | props.searchQuery, | |
| 483 | props.selectedEnvironmentId, | |
| 484 | props.threadSortOrder, | |
| 485 | props.threads, | |
| 486 | ], | |
| 487 | ); | |
| 379 | 488 | |
| 380 | 489 | /* Empty states */ |
| 381 | const hasAnyThreads = props.threads.length > 0; | |
| 382 | const hasResults = filteredThreads.length > 0; | |
| 490 | const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null); | |
| 491 | const hasResults = projectGroups.length > 0; | |
| 492 | const selectedEnvironmentLabel = | |
| 493 | props.selectedEnvironmentId === null | |
| 494 | ? null | |
| 495 | : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? | |
| 496 | "this environment"); | |
| 497 | const hasSearchQuery = props.searchQuery.trim().length > 0; | |
| 383 | 498 | const shouldShowConnectionStatus = |
| 384 | 499 | props.catalogState.networkStatus === "offline" || |
| 385 | 500 | props.catalogState.hasConnectingEnvironment || |
| ... | ... | @@ -396,6 +511,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 396 | 511 | showsVerticalScrollIndicator={false} |
| 397 | 512 | keyboardDismissMode="on-drag" |
| 398 | 513 | keyboardShouldPersistTaps="handled" |
| 514 | onScrollBeginDrag={() => openSwipeableRef.current?.close()} | |
| 399 | 515 | className="flex-1" |
| 400 | 516 | contentContainerStyle={{ |
| 401 | 517 | paddingHorizontal: 16, |
| ... | ... | @@ -418,8 +534,18 @@ export function HomeScreen(props: HomeScreenProps) { |
| 418 | 534 | </View> |
| 419 | 535 | ) : null} |
| 420 | 536 | </View> |
| 421 | ) : !hasResults ? ( | |
| 537 | ) : !hasResults && hasSearchQuery ? ( | |
| 422 | 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 | 550 | projectGroups.map((group) => { |
| 425 | 551 | const isExpanded = expandedProjects.has(group.key); |
| ... | ... | @@ -428,30 +554,52 @@ export function HomeScreen(props: HomeScreenProps) { |
| 428 | 554 | : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); |
| 429 | 555 | |
| 430 | 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 | 564 | <ProjectGroupLabel |
| 433 | project={group.project} | |
| 434 | totalThreadCount={group.threads.length} | |
| 435 | 565 | isExpanded={isExpanded} |
| 436 | 566 | onToggleExpand={() => toggleExpanded(group.key)} |
| 567 | project={group.representative} | |
| 568 | title={group.title} | |
| 569 | totalThreadCount={group.threads.length} | |
| 437 | 570 | /> |
| 438 | 571 | <View |
| 439 | 572 | className="overflow-hidden rounded-[20px] bg-card" |
| 440 | 573 | style={{ borderCurve: "continuous" }} |
| 441 | 574 | > |
| 442 | {visibleThreads.map((thread, i) => ( | |
| 443 | <ThreadRow | |
| 444 | key={`${thread.environmentId}:${thread.id}`} | |
| 445 | thread={thread} | |
| 446 | environmentLabel={ | |
| 447 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null | |
| 448 | } | |
| 449 | onPress={() => props.onSelectThread(thread)} | |
| 450 | isLast={i === visibleThreads.length - 1} | |
| 451 | /> | |
| 452 | ))} | |
| 575 | {visibleThreads.map((thread, i) => { | |
| 576 | const threadKey = `${thread.environmentId}:${thread.id}`; | |
| 577 | return ( | |
| 578 | <Animated.View | |
| 579 | key={threadKey} | |
| 580 | collapsable={false} | |
| 581 | exiting={threadRowExit} | |
| 582 | layout={THREAD_LAYOUT_TRANSITION} | |
| 583 | style={{ overflow: "hidden" }} | |
| 584 | > | |
| 585 | <ThreadRow | |
| 586 | thread={thread} | |
| 587 | environmentLabel={ | |
| 588 | props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? | |
| 589 | null | |
| 590 | } | |
| 591 | isLast={i === visibleThreads.length - 1} | |
| 592 | onArchive={() => props.onArchiveThread(thread)} | |
| 593 | onDelete={() => props.onDeleteThread(thread)} | |
| 594 | onPress={() => props.onSelectThread(thread)} | |
| 595 | onSwipeableClose={handleSwipeableClose} | |
| 596 | onSwipeableWillOpen={handleSwipeableWillOpen} | |
| 597 | /> | |
| 598 | </Animated.View> | |
| 599 | ); | |
| 600 | })} | |
| 453 | 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 | 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 | 707 | async function highlightPatchLinesInChunks(input: { |
| 699 | 708 | readonly lines: ReadonlyArray<string>; |
| 700 | 709 | readonly language: string; |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2| ... | ... | @@ -62,6 +62,7 @@ export interface ThreadDetailScreenProps { |
| 62 | 62 | readonly activeThreadBusy: boolean; |
| 63 | 63 | readonly environmentId: EnvironmentId; |
| 64 | 64 | readonly projectWorkspaceRoot: string | null; |
| 65 | readonly threadCwd: string | null; | |
| 65 | 66 | readonly selectedThreadQueueCount: number; |
| 66 | 67 | readonly serverConfig: T3ServerConfig | null; |
| 67 | 68 | readonly layoutVariant?: LayoutVariant; |
| ... | ... | @@ -309,6 +310,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 309 | 310 | key={props.selectedThread.id} |
| 310 | 311 | environmentId={props.environmentId} |
| 311 | 312 | threadId={props.selectedThread.id} |
| 313 | workspaceRoot={props.threadCwd} | |
| 312 | 314 | feed={props.selectedThreadFeed} |
| 313 | 315 | contentPresentation={props.contentPresentation} |
| 314 | 316 | agentLabel={agentLabel} |
apps/mobile/src/features/threads/ThreadFeed.tsx+108-193| ... | ... | @@ -4,6 +4,7 @@ import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; |
| 4 | 4 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 5 | 5 | import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 6 | 6 | import { SymbolView } from "expo-symbols"; |
| 7 | import { useRouter } from "expo-router"; | |
| 7 | 8 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 8 | 9 | import { |
| 9 | 10 | Markdown, |
| ... | ... | @@ -54,6 +55,7 @@ import { |
| 54 | 55 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 55 | 56 | import { cn } from "../../lib/cn"; |
| 56 | 57 | import type { LayoutVariant } from "../../lib/layout"; |
| 58 | import { buildThreadFilesNavigation } from "../../lib/routes"; | |
| 57 | 59 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 58 | 60 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 59 | 61 | import { |
| ... | ... | @@ -64,7 +66,9 @@ import { |
| 64 | 66 | import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout"; |
| 65 | 67 | import { relativeTime } from "../../lib/time"; |
| 66 | 68 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 69 | import { ThreadWorkLog } from "./thread-work-log"; | |
| 67 | 70 | import { useAssetUrl } from "../../state/assets"; |
| 71 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | |
| 68 | 72 | |
| 69 | 73 | const THREAD_FEED_END_THRESHOLD = 80; |
| 70 | 74 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { |
| ... | ... | @@ -83,6 +87,7 @@ function formatMessageTime(input: string): string { |
| 83 | 87 | export interface ThreadFeedProps { |
| 84 | 88 | readonly environmentId: EnvironmentId; |
| 85 | 89 | readonly threadId: ThreadId; |
| 90 | readonly workspaceRoot?: string | null; | |
| 86 | 91 | readonly feed: ReadonlyArray<ThreadFeedEntry>; |
| 87 | 92 | readonly contentPresentation: ThreadContentPresentation; |
| 88 | 93 | readonly agentLabel: string; |
| ... | ... | @@ -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 | 128 | const MARKDOWN_COLORS = { |
| 150 | 129 | light: { |
| 151 | 130 | body: "#111111", |
| ... | ... | @@ -155,10 +134,12 @@ const MARKDOWN_COLORS = { |
| 155 | 134 | blockquoteBackground: "rgba(0, 0, 0, 0.02)", |
| 156 | 135 | codeBackground: "rgba(0, 0, 0, 0.04)", |
| 157 | 136 | codeText: "#262626", |
| 137 | inlineCodeText: "#5f6368", | |
| 158 | 138 | horizontalRule: "rgba(0, 0, 0, 0.08)", |
| 159 | 139 | userBody: "#ffffff", |
| 160 | 140 | userCodeBackground: "rgba(255, 255, 255, 0.22)", |
| 161 | 141 | userCodeText: "#ffffff", |
| 142 | userInlineCodeText: "rgba(255, 255, 255, 0.82)", | |
| 162 | 143 | userFenceBackground: "rgba(0, 0, 0, 0.16)", |
| 163 | 144 | userFenceText: "#ffffff", |
| 164 | 145 | }, |
| ... | ... | @@ -170,10 +151,12 @@ const MARKDOWN_COLORS = { |
| 170 | 151 | blockquoteBackground: "rgba(255, 255, 255, 0.03)", |
| 171 | 152 | codeBackground: "rgba(255, 255, 255, 0.06)", |
| 172 | 153 | codeText: "#e5e5e5", |
| 154 | inlineCodeText: "#b8bcc2", | |
| 173 | 155 | horizontalRule: "rgba(255, 255, 255, 0.08)", |
| 174 | 156 | userBody: "#ffffff", |
| 175 | 157 | userCodeBackground: "rgba(255, 255, 255, 0.18)", |
| 176 | 158 | userCodeText: "#ffffff", |
| 159 | userInlineCodeText: "rgba(255, 255, 255, 0.82)", | |
| 177 | 160 | userFenceBackground: "rgba(0, 0, 0, 0.28)", |
| 178 | 161 | userFenceText: "#ffffff", |
| 179 | 162 | }, |
| ... | ... | @@ -202,27 +185,18 @@ interface ReviewCommentColors { |
| 202 | 185 | |
| 203 | 186 | const failedMarkdownFaviconHosts = new Set<string>(); |
| 204 | 187 | const markdownLinkStyles = StyleSheet.create({ |
| 205 | favicon: { | |
| 188 | inlineIcon: { | |
| 206 | 189 | width: 14, |
| 207 | 190 | height: 14, |
| 208 | borderRadius: 3, | |
| 209 | 191 | marginHorizontal: 3, |
| 210 | 192 | transform: [{ translateY: 2 }], |
| 211 | 193 | }, |
| 212 | file: { | |
| 213 | borderRadius: 5, | |
| 214 | borderWidth: StyleSheet.hairlineWidth, | |
| 215 | fontFamily: "DMSans_500Medium", | |
| 216 | fontSize: 13, | |
| 217 | lineHeight: 20, | |
| 218 | paddingHorizontal: 6, | |
| 219 | paddingVertical: 2, | |
| 194 | favicon: { | |
| 195 | borderRadius: 3, | |
| 220 | 196 | }, |
| 221 | fileIcon: { | |
| 222 | width: 15, | |
| 223 | height: 15, | |
| 224 | marginRight: 4, | |
| 225 | transform: [{ translateY: 2 }], | |
| 197 | file: { | |
| 198 | fontFamily: "DMSans_700Bold", | |
| 199 | fontWeight: "700", | |
| 226 | 200 | }, |
| 227 | 201 | }); |
| 228 | 202 | |
| ... | ... | @@ -250,7 +224,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { |
| 250 | 224 | source={{ |
| 251 | 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 | 228 | onError={() => { |
| 255 | 229 | failedMarkdownFaviconHosts.add(props.host); |
| 256 | 230 | setFailed(true); |
| ... | ... | @@ -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 | 265 | const colorScheme = useColorScheme(); |
| 292 | 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 | 267 | const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); |
| 296 | 268 | |
| 297 | 269 | return useMemo(() => { |
| ... | ... | @@ -302,10 +274,12 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 302 | 274 | const markdownBlockquoteBorder = colors.blockquoteBorder; |
| 303 | 275 | const markdownCodeBg = colors.codeBackground; |
| 304 | 276 | const markdownCodeText = colors.codeText; |
| 277 | const markdownInlineCodeText = colors.inlineCodeText; | |
| 305 | 278 | const markdownHrColor = colors.horizontalRule; |
| 306 | 279 | const markdownUserBodyColor = colors.userBody; |
| 307 | 280 | const markdownUserCodeBg = colors.userCodeBackground; |
| 308 | 281 | const markdownUserCodeText = colors.userCodeText; |
| 282 | const markdownUserInlineCodeText = colors.userInlineCodeText; | |
| 309 | 283 | const markdownUserFenceBg = colors.userFenceBackground; |
| 310 | 284 | const markdownUserFenceText = colors.userFenceText; |
| 311 | 285 | |
| ... | ... | @@ -394,28 +368,23 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 394 | 368 | }; |
| 395 | 369 | |
| 396 | 370 | const createMarkdownRenderers = ( |
| 397 | inlineBackgroundColor: string, | |
| 398 | 371 | inlineTextColor: string, |
| 372 | inlineCodeTextColor: string, | |
| 399 | 373 | blockBackgroundColor: string, |
| 400 | 374 | blockTextColor: string, |
| 375 | preserveSoftBreaks: boolean, | |
| 401 | 376 | ): CustomRenderers => ({ |
| 402 | 377 | link: ({ children, href = "" }) => { |
| 403 | 378 | const presentation = resolveMarkdownLinkPresentation(href); |
| 404 | 379 | if (presentation.kind === "file") { |
| 405 | 380 | return ( |
| 406 | 381 | <NativeText |
| 407 | style={[ | |
| 408 | markdownLinkStyles.file, | |
| 409 | { | |
| 410 | backgroundColor: inlineBackgroundColor, | |
| 411 | borderColor: markdownHrColor, | |
| 412 | color: inlineTextColor, | |
| 413 | }, | |
| 414 | ]} | |
| 382 | onPress={() => onLinkPress(href)} | |
| 383 | style={[markdownLinkStyles.file, { color: inlineTextColor }]} | |
| 415 | 384 | > |
| 416 | 385 | <Image |
| 417 | 386 | source={markdownFileIconSource(presentation.icon)} |
| 418 | style={markdownLinkStyles.fileIcon} | |
| 387 | style={markdownLinkStyles.inlineIcon} | |
| 419 | 388 | /> |
| 420 | 389 | {presentation.label} |
| 421 | 390 | </NativeText> |
| ... | ... | @@ -492,28 +461,24 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 492 | 461 | ), |
| 493 | 462 | code_inline: ({ content }) => { |
| 494 | 463 | const value = content ?? ""; |
| 495 | const wrapsPoorly = | |
| 496 | value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":"); | |
| 497 | 464 | return ( |
| 498 | 465 | <NativeText |
| 499 | 466 | style={{ |
| 500 | color: inlineTextColor, | |
| 467 | color: inlineCodeTextColor, | |
| 501 | 468 | fontFamily: "ui-monospace", |
| 502 | 469 | fontSize: 12, |
| 503 | lineHeight: 18, | |
| 504 | ...(wrapsPoorly | |
| 505 | ? { opacity: 0.82 } | |
| 506 | : { | |
| 507 | backgroundColor: inlineBackgroundColor, | |
| 508 | borderRadius: 4, | |
| 509 | paddingHorizontal: 3, | |
| 510 | }), | |
| 470 | lineHeight: 22, | |
| 511 | 471 | }} |
| 512 | 472 | > |
| 513 | 473 | {value} |
| 514 | 474 | </NativeText> |
| 515 | 475 | ); |
| 516 | 476 | }, |
| 477 | ...(preserveSoftBreaks | |
| 478 | ? { | |
| 479 | soft_break: () => <NativeText>{"\n"}</NativeText>, | |
| 480 | } | |
| 481 | : {}), | |
| 517 | 482 | code_block: ({ content, language }) => ( |
| 518 | 483 | <View |
| 519 | 484 | style={{ |
| ... | ... | @@ -619,23 +584,23 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 619 | 584 | theme: userTheme, |
| 620 | 585 | styles: userStyles, |
| 621 | 586 | renderers: createMarkdownRenderers( |
| 622 | markdownUserCodeBg, | |
| 623 | 587 | markdownUserCodeText, |
| 588 | markdownUserInlineCodeText, | |
| 624 | 589 | markdownUserFenceBg, |
| 625 | 590 | markdownUserFenceText, |
| 591 | true, | |
| 626 | 592 | ), |
| 627 | 593 | nativeTextStyle: { |
| 628 | 594 | color: markdownUserBodyColor, |
| 629 | 595 | strongColor: markdownUserBodyColor, |
| 630 | 596 | mutedColor: markdownUserBodyColor, |
| 631 | 597 | linkColor: markdownUserBodyColor, |
| 598 | inlineCodeColor: markdownUserInlineCodeText, | |
| 632 | 599 | codeColor: markdownUserCodeText, |
| 633 | 600 | codeBackgroundColor: markdownUserCodeBg, |
| 634 | 601 | codeBlockBackgroundColor: markdownUserFenceBg, |
| 635 | fileBackgroundColor: "rgba(255, 255, 255, 0.12)", | |
| 636 | 602 | fileTextColor: "#ffffff", |
| 637 | skillBackgroundColor: "rgba(217, 70, 239, 0.24)", | |
| 638 | skillTextColor: "#ffffff", | |
| 603 | skillTextColor: "#f0abfc", | |
| 639 | 604 | quoteMarkerColor: markdownUserBodyColor, |
| 640 | 605 | dividerColor: markdownUserBodyColor, |
| 641 | 606 | fontSize: 15, |
| ... | ... | @@ -649,22 +614,22 @@ function useMarkdownStyles(): MarkdownStyleSets { |
| 649 | 614 | theme: assistantTheme, |
| 650 | 615 | styles: assistantStyles, |
| 651 | 616 | renderers: createMarkdownRenderers( |
| 652 | markdownCodeBg, | |
| 653 | 617 | markdownCodeText, |
| 618 | markdownInlineCodeText, | |
| 654 | 619 | markdownCodeBg, |
| 655 | 620 | markdownCodeText, |
| 621 | false, | |
| 656 | 622 | ), |
| 657 | 623 | nativeTextStyle: { |
| 658 | 624 | color: markdownBodyColor, |
| 659 | 625 | strongColor: markdownStrongColor, |
| 660 | 626 | mutedColor: markdownBodyColor, |
| 661 | 627 | linkColor: markdownLinkColor, |
| 628 | inlineCodeColor: markdownInlineCodeText, | |
| 662 | 629 | codeColor: markdownCodeText, |
| 663 | 630 | codeBackgroundColor: markdownCodeBg, |
| 664 | 631 | codeBlockBackgroundColor: markdownCodeBg, |
| 665 | fileBackgroundColor: inlineChipBackground, | |
| 666 | 632 | fileTextColor: markdownCodeText, |
| 667 | skillBackgroundColor: inlineSkillBackground, | |
| 668 | 633 | skillTextColor: inlineSkillForeground, |
| 669 | 634 | quoteMarkerColor: markdownBlockquoteBorder, |
| 670 | 635 | dividerColor: markdownHrColor, |
| ... | ... | @@ -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 | 647 | function renderFeedEntry( |
| ... | ... | @@ -692,11 +657,13 @@ function renderFeedEntry( |
| 692 | 657 | readonly onToggleWorkRow: (rowId: string) => void; |
| 693 | 658 | readonly onToggleTurnFold: (turnId: TurnId) => void; |
| 694 | 659 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 660 | readonly onMarkdownLinkPress: (href: string) => void; | |
| 695 | 661 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| 696 | 662 | readonly userBubbleColor: string | import("react-native").ColorValue; |
| 697 | 663 | readonly markdownStyles: MarkdownStyleSets; |
| 698 | 664 | readonly reviewCommentColors: ReviewCommentColors; |
| 699 | 665 | readonly reviewCommentBubbleWidth: number; |
| 666 | readonly userBubbleMaxWidth: number; | |
| 700 | 667 | }, |
| 701 | 668 | ) { |
| 702 | 669 | const entry = info.item; |
| ... | ... | @@ -745,9 +712,10 @@ function renderFeedEntry( |
| 745 | 712 | return ( |
| 746 | 713 | <View className="mb-5 items-end"> |
| 747 | 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 | 716 | style={{ |
| 750 | 717 | backgroundColor: userBubbleColor, |
| 718 | maxWidth: props.userBubbleMaxWidth, | |
| 751 | 719 | ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null), |
| 752 | 720 | }} |
| 753 | 721 | > |
| ... | ... | @@ -757,6 +725,7 @@ function renderFeedEntry( |
| 757 | 725 | markdownStyles={styles} |
| 758 | 726 | reviewCommentColors={props.reviewCommentColors} |
| 759 | 727 | skills={props.skills} |
| 728 | onLinkPress={props.onMarkdownLinkPress} | |
| 760 | 729 | /> |
| 761 | 730 | ) : null} |
| 762 | 731 | {attachments.map((attachment) => { |
| ... | ... | @@ -803,6 +772,7 @@ function renderFeedEntry( |
| 803 | 772 | markdown={message.text} |
| 804 | 773 | skills={props.skills} |
| 805 | 774 | textStyle={styles.nativeTextStyle} |
| 775 | onLinkPress={props.onMarkdownLinkPress} | |
| 806 | 776 | /> |
| 807 | 777 | ) : ( |
| 808 | 778 | <Markdown |
| ... | ... | @@ -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 | 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]"> | |
| 890 | <View className="mb-1.5 flex-row items-center justify-between gap-3 px-2"> | |
| 891 | <Text className="font-t3-medium text-xs text-foreground">{headerTitle}</Text> | |
| 892 | {hasOverflow ? ( | |
| 893 | <Pressable | |
| 894 | accessibilityRole="button" | |
| 895 | accessibilityState={{ expanded: isExpanded }} | |
| 896 | onPress={() => props.onToggleWorkGroup(entry.id)} | |
| 897 | className="flex-row items-center gap-1" | |
| 898 | > | |
| 899 | <Text className="font-t3-medium text-xs text-foreground-muted"> | |
| 900 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 901 | </Text> | |
| 902 | <SymbolView | |
| 903 | name={isExpanded ? "chevron.up" : "chevron.down"} | |
| 904 | size={12} | |
| 905 | tintColor={iconSubtleColor} | |
| 906 | type="monochrome" | |
| 907 | /> | |
| 908 | </Pressable> | |
| 909 | ) : null} | |
| 910 | </View> | |
| 911 | {visibleRows.map((row, index) => ( | |
| 912 | <Pressable | |
| 913 | key={row.id} | |
| 914 | accessibilityRole={row.fullDetail ? "button" : undefined} | |
| 915 | accessibilityState={ | |
| 916 | row.fullDetail ? { expanded: props.expandedWorkRows[row.id] ?? false } : undefined | |
| 917 | } | |
| 918 | onPress={() => { | |
| 919 | if (row.fullDetail) { | |
| 920 | props.onToggleWorkRow(row.id); | |
| 921 | } | |
| 922 | }} | |
| 923 | onLongPress={() => props.onCopyWorkRow(row.id, row.copyText)} | |
| 924 | className={cn( | |
| 925 | "rounded-lg px-2 py-1.5", | |
| 926 | index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", | |
| 927 | )} | |
| 928 | > | |
| 929 | <View className="flex-row items-center gap-2"> | |
| 930 | <View className="w-4 items-center justify-center"> | |
| 931 | <SymbolView | |
| 932 | name={ | |
| 933 | row.status === "failure" | |
| 934 | ? "xmark" | |
| 935 | : row.status === "success" | |
| 936 | ? "checkmark" | |
| 937 | : row.status === "neutral" | |
| 938 | ? "minus" | |
| 939 | : "terminal" | |
| 940 | } | |
| 941 | size={row.status ? 11 : 13} | |
| 942 | tintColor={row.status === "failure" ? "#e11d48" : iconSubtleColor} | |
| 943 | type="monochrome" | |
| 944 | /> | |
| 945 | </View> | |
| 946 | <Text | |
| 947 | className="min-w-0 flex-1 text-[12px] leading-[18px] text-neutral-700 dark:text-neutral-300" | |
| 948 | numberOfLines={props.expandedWorkRows[row.id] ? undefined : 1} | |
| 949 | > | |
| 950 | {row.detail ? `${row.summary} - ${row.detail}` : row.summary} | |
| 951 | </Text> | |
| 952 | {row.fullDetail ? ( | |
| 953 | <SymbolView | |
| 954 | name={props.expandedWorkRows[row.id] ? "chevron.up" : "chevron.down"} | |
| 955 | size={11} | |
| 956 | tintColor={iconSubtleColor} | |
| 957 | type="monochrome" | |
| 958 | /> | |
| 959 | ) : null} | |
| 960 | {props.copiedRowId === row.id ? ( | |
| 961 | <Text className="shrink-0 font-t3-medium text-[10px] text-emerald-600 dark:text-emerald-400"> | |
| 962 | Copied | |
| 963 | </Text> | |
| 964 | ) : null} | |
| 965 | </View> | |
| 966 | {row.fullDetail && props.expandedWorkRows[row.id] ? ( | |
| 967 | <ScrollView | |
| 968 | horizontal | |
| 969 | nestedScrollEnabled | |
| 970 | directionalLockEnabled | |
| 971 | showsHorizontalScrollIndicator | |
| 972 | bounces={false} | |
| 973 | className="mt-2 rounded-lg bg-neutral-100 dark:bg-black/20" | |
| 974 | contentContainerStyle={{ paddingHorizontal: 10, paddingVertical: 8 }} | |
| 975 | > | |
| 976 | <Text | |
| 977 | selectable | |
| 978 | className="text-[11px] leading-[17px] text-neutral-600 dark:text-neutral-400" | |
| 979 | style={{ fontFamily: "ui-monospace" }} | |
| 980 | > | |
| 981 | {row.fullDetail} | |
| 982 | </Text> | |
| 983 | </ScrollView> | |
| 984 | ) : null} | |
| 985 | </Pressable> | |
| 986 | ))} | |
| 987 | </View> | |
| 842 | <ThreadWorkLog | |
| 843 | activities={entry.activities} | |
| 844 | copiedRowId={props.copiedRowId} | |
| 845 | expanded={props.expandedWorkGroups[entry.id] ?? false} | |
| 846 | expandedRows={props.expandedWorkRows} | |
| 847 | iconSubtleColor={iconSubtleColor} | |
| 848 | onCopyRow={props.onCopyWorkRow} | |
| 849 | onToggleGroup={() => props.onToggleWorkGroup(entry.id)} | |
| 850 | onToggleRow={props.onToggleWorkRow} | |
| 851 | /> | |
| 988 | 852 | ); |
| 989 | 853 | } |
| 990 | 854 | |
| ... | ... | @@ -993,6 +857,7 @@ function UserMessageContent(props: { |
| 993 | 857 | readonly markdownStyles: MarkdownStyleSet; |
| 994 | 858 | readonly reviewCommentColors: ReviewCommentColors; |
| 995 | 859 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 860 | readonly onLinkPress: (href: string) => void; | |
| 996 | 861 | }) { |
| 997 | 862 | const segments = parseReviewCommentMessageSegments(props.text); |
| 998 | 863 | const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); |
| ... | ... | @@ -1003,6 +868,8 @@ function UserMessageContent(props: { |
| 1003 | 868 | markdown={props.text} |
| 1004 | 869 | skills={props.skills} |
| 1005 | 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 | 909 | markdown={text} |
| 1043 | 910 | skills={props.skills} |
| 1044 | 911 | textStyle={props.markdownStyles.nativeTextStyle} |
| 912 | preserveSoftBreaks | |
| 913 | onLinkPress={props.onLinkPress} | |
| 1045 | 914 | /> |
| 1046 | 915 | ) : ( |
| 1047 | 916 | <Markdown |
| ... | ... | @@ -1254,6 +1123,7 @@ function ThreadFeedPlaceholder(props: { |
| 1254 | 1123 | } |
| 1255 | 1124 | |
| 1256 | 1125 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1126 | const router = useRouter(); | |
| 1257 | 1127 | const listRef = useRef<LegendListRef>(null); |
| 1258 | 1128 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1259 | 1129 | const scrollFrameRef = useRef<number | null>(null); |
| ... | ... | @@ -1283,6 +1153,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1283 | 1153 | } | null>(null); |
| 1284 | 1154 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; |
| 1285 | 1155 | const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); |
| 1156 | const userBubbleMaxWidth = contentWidth * 0.85; | |
| 1286 | 1157 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); |
| 1287 | 1158 | const insets = useSafeAreaInsets(); |
| 1288 | 1159 | const topContentInset = props.contentTopInset ?? insets.top + 44; |
| ... | ... | @@ -1290,16 +1161,56 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1290 | 1161 | |
| 1291 | 1162 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); |
| 1292 | 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 | 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 | 1195 | const listAppearanceData = useMemo( |
| 1296 | 1196 | () => ({ |
| 1197 | copiedRowId, | |
| 1198 | expandedWorkGroups, | |
| 1199 | expandedWorkRows, | |
| 1297 | 1200 | iconSubtleColor, |
| 1298 | 1201 | markdownStyles, |
| 1299 | 1202 | reviewCommentColors, |
| 1300 | 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 | 1215 | const presentedFeed = useMemo( |
| 1305 | 1216 | () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), |
| ... | ... | @@ -1490,11 +1401,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1490 | 1401 | onToggleWorkRow, |
| 1491 | 1402 | onToggleTurnFold, |
| 1492 | 1403 | onPressImage, |
| 1404 | onMarkdownLinkPress, | |
| 1493 | 1405 | iconSubtleColor, |
| 1494 | 1406 | userBubbleColor, |
| 1495 | 1407 | markdownStyles, |
| 1496 | 1408 | reviewCommentColors, |
| 1497 | 1409 | reviewCommentBubbleWidth, |
| 1410 | userBubbleMaxWidth, | |
| 1498 | 1411 | skills: props.skills, |
| 1499 | 1412 | }), |
| 1500 | 1413 | [ |
| ... | ... | @@ -1508,7 +1421,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1508 | 1421 | markdownStyles, |
| 1509 | 1422 | reviewCommentColors, |
| 1510 | 1423 | reviewCommentBubbleWidth, |
| 1424 | userBubbleMaxWidth, | |
| 1511 | 1425 | onCopyWorkRow, |
| 1426 | onMarkdownLinkPress, | |
| 1512 | 1427 | onPressImage, |
| 1513 | 1428 | onToggleTurnFold, |
| 1514 | 1429 | onToggleWorkGroup, |
apps/mobile/src/features/threads/ThreadGitControls.tsx+10-1| ... | ... | @@ -14,7 +14,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; |
| 14 | 14 | import Stack from "expo-router/stack"; |
| 15 | 15 | import { useCallback, useMemo } from "react"; |
| 16 | 16 | import { Alert, Linking } from "react-native"; |
| 17 | import { buildThreadReviewRoutePath } from "../../lib/routes"; | |
| 17 | import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; | |
| 18 | 18 | import { |
| 19 | 19 | basename, |
| 20 | 20 | getTerminalStatusLabel, |
| ... | ... | @@ -69,6 +69,7 @@ export function ThreadGitControls(props: { |
| 69 | 69 | readonly gitStatus: VcsStatusResult | null; |
| 70 | 70 | readonly gitOperationLabel: string | null; |
| 71 | 71 | readonly canOpenTerminal: boolean; |
| 72 | readonly canOpenFiles: boolean; | |
| 72 | 73 | readonly projectScripts: ReadonlyArray<ProjectScript>; |
| 73 | 74 | readonly terminalSessions: ReadonlyArray<TerminalMenuSession>; |
| 74 | 75 | readonly onOpenTerminal: (terminalId?: string | null) => void; |
| ... | ... | @@ -259,6 +260,14 @@ export function ThreadGitControls(props: { |
| 259 | 260 | > |
| 260 | 261 | <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label> |
| 261 | 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 | 271 | <Stack.Toolbar.MenuAction |
| 263 | 272 | icon="ellipsis.circle" |
| 264 | 273 | onPress={() => |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2| ... | ... | @@ -420,6 +420,7 @@ export function ThreadRouteScreen() { |
| 420 | 420 | gitStatus={gitStatus.data} |
| 421 | 421 | gitOperationLabel={gitState.gitOperationLabel} |
| 422 | 422 | canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)} |
| 423 | canOpenFiles={Boolean(selectedThreadProject?.workspaceRoot)} | |
| 423 | 424 | projectScripts={selectedThreadProject?.scripts ?? []} |
| 424 | 425 | terminalSessions={terminalMenuSessions} |
| 425 | 426 | onOpenTerminal={handleOpenTerminal} |
| ... | ... | @@ -452,6 +453,7 @@ export function ThreadRouteScreen() { |
| 452 | 453 | activeThreadBusy={composer.activeThreadBusy} |
| 453 | 454 | environmentId={selectedThread.environmentId} |
| 454 | 455 | projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} |
| 456 | threadCwd={selectedThreadCwd} | |
| 455 | 457 | selectedThreadQueueCount={composer.selectedThreadQueueCount} |
| 456 | 458 | onOpenDrawer={handleOpenDrawer} |
| 457 | 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 | 16 | resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), |
| 17 | 17 | ).toEqual({ |
| 18 | 18 | kind: "file", |
| 19 | href: "file:///Users/julius/project/src/main.ts#L42C7", | |
| 19 | 20 | icon: "typescript", |
| 20 | 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 | 28 | it("recognizes relative source paths and bare filenames", () => { |
| 25 | 29 | expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({ |
| 26 | 30 | kind: "file", |
| 31 | href: "apps/mobile/src/index.ts:10", | |
| 27 | 32 | icon: "typescript", |
| 28 | 33 | label: "index.ts:10", |
| 34 | path: "apps/mobile/src/index.ts", | |
| 35 | line: 10, | |
| 29 | 36 | }); |
| 30 | 37 | expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({ |
| 31 | 38 | kind: "file", |
| 39 | href: "AGENTS.md", | |
| 32 | 40 | icon: "agents", |
| 33 | 41 | label: "AGENTS.md", |
| 42 | path: "AGENTS.md", | |
| 34 | 43 | }); |
| 35 | 44 | expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ |
| 36 | 45 | kind: "file", |
| 46 | href: "package.json", | |
| 37 | 47 | icon: "package", |
| 38 | 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 | 7 | nativeMarkdownDocumentRuns, |
| 8 | 8 | nativeMarkdownListItemBlocks, |
| 9 | 9 | nativeMarkdownTextRuns, |
| 10 | nativeMarkdownWithPreservedSoftBreaks, | |
| 10 | 11 | } from "@t3tools/mobile-markdown-text/markdown"; |
| 11 | 12 | |
| 12 | 13 | describe("nativeMarkdownTextRuns", () => { |
| ... | ... | @@ -54,7 +55,11 @@ describe("nativeMarkdownTextRuns", () => { |
| 54 | 55 | externalHost: "example.com", |
| 55 | 56 | }, |
| 56 | 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 | 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 | 96 | it("normalizes common inline HTML and entities", () => { |
| 77 | 97 | const node: MarkdownNode = { |
| 78 | 98 | type: "paragraph", |
| ... | ... | @@ -130,7 +150,7 @@ describe("nativeMarkdownTextRuns", () => { |
| 130 | 150 | }); |
| 131 | 151 | |
| 132 | 152 | describe("nativeMarkdownDocumentRuns", () => { |
| 133 | it("decorates known skill references as selectable skill chips", () => { | |
| 153 | it("decorates known skill references as selectable skill links", () => { | |
| 134 | 154 | const node: MarkdownNode = { |
| 135 | 155 | type: "document", |
| 136 | 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 | 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 | 56 | export function buildThreadTerminalRoutePath( |
| 36 | 57 | input: ThreadRouteInput | PlainThreadRouteInput, |
| 37 | 58 | terminalId?: string | null, |
| ... | ... | @@ -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 | 127 | export function dismissRoute(router: Router) { |
| 75 | 128 | if (router.canGoBack()) { |
| 76 | 129 | router.back(); |
apps/mobile/src/lib/threadActivity.test.ts+53-2| ... | ... | @@ -161,14 +161,65 @@ describe("buildThreadFeed", () => { |
| 161 | 161 | turnId: "turn-1", |
| 162 | 162 | summary: "Run tests", |
| 163 | 163 | detail: "bun run test", |
| 164 | fullDetail: null, | |
| 165 | copyText: "Run tests\nbun run test", | |
| 164 | fullDetail: "/bin/zsh -lc 'bun run test'", | |
| 165 | copyText: "Run tests\nbun run test\n/bin/zsh -lc 'bun run test'", | |
| 166 | icon: "command", | |
| 166 | 167 | toolLike: true, |
| 167 | 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 | 223 | it("folds settled turn work while leaving the terminal answer visible", () => { |
| 173 | 224 | const turnId = TurnId.make("turn-1"); |
| 174 | 225 | const thread = makeThread({ |
apps/mobile/src/lib/threadActivity.ts+72-9| ... | ... | @@ -40,6 +40,19 @@ export interface ThreadFeedActivity { |
| 40 | 40 | readonly detail: string | null; |
| 41 | 41 | readonly fullDetail: string | null; |
| 42 | 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 | 56 | readonly toolLike: boolean; |
| 44 | 57 | readonly status: "success" | "failure" | "neutral" | null; |
| 45 | 58 | } |
| ... | ... | @@ -60,6 +73,7 @@ interface WorkLogEntry { |
| 60 | 73 | itemType?: ToolLifecycleItemType; |
| 61 | 74 | requestKind?: PendingApproval["requestKind"]; |
| 62 | 75 | toolLifecycleStatus?: WorkLogToolLifecycleStatus; |
| 76 | toolData?: unknown; | |
| 63 | 77 | } |
| 64 | 78 | |
| 65 | 79 | interface DerivedWorkLogEntry extends WorkLogEntry { |
| ... | ... | @@ -214,7 +228,7 @@ function resolvePendingUserInputAnswer( |
| 214 | 228 | |
| 215 | 229 | function deriveWorkLogEntries( |
| 216 | 230 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 217 | ): WorkLogEntry[] { | |
| 231 | ): DerivedWorkLogEntry[] { | |
| 218 | 232 | const ordered = Arr.sort(activities, activityOrder); |
| 219 | 233 | const entries: DerivedWorkLogEntry[] = []; |
| 220 | 234 | for (const activity of ordered) { |
| ... | ... | @@ -225,9 +239,7 @@ function deriveWorkLogEntries( |
| 225 | 239 | if (isPlanBoundaryToolActivity(activity)) continue; |
| 226 | 240 | entries.push(toDerivedWorkLogEntry(activity)); |
| 227 | 241 | } |
| 228 | return collapseDerivedWorkLogEntries(entries).map( | |
| 229 | ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry, | |
| 230 | ); | |
| 242 | return collapseDerivedWorkLogEntries(entries); | |
| 231 | 243 | } |
| 232 | 244 | |
| 233 | 245 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { |
| ... | ... | @@ -301,6 +313,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 301 | 313 | if (title) { |
| 302 | 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 | 322 | if (itemType) { |
| 305 | 323 | entry.itemType = itemType; |
| 306 | 324 | } |
| ... | ... | @@ -365,6 +383,7 @@ function mergeDerivedWorkLogEntries( |
| 365 | 383 | const requestKind = next.requestKind ?? previous.requestKind; |
| 366 | 384 | const collapseKey = next.collapseKey ?? previous.collapseKey; |
| 367 | 385 | const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; |
| 386 | const toolData = next.toolData ?? previous.toolData; | |
| 368 | 387 | return { |
| 369 | 388 | ...previous, |
| 370 | 389 | ...next, |
| ... | ... | @@ -377,6 +396,7 @@ function mergeDerivedWorkLogEntries( |
| 377 | 396 | ...(requestKind ? { requestKind } : {}), |
| 378 | 397 | ...(collapseKey ? { collapseKey } : {}), |
| 379 | 398 | ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), |
| 399 | ...(toolData !== undefined ? { toolData } : {}), | |
| 380 | 400 | }; |
| 381 | 401 | } |
| 382 | 402 | |
| ... | ... | @@ -480,6 +500,52 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { |
| 480 | 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 | 549 | function workEntryPreview( |
| 484 | 550 | workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">, |
| 485 | 551 | ): string | null { |
| ... | ... | @@ -1226,11 +1292,7 @@ export function buildThreadFeed( |
| 1226 | 1292 | .map<RawThreadFeedEntry>((entry) => { |
| 1227 | 1293 | const summary = workEntryHeading(entry); |
| 1228 | 1294 | const detail = workEntryPreview(entry); |
| 1229 | const normalizedFullDetail = entry.detail | |
| 1230 | ? unwrapKnownShellCommandWrapper(entry.detail) | |
| 1231 | : null; | |
| 1232 | const fullDetail = | |
| 1233 | normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null; | |
| 1295 | const fullDetail = buildWorkEntryExpandedBody(entry); | |
| 1234 | 1296 | return { |
| 1235 | 1297 | type: "activity", |
| 1236 | 1298 | id: entry.id, |
| ... | ... | @@ -1243,6 +1305,7 @@ export function buildThreadFeed( |
| 1243 | 1305 | summary, |
| 1244 | 1306 | detail, |
| 1245 | 1307 | fullDetail, |
| 1308 | icon: workEntryIcon(entry), | |
| 1246 | 1309 | copyText: [summary, detail, fullDetail] |
| 1247 | 1310 | .filter((value, index, values): value is string => { |
| 1248 | 1311 | return Boolean(value) && values.indexOf(value) === index; |
apps/server/src/assets/AssetAccess.test.ts+36| ... | ... | @@ -89,6 +89,42 @@ describe("AssetAccess", () => { |
| 89 | 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 | 128 | it.effect("issues exact attachment capabilities by attachment id", () => |
| 93 | 129 | Effect.gen(function* () { |
| 94 | 130 | const config = yield* ServerConfig; |
apps/server/src/assets/AssetAccess.ts+45-21| ... | ... | @@ -1,5 +1,11 @@ |
| 1 | 1 | import type { AssetResource } from "@t3tools/contracts"; |
| 2 | 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 | 9 | import * as Clock from "effect/Clock"; |
| 4 | 10 | import * as Effect from "effect/Effect"; |
| 5 | 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 | 30 | |
| 25 | 31 | const SIGNING_SECRET_NAME = "asset-access-signing-key"; |
| 26 | 32 | const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; |
| 27 | const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]); | |
| 28 | 33 | const PREVIEW_ASSET_EXTENSIONS = new Set([ |
| 29 | ...PREVIEWABLE_EXTENSIONS, | |
| 30 | ".avif", | |
| 34 | ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, | |
| 35 | ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, | |
| 31 | 36 | ".css", |
| 32 | ".gif", | |
| 33 | ".ico", | |
| 34 | ".jpeg", | |
| 35 | ".jpg", | |
| 36 | 37 | ".js", |
| 37 | 38 | ".mjs", |
| 38 | 39 | ".otf", |
| 39 | ".png", | |
| 40 | ".svg", | |
| 41 | 40 | ".ttf", |
| 42 | ".webp", | |
| 43 | 41 | ".woff", |
| 44 | 42 | ".woff2", |
| 45 | 43 | ]); |
| ... | ... | @@ -52,6 +50,13 @@ const AssetClaimsSchema = Schema.Union([ |
| 52 | 50 | baseRelativePath: Schema.String, |
| 53 | 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 | 60 | Schema.Struct({ |
| 56 | 61 | version: Schema.Literal(1), |
| 57 | 62 | kind: Schema.Literal("attachment"), |
| ... | ... | @@ -144,8 +149,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 144 | 149 | const resolved = yield* workspacePaths |
| 145 | 150 | .resolveRelativePathWithinRoot({ workspaceRoot, relativePath }) |
| 146 | 151 | .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); |
| 147 | if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { | |
| 148 | return yield* failAccess("Only HTML and PDF files can open in the browser."); | |
| 152 | if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { | |
| 153 | return yield* failAccess("Only browser documents and images can be previewed."); | |
| 149 | 154 | } |
| 150 | 155 | const canonicalFile = yield* resolveCanonicalWorkspaceFile({ |
| 151 | 156 | workspaceRoot, |
| ... | ... | @@ -154,15 +159,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 154 | 159 | if (!canonicalFile) { |
| 155 | 160 | return yield* failAccess("Workspace asset was not found."); |
| 156 | 161 | } |
| 157 | claims = { | |
| 158 | version: 1, | |
| 159 | kind: "workspace-file", | |
| 160 | workspaceRoot: yield* fileSystem | |
| 161 | .realPath(workspaceRoot) | |
| 162 | .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))), | |
| 163 | baseRelativePath: path.dirname(resolved.relativePath), | |
| 164 | expiresAt, | |
| 165 | }; | |
| 162 | const canonicalWorkspaceRoot = yield* fileSystem | |
| 163 | .realPath(workspaceRoot) | |
| 164 | .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))); | |
| 165 | claims = isWorkspaceImagePreviewPath(resolved.relativePath) | |
| 166 | ? { | |
| 167 | version: 1, | |
| 168 | kind: "workspace-file-exact", | |
| 169 | workspaceRoot: canonicalWorkspaceRoot, | |
| 170 | relativePath: resolved.relativePath, | |
| 171 | expiresAt, | |
| 172 | } | |
| 173 | : { | |
| 174 | version: 1, | |
| 175 | kind: "workspace-file", | |
| 176 | workspaceRoot: canonicalWorkspaceRoot, | |
| 177 | baseRelativePath: path.dirname(resolved.relativePath), | |
| 178 | expiresAt, | |
| 179 | }; | |
| 166 | 180 | fileName = path.basename(resolved.relativePath); |
| 167 | 181 | break; |
| 168 | 182 | } |
| ... | ... | @@ -268,6 +282,16 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 268 | 282 | const decodedPath = decodeRelativePath(relativePath); |
| 269 | 283 | if (decodedPath === null) return null; |
| 270 | 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 | 295 | const segments = decodedPath.split(/[\\/]/); |
| 272 | 296 | if ( |
| 273 | 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 | 9 | |
| 10 | 10 | import { useActiveBrowserRecordingTabId } from "./browserRecording"; |
| 11 | 11 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 12 | import { acquireDesktopTab } from "./desktopTabLifetime"; | |
| 12 | import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; | |
| 13 | 13 | import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; |
| 14 | 14 | |
| 15 | 15 | interface ElectronWebview extends HTMLElement { |
| ... | ... | @@ -34,13 +34,21 @@ export function HostedBrowserWebview(props: { |
| 34 | 34 | const { threadRef, tabId, initialUrl } = props; |
| 35 | 35 | const config = usePreviewWebviewConfig(threadRef.environmentId); |
| 36 | 36 | const initialSrcRef = useRef(initialUrl ?? "about:blank"); |
| 37 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); | |
| 37 | 38 | const webviewRef = useRef<ElectronWebview | null>(null); |
| 38 | 39 | const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); |
| 39 | 40 | const recording = useActiveBrowserRecordingTabId() === tabId; |
| 40 | 41 | |
| 41 | 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 | 53 | const setWebviewRef = useCallback((node: HTMLElement | null) => { |
| 46 | 54 | webviewRef.current = node as ElectronWebview | null; |
| ... | ... | @@ -51,19 +59,34 @@ export function HostedBrowserWebview(props: { |
| 51 | 59 | const webview = webviewRef.current; |
| 52 | 60 | const bridge = previewBridge; |
| 53 | 61 | if (!webview || !config || !bridge) return; |
| 62 | let disposed = false; | |
| 54 | 63 | const register = () => { |
| 55 | try { | |
| 56 | const webContentsId = webview.getWebContentsId(); | |
| 57 | if (Number.isInteger(webContentsId) && webContentsId > 0) { | |
| 58 | void bridge.registerWebview(tabId, webContentsId); | |
| 64 | const lease = tabLeaseRef.current; | |
| 65 | if (!lease) return; | |
| 66 | void (async () => { | |
| 67 | try { | |
| 68 | // The main-process tab and the DOM webview are created by separate | |
| 69 | // effects. Wait for the former so registration cannot race and fail | |
| 70 | // with PreviewTabNotFoundError on a fast about:blank attachment. | |
| 71 | await lease.ready; | |
| 72 | if (disposed || webviewRef.current !== webview) return; | |
| 73 | const webContentsId = webview.getWebContentsId(); | |
| 74 | if (Number.isInteger(webContentsId) && webContentsId > 0) { | |
| 75 | await bridge.registerWebview(tabId, webContentsId); | |
| 76 | } | |
| 77 | } catch { | |
| 78 | // did-attach/dom-ready will retry if the guest was not ready yet. | |
| 59 | 79 | } |
| 60 | } catch { | |
| 61 | // A later dom-ready will retry registration. | |
| 62 | } | |
| 80 | })(); | |
| 63 | 81 | }; |
| 82 | webview.addEventListener("did-attach", register); | |
| 64 | 83 | webview.addEventListener("dom-ready", register); |
| 65 | 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 | 90 | }, [config, tabId]); |
| 68 | 91 | |
| 69 | 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 | 3 | interface DesktopTabLease { |
| 4 | 4 | references: number; |
| 5 | 5 | closeTimer: number | null; |
| 6 | ready: Promise<void>; | |
| 6 | 7 | } |
| 7 | 8 | |
| 8 | 9 | const leases = new Map<string, DesktopTabLease>(); |
| 9 | 10 | |
| 10 | export function acquireDesktopTab(tabId: string): () => void { | |
| 11 | const current = leases.get(tabId) ?? { references: 0, closeTimer: null }; | |
| 11 | export interface AcquiredDesktopTab { | |
| 12 | readonly ready: Promise<void>; | |
| 13 | readonly release: () => void; | |
| 14 | } | |
| 15 | ||
| 16 | export function acquireDesktopTab(tabId: string): AcquiredDesktopTab { | |
| 17 | const current = | |
| 18 | leases.get(tabId) ?? | |
| 19 | ({ | |
| 20 | references: 0, | |
| 21 | closeTimer: null, | |
| 22 | ready: previewBridge?.createTab(tabId) ?? Promise.resolve(), | |
| 23 | } satisfies DesktopTabLease); | |
| 12 | 24 | if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); |
| 13 | 25 | current.references += 1; |
| 14 | 26 | current.closeTimer = null; |
| 15 | 27 | leases.set(tabId, current); |
| 16 | if (current.references === 1) void previewBridge?.createTab(tabId); | |
| 17 | 28 | |
| 18 | return () => { | |
| 19 | const lease = leases.get(tabId); | |
| 20 | if (!lease) return; | |
| 21 | lease.references = Math.max(0, lease.references - 1); | |
| 22 | if (lease.references > 0) return; | |
| 23 | lease.closeTimer = window.setTimeout(() => { | |
| 24 | const latest = leases.get(tabId); | |
| 25 | if (!latest || latest.references > 0) return; | |
| 26 | leases.delete(tabId); | |
| 27 | void previewBridge?.closeTab(tabId); | |
| 28 | }, 0); | |
| 29 | return { | |
| 30 | ready: current.ready, | |
| 31 | release: () => { | |
| 32 | const lease = leases.get(tabId); | |
| 33 | if (!lease) return; | |
| 34 | lease.references = Math.max(0, lease.references - 1); | |
| 35 | if (lease.references > 0) return; | |
| 36 | lease.closeTimer = window.setTimeout(() => { | |
| 37 | const latest = leases.get(tabId); | |
| 38 | if (!latest || latest.references > 0) return; | |
| 39 | leases.delete(tabId); | |
| 40 | void previewBridge?.closeTab(tabId); | |
| 41 | }, 0); | |
| 42 | }, | |
| 29 | 43 | }; |
| 30 | 44 | } |
apps/web/src/components/ChatView.tsx+17-24| ... | ... | @@ -111,12 +111,12 @@ import { |
| 111 | 111 | useRightPanelStore, |
| 112 | 112 | } from "../rightPanelStore"; |
| 113 | 113 | import { |
| 114 | applyPreviewServerSnapshot, | |
| 115 | 114 | isPreviewSupportedInRuntime, |
| 116 | removePreviewSession, | |
| 117 | 115 | setActivePreviewTab, |
| 118 | 116 | useThreadPreviewState, |
| 119 | 117 | } from "../previewStateStore"; |
| 118 | import { addBrowserSurface } from "./preview/addBrowserSurface"; | |
| 119 | import { closePreviewSession } from "./preview/closePreviewSession"; | |
| 120 | 120 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 121 | 121 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 122 | 122 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; |
| ... | ... | @@ -2746,23 +2746,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 2746 | 2746 | }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); |
| 2747 | 2747 | const createBrowserSurface = useCallback(() => { |
| 2748 | 2748 | if (!activeThreadRef) return; |
| 2749 | const activeTabId = activePreviewState.activeTabId; | |
| 2750 | if (activeTabId) { | |
| 2751 | useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); | |
| 2752 | return; | |
| 2753 | } | |
| 2754 | void (async () => { | |
| 2755 | const result = await openPreview({ | |
| 2756 | environmentId: activeThreadRef.environmentId, | |
| 2757 | input: { threadId: activeThreadRef.threadId }, | |
| 2758 | }); | |
| 2759 | if (result._tag === "Failure") { | |
| 2760 | return; | |
| 2761 | } | |
| 2762 | applyPreviewServerSnapshot(activeThreadRef, result.value); | |
| 2763 | useRightPanelStore.getState().openBrowser(activeThreadRef, result.value.tabId); | |
| 2764 | })(); | |
| 2765 | }, [activePreviewState.activeTabId, activeThreadRef, openPreview]); | |
| 2749 | void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); | |
| 2750 | }, [activeThreadRef, openPreview]); | |
| 2766 | 2751 | const addDiffSurface = useCallback(() => { |
| 2767 | 2752 | if (!activeThreadRef || !isServerThread || !isGitRepo) return; |
| 2768 | 2753 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| ... | ... | @@ -2807,8 +2792,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 2807 | 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 | 2803 | activeThreadRef, |
| 2813 | 2804 | createBrowserSurface, |
| 2814 | 2805 | diffOpen, |
| ... | ... | @@ -2993,10 +2984,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 2993 | 2984 | |
| 2994 | 2985 | for (const surface of surfaces) { |
| 2995 | 2986 | if (surface.kind === "preview" && surface.resourceId) { |
| 2996 | removePreviewSession(activeThreadRef, surface.resourceId); | |
| 2997 | void closePreview({ | |
| 2998 | environmentId: activeThreadRef.environmentId, | |
| 2999 | input: { threadId: activeThreadRef.threadId, tabId: surface.resourceId }, | |
| 2987 | void closePreviewSession({ | |
| 2988 | closePreview, | |
| 2989 | snapshot: activePreviewState.sessions[surface.resourceId] ?? null, | |
| 2990 | tabId: surface.resourceId, | |
| 2991 | threadRef: activeThreadRef, | |
| 3000 | 2992 | }); |
| 3001 | 2993 | } |
| 3002 | 2994 | if (surface.kind === "terminal") { |
| ... | ... | @@ -3020,6 +3012,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3020 | 3012 | }, |
| 3021 | 3013 | [ |
| 3022 | 3014 | activeThreadRef, |
| 3015 | activePreviewState.sessions, | |
| 3023 | 3016 | closePreview, |
| 3024 | 3017 | closeTerminalMutation, |
| 3025 | 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 | 28 | beforeEach(resetPreviewStateForTests); |
| 29 | 29 | |
| 30 | 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 | 49 | it("applies the RPC response without waiting for a preview event", async () => { |
| 32 | 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 | 14 | readonly input: PreviewOpenInput; |
| 15 | 15 | }) => Promise<AtomCommandResult<PreviewSessionSnapshot, E>>; |
| 16 | 16 | threadRef: ScopedThreadRef; |
| 17 | url: string; | |
| 17 | url?: string; | |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | 20 | export async function openPreviewSession<E>( |
| ... | ... | @@ -24,7 +24,7 @@ export async function openPreviewSession<E>( |
| 24 | 24 | environmentId: input.threadRef.environmentId, |
| 25 | 25 | input: { |
| 26 | 26 | threadId: input.threadRef.threadId, |
| 27 | url: input.url, | |
| 27 | ...(input.url === undefined ? {} : { url: input.url }), | |
| 28 | 28 | }, |
| 29 | 29 | }); |
| 30 | 30 | if (result._tag === "Failure") { |
| ... | ... | @@ -32,9 +32,11 @@ export async function openPreviewSession<E>( |
| 32 | 32 | } |
| 33 | 33 | const snapshot = result.value; |
| 34 | 34 | applyPreviewServerSnapshot(input.threadRef, snapshot); |
| 35 | rememberPreviewUrl( | |
| 36 | input.threadRef, | |
| 37 | snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, | |
| 38 | ); | |
| 35 | if (input.url !== undefined) { | |
| 36 | rememberPreviewUrl( | |
| 37 | input.threadRef, | |
| 38 | snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, | |
| 39 | ); | |
| 40 | } | |
| 39 | 41 | return result; |
| 40 | 42 | } |
apps/web/src/lib/archivedThreadsState.ts+6-39| ... | ... | @@ -1,53 +1,15 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { |
| 3 | 3 | type ArchivedSnapshotEntry, |
| 4 | createArchivedThreadSnapshotsAtomFamily, | |
| 4 | 5 | makeArchivedThreadsEnvironmentKey, |
| 5 | parseArchivedThreadsEnvironmentKey, | |
| 6 | 6 | } from "@t3tools/client-runtime/state/threads"; |
| 7 | 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 | 8 | import { useCallback, useMemo } from "react"; |
| 12 | 9 | |
| 13 | 10 | import { orchestrationEnvironment } from "../state/orchestration"; |
| 14 | 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 | 13 | function archivedSnapshotAtom(environmentId: EnvironmentId) { |
| 52 | 14 | return orchestrationEnvironment.archivedShellSnapshot({ |
| 53 | 15 | 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 | 25 | export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { |
| 59 | 26 | appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); |
| 60 | 27 | } |
apps/web/src/lib/threadSort.ts+7-86| ... | ... | @@ -1,86 +1,7 @@ |
| 1 | import type { ProjectId } from "@t3tools/contracts"; | |
| 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | |
| 3 | import type { Thread } from "../types"; | |
| 4 | ||
| 5 | export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & { | |
| 6 | latestUserMessageAt?: string | null; | |
| 7 | messages?: ReadonlyArray<Pick<Thread["messages"][number], "createdAt" | "role">>; | |
| 8 | }; | |
| 9 | ||
| 10 | export function toSortableTimestamp(iso: string | undefined): number | null { | |
| 11 | if (!iso) return null; | |
| 12 | const ms = Date.parse(iso); | |
| 13 | return Number.isFinite(ms) ? ms : null; | |
| 14 | } | |
| 15 | ||
| 16 | function getFirstSortableTimestamp(...values: Array<string | null | undefined>): number | null { | |
| 17 | for (const value of values) { | |
| 18 | const timestamp = toSortableTimestamp(value ?? undefined); | |
| 19 | if (timestamp !== null) { | |
| 20 | return timestamp; | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 24 | return null; | |
| 25 | } | |
| 26 | ||
| 27 | function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { | |
| 28 | if (thread.latestUserMessageAt) { | |
| 29 | return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; | |
| 30 | } | |
| 31 | ||
| 32 | let latestUserMessageTimestamp: number | null = null; | |
| 33 | ||
| 34 | for (const message of thread.messages ?? []) { | |
| 35 | if (message.role !== "user") continue; | |
| 36 | const messageTimestamp = toSortableTimestamp(message.createdAt); | |
| 37 | if (messageTimestamp === null) continue; | |
| 38 | latestUserMessageTimestamp = | |
| 39 | latestUserMessageTimestamp === null | |
| 40 | ? messageTimestamp | |
| 41 | : Math.max(latestUserMessageTimestamp, messageTimestamp); | |
| 42 | } | |
| 43 | ||
| 44 | if (latestUserMessageTimestamp !== null) { | |
| 45 | return latestUserMessageTimestamp; | |
| 46 | } | |
| 47 | ||
| 48 | return getFirstSortableTimestamp(thread.updatedAt, thread.createdAt) ?? Number.NEGATIVE_INFINITY; | |
| 49 | } | |
| 50 | ||
| 51 | export function getThreadSortTimestamp( | |
| 52 | thread: ThreadSortInput, | |
| 53 | sortOrder: SidebarThreadSortOrder | Exclude<SidebarProjectSortOrder, "manual">, | |
| 54 | ): number { | |
| 55 | if (sortOrder === "created_at") { | |
| 56 | return ( | |
| 57 | getFirstSortableTimestamp(thread.createdAt, thread.updatedAt) ?? Number.NEGATIVE_INFINITY | |
| 58 | ); | |
| 59 | } | |
| 60 | return getLatestUserMessageTimestamp(thread); | |
| 61 | } | |
| 62 | ||
| 63 | export function sortThreads<T extends Pick<Thread, "id"> & ThreadSortInput>( | |
| 64 | threads: readonly T[], | |
| 65 | sortOrder: SidebarThreadSortOrder, | |
| 66 | ): T[] { | |
| 67 | return threads.toSorted((left, right) => { | |
| 68 | const rightTimestamp = getThreadSortTimestamp(right, sortOrder); | |
| 69 | const leftTimestamp = getThreadSortTimestamp(left, sortOrder); | |
| 70 | const byTimestamp = | |
| 71 | rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; | |
| 72 | if (byTimestamp !== 0) return byTimestamp; | |
| 73 | return right.id.localeCompare(left.id); | |
| 74 | }); | |
| 75 | } | |
| 76 | ||
| 77 | export function getLatestThreadForProject< | |
| 78 | T extends Pick<Thread, "id" | "projectId" | "archivedAt"> & ThreadSortInput, | |
| 79 | >(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { | |
| 80 | return ( | |
| 81 | sortThreads( | |
| 82 | threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), | |
| 83 | sortOrder, | |
| 84 | )[0] ?? null | |
| 85 | ); | |
| 86 | } | |
| 1 | export { | |
| 2 | getLatestThreadForProject, | |
| 3 | getThreadSortTimestamp, | |
| 4 | sortThreads, | |
| 5 | toSortableTimestamp, | |
| 6 | type ThreadSortInput, | |
| 7 | } from "@t3tools/client-runtime/state/thread-sort"; |
apps/web/src/logicalProject.ts+14-184| ... | ... | @@ -1,184 +1,14 @@ |
| 1 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | |
| 3 | import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; | |
| 4 | import type { UnifiedSettings } from "@t3tools/contracts/settings"; | |
| 5 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; | |
| 6 | ||
| 7 | export interface ProjectGroupingSettings { | |
| 8 | sidebarProjectGroupingMode: SidebarProjectGroupingMode; | |
| 9 | sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>; | |
| 10 | } | |
| 11 | ||
| 12 | export type ProjectGroupingMode = SidebarProjectGroupingMode; | |
| 13 | ||
| 14 | export function selectProjectGroupingSettings(settings: UnifiedSettings): ProjectGroupingSettings { | |
| 15 | return { | |
| 16 | sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, | |
| 17 | sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, | |
| 18 | }; | |
| 19 | } | |
| 20 | ||
| 21 | function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): string[] { | |
| 22 | const seen = new Set<string>(); | |
| 23 | const unique: string[] = []; | |
| 24 | for (const value of values) { | |
| 25 | const trimmed = value?.trim(); | |
| 26 | if (!trimmed || seen.has(trimmed)) { | |
| 27 | continue; | |
| 28 | } | |
| 29 | seen.add(trimmed); | |
| 30 | unique.push(trimmed); | |
| 31 | } | |
| 32 | return unique; | |
| 33 | } | |
| 34 | ||
| 35 | function deriveRepositoryRelativeProjectPath( | |
| 36 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, | |
| 37 | ): string | null { | |
| 38 | const rootPath = project.repositoryIdentity?.rootPath?.trim(); | |
| 39 | if (!rootPath) { | |
| 40 | return null; | |
| 41 | } | |
| 42 | ||
| 43 | const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); | |
| 44 | const normalizedRootPath = normalizeProjectPathForComparison(rootPath); | |
| 45 | if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { | |
| 46 | return null; | |
| 47 | } | |
| 48 | ||
| 49 | if (normalizedProjectPath === normalizedRootPath) { | |
| 50 | return ""; | |
| 51 | } | |
| 52 | ||
| 53 | const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; | |
| 54 | const rootPrefix = `${normalizedRootPath}${separator}`; | |
| 55 | if (!normalizedProjectPath.startsWith(rootPrefix)) { | |
| 56 | return null; | |
| 57 | } | |
| 58 | ||
| 59 | return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); | |
| 60 | } | |
| 61 | ||
| 62 | export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { | |
| 63 | return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; | |
| 64 | } | |
| 65 | ||
| 66 | export function derivePhysicalProjectKey( | |
| 67 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | |
| 68 | ): string { | |
| 69 | return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); | |
| 70 | } | |
| 71 | ||
| 72 | export function deriveProjectGroupingOverrideKey( | |
| 73 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | |
| 74 | ): string { | |
| 75 | return derivePhysicalProjectKey(project); | |
| 76 | } | |
| 77 | ||
| 78 | // Key under which a project's manual sort order (projectOrder) is stored. | |
| 79 | // Must stay aligned with the drag handlers and readers in `Sidebar`. | |
| 80 | export function getProjectOrderKey( | |
| 81 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | |
| 82 | ): string { | |
| 83 | return derivePhysicalProjectKey(project); | |
| 84 | } | |
| 85 | ||
| 86 | export function resolveProjectGroupingMode( | |
| 87 | project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, | |
| 88 | settings: ProjectGroupingSettings, | |
| 89 | ): SidebarProjectGroupingMode { | |
| 90 | return ( | |
| 91 | settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? | |
| 92 | settings.sidebarProjectGroupingMode | |
| 93 | ); | |
| 94 | } | |
| 95 | ||
| 96 | function deriveRepositoryScopedKey( | |
| 97 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, | |
| 98 | groupingMode: SidebarProjectGroupingMode, | |
| 99 | ): string | null { | |
| 100 | const canonicalKey = project.repositoryIdentity?.canonicalKey; | |
| 101 | if (!canonicalKey) { | |
| 102 | return null; | |
| 103 | } | |
| 104 | ||
| 105 | if (groupingMode === "repository") { | |
| 106 | return canonicalKey; | |
| 107 | } | |
| 108 | ||
| 109 | const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); | |
| 110 | if (relativeProjectPath === null) { | |
| 111 | return canonicalKey; | |
| 112 | } | |
| 113 | ||
| 114 | return relativeProjectPath.length === 0 | |
| 115 | ? canonicalKey | |
| 116 | : `${canonicalKey}::${relativeProjectPath}`; | |
| 117 | } | |
| 118 | ||
| 119 | export function deriveLogicalProjectKey( | |
| 120 | project: Pick< | |
| 121 | EnvironmentProject, | |
| 122 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | |
| 123 | >, | |
| 124 | options?: { | |
| 125 | groupingMode?: SidebarProjectGroupingMode; | |
| 126 | }, | |
| 127 | ): string { | |
| 128 | const groupingMode = options?.groupingMode ?? "repository"; | |
| 129 | if (groupingMode === "separate") { | |
| 130 | return derivePhysicalProjectKey(project); | |
| 131 | } | |
| 132 | ||
| 133 | return ( | |
| 134 | deriveRepositoryScopedKey(project, groupingMode) ?? | |
| 135 | derivePhysicalProjectKey(project) ?? | |
| 136 | scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) | |
| 137 | ); | |
| 138 | } | |
| 139 | ||
| 140 | export function deriveLogicalProjectKeyFromSettings( | |
| 141 | project: Pick< | |
| 142 | EnvironmentProject, | |
| 143 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | |
| 144 | >, | |
| 145 | settings: ProjectGroupingSettings, | |
| 146 | ): string { | |
| 147 | return deriveLogicalProjectKey(project, { | |
| 148 | groupingMode: resolveProjectGroupingMode(project, settings), | |
| 149 | }); | |
| 150 | } | |
| 151 | ||
| 152 | export function deriveLogicalProjectKeyFromRef( | |
| 153 | projectRef: ScopedProjectRef, | |
| 154 | project: | |
| 155 | | Pick<EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"> | |
| 156 | | null | |
| 157 | | undefined, | |
| 158 | options?: { | |
| 159 | groupingMode?: SidebarProjectGroupingMode; | |
| 160 | }, | |
| 161 | ): string { | |
| 162 | return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); | |
| 163 | } | |
| 164 | ||
| 165 | export function deriveProjectGroupLabel(input: { | |
| 166 | representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">; | |
| 167 | members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>; | |
| 168 | }): string { | |
| 169 | const sharedDisplayNames = uniqueNonEmptyValues( | |
| 170 | input.members.map((member) => member.repositoryIdentity?.displayName), | |
| 171 | ); | |
| 172 | if (sharedDisplayNames.length === 1) { | |
| 173 | return sharedDisplayNames[0]!; | |
| 174 | } | |
| 175 | ||
| 176 | const sharedRepositoryNames = uniqueNonEmptyValues( | |
| 177 | input.members.map((member) => member.repositoryIdentity?.name), | |
| 178 | ); | |
| 179 | if (sharedRepositoryNames.length === 1) { | |
| 180 | return sharedRepositoryNames[0]!; | |
| 181 | } | |
| 182 | ||
| 183 | return input.representative.title; | |
| 184 | } | |
| 1 | export { | |
| 2 | deriveLogicalProjectKey, | |
| 3 | deriveLogicalProjectKeyFromRef, | |
| 4 | deriveLogicalProjectKeyFromSettings, | |
| 5 | derivePhysicalProjectKey, | |
| 6 | derivePhysicalProjectKeyFromPath, | |
| 7 | deriveProjectGroupLabel, | |
| 8 | deriveProjectGroupingOverrideKey, | |
| 9 | getProjectOrderKey, | |
| 10 | resolveProjectGroupingMode, | |
| 11 | selectProjectGroupingSettings, | |
| 12 | type ProjectGroupingMode, | |
| 13 | type ProjectGroupingSettings, | |
| 14 | } from "@t3tools/client-runtime/state/project-grouping"; |
apps/web/src/main.tsx+2-8| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import React from "react"; |
| 2 | 2 | import ReactDOM from "react-dom/client"; |
| 3 | 3 | import { ClerkProvider } from "@clerk/react"; |
| 4 | import { RouterProvider } from "@tanstack/react-router"; | |
| 5 | 4 | import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; |
| 6 | 5 | |
| 7 | 6 | import "@fontsource-variable/dm-sans/index.css"; |
| ... | ... | @@ -17,7 +16,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; |
| 17 | 16 | import { getRouter } from "./router"; |
| 18 | 17 | import { APP_DISPLAY_NAME } from "./branding"; |
| 19 | 18 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; |
| 20 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; | |
| 19 | import { AppRoot } from "./AppRoot"; | |
| 21 | 20 | |
| 22 | 21 | // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. |
| 23 | 22 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| ... | ... | @@ -32,12 +31,7 @@ document.title = APP_DISPLAY_NAME; |
| 32 | 31 | |
| 33 | 32 | const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; |
| 34 | 33 | |
| 35 | const app = ( | |
| 36 | <> | |
| 37 | <RouterProvider router={router} /> | |
| 38 | <ElectronBrowserHost /> | |
| 39 | </> | |
| 40 | ); | |
| 34 | const app = <AppRoot router={router} />; | |
| 41 | 35 | |
| 42 | 36 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( |
| 43 | 37 | <React.StrictMode> |
apps/web/src/previewStateStore.test.ts+28-3| ... | ... | @@ -7,10 +7,11 @@ import { |
| 7 | 7 | applyPreviewDesktopState, |
| 8 | 8 | applyPreviewServerEvent, |
| 9 | 9 | applyPreviewServerSnapshot, |
| 10 | beginPreviewSessionClose, | |
| 11 | cancelPreviewSessionClose, | |
| 10 | 12 | previewStateAtom, |
| 11 | 13 | readThreadPreviewState, |
| 12 | 14 | rememberPreviewUrl, |
| 13 | removePreviewSession, | |
| 14 | 15 | removePreviewThread, |
| 15 | 16 | resetPreviewStateForTests, |
| 16 | 17 | setActivePreviewTab, |
| ... | ... | @@ -182,7 +183,7 @@ describe("previewStateStore (single-tab)", () => { |
| 182 | 183 | applyPreviewServerSnapshot(ref, first); |
| 183 | 184 | applyPreviewServerSnapshot(ref, second); |
| 184 | 185 | |
| 185 | removePreviewSession(ref, second.tabId); | |
| 186 | beginPreviewSessionClose(ref, second.tabId); | |
| 186 | 187 | |
| 187 | 188 | const state = readThreadPreviewState(ref); |
| 188 | 189 | expect(Object.keys(state.sessions)).toEqual([first.tabId]); |
| ... | ... | @@ -193,7 +194,7 @@ describe("previewStateStore (single-tab)", () => { |
| 193 | 194 | it("treats a late server close event after optimistic removal as a no-op", () => { |
| 194 | 195 | const snapshot = makeSnapshot(); |
| 195 | 196 | applyPreviewServerSnapshot(ref, snapshot); |
| 196 | removePreviewSession(ref, snapshot.tabId); | |
| 197 | beginPreviewSessionClose(ref, snapshot.tabId); | |
| 197 | 198 | |
| 198 | 199 | applyPreviewServerEvent(ref, { |
| 199 | 200 | type: "closed", |
| ... | ... | @@ -207,6 +208,30 @@ describe("previewStateStore (single-tab)", () => { |
| 207 | 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 | 235 | it("closed event for a different tab is a no-op", () => { |
| 211 | 236 | const snapshot = makeSnapshot({ tabId: "tab_a" }); |
| 212 | 237 | applyPreviewServerEvent(ref, { |
apps/web/src/previewStateStore.ts+42-2| ... | ... | @@ -28,6 +28,8 @@ export interface DesktopPreviewOverlay { |
| 28 | 28 | export interface ThreadPreviewState { |
| 29 | 29 | snapshot: PreviewSessionSnapshot | null; |
| 30 | 30 | sessions: Record<string, PreviewSessionSnapshot>; |
| 31 | /** Tabs intentionally closed by this client. Stale list snapshots must not resurrect them. */ | |
| 32 | suppressedTabIds: ReadonlySet<string>; | |
| 31 | 33 | activeTabId: string | null; |
| 32 | 34 | desktopOverlay: DesktopPreviewOverlay | null; |
| 33 | 35 | desktopByTabId: Record<string, DesktopPreviewOverlay>; |
| ... | ... | @@ -37,6 +39,7 @@ export interface ThreadPreviewState { |
| 37 | 39 | const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ |
| 38 | 40 | snapshot: null, |
| 39 | 41 | sessions: {}, |
| 42 | suppressedTabIds: new Set<string>(), | |
| 40 | 43 | activeTabId: null, |
| 41 | 44 | desktopOverlay: null, |
| 42 | 45 | desktopByTabId: {}, |
| ... | ... | @@ -162,6 +165,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven |
| 162 | 165 | case "opened": |
| 163 | 166 | case "navigated": { |
| 164 | 167 | const snapshot = event.snapshot; |
| 168 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; | |
| 165 | 169 | const recentlySeenUrls = |
| 166 | 170 | snapshot.navStatus._tag === "Idle" |
| 167 | 171 | ? current.recentlySeenUrls |
| ... | ... | @@ -221,6 +225,7 @@ export function applyPreviewServerSnapshot( |
| 221 | 225 | desktopByTabId: {}, |
| 222 | 226 | }; |
| 223 | 227 | } |
| 228 | if (current.suppressedTabIds.has(snapshot.tabId)) return current; | |
| 224 | 229 | const existing = current.sessions[snapshot.tabId]; |
| 225 | 230 | if (existing && existing.updatedAt > snapshot.updatedAt) return current; |
| 226 | 231 | const recentlySeenUrls = |
| ... | ... | @@ -255,8 +260,43 @@ export function applyPreviewDesktopState( |
| 255 | 260 | }); |
| 256 | 261 | } |
| 257 | 262 | |
| 258 | export function removePreviewSession(ref: ScopedThreadRef, tabId: string): void { | |
| 259 | updateThreadPreviewState(ref, (current) => removeSession(current, tabId)); | |
| 263 | export function beginPreviewSessionClose(ref: ScopedThreadRef, tabId: string): void { | |
| 264 | updateThreadPreviewState(ref, (current) => { | |
| 265 | const suppressedTabIds = new Set(current.suppressedTabIds); | |
| 266 | suppressedTabIds.add(tabId); | |
| 267 | return { | |
| 268 | ...removeSession(current, tabId), | |
| 269 | suppressedTabIds, | |
| 270 | }; | |
| 271 | }); | |
| 272 | } | |
| 273 | ||
| 274 | export function cancelPreviewSessionClose( | |
| 275 | ref: ScopedThreadRef, | |
| 276 | snapshot: PreviewSessionSnapshot | null, | |
| 277 | tabId: string, | |
| 278 | ): void { | |
| 279 | updateThreadPreviewState(ref, (current) => { | |
| 280 | if (!current.suppressedTabIds.has(tabId)) return current; | |
| 281 | const suppressedTabIds = new Set(current.suppressedTabIds); | |
| 282 | suppressedTabIds.delete(tabId); | |
| 283 | if (!snapshot) { | |
| 284 | return { ...current, suppressedTabIds }; | |
| 285 | } | |
| 286 | const recentlySeenUrls = | |
| 287 | snapshot.navStatus._tag !== "Idle" | |
| 288 | ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) | |
| 289 | : current.recentlySeenUrls; | |
| 290 | return { | |
| 291 | ...current, | |
| 292 | snapshot, | |
| 293 | sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, | |
| 294 | suppressedTabIds, | |
| 295 | activeTabId: snapshot.tabId, | |
| 296 | desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, | |
| 297 | recentlySeenUrls, | |
| 298 | }; | |
| 299 | }); | |
| 260 | 300 | } |
| 261 | 301 | |
| 262 | 302 | export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { |
apps/web/src/router.ts-3| ... | ... | @@ -1,7 +1,5 @@ |
| 1 | import { createElement } from "react"; | |
| 2 | 1 | import { createRouter, RouterHistory } from "@tanstack/react-router"; |
| 3 | 2 | |
| 4 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; | |
| 5 | 3 | import { routeTree } from "./routeTree.gen"; |
| 6 | 4 | |
| 7 | 5 | export function getRouter(history: RouterHistory) { |
| ... | ... | @@ -9,7 +7,6 @@ export function getRouter(history: RouterHistory) { |
| 9 | 7 | routeTree, |
| 10 | 8 | history, |
| 11 | 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 | 83 | "types": "./src/state/projects.ts", |
| 84 | 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 | 90 | "./state/relay": { |
| 87 | 91 | "types": "./src/state/relayDiscovery.ts", |
| 88 | 92 | "default": "./src/state/relayDiscovery.ts" |
| ... | ... | @@ -119,6 +123,10 @@ |
| 119 | 123 | "types": "./src/state/threads.ts", |
| 120 | 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 | 130 | "./state/vcs": { |
| 123 | 131 | "types": "./src/state/vcs.ts", |
| 124 | 132 | "default": "./src/state/vcs.ts" |
packages/client-runtime/src/state/archivedThreads.ts+44| ... | ... | @@ -1,13 +1,22 @@ |
| 1 | 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; |
| 2 | 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Cause from "effect/Cause"; | |
| 3 | 4 | import { pipe } from "effect/Function"; |
| 5 | import * as Option from "effect/Option"; | |
| 4 | 6 | import * as Order from "effect/Order"; |
| 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 5 | 8 | |
| 6 | 9 | export interface ArchivedSnapshotEntry { |
| 7 | 10 | readonly environmentId: EnvironmentId; |
| 8 | 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 | 20 | const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; |
| 12 | 21 | const environmentIdOrder = Order.String as Order.Order<EnvironmentId>; |
| 13 | 22 | |
| ... | ... | @@ -28,3 +37,38 @@ export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<E |
| 28 | 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 | 163 | "types": "./src/preview.ts", |
| 164 | 164 | "import": "./src/preview.ts" |
| 165 | 165 | }, |
| 166 | "./filePreview": { | |
| 167 | "types": "./src/filePreview.ts", | |
| 168 | "import": "./src/filePreview.ts" | |
| 169 | }, | |
| 166 | 170 | "./hostProcess": { |
| 167 | 171 | "types": "./src/hostProcess.ts", |
| 168 | 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 | 247 | version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) |
| 248 | 248 | expo: |
| 249 | 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 | 251 | expo-asset: |
| 252 | 252 | specifier: ~56.0.15 |
| 253 | 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 | 362 | react-native-svg: |
| 363 | 363 | specifier: 15.15.4 |
| 364 | 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 | 368 | react-native-worklets: |
| 366 | 369 | specifier: 0.8.3 |
| 367 | 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 | 8456 | peerDependencies: |
| 8454 | 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 | 8465 | react-native-worklets@0.8.3: |
| 8457 | 8466 | resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==} |
| 8458 | 8467 | peerDependencies: |
| ... | ... | @@ -10784,7 +10793,7 @@ snapshots: |
| 10784 | 10793 | '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 10785 | 10794 | '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 10786 | 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 | 10797 | react: 19.2.3 |
| 10789 | 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 | 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 | 11405 | connect: 3.7.0 |
| 11397 | 11406 | debug: 4.4.3 |
| 11398 | 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 | 11409 | expo-server: 56.0.4 |
| 11401 | 11410 | fetch-nodeshim: 0.4.10 |
| 11402 | 11411 | getenv: 2.0.0 |
| ... | ... | @@ -11494,7 +11503,7 @@ snapshots: |
| 11494 | 11503 | |
| 11495 | 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 | 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 | 11507 | react: 19.2.3 |
| 11499 | 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 | 11570 | dependencies: |
| 11562 | 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 | 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 | 11574 | react: 19.2.3 |
| 11566 | 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 | 11576 | stacktrace-parser: 0.1.11 |
| ... | ... | @@ -11593,7 +11602,7 @@ snapshots: |
| 11593 | 11602 | postcss: 8.5.15 |
| 11594 | 11603 | resolve-from: 5.0.0 |
| 11595 | 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 | 11606 | transitivePeerDependencies: |
| 11598 | 11607 | - bufferutil |
| 11599 | 11608 | - supports-color |
| ... | ... | @@ -11615,7 +11624,7 @@ snapshots: |
| 11615 | 11624 | dependencies: |
| 11616 | 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 | 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 | 11628 | pretty-format: 29.7.0 |
| 11620 | 11629 | react: 19.2.3 |
| 11621 | 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 | 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 | 11703 | dependencies: |
| 11695 | 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 | 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 | 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 | 11708 | expo-server: 56.0.4 |
| ... | ... | @@ -11717,7 +11726,7 @@ snapshots: |
| 11717 | 11726 | |
| 11718 | 11727 | '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': |
| 11719 | 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 | 11730 | react: 19.2.3 |
| 11722 | 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 | 11732 | sf-symbols-typescript: 2.2.0 |
| ... | ... | @@ -14450,7 +14459,7 @@ snapshots: |
| 14450 | 14459 | react-refresh: 0.14.2 |
| 14451 | 14460 | optionalDependencies: |
| 14452 | 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 | 14463 | expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) |
| 14455 | 14464 | transitivePeerDependencies: |
| 14456 | 14465 | - '@babel/core' |
| ... | ... | @@ -15316,12 +15325,12 @@ snapshots: |
| 15316 | 15325 | |
| 15317 | 15326 | expo-application@56.0.3(expo@56.0.8): |
| 15318 | 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 | 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 | 15331 | dependencies: |
| 15323 | 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 | 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 | 15335 | react: 19.2.3 |
| 15327 | 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 | 15355 | expo-build-properties@56.0.16(expo@56.0.8): |
| 15347 | 15356 | dependencies: |
| 15348 | 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 | 15359 | resolve-from: 5.0.0 |
| 15351 | 15360 | semver: 7.8.1 |
| 15352 | 15361 | |
| 15353 | 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 | 15363 | dependencies: |
| 15355 | 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 | 15366 | react: 19.2.3 |
| 15358 | 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 | 15368 | transitivePeerDependencies: |
| ... | ... | @@ -15361,25 +15370,25 @@ snapshots: |
| 15361 | 15370 | |
| 15362 | 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 | 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 | 15374 | react: 19.2.3 |
| 15366 | 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 | 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 | 15378 | dependencies: |
| 15370 | 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 | 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 | 15382 | transitivePeerDependencies: |
| 15374 | 15383 | - supports-color |
| 15375 | 15384 | |
| 15376 | 15385 | expo-crypto@56.0.4(expo@56.0.8): |
| 15377 | 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 | 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 | 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 | 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 | 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 | 15394 | expo-dev-menu-interface: 56.0.1(expo@56.0.8) |
| ... | ... | @@ -15391,18 +15400,18 @@ snapshots: |
| 15391 | 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 | 15401 | dependencies: |
| 15393 | 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 | 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 | 15405 | expo-manifests: 56.0.4(expo@56.0.8) |
| 15397 | 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 | 15408 | expo-dev-menu-interface@56.0.1(expo@56.0.8): |
| 15400 | 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 | 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 | 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 | 15415 | expo-dev-menu-interface: 56.0.1(expo@56.0.8) |
| 15407 | 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 | 15419 | |
| 15411 | 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 | 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 | 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 | 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 | 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 | 15428 | fontfaceobserver: 2.3.0 |
| 15420 | 15429 | react: 19.2.3 |
| 15421 | 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 | 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 | 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 | 15435 | react: 19.2.3 |
| 15427 | 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 | 15438 | expo-haptics@56.0.3(expo@56.0.8): |
| 15430 | 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 | 15442 | expo-image-loader@56.0.3(expo@56.0.8): |
| 15434 | 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 | 15446 | expo-image-picker@56.0.15(expo@56.0.8): |
| 15438 | 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 | 15449 | expo-image-loader: 56.0.3(expo@56.0.8) |
| 15441 | 15450 | |
| 15442 | 15451 | expo-json-utils@56.0.0: {} |
| 15443 | 15452 | |
| 15444 | 15453 | expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): |
| 15445 | 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 | 15456 | react: 19.2.3 |
| 15448 | 15457 | |
| 15449 | 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 | 15467 | |
| 15459 | 15468 | expo-manifests@56.0.4(expo@56.0.8): |
| 15460 | 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 | 15471 | expo-json-utils: 56.0.0 |
| 15463 | 15472 | |
| 15464 | 15473 | expo-modules-autolinking@56.0.14(typescript@6.0.3): |
| ... | ... | @@ -15487,7 +15496,7 @@ snapshots: |
| 15487 | 15496 | |
| 15488 | 15497 | expo-network@56.0.5(expo@56.0.8)(react@19.2.3): |
| 15489 | 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 | 15500 | react: 19.2.3 |
| 15492 | 15501 | |
| 15493 | 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 | 15504 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 15496 | 15505 | abort-controller: 3.0.0 |
| 15497 | 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 | 15508 | expo-application: 56.0.3(expo@56.0.8) |
| 15500 | 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 | 15510 | react: 19.2.3 |
| ... | ... | @@ -15506,7 +15515,7 @@ snapshots: |
| 15506 | 15515 | |
| 15507 | 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 | 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 | 15519 | react: 19.2.3 |
| 15511 | 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 | 15534 | color: 4.2.3 |
| 15526 | 15535 | debug: 4.4.3 |
| 15527 | 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 | 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 | 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 | 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 | 15570 | |
| 15562 | 15571 | expo-secure-store@56.0.4(expo@56.0.8): |
| 15563 | 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 | 15575 | expo-server@56.0.4: {} |
| 15567 | 15576 | |
| ... | ... | @@ -15569,7 +15578,7 @@ snapshots: |
| 15569 | 15578 | dependencies: |
| 15570 | 15579 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) |
| 15571 | 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 | 15582 | xml2js: 0.6.0 |
| 15574 | 15583 | transitivePeerDependencies: |
| 15575 | 15584 | - supports-color |
| ... | ... | @@ -15580,7 +15589,7 @@ snapshots: |
| 15580 | 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 | 15590 | dependencies: |
| 15582 | 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 | 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 | 15594 | react: 19.2.3 |
| 15586 | 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 | 15597 | |
| 15589 | 15598 | expo-updates-interface@56.0.2(expo@56.0.8): |
| 15590 | 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 | 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 | 15603 | dependencies: |
| ... | ... | @@ -15598,7 +15607,7 @@ snapshots: |
| 15598 | 15607 | arg: 4.1.3 |
| 15599 | 15608 | chalk: 4.1.2 |
| 15600 | 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 | 15611 | expo-eas-client: 56.0.1 |
| 15603 | 15612 | expo-manifests: 56.0.4(expo@56.0.8) |
| 15604 | 15613 | expo-structured-headers: 56.0.0 |
| ... | ... | @@ -15617,14 +15626,14 @@ snapshots: |
| 15617 | 15626 | |
| 15618 | 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 | 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 | 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 | 15632 | expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): |
| 15624 | 15633 | dependencies: |
| 15625 | 15634 | '@expo/plist': 0.7.0 |
| 15626 | 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 | 15637 | react: 19.2.3 |
| 15629 | 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 | 15639 | transitivePeerDependencies: |
| ... | ... | @@ -15635,7 +15644,7 @@ snapshots: |
| 15635 | 15644 | - react-native-reanimated |
| 15636 | 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 | 15648 | dependencies: |
| 15640 | 15649 | '@babel/runtime': 7.29.7 |
| 15641 | 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 | 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 | 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 | 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 | 15678 | transitivePeerDependencies: |
| 15669 | 15679 | - '@babel/core' |
| 15670 | 15680 | - bufferutil |
| ... | ... | @@ -18139,6 +18149,13 @@ snapshots: |
| 18139 | 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 | 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 | 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 | 18160 | dependencies: |
| 18144 | 18161 | '@babel/core': 7.29.7 |