From f391794a35c604d57e166a3ab48d56fc6e4e469a Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:46:57 +0530 Subject: [PATCH 01/78] fix(shared): classify workspace previews by literal filenames (#10311) Co-authored-by: yashranaway Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/server/src/assets/AssetAccess.test.ts | 43 ++++++++++++++++++++++ docs/user/composer.md | 3 ++ packages/shared/src/filePreview.test.ts | 25 +++++++++---- packages/shared/src/filePreview.ts | 4 +- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 71a2c769ef4e..a596ca55c32a 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -713,6 +713,49 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("previews workspace files with literal fragment characters in their paths", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-preview-literal-" }); + const directory = path.join(root, "assets#archive"); + yield* fileSystem.makeDirectory(directory); + + for (const name of ["icon#v2.png", "report#draft.pdf"]) { + const filePath = path.join(directory, name); + yield* fileSystem.writeFileString(filePath, "preview fixture"); + const canonicalFile = yield* fileSystem.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + + expect(yield* resolveAsset(token, name)).toEqual({ kind: "file", path: canonicalFile }); + if (name.endsWith(".png")) { + expect(yield* resolveAsset(token, "other.png")).toBeNull(); + } + } + + const disguisedPath = path.join(root, "image.png#notes.txt"); + yield* fileSystem.writeFileString(disguisedPath, "not an image"); + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: disguisedPath, + }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues exact attachment capabilities by attachment id", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/docs/user/composer.md b/docs/user/composer.md index 59efc3c30f01..8cb8d7805ac9 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -274,5 +274,8 @@ On web and desktop, HTML and PDF files open as rendered pages. Switch an HTML file to source view to read its markup; a link to a specific line opens source automatically. HTML previews cannot access your T3 Code session. +The file viewer recognizes images, HTML, and PDF files by their filename extension, +including filenames or folders containing `#` or `?`. + On mobile, select a PDF attachment or link to open it. iOS uses the native viewer; Android opens a compatible installed file viewer. diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index 34d619e7a782..602fb8299cdd 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -14,7 +14,7 @@ import { } from "./filePreview.ts"; describe("workspace file previews", () => { - it.each(["report.html", "report.HTM", "document.pdf?download=1"])( + it.each(["report.html", "report.HTM", "document#draft.pdf", "reports?old/document.pdf"])( "recognizes browser preview path %s", (path) => { expect(isWorkspaceBrowserPreviewPath(path)).toBe(true); @@ -26,7 +26,9 @@ describe("workspace file previews", () => { "icon.png", "photo.JPEG", "animation.gif", - "vector.svg#mark", + "vector#mark.svg", + "photo?edited.JPEG", + "images#archive/icon.png", "texture.webp", "image.avif", ])("recognizes image preview path %s", (path) => { @@ -34,12 +36,19 @@ describe("workspace file previews", () => { expect(isWorkspacePreviewEntryPath(path)).toBe(true); }); - it.each(["README.md", "src/index.ts", "image.png.ts", "png"])( - "rejects non-preview path %s", - (path) => { - expect(isWorkspacePreviewEntryPath(path)).toBe(false); - }, - ); + it.each([ + "README.md", + "src/index.ts", + "image.png.ts", + "png", + "image.png#notes.txt", + "image.svg?notes.txt", + "document.pdf?download=1", + "report.html#notes.txt", + "image%2Epng", + ])("rejects non-preview path %s", (path) => { + expect(isWorkspacePreviewEntryPath(path)).toBe(false); + }); it("serves audio in place from the host like video and browser documents", () => { expect(isWorkspaceAudioPreviewPath("notes/recording.WAV")).toBe(true); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index da62d2e689ec..04e285eff224 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -171,8 +171,8 @@ export function mediaKindFromPath(path: string): "image" | "video" | null { } function hasPreviewExtension(path: string, extensions: ReadonlyArray): boolean { - const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; - return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); + const literalPath = path.toLowerCase(); + return extensions.some((extension) => literalPath.endsWith(extension)); } export function isWorkspaceBrowserPreviewPath(path: string): boolean { From 1945ce82c0d66d2b8ffbe867e688c0ecbf530e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9?= Date: Sat, 3 Oct 2026 16:34:13 +0200 Subject: [PATCH 02/78] fix(mobile): iOS threads no longer jump to the top (#14808) --- apps/mobile/package.json | 2 +- ...t-native-keyboard-controller@1.22.6.patch} | 155 ++++++++++-------- pnpm-lock.yaml | 12 +- pnpm-workspace.yaml | 4 +- 4 files changed, 96 insertions(+), 77 deletions(-) rename patches/{react-native-keyboard-controller@1.22.4.patch => react-native-keyboard-controller@1.22.6.patch} (88%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d1bd5c810894..b5f0d49d2bc1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -114,7 +114,7 @@ "react-native": "0.88.0-rc.3", "react-native-gesture-handler": "~3.2.1", "react-native-image-viewing": "^0.2.2", - "react-native-keyboard-controller": "1.22.4", + "react-native-keyboard-controller": "1.22.6", "react-native-nitro-markdown": "^0.5.0", "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.7.0", diff --git a/patches/react-native-keyboard-controller@1.22.4.patch b/patches/react-native-keyboard-controller@1.22.6.patch similarity index 88% rename from patches/react-native-keyboard-controller@1.22.4.patch rename to patches/react-native-keyboard-controller@1.22.6.patch index 01c1760bf33a..8b9170240d4e 100644 --- a/patches/react-native-keyboard-controller@1.22.4.patch +++ b/patches/react-native-keyboard-controller@1.22.6.patch @@ -1,8 +1,8 @@ diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js -index 5617394..4f2222a 100644 +index 0b78ff3..98b3b6c 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/index.js -@@ -27,9 +27,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -28,9 +28,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, @@ -15,17 +15,16 @@ index 5617394..4f2222a 100644 onEndVisible, ...rest }, ref) => { -@@ -51,13 +54,17 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ - freeze: freezeSV, +@@ -54,6 +57,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset, blankSpace, -- extraContentPadding -+ extraContentPadding, + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, + initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y }); (0, _useExtraContentPadding.useExtraContentPadding)({ - scrollViewRef, +@@ -61,6 +66,8 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -34,19 +33,19 @@ index 5617394..4f2222a 100644 scroll, layout, size, -@@ -88,10 +95,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -91,10 +98,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ // a bug for you, please open an issue. - const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + const totalPadding = (0, _reanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); + // iOS applies the destination contentInset and contentOffset together at + // keyboard-animation start. Keep that native target behavior, but report + // the presentation height so virtualized-list layout follows the keyboard. -+ const reportedPadding = (0, _reactNativeReanimated.useDerivedValue)(() => _reactNative.Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value); ++ const reportedPadding = (0, _reanimated.useDerivedValue)(() => _reactNative.Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value); + + // Mirror the effective bottom padding (keyboard + composer + blank floor) + // to the consumer - a virtualized list needs it in its own scroll math or + // its end/maintain targets point at the under-the-keyboard resting offset. -+ (0, _reactNativeReanimated.useAnimatedReaction)(() => reportedPadding.value, (current, previous) => { ++ (0, _reanimated.useAnimatedReaction)(() => reportedPadding.value, (current, previous) => { + if (onContentInsetChange && current !== previous) { + (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({ + bottom: current @@ -57,8 +56,8 @@ index 5617394..4f2222a 100644 // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). // Apps that render into the unsafe area can supply a negative // scrollIndicatorInsets adjustment at the application layer. -- const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); -+ const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value); +- const indicatorPadding = (0, _reanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); ++ const indicatorPadding = (0, _reanimated.useDerivedValue)(() => padding.value); const onLayout = (0, _react.useCallback)(e => { onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); @@ -89,7 +88,7 @@ index 4d33b3f..6a787d3 100644 //# sourceMappingURL=helpers.js.map \ No newline at end of file diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index cecee07..533a9e9 100644 +index 6481228..0dc5dd1 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -32,7 +32,9 @@ function useChatKeyboard(scrollViewRef, options) { @@ -188,10 +187,10 @@ index cecee07..533a9e9 100644 padding, currentHeight, diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 54a1c57..00c2941 100644 +index 0215bef..5307446 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -31,6 +31,8 @@ function useExtraContentPadding(options) { +@@ -32,6 +32,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, @@ -200,7 +199,7 @@ index 54a1c57..00c2941 100644 scroll, layout, size, -@@ -50,8 +52,13 @@ function useExtraContentPadding(options) { +@@ -51,8 +53,13 @@ function useExtraContentPadding(options) { // otherwise the native ScrollView clamps to the old range. requestAnimationFrame(() => { // check that view is still mounted and ref is actual @@ -216,7 +215,7 @@ index 54a1c57..00c2941 100644 return; } (0, _reactNativeReanimated.scrollTo)(scrollViewRef, 0, target, false); -@@ -70,8 +77,8 @@ function useExtraContentPadding(options) { +@@ -71,8 +78,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -227,12 +226,15 @@ index 54a1c57..00c2941 100644 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -90,10 +97,11 @@ function useExtraContentPadding(options) { +@@ -91,13 +98,11 @@ function useExtraContentPadding(options) { const target = Math.max(scroll.value - effectiveDelta, -currentTotal); scrollToTarget(target); } else { - const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); -- const target = Math.min(scroll.value + effectiveDelta, maxScroll); +- // Clamp at 0 as well: when the content is shorter than the viewport a +- // shrinking padding makes `effectiveDelta` negative while `scroll.value` +- // is already 0, so the target would go below the top of the content. +- const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), 0); + const minScroll = -adjustedStartInsetCompensation; + const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); + const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll)); @@ -244,20 +246,23 @@ index 54a1c57..00c2941 100644 //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js -index d085394..a57c2f9 100644 +index e0c8391..8df802a 100644 --- a/lib/module/components/KeyboardChatScrollView/index.js +++ b/lib/module/components/KeyboardChatScrollView/index.js -@@ -1,7 +1,7 @@ +@@ -1,9 +1,9 @@ function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } import React, { forwardRef, useCallback, useMemo } from "react"; -import { StyleSheet } from "react-native"; --import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; +-import { makeMutable, useAnimatedRef } from "react-native-reanimated"; +import { Platform, StyleSheet } from "react-native"; -+import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; ++import { makeMutable, runOnJS, useAnimatedRef } from "react-native-reanimated"; import Reanimated from "react-native-reanimated"; +-import { useAnimatedStyle, useDerivedValue } from "../../reanimated"; ++import { useAnimatedReaction, useAnimatedStyle, useDerivedValue } from "../../reanimated"; import useCombinedRef from "../hooks/useCombinedRef"; import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; -@@ -20,9 +20,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ + import { useChatKeyboard } from "./useChatKeyboard"; +@@ -21,9 +21,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, @@ -270,17 +275,16 @@ index d085394..a57c2f9 100644 onEndVisible, ...rest }, ref) => { -@@ -44,13 +47,17 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ - freeze: freezeSV, +@@ -47,6 +50,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset, blankSpace, -- extraContentPadding -+ extraContentPadding, + extraContentPadding, + adjustedInsetCompensation, -+ adjustedStartInsetCompensation ++ adjustedStartInsetCompensation, + initialContentOffsetY: (_rest$contentOffset = rest.contentOffset) === null || _rest$contentOffset === void 0 ? void 0 : _rest$contentOffset.y }); useExtraContentPadding({ - scrollViewRef, +@@ -54,6 +59,8 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ extraContentPadding, keyboardPadding: padding, blankSpace, @@ -289,7 +293,7 @@ index d085394..a57c2f9 100644 scroll, layout, size, -@@ -81,10 +88,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -84,10 +91,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ // a bug for you, please open an issue. const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); @@ -343,7 +347,7 @@ index 295e221..2f7b87e 100644 //# sourceMappingURL=helpers.js.map \ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 407275d..19427d7 100644 +index 043b973..df5a408 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js @@ -25,7 +25,9 @@ function useChatKeyboard(scrollViewRef, options) { @@ -442,10 +446,10 @@ index 407275d..19427d7 100644 padding, currentHeight, diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 5885415..8b3c4f0 100644 +index 5a9b91d..a11bfdb 100644 --- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -25,6 +25,8 @@ function useExtraContentPadding(options) { +@@ -26,6 +26,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, @@ -454,7 +458,7 @@ index 5885415..8b3c4f0 100644 scroll, layout, size, -@@ -44,8 +46,13 @@ function useExtraContentPadding(options) { +@@ -45,8 +47,13 @@ function useExtraContentPadding(options) { // otherwise the native ScrollView clamps to the old range. requestAnimationFrame(() => { // check that view is still mounted and ref is actual @@ -470,7 +474,7 @@ index 5885415..8b3c4f0 100644 return; } scrollTo(scrollViewRef, 0, target, false); -@@ -64,8 +71,8 @@ function useExtraContentPadding(options) { +@@ -65,8 +72,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -481,12 +485,15 @@ index 5885415..8b3c4f0 100644 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -84,11 +91,12 @@ function useExtraContentPadding(options) { +@@ -85,14 +92,12 @@ function useExtraContentPadding(options) { const target = Math.max(scroll.value - effectiveDelta, -currentTotal); scrollToTarget(target); } else { - const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); -- const target = Math.min(scroll.value + effectiveDelta, maxScroll); +- // Clamp at 0 as well: when the content is shorter than the viewport a +- // shrinking padding makes `effectiveDelta` negative while `scroll.value` +- // is already 0, so the target would go below the top of the content. +- const target = Math.max(Math.min(scroll.value + effectiveDelta, maxScroll), 0); + const minScroll = -adjustedStartInsetCompensation; + const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); + const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll)); @@ -524,10 +531,10 @@ index 2b51cc4..3acec1f 100644 -export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number; +export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInsetCompensation?: number) => number; diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -index aff9b5a..fdd8395 100644 +index 8d358bc..911f3d9 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -@@ -9,6 +9,10 @@ type UseChatKeyboardOptions = { +@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -554,21 +561,27 @@ index ec73f70..45b3874 100644 scroll: SharedValue; /** Visible viewport dimensions. */ diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx -index 554b38c..725d546 100644 +index 935bc71..d411dc4 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx -@@ -1,7 +1,9 @@ +@@ -1,9 +1,13 @@ import React, { forwardRef, useCallback, useMemo } from "react"; -import { StyleSheet } from "react-native"; +-import { makeMutable, useAnimatedRef } from "react-native-reanimated"; +import { Platform, StyleSheet } from "react-native"; - import { - makeMutable, -+ runOnJS, ++import { makeMutable, runOnJS, useAnimatedRef } from "react-native-reanimated"; + import Reanimated from "react-native-reanimated"; + +-import { useAnimatedStyle, useDerivedValue } from "../../reanimated"; ++import { + useAnimatedReaction, - useAnimatedRef, - useAnimatedStyle, - useDerivedValue, -@@ -39,9 +41,12 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< ++ useAnimatedStyle, ++ useDerivedValue, ++} from "../../reanimated"; + import useCombinedRef from "../hooks/useCombinedRef"; + import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; + +@@ -35,9 +39,12 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, @@ -581,16 +594,16 @@ index 554b38c..725d546 100644 onEndVisible, ...rest }, -@@ -68,6 +73,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -64,6 +71,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, + adjustedStartInsetCompensation, + initialContentOffsetY: rest.contentOffset?.y, }); - useExtraContentPadding({ -@@ -75,6 +82,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -72,6 +81,8 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< extraContentPadding, keyboardPadding: padding, blankSpace, @@ -599,7 +612,7 @@ index 554b38c..725d546 100644 scroll, layout, size, -@@ -112,13 +121,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< +@@ -109,13 +120,40 @@ const KeyboardChatScrollView: React.ForwardRefExoticComponent< ), ); @@ -697,7 +710,7 @@ index 37eacd1..3d77ab2 100644 + ); }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index 129a74b..168ce1a 100644 +index 1ae9b81..208505b 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts @@ -43,6 +43,8 @@ function useChatKeyboard( @@ -827,10 +840,10 @@ index 129a74b..168ce1a 100644 return { diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -index 02abf5c..4ee0ddd 100644 +index f89b7c8..02e0e19 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = { +@@ -13,6 +13,10 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; @@ -842,10 +855,10 @@ index 02abf5c..4ee0ddd 100644 type UseChatKeyboardReturn = { diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -index c9c93f3..02f95c0 100644 +index de7d6f7..631cdfb 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -@@ -16,6 +16,10 @@ type UseExtraContentPaddingOptions = { +@@ -17,6 +17,10 @@ type UseExtraContentPaddingOptions = { keyboardPadding: SharedValue; /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ blankSpace: SharedValue; @@ -856,7 +869,7 @@ index c9c93f3..02f95c0 100644 /** Current vertical scroll offset. */ scroll: SharedValue; /** Visible viewport dimensions. */ -@@ -51,6 +55,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -52,6 +56,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { extraContentPadding, keyboardPadding, blankSpace, @@ -865,7 +878,7 @@ index c9c93f3..02f95c0 100644 scroll, layout, size, -@@ -72,8 +78,13 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -73,8 +79,13 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { // otherwise the native ScrollView clamps to the old range. requestAnimationFrame(() => { // check that view is still mounted and ref is actual @@ -881,7 +894,7 @@ index c9c93f3..02f95c0 100644 return; } scrollTo(scrollViewRef, 0, target, false); -@@ -99,14 +110,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -100,14 +111,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { } // Compute effective delta considering blankSpace floor @@ -902,7 +915,7 @@ index c9c93f3..02f95c0 100644 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { -@@ -139,16 +148,25 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -140,22 +149,28 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { scrollToTarget(target); } else { @@ -911,12 +924,18 @@ index c9c93f3..02f95c0 100644 size.value.height - layout.value.height + currentTotal, - 0, + minScroll, -+ ); -+ const target = Math.max( + ); +- // Clamp at 0 as well: when the content is shorter than the viewport a +- // shrinking padding makes `effectiveDelta` negative while `scroll.value` +- // is already 0, so the target would go below the top of the content. ++ // Clamp at the top as well: when the content is shorter than the ++ // viewport a shrinking padding makes `effectiveDelta` negative while ++ // `scroll.value` is already at the top. + const target = Math.max( + minScroll, -+ Math.min(scroll.value + effectiveDelta, maxScroll), + Math.min(scroll.value + effectiveDelta, maxScroll), +- 0, ); -- const target = Math.min(scroll.value + effectiveDelta, maxScroll); scrollToTarget(target); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 092af1eaeeb7..f21be6151710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,7 +111,7 @@ patchedDependencies: expo-widgets@58.0.11: 94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 react-native-gesture-handler@3.2.1: d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9 - react-native-keyboard-controller@1.22.4: c631278492f0113f42fa8bed43c7d8ed5779424dd12af0cefc55132c2ee1d84c + react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 react-native-screens@4.28.0: cc1301436ee62c39860acc9ee1a1e60b7d47e6abd211c42effa62c056d165483 @@ -448,8 +448,8 @@ importers: specifier: ^0.2.2 version: 0.2.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-keyboard-controller: - specifier: 1.22.4 - version: 1.22.4(patch_hash=c631278492f0113f42fa8bed43c7d8ed5779424dd12af0cefc55132c2ee1d84c)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + specifier: 1.22.6 + version: 1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-nitro-markdown: specifier: ^0.5.0 version: 0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -9561,8 +9561,8 @@ packages: react: '*' react-native: '*' - react-native-keyboard-controller@1.22.4: - resolution: {integrity: sha512-2by3oJBQqKH9UAydlBeiEpivxIyTbt345bmverLym7vbEp4NXckWQLF0STnVuXYccRak5/sd3wMYDJPhv/9+3g==} + react-native-keyboard-controller@1.22.6: + resolution: {integrity: sha512-gwJAI3Bhbqs/Ja3uaeBz7EFQSFcem8q4wsXkrC9sy1CL14gjWhBICLu2Ecn7HmezpIu33uhLbirLGyqSawxRtg==} peerDependencies: react: '*' react-native: '*' @@ -20049,7 +20049,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.22.4(patch_hash=c631278492f0113f42fa8bed43c7d8ed5779424dd12af0cefc55132c2ee1d84c)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-keyboard-controller@1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3c0ef065701e..5bd65fcacbbb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -143,7 +143,7 @@ minimumReleaseAgeExclude: - "react@19.3.0" - "react-dom@19.3.0" - "react-native@0.88.0-rc.3" - - "react-native-keyboard-controller@1.22.4" + - "react-native-keyboard-controller@1.22.6" - "react-native-worklets@0.13.0" - "react-native-reanimated@4.7.0" - "react-native-screens@4.28.0" @@ -260,7 +260,7 @@ patchedDependencies: # Link http(s) URLs with a port or a single-label host. md4c otherwise requires a dotted host and stops at ':'. react-native-nitro-markdown@0.5.8: patches/react-native-nitro-markdown@0.5.8.patch react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch - react-native-keyboard-controller@1.22.4: patches/react-native-keyboard-controller@1.22.4.patch + react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.28.0: patches/react-native-screens@4.28.0.patch uniwind@1.11.0: patches/uniwind@1.11.0.patch From 44b49650170a76e6171642d6f8de946cf1d1c821 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:51:55 +0300 Subject: [PATCH 03/78] fix(web): reduce the gap above the draft composer (#15196) --- apps/web/src/components/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fe72ec446d34..92400fedd9b3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10812,7 +10812,7 @@ export default function ChatView(props: ChatViewProps) { {isDraftHeroState ? (
Date: Sat, 3 Oct 2026 10:54:10 -0400 Subject: [PATCH 04/78] fix(mobile): a dev server left running no longer shows the waiting bolt (#15194) Co-authored-by: Claude Opus 5.5 (1M context) --- .../mobile/src/features/threads/ThreadDetailScreen.tsx | 3 ++- .../src/features/threads/floating-working-control.tsx | 8 +++++--- .../src/features/threads/floating-working-status.ts | 10 ++++++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 623ae734053d..8b9e061f39a2 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -474,11 +474,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } if (pendingBackgroundWork !== null && contentPresentationKind === "ready") { return { - kind: "waiting", + kind: "background", label: pendingBackgroundWork.title, accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items .map((item) => item.label) .join(", ")}`, + waiting: pendingBackgroundWork.waiting, }; } return null; diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 3538949c9d2d..95ea8ca5534a 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -385,16 +385,18 @@ function FloatingStatusLabel(props: { ); } - if (props.status.kind === "waiting") { + if (props.status.kind === "background") { return ( + {/* A dev server can run for hours after the agent is done, so only work + that will wake the agent gets the bolt. */} Date: Sat, 3 Oct 2026 10:54:44 -0400 Subject: [PATCH 05/78] fix(server): a Claude command you stop shows as interrupted (#14896) Co-authored-by: Claude Opus 5.5 (1M context) --- .../server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 6 ++++-- .../fixtures/claude_background_task_interrupt/output.ts | 4 ++-- .../fixtures/turn_interrupt_mid_tool/claude_output.ts | 2 +- .../fixtures/turn_interrupt_restart/claude_output.ts | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1aa14ae6fdd6..d437ac2a2ef5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3673,7 +3673,7 @@ export function makeClaudeAdapterV2( readonly output: ClaudeNativeToolOutput; readonly status: Extract< OrchestrationV2TurnItem["status"], - "running" | "completed" | "failed" + "running" | "completed" | "failed" | "interrupted" | "cancelled" >; readonly startedAt: DateTime.Utc; readonly updatedAt: DateTime.Utc; @@ -4740,7 +4740,9 @@ export function makeClaudeAdapterV2( parentNodeId: toolCall.parentNodeId, ordinal: toolCall.ordinal, output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, - status: "failed", + // A stopped turn cuts its open tools short; only a turn that + // ended on its own leaves them failed. + status: input.status === "completed" ? "failed" : input.status, startedAt: toolCall.startedAt, updatedAt: input.completedAt, presentation: toolCall.presentation, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts index 120f1cfc435a..c58112b37202 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts @@ -44,7 +44,7 @@ export function assertClaudeBackgroundTaskInterruptOutput( projection.turnItems.flatMap((item) => item.type === "command_execution" ? [item.status] : [], ), - ["completed", "failed"], - "the background launch completed; the interrupted foreground command did not", + ["completed", "interrupted"], + "the background launch completed; the foreground command was interrupted", ); } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts index 72884607068b..d90531499a80 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/claude_output.ts @@ -81,7 +81,7 @@ export function assertTurnInterruptMidToolClaudeOutput( assert.isDefined(commandItem); assert.isDefined(interruptRequest); assert.isDefined(interruptResult); - assert.equal(commandItem.status, "failed"); + assert.equal(commandItem.status, "interrupted"); assert.include(commandItem.input, "node -e"); assert.equal(interruptRequest.status, "completed"); assert.equal(interruptResult.status, "interrupted"); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts index 1da224a8485b..f05b470d32d8 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_restart/claude_output.ts @@ -111,7 +111,7 @@ export function assertTurnInterruptRestartClaudeOutput( assert.equal(projection.providerThreads[0]?.status, "idle"); const commandItem = projection.turnItems.find((item) => item.type === "command_execution"); assert.isDefined(commandItem); - assert.equal(commandItem.status, "failed"); + assert.equal(commandItem.status, "interrupted"); assert.include(commandItem.input, "node -e"); const outboundFrames = transcript.entries.flatMap((entry) => From 0080e80c00a3e333e3dcafd85b2615d56f84e3b8 Mon Sep 17 00:00:00 2001 From: Bob Fowler Date: Sat, 3 Oct 2026 11:45:10 -0400 Subject: [PATCH 06/78] fix(server): editors appear once a slow discovery scan finishes (#13917) --- .../src/process/externalLauncher.test.ts | 41 ++-- apps/server/src/process/externalLauncher.ts | 79 +++++--- apps/server/src/ws.test.ts | 188 +++++++++++++++++- apps/server/src/ws.ts | 110 ++++++++-- 4 files changed, 359 insertions(+), 59 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index aab4d78f4d71..9a5c8b99ce7d 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -6,6 +6,7 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -1186,26 +1187,24 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = ); }); -// A client that disconnects mid-scan interrupts the shared discovery effect on -// the connection fiber. The cache must not retain that interrupt: doing so -// replayed it to every later connect for the whole TTL, so `server.getConfig` -// failed and no client could reconnect until the server restarted. -it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => { +// Connects run discovery under a timeout and may disconnect mid-scan. Neither +// may cancel the scan: on a busy host every connect would time out partway +// through, cache nothing, and leave every client without editors. +it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { const fileInfo = { type: "File" } as FileSystem.File.Info; - let blockFirstScan = true; - let scans = 0; + const release = Deferred.makeUnsafe(); + let parkedStats = 0; const launcherLayer = ExternalLauncher.layer.pipe( Layer.provide( Layer.mergeAll( FileSystem.layerNoop({ - // The first scan parks inside `stat` so the interrupt lands while - // discovery is in flight, which is what a client disconnecting - // mid-connect does to the shared effect. + // Scans park inside `stat` until released, so the interrupt lands + // while discovery is in flight. stat: () => Effect.gen(function* () { - scans += 1; - if (blockFirstScan) { - return yield* Effect.never; + if (!Deferred.isDoneUnsafe(release)) { + parkedStats += 1; + yield* Deferred.await(release); } return fileInfo; }), @@ -1222,16 +1221,18 @@ it.effect("rescans after an interrupted discovery instead of caching the interru return Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors()); + const interrupted = yield* Effect.forkChild(launcher.resolveAvailableEditors()); yield* Effect.yieldNow; - yield* Fiber.interrupt(fiber); + yield* Fiber.interrupt(interrupted); - // The next connect must still get a real answer well inside the TTL. - blockFirstScan = false; - scans = 0; - const editors = yield* launcher.resolveAvailableEditors(); + // The next connect joins the running scan instead of starting its own. + const next = yield* Effect.forkChild(launcher.resolveAvailableEditors()); + yield* Effect.yieldNow; + assert.equal(parkedStats, 1); + + yield* Deferred.succeed(release, undefined); + const editors = yield* Fiber.join(next); assert.equal(editors.includes("vscode"), true); - assert.isAbove(scans, 0); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index ab287e4d78ee..058318ea9d55 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -28,13 +28,16 @@ import { import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -462,21 +465,21 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana // the discovered set for a bounded window so repeat connects skip even the // per-command cache lookups in @t3tools/shared/shell. // -// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the -// first caller's Exit whatever it is, including an interrupt. Callers run this -// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`), -// so one client disconnecting mid-scan would cache the interrupt and replay it -// to every later connect for the whole TTL, breaking `server.getConfig` -// permanently. Storing only on success means an interrupted scan leaves the -// cache untouched and the next connect simply rescans. +// The scan runs on its own fiber in the service scope, and every caller awaits +// that one scan. Callers apply a timeout (`resolveAvailableEditorsForConfig`) +// and disconnect mid-connect; neither may cancel a scan other connects are +// waiting on, or throw away work a slow host (a busy server at startup, a +// long PATH) needs more than one connect to finish. A failed scan clears the +// entry so the next caller starts over rather than replaying the failure. // Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the // command-resolution cache in @t3tools/shared/shell, so a backward wall-clock // adjustment cannot keep an expired entry alive. const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; interface EditorDiscoveryCacheEntry { - readonly editors: ReadonlyArray; - readonly expiresAtNanos: bigint; + readonly scan: Deferred.Deferred>; + /** Undefined while the scan is still running. */ + readonly expiresAtNanos: bigint | undefined; } /** @@ -760,27 +763,57 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); + const scope = yield* Scope.Scope; const editorDiscoveryCache = yield* Ref.make>( Option.none(), ); - const cachedAvailableEditors = Effect.gen(function* () { - const nowNanos = yield* Clock.currentTimeNanos; - const entry = yield* Ref.get(editorDiscoveryCache); - if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { - return entry.value.editors; - } - const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( + const runEditorDiscovery = (scan: Deferred.Deferred>) => + provideCommandResolutionServices(resolveAvailableEditors()).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.onExit((exit) => + Effect.gen(function* () { + const expiresAtNanos = (yield* Clock.currentTimeNanos) + EDITOR_DISCOVERY_CACHE_TTL_NANOS; + yield* Ref.update(editorDiscoveryCache, (current) => + Option.isNone(current) || current.value.scan !== scan + ? current + : Exit.isSuccess(exit) + ? Option.some({ scan, expiresAtNanos }) + : Option.none(), + ); + yield* Deferred.done(scan, exit); + }), + ), + Effect.interruptible, + Effect.forkIn(scope), ); - yield* Ref.set( + // Claiming the cache entry and starting its scan must not be split by an + // interrupt, or the entry would wait on a scan that never runs. + const acquireEditorDiscovery = Effect.gen(function* () { + const nowNanos = yield* Clock.currentTimeNanos; + const [scan, isNewScan] = yield* Ref.modify( editorDiscoveryCache, - Option.some({ - editors, - expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS, - }), + ( + current, + ): [ + [EditorDiscoveryCacheEntry["scan"], boolean], + Option.Option, + ] => { + if ( + Option.isSome(current) && + (current.value.expiresAtNanos === undefined || current.value.expiresAtNanos > nowNanos) + ) { + return [[current.value.scan, false], current]; + } + const scan = Deferred.makeUnsafe>(); + return [[scan, true], Option.some({ scan, expiresAtNanos: undefined })]; + }, ); - return editors; - }); + if (isNewScan) { + yield* runEditorDiscovery(scan); + } + return scan; + }).pipe(Effect.uninterruptible); + const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index e280f0750cef..630d64c49b6c 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,15 +1,29 @@ import { assert, it } from "@effect/vitest"; -import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; +import { + ORCHESTRATION_PROTOCOL_VERSION, + type ServerConfig, + type ServerConfigStreamEvent, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ExternalLauncher from "./process/externalLauncher.ts"; import { hasCompatibleOrchestrationProtocol, resolveAvailableEditorsForConfig, shouldUseBoundedThreadSnapshot, + withLateEditorConfig, } from "./ws.ts"; it("accepts only the current orchestration protocol before websocket RPC setup", () => { @@ -48,3 +62,175 @@ it.effect("does not block server config when editor discovery never resolves", ( assert.deepEqual(availableEditors, []); }), ); + +// Only the fields the late-editor fold reads or rewrites. +const snapshotConfig = (fields: Partial) => + ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; + +const settingsUpdated = (settings: object): ServerConfigStreamEvent => ({ + version: 1, + type: "settingsUpdated", + payload: { settings: settings as ServerConfig["settings"] }, +}); + +it.effect("resends late editors without rolling back updates already sent", () => + Effect.gen(function* () { + const settingsSent = yield* Deferred.make(); + const events = yield* withLateEditorConfig( + snapshotConfig({ settings: { enableProviderUpdateChecks: true } as never }), + Stream.make(settingsUpdated({ enableProviderUpdateChecks: false })), + { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + // Holds the late snapshot until the settings change has gone out. + resolveFileManagerRevealKind: () => + Deferred.await(settingsSent).pipe(Effect.as("file-explorer" as const)), + }, + ).pipe( + Stream.tap((event) => + event.type === "settingsUpdated" ? Deferred.succeed(settingsSent, undefined) : Effect.void, + ), + Stream.runCollect, + ); + + const [first, second] = Array.from(events); + assert.equal(events.length, 2); + assert.equal(first?.type, "settingsUpdated"); + assert.equal(second?.type, "snapshot"); + if (second?.type === "snapshot") { + assert.deepEqual(second.config.availableEditors, ["file-manager"]); + assert.equal(second.config.shellRevealInFileManagerKind, "file-explorer"); + assert.deepEqual(second.config.settings, { enableProviderUpdateChecks: false } as never); + } + }), +); + +it.effect("sends no late snapshot when the scan matches the snapshot", () => + Effect.gen(function* () { + const events = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: ["vscode"] }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed(["vscode"]), + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + }, + ).pipe(Stream.runCollect); + + assert.equal(events.length, 0); + }), +); + +it.effect("resends a file manager reveal kind that missed the snapshot", () => + Effect.gen(function* () { + const events = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: ["file-manager"] }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), + }, + ).pipe(Stream.runCollect); + + const [late] = Array.from(events); + assert.equal(events.length, 1); + assert.equal(late?.type, "snapshot"); + if (late?.type === "snapshot") { + assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); + } + }), +); + +// The real launcher on Windows over a filesystem whose probes park until +// released, like a host too busy to finish discovery inside the snapshot timeout. +const makeParkedWindowsLauncher = Effect.gen(function* () { + const parkedProbes = yield* Queue.unbounded(); + const release = yield* Deferred.make(); + const launcher = yield* ExternalLauncher.make.pipe( + Effect.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Queue.offer(parkedProbes, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ type: "File" } as FileSystem.File.Info), + ), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected spawn")), + ), + ), + ), + ); + const onWindows = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { PATH: "C:\\t3-late-editors-test", PATHEXT: ".EXE" }, + }), + ), + ), + ); + return { + editors: { + resolveAvailableEditors: () => onWindows(launcher.resolveAvailableEditors()), + resolveFileManagerRevealKind: () => onWindows(launcher.resolveFileManagerRevealKind()), + }, + probeParked: Queue.take(parkedProbes), + releaseProbes: Deferred.succeed(release, undefined), + }; +}); + +it.effect("recovers editors after a real scan outlasts the config timeout", () => + Effect.gen(function* () { + const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; + + const snapshotFiber = yield* resolveAvailableEditorsForConfig( + editors.resolveAvailableEditors(), + ).pipe(Effect.forkChild); + yield* probeParked; + yield* TestClock.adjust(Duration.seconds(5)); + const snapshotEditors = yield* Fiber.join(snapshotFiber); + assert.deepEqual(snapshotEditors, []); + + const lateFiber = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: snapshotEditors }), + Stream.empty, + editors, + ).pipe(Stream.runCollect, Effect.forkChild); + yield* releaseProbes; + + const [late] = Array.from(yield* Fiber.join(lateFiber)); + assert.equal(late?.type, "snapshot"); + if (late?.type === "snapshot") { + assert.equal(late.config.availableEditors.includes("vscode"), true); + } + }).pipe(Effect.scoped), +); + +it.effect("recovers a reveal kind whose real probe outlasts the config timeout", () => + Effect.gen(function* () { + const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; + + // The snapshot's bounded probe timed out: file manager, but no reveal kind. + const lateFiber = yield* withLateEditorConfig( + snapshotConfig({ availableEditors: ["file-manager"] }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: editors.resolveFileManagerRevealKind, + }, + ).pipe(Stream.runCollect, Effect.forkChild); + yield* probeParked; + yield* TestClock.adjust(Duration.seconds(6)); + yield* releaseProbes; + + const [late] = Array.from(yield* Fiber.join(lateFiber)); + assert.equal(late?.type, "snapshot"); + if (late?.type === "snapshot") { + assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); + } + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2a0006ddb24b..783377666f14 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -76,6 +76,8 @@ import { type RelayClientInstallProgressEvent, type ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, + type ServerConfig as ClientServerConfig, + type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, type FilesystemBrowseFailure, FilesystemBrowseError, @@ -261,6 +263,94 @@ const resolveFileManagerRevealKindForConfig = ( discovery: Effect.Effect, ) => resolveDiscoveryForConfig(discovery, () => undefined); +type EditorDiscovery = Pick< + ExternalLauncher.ExternalLauncher["Service"], + "resolveAvailableEditors" | "resolveFileManagerRevealKind" +>; + +// The config fields that follow from which editors are installed. +const resolveEditorConfig = ( + availableEditors: ReadonlyArray, + revealKind: Effect.Effect, +) => + Effect.gen(function* () { + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* revealKind + : undefined; + return { + availableEditors, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), + }; + }); + +/** + * Live config updates that follow a snapshot of `config`. A busy host can + * outlast the snapshot's discovery timeouts, which send no editors, or no + * reveal kind for the file manager. The scan keeps running, so once it lands + * this resends the config: clients replace theirs on any snapshot. The resent + * config is folded from the live updates already sent, so it cannot roll back + * a change that landed while the scan ran. + */ +export const withLateEditorConfig = ( + config: ClientServerConfig, + liveUpdates: Stream.Stream, + launcher: EditorDiscovery, +) => { + const lateEditorConfig = Stream.fromEffect(launcher.resolveAvailableEditors()).pipe( + Stream.filter( + (editors) => + editors.join() !== config.availableEditors.join() || + (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), + ), + // Unbounded, unlike the snapshot: the reveal-kind probe is not shared, so + // a timeout here would cancel a probe that outlasts it every time. + Stream.mapEffect((editors) => + resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), + ), + Stream.filter( + (editorConfig) => + editorConfig.availableEditors.join() !== config.availableEditors.join() || + editorConfig.shellRevealInFileManagerKind !== config.shellRevealInFileManagerKind, + ), + Stream.map((editorConfig) => ({ type: "editorsResolved" as const, editorConfig })), + ); + + return Stream.merge(liveUpdates, lateEditorConfig).pipe( + Stream.mapAccum( + (): ClientServerConfig => config, + (current, event): readonly [ClientServerConfig, ReadonlyArray] => { + switch (event.type) { + case "editorsResolved": { + const { + availableEditors: _editors, + shellRevealInFileManager: _reveal, + shellRevealInFileManagerKind: _revealKind, + ...rest + } = current; + const next = { ...rest, ...event.editorConfig }; + return [next, [{ version: 1, type: "snapshot", config: next }]]; + } + case "keybindingsUpdated": + return [{ ...current, ...event.payload }, [event]]; + case "providerStatuses": + return [{ ...current, providers: event.payload.providers }, [event]]; + case "settingsUpdated": + return [{ ...current, settings: event.payload.settings }, [event]]; + // Themes and usage-limit sources never ride in a snapshot; clients + // carry their projected values across one. + default: + return [current, [event]]; + } + }, + ), + ); +}; + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1620,14 +1710,10 @@ const makeWsRpcLayer = ( const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; - const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), + const editorConfig = yield* resolveEditorConfig( + yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), + resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), ); - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* resolveFileManagerRevealKindForConfig( - externalLauncher.resolveFileManagerRevealKind(), - ) - : undefined; return { environment, @@ -1637,7 +1723,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors, + ...editorConfig, // Same discovery-with-timeout treatment as editors: a slow probe // must not stall server.getConfig, so it degrades to no targets. remoteOpenTargets: yield* resolveAvailableEditorsForConfig( @@ -1659,12 +1745,6 @@ const makeWsRpcLayer = ( }, settings, shellResumeCompletionMarker: true, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), threadResumeCompletionMarker: true, threadSnapshotPagination: true, ...Option.match(scratchWorkspaceRoot, { @@ -3632,7 +3712,7 @@ const makeWsRpcLayer = ( return Stream.concat( rpcInitialItems([{ version: 1 as const, type: "snapshot" as const, config }]), - liveUpdates, + withLateEditorConfig(config, liveUpdates, externalLauncher), ); }), { "rpc.aggregate": "server" }, From aad732901e4b7d485574eaef5a9c1fb388c4291a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 08:55:10 -0700 Subject: [PATCH 07/78] fix(server): Claude threads no longer stay stuck in plan mode Claude entered itself (#15224) --- .../Adapters/ClaudeAdapterV2.test.ts | 36 ++++++ .../Adapters/ClaudeAdapterV2.testkit.ts | 13 +++ .../Adapters/ClaudeAdapterV2.ts | 107 ++++++++++++++---- 3 files changed, 131 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e5839c824219..b837575c1faf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -947,6 +947,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1097,6 +1098,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1187,6 +1189,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1406,6 +1409,7 @@ describe("ClaudeAdapterV2 attachments", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }), @@ -1544,6 +1548,7 @@ describe("ClaudeAdapterV2 attachments", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1632,6 +1637,7 @@ describe("ClaudeAdapterV2 native fork", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1803,6 +1809,7 @@ describe("ClaudeAdapterV2 native session identity", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -2058,6 +2065,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Deferred.await(processed); }); const offeredMessages: Array = []; + const permissionModeChanges: Array = []; const continuationRequests: Array = []; const terminalReceipts = yield* Queue.unbounded>(); @@ -2106,6 +2114,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: (mode) => + Effect.sync(() => { + permissionModeChanges.push(mode); + }), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, }; @@ -2158,6 +2170,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { sdkMessages, offerAndWait, offeredMessages, + permissionModeChanges, continuationRequests, events, terminalReceipts, @@ -2985,6 +2998,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), ); + // Claude entered plan mode on its own (EnterPlanMode). + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + uuid: "00000000-0000-4000-8000-000000000508", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -3095,6 +3120,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); assert.equal(proposedPlan?.status, "active"); + // The second prompt reuses the live process, which is still in the + // plan mode Claude entered, so it is put back in the thread's mode. + assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); @@ -3615,6 +3643,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // The first CLI process keeps streaming until the test ends // it, so Stop stays parked waiting for it to exit. @@ -3864,6 +3893,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(queue), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), }; @@ -6655,6 +6685,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // End this process stream so openQuery can replace it. close: Queue.shutdown(sdkMessages), @@ -6837,6 +6868,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7071,6 +7103,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7257,6 +7290,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7433,6 +7467,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7564,6 +7599,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index ca133173a9eb..39b0d44ba9ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -184,6 +184,11 @@ interface ClaudeQuerySetModelFrame { readonly model: string; } +interface ClaudeQuerySetPermissionModeFrame { + readonly type: "query.set_permission_mode"; + readonly mode: string; +} + interface ClaudeQueryInterruptFrame { readonly type: "query.interrupt"; } @@ -239,6 +244,7 @@ type ClaudeOutboundFrame = | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame + | ClaudeQuerySetPermissionModeFrame | ClaudeQueryInterruptFrame | ClaudePermissionResponseFrame | ClaudeSessionForkFrame @@ -811,6 +817,13 @@ function makeReplayQueryRunner( model, }); }), + setPermissionMode: (mode) => + replayEffect(() => { + assertNextOutboundFrame({ + type: "query.set_permission_mode", + mode, + }); + }), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d437ac2a2ef5..0aca36abe77c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -327,6 +327,9 @@ export interface ClaudeAgentSdkQuerySession { readonly messages: Stream.Stream; readonly offer: (message: SDKUserMessage) => Effect.Effect; readonly setModel: (model: string) => Effect.Effect; + readonly setPermissionMode: ( + mode: PermissionMode, + ) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -466,6 +469,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly model: string; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.set_permission_mode"; + readonly mode: PermissionMode; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -670,6 +681,22 @@ export const claudeAgentSdkQueryRunnerLiveLayer: Layer.Layer< }), ), ), + setPermissionMode: (mode) => + Effect.tryPromise({ + try: () => queryRuntime.setPermissionMode(mode), + catch: (cause) => queryRunnerError(cause, "setPermissionMode"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.set_permission_mode", + mode, + }, + }), + ), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -2688,6 +2715,11 @@ interface ClaudeLiveQueryContext { // uuid before any echo, so it echoes, but a resume's own turns can still // run ahead of that prompt. promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; + // The mode this process was opened in, and the mode the CLI last reported + // (init and status frames). Claude changes the latter itself through + // EnterPlanMode. + readonly openedPermissionMode: PermissionMode; + permissionMode: PermissionMode; // Stop, rollback or fork is closing this process; its work is ending. stopping: boolean; // Registry entries still running when this process opened. Their process @@ -6843,10 +6875,23 @@ export function makeClaudeAdapterV2( if ( existing !== null && existing.nativeThreadId === nativeThreadId && - (isClaudeProviderContinuationTurn(turnInput) || - (existing.queryPolicyKey === queryPolicyKey && - existing.selectionKey === compiledSelection.queryIdentity)) + isClaudeProviderContinuationTurn(turnInput) + ) { + return existing; + } + if ( + existing !== null && + existing.nativeThreadId === nativeThreadId && + existing.queryPolicyKey === queryPolicyKey && + existing.selectionKey === compiledSelection.queryIdentity ) { + // Claude can switch its own mode mid-session (EnterPlanMode), and + // a denied ExitPlanMode leaves it there. Put the live process back + // in the thread's mode before the next prompt. + if (existing.permissionMode !== existing.openedPermissionMode) { + yield* existing.query.setPermissionMode(existing.openedPermissionMode); + existing.permissionMode = existing.openedPermissionMode; + } return existing; } @@ -6889,31 +6934,32 @@ export function makeClaudeAdapterV2( const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + const queryOptions = makeClaudeQueryOptions({ + modelSelection: turnInput.modelSelection, + nativeThreadId, + resume: shouldResume, + ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), + cwd: turnInput.runtimePolicy.cwd, + attachmentsDir, + settings: adapterOptions.settings, + environment: adapterOptions.environment, + tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, + ...mcpOverrides, + permissionMode: queryPolicy.permissionMode, + ...(queryPolicy.allowDangerouslySkipPermissions === undefined + ? {} + : { + allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, + }), + canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], + }); const querySession = yield* queryRunner .open({ threadId: turnInput.threadId, providerSessionId: input.providerSessionId, - options: makeClaudeQueryOptions({ - modelSelection: turnInput.modelSelection, - nativeThreadId, - resume: shouldResume, - ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), - cwd: turnInput.runtimePolicy.cwd, - attachmentsDir, - settings: adapterOptions.settings, - environment: adapterOptions.environment, - tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, - ...mcpOverrides, - permissionMode: queryPolicy.permissionMode, - ...(queryPolicy.allowDangerouslySkipPermissions === undefined - ? {} - : { - allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, - }), - canUseTool, - onUserDialog, - supportedDialogKinds: ["resume_return"], - }), + options: queryOptions, }) .pipe( Effect.tapError(() => @@ -6960,6 +7006,8 @@ export function makeClaudeAdapterV2( selectionKey: compiledSelection.queryIdentity, closed, promptEchoMode: "unknown", + openedPermissionMode: queryOptions.permissionMode, + permissionMode: queryOptions.permissionMode, stopping: false, subagentsFromEarlierProcesses: new Set( [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter( @@ -6969,7 +7017,16 @@ export function makeClaudeAdapterV2( }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( - Stream.runForEach((message) => handleSdkMessage({ query: querySession, message })), + Stream.runForEach((message) => { + if ( + message.type === "system" && + (message.subtype === "init" || message.subtype === "status") && + message.permissionMode !== undefined + ) { + context.permissionMode = message.permissionMode; + } + return handleSdkMessage({ query: querySession, message }); + }), Effect.exit, Effect.flatMap( Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { From 65731f986ba2175063b0182deb6a143f8f323b74 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 3 Oct 2026 12:45:58 -0400 Subject: [PATCH 08/78] fix(mobile): show complete subagent details (#15189) --- .../src/features/threads/SubagentRow.tsx | 154 ++++++++++++++++++ .../features/threads/SubagentStatusDot.tsx | 10 +- .../features/threads/ThreadAgentsSheet.tsx | 47 +++--- .../threads/thread-subagent-group.tsx | 76 ++------- .../threads/threadAgentsPresentation.test.ts | 10 ++ .../threads/threadAgentsPresentation.ts | 14 +- .../chat/SubagentTooltipContent.tsx | 50 +----- .../src/state/subagentDisplay.test.ts | 130 ++++++++++++++- .../src/state/subagentDisplay.ts | 88 +++++++++- 9 files changed, 433 insertions(+), 146 deletions(-) create mode 100644 apps/mobile/src/features/threads/SubagentRow.tsx diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx new file mode 100644 index 000000000000..bef52c4b7c75 --- /dev/null +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -0,0 +1,154 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; +import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities"; +import { subagentCardDetail } from "./subagent-card-presentation"; +import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot"; +import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; + +type SubagentRowSubagent = Pick< + OrchestrationV2Subagent, + | "threadId" + | "childThreadId" + | "model" + | "driver" + | "providerInstanceId" + | "title" + | "prompt" + | "status" + | "progress" + | "result" +>; + +/** + * One agent as shown in the Agents sheet and transcript cards: status, model, + * workspace changes, and recent progress or result. Callers own the press + * target and pass their own elapsed timer so ticking stays isolated. + */ +export function SubagentRow(props: { + readonly environmentId: EnvironmentId; + readonly subagent: SubagentRowSubagent; + readonly elapsed: ReactNode; +}) { + const presentation = resolveSubagentRowPresentation(props.subagent); + const detail = subagentCardDetail(presentation.detail); + return ( + + + + + + + + + {presentation.title} + + + · + + + {presentation.statusLabel} + + + {props.elapsed} + {presentation.canOpenThread ? ( + + ) : null} + + + {detail ? ( + + {detail} + + ) : null} + + + ); +} + +/** Uses shell data already held by the client, without loading child transcripts. */ +function SubagentMetadata(props: { + readonly environmentId: EnvironmentId; + readonly subagent: SubagentRowSubagent; +}) { + const { environmentId, subagent } = props; + const config = useEnvironmentServerConfig(environmentId); + const provider = config?.providers.find( + (candidate) => candidate.instanceId === subagent.providerInstanceId, + ); + const parent = useThreadShell(scopeThreadRef(environmentId, subagent.threadId))?.source; + const child = useThreadShell( + subagent.childThreadId === null ? null : scopeThreadRef(environmentId, subagent.childThreadId), + )?.source; + const parentProject = useProject( + parent ? scopeProjectRef(environmentId, parent.projectId) : null, + ); + const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null); + const { modelLabel, workspace } = resolveSubagentMetadata({ + model: subagent.model, + provider, + parentThread: parent, + childThread: child, + parentProject, + childProject, + }); + return ( + + + + {provider?.displayName ? `${provider.displayName} · ` : ""} + {modelLabel} + + {workspace.map(({ label, value }) => ( + // The row reads this label in place of the icon. collapsable keeps the + // view (and label) from being flattened away without making it a + // separate accessibility stop. + + · + + + {value} + + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/SubagentStatusDot.tsx b/apps/mobile/src/features/threads/SubagentStatusDot.tsx index a08355effb50..348f6e534b6c 100644 --- a/apps/mobile/src/features/threads/SubagentStatusDot.tsx +++ b/apps/mobile/src/features/threads/SubagentStatusDot.tsx @@ -10,19 +10,25 @@ const TONE_CLASS = { stopped: "bg-foreground-muted", } as const satisfies Record; +export const SUBAGENT_TONE_TEXT_CLASS = { + working: "text-adaptive-sky-600-400", + completed: "text-adaptive-emerald-600-400", + failed: "text-adaptive-rose-600-400", + stopped: "text-foreground-muted", +} as const satisfies Record; + export function SubagentStatusDot({ tone, placement = "inline", }: { readonly tone: SubagentRowTone; - readonly placement?: "inline" | "provider" | "sheet"; + readonly placement?: "inline" | "sheet"; }) { return ( diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index 7d7c825aaa08..1d082515172d 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -17,13 +17,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { environmentThreadDetails } from "../../state/threads"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; - -import { SubagentStatusDot } from "./SubagentStatusDot"; +import { SubagentRow } from "./SubagentRow"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -72,6 +69,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { @@ -121,32 +119,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { } function AgentRow(props: { + readonly environmentId: EnvironmentId; readonly subagent: OrchestrationV2Subagent; readonly tickSeconds: boolean; readonly onOpen: (childThreadId: ThreadId) => void; }) { const { subagent } = props; - const presentation = resolveSubagentRowPresentation(subagent); const childThreadId = subagent.childThreadId; - const elapsed = useSubagentElapsed(subagent, props.tickSeconds); const row = ( - - - - - {presentation.title} - - - {presentation.detail ?? presentation.statusLabel} - - - {elapsed === null ? null : ( - {elapsed} - )} - {presentation.canOpenThread ? ( - - ) : null} + + } + /> ); @@ -154,7 +141,6 @@ function AgentRow(props: { return ( {row} @@ -165,7 +151,6 @@ function AgentRow(props: { return ( props.onOpen(childThreadId)} className="active:opacity-70" @@ -175,9 +160,19 @@ function AgentRow(props: { ); } +function AgentElapsed(props: { + readonly subagent: OrchestrationV2Subagent; + readonly tickSeconds: boolean; +}) { + const elapsed = useSubagentElapsed(props.subagent, props.tickSeconds); + return elapsed === null ? null : ( + {elapsed} + ); +} + /** - * Elapsed time for one agent. Only a roster with live work subscribes to the - * shared second tick, so a settled sheet never repaints. + * Elapsed time for one agent. Only live work ticks, inside AgentElapsed, + * so the timer never repaints the metadata or a settled sheet. */ function useSubagentElapsed( subagent: Pick, diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index 0c51c6195b75..c0f1adf4736e 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -2,10 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useIsFocused, useNavigation } from "@react-navigation/native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; -import { - isActiveSubagentStatus, - isTerminalSubagentStatus, -} from "@t3tools/client-runtime/state/subagentRuntime"; +import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { EnvironmentId, OrchestrationV2Subagent, @@ -21,9 +18,8 @@ import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { serverEnvironment } from "../../state/server"; import { environmentThreadDetails } from "../../state/threads"; -import { SubagentStatusDot } from "./SubagentStatusDot"; -import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation"; -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; +import { subagentCardElapsed } from "./subagent-card-presentation"; +import { SubagentRow } from "./SubagentRow"; import { WorkLogBlock } from "./work-log-layout"; type SubagentItem = Extract; @@ -47,27 +43,20 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray{elapsed} + {elapsed} ) : null; } function SubagentAvatar(props: { readonly item: SubagentItem; readonly iconUrl?: string | null | undefined; - readonly status?: OrchestrationV2Subagent["status"]; }) { return ( - {props.status ? ( - - ) : null} ); } @@ -99,6 +88,7 @@ export function ThreadSubagentGroup(props: { completedAt: live?.completedAt ?? item.completedAt, result: live?.result ?? item.result, progress: live?.progress ?? item.progress, + model: live?.model ?? null, }; }); const grouped = agents.length > 1; @@ -157,19 +147,12 @@ export function ThreadSubagentGroup(props: { {!grouped || expanded ? ( {agents.map((agent) => { - const presentation = resolveSubagentRowPresentation(agent); - const detail = subagentCardDetail( - isTerminalSubagentStatus(agent.status) - ? agent.result?.trim() || agent.progress || null - : agent.progress?.trim() || agent.result, - ); const threadId = agent.childThreadId; return ( - } /> - - - - {presentation.title} - - {detail && agent.status !== "completed" ? ( - - {presentation.statusLabel} - - ) : null} - - - {detail ?? presentation.statusLabel} - - - - {threadId !== null ? ( - - ) : null} ); })} diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts index bf095f541873..65c12910cea7 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts @@ -65,4 +65,14 @@ describe("resolveSubagentRowPresentation", () => { expect(row.detail).toBeNull(); expect(row.statusLabel).toBe("Working"); }); + + it("bounds long result previews without dropping the agent's status", () => { + const row = resolveSubagentRowPresentation({ + ...base, + status: "failed", + result: "x".repeat(400), + }); + expect(row.detail).toBe(`${"x".repeat(280)}…`); + expect(row.statusLabel).toBe("Failed"); + }); }); diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index d1b85b896470..9605891143b4 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -1,8 +1,8 @@ -import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { - isActiveSubagentStatus, - isTerminalSubagentStatus, -} from "@t3tools/client-runtime/state/subagentRuntime"; + formatSubagentDisplayTitle, + subagentDetailPreview, +} from "@t3tools/client-runtime/state/subagent-display"; +import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { OrchestrationV2Subagent } from "@t3tools/contracts"; const PROMPT_TITLE_LIMIT = 80; @@ -64,13 +64,9 @@ export function resolveSubagentRowPresentation( >, ): SubagentRowPresentation { const live = isActiveSubagentStatus(subagent.status); - const progress = subagent.progress?.trim() ?? ""; - const result = subagent.result?.trim() ?? ""; - const settled = isTerminalSubagentStatus(subagent.status); - const detail = settled ? result || progress : progress || result; return { title: rowTitle(subagent), - detail: detail.length > 0 ? detail.replace(/\s+/gu, " ") : null, + detail: subagentDetailPreview(subagent), statusLabel: rowStatusLabel(subagent.status), tone: rowTone(subagent.status), live, diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 49c382ba639f..d638736759a4 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -1,12 +1,14 @@ import type { OrchestrationV2ThreadShell, + OrchestrationV2TurnItemStatus, OrchestrationProjectShell, ServerProvider, ProviderDriverKind, } from "@t3tools/contracts"; -import { fileBasename } from "@t3tools/client-runtime/markdown-links"; -import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; -import { getTriggerDisplayModelName } from "./providerIconUtils"; +import { + resolveSubagentMetadata, + subagentDetailPreview, +} from "@t3tools/client-runtime/state/subagent-display"; import type { ReactNode } from "react"; import { BotIcon, @@ -33,48 +35,12 @@ export function SubagentTooltipContent(props: { childThread?: Pick | undefined; parentProject?: Pick | undefined; childProject?: Pick | undefined; - status: string; + status: OrchestrationV2TurnItemStatus; result?: string | null | undefined; progress?: string | null | undefined; }) { - const model = props.model?.trim(); - const modelSlug = props.provider - ? resolveSelectableModel(props.provider.driver, model, props.provider.models) - : model; - const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); - const modelLabel = providerModel - ? getTriggerDisplayModelName(providerModel) - : model - ? formatModelSlugName(model) - : "Not reported"; - const currentWorkspace = props.parentThread?.worktreePath ?? props.parentProject?.workspaceRoot; - const childWorkspace = props.childThread?.worktreePath ?? props.childProject?.workspaceRoot; - const metadata = [ - ...(props.parentThread && - props.childProject && - props.childProject.id !== props.parentThread.projectId - ? [{ label: "Project", value: props.childProject.title }] - : []), - ...(currentWorkspace && childWorkspace && currentWorkspace !== childWorkspace - ? [ - { - label: props.childThread?.branch - ? "Branch" - : props.childThread?.worktreePath - ? "Worktree" - : "Workspace", - value: props.childThread?.branch ?? fileBasename(childWorkspace), - }, - ] - : []), - ]; - const settled = ["completed", "failed", "cancelled", "interrupted"].includes(props.status); - const result = props.result?.trim(); - const progress = props.progress?.trim(); - const detail = (settled ? result || progress : progress || result) || ""; - const compactDetail = detail.trim().replace(/\s+/g, " "); - const preview = - compactDetail.length > 280 ? `${compactDetail.slice(0, 280).trimEnd()}…` : compactDetail; + const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props); + const preview = subagentDetailPreview(props); const driver = props.provider?.driver ?? props.driver; const working = ["running", "in_progress", "pending", "waiting"].includes(props.status); const failed = ["failed", "error"].includes(props.status); diff --git a/packages/client-runtime/src/state/subagentDisplay.test.ts b/packages/client-runtime/src/state/subagentDisplay.test.ts index 11184d369469..3485cd4f2da2 100644 --- a/packages/client-runtime/src/state/subagentDisplay.test.ts +++ b/packages/client-runtime/src/state/subagentDisplay.test.ts @@ -1,6 +1,11 @@ +import { ProjectId, ProviderDriverKind } from "@t3tools/contracts"; import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { subagentGroupSummary } from "./subagentDisplay.js"; +import { + subagentGroupSummary, + resolveSubagentMetadata, + subagentDetailPreview, +} from "./subagentDisplay.js"; describe("subagentGroupSummary", () => { it.each(["pending", "running", "waiting"] as const)( @@ -36,3 +41,126 @@ describe("subagentGroupSummary", () => { }); }); }); + +describe("resolveSubagentMetadata", () => { + it("resolves provider aliases to catalog names, including custom models", () => { + expect( + resolveSubagentMetadata({ + model: "claude-haiku-4-5-20251001", + provider: { + driver: ProviderDriverKind.make("claudeAgent"), + models: [ + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + shortName: "Haiku 4.5", + aliases: ["claude-haiku-4-5-20251001"], + isCustom: false, + capabilities: null, + }, + ], + }, + }).modelLabel, + ).toBe("Haiku 4.5"); + expect( + resolveSubagentMetadata({ + model: "my-model", + provider: { + driver: ProviderDriverKind.make("acpRegistry"), + models: [ + { + slug: "my-model", + name: "Cloud+ / My custom model", + subProvider: "Cloud+", + isCustom: true, + capabilities: null, + }, + ], + }, + }).modelLabel, + ).toBe("My custom model"); + }); + + it("keeps unknown model identities and does not invent an unreported model", () => { + expect(resolveSubagentMetadata({ model: " custom/model " }).modelLabel).toBe("custom/model"); + expect( + resolveSubagentMetadata({ + model: null, + provider: { driver: ProviderDriverKind.make("codex"), models: [] }, + }).modelLabel, + ).toBe("Not reported"); + expect(resolveSubagentMetadata({ model: " " }).modelLabel).toBe("Not reported"); + }); + + const parentThread = { projectId: ProjectId.make("parent"), worktreePath: null }; + const parentProject = { workspaceRoot: "/repo" }; + + it("shows another project and its branch when the child has a different workspace", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: "fix/agents", worktreePath: "/worktrees/agents" }, + childProject: { + id: ProjectId.make("child"), + title: "Other project", + workspaceRoot: "/other", + }, + }).workspace, + ).toEqual([ + { label: "Project", value: "Other project" }, + { label: "Branch", value: "fix/agents" }, + ]); + }); + + it("labels a detached worktree or another project workspace without a branch", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: null, worktreePath: "/worktrees/agents" }, + }).workspace, + ).toEqual([{ label: "Worktree", value: "agents" }]); + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childProject: { + id: parentThread.projectId, + title: "Same project", + workspaceRoot: "/other", + }, + }).workspace, + ).toEqual([{ label: "Workspace", value: "other" }]); + }); + + it("hides redundant workspace metadata and tolerates unavailable child shells", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: "main", worktreePath: "/repo" }, + childProject: { id: parentThread.projectId, title: "Same project", workspaceRoot: "/repo" }, + }).workspace, + ).toEqual([]); + expect(resolveSubagentMetadata({ model: null, parentThread, parentProject }).workspace).toEqual( + [], + ); + }); +}); + +describe("subagentDetailPreview", () => { + it("prefers progress for live work and results for settled work", () => { + const details = { progress: "Reading files", result: "Found two\n problems" }; + expect(subagentDetailPreview({ ...details, status: "running" })).toBe("Reading files"); + expect(subagentDetailPreview({ ...details, status: "completed" })).toBe("Found two problems"); + expect( + subagentDetailPreview({ status: "failed", progress: "Last progress", result: " " }), + ).toBe("Last progress"); + expect(subagentDetailPreview({ status: "pending" })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index 4d1d2c5cd15d..0a456e7a2ddc 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -1,4 +1,12 @@ -import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; +import type { + OrchestrationV2TurnItemStatus, + OrchestrationV2ThreadShell, + OrchestrationProjectShell, + ServerProvider, +} from "@t3tools/contracts"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; +import { fileBasename } from "../markdownLinks.ts"; +import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; /** Summarizes one adjacent group, without changing its member identities or order. */ export function subagentGroupSummary( @@ -44,3 +52,81 @@ export function formatSubagentDisplayTitle(title: string): string { const name = path[1]!.replace(/[_\s]+/gu, " ").trim(); return name.replace(/(^|\s)\S/gu, (letter) => letter.toUpperCase()) || displayTitle; } + +/** Match desktop's model resolution and show only changes from the parent's workspace. */ +export function resolveSubagentMetadata(input: { + readonly model: string | null; + readonly provider?: Pick | null | undefined; + readonly parentThread?: + | Pick + | null + | undefined; + readonly childThread?: + | Pick + | null + | undefined; + readonly parentProject?: Pick | null | undefined; + readonly childProject?: + | Pick + | null + | undefined; +}) { + const model = input.model?.trim(); + const slug = input.provider + ? resolveSelectableModel(input.provider.driver, model, input.provider.models) + : model; + const catalogModel = input.provider?.models.find((candidate) => candidate.slug === slug); + const reportedLabel = catalogModel + ? catalogModel.shortName || catalogModel.name + : model + ? formatModelSlugName(model) + : "Not reported"; + const qualifier = catalogModel?.subProvider?.trim(); + const modelLabel = qualifier + ? reportedLabel + .replace( + new RegExp( + `^${qualifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s*[.:/-]\\s*|\\s+)`, + "iu", + ), + "", + ) + .trim() || reportedLabel + : reportedLabel; + const parentWorkspace = input.parentThread?.worktreePath ?? input.parentProject?.workspaceRoot; + const childWorkspace = input.childThread?.worktreePath ?? input.childProject?.workspaceRoot; + const workspace = [ + ...(input.parentThread && + input.childProject && + input.childProject.id !== input.parentThread.projectId + ? [{ label: "Project", value: input.childProject.title }] + : []), + ...(parentWorkspace && childWorkspace && parentWorkspace !== childWorkspace + ? [ + { + label: input.childThread?.branch + ? "Branch" + : input.childThread?.worktreePath + ? "Worktree" + : "Workspace", + value: input.childThread?.branch ?? fileBasename(childWorkspace), + }, + ] + : []), + ]; + return { modelLabel, workspace }; +} + +/** Live work leads with progress; settled work leads with its result. */ +export function subagentDetailPreview(input: { + readonly status: OrchestrationV2TurnItemStatus; + readonly result?: string | null | undefined; + readonly progress?: string | null | undefined; +}): string | null { + const result = input.result?.trim(); + const progress = input.progress?.trim(); + const detail = + (isTerminalSubagentStatus(input.status) ? result || progress : progress || result) || ""; + const compact = detail.replace(/\s+/gu, " "); + return compact.length > 280 ? `${compact.slice(0, 280).trimEnd()}…` : compact || null; +} From 77823bd102ae50430d4acda9a553e5743d2aa5ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 10:24:23 -0700 Subject: [PATCH 09/78] fix(mobile): an expired Live Activity no longer leaves a second card (#15254) Co-authored-by: Claude Opus 5.5 (1M context) --- patches/expo-widgets@58.0.11.patch | 29 ++++++++++++++++++++++++----- pnpm-lock.yaml | 8 ++++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/patches/expo-widgets@58.0.11.patch b/patches/expo-widgets@58.0.11.patch index f2632f180c84..cb8aef9f06dd 100644 --- a/patches/expo-widgets@58.0.11.patch +++ b/patches/expo-widgets@58.0.11.patch @@ -1,5 +1,5 @@ diff --git a/build/Widgets.types.d.ts b/build/Widgets.types.d.ts -index c9f4dfe..dc0792e 100644 +index c9f4dfe1f10a3a65ab72bd7e639f6f6d148e5f95..dc0792e1cd7b6762394de5e3fefa0863a253f70c 100644 --- a/build/Widgets.types.d.ts +++ b/build/Widgets.types.d.ts @@ -93,6 +93,10 @@ export type LiveActivityEnvironment = { @@ -13,8 +13,27 @@ index c9f4dfe..dc0792e 100644 /** * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ +diff --git a/ios/LiveActivityFactory.swift b/ios/LiveActivityFactory.swift +index 29e4f1566d188665e301d32bc1ad85640f83e68c..7143f9a1c06245d16649b981d7f5cde1d8868217 100644 +--- a/ios/LiveActivityFactory.swift ++++ b/ios/LiveActivityFactory.swift +@@ -19,6 +19,14 @@ final class LiveActivityFactory: SharedObject { + throw LiveActivitiesNotSupportedException() + } + ++ // iOS ends an activity after 8 hours but leaves it frozen on the Lock Screen for up to ++ // 4 more. getInstances() can't see it, so a caller starting a replacement would leave ++ // both cards visible. Dismiss the ended ones now. ++ for activity in Activity.activities ++ where activity.content.state.name == name && activity.activityState == .ended { ++ Task { await activity.end(nil, dismissalPolicy: .immediate) } ++ } ++ + do { + let initialState = LiveActivityAttributes.ContentState(name: name, props: props) + let activity = try Activity.request( diff --git a/ios/Widgets/Utils.swift b/ios/Widgets/Utils.swift -index 76b2199..6cb52c3 100644 +index 76b2199c2d3f879463fed5d2bd49038b0428a2e7..6cb52c3de4ad9696bc0c51ad62401cce96f405e7 100644 --- a/ios/Widgets/Utils.swift +++ b/ios/Widgets/Utils.swift @@ -125,6 +125,10 @@ func getLiveActivityEnvironment(for environment: EnvironmentValues, in context: @@ -29,7 +48,7 @@ index 76b2199..6cb52c3 100644 env["isActivityUpdateReduced"] = environment.isActivityUpdateReduced env["activityFamily"] = "\(environment.activityFamily)" diff --git a/ios/Widgets/WidgetLiveActivity.swift b/ios/Widgets/WidgetLiveActivity.swift -index 4d135c4..dae4744 100644 +index 4d135c4e4eb1f846315654ed9d835a935d120d42..dae47445b27516fa2464a25b68bddc8f6dec3e1e 100644 --- a/ios/Widgets/WidgetLiveActivity.swift +++ b/ios/Widgets/WidgetLiveActivity.swift @@ -19,53 +19,41 @@ struct LiveActivityAttributes: ActivityAttributes { @@ -132,7 +151,7 @@ index 4d135c4..dae4744 100644 LiveActivityBanner(context: context, nodes: nodes) } else if let node = nodes["banner"] as? [String: Any] { diff --git a/scripts/build-layout-registry.mjs b/scripts/build-layout-registry.mjs -index d52a60e..fa9bd77 100644 +index d52a60e342c8e0a8a1d78f7174da5e60e6bd1c53..fa9bd77c5c04d459038a5b4a00e1391780b406fa 100644 --- a/scripts/build-layout-registry.mjs +++ b/scripts/build-layout-registry.mjs @@ -154,6 +154,8 @@ function createLayoutRegistry(expectedWidgets, capturedLayouts) { @@ -146,7 +165,7 @@ index d52a60e..fa9bd77 100644 await main(); } diff --git a/src/Widgets.types.ts b/src/Widgets.types.ts -index 0770613..924fa00 100644 +index 07706139379384dae27befef4e3e5ae09d04a0db..924fa0029243664e369ce08c81e504b6300bfc7f 100644 --- a/src/Widgets.types.ts +++ b/src/Widgets.types.ts @@ -107,6 +107,10 @@ export type LiveActivityEnvironment = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f21be6151710..8315c8a24db9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,7 +108,7 @@ patchedDependencies: expo-blur@58.0.3: d0311b0c0b05bd94393bd9a84653cb42503d5f3c8a19eb2627c34ace08bedf18 expo-glass-effect@58.0.3: f750041c45ab838a3530451dc7b50d6640a79141037a1b0499c5e3412a860c57 expo-sharing@58.0.13: fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6 - expo-widgets@58.0.11: 94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b + expo-widgets@58.0.11: d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 react-native-gesture-handler@3.2.1: d518a937e3cb2df5e7d67f1ab6b265c74a8639e4572d1b032b7b747e9b1c01a9 react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d @@ -431,7 +431,7 @@ importers: version: 58.0.4(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) expo-widgets: specifier: 58.0.11 - version: 58.0.11(patch_hash=94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react: specifier: 19.3.0 version: 19.3.0 @@ -16305,7 +16305,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) - expo-widgets: 58.0.11(patch_hash=94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + expo-widgets: 58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -17535,7 +17535,7 @@ snapshots: expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - expo-widgets@58.0.11(patch_hash=94de7964c5a67ab40e6b79d9bdf4b7bfc50ae97b70722ee8e5a35105ca83386b)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + expo-widgets@58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: '@expo/plist': 0.10.1 '@expo/ui': 58.0.11(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) From cfdff56f7b3c9c931ae368224041aa14825649fd Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:29:18 -0300 Subject: [PATCH 10/78] test(web): remove redundant thread sort fallback tests (#15095) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- apps/web/src/lib/threadSort.test.ts | 50 ----------------------------- 1 file changed, 50 deletions(-) diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index 9ae3c3386cd3..0d48fbe3e136 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -117,56 +117,6 @@ describe("sortThreads", () => { ]); }); - it("falls back to createdAt when updatedAt is invalid", () => { - const sorted = sortThreads( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "invalid-date" as never, - messages: [], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "2026-03-09T09:00:00.000Z", - updatedAt: "2026-03-09T09:30:00.000Z", - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ]); - }); - - it("falls back to id ordering when threads have no sortable timestamps", () => { - const sorted = sortThreads( - [ - makeThread({ - id: ThreadId.make("thread-1"), - createdAt: "invalid-created-at" as never, - updatedAt: "invalid-updated-at" as never, - messages: [], - }), - makeThread({ - id: ThreadId.make("thread-2"), - createdAt: "invalid-created-at" as never, - updatedAt: "invalid-updated-at" as never, - messages: [], - }), - ], - "updated_at", - ); - - expect(sorted.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-2"), - ThreadId.make("thread-1"), - ]); - }); - it("can sort threads by createdAt when configured", () => { const sorted = sortThreads( [ From 70e5a40c20ddbaab7831ab36e17e64c9908269a5 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 3 Oct 2026 16:41:56 -0300 Subject: [PATCH 11/78] fix(web): thinking row after a failed tool expands the run's tool calls (#15056) --- .../chat/MessagesTimeline.logic.test.ts | 15 +++++++ .../components/chat/MessagesTimeline.logic.ts | 42 ++++++++++++++++--- .../src/components/chat/MessagesTimeline.tsx | 25 +++++++---- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index f87cb75b34b8..173a964d5efc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2684,8 +2684,10 @@ describe("deriveMessagesTimelineRows", () => { it("reuses one activity row for initial thinking and the latest tool", () => { const deriveRows = ( toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null, + expandedWorkGroupIds?: ReadonlySet, ) => deriveMessagesTimelineRows({ + ...(expandedWorkGroupIds ? { expandedWorkGroupIds } : {}), timelineEntries: toolLifecycleStatus === null ? [] @@ -2733,6 +2735,19 @@ describe("deriveMessagesTimelineRows", () => { expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); expect(failedRows.some((row) => row.kind === "work-live")).toBe(false); expect(failedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); + const failedThinkingRow = failedRows.at(-1); + const failedGroupId = + failedThinkingRow?.kind === "thinking" ? failedThinkingRow.groupId : undefined; + expect(failedGroupId).toBeDefined(); + const expandedFailedRows = deriveRows("failed", new Set([failedGroupId!])); + expect(expandedFailedRows.slice(-2)).toMatchObject([ + { kind: "thinking", id: "live-activity-row", expanded: true }, + { + kind: "work", + isExpandedToolGroup: true, + groupedEntries: [{ id: "latest-command" }], + }, + ]); expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 09784aaf9dbd..280cc3672103 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -509,6 +509,9 @@ type MessagesTimelineRowContent = kind: "thinking"; id: string; createdAt: string | null; + /** Tool calls this row stands in for after the latest one failed. */ + groupId?: string; + expanded?: boolean; } | { kind: "work-toggle"; @@ -1737,11 +1740,35 @@ export function deriveMessagesTimelineRows(input: { !hasActiveCompaction && (!hasActivityRow || latestToolFailed) ) { - nextRows.push({ - kind: "thinking", - id: LIVE_ACTIVITY_ROW_ID, - createdAt: input.activeTurnStartedAt ?? null, - }); + // A failed latest tool hands the row back to thinking, but its group + // stays reachable through the same disclosure the live row offers. + const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined; + if (failedGroupAnchor) { + const groupId = workGroupId(failedGroupAnchor.id); + const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; + nextRows.push({ + kind: "thinking", + id: LIVE_ACTIVITY_ROW_ID, + createdAt: input.activeTurnStartedAt ?? null, + groupId, + expanded, + }); + if (expanded) { + nextRows.push( + expandedWorkGroupRow( + groupId, + failedGroupAnchor.createdAt, + visibleActiveToolEntries.map((entry) => entry.entry), + ), + ); + } + } else { + nextRows.push({ + kind: "thinking", + id: LIVE_ACTIVITY_ROW_ID, + createdAt: input.activeTurnStartedAt ?? null, + }); + } } const result = attachTrailingToolGroupsToAssistant( @@ -1952,8 +1979,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - case "thinking": return a.createdAt === (b as typeof a).createdAt; + case "thinking": { + const bt = b as typeof a; + return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded; + } case "worktree-setup": return a.snapshot === (b as typeof a).snapshot; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b5113b5f41a3..8b2808b3a18d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1741,7 +1741,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "work-toggle" || row.kind === "thinking"; const isExpandedToolGroupHeader = - (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded); + (row.kind === "work-toggle" || row.kind === "work-live" || row.kind === "thinking") && + row.expanded === true; return (
: null} {row.kind === "work-toggle" ? : null} - {row.kind === "thinking" ? : null} + {row.kind === "thinking" ? : null} ) : null} {row.kind === "turn-fold" ? : null} @@ -3461,13 +3462,23 @@ function CompactingLabel() { ); } -function ThinkingTimelineRow() { +function ThinkingTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); // Reserve the activity row during setup so the handoff keeps the same height. - return isPreparingWorktree || isCompacting ? ( - - ) : ( - + if (isPreparingWorktree || isCompacting) return ; + const activity = ; + const { groupId } = row; + if (groupId === undefined) return activity; + return ( + ); } From 39efcd855833a1486ff7662e2efcedeeee6be0cc Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:58:09 -0700 Subject: [PATCH 12/78] perf(web): DOM changes no longer restyle the whole page (#15265) --- apps/web/src/components/chat/ComposerBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index 78753c36097b..cbcb58897157 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -111,7 +111,7 @@ function Attachment({ className, ...props }: ComponentProps<"div">) { "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]", // Adjacent attachments share their outline, including notices outside the form. "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0", - "[&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", + "[&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", className, )} {...props} From 71dbaf1f94b580491d033f2e347a9b1c507fd793 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 13:03:09 -0700 Subject: [PATCH 13/78] perf(usage): cut warm usage scans from seconds to milliseconds on large histories (#15149) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/usage/UsageService.test.ts | 187 ++++++++++++++++++ apps/server/src/usage/UsageService.ts | 169 +++++++++++----- .../server/src/usage/usageAggregation.test.ts | 35 ++++ apps/server/src/usage/usageAggregation.ts | 116 ++++++++--- apps/server/src/usage/usagePricing.ts | 20 ++ apps/server/src/usage/usageScanCache.test.ts | 21 ++ apps/server/src/usage/usageScanCache.ts | 111 ++++++++--- .../server/src/usage/usageTranscriptReader.ts | 41 ++-- apps/server/src/usage/usageTranscripts.ts | 10 - 9 files changed, 579 insertions(+), 131 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 127679d0a449..62345d8472e1 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real // transcript trees on disk, outside the service's Effect FileSystem. +import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -117,6 +118,41 @@ const serviceLayers = (input: { ), ); +/** Outside `NARROW_WINDOW`, inside `WINDOW`. Seconds, as `utimes` takes them. */ +const BEFORE_NARROW_WINDOW = Date.parse("2026-08-01T10:00:00Z") / 1000; +const NARROW_WINDOW: UsageSummaryInput = { + timeZone: "UTC", + sinceDay: UsageDay.make("2026-09-01"), + untilDay: UsageDay.make("2026-09-02"), +}; + +/** + * A FIFO named like a transcript. A scan's read of it waits in `open` until + * `openGate`, then fails at once, so a gate holds that scan's directory reads + * in flight. Each waiting gate holds one libuv pool thread; keep at most three. + */ +const makeGate = (path: string, lastWriteSeconds?: number) => + Effect.promise(async () => { + NodeChildProcess.execFileSync("mkfifo", [path]); + if (lastWriteSeconds !== undefined) { + await NodeFSP.utimes(path, lastWriteSeconds, lastWriteSeconds); + } + }); + +/** + * Returns once a scan has opened the gate. A scan opens every file of a + * directory at once, so it then holds the directory's transcripts open too. + */ +const openGate = (path: string) => + Effect.promise(async () => (await NodeFSP.open(path, "w")).close()); + +/** Replaces a file by rename, so a scan holding the old one keeps reading it. */ +const replaceFile = (path: string, content: string) => + Effect.promise(async () => { + await NodeFSP.writeFile(path + ".next", content); + await NodeFSP.rename(path + ".next", path); + }); + function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); } @@ -972,6 +1008,157 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("credits the same copy of a duplicate after its transcripts are deleted", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const dir = NodePath.dirname(transcript); + // The walk-first file is the original, padded so it finishes parsing + // after the small fork copy that repeats its record under a new session. + const [first = "", second = ""] = yield* Effect.promise(async () => { + await NodeFSP.writeFile(NodePath.join(dir, "a.jsonl"), ""); + await NodeFSP.writeFile(NodePath.join(dir, "b.jsonl"), ""); + return (await NodeFSP.readdir(dir)).map((name) => NodePath.join(dir, name)); + }); + const forked = (line: string) => line.replace('"session-1"', '"session-2"'); + yield* Effect.promise(async () => { + await NodeFSP.writeFile( + first, + claudeLine(1, 5).replace( + '"message":', + '"padding":' + encodeUnknownJsonString("x".repeat(9 * 1024 * 1024)) + ',"message":', + ), + ); + await NodeFSP.writeFile(second, forked(claudeLine(1, 5)) + forked(claudeLine(2, 7))); + }); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const live = yield* service.readSummary(WINDOW); + assert.strictEqual(live.buckets[0]?.sessions, 2); + + yield* Effect.promise(() => Promise.all([NodeFSP.rm(first), NodeFSP.rm(second)])); + const saved = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(saved.buckets, live.buckets); + assert.deepStrictEqual(saved.sources, live.sources); + const restored = yield* (yield* UsageService.make).readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, live.buckets); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-copy-order-test", home, settings })), + ); + }).pipe(Effect.scoped), + ); + + it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "keeps a newer cached read when a slower scan of another window finishes later", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const dir = NodePath.dirname(transcript); + const probe = NodePath.join(dir, "probe.jsonl"); + const hold = NodePath.join(dir, "hold.jsonl"); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + yield* makeGate(probe, BEFORE_NARROW_WINDOW); + yield* makeGate(hold, BEFORE_NARROW_WINDOW); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* openGate(probe); + yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); + // The narrow scan caches the newer read while the wide one waits. + yield* service.readSummary(NARROW_WINDOW); + yield* openGate(hold); + yield* Fiber.join(wide); + + yield* Effect.promise(() => + Promise.all([transcript, probe, hold].map((path) => NodeFSP.rm(path))), + ); + assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-stale-read-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "keeps the later read when a scan that read earlier finishes first", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const dir = NodePath.dirname(transcript); + const gate = (name: string) => NodePath.join(dir, `${name}.jsonl`); + const wideProbe = gate("wide-probe"); + const wideHold = gate("wide-hold"); + const narrowProbe = gate("narrow-probe"); + const narrowHold = gate("narrow-hold"); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + yield* makeGate(wideProbe, BEFORE_NARROW_WINDOW); + yield* makeGate(wideHold, BEFORE_NARROW_WINDOW); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* openGate(wideProbe); + yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); + // Made after the wide scan's walk, so only the narrow scan waits on them. + yield* makeGate(narrowProbe); + yield* makeGate(narrowHold); + const narrow = yield* service.readSummary(NARROW_WINDOW).pipe(Effect.forkChild); + yield* openGate(narrowProbe); + // Both scans started from an empty cache entry; the earlier read lands first. + yield* openGate(wideHold); + yield* Fiber.join(wide); + yield* openGate(narrowHold); + yield* Fiber.join(narrow); + + yield* Effect.promise(() => + Promise.all( + [transcript, wideProbe, wideHold, narrowProbe, narrowHold].map((path) => + NodeFSP.rm(path), + ), + ), + ); + assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-late-read-test", home, settings })), + ); + }).pipe(Effect.scoped), + ); + + it.live("reports saved usage of a removed directory only for windows it reaches", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, claudeLine(1, 5)); + const lastWrite = Date.parse("2026-08-01T10:00:00Z") / 1000; + await NodeFSP.utimes(transcript, lastWrite, lastWrite); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-saved-window-test", home, settings }), + ), + ); + const first = yield* service.readSummary(WINDOW); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), + ); + + const reached = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(reached.buckets, first.buckets); + assert.strictEqual(reached.sources[0]?.status, "ok"); + + // A missing source cannot claim this directory from another environment + // that still reads it, so it adds nothing to a window after its last write. + const later = yield* service.readSummary({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-10"), + untilDay: UsageDay.make("2026-08-12"), + }); + assert.deepStrictEqual(later.buckets, []); + assert.strictEqual(later.sources[0]?.status, "missing"); + assert.strictEqual(later.sources[0]?.scannedFiles, 0); + }).pipe(Effect.scoped), + ); + it.live("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 026641ae4d07..dc3121fc0024 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -63,10 +63,11 @@ import { import { decodeScanCache, dedupeWithinFile, - encodeScanCache, LEGACY_SCAN_CACHE_FILE_NAME, + makeScanCacheWriter, pruneScanCache, SCAN_CACHE_FILE_NAME, + type CachedFile, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -90,6 +91,9 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +/** Transcripts parsed at once. More gains little once the disk stays busy. */ +const TRANSCRIPT_READ_CONCURRENCY = 4; + const decodeCodexSettings = Schema.decodeOption(CodexSettings); const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); @@ -108,9 +112,37 @@ const encodeRatesCache = Schema.encodeEffect( /** The scan cache is narrowed by hand in `usageScanCache`, so JSON is enough here. */ const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); -const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); + +/** Whether `a` read a later state of its file than `b`. Transcripts only grow. */ +function isLaterRead(a: CachedFile, b: CachedFile): boolean { + return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size); +} + +/** + * Codex sessions with records in more than one file, such as a rollout that + * moved after it was read. Only these need cross-file dedupe keys: within one + * file the occurrence count already keeps every key unique, so keying the rest + * would only build and hash a string for each of their records. + */ +function sharedCodexSessions( + files: readonly { readonly records: readonly UsageRecord[] }[], +): ReadonlySet { + const firstFile = new Map(); + const shared = new Set(); + for (const [index, file] of files.entries()) { + let previous = ""; + for (const { provider, sessionId } of file.records) { + if (provider !== "codex" || sessionId === previous || sessionId.length === 0) continue; + previous = sessionId; + const first = firstFile.get(sessionId); + if (first === undefined) firstFile.set(sessionId, index); + else if (first !== index) shared.add(sessionId); + } + } + return shared; +} const decodeCachedSources = Schema.decodeUnknownOption( Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), ); @@ -386,20 +418,28 @@ export const make = Effect.gen(function* () { }), ); + const writeScanCache = makeScanCacheWriter(); + // Scans with different windows can finish together; two writes interleaved + // in one file would corrupt it. + const persistLock = yield* Semaphore.make(1); + const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { if (!cacheDirty) return; - // Cleared only after the write lands, so a failed persist is retried on - // the next scan instead of leaving disk permanently stale. - yield* encodeScanCacheFile({ - ...encodeScanCache(fileCache), - sources: Object.fromEntries(sourceCache), - }).pipe( + // Cleared before encoding, so a scan that changes the cache while this + // write is in flight marks it dirty again. A failed write restores the + // flag, so the next scan retries instead of leaving disk stale. + cacheDirty = false; + yield* Effect.sync(() => + writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }), + ).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), - Effect.map(() => { - cacheDirty = false; - }), // A cache we cannot write is a slower next start, not a failed read. - Effect.ignoreCause, + Effect.catchCause(() => + Effect.sync(() => { + cacheDirty = true; + }), + ), + persistLock.withPermit, ); }); @@ -410,13 +450,22 @@ export const make = Effect.gen(function* () { * written multi-hundred-megabyte rollout costs its appended bytes per scan * rather than a full re-read. The reader verifies the position's guard bytes * and silently restarts from byte 0 when they no longer match. + * + * A fresh parse comes back as `update` for the caller to cache, with the + * entry it was built from. Reads run concurrently, and the caller stores + * updates in walk order rather than completion order: saved records of + * deleted transcripts aggregate in cache order, where the first copy of a + * duplicate wins. */ const readFileRecords = ( filePath: string, size: number, mtimeMs: number, provider: UsageProviderKind, - ): Effect.Effect => + ): Effect.Effect<{ + readonly records: readonly UsageRecord[]; + readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined }; + }> => Effect.gen(function* () { const cached = fileCache.get(filePath); // Provider is part of the identity: if both providers were ever pointed @@ -427,9 +476,12 @@ export const make = Effect.gen(function* () { cached.mtimeMs === mtimeMs && cached.provider === provider ) { - return cached.tailRecords.length === 0 - ? cached.records - : [...cached.records, ...cached.tailRecords]; + return { + records: + cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords], + }; } // Only a strictly grown file may resume. Same size with a new mtime, or @@ -445,7 +497,9 @@ export const make = Effect.gen(function* () { // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. if (parsed === null) - return cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : []; + return { + records: cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : [], + }; // Stored already de-duplicated within the file, which is 99% of all // duplicates. The aggregator still runs the cross-file dedupe pass. One @@ -456,16 +510,13 @@ export const make = Effect.gen(function* () { const records = dedupeWithinFile([...base, ...parsed.records], seen); const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); - fileCache.set(filePath, { - size, - mtimeMs, - provider, - records, - tailRecords, - position: parsed.position, - }); - cacheDirty = true; - return tailRecords.length === 0 ? records : [...records, ...tailRecords]; + return { + records: tailRecords.length === 0 ? records : [...records, ...tailRecords], + update: { + entry: { size, mtimeMs, provider, records, tailRecords, position: parsed.position }, + replaces: cached, + }, + }; }); /** One provider directory's walk and parse, before rates are involved. */ @@ -505,11 +556,32 @@ export const make = Effect.gen(function* () { const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), ); - const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; - for (const file of files) { - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); - parsedFiles.push({ path: file.path, records }); - } + // A cold parse waits on disk reads, so a few files in flight read + // close to twice as fast. Results keep walk order. + const read = yield* Effect.forEach( + files, + (file) => + readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe( + Effect.map((result) => ({ path: file.path, ...result })), + ), + { concurrency: TRANSCRIPT_READ_CONCURRENCY }, + ); + const parsedFiles = read.map(({ path, records, update }) => { + if (update === undefined) return { path, records }; + // A scan of another window may have cached its own read of this file + // meanwhile. Then keep whichever read saw the later file, so a slower + // scan never replaces newer usage with older. + const current = fileCache.get(path); + if ( + current === update.replaces || + current === undefined || + !isLaterRead(current, update.entry) + ) { + fileCache.set(path, update.entry); + cacheDirty = true; + } + return { path, records }; + }); scanned.push({ provider, dir, volumeId, files: parsedFiles }); } @@ -753,37 +825,39 @@ export const make = Effect.gen(function* () { const sources: UsageSource[] = []; - for (const { - provider, - dir, - volumeId, - files, - status, - message, - action, - hostId: sourceHostId, - } of scannedDirs) { + // Cleanup may remove transcripts, but the usage we already saved still + // contributes to its source through the normal aggregation and dedupe + // path. Like the walk, skip files last written before the window: they + // cannot hold records inside it. + const retainedSinceMs = Math.max(windowStartMs, retentionCutoffMs); + const filesByDir = scannedDirs.map(({ provider, dir, files }) => { const retainedFiles = [...(files ?? [])]; const livePaths = new Set(retainedFiles.map((file) => file.path)); - // Cleanup may remove transcripts, but the usage we already saved still - // contributes to this source. Keep the normal aggregation and dedupe path. for (const [filePath, entry] of fileCache) { if ( entry.provider !== provider || - entry.mtimeMs < retentionCutoffMs || + entry.mtimeMs < retainedSinceMs || livePaths.has(filePath) || !isWithinDirectory(filePath, dir) ) continue; retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); } + return retainedFiles; + }); + const sharedSessions = sharedCodexSessions(filesByDir.flat()); + + for (const [ + index, + { provider, dir, volumeId, files, status, message, action, hostId: sourceHostId }, + ] of scannedDirs.entries()) { let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); - for (const file of retainedFiles) { + for (const file of filesByDir[index] ?? []) { if (file.records.length === 0) { skippedFiles += 1; continue; @@ -792,9 +866,10 @@ export const make = Effect.gen(function* () { const codexEventOccurrences = new Map(); for (const record of file.records) { let usageRecord = record; - if (record.provider === "codex" && record.sessionId.length > 0) { + if (record.provider === "codex" && sharedSessions.has(record.sessionId)) { // Match moved rollout copies without collapsing repeated equal events // within one rollout (timestamps can have only second precision). + // Only sessions seen in several files can have a copy to match. const key = encodeUsageRecordKey([ record.provider, record.sessionId, diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index ab260d3398b2..9fea9cd96644 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -128,6 +128,41 @@ describe("UsageAggregator", () => { expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); }); + it("finds the day at a quarter-hour zone's midnight across interleaved buckets", () => { + // Kathmandu is UTC+5:45, so its midnight falls at 18:15 UTC. + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-01T18:14:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z") }), + record({ timestampMs: Date.parse("2026-08-01T18:15:00.000Z"), model: "claude-opus-5" }), + record({ timestampMs: Date.parse("2026-08-01T18:16:00.000Z") }), + ], + "Asia/Kathmandu", + ); + + expect(result.buckets.map((bucket) => [bucket.day, bucket.model, bucket.records])).toEqual([ + ["2026-08-01", "claude-fable-5", 1], + ["2026-08-02", "claude-fable-5", 2], + ["2026-08-02", "claude-opus-5", 1], + ]); + }); + + it("finds the day at a fixed offset's midnight between quarter hours", () => { + // At +00:01, midnight falls at 23:59 UTC. + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-01T23:58:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-01T23:59:00.000Z") }), + ], + "+00:01", + ); + + expect(result.buckets.map((bucket) => [bucket.day, bucket.records])).toEqual([ + ["2026-08-01", 1], + ["2026-08-02", 1], + ]); + }); + it("splits an hourly request into fixed buckets anchored to its exact start", () => { const result = aggregate( [ diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 040c258075d4..0652ca4b7dcf 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -20,7 +20,7 @@ import type { UsageTokenTotals, } from "@t3tools/contracts"; -import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; +import { EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; /** @@ -47,13 +47,30 @@ function makeDayFormatter(timeZone: string): (timestampMs: number) => string { day: "2-digit", }); } - return (timestampMs) => format.format(new Date(timestampMs)); + // Formatting once per quarter-hour slot keeps `Intl` (about 2µs a call) off + // the per-record path. A slot whose two ends fall on one day lies wholly in + // that day. Named zones put midnight on a quarter hour, so their slots never + // split; a fixed offset such as `+00:01` can, and records in a split slot are + // formatted exactly. `null` marks a split slot. + const days = new Map(); + return (timestampMs) => { + const slot = Math.floor(timestampMs / QUARTER_HOUR_MS); + let day = days.get(slot); + if (day === undefined) { + const first = format.format(new Date(slot * QUARTER_HOUR_MS)); + const last = format.format(new Date((slot + 1) * QUARTER_HOUR_MS - 1)); + day = first === last ? first : null; + days.set(slot, day); + } + return day ?? format.format(new Date(timestampMs)); + }; } +const QUARTER_HOUR_MS = 15 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000; interface MutableBucket { - totals: UsageTokenTotals; + totals: { -readonly [K in keyof UsageTokenTotals]: number }; costUsd: number; cacheSavingsUsd: number; categoryCostUsd: UsageCategoryCost | null; @@ -98,6 +115,14 @@ export class UsageAggregator { readonly #toDay: (timestampMs: number) => string; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; + #lastBucket: { + readonly day: string; + readonly hourIndex: number; + readonly provider: string; + readonly model: string; + readonly source: string; + readonly bucket: MutableBucket; + } | null = null; #duplicatesDropped = 0; #outOfWindow = 0; @@ -149,35 +174,20 @@ export class UsageAggregator { return false; } - const hourStart = + const hourIndex = this.#hourlyWindow === null - ? "" - : new Date( - this.#hourlyWindow.sinceTimeMs + - Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, - ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourcePath ?? ""}`; - let bucket = this.#buckets.get(key); - if (bucket === undefined) { - bucket = { - totals: EMPTY_TOTALS, - costUsd: 0, - cacheSavingsUsd: 0, - categoryCostUsd: null, - fastCostUsd: 0, - ultrafastCostUsd: 0, - speedPremiumUsd: 0, - records: 0, - unpricedRecords: 0, - providerReportedRecords: 0, - sessions: new Set(), - }; - this.#buckets.set(key, bucket); - } + ? -1 + : Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS); + const bucket = this.#bucketFor(day, hourIndex, record.provider, record.model, sourcePath ?? ""); const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides); - bucket.totals = addTotals(bucket.totals, record.totals); + const totals = bucket.totals; + totals.uncachedInputTokens += record.totals.uncachedInputTokens; + totals.cachedInputTokens += record.totals.cachedInputTokens; + totals.cacheCreationTokens += record.totals.cacheCreationTokens; + totals.outputTokens += record.totals.outputTokens; + totals.reasoningTokens += record.totals.reasoningTokens; bucket.costUsd += priced.costUsd; if (priced.categoryCostUsd !== null) { const sum = bucket.categoryCostUsd; @@ -207,6 +217,54 @@ export class UsageAggregator { return true; } + /** + * Records arrive file by file in time order, so most land in the bucket the + * previous record used. Checking that first skips building and hashing a + * key string per record. + */ + #bucketFor( + day: string, + hourIndex: number, + provider: string, + model: string, + source: string, + ): MutableBucket { + const last = this.#lastBucket; + if ( + last !== null && + last.day === day && + last.hourIndex === hourIndex && + last.provider === provider && + last.model === model && + last.source === source + ) { + return last.bucket; + } + const window = this.#hourlyWindow; + const hourStart = + window === null ? "" : new Date(window.sinceTimeMs + hourIndex * HOUR_MS).toISOString(); + const key = `${day}\u0000${hourStart}\u0000${provider}\u0000${model}\u0000${source}`; + let bucket = this.#buckets.get(key); + if (bucket === undefined) { + bucket = { + totals: { ...EMPTY_TOTALS }, + costUsd: 0, + cacheSavingsUsd: 0, + categoryCostUsd: null, + fastCostUsd: 0, + ultrafastCostUsd: 0, + speedPremiumUsd: 0, + records: 0, + unpricedRecords: 0, + providerReportedRecords: 0, + sessions: new Set(), + }; + this.#buckets.set(key, bucket); + } + this.#lastBucket = { day, hourIndex, provider, model, source, bucket }; + return bucket; + } + finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { @@ -222,7 +280,7 @@ export class UsageAggregator { provider: provider as UsageBucket["provider"], model, ...(sourcePath === "" ? {} : { sourcePath }), - totals: bucket.totals, + totals: { ...bucket.totals }, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, // Zero and unknown figures are omitted to keep payloads small. diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 51ef35d7a8ad..0ec7ef8d622f 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -235,7 +235,27 @@ const UNPRICEABLE_MODELS = new Set([ "fable", ]); +/** + * Lookups per table, by raw model name. A scan prices every record twice + * against a few dozen models, and tables are never mutated once built. + */ +const resolvedRates = new WeakMap>(); + export function lookupRate(table: RateTable, model: string): ModelRate | null { + let resolved = resolvedRates.get(table); + if (resolved === undefined) { + resolved = new Map(); + resolvedRates.set(table, resolved); + } + let rate = resolved.get(model); + if (rate === undefined) { + rate = resolveRate(table, model); + resolved.set(model, rate); + } + return rate; +} + +function resolveRate(table: RateTable, model: string): ModelRate | null { const key = stripVariantSuffix(normalizeRateKey(model)); const bareName = bareModelName(key); if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 82ff1e36dfc1..b59768cf892b 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -4,6 +4,7 @@ import { decodeScanCache, dedupeWithinFile, encodeScanCache, + makeScanCacheWriter, pruneScanCache, type CachedFile, type ScanCache, @@ -149,6 +150,26 @@ describe("scan cache round trip", () => { expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); + it("rewrites only changed entries and still restores the whole cache", () => { + const write = makeScanCacheWriter(); + const cache = cacheWith([ + ["/a.jsonl", 100, [record()]], + ["/b.jsonl", 200, [record({ sessionId: "session-b" })]], + ]); + const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } }; + expect(decodeScanCache(JSON.parse(write(cache, { sources })))).toEqual(cache); + + // The replacement adds intern entries; /a's memoised indexes must hold. + cache.set("/b.jsonl", { + ...cache.get("/b.jsonl")!, + size: 30, + records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })], + }); + const document = JSON.parse(write(cache, { sources })); + expect(decodeScanCache(document)).toEqual(cache); + expect(document.sources).toEqual(sources); + }); + it("interns repeated model and session strings", () => { const encoded = encodeScanCache( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 3c6826a67986..04e7ab4ce982 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -103,26 +103,32 @@ interface SerializedCache { readonly files: Readonly>; } -/** Serialises the cache, interning the repeated model and session strings. */ -export function encodeScanCache(cache: ScanCache): SerializedCache { - const models: string[] = []; - const sessions: string[] = []; - const modelIndex = new Map(); - const sessionIndex = new Map(); +/** Model and session strings, each stored once and referenced by index. */ +interface InternTables { + readonly models: string[]; + readonly sessions: string[]; + readonly modelIndex: Map; + readonly sessionIndex: Map; +} - const intern = (table: string[], index: Map, value: string): number => { - const existing = index.get(value); - if (existing !== undefined) return existing; - const next = table.length; - table.push(value); - index.set(value, next); - return next; - }; +function makeInternTables(): InternTables { + return { models: [], sessions: [], modelIndex: new Map(), sessionIndex: new Map() }; +} + +function intern(table: string[], index: Map, value: string): number { + const existing = index.get(value); + if (existing !== undefined) return existing; + const next = table.length; + table.push(value); + index.set(value, next); + return next; +} +function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile { const serializeRecord = (record: UsageRecord): SerializedRecord => [ record.timestampMs, - intern(models, modelIndex, record.model), - intern(sessions, sessionIndex, record.sessionId), + intern(tables.models, tables.modelIndex, record.model), + intern(tables.sessions, tables.sessionIndex, record.sessionId), record.totals.uncachedInputTokens, record.totals.cachedInputTokens, record.totals.cacheCreationTokens, @@ -132,23 +138,68 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { record.reportedCostUsd, SPEEDS.indexOf(record.speed), ]; + return { + s: entry.size, + m: entry.mtimeMs, + p: entry.provider, + r: entry.records.map(serializeRecord), + t: entry.tailRecords.map(serializeRecord), + o: entry.position.resumeOffset, + gl: entry.position.guardLength, + gh: entry.position.guardHash, + cs: entry.position.codexState, + }; +} +/** Serialises the cache, interning the repeated model and session strings. */ +export function encodeScanCache(cache: ScanCache): SerializedCache { + const tables = makeInternTables(); const files: Record = {}; - for (const [path, entry] of cache) { - files[path] = { - s: entry.size, - m: entry.mtimeMs, - p: entry.provider, - r: entry.records.map(serializeRecord), - t: entry.tailRecords.map(serializeRecord), - o: entry.position.resumeOffset, - gl: entry.position.guardLength, - gh: entry.position.guardHash, - cs: entry.position.codexState, - }; - } + for (const [path, entry] of cache) files[path] = serializeFile(entry, tables); + return { + version: USAGE_SCAN_CACHE_VERSION, + models: tables.models, + sessions: tables.sessions, + files, + }; +} - return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; +/** + * Returns a function that serialises the cache to JSON text, re-encoding only + * the entries that changed since its last call. Call it once per persist. + * + * Writes the same document as `encodeScanCache`. Most entries never change + * between scans, and encoding all of them made each persist cost close to a + * second on a large cache. Entries are replaced, never mutated, when their file + * changes, so an entry's JSON is memoised by identity. The intern tables only + * grow, so a memoised entry's indexes stay valid; a pruned entry can leave an + * unused string behind until the next process start. + */ +export function makeScanCacheWriter(): ( + cache: ScanCache, + extra: Readonly>, +) => string { + const tables = makeInternTables(); + const fragments = new WeakMap(); + return (cache, extra) => { + const files: string[] = []; + for (const [path, entry] of cache) { + let fragment = fragments.get(entry); + if (fragment === undefined) { + fragment = JSON.stringify(serializeFile(entry, tables)); + fragments.set(entry, fragment); + } + files.push(`${JSON.stringify(path)}:${fragment}`); + } + // Encoded after the files, which may have added to the intern tables. + const head = JSON.stringify({ + ...extra, + version: USAGE_SCAN_CACHE_VERSION, + models: tables.models, + sessions: tables.sessions, + }); + return `${head.slice(0, -1)},"files":{${files.join(",")}}}`; + }; } function isRecordArray(value: unknown): value is readonly unknown[] { diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 4d170237568d..bca22468a95e 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -85,6 +85,8 @@ export const GUARD_LENGTH = 64; // readers; it never discards a record because of its size. const STREAMING_THRESHOLD_BYTES = 8 * 1024 * 1024; const NEWLINE = 0x0a; +/** `stat` calls one transcript walk keeps in flight. The libuv pool has 4 threads. */ +const STAT_CONCURRENCY = 32; const CARRIAGE_RETURN = 0x0d; type SelectedFields = { readonly [key: string]: true | SelectedFields }; @@ -156,15 +158,19 @@ function fnv1a(buffer: Buffer): number { * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`). * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that * never carry usage, so the basename filter keeps a cold scan off those files. + * + * Directories are listed depth-first, one at a time, then the candidates are + * stat'd by a fixed pool of workers: a warm scan stats thousands of files, and + * one at a time each waits its own trip through the thread pool. Results keep + * `readdir` order, which the aggregator's first-seen dedupe relies on. */ export async function listTranscriptFiles( root: string, sinceMs: number, options?: { readonly fileName?: string }, ): Promise { - const found: TranscriptFile[] = []; const fileName = options?.fileName; - + const candidates: string[] = []; const walk = async (dir: string): Promise => { let entries; try { @@ -174,28 +180,33 @@ export async function listTranscriptFiles( } for (const entry of entries) { const child = NodePath.join(dir, entry.name); - if (entry.isDirectory()) { - await walk(child); - continue; - } - if (fileName !== undefined) { - if (entry.name !== fileName) continue; - } else if (!entry.name.endsWith(".jsonl")) { - continue; + if (entry.isDirectory()) await walk(child); + else if (fileName !== undefined ? entry.name === fileName : entry.name.endsWith(".jsonl")) { + candidates.push(child); } + } + }; + await walk(root); + + const found: Array = Array.from({ length: candidates.length }); + // Each worker pulls the next candidate from one shared iterator. + const queue = candidates.entries(); + const statQueued = async (): Promise => { + for (const [index, path] of queue) { try { - const stats = await NodeFSP.stat(child); + const stats = await NodeFSP.stat(path); if (stats.mtimeMs >= sinceMs) { - found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); + found[index] = { path, size: stats.size, mtimeMs: stats.mtimeMs }; } } catch { // Vanished between readdir and stat. } } }; - - await walk(root); - return found; + await Promise.all( + Array.from({ length: Math.min(STAT_CONCURRENCY, candidates.length) }, statQueued), + ); + return found.filter((file) => file !== undefined); } /** diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index eb32e664f436..451f8a726b3e 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -54,16 +54,6 @@ function parseTimestampMs(value: unknown): number | null { return Number.isNaN(parsed) ? null : parsed; } -export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { - return { - uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, - cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, - cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, - outputTokens: a.outputTokens + b.outputTokens, - reasoningTokens: a.reasoningTokens + b.reasoningTokens, - }; -} - export function totalTokens(totals: UsageTokenTotals): number { // reasoningTokens is a subset of outputTokens and must not be added again. return ( From fffe6e6c82c12769e0cbc4d89a23ec845c5abed1 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:27:38 -0700 Subject: [PATCH 14/78] perf(web): virtualize command palette results (#15266) --- .../components/CommandPalette.logic.test.ts | 45 +++++++ .../src/components/CommandPalette.logic.ts | 59 ++++++++++ apps/web/src/components/CommandPalette.tsx | 83 +++++++++++-- .../src/components/CommandPaletteResults.tsx | 110 ++++++++++++++++-- apps/web/src/components/ui/autocomplete.tsx | 28 ++++- apps/web/src/components/ui/command.tsx | 27 +++++ .../src/state/queries.threadSearch.test.tsx | 64 ++++++++++ apps/web/src/state/queries.ts | 3 + 8 files changed, 400 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/state/queries.threadSearch.test.tsx diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 916b66a32b34..2bd7c118c69f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures"; import { buildBrowseGroups, buildCommandPaletteProjectMetadata, + buildCommandPaletteRows, buildProjectActionItems, buildThreadActionItems, buildLinkedThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + findHighlightedCommandPaletteItem, reduceCommandPaletteUiState, type CommandPaletteActionItem, type CommandPaletteGroup, @@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => { ]); }); }); + +describe("virtualized command palette rows", () => { + const action = (value: string, disabled = false): CommandPaletteActionItem => ({ + kind: "action", + value, + searchTerms: [], + title: value, + icon: null, + ...(disabled ? { disabled } : {}), + run: async () => {}, + }); + const groups: CommandPaletteGroup[] = [ + { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] }, + { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] }, + ]; + + it("keeps group order and headings while indexing only enabled items", () => { + const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups); + + expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([ + "# Actions", + "actions:new-thread", + "actions:offline", + "# Threads", + "threads:thread-a", + "threads:thread-b", + ]); + expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]); + expect(rowIndexByItemIndex).toEqual([1, 4, 5]); + expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([ + 0, + null, + 1, + 2, + ]); + }); + + it("resolves Enter to the highlighted item without needing its row mounted", () => { + expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b"); + expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull(); + expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index d031f36ed35c..49e963f68313 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -172,6 +172,65 @@ export interface CommandPaletteView { readonly initialQuery?: string; } +export type CommandPaletteRow = + | { + readonly kind: "label"; + readonly key: string; + readonly label: string; + readonly first: boolean; + } + | { + readonly kind: "item"; + readonly key: string; + readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + /** Position among enabled items, or null for disabled rows the keyboard skips. */ + readonly itemIndex: number | null; + }; + +/** + * Flattens groups into the rows a virtualized list renders. `itemValues` is the + * highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a + * highlight back to its row for scrolling. + */ +export function buildCommandPaletteRows(groups: ReadonlyArray) { + const rows: CommandPaletteRow[] = []; + const itemValues: string[] = []; + const rowIndexByItemIndex: number[] = []; + for (const group of groups) { + if (group.label) { + rows.push({ + kind: "label", + key: `group:${group.value}`, + label: group.label, + first: rows.length === 0, + }); + } + for (const item of group.items) { + const itemIndex = item.disabled ? null : itemValues.length; + if (itemIndex !== null) { + itemValues.push(item.value); + rowIndexByItemIndex.push(rows.length); + } + rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex }); + } + } + return { rows, itemValues, rowIndexByItemIndex }; +} + +/** The enabled item Enter should run for a highlight, whether or not its row is mounted. */ +export function findHighlightedCommandPaletteItem( + groups: ReadonlyArray, + highlightedItemValue: string | null, +): CommandPaletteActionItem | CommandPaletteSubmenuItem | null { + if (highlightedItemValue === null) return null; + for (const group of groups) { + for (const item of group.items) { + if (item.value === highlightedItemValue && !item.disabled) return item; + } + } + return null; +} + export function enumerateCommandPaletteItems( items: ReadonlyArray, ): CommandPaletteActionItem[] { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aad0e71a06b0..f7b78b5ae676 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -77,6 +77,7 @@ import { type KeyboardEvent, type ReactNode, } from "react"; +import type { LegendListRef } from "@legendapp/list/react"; import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; @@ -157,7 +158,9 @@ import { buildRootGroups, buildThreadActionItems, buildLinkedThreadActionItems, + buildCommandPaletteRows, enumerateCommandPaletteItems, + findHighlightedCommandPaletteItem, type CommandPaletteActionItem, type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, @@ -174,7 +177,10 @@ import { import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; -import { CommandPaletteResults } from "./CommandPaletteResults"; +import { + CommandPaletteVirtualizedResults, + scrollCommandPaletteRowIntoView, +} from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; @@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: { const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const resultListRef = useRef(null); + // Typing or entering a submenu clears the highlight. Base UI keeps its own on the + // first row, but the palette shows none until the user navigates, and the first + // ArrowDown lands on it. + const highlightClearedRef = useRef(false); + function clearTypedHighlight(): void { + highlightClearedRef.current = true; + setHighlightedItemValue(null); + } const clientSettings = useClientSettings(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, @@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: { ? { source: match.source, snippet: match.snippet, - query: threadSearchQuery, + query: threadSearch.query, } : undefined; }, @@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: { projectTitleById, providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, - threadSearchQuery, + threadSearch.query, threads, ], ); @@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: { ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), }, ]); + highlightClearedRef.current = true; setHighlightedItemValue(null); setQuery(view.initialQuery ?? ""); }, @@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: { function handleQueryChange(nextQuery: string): void { browseNavigation.invalidate(); - setHighlightedItemValue(null); + clearTypedHighlight(); setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { popView(); @@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: { } else if (isBrowsing) { displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; } + const resultRows = buildCommandPaletteRows(displayedGroups); + const autoHighlightsFirstRow = + !isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null; const inputPlaceholder = newProjectFlow !== null @@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: { if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); popView(); + return; + } + + // Base UI ignores navigation keys with modifiers, so these fallbacks do too. + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return; + // Base UI only keeps a hidden highlight on the first row when it auto-highlights. + const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined; + if ( + event.key === "ArrowDown" && + highlightClearedRef.current && + firstItemValue && + !event.nativeEvent.isComposing + ) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + highlightClearedRef.current = false; + setHighlightedItemValue(firstItemValue); + scrollCommandPaletteRowIntoView( + resultListRef.current, + resultRows.rowIndexByItemIndex[0] ?? 0, + ); + return; + } + + // Base UI clicks the highlighted row on Enter, which does nothing once the + // virtualized list has unmounted it, so run the tracked highlight directly. + if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) { + const highlightedItem = findHighlightedCommandPaletteItem( + displayedGroups, + highlightedItemValue ?? firstItemValue ?? null, + ); + if (highlightedItem) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + executeItem(highlightedItem); + } } } @@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: { { + items={resultRows.itemValues} + virtualized + onItemHighlighted={(value, eventDetails) => { + if (eventDetails.reason === "none" && highlightClearedRef.current) return; + highlightClearedRef.current = false; setHighlightedItemValue(typeof value === "string" ? value : null); + const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index]; + if (eventDetails.reason === "keyboard" && rowIndex !== undefined) { + scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex); + } }} onValueChange={handleQueryChange} showBackHint={isSubmenu} @@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: {
) : null} - void; } +function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) { + return ( +
+ {props.emptyStateMessage ?? + (props.isActionsOnly + ? "No matching actions." + : "No matching commands, projects, or threads.")} +
+ ); +} + export function CommandPaletteResults(props: CommandPaletteResultsProps) { if (props.groups.length === 0) { - return ( -
- {props.emptyStateMessage ?? - (props.isActionsOnly - ? "No matching actions." - : "No matching commands, projects, or threads.")} -
- ); + return ; } return ( @@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { ); } +/** + * Scrolls a keyboard highlight into view the way the unvirtualized list did: + * nearest edge, clear of the scroll fade. Rows outside the rendered window + * fall back to the list's own scrolling. + */ +export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) { + const element = list?.getState?.().elementAtIndex(rowIndex); + if (element instanceof HTMLElement) { + element.scrollIntoView({ block: "nearest" }); + return; + } + void list?.scrollIndexIntoView?.({ index: rowIndex, animated: false }); +} + +interface CommandPaletteVirtualizedResultsProps extends Omit { + rows: ReadonlyArray; + listRef: RefObject; +} + +/** + * Renders only the visible rows. The parent passes the flat item order to the + * Command root as `items` with `virtualized`, scrolls keyboard highlights into + * view through `listRef`, and runs Enter itself since the row may be unmounted. + */ +export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualizedResultsProps) { + const { listRef } = props; + const [scrollFade, setScrollFade] = useState({ top: false, bottom: false }); + const updateScrollFade = useCallback(() => { + const scrollElement = listRef.current?.getScrollableNode?.(); + if (!(scrollElement instanceof HTMLElement)) return; + const top = scrollElement.scrollTop > 1; + const bottom = + scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1; + setScrollFade((current) => + current.top === top && current.bottom === bottom ? current : { top, bottom }, + ); + }, [listRef]); + + if (props.rows.length === 0) { + return ; + } + + return ( + + + ref={listRef} + data={props.rows} + keyExtractor={(row) => row.key} + getItemType={(row) => row.kind} + extraData={props.highlightedItemValue} + renderItem={({ item: row }) => + row.kind === "label" ? ( +
+ {row.label} +
+ ) : row.itemIndex === null ? ( + + ) : ( + + ) + } + estimatedItemSize={40} + drawDistance={400} + onLayout={updateScrollFade} + onScroll={updateScrollFade} + contentContainerClassName="px-2" + className={cn( + "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2", + getVirtualizedScrollFadeClassName(scrollFade), + )} + /> +
+ ); +} + function DisabledCommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; }) { @@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: { } function CommandPaletteResultRow(props: { + index?: number; item: CommandPaletteActionItem | CommandPaletteSubmenuItem; isActive: boolean; keybindings: ResolvedKeybindingsConfig; @@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: { return ( { diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index 8251d8c24b7c..548efd944897 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group. ); } +const GROUP_LABEL_CLASS_NAME = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; + function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) { return ( + ); +} + +// A group label for virtualized lists, where rows cannot be wrapped in a group. +function AutocompleteListHeading({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AutocompleteListVirtualized({ className, ...props }: AutocompletePrimitive.List.Props) { + return ( + ); @@ -226,6 +250,8 @@ export { AutocompleteGroupLabel, AutocompleteEmpty, AutocompleteList, + AutocompleteListHeading, + AutocompleteListVirtualized, AutocompleteClear, AutocompleteCollection, }; diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index d3a83865151d..b3a473ba1d22 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -12,6 +12,8 @@ import { AutocompleteInput, AutocompleteItem, AutocompleteList, + AutocompleteListHeading, + AutocompleteListVirtualized, } from "~/components/ui/autocomplete"; import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; import { Button } from "~/components/ui/button"; @@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandListHeading({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { return (
({ + matches: [{ threadId: "thread-1", source: "user", snippet: "a link" }], + isLoading: false, +})); +vi.mock("@effect/atom-react", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: (atom: { label?: ReadonlyArray }) => + String(atom.label?.[0]).includes("empty") ? { matches: [], isLoading: false } : searchResults, +})); + +const environmentIds = [EnvironmentId.make("local")]; +type ThreadSearch = ReturnType; + +function Probe(props: { query: string; onResult: (result: ThreadSearch) => void }) { + props.onResult(useThreadSearch(environmentIds, props.query)); + return null; +} + +describe("useThreadSearch", () => { + let renderer: ReactTestRenderer | null = null; + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("window", globalThis); + }); + afterEach(() => { + act(() => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("keeps the settled query and matches stable while typing, then reports the settled query", () => { + const results: ThreadSearch[] = []; + const render = (query: string) => { + const element = results.push(result)} />; + act(() => { + if (renderer) renderer.update(element); + else renderer = create(element); + }); + return results.at(-1)!; + }; + + render(""); + const typing = ["li", "lin", "link "].map(render); + expect(typing.map((result) => result.query)).toEqual(["", "", ""]); + expect(new Set(typing.map((result) => result.matches)).size).toBe(1); + expect(typing.every((result) => result.isPending)).toBe(true); + + act(() => { + vi.advanceTimersByTime(200); + }); + const settled = results.at(-1)!; + expect(settled.query).toBe("link"); + expect(settled.matches).toBe(searchResults.matches); + expect(settled.isPending).toBe(false); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 15211efab4e6..01bfa8864bb7 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -72,6 +72,8 @@ export function useThreadSearch( query: string, ): { readonly matches: ReadonlyArray; + /** The settled query `matches` came from; it only changes when results do. */ + readonly query: string; readonly isPending: boolean; } { const normalizedQuery = query.trim(); @@ -88,6 +90,7 @@ export function useThreadSearch( const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; return { matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, + query: settledQuery ?? "", isPending: canSearch && (isDebouncing || result.isLoading), }; } From 0cfa113be597af3a1b2fe905a58a5b583f8089b8 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:28:08 -0700 Subject: [PATCH 15/78] chore(lint): flag :has() variants that restyle the whole page (#15274) --- oxlint-plugin-t3code/index.ts | 2 + .../rules/no-unscoped-has.test.ts | 124 ++++++++++++ oxlint-plugin-t3code/rules/no-unscoped-has.ts | 179 ++++++++++++++++++ vite.config.ts | 1 + 4 files changed, 306 insertions(+) create mode 100644 oxlint-plugin-t3code/rules/no-unscoped-has.test.ts create mode 100644 oxlint-plugin-t3code/rules/no-unscoped-has.ts diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index f4669f588f67..855ba1e7d098 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -8,6 +8,7 @@ import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-te import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; +import noUnscopedHas from "./rules/no-unscoped-has.ts"; export default definePlugin({ meta: { @@ -22,5 +23,6 @@ export default definePlugin({ "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, "no-native-title-tooltip": noNativeTitleTooltip, "no-test-in-loop": noTestInLoop, + "no-unscoped-has": noUnscopedHas, }, }); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts new file mode 100644 index 000000000000..76f0e7c7159d --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts @@ -0,0 +1,124 @@ +/* oxlint-disable t3code/no-unscoped-has -- the fixtures are invalid on purpose */ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-unscoped-has", { + filename: "fixture.tsx", +}); + +describe("t3code/no-unscoped-has", () => { + rule.valid( + "allows :has() on the element itself", + `const className = "[&:has([data-slot=icon])]:ps-2";`, + ); + + rule.valid( + "allows :has() anchored to an attribute", + `const className = "[&+[data-chat-composer-form]:has(>[data-slot=banner])]:mt-0";`, + ); + + rule.valid( + "allows :has() inside :not() on the element itself", + `const className = "[&:not(:has(+[data-slot=footer]))]:rounded-b-2xl";`, + ); + + rule.valid( + "allows built-in has-* variants", + `const className = "has-[>[data-slot=icon]]:ps-2 group-has-[:checked]:opacity-100";`, + ); + + rule.valid( + "allows a sibling selector without :has()", + `const className = "[&+*_[data-chat-composer-form]>[data-slot=attachment]]:before:rounded-none";`, + ); + + rule.valid("ignores prose mentioning :has()", `const note = "uses :has( for styling";`); + + rule.valid( + "ignores :has() inside an arbitrary value", + `const className = "before:content-[':has(foo)']";`, + ); + + rule.valid( + "allows a negated :has() on the element itself", + `const className = "[&:not(.collapsed):not(:has(>[data-slot=icon]))]:ps-2";`, + ); + + rule.valid( + "allows :has() on a group or peer element", + `const className = "group-[:has(input)]:p-2 peer-[:has(input)]:p-2 group-[&:has(input)]/row:p-2";`, + ); + + rule.valid( + "allows a selector list whose own branch is anchored", + `const className = "[:is(.a,.b):has(x)_&]:p-2 [&:not(.a,:has(x))]:p-2";`, + ); + + rule.valid( + "ignores :has() text in quoted attribute values", + `const className = "data-[foo='_:has(x)']:p-2 [&_[data-query='_:has(foo)']]:p-2";`, + ); + + rule.valid( + "allows a selector without & on the element itself", + `const className = "[:has(>input)]:p-2 not-[:has(>[data-slot=icon])]:ps-2";`, + ); + + rule.invalid( + "reports a sibling :has() with nothing anchoring it", + `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`, + (output) => { + assert.match(output, /Anchor the :has\(\)/); + }, + ); + + rule.invalid( + "reports a descendant :has() with nothing anchoring it", + `const className = cn("p-2", "[&_:has(>input)]:gap-1");`, + ); + + rule.invalid( + "reports a universal :has() ancestor", + "const className = `flex [*:has([data-open])_&]:hidden`;", + ); + + rule.invalid( + "reports :has() whose only anchor is negated", + `const className = "[*:not(.safe):has([data-open])_&]:hidden";`, + ); + + rule.invalid("reports uppercase :HAS()", `const className = "[*:HAS([data-open])_&]:hidden";`); + + rule.invalid( + "reports a selector-list branch borrowing another branch's anchor", + `const className = "[.safe,:has(input)_&]:p-2";`, + ); + + rule.invalid( + "reports an unanchored branch inside :is()", + `const className = "[&_:is(.safe,:has(input))]:p-2";`, + ); + + rule.invalid( + "reports an unanchored :has() inside a named group variant", + `const className = "group-[&_:has(x)]/row:p-2";`, + ); + + rule.invalid( + "reports a :has() on an ancestor of a selector without &", + `const className = "[:has(x)_.foo]:p-2";`, + ); + + rule.invalid( + "reports a :has() on an ancestor of a group", + `const className = "group-[:has(x)_.y]:p-2";`, + ); + + rule.invalid("reports an in-* ancestor :has()", `const className = "in-[:has(x)]:p-2";`); + + rule.invalid( + "reports :has() anchored to the document root", + `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts new file mode 100644 index 000000000000..088505b3e0cf --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -0,0 +1,179 @@ +import { defineRule } from "@oxlint/plugins"; + +const COMBINATOR_PATTERN = /[\s>+~]/u; +// A class, id, attribute, or leading tag narrows a compound. Negated ones don't, +// so `:not(...)` is removed before this check. +const NARROWING_PATTERN = /[.#[]|^[a-z]/iu; +const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu; + +// group-[...] and peer-[...] match the .group/.peer element, in-[...] matches an +// ancestor, and data-[...] and aria-[...] hold attribute values, not selectors. +const GROUP_PREFIX_PATTERN = /(?:^|:)(?:group|peer)-$/u; +const ANCESTOR_PREFIX_PATTERN = /(?:^|:)in-$/u; +const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u; +const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu; + +// A variant's closing bracket is followed by ":" or a group/peer name like "/row:". +const VARIANT_END_PATTERN = /^(?:\/[\w-]+)?:/u; + +/** Tailwind arbitrary variants in a class token: top-level `[...]` groups used as variants. */ +function variantGroups(token: string): { prefix: string; group: string }[] { + const groups: { prefix: string; group: string }[] = []; + let depth = 0; + let start = -1; + for (let index = 0; index < token.length; index++) { + const char = token[index]; + if (char === "[") { + if (depth === 0) start = index + 1; + depth++; + } else if (char === "]" && depth > 0) { + depth--; + if (depth === 0 && VARIANT_END_PATTERN.test(token.slice(index + 1))) { + groups.push({ prefix: token.slice(0, start - 1), group: token.slice(start, index) }); + } + } + } + return groups; +} + +/** Index of the ")" closing the "(" at `open`, or the selector's length. */ +function closingParen(selector: string, open: number): number { + let depth = 0; + for (let index = open; index < selector.length; index++) { + if (selector[index] === "(") depth++; + else if (selector[index] === ")" && --depth === 0) return index; + } + return selector.length; +} + +/** + * Whether nothing after `from` in the enclosing branch is joined by a combinator, + * i.e. the element ending at `from` is the subject that its wrapper matches. + */ +function isWrapperSubject(selector: string, from: number): boolean { + let depth = 0; + for (let index = from; index < selector.length; index++) { + const char = selector[index] ?? ""; + if (char === "(") depth++; + else if (char === ")") { + if (depth === 0) return true; + depth--; + } else if (depth > 0) continue; + else if (char === ",") return true; + else if (COMBINATOR_PATTERN.test(char)) { + const next = selector.slice(index).trimStart()[0]; + if (/\s/u.test(char) && (next === undefined || next === ")" || next === ",")) continue; + return false; + } + } + return true; +} + +/** The compound selector each `:has(` in `selector` is attached to. */ +function hasCompounds(selector: string): string[] { + const compounds: string[] = []; + let index = selector.indexOf(":has("); + while (index !== -1) { + let compound = ""; + let depth = 0; + let subjectEnd = closingParen(selector, index + ":has".length) + 1; + // Other branches of a selector list are skipped until the wrapper that holds + // them opens. An unbalanced "(" means the :has() sits inside :not()/:is()/ + // :where(); the compound outside that wrapper applies only when the :has() + // is in the wrapper's subject position. + let skippingBranch = false; + for (let position = index - 1; position >= 0; position--) { + const char = selector[position] ?? ""; + if (char === ")") depth++; + else if (char === "(") { + if (depth > 0) depth--; + else { + if (!isWrapperSubject(selector, subjectEnd)) break; + skippingBranch = false; + subjectEnd = closingParen(selector, position) + 1; + } + } else if (depth === 0 && char === ",") { + skippingBranch = true; + continue; + } else if (depth === 0 && !skippingBranch && COMBINATOR_PATTERN.test(char)) break; + if (!skippingBranch) compound = char + compound; + } + compounds.push(compound); + index = selector.indexOf(":has(", index + 1); + } + return compounds; +} + +/** `compound` without any `:not(...)`, including one left open around the `:has()`. */ +function withoutNegations(compound: string): string { + let result = ""; + let index = 0; + while (index < compound.length) { + if (!compound.startsWith(":not(", index)) { + result += compound[index]; + index++; + continue; + } + let depth = 0; + for (index += ":not".length; index < compound.length; index++) { + if (compound[index] === "(") depth++; + else if (compound[index] === ")" && --depth === 0) break; + } + index++; + } + return result; +} + +/** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */ +function findUnscopedHasVariants(text: string): string[] { + // Selectors are ASCII case-insensitive. + if (!text.toLowerCase().includes(":has(")) return []; + const offenders: string[] = []; + for (const token of text.split(/\s+/u)) { + for (const { prefix, group } of variantGroups(token)) { + if (ATTRIBUTE_PREFIX_PATTERN.test(prefix)) continue; + // Tailwind writes spaces as "_", and "&" is the element carrying the class. + // A selector without "&" applies to that element, as `&:is(...)`. + const relative = group.toLowerCase().replace(QUOTED_PATTERN, '""').replaceAll("_", " "); + const owner = GROUP_PREFIX_PATTERN.test(prefix) ? ".group" : ".self"; + const selector = ANCESTOR_PREFIX_PATTERN.test(prefix) + ? `:is(${relative.replaceAll("&", "*")}) .self` + : relative.includes("&") + ? relative.replaceAll("&", owner) + : `${owner}:is(${relative})`; + const unscoped = hasCompounds(selector).some((compound) => { + const anchor = withoutNegations(compound); + return !NARROWING_PATTERN.test(anchor) || ROOT_COMPOUND_PATTERN.test(anchor); + }); + if (unscoped) offenders.push(`[${group}]`); + } + } + return offenders; +} + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Tailwind arbitrary variants with a :has() that is not anchored to a class, attribute, id, or tag.", + }, + }, + create(context) { + const message = (variant: string) => + `Anchor the :has() in ${variant} to a class, attribute, or tag below the document root, e.g. [&+[data-x]_…] or a has-* variant. Chrome evaluates an unanchored :has() on every ancestor, so any DOM change then restyles the whole page.`; + return { + Literal(node) { + if (typeof node.value !== "string") return; + for (const variant of findUnscopedHasVariants(node.value)) { + context.report({ node, message: message(variant) }); + } + }, + TemplateElement(node) { + for (const variant of findUnscopedHasVariants(node.value.cooked ?? node.value.raw)) { + context.report({ node, message: message(variant) }); + } + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index 97729f5784b3..0eee88e2ad08 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -168,6 +168,7 @@ export default defineConfig({ "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", "t3code/no-test-in-loop": "error", + "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", }, overrides: [ From 429c625a8515f9c2e4af28c488fbf32ce7789645 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 13:31:44 -0700 Subject: [PATCH 16/78] fix(web): workspace card docks beside chat when the window is narrow (#14992) Chat stays centered while the workspace card fits beside it with 32px to spare. When it does not fit, chat moves left only as far as needed, narrows only after it reaches the left padding, and the card becomes a popover below a 640px chat. The card is lighter: 280px wide, 32px rows, no section labels, no "Project folder" hint. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../BranchToolbarEnvModeSelector.tsx | 14 ++++-- apps/web/src/components/GitActionsControl.tsx | 2 +- apps/web/src/components/chat/ChatCanvas.tsx | 6 ++- .../src/components/chat/ChatCanvasContext.ts | 1 + .../src/components/chat/ThreadDetailsCard.tsx | 9 +++- .../components/chat/ThreadDetailsControl.tsx | 2 +- .../components/chat/ThreadDetailsPanel.tsx | 4 +- .../chat/ThreadRelationshipsControl.tsx | 4 +- .../components/chat/chatCanvasLayout.test.ts | 46 ++++++++++++++++++- .../src/components/chat/chatCanvasLayout.ts | 26 +++++++++-- .../chat/threadDetailsCardLayout.test.ts | 28 +++++------ .../chat/threadDetailsCardLayout.ts | 15 ++++-- .../chat/threadDetailsPanelStyles.ts | 4 +- apps/web/src/index.css | 2 +- 14 files changed, 123 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 465be828364b..3afa31e2acee 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -59,7 +59,11 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe }: BranchToolbarEnvModeSelectorProps) { const workspacePath = displayMode === "panel" ? (activeWorktreePath ?? workspaceRoot) : null; const workspaceDisplayName = resolveWorkspaceDisplayName(workspacePath); - const workspaceKind = activeWorktreePath ? "Worktree" : "Project folder"; + // The panel names the workspace kind only when it is not the project folder. + const workspaceKind = activeWorktreePath ? "Worktree" : null; + const lockedWorkspaceKind = forceNewWorktree ? "Worktree" : workspaceKind; + const selectWorkspaceKind = + effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind; const composerFloatingLayerProps = useComposerMenuProps(); const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( @@ -147,9 +151,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe : (workspaceDisplayName ?? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))} - {displayMode === "panel" ? ( + {displayMode === "panel" && lockedWorkspaceKind ? ( - {forceNewWorktree ? "Worktree" : workspaceKind} + {lockedWorkspaceKind} ) : null} @@ -210,9 +214,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe - {displayMode === "panel" ? ( + {displayMode === "panel" && selectWorkspaceKind ? ( - {effectiveEnvMode === "worktree" && !activeWorktreePath ? "Create" : workspaceKind} + {selectWorkspaceKind} ) : null} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 9d9d82c6273f..e6635d892165 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1880,7 +1880,7 @@ export default function GitActionsControl({ // the output row expands below. ) : ( <> diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index bc222d07852e..504db1730489 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -11,7 +11,10 @@ import { ChatCanvasContext } from "./ChatCanvasContext"; import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; -/** Owns the available conversation space. Floating cards never reserve it themselves. */ +/** + * Owns the available conversation space. Cards only report where they sit; the + * canvas decides when chat moves over to make room for them. + */ export function ChatCanvas({ composerOverlayElement, children, @@ -96,6 +99,7 @@ export function ChatCanvas({ const container = { width: measurements.width, height: measurements.height }; return { container, + lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth }, layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }), previewKey: preview?.key ?? null, reportPreview, diff --git a/apps/web/src/components/chat/ChatCanvasContext.ts b/apps/web/src/components/chat/ChatCanvasContext.ts index b810b0239240..44b91f82632a 100644 --- a/apps/web/src/components/chat/ChatCanvasContext.ts +++ b/apps/web/src/components/chat/ChatCanvasContext.ts @@ -4,6 +4,7 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay export const ChatCanvasContext = createContext<{ container: { width: number; height: number }; + lane: { padding: number; minChatWidth: number }; layout: ReturnType; previewKey: string | null; reportPreview: (preview: ChatCanvasPreview) => void; diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 1886aa1ec483..dfee1453eda9 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -29,12 +29,17 @@ export function ThreadDetailsCard({ const preferredPlacement = canvas ? resolveThreadDetailsCardLayout({ container: canvas.container, - chat: canvas.layout.chat, + lane: canvas.lane, frame: null, }) : null; const placement = canvas - ? resolveThreadDetailsCardLayout({ container: canvas.container, ...canvas.layout }) + ? resolveThreadDetailsCardLayout({ + container: canvas.container, + lane: canvas.lane, + frame: canvas.layout.frame, + overlapsDetailsCard: canvas.layout.overlapsDetailsCard, + }) : null; const mode = placement ? "inline" : "popover"; const inlineOpen = useRightPanelStore((state) => diff --git a/apps/web/src/components/chat/ThreadDetailsControl.tsx b/apps/web/src/components/chat/ThreadDetailsControl.tsx index 551cc54282aa..3169f8018488 100644 --- a/apps/web/src/components/chat/ThreadDetailsControl.tsx +++ b/apps/web/src/components/chat/ThreadDetailsControl.tsx @@ -56,7 +56,7 @@ export function ThreadDetailsControl({ className: cn( "relative inline-flex shrink-0 cursor-pointer items-center whitespace-nowrap border outline-none transition-[box-shadow,scale] [&:active:not([aria-haspopup])]:scale-[0.97] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 disabled:pointer-events-none disabled:opacity-64 aria-disabled:cursor-not-allowed aria-disabled:opacity-64 [&_svg]:shrink-0", parts[part], - multiline && "h-auto min-h-9 py-0.75 disabled:opacity-100 sm:h-auto", + multiline && "h-auto min-h-8 py-0.75 disabled:opacity-100 sm:h-auto", tone === "muted" && "text-muted-foreground/70 hover:text-foreground/80", tone === "destructive" && "text-destructive hover:text-destructive data-pressed:text-destructive", diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 1d02a0ada889..5b79f0c00f57 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -126,7 +126,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { headingId="thread-details-workspace-heading" title="Workspace" separated={false} - showHeading={density === "full"} + showHeading={false} > {props.versionMismatch ? (
@@ -198,7 +198,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index ac0235f85d1c..418f917a3f61 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -97,7 +97,7 @@ export function ThreadLineageRowList(props: {
- {children} -
+ ) + } + > + {showDiagram ? ( + + + Rendering diagram +
+ } + > + + + + ) : ( + children + )} + ); } @@ -3300,7 +3400,7 @@ const CHAT_MARKDOWN_COMPONENTS = { return {children}; }, pre: function MarkdownPre({ node, children, ...props }) { - const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use( + const { resolvedTheme, diffThemeName, expandMedia, isStreaming, onRunShellCommand, text } = use( ChatMarkdownRendererContext, ); const codeBlock = extractCodeBlock(children); @@ -3310,6 +3410,42 @@ const CHAT_MARKDOWN_COMPONENTS = { const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); + const highlightedCode = ( + {children}} + > + {/* Reserve the block's height but stay hidden until Shiki has colored + it, so plain text never flashes before the highlighted version. */} + + {children} + + } + > + + + + ); + if (language === "mermaid") { + return ( + expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} + > + {highlightedCode} + + ); + } return ( - {children}} - > - {/* Reserve the block's height but stay hidden until Shiki has colored - it, so plain text never flashes before the highlighted version. */} - - {children} - - } - > - - - + {highlightedCode} ); }, diff --git a/apps/web/src/components/chat/MermaidDiagram.tsx b/apps/web/src/components/chat/MermaidDiagram.tsx new file mode 100644 index 000000000000..30c987918845 --- /dev/null +++ b/apps/web/src/components/chat/MermaidDiagram.tsx @@ -0,0 +1,213 @@ +import DOMPurify from "dompurify"; +import type { Mermaid } from "mermaid"; +import { use, useState } from "react"; + +import { Button } from "../ui/button"; + +type MermaidRenderResult = + | { readonly status: "rendered"; readonly svg: string } + | { readonly status: "error"; readonly message: string; readonly retryable: boolean }; + +let mermaidModulePromise: Promise | null = null; +let renderQueue: Promise = Promise.resolve(); +let nextDiagramId = 0; +const MAX_CACHED_RENDERS = 64; +// Keyed by the full source so distinct diagrams never share an entry. Pending +// renders are never evicted, because use() must get the same promise on retry. +const renderCache = new Map>(); +const settledRenders = new WeakSet>(); + +// Mermaid is ~1MB, so it only loads once a diagram is actually shown. +function loadMermaid(): Promise { + mermaidModulePromise ??= import("mermaid") + .then((module) => module.default) + .catch((error: unknown) => { + mermaidModulePromise = null; + throw error; + }); + return mermaidModulePromise; +} + +const REMOTE_CSS_URL = /url\(\s*(?!['"]?#)[^)]*\)/gi; +let purifier: ReturnType | null = null; + +// Diagrams can come from untrusted PR descriptions, so strip anything that can +// navigate, run script, or fetch remote content on top of Mermaid's own strict +// sanitization. CSS keeps only local url(#id) references; label text is untouched. +function sanitizeMermaidSvg(svg: string): string { + if (!purifier) { + purifier = DOMPurify(window); + purifier.addHook("uponSanitizeElement", (node, data) => { + if (data.tagName === "style" && node.textContent) { + node.textContent = node.textContent.replace(REMOTE_CSS_URL, "none"); + } + }); + purifier.addHook("uponSanitizeAttribute", (_node, data) => { + if (data.attrName === "style") + data.attrValue = data.attrValue.replace(REMOTE_CSS_URL, "none"); + }); + } + return purifier.sanitize(svg, { + ADD_TAGS: ["foreignObject"], + HTML_INTEGRATION_POINTS: { foreignobject: true }, + FORBID_ATTR: ["href", "xlink:href", "src", "srcset"], + FORBID_TAGS: ["a", "img", "image", "script"], + USE_PROFILES: { svg: true, svgFilters: true, html: true }, + }); +} + +// Mermaid also lazy-loads diagram chunks inside render(); losing the network +// there is worth a retry, unlike a syntax error. +const CHUNK_LOAD_ERROR = /dynamically imported module|importing a module script|failed to fetch/i; + +async function renderMermaid( + source: string, + theme: "light" | "dark", +): Promise { + const id = `mermaid-diagram-${nextDiagramId++}`; + let mermaid: Mermaid; + try { + mermaid = await loadMermaid(); + } catch { + return { status: "error", message: "Mermaid failed to load.", retryable: true }; + } + try { + // initialize() mutates global config, so renders run one at a time. + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + // HTML labels and theme CSS are mounted while Mermaid lays the diagram + // out, before sanitizing, so diagram directives must not set them. + secure: [ + "secure", + "securityLevel", + "startOnLoad", + "maxTextSize", + "suppressErrorRendering", + "maxEdges", + "htmlLabels", + "themeCSS", + ], + htmlLabels: false, + flowchart: { htmlLabels: false }, + theme: theme === "dark" ? "dark" : "default", + fontFamily: getComputedStyle(document.body).fontFamily, + }); + const { svg } = await mermaid.render(id, source); + return { status: "rendered", svg: sanitizeMermaidSvg(svg) }; + } catch (error) { + const message = error instanceof Error ? error.message : "The diagram could not be rendered."; + return { status: "error", message, retryable: CHUNK_LOAD_ERROR.test(message) }; + } finally { + document.getElementById(`d${id}`)?.remove(); + } +} + +function evictSettledRenders() { + for (const [key, result] of renderCache) { + if (renderCache.size <= MAX_CACHED_RENDERS) return; + if (settledRenders.has(result)) renderCache.delete(key); + } +} + +function mermaidRenderKey(source: string, theme: "light" | "dark") { + return `${theme}\n${source}`; +} + +function mermaidRenderPromise(source: string, theme: "light" | "dark") { + const key = mermaidRenderKey(source, theme); + const cached = renderCache.get(key); + if (cached) { + renderCache.delete(key); + renderCache.set(key, cached); + return cached; + } + const result = renderQueue.then(() => renderMermaid(source, theme)); + renderQueue = result; + void result.then(() => { + settledRenders.add(result); + evictSettledRenders(); + }); + renderCache.set(key, result); + evictSettledRenders(); + return result; +} + +let expandedImageUrl: string | null = null; + +/** + * Converts a rendered diagram into a standalone image with fixed size and background. + * A blob URL, unlike a data URL, passes the desktop connect-src policy that media + * save and copy actions fetch through. Only one diagram is expanded at a time, so + * the previous URL is released. + */ +function mermaidImageUrl(svg: string): string { + const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml"); + const element = svgDocument.documentElement; + const viewBox = element.getAttribute("viewBox")?.trim().split(/\s+/).map(Number); + if (viewBox?.length === 4 && viewBox.every(Number.isFinite)) { + element.setAttribute("width", String(viewBox[2])); + element.setAttribute("height", String(viewBox[3])); + } + element.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + element.style.maxWidth = "none"; + element.style.backgroundColor = getComputedStyle(document.body).backgroundColor; + if (expandedImageUrl) URL.revokeObjectURL(expandedImageUrl); + expandedImageUrl = URL.createObjectURL( + new Blob([new XMLSerializer().serializeToString(element)], { type: "image/svg+xml" }), + ); + return expandedImageUrl; +} + +/** Suspends until the diagram renders; failures show the parser message above the source. */ +export function MermaidDiagram({ + source, + theme, + onExpand, +}: { + source: string; + theme: "light" | "dark"; + onExpand: (imageUrl: string) => void; +}) { + const [, setAttempt] = useState(0); + const result = use(mermaidRenderPromise(source.trim(), theme)); + + if (result.status === "error") { + return ( +
+
+

Unable to render diagram: {result.message}

+ {result.retryable ? ( + + ) : null} +
+
+          {source}
+        
+
+ ); + } + + return ( +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8315c8a24db9..963f68b03ae3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,9 @@ importers: culori: specifier: ^4.0.2 version: 4.0.2 + dompurify: + specifier: ^3.4.16 + version: 3.4.16 effect: specifier: 4.0.0-rc.115 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) @@ -716,6 +719,9 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) + mermaid: + specifier: ^11.17.2 + version: 11.17.2 react: specifier: 19.2.6 version: 19.2.6 @@ -1169,6 +1175,9 @@ packages: react-devtools-core: optional: true + '@antfu/install-pkg@2.1.0': + resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} + '@anthropic-ai/claude-agent-sdk@0.3.276': resolution: {integrity: sha512-Dic43v4uuGLhibPAArWy3RSfq3zHdn+4Oh2AI2/18a5tlbNKkvChEpazyp8c+grUJvzxkzt0jETCa1wrADbkyw==} engines: {node: '>=18.0.0'} @@ -1816,6 +1825,9 @@ packages: '@blazediff/core@1.10.0': resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -1876,6 +1888,9 @@ packages: resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@clack/core@1.5.1': resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} engines: {node: '>= 20.12.0'} @@ -3084,6 +3099,12 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.7': + resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -3403,6 +3424,9 @@ packages: '@material/material-color-utilities@0.3.0': resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} + '@mermaid-js/parser@1.2.1': + resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -5341,6 +5365,99 @@ packages: '@types/culori@4.0.1': resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -5365,6 +5482,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -5448,6 +5568,9 @@ packages: '@types/three@0.180.0': resolution: {integrity: sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -5601,6 +5724,9 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/config@0.3.0': resolution: {integrity: sha512-Tf5k5y2F478oTiQcU5R8Ntix1UejE6NdduZnI7aa1XXxjCtifX1XdRS/D2uTjiQAwIL3pLa1LSAN80ABbba+TQ==} hasBin: true @@ -6657,6 +6783,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -6738,6 +6868,12 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -6794,6 +6930,162 @@ packages: resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.3: + resolution: {integrity: sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -6801,6 +7093,9 @@ packages: date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + dbus-next@0.10.2: resolution: {integrity: sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==} @@ -6873,6 +7168,9 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -6943,6 +7241,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -7237,6 +7538,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -7682,6 +7986,9 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true + fastdom@1.0.12: + resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -7913,6 +8220,9 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -8051,6 +8361,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -8061,6 +8374,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -8292,9 +8612,16 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -8312,6 +8639,12 @@ packages: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -8414,6 +8747,9 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -8500,6 +8836,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -8585,6 +8926,9 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + mermaid@11.17.2: + resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} + meshoptimizer@0.22.0: resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==} @@ -9187,6 +9531,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -9317,6 +9664,12 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -9816,6 +10169,9 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.0.0-rc.17: resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9829,10 +10185,16 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -10132,6 +10494,9 @@ packages: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} + strictdom@1.0.1: + resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -10178,6 +10543,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -10358,6 +10726,10 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -10624,6 +10996,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -11145,6 +11521,11 @@ snapshots: '@types/react': 19.3.0 react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@antfu/install-pkg@2.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.3.1 + '@anthropic-ai/claude-agent-sdk@0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.6.5) @@ -12029,6 +12410,8 @@ snapshots: '@blazediff/core@1.10.0': {} + '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -12070,6 +12453,8 @@ snapshots: dependencies: fontkitten: 1.0.3 + '@chevrotain/types@11.1.2': {} + '@clack/core@1.5.1': dependencies: fast-wrap-ansi: 0.2.2 @@ -13406,6 +13791,14 @@ snapshots: '@iarna/toml@2.2.5': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.7': + dependencies: + '@antfu/install-pkg': 2.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.4': @@ -13683,6 +14076,10 @@ snapshots: '@material/material-color-utilities@0.3.0': {} + '@mermaid-js/parser@1.2.1': + dependencies: + '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@4.6.5)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.7) @@ -14773,7 +15170,7 @@ snapshots: '@shikijs/types@4.3.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/vscode-textmate@10.0.2': {} @@ -15314,6 +15711,123 @@ snapshots: '@types/culori@4.0.1': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.12': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-zoom@3.0.9': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.12 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.9 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -15345,6 +15859,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -15441,6 +15957,9 @@ snapshots: fflate: 0.8.3 meshoptimizer: 0.22.0 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -15533,6 +16052,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/config@0.3.0': dependencies: '@vercel/routing-utils': 6.2.0 @@ -16618,6 +17142,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -16699,6 +17225,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cross-dirname@0.1.0: optional: true @@ -16762,6 +17296,190 @@ snapshots: culori@4.0.2: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.3): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.3 + + cytoscape-fcose@2.2.0(cytoscape@3.34.3): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.3 + + cytoscape@3.34.3: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-urls@7.0.0(@noble/hashes@1.8.0): dependencies: whatwg-mimetype: 5.0.0 @@ -16771,6 +17489,8 @@ snapshots: date-fns@4.4.0: {} + dayjs@1.11.23: {} + dbus-next@0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4): dependencies: '@nornagon/put': 0.0.8 @@ -16835,6 +17555,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} denque@2.1.0: @@ -16896,6 +17620,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.16: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -17097,6 +17825,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + es-toolkit@1.52.0: {} + es6-error@4.1.1: optional: true @@ -17676,6 +18406,10 @@ snapshots: path-expression-matcher: 1.6.2 strnum: 2.4.2 + fastdom@1.0.12: + dependencies: + strictdom: 1.0.1 + fastest-levenshtein@1.0.16: {} fb-dotslash@0.5.8: {} @@ -17952,6 +18686,8 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + hachure-fill@0.5.2: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -18135,7 +18871,6 @@ snapshots: iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - optional: true iconv-lite@0.7.2: dependencies: @@ -18150,6 +18885,8 @@ snapshots: immediate@3.0.6: {} + import-meta-resolve@4.2.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -18159,6 +18896,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -18372,10 +19113,16 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + katex@0.16.47: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -18398,6 +19145,10 @@ snapshots: lan-network@0.2.1: {} + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lazy-val@1.0.5: {} leven@3.1.0: {} @@ -18483,6 +19234,8 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 + lodash-es@4.18.1: {} + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} @@ -18556,6 +19309,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -18752,6 +19507,31 @@ snapshots: merge-stream@2.0.0: {} + mermaid@11.17.2: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.7 + '@mermaid-js/parser': 1.2.1 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.3 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.3) + cytoscape-fcose: 2.2.0(cytoscape@3.34.3) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.16 + es-toolkit: 1.52.0 + fastdom: 1.0.12 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.2 + meshoptimizer@0.22.0: {} metro-babel-transformer@0.87.1: @@ -19653,6 +20433,8 @@ snapshots: path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@3.0.0: {} path-expression-matcher@1.6.2: {} @@ -19769,6 +20551,13 @@ snapshots: pngjs@7.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -20386,6 +21175,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.3: {} + rolldown@1.0.0-rc.17: dependencies: '@oxc-project/types': 0.127.0 @@ -20431,6 +21222,13 @@ snapshots: rope-sequence@1.3.4: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -20441,6 +21239,8 @@ snapshots: transitivePeerDependencies: - supports-color + rw@1.3.3: {} + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -20791,6 +21591,8 @@ snapshots: strict-uri-encode@2.0.0: {} + strictdom@1.0.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -20845,6 +21647,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.4.0: {} + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -21018,6 +21822,8 @@ snapshots: ts-algebra@2.0.0: {} + ts-dedent@2.3.0: {} + tslib@2.8.1: {} type-fest@0.13.1: @@ -21240,6 +22046,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@14.0.2: {} + uuid@7.0.3: {} valibot@1.2.0(typescript@7.0.2): diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 7e9e4f5db83c..3376a841e78d 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -599,6 +599,33 @@ "licenseId": "MIT", "copyrights": ["Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)"] } + }, + { + "license": "MIT", + "name": "fastdom", + "sourceUrl": "https://github.com/wilsonpage/fastdom", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2016 Wilson Page"] + } + }, + { + "license": "MIT", + "name": "strictdom", + "sourceUrl": "https://github.com/wilsonpage/strictdom", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2013 Wilson Page"] + } + }, + { + "license": "MIT", + "name": "khroma", + "sourceUrl": "https://github.com/fabiospampinato/khroma", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2019-present Fabio Spampinato, Andrew Maney"] + } } ] } From f1dcd93931b322d587a7253856f44625a6afbce0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 14:15:20 -0700 Subject: [PATCH 18/78] feat(web): Nightly tells you to get the beta mobile app (#15070) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/NightlyMobileBeta.tsx | 159 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 16 +- apps/web/src/routes/__root.tsx | 3 + docs/user/install.md | 8 + 4 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/NightlyMobileBeta.tsx diff --git a/apps/web/src/components/NightlyMobileBeta.tsx b/apps/web/src/components/NightlyMobileBeta.tsx new file mode 100644 index 000000000000..45e11c27e159 --- /dev/null +++ b/apps/web/src/components/NightlyMobileBeta.tsx @@ -0,0 +1,159 @@ +import { parseSemver } from "@t3tools/shared/semver"; +import { useNavigate } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { SmartphoneIcon } from "lucide-react"; +import { useEffect } from "react"; + +import { APP_VERSION, HOSTED_APP_CHANNEL } from "../branding"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; +import { AndroidIcon, AppleIcon } from "./Icons"; +import { SettingsRow } from "./settings/settingsLayout"; +import { Button } from "./ui/button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover"; +import { QRCodeSvg } from "./ui/qr-code"; +import { toastManager } from "./ui/toast"; + +// Orchestrator V2 ships to Nightly first. The App Store and Google Play apps are +// still V1 and cannot connect to a V2 server, so Nightly users need the V2 beta +// app. Delete this file when the store apps move to V2. See #14871. + +/** True on Nightly desktop, `npx t3@nightly`, and the hosted Nightly app. */ +export const IS_NIGHTLY_BUILD = + parseSemver(APP_VERSION)?.prerelease[0] === "nightly" || HOSTED_APP_CHANNEL === "nightly"; + +const IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/XgaxaRtd"; +const ANDROID_BETA_GROUP_URL = "https://groups.google.com/g/t3-code-v2-beta"; +const ANDROID_PLAY_TESTING_URL = "https://play.google.com/apps/testing/com.t3tools.t3code"; + +const ROW_ID = "nightly-mobile-beta"; +const NOTICE_DISMISSED_STORAGE_KEY = "t3code:nightly-mobile-beta-notice-dismissed:v1"; + +// Guards against a second toast from a remount or a Strict Mode effect replay. +let noticeShown = false; + +function isNoticeDismissed(): boolean { + try { + return getLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, Schema.Boolean) === true; + } catch { + return false; + } +} + +function dismissNotice() { + try { + setLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, true, Schema.Boolean); + } catch { + // Storage is unavailable. The notice shows again on the next launch. + } +} + +/** + * One-time Nightly toast that points to the beta mobile app. Any close (the + * corner button, swipe, Dismiss, or the action) hides it for good on this client. + */ +export function NightlyMobileBetaNotice() { + const navigate = useNavigate(); + + useEffect(() => { + if (!IS_NIGHTLY_BUILD || noticeShown || isNoticeDismissed()) return; + noticeShown = true; + const toastId = toastManager.add({ + title: "Nightly needs the beta mobile app", + description: + "Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.", + timeout: 0, + onClose: dismissNotice, + actionProps: { + children: "Get the beta app", + onClick: () => { + toastManager.close(toastId); + void navigate({ to: "/settings/general", hash: ROW_ID }); + }, + }, + data: { + leadingIcon: , + actionLayout: "stacked-end", + secondaryActionProps: { + children: "Dismiss", + onClick: () => toastManager.close(toastId), + }, + secondaryActionVariant: "ghost", + }, + }); + }, [navigate]); + + return null; +} + +/** QR code plus a copy button for one beta link. */ +function BetaLinkQr({ url, label }: { url: string; label: string }) { + const { copyToClipboard, isCopied } = useCopyToClipboard(); + return ( +
+
+ +
+ +
+ ); +} + +/** Settings → General → About row with the beta app links. Render it only for Nightly. */ +export function NightlyMobileBetaRow() { + return ( + + + }> + + iPhone + + +
+
+ TestFlight beta +

Scan with your iPhone camera.

+
+ +
+
+
+ + }> + + Android + + +
+
+ Google Play beta +

+ Use the same Google account for both steps. Step 2 can take up to an hour to + work after you join the group. +

+
+
+
+

1. Join the group

+ +
+
+

2. Become a tester

+ +
+
+
+
+
+
+ } + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8c5a3a935c5a..1104f875f6c5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -52,6 +52,7 @@ import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; +import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -282,6 +283,9 @@ function AboutVersionSection() { const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; + // Show the beta app links as soon as someone picks Nightly, before the update installs. + const showNightlyMobileBeta = + IS_NIGHTLY_BUILD || (hasDesktopBridge && selectedUpdateChannel === "nightly"); const handleUpdateChannelChange = useCallback( (channel: DesktopUpdateChannel) => { @@ -502,6 +506,7 @@ function AboutVersionSection() { } /> ) : null} + {showNightlyMobileBeta ? : null} ); } @@ -3329,10 +3334,13 @@ export function GeneralSettingsPanel() { {isElectron || HOSTED_APP_CHANNEL ? ( ) : ( - } - description="Current version of the application." - /> + <> + } + description="Current version of the application." + /> + {IS_NIGHTLY_BUILD ? : null} + )} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b5cefd1342ea..c8963ea635ee 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,6 +28,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; +import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; @@ -242,6 +243,8 @@ function RootRouteView() { ) : null} {primaryEnvironmentAuthenticated ? : null} + {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */} + {appShell} {/* Above the router: a theme draft is judged by walking the app, so the editor has to survive navigation away from settings. */} diff --git a/docs/user/install.md b/docs/user/install.md index 3a9fb3296257..77b09863d746 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -98,6 +98,14 @@ Install T3 Code from the The phone connects to a server on another machine. Follow [remote access](./remote-access.md) to link it through T3 Connect or a pairing URL. +Nightly builds need the beta app. The store apps cannot connect to them. A Nightly build also +shows these links as QR codes in **Settings → General → Mobile app**. + +- **iPhone and iPad:** join the [TestFlight beta](https://testflight.apple.com/join/XgaxaRtd). +- **Android:** join the [beta group](https://groups.google.com/g/t3-code-v2-beta). With the same + Google account, open the [Google Play testing page](https://play.google.com/apps/testing/com.t3tools.t3code) + and become a tester. + If the app crashes during launch, open Settings → Diagnostics on the next launch that succeeds. It lists startup crashes from the last 7 days with the error and component stack that store crash reports leave out. Copy the report and paste it From a21702d0e58ad20263dc965d84472bb6d7eb38c8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 14:28:20 -0700 Subject: [PATCH 19/78] test(server): ACP adapter tests no longer race the prompt settle (#15330) Takes over #14876. Co-authored-by: tris203 Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/AcpAdapterV2.test.ts | 278 +++++------------- 1 file changed, 80 insertions(+), 198 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 146924586763..b6cb0be45acf 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -4925,9 +4925,13 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -4950,7 +4954,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -4986,28 +4989,13 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); // The still-running subagent defers finalize after session/prompt returns. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId }); let terminalStatus: string | null = null; while (terminalStatus === null) { @@ -5033,10 +5021,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5072,7 +5064,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5107,28 +5098,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let subagentTurnItemId: string | null = null; let firstTerminalStatus: string | null = null; @@ -5193,10 +5169,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5230,7 +5210,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -5272,28 +5251,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5352,7 +5316,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const secondPromptWireReturned = yield* Deferred.make(); const releaseSecondPromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); @@ -5361,7 +5324,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5395,7 +5363,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5456,28 +5423,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -5601,7 +5553,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const baseClock = yield* Clock.Clock; const finalizationClockRead = yield* Deferred.make(); const releaseFinalizationClockRead = yield* Deferred.make(); @@ -5629,7 +5580,12 @@ describe("AcpAdapterV2", () => { let sessionUpdateHandler: | Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -5668,7 +5624,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -5719,18 +5674,7 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); yield* TestClock.adjust("1 second"); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); @@ -6451,14 +6395,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const bufferedAssistantText = "BUFFERED_WAKE_AFTER_USER_ATTACH"; const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6492,7 +6440,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6548,28 +6495,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -6752,14 +6684,18 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const continuationRequests: Array = []; - const protocolEvents = yield* Queue.bounded(256); const promptWireReturned = yield* Deferred.make(); const releasePromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -6795,7 +6731,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -6875,18 +6810,13 @@ describe("AcpAdapterV2", () => { }, }); yield* Deferred.succeed(releasePromptCompletion, undefined); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7223,7 +7153,6 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-pending-continuation"; @@ -7231,7 +7160,12 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7265,7 +7199,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7321,28 +7254,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7506,14 +7424,18 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-root-then-child"; let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7547,7 +7469,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => @@ -7603,28 +7524,13 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); let firstTerminalStatus: string | null = null; while (firstTerminalStatus === null) { @@ -7760,12 +7666,16 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; let cancelCalled = false; let runtimeOrdinalSeen = 0; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -7812,7 +7722,6 @@ describe("AcpAdapterV2", () => { runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; }, - protocolEvents, wrapCancel: (cancel) => Effect.sync(() => { cancelCalled = true; @@ -7853,28 +7762,13 @@ describe("AcpAdapterV2", () => { ); // The still-running subagent defers finalize after session/prompt returns, // so the interrupt below hits a settled turn held open for background work. - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); assert.isFalse( cancelCalled, "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)", @@ -13725,10 +13619,14 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); const adapter = makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, crypto: yield* Crypto.Crypto, instanceId, flavor: { @@ -13762,7 +13660,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, }), }, fileSystem, @@ -13799,32 +13696,17 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: firstProviderTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: firstProviderTurnId, + requestRuntimeRestart: true, + }); let subagentStatus: string | null = null; let firstTerminalStatus: string | null = null; From b05adb70b00feac0883b2e364256100cfab4b6ca Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 14:55:47 -0700 Subject: [PATCH 20/78] feat(usage): fold preview model IDs into the model they belong to (#15333) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/usage/UsageService.ts | 18 +- .../server/src/usage/usageAggregation.test.ts | 41 ++- apps/server/src/usage/usageAggregation.ts | 35 +- .../components/usage/UsagePriceOverrides.tsx | 331 +++++++++++------- .../components/usage/usagePriceTable.test.ts | 59 +++- .../src/components/usage/usagePriceTable.ts | 38 ++ .../usage/usagePriceTargets.test.ts | 32 ++ .../src/components/usage/usagePriceTargets.ts | 34 +- docs/user/usage.md | 5 + packages/client-runtime/src/state/server.ts | 15 +- packages/contracts/src/environment.ts | 2 + packages/contracts/src/settings.ts | 11 + packages/shared/src/serverSettings.ts | 9 + 14 files changed, 471 insertions(+), 160 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 5381645e9fcf..16a4a93b3f20 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -233,6 +233,7 @@ export const make = Effect.gen(function* () { environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, + usageModelAliases: true, threadPinning: true, threadPinReorder: true, threadActiveReorder: true, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index dc3121fc0024..ca56c36028bb 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -53,7 +53,7 @@ import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEn import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; import { readCursorAccountUsage } from "./cursorUsageReader.ts"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -821,6 +821,7 @@ export const make = Effect.gen(function* () { ...hourlyWindow, rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + modelAliases: resolveModelAliases(settings.usageModelAliases), }); const sources: UsageSource[] = []; @@ -925,17 +926,13 @@ export const make = Effect.gen(function* () { }); /** - * In-flight scans by window and custom prices, so concurrent identical requests (the usage + * In-flight scans by window and usage settings, so concurrent identical requests (the usage * page open on two clients at once) share one scan instead of racing over * the same corpus twice. */ const inflightScans = new Map>(); - const scanKey = ( - input: UsageSummaryInput, - priceOverrides: ServerSettingsValue["usagePriceOverrides"], - cursorKeychainUsageEnabled: boolean, - ): string => + const scanKey = (input: UsageSummaryInput, settings: ServerSettingsValue): string => JSON.stringify([ input.timeZone, input.sinceDay, @@ -943,13 +940,14 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - priceOverrides, - cursorKeychainUsageEnabled, + settings.usagePriceOverrides, + settings.usageModelAliases, + settings.cursorKeychainUsageEnabled, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { const settings = yield* readSettings; - const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled); + const key = scanKey(input, settings); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 9fea9cd96644..a27a9b101cc3 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -253,6 +253,25 @@ describe("UsageAggregator", () => { expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); }); + it("folds a mapped model into its target and prices it there", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }), + }); + aggregator.add(record()); + aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" })); + const result = aggregator.finish(); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.model).toBe("claude-fable-5"); + expect(result.buckets[0]?.records).toBe(2); + expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9); + expect(result.buckets[0]?.unpricedRecords).toBe(0); + }); + it("separates providers and models into their own buckets", () => { const result = aggregate([ record(), @@ -263,3 +282,23 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(3); }); }); + +describe("resolveModelAliases", () => { + it("follows chains to the final model and drops chains that enter a loop", () => { + expect( + resolveModelAliases({ + "preview[1m]": "preview", + preview: "example-model", + loop: "back", + back: "loop", + intoLoop: "loop", + self: "self", + }), + ).toEqual( + new Map([ + ["preview[1m]", "example-model"], + ["preview", "example-model"], + ]), + ); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 0652ca4b7dcf..f2923871a323 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -89,11 +89,35 @@ export interface AggregateOptions { readonly untilDay: string; readonly rates: RateTable; readonly priceOverrides?: RateTable; + /** From {@link resolveModelAliases}. Mapped records bucket and price as their target. */ + readonly modelAliases?: ReadonlyMap; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; } +/** + * Resolves user model mappings to their final target, so `a -> b` and + * `b -> c` both land on `c`. A model whose chain enters a loop is left + * unmapped. + */ +export function resolveModelAliases( + aliases: Readonly>, +): ReadonlyMap { + const resolved = new Map(); + for (const model of Object.keys(aliases)) { + const seen = new Set([model]); + let target = aliases[model]!; + while (Object.hasOwn(aliases, target) && !seen.has(target)) { + seen.add(target); + target = aliases[target]!; + } + // Stopping on a mapped model means the chain entered a loop. + if (!Object.hasOwn(aliases, target)) resolved.set(model, target); + } + return resolved; +} + export interface AggregateResult { readonly buckets: readonly UsageBucket[]; /** Records dropped because an earlier record carried the same dedupe key. */ @@ -147,7 +171,8 @@ export class UsageAggregator { * can derive per-window facts (distinct sessions, for one) from the records * that landed rather than everything the mtime prefilter happened to admit. */ - add(record: UsageRecord, sourcePath?: string): boolean { + add(input: UsageRecord, sourcePath?: string): boolean { + const record = this.#mapModel(input); if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -217,6 +242,14 @@ export class UsageAggregator { return true; } + /** The target's own rate applies, so a provider-specific `rateModel` is dropped. */ + #mapModel(record: UsageRecord): UsageRecord { + const model = this.#options.modelAliases?.get(record.model); + if (model === undefined) return record; + const { rateModel: _rateModel, ...rest } = record; + return { ...rest, model }; + } + /** * Records arrive file by file in time order, so most land in the bucket the * previous record used. Checking that first skips building and hashing a diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index c9af1c0e1770..0b52687c81b0 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -34,6 +34,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { USAGE_PRICE_FIELDS } from "./usagePriceForm"; import { isEmptyUsagePriceDraft, + usageAliasCell, usagePriceCell, usagePriceTableChanges, usagePriceTableErrors, @@ -63,6 +64,10 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => environmentId, label: environment.entry.target.label, prices: settings?.usagePriceOverrides ?? null, + aliases: + environment.serverConfig?.environment.capabilities.usageModelAliases === true + ? (settings?.usageModelAliases ?? null) + : null, unavailable: environment.connection.phase !== "connected" ? "Offline" @@ -91,7 +96,10 @@ type SaveAttempt = { >; }; -/** Edits custom model prices. `initialModel` opens with a new row for that model. */ +/** + * Edits custom model prices and mappings. A mapped model's usage counts as its + * target model. `initialModel` opens with a new row for that model. + */ export function UsagePriceOverrides({ usage, initialSelectedEnvironmentIds, @@ -108,12 +116,13 @@ export function UsagePriceOverrides({ const selected = environments.filter( (environment) => selectedIds === null || selectedIds.has(environment.environmentId), ); - // A model that already has a custom price somewhere is edited in its existing row. + // A model that already has a custom price or mapping is edited in its existing row. const [drafts, setDrafts] = useState(() => initialModel === undefined || selected.some( (environment) => - environment.prices !== null && Object.hasOwn(environment.prices, initialModel), + Object.hasOwn(environment.prices ?? {}, initialModel) || + Object.hasOwn(environment.aliases ?? {}, initialModel), ) ? [] : [{ id: "new:initial", model: initialModel, isNew: true, values: {} }], @@ -124,7 +133,12 @@ export function UsagePriceOverrides({ const nextRowId = useRef(0); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); const customModels = [ - ...new Set(selected.flatMap((environment) => Object.keys(environment.prices ?? {}))), + ...new Set( + selected.flatMap((environment) => [ + ...Object.keys(environment.prices ?? {}), + ...Object.keys(environment.aliases ?? {}), + ]), + ), ].sort(); const models = [ ...new Set([ @@ -191,19 +205,31 @@ export function UsagePriceOverrides({ ); setAttempt(null); }; + // An existing row edited back to its saved values has nothing left to save. + const matchesSaved = ( + row: UsagePriceDraft, + cell: { value: string; placeholder: string }, + value: string, + ) => + !row.isNew && + cell.placeholder !== "Mixed" && + cell.placeholder !== "Unavailable" && + value === cell.value; + const updateOrDropDraft = (draft: UsagePriceDraft) => { + if (!draft.isNew && Object.keys(draft.values).length === 0 && draft.alias === undefined) + setDrafts((previous) => previous.filter((entry) => entry.id !== draft.id)); + else updateDraft(draft); + }; const editCell = (row: UsagePriceDraft, field: UsagePriceField, value: string) => { const values = { ...row.values, [field]: value }; const original = usagePriceCell(selected, row.model, field); - if ( - !row.isNew && - original.placeholder !== "Mixed" && - original.placeholder !== "Unavailable" && - value === original.value - ) - delete values[field]; - if (!row.isNew && Object.keys(values).length === 0) - setDrafts((previous) => previous.filter((entry) => entry.id !== row.id)); - else updateDraft({ ...row, values }); + if (matchesSaved(row, original, value)) delete values[field]; + updateOrDropDraft({ ...row, values }); + }; + const editAlias = (row: UsagePriceDraft, value: string) => { + const { alias: _alias, ...rest } = row; + const original = usageAliasCell(selected, row.model); + updateOrDropDraft(matchesSaved(row, original, value) ? rest : { ...rest, alias: value }); }; const save = async (retry = false) => { if (pending) return; @@ -214,7 +240,7 @@ export function UsagePriceOverrides({ (destination) => environments.find( (environment) => environment.environmentId === destination.environmentId, - ) ?? { ...destination, prices: null, unavailable: "Environment removed" }, + ) ?? { ...destination, prices: null, aliases: null, unavailable: "Environment removed" }, ); const changes = new Map( targets.map((target) => [target.environmentId, usagePriceTableChanges(target, edits)]), @@ -257,11 +283,11 @@ export function UsagePriceOverrides({ if (!pending) onOpenChange(open); }} > - + Custom model prices - Prices apply to all past and future usage on the environments you select. + Prices and mappings apply to all past and future usage on the environments you select. @@ -326,9 +352,10 @@ export function UsagePriceOverrides({ ) : ( <>
- +
- + + {USAGE_PRICE_FIELDS.map((field) => ( ))} @@ -337,6 +364,7 @@ export function UsagePriceOverrides({ Model ID + Map to {USAGE_PRICE_FIELDS.map((field) => ( {field.label} ))} @@ -360,132 +388,157 @@ export function UsagePriceOverrides({ {rows.length === 0 ? ( - +

{selected.some((environment) => environment.prices === null) ? "Some environment prices are unavailable." - : "No custom prices. Add a row to override automatic pricing."} + : "No custom prices or mappings. Add a row to set one."}

) : ( - rows.map((row) => ( - - - {row.isNew ? ( - { - if (node && focusRowRef.current === row.id) { - node.focus(); - focusRowRef.current = null; + rows.map((row) => { + const aliasCell = usageAliasCell(selected, row.model); + const alias = (row.alias ?? aliasCell.value).trim(); + return ( + + + {row.isNew ? ( + { + if (node && focusRowRef.current === row.id) { + node.focus(); + focusRowRef.current = null; + } + }} + aria-label="New model ID" + aria-invalid={ + (row.model.trim() !== "" && errors.has(row.id)) || undefined + } + list="usage-price-models" + placeholder="Model ID" + autoComplete="off" + spellCheck={false} + disabled={locked} + onChange={(event) => + updateDraft({ ...row, model: event.target.value }) } - }} - aria-label="New model ID" - aria-invalid={ - (row.model.trim() !== "" && errors.has(row.id)) || undefined - } - list="usage-price-models" - placeholder="Model ID" - autoComplete="off" - spellCheck={false} - disabled={locked} - onChange={(event) => - updateDraft({ ...row, model: event.target.value }) - } - /> + /> + ) : ( + + {row.model} + + )} + {errors.has(row.id) && + (row.model.trim() !== "" || + Object.values(row.values).some((value) => value !== "")) ? ( +

+ {errors.get(row.id)} +

+ ) : null} +
+ {row.removed ? ( + + + Automatic pricing after saving + + ) : ( - - {row.model} - + + editAlias(row, event.target.value)} + /> + )} - {errors.has(row.id) && - (row.model.trim() !== "" || - Object.values(row.values).some((value) => value !== "")) ? ( -

- {errors.get(row.id)} -

- ) : null} -
- {row.removed ? ( - - - Automatic pricing after saving - - - ) : ( - USAGE_PRICE_FIELDS.map((field) => { - const cell = usagePriceCell(selected, row.model, field.key); - return ( - - - editCell(row, field.key, event.target.value) - } - /> - - ); - }) - )} - - - } - disabled={locked} - aria-label={ - row.removed - ? `Undo reset for ${row.model}` - : row.isNew - ? "Remove new model" - : `Reset price for ${row.model} to automatic` - } - onClick={() => { - if (row.isNew) - setDrafts((previous) => - previous.filter((entry) => entry.id !== row.id), - ); - else if (row.removed) { - if (Object.keys(row.values).length === 0) + {row.removed ? null : alias !== "" ? ( + + + Counted as {alias} + + + ) : ( + USAGE_PRICE_FIELDS.map((field) => { + const cell = usagePriceCell(selected, row.model, field.key); + return ( + + + editCell(row, field.key, event.target.value) + } + /> + + ); + }) + )} + + + } + disabled={locked} + aria-label={ + row.removed + ? `Undo reset for ${row.model}` + : row.isNew + ? "Remove new model" + : `Reset price for ${row.model} to automatic` + } + onClick={() => { + if (row.isNew) setDrafts((previous) => previous.filter((entry) => entry.id !== row.id), ); - else updateDraft({ ...row, removed: false }); - } else updateDraft({ ...row, removed: true }); - }} - > - {row.isNew ? : } - - - {row.removed - ? "Undo reset" - : row.isNew - ? "Remove row" - : "Reset to automatic"} - - - -
- )) + else if (row.removed) + updateOrDropDraft({ ...row, removed: false }); + else updateDraft({ ...row, removed: true }); + }} + > + {row.isNew ? ( + + ) : ( + + )} + + + {row.removed + ? "Undo reset" + : row.isNew + ? "Remove row" + : "Reset to automatic"} + + + +
+ ); + }) )}
@@ -497,8 +550,20 @@ export function UsagePriceOverrides({