Adopts the round's main features into the v2 architecture: the #9023 media rework (streamed videos, media-file assets, protocol-relative links), #9098 shared live-activity row folded into the v2 working and thinking rows, the #9084/#9078 Claude model catalog for v2 consumers, a native #9005 OpenCode child-session abort in the v2 adapter, #9013's landed LegendList patch, and per-environment sidebar provider entries. For #8600 the server-side pieces land, but auto-settle evaluation stays client-side (reading the new server-owned settings) until the v2 orchestrator grows its own settlement reactor; main's v1-only reactor and coalescer additions are dropped with the rest of the v1 path.
Unified · Split
| @@ -681,7 +681,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 680 unmodified lines | ||
| 681 | 681 | onTouchCancel={handleFeedTouchCancel} |
| 682 | 682 | > |
| 683 | 683 | <ThreadFeed |
| 684 | − key={props.selectedThread.id} | |
| 684 | + key={selectedThreadKey} | |
| 685 | 685 | environmentId={props.environmentId} |
| 686 | 686 | threadId={props.selectedThread.id} |
| 687 | 687 | workspaceRoot={props.threadCwd} |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import * as Haptics from "expo-haptics"; |
| 2 | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | −import { type LegendListRef } from "@legendapp/list/react-native"; | |
| 3 | +import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; | |
| 4 | 4 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 5 | 5 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 6 | 6 | import { |
| @@ -79,7 +79,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 72 unmodified lines | ||
| 79 | 79 | import Animated, { |
| 80 | 80 | FadeIn, |
| 81 | 81 | FadeInUp, |
| 82 | − FadeOut, | |
| 83 | 82 | LinearTransition, |
| 84 | 83 | type SharedValue, |
| 85 | 84 | } from "react-native-reanimated"; |
| @@ -150,13 +149,19 @@ import { · 64 unmodified lines | ||
| 150 | 149 | type ThreadFeedLiveFollowEvent, |
| 151 | 150 | } from "./thread-feed-live-follow"; |
| 152 | 151 | import { |
| 152 | + ThreadDisclosureChevron, | |
| 153 | 153 | THREAD_DISCLOSURE_TRANSITION_MS, |
| 154 | 154 | ThreadWorkGroupToggle, |
| 155 | 155 | ThreadWorkLog, |
| 156 | 156 | } from "./thread-work-log"; |
| 157 | 157 | import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; |
| 158 | 158 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 159 | −import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets"; | |
| 159 | +import { | |
| 160 | + assetEnvironment, | |
| 161 | + useAssetUrl, | |
| 162 | + useAssetUrlState, | |
| 163 | + useRefreshAssetUrl, | |
| 164 | +} from "../../state/assets"; | |
| 160 | 165 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 161 | 166 | import { usePreparedConnection } from "../../state/session"; |
| 162 | 167 | import * as Option from "effect/Option"; |
| @@ -191,9 +196,10 @@ function formatMessageTime(input: string): string { · 28 unmodified lines | ||
| 191 | 196 | } |
| 192 | 197 | |
| 193 | 198 | const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); |
| 194 | −const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140); | |
| 195 | −const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120); | |
| 196 | −const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set(); | |
| 199 | +// Let neighboring rows move out of the new rows' space before showing their text. | |
| 200 | +const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( | |
| 201 | + THREAD_DISCLOSURE_TRANSITION_MS, | |
| 202 | +).duration(140); | |
| 197 | 203 | |
| 198 | 204 | // Entering animations must only play for rows born just now — LegendList |
| 199 | 205 | // remounts rows when they scroll back into view, and replaying an entrance for |
| @@ -340,6 +346,7 @@ function MessageAttachmentFile(props: { · 140 unmodified lines | ||
| 340 | 346 | }) { |
| 341 | 347 | const sourceIdentifier = useId(); |
| 342 | 348 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 349 | + refresh: true, | |
| 343 | 350 | reportFailure: false, |
| 344 | 351 | }); |
| 345 | 352 | const preparedConnection = usePreparedConnection(props.environmentId); |
| @@ -557,9 +564,11 @@ function ThreadMarkdownImageView(props: { · 211 unmodified lines | ||
| 557 | 564 | readonly sourceKey: string; |
| 558 | 565 | readonly unavailable: boolean; |
| 559 | 566 | readonly alt: string | null; |
| 567 | + readonly actionsSource?: MediaActionsSource; | |
| 560 | 568 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 561 | 569 | }) { |
| 562 | 570 | const sourceIdentifier = useId(); |
| 571 | + const mediaActions = useMediaActions(props.actionsSource); | |
| 563 | 572 | const [availableWidth, setAvailableWidth] = useState(0); |
| 564 | 573 | const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); |
| 565 | 574 | const [failedUri, setFailedUri] = useState<string | null>(null); |
| @@ -602,36 +611,51 @@ function ThreadMarkdownImageView(props: { · 36 unmodified lines | ||
| 602 | 611 | ) : ( |
| 603 | 612 | <ActivityIndicator /> |
| 604 | 613 | )} |
| 614 | + {props.actionsSource ? ( | |
| 615 | + <View className="absolute right-1 top-1"> | |
| 616 | + <MediaActionsMenu media={mediaActions} /> | |
| 617 | + </View> | |
| 618 | + ) : null} | |
| 605 | 619 | </View> |
| 606 | 620 | ) : ( |
| 607 | 621 | <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> |
| 608 | − <Pressable | |
| 609 | − accessibilityRole="imagebutton" | |
| 610 | − accessibilityLabel={props.alt ?? "Markdown image"} | |
| 611 | − onPress={() => | |
| 612 | − props.onPressPreview({ | |
| 613 | − kind: "image", | |
| 614 | − uri: props.uri!, | |
| 615 | − name: props.alt ?? "Image", | |
| 616 | − sourceIdentifier, | |
| 617 | − }) | |
| 618 | − } | |
| 619 | − style={{ alignSelf: "flex-start" }} | |
| 620 | − > | |
| 621 | − <View | |
| 622 | − className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | |
| 623 | − style={{ | |
| 624 | − ...frameStyle, | |
| 625 | − }} | |
| 626 | − > | |
| 627 | − <ThreadMarkdownImageRequest | |
| 628 | − key={props.uri} | |
| 629 | − uri={props.uri} | |
| 630 | − onLoad={setSourceSize} | |
| 631 | − onError={() => setFailedUri(props.uri)} | |
| 632 | − /> | |
| 633 | − </View> | |
| 634 | − </Pressable> | |
| 622 | + <View> | |
| 623 | + <MediaActionsMenu media={mediaActions}> | |
| 624 | + <Pressable | |
| 625 | + accessibilityRole="imagebutton" | |
| 626 | + accessibilityLabel={props.alt ?? "Markdown image"} | |
| 627 | + onPress={() => | |
| 628 | + props.onPressPreview({ | |
| 629 | + kind: "image", | |
| 630 | + uri: props.uri!, | |
| 631 | + name: props.alt ?? "Image", | |
| 632 | + sourceIdentifier, | |
| 633 | + actionsSource: props.actionsSource, | |
| 634 | + }) | |
| 635 | + } | |
| 636 | + style={{ alignSelf: "flex-start" }} | |
| 637 | + > | |
| 638 | + <View | |
| 639 | + className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | |
| 640 | + style={{ | |
| 641 | + ...frameStyle, | |
| 642 | + }} | |
| 643 | + > | |
| 644 | + <ThreadMarkdownImageRequest | |
| 645 | + key={props.uri} | |
| 646 | + uri={props.uri} | |
| 647 | + onLoad={setSourceSize} | |
| 648 | + onError={() => setFailedUri(props.uri)} | |
| 649 | + /> | |
| 650 | + </View> | |
| 651 | + </Pressable> | |
| 652 | + </MediaActionsMenu> | |
| 653 | + {props.actionsSource ? ( | |
| 654 | + <View className="absolute right-1 top-1"> | |
| 655 | + <MediaActionsMenu media={mediaActions} /> | |
| 656 | + </View> | |
| 657 | + ) : null} | |
| 658 | + </View> | |
| 635 | 659 | </PresentationSource> |
| 636 | 660 | )} |
| 637 | 661 | {props.alt ? ( |
| @@ -678,9 +702,10 @@ function ThreadMarkdownImageRequest(props: { · 40 unmodified lines | ||
| 678 | 702 | /** Environment-hosted image that loads through a signed asset URL. */ |
| 679 | 703 | function ThreadMarkdownImage(props: { |
| 680 | 704 | readonly environmentId: EnvironmentId; |
| 681 | − readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>; | |
| 705 | + readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>; | |
| 682 | 706 | readonly alt: string | null; |
| 683 | 707 | readonly srcFragment?: string; |
| 708 | + readonly actionsSource?: MediaActionsSource; | |
| 684 | 709 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 685 | 710 | }) { |
| 686 | 711 | const assetUrl = useAssetUrlState(props.environmentId, props.resource); |
| @@ -695,11 +720,59 @@ function ThreadMarkdownImage(props: { · 8 unmodified lines | ||
| 695 | 720 | } |
| 696 | 721 | unavailable={assetUrl._tag === "Failure"} |
| 697 | 722 | alt={props.alt} |
| 723 | + actionsSource={props.actionsSource} | |
| 698 | 724 | onPressPreview={props.onPressPreview} |
| 699 | 725 | /> |
| 700 | 726 | ); |
| 701 | 727 | } |
| 702 | 728 | |
| 729 | +const ThreadMediaVisibleContext = createContext(false); | |
| 730 | +// LegendList only computes hook visibility when the list has a viewability config. | |
| 731 | +const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; | |
| 732 | + | |
| 733 | +function ThreadMediaVisibility(props: { readonly children: ReactNode }) { | |
| 734 | + const [visible, setVisible] = useState(false); | |
| 735 | + useViewabilityAmount<ThreadFeedEntry>( | |
| 736 | + useCallback((token) => setVisible(token.sizeVisible > 0), []), | |
| 737 | + ); | |
| 738 | + return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>; | |
| 739 | +} | |
| 740 | + | |
| 741 | +function ThreadMarkdownVideo(props: { | |
| 742 | + readonly source: MediaVideoPreviewSource; | |
| 743 | + readonly onExpand: (source: MediaVideoPreviewSource) => void; | |
| 744 | +}) { | |
| 745 | + const { source } = props; | |
| 746 | + const visible = useContext(ThreadMediaVisibleContext); | |
| 747 | + const thumbnailKey = mediaVideoThumbnailKey(source); | |
| 748 | + const asset = useAssetUrlState( | |
| 749 | + "environmentId" in source ? source.environmentId : null, | |
| 750 | + "resource" in source ? source.resource : null, | |
| 751 | + ); | |
| 752 | + const refreshAssetUrl = useRefreshAssetUrl( | |
| 753 | + "environmentId" in source ? source.environmentId : null, | |
| 754 | + "resource" in source ? source.resource : null, | |
| 755 | + ); | |
| 756 | + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); | |
| 757 | + return ( | |
| 758 | + <MediaVideoPlayer | |
| 759 | + key={thumbnailKey} | |
| 760 | + uri={uri} | |
| 761 | + resolvePlaybackUri={ | |
| 762 | + "resource" in source | |
| 763 | + ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) | |
| 764 | + : undefined | |
| 765 | + } | |
| 766 | + name={source.name} | |
| 767 | + thumbnailKey={thumbnailKey} | |
| 768 | + thumbnailVisible={visible} | |
| 769 | + unavailable={"resource" in source && asset._tag === "Failure"} | |
| 770 | + actionsSource={source.actionsSource} | |
| 771 | + onExpand={() => props.onExpand(source)} | |
| 772 | + /> | |
| 773 | + ); | |
| 774 | +} | |
| 775 | + | |
| 703 | 776 | function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { |
| 704 | 777 | return ( |
| 705 | 778 | <ThreadMarkdownImageView |
| @@ -1287,21 +1360,16 @@ function useMarkdownStyles( · 581 unmodified lines | ||
| 1287 | 1360 | title: node.title ?? null, |
| 1288 | 1361 | }) ?? undefined) |
| 1289 | 1362 | : undefined, |
| 1290 | − code_inline: ({ content }) => { | |
| 1291 | − const value = content ?? ""; | |
| 1292 | − return ( | |
| 1293 | − <NativeText | |
| 1294 | − className="font-mono" | |
| 1295 | − style={{ | |
| 1296 | − color: inlineCodeTextColor, | |
| 1297 | − fontSize: markdownFontSizes.codeBlockFontSize, | |
| 1298 | − lineHeight: markdownFontSizes.bodyLineHeight, | |
| 1299 | − }} | |
| 1300 | − > | |
| 1301 | − {value} | |
| 1302 | − </NativeText> | |
| 1303 | − ); | |
| 1304 | − }, | |
| 1363 | + code_inline: ({ content }) => ( | |
| 1364 | + <MarkdownInlineCode | |
| 1365 | + content={content ?? ""} | |
| 1366 | + textColor={inlineTextColor} | |
| 1367 | + codeColor={inlineCodeTextColor} | |
| 1368 | + fontSize={markdownFontSizes.codeBlockFontSize} | |
| 1369 | + lineHeight={markdownFontSizes.bodyLineHeight} | |
| 1370 | + onLinkPress={onLinkPress} | |
| 1371 | + /> | |
| 1372 | + ), | |
| 1305 | 1373 | ...(preserveSoftBreaks |
| 1306 | 1374 | ? { |
| 1307 | 1375 | soft_break: () => <NativeText>{"\n"}</NativeText>, |
| @@ -1504,7 +1572,7 @@ function renderFeedEntry( · 196 unmodified lines | ||
| 1504 | 1572 | accessibilityState={{ expanded: entry.expanded }} |
| 1505 | 1573 | onPress={() => props.onToggleTurnFold(entry.runId)} |
| 1506 | 1574 | hitSlop={4} |
| 1507 | − className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" | |
| 1575 | + className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" | |
| 1508 | 1576 | > |
| 1509 | 1577 | <Text |
| 1510 | 1578 | key={props.workRowSizing.textSizeKey} |
| @@ -1516,8 +1584,7 @@ function renderFeedEntry( · 5 unmodified lines | ||
| 1516 | 1584 | expanded={entry.expanded} |
| 1517 | 1585 | collapsedDirection="right" |
| 1518 | 1586 | size={15} |
| 1519 | − tintColorClassName={"accent-icon-subtle"} | |
| 1520 | − type="monochrome" | |
| 1587 | + tintColor={iconSubtleColor} | |
| 1521 | 1588 | /> |
| 1522 | 1589 | </Pressable> |
| 1523 | 1590 | ); |
| @@ -2092,7 +2159,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 568 unmodified lines | ||
| 2092 | 2159 | const [endFollowEnabled, setEndFollowEnabled] = useState(true); |
| 2093 | 2160 | const endFollowEnabledRef = useRef(true); |
| 2094 | 2161 | // A "user scroll session" spans from drag start through the end of its |
| 2095 | − // momentum; only motion inside a session can break follow, so MVCP | |
| 2162 | + // momentum; scroll events only break follow inside that session, so MVCP | |
| 2096 | 2163 | // compensations and programmatic scrolls never strand a follower. |
| 2097 | 2164 | const userScrollSessionRef = useRef(false); |
| 2098 | 2165 | const setEndFollow = useCallback( |
| @@ -2253,14 +2320,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 154 unmodified lines | ||
| 2253 | 2320 | ); |
| 2254 | 2321 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( |
| 2255 | 2322 | (image) => { |
| 2323 | + const media = resolveMarkdownMediaPreview(image.href, { | |
| 2324 | + environmentId: props.environmentId, | |
| 2325 | + threadId: props.threadId, | |
| 2326 | + workspaceRoot: props.workspaceRoot, | |
| 2327 | + imageEmbed: true, | |
| 2328 | + }); | |
| 2329 | + if (media?.kind === "video") { | |
| 2330 | + return ( | |
| 2331 | + <ThreadMarkdownVideo | |
| 2332 | + key={image.href} | |
| 2333 | + source={{ ...media.source, name: image.alt ?? media.source.name }} | |
| 2334 | + onExpand={(source) => setExpandedVideo((current) => current ?? source)} | |
| 2335 | + /> | |
| 2336 | + ); | |
| 2337 | + } | |
| 2256 | 2338 | const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); |
| 2257 | 2339 | if (imageSource._tag === "Direct") { |
| 2258 | 2340 | return ( |
| 2259 | 2341 | <ThreadMarkdownImageView |
| 2260 | − uri={imageSource.uri} | |
| 2342 | + uri={normalizeNativeMarkdownUrl(imageSource.uri)} | |
| 2261 | 2343 | sourceKey={imageSource.uri} |
| 2262 | 2344 | unavailable={false} |
| 2263 | 2345 | alt={image.alt} |
| 2346 | + actionsSource={media?.source.actionsSource} | |
| 2264 | 2347 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} |
| 2265 | 2348 | /> |
| 2266 | 2349 | ); |
| @@ -2272,12 +2355,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 5 unmodified lines | ||
| 2272 | 2355 | <ThreadMarkdownImage |
| 2273 | 2356 | environmentId={props.environmentId} |
| 2274 | 2357 | resource={{ |
| 2275 | − _tag: "workspace-file", | |
| 2358 | + _tag: "media-file", | |
| 2276 | 2359 | threadId: props.threadId, |
| 2277 | 2360 | path: imageSource.path, |
| 2278 | 2361 | }} |
| 2279 | 2362 | alt={image.alt} |
| 2280 | 2363 | srcFragment={markdownImageSourceFragment(image.href)} |
| 2364 | + actionsSource={media?.source.actionsSource} | |
| 2281 | 2365 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} |
| 2282 | 2366 | /> |
| 2283 | 2367 | ); |
| @@ -2290,12 +2374,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 6 unmodified lines | ||
| 2290 | 2374 | threadId: props.threadId, |
| 2291 | 2375 | workspaceRoot: props.workspaceRoot, |
| 2292 | 2376 | }); |
| 2377 | + const media = viewedImage | |
| 2378 | + ? resolveMarkdownMediaPreview(image.href, { | |
| 2379 | + environmentId: props.environmentId, | |
| 2380 | + threadId: props.threadId, | |
| 2381 | + workspaceRoot: props.workspaceRoot, | |
| 2382 | + imageEmbed: true, | |
| 2383 | + }) | |
| 2384 | + : null; | |
| 2385 | + const actionsSource = media?.source.actionsSource; | |
| 2293 | 2386 | return viewedImage ? ( |
| 2294 | 2387 | <ThreadMarkdownImage |
| 2295 | 2388 | environmentId={props.environmentId} |
| 2296 | 2389 | resource={viewedImage.resource} |
| 2297 | 2390 | alt={viewedImage.alt} |
| 2298 | 2391 | srcFragment={viewedImage.srcFragment} |
| 2392 | + actionsSource={ | |
| 2393 | + actionsSource && "resource" in actionsSource | |
| 2394 | + ? { ...actionsSource, resource: viewedImage.resource } | |
| 2395 | + : undefined | |
| 2396 | + } | |
| 2299 | 2397 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} |
| 2300 | 2398 | /> |
| 2301 | 2399 | ) : null; |
| @@ -2572,13 +2670,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 270 unmodified lines | ||
| 2572 | 2670 | } |
| 2573 | 2671 | disclosureSettleFrameRef.current = requestAnimationFrame(() => { |
| 2574 | 2672 | disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { |
| 2673 | + // A disclosure can leave the reader above the end without a drag. | |
| 2674 | + // Reconcile follow before a later layout or resume can re-pin it. | |
| 2675 | + const listState = props.listRef.current?.getState(); | |
| 2676 | + if (listState) { | |
| 2677 | + transitionEndFollow({ | |
| 2678 | + type: "disclosure-settled", | |
| 2679 | + isAtEnd: listState.isAtEnd, | |
| 2680 | + userScrollSessionActive: userScrollSessionRef.current, | |
| 2681 | + }); | |
| 2682 | + } | |
| 2575 | 2683 | disclosureAnchorKeyRef.current = null; |
| 2576 | 2684 | setDisclosureToggleSettling(false); |
| 2577 | 2685 | disclosureSettleFrameRef.current = null; |
| 2578 | 2686 | disclosureSettleSecondFrameRef.current = null; |
| 2579 | 2687 | }); |
| 2580 | 2688 | }); |
| 2581 | − }, []); | |
| 2689 | + }, [props.listRef, transitionEndFollow]); | |
| 2582 | 2690 | |
| 2583 | 2691 | const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { |
| 2584 | 2692 | disclosureAnchorKeyRef.current = anchorKey; |
| @@ -2713,44 +2821,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 128 unmodified lines | ||
| 2713 | 2821 | (info: { item: ThreadFeedEntry; index: number }) => ( |
| 2714 | 2822 | <Animated.View |
| 2715 | 2823 | key={info.item.id} |
| 2716 | − entering={ | |
| 2717 | − disclosureEnteringEntryIds.has(info.item.id) | |
| 2718 | − ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION | |
| 2719 | − : undefined | |
| 2720 | − } | |
| 2721 | − exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION} | |
| 2824 | + entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined} | |
| 2722 | 2825 | > |
| 2723 | − {renderFeedEntry(info, { | |
| 2724 | − environmentId: props.environmentId, | |
| 2725 | − threadId: props.threadId, | |
| 2726 | − copiedRowId, | |
| 2727 | − expandedWorkRows, | |
| 2728 | − terminalAssistantMessageIds, | |
| 2729 | − unsettledTurnId, | |
| 2730 | − onCopyWorkRow, | |
| 2731 | − onToggleWorkGroup, | |
| 2732 | − onToggleWorkRow, | |
| 2733 | − onToggleTurnFold, | |
| 2734 | − onPressPreview, | |
| 2735 | − onPressVideo, | |
| 2736 | − onMarkdownLinkPress, | |
| 2737 | − renderMarkdownImage, | |
| 2738 | − renderViewedImage, | |
| 2739 | − iconSubtleColor, | |
| 2740 | − userBubbleColor, | |
| 2741 | − markdownStyles, | |
| 2742 | − reviewCommentColors, | |
| 2743 | − reviewCommentBubbleWidth, | |
| 2744 | − userBubbleMaxWidth, | |
| 2745 | − threadTitle: props.threadTitle, | |
| 2746 | − skills: props.skills, | |
| 2747 | − workspaceRoot: props.workspaceRoot, | |
| 2748 | − })} | |
| 2826 | + <ThreadMediaVisibility> | |
| 2827 | + {renderFeedEntry(info, { | |
| 2828 | + environmentId: props.environmentId, | |
| 2829 | + threadId: props.threadId, | |
| 2830 | + copiedRowId, | |
| 2831 | + expandedWorkRows, | |
| 2832 | + terminalAssistantMessageIds, | |
| 2833 | + unsettledTurnId, | |
| 2834 | + onCopyWorkRow, | |
| 2835 | + onToggleWorkGroup, | |
| 2836 | + onToggleWorkRow, | |
| 2837 | + onToggleTurnFold, | |
| 2838 | + onPressPreview, | |
| 2839 | + onPressVideo, | |
| 2840 | + onMarkdownLinkPress, | |
| 2841 | + renderMarkdownImage, | |
| 2842 | + renderViewedImage, | |
| 2843 | + iconSubtleColor, | |
| 2844 | + userBubbleColor, | |
| 2845 | + markdownStyles, | |
| 2846 | + reviewCommentColors, | |
| 2847 | + reviewCommentBubbleWidth, | |
| 2848 | + userBubbleMaxWidth, | |
| 2849 | + threadTitle: props.threadTitle, | |
| 2850 | + skills: props.skills, | |
| 2851 | + workspaceRoot: props.workspaceRoot, | |
| 2852 | + })} | |
| 2853 | + </ThreadMediaVisibility> | |
| 2749 | 2854 | </Animated.View> |
| 2750 | 2855 | ), |
| 2751 | 2856 | [ |
| 2752 | 2857 | copiedRowId, |
| 2753 | − disclosureEnteringEntryIds, | |
| 2858 | + disclosureToggleSettling, | |
| 2754 | 2859 | expandedWorkRows, |
| 2755 | 2860 | workRowSizing, |
| 2756 | 2861 | workGroupScrollPositions, |
| @@ -2863,6 +2968,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 106 unmodified lines | ||
| 2863 | 2968 | data={presentedFeed} |
| 2864 | 2969 | extraData={listAppearanceData} |
| 2865 | 2970 | renderItem={renderItem} |
| 2971 | + viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} | |
| 2866 | 2972 | keyExtractor={(entry) => entry.id} |
| 2867 | 2973 | getItemType={(entry) => |
| 2868 | 2974 | entry.type === "message" ? `message:${entry.message.role}` : entry.type |
| @@ -2938,4 +3044,5 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 69 unmodified lines | ||
| 2938 | 3044 | <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} /> |
| 2939 | 3045 | </> |
| 2940 | 3046 | ); |
| 3047 | + const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set(); | |
| 2941 | 3048 | }); |
| @@ -23,10 +23,13 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; · 22 unmodified lines | ||
| 23 | 23 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 24 | 24 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; |
| 25 | 25 | import { |
| 26 | − resolveThreadListV2SnoozeMenuSelection, | |
| 26 | + resolveThreadListV2ChangeRequestState, | |
| 27 | 27 | resolveThreadListV2SnoozeGateExpiryMs, |
| 28 | + resolveThreadListV2SnoozeMenuSelection, | |
| 28 | 29 | resolveThreadListV2Status, |
| 29 | 30 | resolveThreadListV2SwipeActions, |
| 31 | + threadHasUnseenCompletion, | |
| 32 | + type ThreadListV2ChangeRequestState, | |
| 30 | 33 | type ThreadListV2Status, |
| 31 | 34 | } from "./threadListV2"; |
| 32 | 35 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; |
| @@ -368,6 +371,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 335 unmodified lines | ||
| 368 | 371 | readonly canMovePinnedDown?: boolean; |
| 369 | 372 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 370 | 373 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 374 | + /** Reports this row's live PR (state + last activity) for the partition's | |
| 375 | + merge and close rules. Mirrors web's onChangeRequestState. */ | |
| 376 | + readonly onChangeRequestState?: ( | |
| 377 | + threadKey: string, | |
| 378 | + changeRequest: ThreadListV2ChangeRequestState | null, | |
| 379 | + ) => void; | |
| 371 | 380 | readonly projectCwd?: string | null; |
| 372 | 381 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 373 | 382 | readonly searchQuery?: string; |
| @@ -390,11 +399,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 16 unmodified lines | ||
| 390 | 399 | onPinThread, |
| 391 | 400 | onUnpinThread, |
| 392 | 401 | onMovePinnedThread, |
| 402 | + onChangeRequestState, | |
| 393 | 403 | } = props; |
| 394 | 404 | const snoozedRow = props.snoozed === true; |
| 395 | 405 | const pinnedRow = props.pinned === true; |
| 396 | 406 | |
| 397 | 407 | const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); |
| 408 | + const prState = pr?.state ?? null; | |
| 409 | + const prUpdatedAt = pr?.updatedAt ?? null; | |
| 410 | + const threadKey = `${thread.environmentId}:${thread.id}`; | |
| 411 | + useEffect(() => { | |
| 412 | + const changeRequest = resolveThreadListV2ChangeRequestState({ | |
| 413 | + linkedPullRequest: thread.linkedPullRequest, | |
| 414 | + state: prState, | |
| 415 | + updatedAt: prUpdatedAt, | |
| 416 | + }); | |
| 417 | + if (changeRequest === undefined) return; | |
| 418 | + onChangeRequestState?.(threadKey, changeRequest); | |
| 419 | + }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); | |
| 398 | 420 | |
| 399 | 421 | const theme = useUniwindTheme(); |
| 400 | 422 | const screenColor = theme["--color-screen"]; |
| @@ -438,8 +460,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 37 unmodified lines | ||
| 438 | 460 | ); |
| 439 | 461 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); |
| 440 | 462 | |
| 441 | − // Swipe: the v2 primary action is the lifecycle transition. Un-settling a | |
| 442 | − // settled row keeps it active until new activity clears the user override. | |
| 463 | + // Swipe: the v2 primary action is the lifecycle transition. Every settled | |
| 464 | + // row can un-settle — explicit settles clear the override, auto-settled | |
| 465 | + // rows get pinned active until real activity clears the pin. | |
| 443 | 466 | const canUnsettle = variant === "slim"; |
| 444 | 467 | const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); |
| 445 | 468 | const snoozeGateExpiryMs = props.snoozeSupported |
| @@ -578,7 +578,9 @@ describe("buildThreadListV2Items", () => { · 577 unmodified lines | ||
| 578 | 578 | ], |
| 579 | 579 | environmentId: null, |
| 580 | 580 | searchQuery: "", |
| 581 | − now: "2026-06-02T00:01:07.500Z", | |
| 581 | + // Minute-floored partition clock vs precise snooze clock. | |
| 582 | + now: "2026-06-02T00:01:00.000Z", | |
| 583 | + snoozeNow: "2026-06-02T00:01:07.500Z", | |
| 582 | 584 | }); |
| 583 | 585 | |
| 584 | 586 | expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); |
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | import { |
| 2 | + effectiveSettled, | |
| 2 | 3 | effectiveSnoozed, |
| 3 | 4 | hasQueuedTurnStart, |
| 4 | 5 | QUEUED_TURN_START_GRACE_MS, |
| 5 | 6 | resolveSnoozePresets, |
| 6 | 7 | snoozeWakeLabel, |
| 7 | 8 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 8 | −import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; | |
| 9 | +import type { | |
| 10 | + ChangeRequestSettleSource, | |
| 11 | + SnoozePreset, | |
| 12 | +} from "@t3tools/client-runtime/state/thread-settled"; | |
| 9 | 13 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 10 | 14 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 11 | 15 | import { |
| @@ -13,7 +17,7 @@ import { · 1 unmodified line | ||
| 13 | 17 | resolveSettledThreadTimestamp, |
| 14 | 18 | sortPinnedThreadsByOrderKey, |
| 15 | 19 | } from "@t3tools/client-runtime/state/thread-sort"; |
| 16 | −import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 20 | +import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; | |
| 17 | 21 | |
| 18 | 22 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 19 | 23 | |
| @@ -32,6 +36,35 @@ export { snoozeWakeLabel }; · 12 unmodified lines | ||
| 32 | 36 | export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready"; |
| 33 | 37 | export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; |
| 34 | 38 | |
| 39 | +export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { | |
| 40 | + readonly linkedPullRequestKey?: string | null; | |
| 41 | +} | |
| 42 | + | |
| 43 | +function linkedPullRequestKey( | |
| 44 | + linkedPullRequest: ThreadLinkedPullRequest | null | undefined, | |
| 45 | +): string | null { | |
| 46 | + if (linkedPullRequest == null) return null; | |
| 47 | + return JSON.stringify([ | |
| 48 | + linkedPullRequest.projectId, | |
| 49 | + linkedPullRequest.repository.toLowerCase(), | |
| 50 | + linkedPullRequest.number, | |
| 51 | + ]); | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** Keep the previous linked PR state while its detail query reloads. */ | |
| 55 | +export function resolveThreadListV2ChangeRequestState(input: { | |
| 56 | + readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined; | |
| 57 | + readonly state: ChangeRequestSettleSource["state"] | null; | |
| 58 | + readonly updatedAt: string | null; | |
| 59 | +}): ThreadListV2ChangeRequestState | null | undefined { | |
| 60 | + if (input.state === null) return input.linkedPullRequest == null ? null : undefined; | |
| 61 | + return { | |
| 62 | + state: input.state, | |
| 63 | + updatedAt: input.updatedAt, | |
| 64 | + linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest), | |
| 65 | + }; | |
| 66 | +} | |
| 67 | + | |
| 35 | 68 | export function resolveThreadListV2SnoozeMenuSelection(input: { |
| 36 | 69 | readonly event: string; |
| 37 | 70 | readonly displayedPresets: ReadonlyArray<SnoozePreset>; |
| @@ -329,7 +362,8 @@ export function buildThreadListV2ListItems(input: { · 291 unmodified lines | ||
| 329 | 362 | |
| 330 | 363 | /** |
| 331 | 364 | * Partitions visible threads into the active card block (creation order) and |
| 332 | − * the settled recency tail, matching the web v2 list. | |
| 365 | + * the settled recency tail, matching the web v2 list. Mobile stores these | |
| 366 | + * auto-settle preferences per device. | |
| 333 | 367 | */ |
| 334 | 368 | export function buildThreadListV2Items(input: { |
| 335 | 369 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| @@ -340,6 +374,8 @@ export function buildThreadListV2Items(input: { · 4 unmodified lines | ||
| 340 | 374 | }> | null; |
| 341 | 375 | readonly searchQuery: string; |
| 342 | 376 | readonly matchedThreadKeys?: ReadonlySet<string>; |
| 377 | + /** Per-row PR reported up by visible rows ("env:threadId" keys). */ | |
| 378 | + readonly changeRequestByKey?: ReadonlyMap<string, ThreadListV2ChangeRequestState>; | |
| 343 | 379 | /** Environments whose server supports thread.settle/unsettle. Threads on |
| 344 | 380 | other environments never classify as settled — the user could neither |
| 345 | 381 | un-settle nor pin them. Absent = no gating (tests). */ |
| @@ -347,10 +383,17 @@ export function buildThreadListV2Items(input: { · 1 unmodified line | ||
| 347 | 383 | /** Environments whose server supports thread.snooze/unsnooze. Same |
| 348 | 384 | contract as settlementEnvironmentIds. */ |
| 349 | 385 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; |
| 386 | + readonly autoSettleAfterDays?: number; | |
| 387 | + readonly autoSettleOnMerge?: boolean; | |
| 350 | 388 | /** Max settled rows to render; the rest are counted, not built. */ |
| 351 | 389 | readonly settledLimit?: number; |
| 352 | − /** Second-precise clock used for time-based classification. */ | |
| 353 | − readonly now: string; | |
| 390 | + /** Injectable for tests; defaults to now. */ | |
| 391 | + readonly now?: string; | |
| 392 | + /** Second-precise clock for snooze classification. Callers pass a | |
| 393 | + minute-quantized `now` for memoization; snooze wake times are | |
| 394 | + second-precise, so classifying with the floored minute would hold a | |
| 395 | + woken thread hidden for up to a minute. Defaults to `now`. */ | |
| 396 | + readonly snoozeNow?: string; | |
| 354 | 397 | /** Expands the snoozed shelf into rows. Collapsed is the default. */ |
| 355 | 398 | readonly snoozedShelfExpanded?: boolean; |
| 356 | 399 | /** Expands the settled shelf into rows. Expanded is the default. */ |
| @@ -359,7 +402,10 @@ export function buildThreadListV2Items(input: { · 2 unmodified lines | ||
| 359 | 402 | a split-view detail can never lose its navigation row. */ |
| 360 | 403 | readonly selectedThreadKey?: string | null; |
| 361 | 404 | }): ThreadListV2Layout { |
| 362 | − const now = input.now; | |
| 405 | + const now = input.now ?? new Date().toISOString(); | |
| 406 | + const snoozeNow = input.snoozeNow ?? now; | |
| 407 | + const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; | |
| 408 | + const autoSettleOnMerge = input.autoSettleOnMerge ?? true; | |
| 363 | 409 | const query = input.searchQuery.trim().toLocaleLowerCase(); |
| 364 | 410 | const projectKeys = input.projectRefs |
| 365 | 411 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) |
| @@ -392,8 +438,16 @@ export function buildThreadListV2Items(input: { · 26 unmodified lines | ||
| 392 | 438 | } |
| 393 | 439 | const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; |
| 394 | 440 | const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; |
| 441 | + const cachedChangeRequest = | |
| 442 | + input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; | |
| 443 | + const changeRequest = | |
| 444 | + cachedChangeRequest !== null && | |
| 445 | + (cachedChangeRequest.linkedPullRequestKey ?? null) === | |
| 446 | + linkedPullRequestKey(thread.linkedPullRequest) | |
| 447 | + ? cachedChangeRequest | |
| 448 | + : null; | |
| 395 | 449 | // Snooze outranks settlement and pinning until the thread wakes. |
| 396 | − if (supportsSnooze && effectiveSnoozed(thread, { now })) { | |
| 450 | + if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { | |
| 397 | 451 | snoozed.push(thread); |
| 398 | 452 | if ( |
| 399 | 453 | thread.snoozedUntil != null && |
| @@ -404,7 +458,15 @@ export function buildThreadListV2Items(input: { · 4 unmodified lines | ||
| 404 | 458 | } |
| 405 | 459 | continue; |
| 406 | 460 | } |
| 407 | − if (supportsSettlement && thread.settledOverride === "settled") { | |
| 461 | + if ( | |
| 462 | + supportsSettlement && | |
| 463 | + effectiveSettled(thread, { | |
| 464 | + now, | |
| 465 | + autoSettleAfterDays, | |
| 466 | + autoSettleOnMerge, | |
| 467 | + changeRequest, | |
| 468 | + }) | |
| 469 | + ) { | |
| 408 | 470 | settled.push(thread); |
| 409 | 471 | } else if (thread.pinnedAt != null) { |
| 410 | 472 | pinned.push(thread); |
| @@ -7,12 +7,14 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "@t3tools/shared/model"; |
| 8 | 8 | |
| 9 | 9 | import { |
| 10 | − getClaudeModelCapabilities, | |
| 11 | − isClaudeUltracodeEffort, | |
| 12 | − normalizeClaudeCliEffort, | |
| 13 | − resolveClaudeApiModelId, | |
| 14 | − resolveClaudeEffort, | |
| 15 | −} from "./provider/Layers/ClaudeProvider.ts"; | |
| 10 | + BUNDLED_CLAUDE_MODEL_CATALOG, | |
| 11 | + getClaudeCatalogModelCapabilities, | |
| 12 | + isClaudeCatalogUltracodeEffort, | |
| 13 | + normalizeClaudeCatalogEffort, | |
| 14 | + resolveClaudeCatalogApiModelId, | |
| 15 | + resolveClaudeCatalogEffort, | |
| 16 | + type ClaudeModelCatalog, | |
| 17 | +} from "./provider/ClaudeModelCatalog.ts"; | |
| 16 | 18 | |
| 17 | 19 | export interface CompiledClaudeModelSelection { |
| 18 | 20 | readonly apiModelId: string; |
| @@ -25,14 +27,15 @@ export interface CompiledClaudeModelSelection { · 6 unmodified lines | ||
| 25 | 27 | /** Compile every Claude model option at the provider boundary. */ |
| 26 | 28 | export function compileClaudeModelSelection( |
| 27 | 29 | selection: ModelSelection, |
| 30 | + catalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, | |
| 28 | 31 | ): CompiledClaudeModelSelection { |
| 29 | − const capabilities = getClaudeModelCapabilities(selection.model); | |
| 32 | + const capabilities = getClaudeCatalogModelCapabilities(catalog, selection.model); | |
| 30 | 33 | const descriptors = getProviderOptionDescriptors({ caps: capabilities }); |
| 31 | 34 | const supportsBoolean = (id: string) => |
| 32 | 35 | descriptors.some((descriptor) => descriptor.type === "boolean" && descriptor.id === id); |
| 33 | 36 | const rawEffort = getModelSelectionStringOptionValue(selection, "effort"); |
| 34 | − const resolvedEffort = resolveClaudeEffort(capabilities, rawEffort); | |
| 35 | − const effort = normalizeClaudeCliEffort(resolvedEffort, selection.model); | |
| 37 | + const resolvedEffort = resolveClaudeCatalogEffort(catalog, selection.model, rawEffort); | |
| 38 | + const effort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, selection.model); | |
| 36 | 39 | const fastMode = supportsBoolean("fastMode") |
| 37 | 40 | ? getModelSelectionBooleanOptionValue(selection, "fastMode") |
| 38 | 41 | : undefined; |
| @@ -42,9 +45,9 @@ export function compileClaudeModelSelection( · 3 unmodified lines | ||
| 42 | 45 | const settings = { |
| 43 | 46 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), |
| 44 | 47 | ...(typeof fastMode === "boolean" ? { fastMode } : {}), |
| 45 | − ...(isClaudeUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}), | |
| 48 | + ...(isClaudeCatalogUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}), | |
| 46 | 49 | }; |
| 47 | − const apiModelId = resolveClaudeApiModelId(selection); | |
| 50 | + const apiModelId = resolveClaudeCatalogApiModelId(catalog, selection); | |
| 48 | 51 | const promptEffort = resolvePromptInjectedEffort(capabilities, rawEffort) ?? undefined; |
| 49 | 52 | return { |
| 50 | 53 | apiModelId, |
| @@ -1,12 +1,13 @@ | ||
| 1 | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | +import { describe, vi } from "vite-plus/test"; | |
| 3 | +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; | |
| 4 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | 5 | import * as Effect from "effect/Effect"; |
| 3 | 6 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 7 | import * as Layer from "effect/Layer"; |
| 5 | 8 | import * as Path from "effect/Path"; |
| 6 | 9 | import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; |
| 7 | −import { describe } from "vite-plus/test"; | |
| 8 | −import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; | |
| 9 | −import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 10 | +import { openMediaFile } from "./assets/MediaFile.ts"; | |
| 10 | 11 | |
| 11 | 12 | import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; |
| 12 | 13 | |
| @@ -70,6 +71,179 @@ describe("browser API CORS", () => { · 57 unmodified lines | ||
| 70 | 71 | const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); |
| 71 | 72 | |
| 72 | 73 | describe("video asset byte ranges", () => { |
| 74 | + it.effect("uses current descriptor metadata after an in-place truncate or extension", () => | |
| 75 | + Effect.gen(function* () { | |
| 76 | + const fs = yield* FileSystem.FileSystem; | |
| 77 | + const path = yield* Path.Path; | |
| 78 | + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" }); | |
| 79 | + const filePath = path.join(directory, "clip.mp4"); | |
| 80 | + for (const [contents, range, method, expected, status, contentRange] of [ | |
| 81 | + ["1234", undefined, "GET", "1234", 200, null], | |
| 82 | + ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null], | |
| 83 | + ["1234", "bytes=4-", "GET", "", 416, "bytes */4"], | |
| 84 | + ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"], | |
| 85 | + ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"], | |
| 86 | + ["0123456789abcdef", undefined, "HEAD", "", 200, null], | |
| 87 | + ["", undefined, "GET", "", 200, null], | |
| 88 | + ["", "bytes=0-1", "GET", "", 416, "bytes */0"], | |
| 89 | + ] as const) { | |
| 90 | + yield* fs.writeFileString(filePath, "0123456789"); | |
| 91 | + const canonicalPath = yield* fs.realPath(filePath); | |
| 92 | + const file = yield* openMediaFile(canonicalPath); | |
| 93 | + if (!file) throw new Error("Expected an opened media file"); | |
| 94 | + yield* fs.writeFileString(filePath, contents); | |
| 95 | + const response = HttpServerResponse.toWeb( | |
| 96 | + yield* assetFileResponse( | |
| 97 | + { path: canonicalPath, file, mimeType: "video/mp4" }, | |
| 98 | + range, | |
| 99 | + undefined, | |
| 100 | + method, | |
| 101 | + ), | |
| 102 | + ); | |
| 103 | + expect(response.status).toBe(status); | |
| 104 | + expect(response.headers.get("content-range")).toBe(contentRange); | |
| 105 | + if (status !== 416) { | |
| 106 | + expect(response.headers.get("content-length")).toBe( | |
| 107 | + String(method === "HEAD" ? contents.length : expected.length), | |
| 108 | + ); | |
| 109 | + } | |
| 110 | + expect(yield* Effect.promise(() => response.text())).toBe(expected); | |
| 111 | + } | |
| 112 | + }).pipe(Effect.provide(fileResponseLayer)), | |
| 113 | + ); | |
| 114 | + | |
| 115 | + it.effect( | |
| 116 | + "rejects unaddressable ranges before streaming and preserves small ranges on large files", | |
| 117 | + () => | |
| 118 | + Effect.gen(function* () { | |
| 119 | + const fs = yield* FileSystem.FileSystem; | |
| 120 | + const path = yield* Path.Path; | |
| 121 | + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" }); | |
| 122 | + const filePath = path.join(directory, "clip.mp4"); | |
| 123 | + yield* fs.writeFileString(filePath, "0123456789"); | |
| 124 | + const canonicalPath = yield* fs.realPath(filePath); | |
| 125 | + const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n; | |
| 126 | + const size = unsafeOffset + 32n; | |
| 127 | + for (const [range, status] of [ | |
| 128 | + [`bytes=${unsafeOffset}-${unsafeOffset}`, 416], | |
| 129 | + [`bytes=0-${unsafeOffset}`, 416], | |
| 130 | + ["bytes=-1", 416], | |
| 131 | + [undefined, 413], | |
| 132 | + ["bytes=0-1", 206], | |
| 133 | + ] as const) { | |
| 134 | + const file = yield* openMediaFile(canonicalPath); | |
| 135 | + if (!file) throw new Error("Expected an opened media file"); | |
| 136 | + // Model a sparse file beyond the native stream's numeric addressing limit. | |
| 137 | + const info = yield* Effect.promise(() => file.handle.stat({ bigint: true })); | |
| 138 | + info.size = size; | |
| 139 | + const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info); | |
| 140 | + yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore())); | |
| 141 | + const response = HttpServerResponse.toWeb( | |
| 142 | + yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range), | |
| 143 | + ); | |
| 144 | + expect(response.status).toBe(status); | |
| 145 | + if (status === 416) { | |
| 146 | + expect(response.headers.get("content-range")).toBe(`bytes */${size}`); | |
| 147 | + expect(yield* Effect.promise(() => response.text())).toBe(""); | |
| 148 | + } else if (status === 206) { | |
| 149 | + expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`); | |
| 150 | + expect(yield* Effect.promise(() => response.text())).toBe("01"); | |
| 151 | + } else { | |
| 152 | + expect(yield* Effect.promise(() => response.text())).toBe( | |
| 153 | + "File is too large to preview.", | |
| 154 | + ); | |
| 155 | + } | |
| 156 | + } | |
| 157 | + }).pipe(Effect.provide(fileResponseLayer)), | |
| 158 | + ); | |
| 159 | + | |
| 160 | + it.effect("streams guarded file ranges, including suffixes and conditional requests", () => | |
| 161 | + Effect.gen(function* () { | |
| 162 | + const fs = yield* FileSystem.FileSystem; | |
| 163 | + const path = yield* Path.Path; | |
| 164 | + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" }); | |
| 165 | + const filePath = path.join(directory, "clip.mp4"); | |
| 166 | + yield* fs.writeFileString(filePath, "0123456789"); | |
| 167 | + const canonicalPath = yield* fs.realPath(filePath); | |
| 168 | + for (const [range, ifRange, expected, status, contentRange] of [ | |
| 169 | + [undefined, undefined, "0123456789", 200, null], | |
| 170 | + ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"], | |
| 171 | + ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"], | |
| 172 | + ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"], | |
| 173 | + ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"], | |
| 174 | + ["bytes=10-", undefined, "", 416, "bytes */10"], | |
| 175 | + ["bytes=0-1", '"old-etag"', "0123456789", 200, null], | |
| 176 | + ["bytes=0-1", "", "0123456789", 200, null], | |
| 177 | + ] as const) { | |
| 178 | + const file = yield* openMediaFile(canonicalPath); | |
| 179 | + if (!file) throw new Error("Expected an opened media file"); | |
| 180 | + const response = HttpServerResponse.toWeb( | |
| 181 | + yield* assetFileResponse( | |
| 182 | + { path: canonicalPath, file, mimeType: "video/mp4" }, | |
| 183 | + range, | |
| 184 | + ifRange, | |
| 185 | + ), | |
| 186 | + ); | |
| 187 | + expect(response.status).toBe(status); | |
| 188 | + expect(response.headers.get("accept-ranges")).toBe("bytes"); | |
| 189 | + expect(response.headers.get("content-range")).toBe(contentRange); | |
| 190 | + expect(response.headers.get("cache-control")).toBe("private, no-store"); | |
| 191 | + expect(response.headers.get("etag")).toBeNull(); | |
| 192 | + expect(response.headers.get("last-modified")).toBeNull(); | |
| 193 | + if (status !== 416) | |
| 194 | + expect(response.headers.get("content-length")).toBe(String(expected.length)); | |
| 195 | + expect(yield* Effect.promise(() => response.text())).toBe(expected); | |
| 196 | + } | |
| 197 | + }).pipe(Effect.provide(fileResponseLayer)), | |
| 198 | + ); | |
| 199 | + | |
| 200 | + it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () => | |
| 201 | + Effect.gen(function* () { | |
| 202 | + const fs = yield* FileSystem.FileSystem; | |
| 203 | + const path = yield* Path.Path; | |
| 204 | + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" }); | |
| 205 | + const filePath = path.join(directory, "clip.mp4"); | |
| 206 | + const bytes = new Uint8Array(1024 * 1024).fill(42); | |
| 207 | + yield* fs.writeFile(filePath, bytes); | |
| 208 | + const canonicalPath = yield* fs.realPath(filePath); | |
| 209 | + for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) { | |
| 210 | + const file = yield* Effect.scoped( | |
| 211 | + Effect.gen(function* () { | |
| 212 | + const file = yield* openMediaFile(canonicalPath); | |
| 213 | + if (!file) throw new Error("Expected an opened media file"); | |
| 214 | + const response = HttpServerResponse.toWeb( | |
| 215 | + yield* assetFileResponse( | |
| 216 | + { path: canonicalPath, file, mimeType: "video/mp4" }, | |
| 217 | + mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-", | |
| 218 | + undefined, | |
| 219 | + mode === "HEAD" ? "HEAD" : "GET", | |
| 220 | + ), | |
| 221 | + ); | |
| 222 | + if (mode === "HEAD") { | |
| 223 | + expect(response.status).toBe(200); | |
| 224 | + expect(response.headers.get("content-length")).toBe(String(bytes.length)); | |
| 225 | + expect(response.headers.get("content-range")).toBeNull(); | |
| 226 | + expect(yield* Effect.promise(() => response.text())).toBe(""); | |
| 227 | + } else if (mode === "rejected") { | |
| 228 | + expect(response.status).toBe(416); | |
| 229 | + expect(yield* Effect.promise(() => response.text())).toBe(""); | |
| 230 | + } else if (mode === "cancelled") { | |
| 231 | + const reader = response.body!.getReader(); | |
| 232 | + const first = yield* Effect.promise(() => reader.read()); | |
| 233 | + expect(first.done).toBe(false); | |
| 234 | + expect(first.value!.byteLength).toBeLessThan(bytes.length); | |
| 235 | + yield* Effect.promise(() => reader.cancel()); | |
| 236 | + } else { | |
| 237 | + expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer); | |
| 238 | + } | |
| 239 | + return file; | |
| 240 | + }), | |
| 241 | + ); | |
| 242 | + expect(file.handle.fd).toBe(-1); | |
| 243 | + } | |
| 244 | + }).pipe(Effect.provide(fileResponseLayer)), | |
| 245 | + ); | |
| 246 | + | |
| 73 | 247 | it.effect("streams exactly the requested bytes and leaves full downloads intact", () => |
| 74 | 248 | Effect.gen(function* () { |
| 75 | 249 | const fs = yield* FileSystem.FileSystem; |
| @@ -77,7 +77,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; · 76 unmodified lines | ||
| 77 | 77 | import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; |
| 78 | 78 | import { ServerConfig } from "../../config.ts"; |
| 79 | 79 | import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; |
| 80 | −import { resolveClaudeContextWindow } from "../../provider/Layers/ClaudeProvider.ts"; | |
| 80 | +import { | |
| 81 | + BUNDLED_CLAUDE_MODEL_CATALOG, | |
| 82 | + resolveClaudeCatalogContextWindow, | |
| 83 | +} from "../../provider/ClaudeModelCatalog.ts"; | |
| 81 | 84 | import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; |
| 82 | 85 | import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; |
| 83 | 86 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| @@ -134,7 +137,9 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null { · 50 unmodified lines | ||
| 134 | 137 | if (modelSelection.model === "claude-opus-4-6" || modelSelection.model === "claude-opus-4-7") { |
| 135 | 138 | return 1_000_000; |
| 136 | 139 | } |
| 137 | − return resolveClaudeContextWindow(modelSelection) === "1m" ? 1_000_000 : 200_000; | |
| 140 | + return resolveClaudeCatalogContextWindow(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) === "1m" | |
| 141 | + ? 1_000_000 | |
| 142 | + : 200_000; | |
| 138 | 143 | } |
| 139 | 144 | |
| 140 | 145 | export function claudeProviderTurnTokenUsage( |
| @@ -3027,6 +3027,36 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 3026 unmodified lines | ||
| 3027 | 3027 | }), |
| 3028 | 3028 | ), |
| 3029 | 3029 | ); |
| 3030 | + // Child agents run in their own sessions and outlive a root | |
| 3031 | + // abort (#9005). Best-effort: walk session.children and abort | |
| 3032 | + // each descendant; the root abort above already settled the | |
| 3033 | + // interrupt contract. | |
| 3034 | + yield* Effect.gen(function* () { | |
| 3035 | + const visited = new Set([sessionId]); | |
| 3036 | + const abortDescendants = (parentId: string): Effect.Effect<void> => | |
| 3037 | + Effect.gen(function* () { | |
| 3038 | + const children = yield* sdkCall( | |
| 3039 | + "session.children", | |
| 3040 | + { sessionID: parentId }, | |
| 3041 | + () => client.session.children({ sessionID: parentId }), | |
| 3042 | + ).pipe(Effect.option); | |
| 3043 | + const rows = Option.isSome(children) ? (children.value.data ?? []) : []; | |
| 3044 | + const fresh = rows.filter((child) => { | |
| 3045 | + if (visited.has(child.id)) return false; | |
| 3046 | + visited.add(child.id); | |
| 3047 | + return true; | |
| 3048 | + }); | |
| 3049 | + yield* Effect.forEach( | |
| 3050 | + fresh, | |
| 3051 | + (child) => | |
| 3052 | + sdkCall("session.abort", { sessionID: child.id }, () => | |
| 3053 | + client.session.abort({ sessionID: child.id }), | |
| 3054 | + ).pipe(Effect.ignore, Effect.andThen(abortDescendants(child.id))), | |
| 3055 | + { concurrency: 8, discard: true }, | |
| 3056 | + ); | |
| 3057 | + }); | |
| 3058 | + yield* abortDescendants(sessionId); | |
| 3059 | + }).pipe(Effect.timeout("15 seconds"), Effect.ignore); | |
| 3030 | 3060 | }).pipe( |
| 3031 | 3061 | Effect.mapError( |
| 3032 | 3062 | (cause) => |
| @@ -166,42 +166,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 165 unmodified lines | ||
| 166 | 166 | }); |
| 167 | 167 | } |
| 168 | 168 | |
| 169 | − if ( | |
| 170 | − envelope.command.type === "thread.auto-settle" && | |
| 171 | − (yield* eventStore.hasEventAfter({ | |
| 172 | − aggregateKind: "thread", | |
| 173 | − aggregateId: envelope.command.threadId, | |
| 174 | − sequenceExclusive: envelope.command.snapshotSequence, | |
| 175 | − })) | |
| 176 | − ) { | |
| 177 | − return yield* new OrchestrationCommandInvariantError({ | |
| 178 | − commandType: envelope.command.type, | |
| 179 | − detail: `thread ${envelope.command.threadId} changed before automatic settlement`, | |
| 180 | − }); | |
| 181 | − } | |
| 182 | − | |
| 183 | − if ( | |
| 184 | − envelope.command.type === "thread.auto-settle" && | |
| 185 | − threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null | |
| 186 | − ) { | |
| 187 | − return yield* new OrchestrationCommandInvariantError({ | |
| 188 | − commandType: envelope.command.type, | |
| 189 | − detail: `thread ${envelope.command.threadId} has live background work`, | |
| 190 | − }); | |
| 191 | − } | |
| 192 | − | |
| 193 | − // Command snapshots omit activities at startup and cap them while running. | |
| 194 | − // Read this request's durable state before deciding how to send the answer. | |
| 195 | − const userInputActivity = | |
| 196 | − envelope.command.type === "thread.user-input.respond" | |
| 197 | − ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command) | |
| 198 | − : Option.none(); | |
| 199 | 169 | const eventBase = yield* decideOrchestrationCommand({ |
| 200 | 170 | command: envelope.command, |
| 201 | 171 | readModel: commandReadModel, |
| 202 | − ...(Option.isSome(userInputActivity) | |
| 203 | − ? { userInputActivity: userInputActivity.value } | |
| 204 | − : {}), | |
| 205 | 172 | }).pipe( |
| 206 | 173 | Effect.provideService(Crypto.Crypto, crypto), |
| 207 | 174 | Effect.mapError((cause) => |
| @@ -1018,34 +1018,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1017 unmodified lines | ||
| 1018 | 1018 | return; |
| 1019 | 1019 | |
| 1020 | 1020 | case "thread.message-sent": { |
| 1021 | − if (event.payload.streaming) { | |
| 1022 | − const attachments = | |
| 1023 | − event.payload.attachments !== undefined | |
| 1024 | − ? yield* materializeAttachmentsForProjection({ | |
| 1025 | − attachments: event.payload.attachments, | |
| 1026 | − }) | |
| 1027 | − : undefined; | |
| 1028 | − yield* projectionThreadMessageRepository.appendStreaming({ | |
| 1029 | − messageId: event.payload.messageId, | |
| 1030 | − threadId: event.payload.threadId, | |
| 1031 | − turnId: event.payload.turnId, | |
| 1032 | − role: event.payload.role, | |
| 1033 | − text: event.payload.text, | |
| 1034 | − ...(attachments !== undefined ? { attachments: [...attachments] } : {}), | |
| 1035 | − createdAt: event.payload.createdAt, | |
| 1036 | − updatedAt: event.payload.updatedAt, | |
| 1037 | − }); | |
| 1038 | − return; | |
| 1039 | − } | |
| 1040 | − | |
| 1041 | 1021 | const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ |
| 1042 | 1022 | messageId: event.payload.messageId, |
| 1043 | 1023 | }); |
| 1044 | 1024 | const previousMessage = Option.getOrUndefined(existingMessage); |
| 1045 | 1025 | const nextText = Option.match(existingMessage, { |
| 1046 | 1026 | onNone: () => event.payload.text, |
| 1047 | − onSome: (message) => | |
| 1048 | − event.payload.text.length === 0 ? message.text : event.payload.text, | |
| 1027 | + onSome: (message) => { | |
| 1028 | + if (event.payload.streaming) { | |
| 1029 | + return `${message.text}${event.payload.text}`; | |
| 1030 | + } | |
| 1031 | + if (event.payload.text.length === 0) { | |
| 1032 | + return message.text; | |
| 1033 | + } | |
| 1034 | + return event.payload.text; | |
| 1035 | + }, | |
| 1049 | 1036 | }); |
| 1050 | 1037 | const nextAttachments = |
| 1051 | 1038 | event.payload.attachments !== undefined |
| @@ -1060,7 +1047,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 8 unmodified lines | ||
| 1060 | 1047 | role: event.payload.role, |
| 1061 | 1048 | text: nextText, |
| 1062 | 1049 | ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), |
| 1063 | − isStreaming: false, | |
| 1050 | + isStreaming: event.payload.streaming, | |
| 1064 | 1051 | createdAt: previousMessage?.createdAt ?? event.payload.createdAt, |
| 1065 | 1052 | updatedAt: event.payload.updatedAt, |
| 1066 | 1053 | }); |
| @@ -77,9 +77,6 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); · 76 unmodified lines | ||
| 77 | 77 | // activity window. Applying the limit in SQL avoids decoding an unbounded |
| 78 | 78 | // payload_json set before the projector can enforce that invariant. |
| 79 | 79 | const THREAD_DETAIL_ACTIVITY_LIMIT = 500; |
| 80 | −// Snapshot payloads are decoded and projected in small sequential batches so | |
| 81 | −// one client read does not retain the raw payloads for the full activity window. | |
| 82 | −const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; | |
| 83 | 80 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 84 | 81 | Struct.assign({ |
| 85 | 82 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| @@ -104,9 +101,6 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( · 18 unmodified lines | ||
| 104 | 101 | sequence: Schema.NullOr(NonNegativeInt), |
| 105 | 102 | }), |
| 106 | 103 | ); |
| 107 | −const ProjectionThreadActivityIdRowSchema = Schema.Struct({ | |
| 108 | − activityId: ProjectionThreadActivity.fields.activityId, | |
| 109 | −}); | |
| 110 | 104 | const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; |
| 111 | 105 | const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ |
| 112 | 106 | id: ThreadId, |
| @@ -166,9 +160,6 @@ const ThreadActivityKindsLookupInput = Schema.Struct({ · 53 unmodified lines | ||
| 166 | 160 | threadId: ThreadId, |
| 167 | 161 | activityKinds: Schema.Array(Schema.String), |
| 168 | 162 | }); |
| 169 | −const ThreadActivityIdsLookupInput = Schema.Struct({ | |
| 170 | − activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId), | |
| 171 | −}); | |
| 172 | 163 | // Windowed reads order turns by the stable keyset (anchor, turn key), where |
| 173 | 164 | // anchor is requested_at and turn key is |
| 174 | 165 | // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the |
| @@ -372,21 +363,6 @@ function mapProposedPlanRow( · 197 unmodified lines | ||
| 372 | 363 | }; |
| 373 | 364 | } |
| 374 | 365 | |
| 375 | −function mapThreadActivityRow( | |
| 376 | − row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>, | |
| 377 | −): OrchestrationThreadActivity { | |
| 378 | − return { | |
| 379 | − id: row.activityId, | |
| 380 | − tone: row.tone, | |
| 381 | − kind: row.kind, | |
| 382 | − summary: row.summary, | |
| 383 | − payload: row.payload, | |
| 384 | − turnId: row.turnId, | |
| 385 | − createdAt: row.createdAt, | |
| 386 | − ...(row.sequence !== null ? { sequence: row.sequence } : {}), | |
| 387 | − }; | |
| 388 | −} | |
| 389 | − | |
| 390 | 366 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { |
| 391 | 367 | return (cause: unknown): ProjectionRepositoryError => |
| 392 | 368 | Schema.isSchemaError(cause) |
| @@ -1187,44 +1163,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 794 unmodified lines | ||
| 1187 | 1163 | `, |
| 1188 | 1164 | }); |
| 1189 | 1165 | |
| 1190 | − const listThreadActivityIdsByThread = SqlSchema.findAll({ | |
| 1191 | − Request: ThreadIdLookupInput, | |
| 1192 | − Result: ProjectionThreadActivityIdRowSchema, | |
| 1193 | − execute: ({ threadId }) => | |
| 1194 | − sql` | |
| 1195 | − SELECT activity_id AS "activityId" | |
| 1196 | − FROM projection_thread_activities | |
| 1197 | − WHERE thread_id = ${threadId} | |
| 1198 | − ORDER BY | |
| 1199 | − sequence DESC, | |
| 1200 | − created_at DESC, | |
| 1201 | − activity_id DESC | |
| 1202 | − LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} | |
| 1203 | − `, | |
| 1204 | − }); | |
| 1205 | − | |
| 1206 | − const listThreadActivityRowsByIds = SqlSchema.findAll({ | |
| 1207 | − Request: ThreadActivityIdsLookupInput, | |
| 1208 | − Result: ProjectionThreadActivityDbRowSchema, | |
| 1209 | − execute: ({ activityIds }) => | |
| 1210 | − sql` | |
| 1211 | − SELECT | |
| 1212 | − activity_id AS "activityId", | |
| 1213 | − thread_id AS "threadId", | |
| 1214 | − turn_id AS "turnId", | |
| 1215 | − tone, | |
| 1216 | − kind, | |
| 1217 | − summary, | |
| 1218 | − payload_json AS "payload", | |
| 1219 | − sequence, | |
| 1220 | − created_at AS "createdAt" | |
| 1221 | − FROM projection_thread_activities | |
| 1222 | − -- The selectors already scoped these globally unique ids to the | |
| 1223 | − -- thread inside this transaction. Keep this as a primary-key lookup. | |
| 1224 | − WHERE ${sql.in("activity_id", activityIds)} | |
| 1225 | − `, | |
| 1226 | − }); | |
| 1227 | − | |
| 1228 | 1166 | const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ |
| 1229 | 1167 | Request: ThreadActivityKindsLookupInput, |
| 1230 | 1168 | Result: ProjectionThreadActivityDbRowSchema, |
| @@ -1475,8 +1413,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 244 unmodified lines | ||
| 1475 | 1413 | `, |
| 1476 | 1414 | }); |
| 1477 | 1415 | |
| 1478 | − const pinnedThreadActivityIdsCte = (threadId: string) => sql` | |
| 1479 | −pending_approval_requests AS ( | |
| 1416 | + // Blocking request payloads must remain available even if they predate the | |
| 1417 | + // recent activity window. Each CTE returns at most one unresolved row per | |
| 1418 | + // request, so the merge below stays bounded by actionable work. | |
| 1419 | + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ | |
| 1420 | + Request: ThreadIdLookupInput, | |
| 1421 | + Result: ProjectionThreadActivityDbRowSchema, | |
| 1422 | + execute: ({ threadId }) => | |
| 1423 | + sql` | |
| 1424 | + WITH pending_approval_requests AS ( | |
| 1480 | 1425 | SELECT request_id, thread_id |
| 1481 | 1426 | FROM projection_pending_approvals |
| 1482 | 1427 | WHERE thread_id = ${threadId} |
| @@ -1540,17 +1485,6 @@ pending_approval_requests AS ( · 57 unmodified lines | ||
| 1540 | 1485 | WHERE request_order = 1 |
| 1541 | 1486 | AND kind = 'user-input.requested' |
| 1542 | 1487 | ) |
| 1543 | − `; | |
| 1544 | − | |
| 1545 | − // Blocking request payloads must remain available even if they predate the | |
| 1546 | − // recent activity window. Each CTE returns at most one unresolved row per | |
| 1547 | − // request, so the merge below stays bounded by actionable work. | |
| 1548 | − const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ | |
| 1549 | − Request: ThreadIdLookupInput, | |
| 1550 | − Result: ProjectionThreadActivityDbRowSchema, | |
| 1551 | − execute: ({ threadId }) => | |
| 1552 | − sql` | |
| 1553 | − WITH ${pinnedThreadActivityIdsCte(threadId)} | |
| 1554 | 1488 | SELECT |
| 1555 | 1489 | activity.activity_id AS "activityId", |
| 1556 | 1490 | activity.thread_id AS "threadId", |
| @@ -1568,17 +1502,6 @@ pending_approval_requests AS ( · 11 unmodified lines | ||
| 1568 | 1502 | `, |
| 1569 | 1503 | }); |
| 1570 | 1504 | |
| 1571 | − const listPinnedThreadActivityIdsByThread = SqlSchema.findAll({ | |
| 1572 | − Request: ThreadIdLookupInput, | |
| 1573 | − Result: ProjectionThreadActivityIdRowSchema, | |
| 1574 | − execute: ({ threadId }) => | |
| 1575 | − sql` | |
| 1576 | − WITH ${pinnedThreadActivityIdsCte(threadId)} | |
| 1577 | − SELECT activity_id AS "activityId" | |
| 1578 | − FROM pinned_activity_ids | |
| 1579 | − `, | |
| 1580 | − }); | |
| 1581 | − | |
| 1582 | 1505 | const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ |
| 1583 | 1506 | Request: ThreadTurnRangeLookupInput, |
| 1584 | 1507 | Result: ProjectionThreadActivityDbRowSchema, |
| @@ -1646,48 +1569,6 @@ pending_approval_requests AS ( · 61 unmodified lines | ||
| 1646 | 1569 | `, |
| 1647 | 1570 | }); |
| 1648 | 1571 | |
| 1649 | − const listThreadActivityIdsByThreadWindow = SqlSchema.findAll({ | |
| 1650 | − Request: ThreadTurnRangeLookupInput, | |
| 1651 | − Result: ProjectionThreadActivityIdRowSchema, | |
| 1652 | − execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => | |
| 1653 | − sql` | |
| 1654 | − SELECT activity_id AS "activityId" | |
| 1655 | − FROM projection_thread_activities | |
| 1656 | − WHERE thread_id = ${threadId} | |
| 1657 | − AND ( | |
| 1658 | − turn_id IN ( | |
| 1659 | − SELECT turn_id FROM projection_turns | |
| 1660 | − WHERE thread_id = ${threadId} | |
| 1661 | − AND turn_id IS NOT NULL | |
| 1662 | − AND ( | |
| 1663 | − requested_at > ${minAnchorAt} | |
| 1664 | − OR ( | |
| 1665 | − requested_at = ${minAnchorAt} | |
| 1666 | − AND turn_id >= ${minTurnKey} | |
| 1667 | − ) | |
| 1668 | − ) | |
| 1669 | − AND ( | |
| 1670 | − requested_at < ${beforeAnchorAt} | |
| 1671 | − OR ( | |
| 1672 | − requested_at = ${beforeAnchorAt} | |
| 1673 | − AND turn_id < ${beforeTurnKey} | |
| 1674 | − ) | |
| 1675 | − ) | |
| 1676 | − ) | |
| 1677 | − OR ( | |
| 1678 | − turn_id IS NULL | |
| 1679 | − AND created_at >= ${minAnchorAt} | |
| 1680 | − AND created_at < ${beforeAnchorAt} | |
| 1681 | − ) | |
| 1682 | − ) | |
| 1683 | − ORDER BY | |
| 1684 | − sequence DESC, | |
| 1685 | − created_at DESC, | |
| 1686 | − activity_id DESC | |
| 1687 | − LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} | |
| 1688 | − `, | |
| 1689 | − }); | |
| 1690 | − | |
| 1691 | 1572 | const getFullThreadDiffContextRow = SqlSchema.findOneOption({ |
| 1692 | 1573 | Request: FullThreadDiffContextLookupInput, |
| 1693 | 1574 | Result: ProjectionFullThreadDiffContextRowSchema, |
| @@ -2832,136 +2713,49 @@ pending_approval_requests AS ( · 1138 unmodified lines | ||
| 2832 | 2713 | readonly beforeTurnKey: string; |
| 2833 | 2714 | } |
| 2834 | 2715 | |
| 2835 | − type ThreadDetailActivityRead = | |
| 2836 | − | { | |
| 2837 | − readonly mode: "raw"; | |
| 2838 | − readonly query?: ProjectionThreadDetailQuery; | |
| 2839 | − } | |
| 2840 | − | { | |
| 2841 | − readonly mode: "client"; | |
| 2842 | − }; | |
| 2843 | − | |
| 2844 | − const listProjectedThreadActivities = Effect.fn( | |
| 2845 | − "ProjectionSnapshotQuery.listProjectedThreadActivities", | |
| 2846 | − )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) { | |
| 2847 | − const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([ | |
| 2848 | − (bounds === undefined | |
| 2849 | − ? listThreadActivityIdsByThread({ threadId }) | |
| 2850 | − : listThreadActivityIdsByThreadWindow({ threadId, ...bounds }) | |
| 2716 | + const getThreadDetailByIdBounded = ( | |
| 2717 | + threadId: ThreadId, | |
| 2718 | + bounds: ThreadDetailBounds | undefined, | |
| 2719 | + query: ProjectionThreadDetailQuery | undefined = undefined, | |
| 2720 | + ) => | |
| 2721 | + Effect.gen(function* () { | |
| 2722 | + const activityRowsEffect = ( | |
| 2723 | + query?.activityKinds === undefined | |
| 2724 | + ? bounds === undefined | |
| 2725 | + ? listThreadActivityRowsByThread({ threadId }) | |
| 2726 | + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) | |
| 2727 | + : query.activityKinds.length === 0 | |
| 2728 | + ? Effect.succeed([]) | |
| 2729 | + : listThreadActivityRowsByThreadAndKinds({ | |
| 2730 | + threadId, | |
| 2731 | + activityKinds: query.activityKinds, | |
| 2732 | + }) | |
| 2851 | 2733 | ).pipe( |
| 2852 | 2734 | Effect.mapError( |
| 2853 | 2735 | toPersistenceSqlOrDecodeError( |
| 2854 | − "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query", | |
| 2855 | − "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows", | |
| 2736 | + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", | |
| 2737 | + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", | |
| 2856 | 2738 | ), |
| 2857 | 2739 | ), |
| 2858 | − ), | |
| 2859 | − listPinnedThreadActivityIdsByThread({ threadId }).pipe( | |
| 2860 | − Effect.mapError( | |
| 2861 | − toPersistenceSqlOrDecodeError( | |
| 2862 | − "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:query", | |
| 2863 | − "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:decodeRows", | |
| 2864 | − ), | |
| 2865 | − ), | |
| 2866 | − ), | |
| 2867 | − ]); | |
| 2868 | − const activityIds = [ | |
| 2869 | − ...new Set([...activityIdRows, ...pinnedActivityIdRows].map(({ activityId }) => activityId)), | |
| 2870 | − ]; | |
| 2871 | − const activities: OrchestrationThreadActivity[] = []; | |
| 2872 | − | |
| 2873 | − for ( | |
| 2874 | − let offset = 0; | |
| 2875 | − offset < activityIds.length; | |
| 2876 | − offset += THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE | |
| 2877 | − ) { | |
| 2878 | − const batchIds = activityIds.slice( | |
| 2879 | − offset, | |
| 2880 | − offset + THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE, | |
| 2881 | 2740 | ); |
| 2882 | − const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe( | |
| 2883 | − Effect.mapError( | |
| 2884 | − toPersistenceSqlOrDecodeError( | |
| 2885 | − "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query", | |
| 2886 | − "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows", | |
| 2887 | − ), | |
| 2888 | − ), | |
| 2889 | − ); | |
| 2890 | − for (const row of batchRows) { | |
| 2891 | − activities.push(projectActivityPayload(mapThreadActivityRow(row))); | |
| 2892 | − } | |
| 2893 | − } | |
| 2894 | − | |
| 2895 | − return activities.toSorted( | |
| 2896 | − (left, right) => | |
| 2897 | − (left.sequence ?? -1) - (right.sequence ?? -1) || | |
| 2898 | − left.createdAt.localeCompare(right.createdAt) || | |
| 2899 | − left.id.localeCompare(right.id), | |
| 2900 | − ); | |
| 2901 | − }); | |
| 2902 | − | |
| 2903 | − const getThreadDetailByIdBounded = ( | |
| 2904 | − threadId: ThreadId, | |
| 2905 | − bounds: ThreadDetailBounds | undefined, | |
| 2906 | − activityRead: ThreadDetailActivityRead = { mode: "raw" }, | |
| 2907 | − ) => | |
| 2908 | − Effect.gen(function* () { | |
| 2909 | − const activitiesEffect = | |
| 2910 | − activityRead.mode === "client" | |
| 2911 | − ? listProjectedThreadActivities(threadId, bounds) | |
| 2912 | − : Effect.all([ | |
| 2913 | − (activityRead.query?.activityKinds === undefined | |
| 2914 | − ? bounds === undefined | |
| 2915 | − ? listThreadActivityRowsByThread({ threadId }) | |
| 2916 | − : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) | |
| 2917 | − : activityRead.query.activityKinds.length === 0 | |
| 2918 | − ? Effect.succeed([]) | |
| 2919 | − : listThreadActivityRowsByThreadAndKinds({ | |
| 2920 | − threadId, | |
| 2921 | − activityKinds: activityRead.query.activityKinds, | |
| 2922 | − }) | |
| 2923 | − ).pipe( | |
| 2924 | − Effect.mapError( | |
| 2925 | − toPersistenceSqlOrDecodeError( | |
| 2926 | − "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", | |
| 2927 | − "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", | |
| 2928 | − ), | |
| 2741 | + const pinnedActivityRowsEffect = | |
| 2742 | + query?.activityKinds === undefined | |
| 2743 | + ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( | |
| 2744 | + Effect.mapError( | |
| 2745 | + toPersistenceSqlOrDecodeError( | |
| 2746 | + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", | |
| 2747 | + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", | |
| 2929 | 2748 | ), |
| 2930 | 2749 | ), |
| 2931 | − activityRead.query?.activityKinds === undefined | |
| 2932 | − ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( | |
| 2933 | − Effect.mapError( | |
| 2934 | − toPersistenceSqlOrDecodeError( | |
| 2935 | − "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", | |
| 2936 | − "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", | |
| 2937 | − ), | |
| 2938 | − ), | |
| 2939 | − ) | |
| 2940 | − : Effect.succeed([]), | |
| 2941 | − ]).pipe( | |
| 2942 | − Effect.map(([activityRows, pinnedActivityRows]) => | |
| 2943 | − [ | |
| 2944 | − ...new Map( | |
| 2945 | − [...activityRows, ...pinnedActivityRows].map( | |
| 2946 | − (row) => [row.activityId, row] as const, | |
| 2947 | − ), | |
| 2948 | − ).values(), | |
| 2949 | − ] | |
| 2950 | − .toSorted( | |
| 2951 | − (left, right) => | |
| 2952 | − (left.sequence ?? -1) - (right.sequence ?? -1) || | |
| 2953 | − left.createdAt.localeCompare(right.createdAt) || | |
| 2954 | − left.activityId.localeCompare(right.activityId), | |
| 2955 | − ) | |
| 2956 | − .map(mapThreadActivityRow), | |
| 2957 | − ), | |
| 2958 | − ); | |
| 2750 | + ) | |
| 2751 | + : Effect.succeed([]); | |
| 2959 | 2752 | |
| 2960 | 2753 | const [ |
| 2961 | 2754 | threadRow, |
| 2962 | 2755 | messageRows, |
| 2963 | 2756 | proposedPlanRows, |
| 2964 | − activities, | |
| 2757 | + activityRows, | |
| 2758 | + pinnedActivityRows, | |
| 2965 | 2759 | checkpointRows, |
| 2966 | 2760 | latestTurnRow, |
| 2967 | 2761 | sessionRow, |
| @@ -2993,7 +2787,8 @@ pending_approval_requests AS ( · 25 unmodified lines | ||
| 2993 | 2787 | ), |
| 2994 | 2788 | ), |
| 2995 | 2789 | ), |
| 2996 | − activitiesEffect, | |
| 2790 | + activityRowsEffect, | |
| 2791 | + pinnedActivityRowsEffect, | |
| 2997 | 2792 | listCheckpointRowsByThread({ threadId }).pipe( |
| 2998 | 2793 | Effect.mapError( |
| 2999 | 2794 | toPersistenceSqlOrDecodeError( |
| @@ -3024,6 +2819,17 @@ pending_approval_requests AS ( · 24 unmodified lines | ||
| 3024 | 2819 | return Option.none<OrchestrationThread>(); |
| 3025 | 2820 | } |
| 3026 | 2821 | |
| 2822 | + const selectedActivityRows = [ | |
| 2823 | + ...new Map( | |
| 2824 | + [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const), | |
| 2825 | + ).values(), | |
| 2826 | + ].toSorted( | |
| 2827 | + (left, right) => | |
| 2828 | + (left.sequence ?? -1) - (right.sequence ?? -1) || | |
| 2829 | + left.createdAt.localeCompare(right.createdAt) || | |
| 2830 | + left.activityId.localeCompare(right.activityId), | |
| 2831 | + ); | |
| 2832 | + | |
| 3027 | 2833 | const thread = { |
| 3028 | 2834 | id: threadRow.value.threadId, |
| 3029 | 2835 | projectId: threadRow.value.projectId, |
| @@ -3061,7 +2867,21 @@ pending_approval_requests AS ( · 31 unmodified lines | ||
| 3061 | 2867 | return message; |
| 3062 | 2868 | }), |
| 3063 | 2869 | proposedPlans: proposedPlanRows.map(mapProposedPlanRow), |
| 3064 | − activities, | |
| 2870 | + activities: selectedActivityRows.map((row) => { | |
| 2871 | + const activity = { | |
| 2872 | + id: row.activityId, | |
| 2873 | + tone: row.tone, | |
| 2874 | + kind: row.kind, | |
| 2875 | + summary: row.summary, | |
| 2876 | + payload: row.payload, | |
| 2877 | + turnId: row.turnId, | |
| 2878 | + createdAt: row.createdAt, | |
| 2879 | + }; | |
| 2880 | + if (row.sequence !== null) { | |
| 2881 | + return Object.assign(activity, { sequence: row.sequence }); | |
| 2882 | + } | |
| 2883 | + return activity; | |
| 2884 | + }), | |
| 3065 | 2885 | checkpoints: checkpointRows.map((row) => ({ |
| 3066 | 2886 | turnId: row.turnId, |
| 3067 | 2887 | checkpointTurnCount: row.checkpointTurnCount, |
| @@ -3086,11 +2906,7 @@ pending_approval_requests AS ( · 18 unmodified lines | ||
| 3086 | 2906 | const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( |
| 3087 | 2907 | threadId, |
| 3088 | 2908 | query, |
| 3089 | − ) => | |
| 3090 | − getThreadDetailByIdBounded(threadId, undefined, { | |
| 3091 | − mode: "raw", | |
| 3092 | − ...(query === undefined ? {} : { query }), | |
| 3093 | − }); | |
| 2909 | + ) => getThreadDetailByIdBounded(threadId, undefined, query); | |
| 3094 | 2910 | |
| 3095 | 2911 | // Bounds pathological fan-out: one user turn that spawned hundreds of |
| 3096 | 2912 | // subagent turns still pages in bounded chunks, at the cost of splitting the |
| @@ -3114,9 +2930,7 @@ pending_approval_requests AS ( · 17 unmodified lines | ||
| 3114 | 2930 | .withTransaction( |
| 3115 | 2931 | Effect.gen(function* () { |
| 3116 | 2932 | if (window?.turnLimit === undefined) { |
| 3117 | − const thread = yield* getThreadDetailByIdBounded(threadId, undefined, { | |
| 3118 | − mode: "client", | |
| 3119 | − }); | |
| 2933 | + const thread = yield* getThreadDetailById(threadId); | |
| 3120 | 2934 | if (Option.isNone(thread)) { |
| 3121 | 2935 | return Option.none<OrchestrationThreadDetailSnapshot>(); |
| 3122 | 2936 | } |
| @@ -3169,9 +2983,7 @@ pending_approval_requests AS ( · 46 unmodified lines | ||
| 3169 | 2983 | ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } |
| 3170 | 2984 | : undefined; |
| 3171 | 2985 | |
| 3172 | − const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, { | |
| 3173 | − mode: "client", | |
| 3174 | − }); | |
| 2986 | + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); | |
| 3175 | 2987 | if (Option.isNone(thread)) { |
| 3176 | 2988 | return Option.none<OrchestrationThreadDetailSnapshot>(); |
| 3177 | 2989 | } |
| @@ -227,10 +227,6 @@ export interface ProjectionSnapshotQueryShape { · 226 unmodified lines | ||
| 227 | 227 | * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). |
| 228 | 228 | * Without a window the full thread is returned with no `page` field — |
| 229 | 229 | * pagination is strictly opt-in. |
| 230 | − * | |
| 231 | − * Activity payloads are projected for clients as they are read in small | |
| 232 | − * sequential batches. Callers still apply the full snapshot projector for | |
| 233 | − * collection-level activity pruning. | |
| 234 | 230 | */ |
| 235 | 231 | readonly getThreadDetailSnapshot: ( |
| 236 | 232 | threadId: ThreadId, |
| @@ -1,250 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − EventId, | |
| 3 | − MessageId, | |
| 4 | − ThreadId, | |
| 5 | − TurnId, | |
| 6 | − OrchestrationEvent, | |
| 7 | − type OrchestrationThreadActivity, | |
| 8 | −} from "@t3tools/contracts"; | |
| 9 | −import { it } from "@effect/vitest"; | |
| 10 | −import * as Clock from "effect/Clock"; | |
| 11 | −import * as Effect from "effect/Effect"; | |
| 12 | −import * as Schema from "effect/Schema"; | |
| 13 | −import * as Stream from "effect/Stream"; | |
| 14 | −import * as TestClock from "effect/testing/TestClock"; | |
| 15 | −import { describe, expect } from "vite-plus/test"; | |
| 16 | − | |
| 17 | −import { | |
| 18 | − coalesceLiveToolUpdatedEvents, | |
| 19 | − makeThreadLiveEventCoalescer, | |
| 20 | −} from "./ThreadLiveEventCoalescer.ts"; | |
| 21 | − | |
| 22 | −const threadId = ThreadId.make("thread-coalescer-test"); | |
| 23 | −const turnId = TurnId.make("turn-coalescer-test"); | |
| 24 | −const encodeEvent = Schema.encodeSync(Schema.fromJsonString(OrchestrationEvent)); | |
| 25 | − | |
| 26 | −function makeToolActivity( | |
| 27 | − sequence: number, | |
| 28 | − options: { | |
| 29 | − readonly kind?: "tool.updated" | "tool.completed"; | |
| 30 | − readonly toolCallId?: string; | |
| 31 | − readonly turnId?: TurnId; | |
| 32 | − } = {}, | |
| 33 | −): OrchestrationEvent { | |
| 34 | − const { | |
| 35 | − kind = "tool.updated", | |
| 36 | − toolCallId = "call-edit", | |
| 37 | − turnId: activityTurnId = turnId, | |
| 38 | − } = options; | |
| 39 | − const activity: OrchestrationThreadActivity = { | |
| 40 | − id: EventId.make(`activity-${sequence}`), | |
| 41 | − tone: "tool", | |
| 42 | − kind, | |
| 43 | − summary: "Editing app.ts", | |
| 44 | − payload: { | |
| 45 | − itemType: "file_change", | |
| 46 | − title: "Editing app.ts", | |
| 47 | − data: toolCallId ? { toolCallId } : {}, | |
| 48 | − }, | |
| 49 | − turnId: activityTurnId, | |
| 50 | − createdAt: "2026-01-01T00:00:01.000Z", | |
| 51 | − }; | |
| 52 | − return { | |
| 53 | − sequence, | |
| 54 | − eventId: EventId.make(`event-${sequence}`), | |
| 55 | − aggregateKind: "thread", | |
| 56 | − aggregateId: threadId, | |
| 57 | − occurredAt: "2026-01-01T00:00:01.000Z", | |
| 58 | − commandId: null, | |
| 59 | − causationEventId: null, | |
| 60 | − correlationId: null, | |
| 61 | − metadata: {}, | |
| 62 | − type: "thread.activity-appended", | |
| 63 | − payload: { threadId, activity }, | |
| 64 | − }; | |
| 65 | −} | |
| 66 | − | |
| 67 | −function makeMessage(sequence: number, text = "Still working"): OrchestrationEvent { | |
| 68 | − return { | |
| 69 | − sequence, | |
| 70 | − eventId: EventId.make(`event-${sequence}`), | |
| 71 | − aggregateKind: "thread", | |
| 72 | − aggregateId: threadId, | |
| 73 | − occurredAt: "2026-01-01T00:00:02.000Z", | |
| 74 | − commandId: null, | |
| 75 | − causationEventId: null, | |
| 76 | − correlationId: null, | |
| 77 | − metadata: {}, | |
| 78 | − type: "thread.message-sent", | |
| 79 | − payload: { | |
| 80 | − threadId, | |
| 81 | − messageId: MessageId.make(`message-${sequence}`), | |
| 82 | − role: "assistant", | |
| 83 | − text, | |
| 84 | − turnId, | |
| 85 | − streaming: false, | |
| 86 | − createdAt: "2026-01-01T00:00:02.000Z", | |
| 87 | − updatedAt: "2026-01-01T00:00:02.000Z", | |
| 88 | − }, | |
| 89 | − }; | |
| 90 | −} | |
| 91 | − | |
| 92 | −describe("ThreadLiveEventCoalescer", () => { | |
| 93 | − it("coalesces only calls with a stable toolCallId", () => { | |
| 94 | − const events = [ | |
| 95 | − makeToolActivity(1, { toolCallId: "call-a" }), | |
| 96 | − makeToolActivity(2, { toolCallId: "call-b" }), | |
| 97 | − makeToolActivity(3, { toolCallId: "call-a" }), | |
| 98 | − ]; | |
| 99 | − | |
| 100 | − expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]); | |
| 101 | − }); | |
| 102 | − | |
| 103 | − it("preserves parallel same-label calls without a stable toolCallId", () => { | |
| 104 | − const events = [ | |
| 105 | − makeToolActivity(1, { toolCallId: "" }), | |
| 106 | − makeToolActivity(2, { toolCallId: "" }), | |
| 107 | − makeToolActivity(3, { kind: "tool.completed", toolCallId: "" }), | |
| 108 | − ]; | |
| 109 | − | |
| 110 | − expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2, 3]); | |
| 111 | − }); | |
| 112 | − | |
| 113 | − it("does not coalesce stable tool calls across turns", () => { | |
| 114 | − const events = [ | |
| 115 | − makeToolActivity(1, { turnId: TurnId.make("turn-old") }), | |
| 116 | − makeToolActivity(2, { turnId: TurnId.make("turn-new") }), | |
| 117 | − ]; | |
| 118 | − | |
| 119 | − expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2]); | |
| 120 | − }); | |
| 121 | − | |
| 122 | − it("flushes a stable update run before a completion boundary", () => { | |
| 123 | − const events = [ | |
| 124 | − makeToolActivity(1), | |
| 125 | − makeToolActivity(2), | |
| 126 | − makeToolActivity(3, { kind: "tool.completed" }), | |
| 127 | − makeToolActivity(4), | |
| 128 | − ]; | |
| 129 | − | |
| 130 | − expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]); | |
| 131 | − }); | |
| 132 | − | |
| 133 | − it.effect("flushes pending tool updates as soon as an unrelated event arrives", () => | |
| 134 | − Effect.scoped( | |
| 135 | − Effect.gen(function* () { | |
| 136 | − const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); | |
| 137 | − const startedAt = yield* Clock.currentTimeMillis; | |
| 138 | − yield* Effect.forEach( | |
| 139 | − Array.from({ length: 10 }, (_, index) => index + 2), | |
| 140 | − (sequence) => coalescer.offer({ kind: "event", event: makeToolActivity(sequence) }), | |
| 141 | − { discard: true }, | |
| 142 | − ); | |
| 143 | − yield* coalescer.offer({ kind: "event", event: makeMessage(12) }); | |
| 144 | − | |
| 145 | − expect(yield* Clock.currentTimeMillis).toBe(startedAt); | |
| 146 | − expect( | |
| 147 | − (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) => | |
| 148 | − item.kind === "event" ? item.event.sequence : item.kind, | |
| 149 | − ), | |
| 150 | − ).toEqual([11, 12]); | |
| 151 | − }), | |
| 152 | − ).pipe(Effect.provide(TestClock.layer())), | |
| 153 | − ); | |
| 154 | − | |
| 155 | − it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () => | |
| 156 | − Effect.scoped( | |
| 157 | − Effect.gen(function* () { | |
| 158 | − const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); | |
| 159 | − const startedAt = yield* Clock.currentTimeMillis; | |
| 160 | − yield* coalescer.offer({ kind: "event", event: makeToolActivity(2) }); | |
| 161 | − yield* coalescer.offer({ kind: "event", event: makeToolActivity(3) }); | |
| 162 | − yield* coalescer.offer({ kind: "synchronized" }); | |
| 163 | − | |
| 164 | − expect(yield* Clock.currentTimeMillis).toBe(startedAt); | |
| 165 | − expect( | |
| 166 | − (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) => | |
| 167 | − item.kind === "event" ? item.event.sequence : item.kind, | |
| 168 | − ), | |
| 169 | − ).toEqual([3, "synchronized"]); | |
| 170 | − }), | |
| 171 | − ).pipe(Effect.provide(TestClock.layer())), | |
| 172 | − ); | |
| 173 | − | |
| 174 | − it.effect("fails and clears pending updates when their serialized payload fills the budget", () => | |
| 175 | − Effect.scoped( | |
| 176 | − Effect.gen(function* () { | |
| 177 | − const first = makeToolActivity(1); | |
| 178 | − const coalescer = yield* makeThreadLiveEventCoalescer({ | |
| 179 | − coalesceWindow: "500 millis", | |
| 180 | − maxSerializedBytes: Buffer.byteLength(encodeEvent(first)), | |
| 181 | − }); | |
| 182 | − yield* coalescer.offer({ kind: "event", event: first }); | |
| 183 | − expect(yield* coalescer.usage).toEqual({ | |
| 184 | − retainedItems: 1, | |
| 185 | − retainedSerializedBytes: Buffer.byteLength(encodeEvent(first)), | |
| 186 | − }); | |
| 187 | − | |
| 188 | − const overflow = yield* coalescer | |
| 189 | − .offer({ kind: "event", event: makeToolActivity(2) }) | |
| 190 | − .pipe(Effect.result); | |
| 191 | − expect(overflow._tag).toBe("Failure"); | |
| 192 | − yield* coalescer.closed; | |
| 193 | − expect(yield* coalescer.usage).toEqual({ retainedItems: 0, retainedSerializedBytes: 0 }); | |
| 194 | − const marker = yield* coalescer.offer({ kind: "synchronized" }).pipe(Effect.result); | |
| 195 | − expect(marker._tag).toBe("Failure"); | |
| 196 | − const delivered = yield* coalescer.stream.pipe(Stream.runCollect, Effect.result); | |
| 197 | − expect(delivered._tag).toBe("Failure"); | |
| 198 | − }), | |
| 199 | − ), | |
| 200 | − ); | |
| 201 | − | |
| 202 | − it.effect("keeps the flush timer alive when an offer's shorter scope closes", () => | |
| 203 | − Effect.scoped( | |
| 204 | − Effect.gen(function* () { | |
| 205 | − const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "50 millis" }); | |
| 206 | − yield* Effect.scoped(coalescer.offer({ kind: "event", event: makeToolActivity(1) })); | |
| 207 | − yield* TestClock.adjust("50 millis"); | |
| 208 | − const items = yield* coalescer.stream.pipe(Stream.take(1), Stream.runCollect); | |
| 209 | − expect( | |
| 210 | − items.map((item) => (item.kind === "event" ? item.event.sequence : item.kind)), | |
| 211 | − ).toEqual([1]); | |
| 212 | − }), | |
| 213 | − ), | |
| 214 | − ); | |
| 215 | − | |
| 216 | − it.effect("keeps an unacknowledged batch charged and clears later events on overflow", () => | |
| 217 | − Effect.scoped( | |
| 218 | − Effect.gen(function* () { | |
| 219 | − const coalescer = yield* makeThreadLiveEventCoalescer({ maxItems: 3 }); | |
| 220 | − const first = makeMessage(1, "é".repeat(1_024)); | |
| 221 | − yield* coalescer.offer({ kind: "event", event: first }); | |
| 222 | − | |
| 223 | − yield* Effect.scoped( | |
| 224 | − Effect.gen(function* () { | |
| 225 | − const pull = yield* Stream.toPull(coalescer.stream); | |
| 226 | − const batch = yield* pull; | |
| 227 | − expect( | |
| 228 | − batch.map((item) => (item.kind === "event" ? item.event.sequence : null)), | |
| 229 | − ).toEqual([1]); | |
| 230 | − yield* coalescer.offer({ kind: "event", event: makeMessage(2) }); | |
| 231 | − yield* coalescer.offer({ kind: "event", event: makeToolActivity(3) }); | |
| 232 | − expect((yield* coalescer.usage).retainedItems).toBe(3); | |
| 233 | − | |
| 234 | − const overflow = yield* coalescer | |
| 235 | − .offer({ kind: "event", event: makeToolActivity(4, { kind: "tool.completed" }) }) | |
| 236 | − .pipe(Effect.result); | |
| 237 | − expect(overflow._tag).toBe("Failure"); | |
| 238 | − // Do not pull or acknowledge the batch. Cleanup must still finish. | |
| 239 | − yield* coalescer.closed; | |
| 240 | − expect(yield* coalescer.usage).toEqual({ | |
| 241 | − retainedItems: 1, | |
| 242 | − retainedSerializedBytes: Buffer.byteLength(encodeEvent(first)), | |
| 243 | − }); | |
| 244 | − }), | |
| 245 | − ); | |
| 246 | − expect(yield* coalescer.usage).toEqual({ retainedItems: 0, retainedSerializedBytes: 0 }); | |
| 247 | − }), | |
| 248 | − ), | |
| 249 | − ); | |
| 250 | −}); | |
| @@ -1,241 +0,0 @@ | ||
| 1 | −import type { | |
| 2 | − OrchestrationEvent, | |
| 3 | − OrchestrationGetSnapshotError, | |
| 4 | − OrchestrationThreadStreamItem, | |
| 5 | −} from "@t3tools/contracts"; | |
| 6 | −import * as Deferred from "effect/Deferred"; | |
| 7 | −import * as Duration from "effect/Duration"; | |
| 8 | −import * as Effect from "effect/Effect"; | |
| 9 | −import * as Fiber from "effect/Fiber"; | |
| 10 | −import * as Predicate from "effect/Predicate"; | |
| 11 | −import * as Queue from "effect/Queue"; | |
| 12 | −import * as Semaphore from "effect/Semaphore"; | |
| 13 | −import * as Stream from "effect/Stream"; | |
| 14 | − | |
| 15 | −import { projectActivityEvent } from "./ActivityPayloadProjection.ts"; | |
| 16 | −import { makeLiveStreamBudget, type RetainedLiveItem } from "./LiveStreamBudget.ts"; | |
| 17 | − | |
| 18 | −const COALESCE_WINDOW = Duration.millis(50); | |
| 19 | −const MAX_PENDING_UPDATES = 512; | |
| 20 | − | |
| 21 | −export type ThreadLiveInput = | |
| 22 | − | { readonly kind: "event"; readonly event: OrchestrationEvent } | |
| 23 | − | { readonly kind: "synchronized" }; | |
| 24 | − | |
| 25 | −function isToolUpdated(event: OrchestrationEvent): boolean { | |
| 26 | − return ( | |
| 27 | − event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated" | |
| 28 | − ); | |
| 29 | −} | |
| 30 | − | |
| 31 | −function asTrimmedString(value: unknown): string | null { | |
| 32 | − if (!Predicate.isString(value)) { | |
| 33 | − return null; | |
| 34 | − } | |
| 35 | − const trimmed = value.trim(); | |
| 36 | − return trimmed.length > 0 ? trimmed : null; | |
| 37 | −} | |
| 38 | − | |
| 39 | −function stableToolCallIdentity(event: OrchestrationEvent): string | null { | |
| 40 | − if (event.type !== "thread.activity-appended") { | |
| 41 | − return null; | |
| 42 | − } | |
| 43 | − const payload = event.payload.activity.payload; | |
| 44 | − if (!Predicate.isObject(payload)) { | |
| 45 | − return null; | |
| 46 | − } | |
| 47 | − const data = Predicate.isObject(payload.data) ? payload.data : null; | |
| 48 | − return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId); | |
| 49 | −} | |
| 50 | − | |
| 51 | −/** | |
| 52 | − * Retain only the latest in-flight update for each stable tool-call id in a | |
| 53 | − * live run. Anonymous calls pass through because labels are not unique when | |
| 54 | − * tools execute in parallel. Survivors remain in sequence order. | |
| 55 | − */ | |
| 56 | −export function coalesceLiveToolUpdatedEvents( | |
| 57 | − events: ReadonlyArray<OrchestrationEvent>, | |
| 58 | −): ReadonlyArray<OrchestrationEvent> { | |
| 59 | − const survivors: Array<OrchestrationEvent> = []; | |
| 60 | − let pendingUpdates: Array<OrchestrationEvent> = []; | |
| 61 | − | |
| 62 | − const flushUpdates = () => { | |
| 63 | − const seen = new Set<string>(); | |
| 64 | − const latestUpdates: Array<OrchestrationEvent> = []; | |
| 65 | − for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) { | |
| 66 | − const event = pendingUpdates[index]!; | |
| 67 | − const identity = stableToolCallIdentity(event); | |
| 68 | − const activity = | |
| 69 | − event.type === "thread.activity-appended" ? event.payload.activity : undefined; | |
| 70 | − const key = identity ? `${activity?.turnId ?? ""}\u0000${identity}` : null; | |
| 71 | − if (key && seen.has(key)) { | |
| 72 | − continue; | |
| 73 | − } | |
| 74 | − if (key) { | |
| 75 | − seen.add(key); | |
| 76 | − } | |
| 77 | − latestUpdates.push(event); | |
| 78 | − } | |
| 79 | − latestUpdates.reverse(); | |
| 80 | − survivors.push(...latestUpdates); | |
| 81 | − pendingUpdates = []; | |
| 82 | − }; | |
| 83 | − | |
| 84 | − for (const event of events) { | |
| 85 | − if (isToolUpdated(event)) { | |
| 86 | − pendingUpdates.push(event); | |
| 87 | − continue; | |
| 88 | − } | |
| 89 | − flushUpdates(); | |
| 90 | − survivors.push(event); | |
| 91 | − } | |
| 92 | − flushUpdates(); | |
| 93 | − return survivors; | |
| 94 | −} | |
| 95 | − | |
| 96 | −export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoalescer")( | |
| 97 | − function* (options?: { | |
| 98 | − readonly coalesceWindow?: Duration.Input; | |
| 99 | − readonly maxItems?: number; | |
| 100 | − readonly maxSerializedBytes?: number; | |
| 101 | − }) { | |
| 102 | − const coalescerScope = yield* Effect.scope; | |
| 103 | − const budget = yield* makeLiveStreamBudget(options); | |
| 104 | − const cleanupComplete = yield* Deferred.make<void>(); | |
| 105 | − const output = yield* Queue.unbounded< | |
| 106 | − RetainedLiveItem<OrchestrationThreadStreamItem>, | |
| 107 | − OrchestrationGetSnapshotError | |
| 108 | − >(); | |
| 109 | − const mutex = yield* Semaphore.make(1); | |
| 110 | − const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW; | |
| 111 | − let pendingUpdates: Array<RetainedLiveItem<OrchestrationEvent>> = []; | |
| 112 | − let windowGeneration = 0; | |
| 113 | − let windowFiber: Fiber.Fiber<void, never> | null = null; | |
| 114 | − let closed = false; | |
| 115 | − | |
| 116 | − const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () { | |
| 117 | − const fiber = windowFiber; | |
| 118 | − if (!fiber) { | |
| 119 | − return; | |
| 120 | − } | |
| 121 | − windowFiber = null; | |
| 122 | − yield* Fiber.interrupt(fiber); | |
| 123 | − }); | |
| 124 | − | |
| 125 | − const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* () { | |
| 126 | − if (pendingUpdates.length === 0) { | |
| 127 | − return; | |
| 128 | − } | |
| 129 | − const items = yield* budget.replace( | |
| 130 | − pendingUpdates, | |
| 131 | − coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)).map((event) => ({ | |
| 132 | − kind: "event" as const, | |
| 133 | − event, | |
| 134 | − })), | |
| 135 | − (item) => item.event, | |
| 136 | − ); | |
| 137 | − pendingUpdates = []; | |
| 138 | − yield* Queue.offerAll(output, items); | |
| 139 | − }, Effect.uninterruptible); | |
| 140 | − | |
| 141 | − const flushWindow = (generation: number) => | |
| 142 | − Effect.sleep(coalesceWindow).pipe( | |
| 143 | − Effect.andThen( | |
| 144 | − mutex.withPermits(1)( | |
| 145 | − Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)), | |
| 146 | − ), | |
| 147 | − ), | |
| 148 | − Effect.ensuring( | |
| 149 | − Effect.sync(() => { | |
| 150 | − if (generation === windowGeneration) { | |
| 151 | − windowFiber = null; | |
| 152 | − } | |
| 153 | − }), | |
| 154 | − ), | |
| 155 | − Effect.catchTags({ OrchestrationGetSnapshotError: () => Effect.void }), | |
| 156 | − ); | |
| 157 | − | |
| 158 | − // Keep each source batch together so a synchronization marker cannot pass | |
| 159 | − // events already pulled from PubSub but still being coalesced. | |
| 160 | − const offerAll = Effect.fn("ThreadLiveEventCoalescer.offerAll")(function* ( | |
| 161 | − inputs: ReadonlyArray<ThreadLiveInput>, | |
| 162 | − ) { | |
| 163 | − yield* mutex.withPermits(1)( | |
| 164 | − Effect.forEach( | |
| 165 | − inputs, | |
| 166 | − (input) => | |
| 167 | − Effect.gen(function* () { | |
| 168 | − yield* budget.check; | |
| 169 | − if (input.kind === "event") { | |
| 170 | − // Retain only the client payload, not full persisted tool output. | |
| 171 | − yield* budget.retain(projectActivityEvent(input.event)).pipe( | |
| 172 | − Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))), | |
| 173 | − Effect.uninterruptible, | |
| 174 | − ); | |
| 175 | − } | |
| 176 | − if (input.kind === "event" && isToolUpdated(input.event)) { | |
| 177 | − if (pendingUpdates.length === 1) { | |
| 178 | − const generation = ++windowGeneration; | |
| 179 | − windowFiber = yield* Effect.forkIn(flushWindow(generation), coalescerScope); | |
| 180 | − } | |
| 181 | − if (pendingUpdates.length >= MAX_PENDING_UPDATES) { | |
| 182 | − yield* cancelWindow(); | |
| 183 | − windowGeneration += 1; | |
| 184 | − yield* flushPending(); | |
| 185 | − } | |
| 186 | − return; | |
| 187 | − } | |
| 188 | − | |
| 189 | − yield* cancelWindow(); | |
| 190 | − windowGeneration += 1; | |
| 191 | − // A non-update event closes the run immediately. The coalescer keeps | |
| 192 | − // that boundary after the final update from the run. | |
| 193 | − yield* flushPending(); | |
| 194 | − if (input.kind === "synchronized") { | |
| 195 | − yield* budget.retain({ kind: "synchronized" as const }).pipe( | |
| 196 | − Effect.flatMap((marker) => Queue.offer(output, marker)), | |
| 197 | − Effect.uninterruptible, | |
| 198 | − ); | |
| 199 | − } | |
| 200 | − }), | |
| 201 | − { discard: true }, | |
| 202 | − ), | |
| 203 | − ); | |
| 204 | − }); | |
| 205 | − | |
| 206 | − const close = (error?: OrchestrationGetSnapshotError) => | |
| 207 | − mutex.withPermits(1)( | |
| 208 | − Effect.gen(function* () { | |
| 209 | − if (closed) { | |
| 210 | − return; | |
| 211 | − } | |
| 212 | − closed = true; | |
| 213 | − windowGeneration += 1; | |
| 214 | − yield* cancelWindow(); | |
| 215 | − budget.release(pendingUpdates); | |
| 216 | − pendingUpdates = []; | |
| 217 | − budget.release(yield* Queue.clear(output).pipe(Effect.orDie)); | |
| 218 | − if (error) { | |
| 219 | − yield* Queue.fail(output, error); | |
| 220 | − } | |
| 221 | − yield* Queue.shutdown(output); | |
| 222 | − yield* Deferred.succeed(cleanupComplete, undefined); | |
| 223 | − }), | |
| 224 | − ); | |
| 225 | − | |
| 226 | − yield* Effect.addFinalizer(() => close()); | |
| 227 | − yield* budget.failed.pipe( | |
| 228 | − Effect.catchTags({ OrchestrationGetSnapshotError: close }), | |
| 229 | − Effect.forkScoped, | |
| 230 | − ); | |
| 231 | − | |
| 232 | − return { | |
| 233 | − offer: (input: ThreadLiveInput) => offerAll([input]), | |
| 234 | − offerAll, | |
| 235 | − stream: budget.deliver(Stream.fromQueue(output)), | |
| 236 | − failed: budget.failed, | |
| 237 | − closed: Deferred.await(cleanupComplete), | |
| 238 | − usage: budget.usage, | |
| 239 | − } as const; | |
| 240 | − }, | |
| 241 | −); | |
| @@ -1,858 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − DEFAULT_SERVER_SETTINGS, | |
| 3 | − ProjectId, | |
| 4 | − ProviderInstanceId, | |
| 5 | − PullRequestOperationError, | |
| 6 | − ThreadId, | |
| 7 | − type OrchestrationCommand, | |
| 8 | − type OrchestrationProjectShell, | |
| 9 | − type OrchestrationShellSnapshot, | |
| 10 | − type OrchestrationThreadShell, | |
| 11 | − type PullRequestSummary, | |
| 12 | − type ServerSettings, | |
| 13 | − type ServerSettingsPatch, | |
| 14 | −} from "@t3tools/contracts"; | |
| 15 | −import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
| 16 | −import { assert, describe, it } from "@effect/vitest"; | |
| 17 | −import * as Crypto from "effect/Crypto"; | |
| 18 | −import * as Deferred from "effect/Deferred"; | |
| 19 | −import * as Effect from "effect/Effect"; | |
| 20 | −import * as FileSystem from "effect/FileSystem"; | |
| 21 | −import * as Layer from "effect/Layer"; | |
| 22 | −import * as PubSub from "effect/PubSub"; | |
| 23 | −import * as Queue from "effect/Queue"; | |
| 24 | −import * as Ref from "effect/Ref"; | |
| 25 | −import * as Stream from "effect/Stream"; | |
| 26 | −import { TestClock } from "effect/testing"; | |
| 27 | − | |
| 28 | −import { GitManager } from "../git/GitManager.ts"; | |
| 29 | −import { | |
| 30 | − PullRequestService, | |
| 31 | − type PullRequestMergeEvent, | |
| 32 | −} from "../pullRequest/PullRequestService.ts"; | |
| 33 | −import { ServerActivation } from "../serverActivation.ts"; | |
| 34 | −import { ServerSettingsService } from "../serverSettings.ts"; | |
| 35 | −import { OrchestrationCommandInvariantError } from "./Errors.ts"; | |
| 36 | −import { | |
| 37 | − OrchestrationEngineService, | |
| 38 | − type OrchestrationEngineShape, | |
| 39 | −} from "./Services/OrchestrationEngine.ts"; | |
| 40 | −import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; | |
| 41 | −import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; | |
| 42 | − | |
| 43 | −const NOW = "2026-08-28T12:00:00.000Z"; | |
| 44 | −const PROJECT_ID = ProjectId.make("settlement-project"); | |
| 45 | −const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); | |
| 46 | − | |
| 47 | −type AutoSettleCommand = Extract<OrchestrationCommand, { readonly type: "thread.auto-settle" }>; | |
| 48 | − | |
| 49 | −const testCrypto = Crypto.make({ | |
| 50 | − randomBytes: (size) => new Uint8Array(size).fill(1), | |
| 51 | − digest: (_algorithm, data) => Effect.succeed(data), | |
| 52 | −}); | |
| 53 | − | |
| 54 | −function makeProject( | |
| 55 | − id: ProjectId = PROJECT_ID, | |
| 56 | − workspaceRoot = "/workspace/project", | |
| 57 | −): OrchestrationProjectShell { | |
| 58 | − return { | |
| 59 | − id, | |
| 60 | − title: `Project ${id}`, | |
| 61 | − workspaceRoot, | |
| 62 | − defaultModelSelection: null, | |
| 63 | − scripts: [], | |
| 64 | − createdAt: "2026-08-01T00:00:00.000Z", | |
| 65 | − updatedAt: NOW, | |
| 66 | − }; | |
| 67 | −} | |
| 68 | − | |
| 69 | −function makeThread( | |
| 70 | − id: string, | |
| 71 | − overrides: Partial<OrchestrationThreadShell> = {}, | |
| 72 | −): OrchestrationThreadShell { | |
| 73 | − return { | |
| 74 | − id: ThreadId.make(id), | |
| 75 | − projectId: PROJECT_ID, | |
| 76 | − title: id, | |
| 77 | − modelSelection: { | |
| 78 | − instanceId: ProviderInstanceId.make("codex"), | |
| 79 | − model: "gpt-5", | |
| 80 | − }, | |
| 81 | − runtimeMode: "full-access", | |
| 82 | − interactionMode: "default", | |
| 83 | − branch: null, | |
| 84 | − worktreePath: null, | |
| 85 | − latestTurn: null, | |
| 86 | − createdAt: "2026-08-01T00:00:00.000Z", | |
| 87 | − updatedAt: "2026-08-20T00:00:00.000Z", | |
| 88 | − archivedAt: null, | |
| 89 | − settledOverride: null, | |
| 90 | − settledAt: null, | |
| 91 | − session: null, | |
| 92 | − latestUserMessageAt: "2026-08-20T00:00:00.000Z", | |
| 93 | − hasPendingApprovals: false, | |
| 94 | − hasPendingUserInput: false, | |
| 95 | − hasActionableProposedPlan: false, | |
| 96 | − ...overrides, | |
| 97 | − }; | |
| 98 | −} | |
| 99 | − | |
| 100 | −function makeSnapshot( | |
| 101 | − threads: ReadonlyArray<OrchestrationThreadShell>, | |
| 102 | − projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()], | |
| 103 | −): OrchestrationShellSnapshot { | |
| 104 | − return { | |
| 105 | − snapshotSequence: 1, | |
| 106 | − projects, | |
| 107 | − threads, | |
| 108 | − updatedAt: NOW, | |
| 109 | − }; | |
| 110 | −} | |
| 111 | − | |
| 112 | −function makePullRequestSummary(input: { | |
| 113 | − readonly projectId: ProjectId; | |
| 114 | − readonly repository: string; | |
| 115 | − readonly number: number; | |
| 116 | − readonly state: "open" | "closed" | "merged"; | |
| 117 | − readonly updatedAt?: string; | |
| 118 | −}): PullRequestSummary { | |
| 119 | − return { | |
| 120 | − provider: "github", | |
| 121 | − projectId: input.projectId, | |
| 122 | − repository: input.repository, | |
| 123 | − number: input.number, | |
| 124 | − title: "Pull request", | |
| 125 | − url: `https://example.test/${input.repository}/pull/${input.number}`, | |
| 126 | − state: input.state, | |
| 127 | − headBranch: "feature", | |
| 128 | − baseBranch: "main", | |
| 129 | − updatedAt: input.updatedAt ?? NOW, | |
| 130 | − }; | |
| 131 | −} | |
| 132 | − | |
| 133 | −interface HarnessOptions { | |
| 134 | − readonly snapshot: OrchestrationShellSnapshot; | |
| 135 | − readonly settings?: ServerSettings; | |
| 136 | − readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; | |
| 137 | − readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; | |
| 138 | − readonly existingWorktreePaths?: ReadonlyArray<string>; | |
| 139 | − readonly onDispatch?: ( | |
| 140 | − command: AutoSettleCommand, | |
| 141 | − ) => Effect.Effect<void, OrchestrationCommandInvariantError>; | |
| 142 | −} | |
| 143 | − | |
| 144 | −const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) { | |
| 145 | − const activation = yield* Deferred.make<void>(); | |
| 146 | − const snapshots = yield* Ref.make(options.snapshot); | |
| 147 | − const snapshotReadCount = yield* Ref.make(0); | |
| 148 | − const snapshotReads = yield* Queue.unbounded<number>(); | |
| 149 | − const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); | |
| 150 | − const settingsChanges = yield* PubSub.unbounded<ServerSettings>(); | |
| 151 | − const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>(); | |
| 152 | − const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]); | |
| 153 | − const branchCalls = yield* Ref.make< | |
| 154 | − ReadonlyArray<{ readonly cwd: string; readonly branch: string }> | |
| 155 | − >([]); | |
| 156 | − const summaryCalls = yield* Ref.make< | |
| 157 | − ReadonlyArray<{ | |
| 158 | − readonly projectId: ProjectId; | |
| 159 | − readonly repository: string; | |
| 160 | − readonly number: number; | |
| 161 | − }> | |
| 162 | − >([]); | |
| 163 | − const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]); | |
| 164 | − const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]); | |
| 165 | − | |
| 166 | − const updateSettings = (patch: ServerSettingsPatch) => | |
| 167 | − Effect.gen(function* () { | |
| 168 | − const next = applyServerSettingsPatch(yield* Ref.get(settings), patch); | |
| 169 | − yield* Ref.set(settings, next); | |
| 170 | − yield* PubSub.publish(settingsChanges, next); | |
| 171 | − return next; | |
| 172 | − }); | |
| 173 | − | |
| 174 | − const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => | |
| 175 | − Ref.update(branchCalls, (calls) => [...calls, input]).pipe( | |
| 176 | − Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), | |
| 177 | − ); | |
| 178 | − const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => | |
| 179 | − Effect.gen(function* () { | |
| 180 | − yield* Ref.update(summaryCalls, (calls) => [...calls, input]); | |
| 181 | − yield* Ref.update(summaryRecovery, (values) => [ | |
| 182 | − ...values, | |
| 183 | − readOptions?.recoverTransientFailure, | |
| 184 | − ]); | |
| 185 | − return yield* ( | |
| 186 | − options.pullRequestSummary?.(input, readOptions) ?? | |
| 187 | − Effect.succeed( | |
| 188 | − makePullRequestSummary({ | |
| 189 | − ...input, | |
| 190 | − state: "open", | |
| 191 | − }), | |
| 192 | − ) | |
| 193 | − ); | |
| 194 | − }); | |
| 195 | − | |
| 196 | − const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { | |
| 197 | − if (command.type !== "thread.auto-settle") { | |
| 198 | − return Effect.die(new Error(`Unexpected command: ${command.type}`)); | |
| 199 | − } | |
| 200 | − return Ref.update(commands, (recorded) => [...recorded, command]).pipe( | |
| 201 | − Effect.andThen(options.onDispatch?.(command) ?? Effect.void), | |
| 202 | − Effect.as({ sequence: 1 }), | |
| 203 | − ); | |
| 204 | − }; | |
| 205 | − | |
| 206 | − const serverSettings = ServerSettingsService.of({ | |
| 207 | − start: Effect.void, | |
| 208 | − ready: Effect.void, | |
| 209 | − getSettings: Ref.get(settings), | |
| 210 | − updateSettings, | |
| 211 | − streamChanges: Stream.fromPubSub(settingsChanges), | |
| 212 | − subscribeChanges: PubSub.subscribe(settingsChanges).pipe( | |
| 213 | − Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 214 | − ), | |
| 215 | − }); | |
| 216 | − | |
| 217 | − const dependencies = Layer.mergeAll( | |
| 218 | − Layer.mock(ProjectionSnapshotQuery)({ | |
| 219 | − getShellSnapshot: () => | |
| 220 | − Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( | |
| 221 | − Effect.tap((count) => Queue.offer(snapshotReads, count)), | |
| 222 | − Effect.andThen(Ref.get(snapshots)), | |
| 223 | − ), | |
| 224 | − }), | |
| 225 | − Layer.mock(GitManager)({ | |
| 226 | − branchPullRequest, | |
| 227 | − invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]), | |
| 228 | − }), | |
| 229 | − Layer.mock(PullRequestService)({ | |
| 230 | − summary: pullRequestSummary, | |
| 231 | − subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( | |
| 232 | − Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 233 | − ), | |
| 234 | − }), | |
| 235 | − Layer.mock(OrchestrationEngineService)({ | |
| 236 | − readEvents: () => Stream.empty, | |
| 237 | − dispatch, | |
| 238 | − streamDomainEvents: Stream.empty, | |
| 239 | − latestSequence: Effect.succeed(0), | |
| 240 | − }), | |
| 241 | − Layer.succeed(ServerSettingsService, serverSettings), | |
| 242 | − Layer.succeed(ServerActivation, Deferred.await(activation)), | |
| 243 | − Layer.succeed(Crypto.Crypto, testCrypto), | |
| 244 | − FileSystem.layerNoop({ | |
| 245 | − exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), | |
| 246 | − }), | |
| 247 | − ); | |
| 248 | − | |
| 249 | − return { | |
| 250 | − activation, | |
| 251 | − snapshots, | |
| 252 | − snapshotReadCount, | |
| 253 | − snapshotReads, | |
| 254 | − commands, | |
| 255 | − branchCalls, | |
| 256 | − summaryCalls, | |
| 257 | − summaryRecovery, | |
| 258 | − invalidatedCwds, | |
| 259 | − updateSettings, | |
| 260 | − publishMerge: PubSub.publish(mergedPullRequests, { | |
| 261 | − projectId: PROJECT_ID, | |
| 262 | − repository: "owner/repository", | |
| 263 | − number: 42, | |
| 264 | − mergedAt: NOW, | |
| 265 | − }), | |
| 266 | − layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), | |
| 267 | − }; | |
| 268 | −}); | |
| 269 | − | |
| 270 | −const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( | |
| 271 | − reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"], | |
| 272 | − activation: Deferred.Deferred<void>, | |
| 273 | − snapshotReads: Queue.Queue<number>, | |
| 274 | −) { | |
| 275 | − yield* reactor.start(); | |
| 276 | − yield* Deferred.succeed(activation, undefined); | |
| 277 | − yield* Queue.take(snapshotReads); | |
| 278 | − yield* reactor.drain; | |
| 279 | −}); | |
| 280 | − | |
| 281 | −describe("ThreadSettlementReactor", () => { | |
| 282 | − it.effect("starts without clients and skips protected threads before pull request lookup", () => | |
| 283 | − Effect.scoped( | |
| 284 | − Effect.gen(function* () { | |
| 285 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 286 | − const linkedPullRequest = { | |
| 287 | − projectId: LINKED_PROJECT_ID, | |
| 288 | − repository: "owner/repository", | |
| 289 | − number: 42, | |
| 290 | − url: "https://example.test/owner/repository/pull/42", | |
| 291 | − } as const; | |
| 292 | − const skipped = [ | |
| 293 | − makeThread("pending-approval", { | |
| 294 | − branch: "skip-approval", | |
| 295 | − hasPendingApprovals: true, | |
| 296 | − }), | |
| 297 | − makeThread("snoozed", { | |
| 298 | − branch: "skip-snoozed", | |
| 299 | − snoozedUntil: "2026-08-29T00:00:00.000Z", | |
| 300 | − }), | |
| 301 | − ]; | |
| 302 | − const fixture = yield* makeHarness({ | |
| 303 | − snapshot: makeSnapshot( | |
| 304 | − [ | |
| 305 | − makeThread("inactive", { branch: "inactive-feature" }), | |
| 306 | − makeThread("closed-pr", { linkedPullRequest }), | |
| 307 | − ...skipped, | |
| 308 | − ], | |
| 309 | − [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], | |
| 310 | − ), | |
| 311 | − branchPullRequest: () => Effect.succeed(null), | |
| 312 | − pullRequestSummary: (input) => | |
| 313 | − Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })), | |
| 314 | − }); | |
| 315 | − | |
| 316 | − yield* Effect.gen(function* () { | |
| 317 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 318 | − yield* reactor.start(); | |
| 319 | − assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0); | |
| 320 | − | |
| 321 | − yield* Deferred.succeed(fixture.activation, undefined); | |
| 322 | − yield* Queue.take(fixture.snapshotReads); | |
| 323 | − yield* reactor.drain; | |
| 324 | − | |
| 325 | − const commands = yield* Ref.get(fixture.commands); | |
| 326 | − assert.deepStrictEqual( | |
| 327 | − commands | |
| 328 | − .map(({ threadId, snapshotSequence, settledAt }) => ({ | |
| 329 | − threadId, | |
| 330 | − snapshotSequence, | |
| 331 | − settledAt, | |
| 332 | − })) | |
| 333 | − .sort((left, right) => left.threadId.localeCompare(right.threadId)), | |
| 334 | − [ | |
| 335 | − { | |
| 336 | − threadId: ThreadId.make("closed-pr"), | |
| 337 | − snapshotSequence: 1, | |
| 338 | − settledAt: "2026-08-20T00:00:00.000Z", | |
| 339 | − }, | |
| 340 | − { | |
| 341 | − threadId: ThreadId.make("inactive"), | |
| 342 | − snapshotSequence: 1, | |
| 343 | − settledAt: "2026-08-20T00:00:00.000Z", | |
| 344 | − }, | |
| 345 | − ], | |
| 346 | − ); | |
| 347 | − assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ | |
| 348 | − { cwd: "/workspace/project", branch: "inactive-feature" }, | |
| 349 | − ]); | |
| 350 | − assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ | |
| 351 | − { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, | |
| 352 | − ]); | |
| 353 | − assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]); | |
| 354 | − }).pipe(Effect.provide(fixture.layer)); | |
| 355 | − }), | |
| 356 | − ), | |
| 357 | − ); | |
| 358 | − | |
| 359 | − it.effect("reevaluates inactivity and pull request state once per minute", () => | |
| 360 | − Effect.scoped( | |
| 361 | − Effect.gen(function* () { | |
| 362 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 363 | − const pullRequest = yield* Ref.make<"open" | "merged">("open"); | |
| 364 | − const fixture = yield* makeHarness({ | |
| 365 | − snapshot: makeSnapshot([ | |
| 366 | − makeThread("at-boundary", { | |
| 367 | − latestUserMessageAt: "2026-08-25T12:00:00.000Z", | |
| 368 | − }), | |
| 369 | − makeThread("open-pr", { | |
| 370 | − branch: "saved-feature", | |
| 371 | − latestUserMessageAt: "2026-08-27T00:00:00.000Z", | |
| 372 | − }), | |
| 373 | − ]), | |
| 374 | − branchPullRequest: () => | |
| 375 | − Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), | |
| 376 | − }); | |
| 377 | − | |
| 378 | − yield* Effect.gen(function* () { | |
| 379 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 380 | − yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 381 | − assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); | |
| 382 | − | |
| 383 | − yield* Ref.set(pullRequest, "merged"); | |
| 384 | − yield* TestClock.adjust("1 minute"); | |
| 385 | − yield* Queue.take(fixture.snapshotReads); | |
| 386 | − yield* reactor.drain; | |
| 387 | − | |
| 388 | − assert.deepStrictEqual( | |
| 389 | − (yield* Ref.get(fixture.commands)) | |
| 390 | − .map((command) => command.threadId) | |
| 391 | − .sort((left, right) => left.localeCompare(right)), | |
| 392 | − [ThreadId.make("at-boundary"), ThreadId.make("open-pr")], | |
| 393 | − ); | |
| 394 | − assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2); | |
| 395 | − }).pipe(Effect.provide(fixture.layer)); | |
| 396 | − }), | |
| 397 | − ), | |
| 398 | − ); | |
| 399 | − | |
| 400 | − it.effect("reevaluates immediately after a pull request merge", () => | |
| 401 | − Effect.scoped( | |
| 402 | − Effect.gen(function* () { | |
| 403 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 404 | − const periodicLookupStarted = yield* Deferred.make<void>(); | |
| 405 | − const releasePeriodicLookup = yield* Deferred.make<void>(); | |
| 406 | − const mergedThreadSettled = yield* Deferred.make<void>(); | |
| 407 | − const branchLookupCount = yield* Ref.make(0); | |
| 408 | − const fixture = yield* makeHarness({ | |
| 409 | − snapshot: makeSnapshot([ | |
| 410 | − makeThread("merged-in-app", { | |
| 411 | − latestUserMessageAt: "2026-08-27T00:00:00.000Z", | |
| 412 | − linkedPullRequest: { | |
| 413 | − projectId: PROJECT_ID, | |
| 414 | − repository: "owner/repository", | |
| 415 | − number: 42, | |
| 416 | − url: "https://example.test/owner/repository/pull/42", | |
| 417 | − }, | |
| 418 | − }), | |
| 419 | − makeThread("slow-periodic-lookup", { | |
| 420 | − branch: "another-feature", | |
| 421 | − latestUserMessageAt: "2026-08-27T00:00:00.000Z", | |
| 422 | − }), | |
| 423 | − ]), | |
| 424 | − branchPullRequest: () => | |
| 425 | − Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( | |
| 426 | − Effect.flatMap((count) => | |
| 427 | − count === 1 | |
| 428 | − ? Effect.succeed({ state: "open" as const, updatedAt: NOW }) | |
| 429 | − : Deferred.succeed(periodicLookupStarted, undefined).pipe( | |
| 430 | − Effect.andThen(Deferred.await(releasePeriodicLookup)), | |
| 431 | − Effect.as({ state: "open" as const, updatedAt: NOW }), | |
| 432 | − ), | |
| 433 | − ), | |
| 434 | − ), | |
| 435 | − onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), | |
| 436 | − }); | |
| 437 | − | |
| 438 | − yield* Effect.gen(function* () { | |
| 439 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 440 | − yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 441 | − yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); | |
| 442 | − yield* Deferred.await(periodicLookupStarted); | |
| 443 | − | |
| 444 | − yield* fixture.publishMerge; | |
| 445 | − yield* Deferred.await(mergedThreadSettled); | |
| 446 | − | |
| 447 | − assert.deepStrictEqual( | |
| 448 | − (yield* Ref.get(fixture.commands)).map((command) => command.threadId), | |
| 449 | − [ThreadId.make("merged-in-app")], | |
| 450 | − ); | |
| 451 | − yield* Deferred.succeed(releasePeriodicLookup, undefined); | |
| 452 | − yield* reactor.drain; | |
| 453 | − }).pipe(Effect.provide(fixture.layer)); | |
| 454 | − }), | |
| 455 | − ), | |
| 456 | − ); | |
| 457 | − | |
| 458 | − it.effect( | |
| 459 | − "settles branch threads on a pull request merge without waiting for the next sweep", | |
| 460 | − () => | |
| 461 | − Effect.scoped( | |
| 462 | − Effect.gen(function* () { | |
| 463 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 464 | − const state = yield* Ref.make<"open" | "merged">("open"); | |
| 465 | − const mergedThreadSettled = yield* Deferred.make<void>(); | |
| 466 | − const fixture = yield* makeHarness({ | |
| 467 | − snapshot: makeSnapshot([ | |
| 468 | − makeThread("branch-thread", { | |
| 469 | − branch: "saved-feature", | |
| 470 | − latestUserMessageAt: "2026-08-27T00:00:00.000Z", | |
| 471 | − }), | |
| 472 | − ]), | |
| 473 | − branchPullRequest: () => | |
| 474 | − Ref.get(state).pipe( | |
| 475 | − Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), | |
| 476 | − ), | |
| 477 | − onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), | |
| 478 | − }); | |
| 479 | − | |
| 480 | − yield* Effect.gen(function* () { | |
| 481 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 482 | − yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 483 | − assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); | |
| 484 | − assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), []); | |
| 485 | − | |
| 486 | − yield* Ref.set(state, "merged"); | |
| 487 | − yield* fixture.publishMerge; | |
| 488 | − yield* Deferred.await(mergedThreadSettled); | |
| 489 | − | |
| 490 | − assert.deepStrictEqual( | |
| 491 | − (yield* Ref.get(fixture.commands)).map((command) => command.threadId), | |
| 492 | − [ThreadId.make("branch-thread")], | |
| 493 | − ); | |
| 494 | − assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), ["/workspace/project"]); | |
| 495 | − yield* reactor.drain; | |
| 496 | − }).pipe(Effect.provide(fixture.layer)); | |
| 497 | − }), | |
| 498 | − ), | |
| 499 | − ); | |
| 500 | − | |
| 501 | − it.effect("a merge does not settle threads linked to an unrelated pull request", () => | |
| 502 | − Effect.scoped( | |
| 503 | − Effect.gen(function* () { | |
| 504 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 505 | − const mergedThreadSettled = yield* Deferred.make<void>(); | |
| 506 | − const mergeLookupStarted = yield* Deferred.make<void>(); | |
| 507 | − const releaseMergeLookup = yield* Deferred.make<void>(); | |
| 508 | − const lookupCount = yield* Ref.make(0); | |
| 509 | − const fixture = yield* makeHarness({ | |
| 510 | − snapshot: makeSnapshot([ | |
| 511 | − makeThread("merged-in-app", { | |
| 512 | − latestUserMessageAt: "2026-08-27T00:00:00.000Z", | |
| 513 | − linkedPullRequest: { | |
| 514 | − projectId: PROJECT_ID, | |
| 515 | − repository: "owner/repository", | |
| 516 | − number: 42, | |
| 517 | − url: "https://example.test/owner/repository/pull/42", | |
| 518 | − }, | |
| 519 | − }), | |
| 520 | − makeThread("unrelated-linked", { | |
| 521 | − latestUserMessageAt: "2026-08-27T00:00:00.000Z", | |
| 522 | − linkedPullRequest: { | |
| 523 | − projectId: PROJECT_ID, | |
| 524 | − repository: "owner/repository", | |
| 525 | − number: 99, | |
| 526 | − url: "https://example.test/owner/repository/pull/99", | |
| 527 | − }, | |
| 528 | − }), | |
| 529 | − ]), | |
| 530 | − pullRequestSummary: (input) => | |
| 531 | − Ref.updateAndGet(lookupCount, (count) => count + 1).pipe( | |
| 532 | − // The initial sweep looks up both linked threads; the merge | |
| 533 | − // sweep only looks up the unrelated one, since the merged | |
| 534 | − // thread settles from the event itself. | |
| 535 | − Effect.tap((count) => | |
| 536 | − count === 3 ? Deferred.succeed(mergeLookupStarted, undefined) : Effect.void, | |
| 537 | − ), | |
| 538 | − Effect.tap((count) => | |
| 539 | − count === 3 ? Deferred.await(releaseMergeLookup) : Effect.void, | |
| 540 | − ), | |
| 541 | − Effect.map(() => makePullRequestSummary({ ...input, state: "open" })), | |
| 542 | − ), | |
| 543 | − onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), | |
| 544 | − }); | |
| 545 | − | |
| 546 | − yield* Effect.gen(function* () { | |
| 547 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 548 | − yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 549 | − assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); | |
| 550 | − | |
| 551 | − yield* fixture.publishMerge; | |
| 552 | − yield* Deferred.await(mergeLookupStarted); | |
| 553 | − yield* Deferred.await(mergedThreadSettled); | |
| 554 | − yield* Deferred.succeed(releaseMergeLookup, undefined); | |
| 555 | − yield* reactor.drain; | |
| 556 | − | |
| 557 | − assert.deepStrictEqual( | |
| 558 | − (yield* Ref.get(fixture.commands)).map((command) => command.threadId), | |
| 559 | − [ThreadId.make("merged-in-app")], | |
| 560 | − ); | |
| 561 | − assert.deepStrictEqual( | |
| 562 | − (yield* Ref.get(fixture.summaryCalls)) | |
| 563 | − .map((call) => call.number) | |
| 564 | − .toSorted((left, right) => left - right), | |
| 565 | − [42, 99, 99], | |
| 566 | − ); | |
| 567 | − }).pipe(Effect.provide(fixture.layer)); | |
| 568 | − }), | |
| 569 | − ), | |
| 570 | − ); | |
| 571 | − | |
| 572 | − it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => | |
| 573 | − Effect.scoped( | |
| 574 | − Effect.gen(function* () { | |
| 575 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 576 | − const state = yield* Ref.make<"merged" | "closed">("merged"); | |
| 577 | − const firstLookupStarted = yield* Deferred.make<void>(); | |
| 578 | − const releaseFirstLookup = yield* Deferred.make<void>(); | |
| 579 | − const laterLookupStarted = yield* Deferred.make<void>(); | |
| 580 | − const releaseLaterLookup = yield* Deferred.make<void>(); | |
| 581 | − const lookupCount = yield* Ref.make(0); | |
| 582 | − const fixture = yield* makeHarness({ | |
| 583 | − snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]), | |
| 584 | − settings: { | |
| 585 | − ...DEFAULT_SERVER_SETTINGS, | |
| 586 | − sidebarAutoSettleAfterDays: null, | |
| 587 | − sidebarAutoSettleOnMerge: true, | |
| 588 | − }, | |
| 589 | − branchPullRequest: () => | |
| 590 | − Ref.updateAndGet(lookupCount, (count) => count + 1).pipe( | |
| 591 | − Effect.tap((count) => | |
| 592 | − count === 1 | |
| 593 | − ? Deferred.succeed(firstLookupStarted, undefined) | |
| 594 | − : count === 3 | |
| 595 | − ? Deferred.succeed(laterLookupStarted, undefined) | |
| 596 | − : Effect.void, | |
| 597 | − ), | |
| 598 | − Effect.tap((count) => | |
| 599 | − count === 1 | |
| 600 | − ? Deferred.await(releaseFirstLookup) | |
| 601 | − : count === 3 | |
| 602 | − ? Deferred.await(releaseLaterLookup) | |
| 603 | − : Effect.void, | |
| 604 | − ), | |
| 605 | − Effect.andThen(Ref.get(state)), | |
| 606 | − Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), | |
| 607 | − ), | |
| 608 | − }); | |
| 609 | − | |
| 610 | − yield* Effect.gen(function* () { | |
| 611 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 612 | − yield* reactor.start(); | |
| 613 | − yield* Deferred.succeed(fixture.activation, undefined); | |
| 614 | − yield* Queue.take(fixture.snapshotReads); | |
| 615 | − yield* Deferred.await(firstLookupStarted); | |
| 616 | − | |
| 617 | − yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false }); | |
| 618 | − yield* Deferred.succeed(releaseFirstLookup, undefined); | |
| 619 | − yield* Queue.take(fixture.snapshotReads); | |
| 620 | − yield* reactor.drain; | |
| 621 | − assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); | |
| 622 | − assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2); | |
| 623 | − | |
| 624 | − yield* Ref.set(state, "closed"); | |
| 625 | − yield* fixture.updateSettings({ enableAgentBrowserAccess: false }); | |
| 626 | − yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 }); | |
| 627 | − yield* Deferred.await(laterLookupStarted); | |
| 628 | − yield* Deferred.succeed(releaseLaterLookup, undefined); | |
| 629 | − yield* reactor.drain; | |
| 630 | − | |
| 631 | − assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3); | |
| 632 | − assert.strictEqual(yield* Ref.get(lookupCount), 3); | |
| 633 | − assert.deepStrictEqual( | |
| 634 | − (yield* Ref.get(fixture.commands)).map((command) => command.threadId), | |
| 635 | − [ThreadId.make("settings-thread")], | |
| 636 | − ); | |
| 637 | − }).pipe(Effect.provide(fixture.layer)); | |
| 638 | − }), | |
| 639 | − ), | |
| 640 | − ); | |
| 641 | − | |
| 642 | − it.effect("keeps an unknown pull request active and continues with other candidates", () => | |
| 643 | − Effect.scoped( | |
| 644 | − Effect.gen(function* () { | |
| 645 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 646 | − const fixture = yield* makeHarness({ | |
| 647 | − snapshot: makeSnapshot( | |
| 648 | − [ | |
| 649 | − makeThread("lookup-failed", { | |
| 650 | − linkedPullRequest: { | |
| 651 | − projectId: LINKED_PROJECT_ID, | |
| 652 | − repository: "owner/repository", | |
| 653 | − number: 9, | |
| 654 | − url: "https://example.test/owner/repository/pull/9", | |
| 655 | − }, | |
| 656 | − }), | |
| 657 | − makeThread("inactive-without-pr"), | |
| 658 | − ], | |
| 659 | − [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], | |
| 660 | − ), | |
| 661 | − pullRequestSummary: () => | |
| 662 | − Effect.fail( | |
| 663 | − new PullRequestOperationError({ | |
| 664 | − operation: "summary", | |
| 665 | − detail: "host unavailable", | |
| 666 | − }), | |
| 667 | − ), | |
| 668 | − }); | |
| 669 | − | |
| 670 | − yield* Effect.gen(function* () { | |
| 671 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 672 | − yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 673 | − | |
| 674 | − assert.deepStrictEqual( | |
| 675 | − (yield* Ref.get(fixture.commands)).map((command) => command.threadId), | |
| 676 | − [ThreadId.make("inactive-without-pr")], | |
| 677 | − ); | |
| 678 | − assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); | |
| 679 | − }).pipe(Effect.provide(fixture.layer)); | |
| 680 | − }), | |
| 681 | − ), | |
| 682 | − ); | |
| 683 | − | |
| 684 | − it.effect("keeps threads active when their pull request project is unavailable", () => | |
| 685 | − Effect.scoped( | |
| 686 | − Effect.gen(function* () { | |
| 687 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 688 | − const linkedPullRequest = { | |
| 689 | − projectId: LINKED_PROJECT_ID, | |
| 690 | − repository: "owner/repository", | |
| 691 | − number: 10, | |
| 692 | − url: "https://example.test/owner/repository/pull/10", | |
| 693 | − } as const; | |
| 694 | − const fixture = yield* makeHarness({ | |
| 695 | − snapshot: makeSnapshot( | |
| 696 | − [ | |
| 697 | − makeThread("missing-own-project", { | |
| 698 | − latestUserMessageAt: "2026-08-27T00:00:00.000Z", | |
| 699 | − linkedPullRequest, | |
| 700 | − }), | |
| 701 | − makeThread("missing-branch-project", { branch: "saved-feature" }), | |
| 702 | − ], | |
| 703 | − [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], | |
| 704 | − ), | |
| 705 | − pullRequestSummary: (input) => | |
| 706 | − Effect.succeed(makePullRequestSummary({ ...input, state: "open" })), | |
| 707 | − }); | |
| 708 | − | |
| 709 | − yield* Effect.gen(function* () { | |
| 710 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 711 | − yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 712 | − | |
| 713 | − assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); | |
| 714 | − assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ | |
| 715 | − { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, | |
| 716 | − ]); | |
| 717 | − assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); | |
| 718 | − }).pipe(Effect.provide(fixture.layer)); | |
| 719 | − }), | |
| 720 | − ), | |
| 721 | − ); | |
| 722 | − | |
| 723 | − it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () => | |
| 724 | − Effect.scoped( | |
| 725 | − Effect.gen(function* () { | |
| 726 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 727 | − const linkedPullRequest = { | |
| 728 | − projectId: LINKED_PROJECT_ID, | |
| 729 | − repository: "owner/repository", | |
| 730 | − number: 77, | |
| 731 | − url: "https://example.test/owner/repository/pull/77", | |
| 732 | − } as const; | |
| 733 | − const fixture = yield* makeHarness({ | |
| 734 | − snapshot: makeSnapshot( | |
| 735 | − [ | |
| 736 | − makeThread("branch-one", { | |
| 737 | − branch: "saved-feature", | |
| 738 | − worktreePath: "/deleted/worktree-one", | |
| 739 | − }), | |
| 740 | − makeThread("branch-two", { | |
| 741 | − branch: "saved-feature", | |
| 742 | − worktreePath: "/deleted/worktree-two", | |
| 743 | − }), | |
| 744 | − makeThread("linked-one", { linkedPullRequest }), | |
| 745 | − makeThread("linked-two", { linkedPullRequest }), | |
| 746 | − ], | |
| 747 | − [ | |
| 748 | − makeProject(PROJECT_ID, "/workspace/project-root"), | |
| 749 | − makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), | |
| 750 | − ], | |
| 751 | − ), | |
| 752 | − branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), | |
| 753 | − pullRequestSummary: (input) => | |
| 754 | − Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), | |
| 755 | − }); | |
| 756 | − | |
| 757 | − yield* Effect.gen(function* () { | |
| 758 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 759 | − yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 760 | − | |
| 761 | − assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ | |
| 762 | − { cwd: "/workspace/project-root", branch: "saved-feature" }, | |
| 763 | − ]); | |
| 764 | − assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ | |
| 765 | − { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, | |
| 766 | − ]); | |
| 767 | − assert.deepStrictEqual( | |
| 768 | − new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), | |
| 769 | − new Set([ | |
| 770 | − ThreadId.make("branch-one"), | |
| 771 | − ThreadId.make("branch-two"), | |
| 772 | − ThreadId.make("linked-one"), | |
| 773 | − ThreadId.make("linked-two"), | |
| 774 | − ]), | |
| 775 | − ); | |
| 776 | − }).pipe(Effect.provide(fixture.layer)); | |
| 777 | − }), | |
| 778 | − ), | |
| 779 | − ); | |
| 780 | − | |
| 781 | − it.effect("looks up the branch pull request from a thread's live worktree", () => | |
| 782 | − Effect.scoped( | |
| 783 | − Effect.gen(function* () { | |
| 784 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 785 | − const fixture = yield* makeHarness({ | |
| 786 | − snapshot: makeSnapshot( | |
| 787 | − [ | |
| 788 | − makeThread("live-worktree", { | |
| 789 | − branch: "feature/live", | |
| 790 | − worktreePath: "/workspace/project-root/.worktrees/live", | |
| 791 | − }), | |
| 792 | − makeThread("deleted-worktree", { | |
| 793 | − branch: "feature/deleted", | |
| 794 | − worktreePath: "/workspace/project-root/.worktrees/deleted", | |
| 795 | − }), | |
| 796 | − ], | |
| 797 | − [makeProject(PROJECT_ID, "/workspace/project-root")], | |
| 798 | − ), | |
| 799 | − existingWorktreePaths: ["/workspace/project-root/.worktrees/live"], | |
| 800 | − }); | |
| 801 | − | |
| 802 | − yield* Effect.gen(function* () { | |
| 803 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 804 | − yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 805 | − | |
| 806 | − assert.deepStrictEqual( | |
| 807 | − new Set(yield* Ref.get(fixture.branchCalls)), | |
| 808 | − new Set([ | |
| 809 | − { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" }, | |
| 810 | − { cwd: "/workspace/project-root", branch: "feature/deleted" }, | |
| 811 | − ]), | |
| 812 | − ); | |
| 813 | − }).pipe(Effect.provide(fixture.layer)); | |
| 814 | − }), | |
| 815 | − ), | |
| 816 | − ); | |
| 817 | − | |
| 818 | − it.effect("carries the snapshot guard and survives a stale dispatch rejection", () => | |
| 819 | − Effect.scoped( | |
| 820 | − Effect.gen(function* () { | |
| 821 | − yield* TestClock.setTime(Date.parse(NOW)); | |
| 822 | − const fixture = yield* makeHarness({ | |
| 823 | − snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]), | |
| 824 | − onDispatch: (command) => | |
| 825 | − command.threadId === ThreadId.make("stale") | |
| 826 | − ? Effect.fail( | |
| 827 | − new OrchestrationCommandInvariantError({ | |
| 828 | − commandType: command.type, | |
| 829 | − detail: "thread changed after settlement evaluation", | |
| 830 | − }), | |
| 831 | − ) | |
| 832 | − : Effect.void, | |
| 833 | − }); | |
| 834 | − | |
| 835 | − yield* Effect.gen(function* () { | |
| 836 | − const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 837 | − yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 838 | − | |
| 839 | − const firstSweep = yield* Ref.get(fixture.commands); | |
| 840 | − assert.strictEqual( | |
| 841 | − firstSweep.find((command) => command.threadId === ThreadId.make("stale")) | |
| 842 | − ?.snapshotSequence, | |
| 843 | − 1, | |
| 844 | − ); | |
| 845 | − assert.strictEqual( | |
| 846 | − firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")), | |
| 847 | − true, | |
| 848 | − ); | |
| 849 | − | |
| 850 | − yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); | |
| 851 | − yield* Queue.take(fixture.snapshotReads); | |
| 852 | − yield* reactor.drain; | |
| 853 | − assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4); | |
| 854 | − }).pipe(Effect.provide(fixture.layer)); | |
| 855 | − }), | |
| 856 | − ), | |
| 857 | − ); | |
| 858 | −}); | |
| @@ -1,249 +0,0 @@ | ||
| 1 | −import { CommandId } from "@t3tools/contracts"; | |
| 2 | −import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | |
| 3 | −import * as Cause from "effect/Cause"; | |
| 4 | −import * as Context from "effect/Context"; | |
| 5 | −import * as Crypto from "effect/Crypto"; | |
| 6 | −import * as DateTime from "effect/DateTime"; | |
| 7 | −import * as Effect from "effect/Effect"; | |
| 8 | −import * as FileSystem from "effect/FileSystem"; | |
| 9 | −import * as Layer from "effect/Layer"; | |
| 10 | −import * as Schedule from "effect/Schedule"; | |
| 11 | −import type * as Scope from "effect/Scope"; | |
| 12 | −import * as Stream from "effect/Stream"; | |
| 13 | − | |
| 14 | −import * as GitManager from "../git/GitManager.ts"; | |
| 15 | −import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | |
| 16 | −import * as ServerSettings from "../serverSettings.ts"; | |
| 17 | −import { forkParked } from "../serverActivation.ts"; | |
| 18 | −import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; | |
| 19 | −import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; | |
| 20 | −import { | |
| 21 | − isAutoSettlementCandidate, | |
| 22 | − resolveAutoSettlementAt, | |
| 23 | − type SettlementPullRequest, | |
| 24 | −} from "./ThreadSettlementPolicy.ts"; | |
| 25 | − | |
| 26 | −export class ThreadSettlementReactor extends Context.Service< | |
| 27 | − ThreadSettlementReactor, | |
| 28 | − { | |
| 29 | − readonly start: () => Effect.Effect<void, never, Scope.Scope>; | |
| 30 | − readonly drain: Effect.Effect<void>; | |
| 31 | − } | |
| 32 | −>()("t3/orchestration/ThreadSettlementReactor") {} | |
| 33 | − | |
| 34 | −export const make = Effect.gen(function* () { | |
| 35 | − const engine = yield* OrchestrationEngine.OrchestrationEngineService; | |
| 36 | − const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | |
| 37 | − const settingsService = yield* ServerSettings.ServerSettingsService; | |
| 38 | − const git = yield* GitManager.GitManager; | |
| 39 | − const pullRequests = yield* PullRequestService.PullRequestService; | |
| 40 | − const crypto = yield* Crypto.Crypto; | |
| 41 | − const fileSystem = yield* FileSystem.FileSystem; | |
| 42 | − | |
| 43 | − const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* ( | |
| 44 | − mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, | |
| 45 | − ) { | |
| 46 | − const snapshot = yield* snapshots.getShellSnapshot(); | |
| 47 | − const now = DateTime.formatIso(yield* DateTime.now); | |
| 48 | − const projects = new Map(snapshot.projects.map((project) => [project.id, project])); | |
| 49 | − // A merge event re-sweeps every candidate, not just the threads linked to | |
| 50 | − // the merged pull request: most threads carry no link and settle from | |
| 51 | − // their branch lookup, which would otherwise wait for the next minute's | |
| 52 | − // sweep on a possibly stale cached answer. | |
| 53 | − const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); | |
| 54 | − // Use the same cwd as the sidebar so both paths share GitManager's PR cache. | |
| 55 | − const lookupCwdByThreadId = new Map<string, string>(); | |
| 56 | − yield* Effect.forEach( | |
| 57 | − candidates, | |
| 58 | − (thread) => | |
| 59 | − Effect.gen(function* () { | |
| 60 | − const project = projects.get(thread.projectId); | |
| 61 | − if (project === undefined || thread.linkedPullRequest != null) return; | |
| 62 | − const worktreeExists = | |
| 63 | − thread.worktreePath !== null && | |
| 64 | − (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); | |
| 65 | − lookupCwdByThreadId.set( | |
| 66 | − thread.id, | |
| 67 | − worktreeExists && thread.worktreePath !== null | |
| 68 | − ? thread.worktreePath | |
| 69 | − : project.workspaceRoot, | |
| 70 | − ); | |
| 71 | − }), | |
| 72 | − { concurrency: 8, discard: true }, | |
| 73 | − ); | |
| 74 | − if (mergedPullRequest !== null) { | |
| 75 | − // The merge just confirmed a terminal state the lookup caches can still | |
| 76 | − // call open (branch answers live two minutes, the sweep runs every | |
| 77 | − // minute). Drop the swept checkouts' cached answers so the merge settles | |
| 78 | − // its branch threads now instead of on a later sweep. Threads linked to | |
| 79 | − // the merged pull request settle from the event itself below and need no | |
| 80 | − // lookup, so they are absent from this map by construction. | |
| 81 | − const cwds = [...new Set(lookupCwdByThreadId.values())]; | |
| 82 | − yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { | |
| 83 | − concurrency: 8, | |
| 84 | − discard: true, | |
| 85 | − }); | |
| 86 | − } | |
| 87 | − const lookupKey = (thread: (typeof candidates)[number]) => { | |
| 88 | − if (thread.linkedPullRequest != null) { | |
| 89 | − return JSON.stringify([ | |
| 90 | − "linked", | |
| 91 | − thread.linkedPullRequest.projectId, | |
| 92 | − thread.linkedPullRequest.repository, | |
| 93 | − thread.linkedPullRequest.number, | |
| 94 | − ]); | |
| 95 | − } | |
| 96 | − if (thread.branch === null) return JSON.stringify(["none", thread.id]); | |
| 97 | − const cwd = lookupCwdByThreadId.get(thread.id); | |
| 98 | − return JSON.stringify( | |
| 99 | − cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch], | |
| 100 | − ); | |
| 101 | − }; | |
| 102 | − const groups = Map.groupBy(candidates, lookupKey); | |
| 103 | − | |
| 104 | − const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( | |
| 105 | − thread: (typeof candidates)[number], | |
| 106 | − ) { | |
| 107 | − if (thread.linkedPullRequest != null) { | |
| 108 | − // The event carries the merged state, so only the threads linked to | |
| 109 | − // that exact pull request settle from it. Every other linked thread | |
| 110 | − // falls through to a fresh summary lookup below: the merge sweep | |
| 111 | − // covers all candidates, and an unrelated merge must never settle | |
| 112 | − // them. | |
| 113 | − if ( | |
| 114 | − mergedPullRequest !== null && | |
| 115 | − thread.linkedPullRequest.projectId === mergedPullRequest.projectId && | |
| 116 | − thread.linkedPullRequest.repository.toLowerCase() === | |
| 117 | − mergedPullRequest.repository.toLowerCase() && | |
| 118 | − thread.linkedPullRequest.number === mergedPullRequest.number | |
| 119 | − ) { | |
| 120 | − return { | |
| 121 | − state: "merged", | |
| 122 | − updatedAt: mergedPullRequest.mergedAt, | |
| 123 | − } satisfies SettlementPullRequest; | |
| 124 | − } | |
| 125 | − if (!projects.has(thread.linkedPullRequest.projectId)) { | |
| 126 | − return yield* Effect.die(new Error("linked pull request project not found")); | |
| 127 | − } | |
| 128 | − const summary = yield* pullRequests.summary( | |
| 129 | − { | |
| 130 | − projectId: thread.linkedPullRequest.projectId, | |
| 131 | − repository: thread.linkedPullRequest.repository, | |
| 132 | − number: thread.linkedPullRequest.number, | |
| 133 | − }, | |
| 134 | − { recoverTransientFailure: false }, | |
| 135 | − ); | |
| 136 | − return { | |
| 137 | − state: summary.state, | |
| 138 | − updatedAt: summary.updatedAt, | |
| 139 | − } satisfies SettlementPullRequest; | |
| 140 | − } | |
| 141 | − if (thread.branch === null) return null; | |
| 142 | − const cwd = lookupCwdByThreadId.get(thread.id); | |
| 143 | − if (cwd === undefined) { | |
| 144 | − return yield* Effect.die(new Error("thread project not found")); | |
| 145 | − } | |
| 146 | − return yield* git.branchPullRequest({ cwd, branch: thread.branch }); | |
| 147 | − }); | |
| 148 | − | |
| 149 | − yield* Effect.forEach( | |
| 150 | − groups.values(), | |
| 151 | − (group) => | |
| 152 | − Effect.gen(function* () { | |
| 153 | − const pullRequest = yield* pullRequestFor(group[0]!); | |
| 154 | − yield* Effect.forEach( | |
| 155 | − group, | |
| 156 | − (thread) => | |
| 157 | − Effect.gen(function* () { | |
| 158 | − const settings = yield* settingsService.getSettings; | |
| 159 | − const decisionNow = DateTime.formatIso(yield* DateTime.now); | |
| 160 | − const settledAt = resolveAutoSettlementAt({ | |
| 161 | − thread, | |
| 162 | − pullRequest, | |
| 163 | − now: decisionNow, | |
| 164 | − autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, | |
| 165 | − autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, | |
| 166 | − }); | |
| 167 | − if (settledAt === null) { | |
| 168 | − return; | |
| 169 | − } | |
| 170 | − const uuid = yield* crypto.randomUUIDv4; | |
| 171 | − yield* engine.dispatch({ | |
| 172 | − type: "thread.auto-settle", | |
| 173 | − commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), | |
| 174 | − threadId: thread.id, | |
| 175 | − snapshotSequence: snapshot.snapshotSequence, | |
| 176 | − settledAt, | |
| 177 | − }); | |
| 178 | − }).pipe( | |
| 179 | − Effect.catchCause((cause) => | |
| 180 | − Cause.hasInterruptsOnly(cause) | |
| 181 | − ? Effect.failCause(cause) | |
| 182 | − : Effect.logWarning("automatic thread settlement skipped", { | |
| 183 | − threadId: thread.id, | |
| 184 | − cause: Cause.pretty(cause), | |
| 185 | − }), | |
| 186 | − ), | |
| 187 | − ), | |
| 188 | − { discard: true }, | |
| 189 | − ); | |
| 190 | − }).pipe( | |
| 191 | − Effect.catchCause((cause) => | |
| 192 | − Cause.hasInterruptsOnly(cause) | |
| 193 | − ? Effect.failCause(cause) | |
| 194 | − : Effect.logWarning("automatic thread settlement skipped", { | |
| 195 | − threadIds: group.map((thread) => thread.id), | |
| 196 | − cause: Cause.pretty(cause), | |
| 197 | − }), | |
| 198 | − ), | |
| 199 | − ), | |
| 200 | − { concurrency: 8, discard: true }, | |
| 201 | − ); | |
| 202 | − }); | |
| 203 | − | |
| 204 | − const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) => | |
| 205 | − sweep(mergedPullRequest).pipe( | |
| 206 | − Effect.catchCause((cause) => | |
| 207 | − Cause.hasInterruptsOnly(cause) | |
| 208 | − ? Effect.failCause(cause) | |
| 209 | − : Effect.logWarning("automatic thread settlement sweep failed", { | |
| 210 | − cause: Cause.pretty(cause), | |
| 211 | − }), | |
| 212 | − ), | |
| 213 | − ); | |
| 214 | − const worker = yield* makeDrainableWorker(() => runSweep(null)); | |
| 215 | − | |
| 216 | − const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( | |
| 217 | − "ThreadSettlementReactor.start", | |
| 218 | − )(function* () { | |
| 219 | − const settingsChanges = yield* settingsService.subscribeChanges; | |
| 220 | − const mergedPullRequests = yield* pullRequests.subscribeMerges; | |
| 221 | − const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); | |
| 222 | − let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; | |
| 223 | − let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; | |
| 224 | − yield* forkParked( | |
| 225 | − Effect.gen(function* () { | |
| 226 | − yield* worker.enqueue(undefined); | |
| 227 | − yield* worker.drain; | |
| 228 | − }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), | |
| 229 | − ); | |
| 230 | − yield* forkParked( | |
| 231 | − Stream.runForEach(settingsChanges, (settings) => { | |
| 232 | − if ( | |
| 233 | − settings.sidebarAutoSettleAfterDays === lastAfterDays && | |
| 234 | − settings.sidebarAutoSettleOnMerge === lastOnMerge | |
| 235 | − ) { | |
| 236 | − return Effect.void; | |
| 237 | − } | |
| 238 | − lastAfterDays = settings.sidebarAutoSettleAfterDays; | |
| 239 | − lastOnMerge = settings.sidebarAutoSettleOnMerge; | |
| 240 | − return worker.enqueue(undefined); | |
| 241 | − }), | |
| 242 | − ); | |
| 243 | − yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep)); | |
| 244 | − }); | |
| 245 | − | |
| 246 | − return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; | |
| 247 | −}); | |
| 248 | − | |
| 249 | −export const layer = Layer.effect(ThreadSettlementReactor, make); | |
| @@ -3,6 +3,7 @@ import { · 2 unmodified lines | ||
| 3 | 3 | type OrchestrationCommand, |
| 4 | 4 | type OrchestrationEvent, |
| 5 | 5 | type OrchestrationReadModel, |
| 6 | + type OrchestrationThread, | |
| 6 | 7 | } from "@t3tools/contracts/legacy-orchestration"; |
| 7 | 8 | import * as DateTime from "effect/DateTime"; |
| 8 | 9 | import * as Crypto from "effect/Crypto"; |
| @@ -647,16 +647,17 @@ const makeEventStore = Effect.gen(function* () { · 646 unmodified lines | ||
| 647 | 647 | const findEventAfter = SqlSchema.findOneOption({ |
| 648 | 648 | Request: HasEventAfterRequestSchema, |
| 649 | 649 | Result: Schema.Struct({ sequence: Schema.Number }), |
| 650 | − execute: (request) => | |
| 651 | − sql` | |
| 652 | − SELECT sequence | |
| 653 | − FROM orchestration_events | |
| 654 | − WHERE aggregate_kind = ${request.aggregateKind} | |
| 655 | − AND stream_id = ${request.aggregateId} | |
| 656 | − AND event_type = ${request.type} | |
| 657 | − AND sequence > ${request.sequenceExclusive} | |
| 658 | − LIMIT 1 | |
| 659 | − `, | |
| 650 | + execute: (request) => sql` | |
| 651 | + SELECT sequence | |
| 652 | + FROM orchestration_events | |
| 653 | + WHERE aggregate_kind = ${request.aggregateKind} | |
| 654 | + AND stream_id = ${request.aggregateId} | |
| 655 | + AND ${sql.and([ | |
| 656 | + sql`sequence > ${request.sequenceExclusive}`, | |
| 657 | + ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]), | |
| 658 | + ])} | |
| 659 | + LIMIT 1 | |
| 660 | + `, | |
| 660 | 661 | }); |
| 661 | 662 | |
| 662 | 663 | const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => |
| @@ -123,7 +123,8 @@ export interface OrchestrationEventStoreShape { · 122 unmodified lines | ||
| 123 | 123 | readonly afterSequence?: number; |
| 124 | 124 | }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>; |
| 125 | 125 | /** |
| 126 | − * Check whether an aggregate has an event of the given type after a sequence. | |
| 126 | + * Check whether an aggregate has an event after a sequence, optionally | |
| 127 | + * restricted to one event type. | |
| 127 | 128 | * |
| 128 | 129 | * Used during replay to tell whether a later event supersedes the one being |
| 129 | 130 | * applied, without streaming the rest of the log. |
| @@ -131,7 +132,7 @@ export interface OrchestrationEventStoreShape { · 1 unmodified line | ||
| 131 | 132 | readonly hasEventAfter: (input: { |
| 132 | 133 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; |
| 133 | 134 | readonly aggregateId: string; |
| 134 | − readonly type: OrchestrationEvent["type"]; | |
| 135 | + readonly type?: OrchestrationEvent["type"]; | |
| 135 | 136 | readonly sequenceExclusive: number; |
| 136 | 137 | }) => Effect.Effect<boolean, OrchestrationEventStoreError>; |
| 137 | 138 | } |
| @@ -40,6 +40,7 @@ import { · 39 unmodified lines | ||
| 40 | 40 | } from "../Layers/ClaudeProvider.ts"; |
| 41 | 41 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 42 | 42 | import * as ModelManifest from "../ModelManifest.ts"; |
| 43 | +import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; | |
| 43 | 44 | import { |
| 44 | 45 | defaultProviderContinuationIdentity, |
| 45 | 46 | type ProviderDriver, |
| @@ -114,6 +115,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 68 unmodified lines | ||
| 114 | 115 | const serverSettings = yield* ServerSettingsService; |
| 115 | 116 | const eventLoggers = yield* ProviderEventLoggers; |
| 116 | 117 | const modelManifest = yield* ModelManifest.ModelManifest; |
| 118 | + const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); | |
| 117 | 119 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 118 | 120 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ |
| 119 | 121 | driverKind: DRIVER_KIND, |
| @@ -157,7 +159,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 37 unmodified lines | ||
| 157 | 159 | }), |
| 158 | 160 | ), |
| 159 | 161 | ); |
| 160 | − const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); | |
| 162 | + const textGeneration = yield* makeClaudeTextGeneration( | |
| 163 | + effectiveConfig, | |
| 164 | + processEnv, | |
| 165 | + modelCatalog, | |
| 166 | + ); | |
| 161 | 167 | |
| 162 | 168 | // Per-instance capabilities cache: keyed on binary + resolved HOME so |
| 163 | 169 | // account-specific probes never share auth metadata across instances. |
| @@ -171,22 +177,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 7 unmodified lines | ||
| 171 | 177 | }); |
| 172 | 178 | const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); |
| 173 | 179 | |
| 174 | − // Kick the TTL-gated manifest refresh in the background and classify | |
| 175 | − // with the in-memory manifest, so a slow or hung fetch never delays the | |
| 176 | − // provider check. A refresh that lands mid-probe applies on the next one. | |
| 180 | + // Start the TTL-gated refresh without delaying provider readiness. The | |
| 181 | + // next check observes a remote manifest after the background fetch lands. | |
| 177 | 182 | const checkProvider = modelManifest.refreshInBackground.pipe( |
| 178 | 183 | Effect.andThen( |
| 179 | − Effect.zipWith( | |
| 180 | − checkClaudeProviderStatus( | |
| 181 | − effectiveConfig, | |
| 182 | − () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), | |
| 183 | − processEnv, | |
| 184 | − cwd, | |
| 184 | + modelManifest.current.pipe( | |
| 185 | + Effect.flatMap((manifest) => | |
| 186 | + checkClaudeProviderStatus( | |
| 187 | + effectiveConfig, | |
| 188 | + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), | |
| 189 | + processEnv, | |
| 190 | + cwd, | |
| 191 | + resolveClaudeModelCatalog(manifest), | |
| 192 | + ), | |
| 185 | 193 | ), |
| 186 | − modelManifest.current, | |
| 187 | − (draft, manifest) => | |
| 188 | − stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | |
| 189 | − { concurrent: true }, | |
| 194 | + Effect.map(stampIdentity), | |
| 190 | 195 | ), |
| 191 | 196 | ), |
| 192 | 197 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| @@ -201,11 +206,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 8 unmodified lines | ||
| 201 | 206 | streamSettings: snapshotSettings.streamSettings, |
| 202 | 207 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| 203 | 208 | initialSnapshot: (settings) => |
| 204 | − Effect.zipWith( | |
| 205 | − makePendingClaudeProvider(settings.provider), | |
| 206 | − modelManifest.current, | |
| 207 | − (draft, manifest) => | |
| 208 | − stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | |
| 209 | + modelManifest.current.pipe( | |
| 210 | + Effect.flatMap((manifest) => | |
| 211 | + makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)), | |
| 212 | + ), | |
| 213 | + Effect.map(stampIdentity), | |
| 209 | 214 | ), |
| 210 | 215 | checkProvider, |
| 211 | 216 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; |
| 2 | −import { causeErrorTag } from "@t3tools/shared/observability"; | |
| 2 | +import { causeErrorTag, errorTag } from "@t3tools/shared/observability"; | |
| 3 | 3 | import * as Cause from "effect/Cause"; |
| 4 | 4 | import * as Crypto from "effect/Crypto"; |
| 5 | 5 | import * as DateTime from "effect/DateTime"; |
| @@ -13,6 +13,40 @@ import { · 7 unmodified lines | ||
| 13 | 13 | } from "../NativeProtocolLogging.ts"; |
| 14 | 14 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 15 | 15 | |
| 16 | +const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); | |
| 17 | + | |
| 18 | +function structuralMethod(value: string): string { | |
| 19 | + return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; | |
| 20 | +} | |
| 21 | + | |
| 22 | +function summarizePayload(payload: unknown): Readonly<Record<string, unknown>> { | |
| 23 | + if (payload === null) return { valueType: "null" }; | |
| 24 | + if (typeof payload === "string") { | |
| 25 | + return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; | |
| 26 | + } | |
| 27 | + if (payload instanceof Uint8Array) { | |
| 28 | + return { valueType: "bytes", byteLength: payload.byteLength }; | |
| 29 | + } | |
| 30 | + if (Array.isArray(payload)) { | |
| 31 | + return { valueType: "array", itemCount: payload.length }; | |
| 32 | + } | |
| 33 | + if (typeof payload !== "object") { | |
| 34 | + return { valueType: typeof payload }; | |
| 35 | + } | |
| 36 | + | |
| 37 | + try { | |
| 38 | + const record = payload as Record<string, unknown>; | |
| 39 | + return { | |
| 40 | + valueType: "object", | |
| 41 | + fieldCount: Object.keys(record).length, | |
| 42 | + ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}), | |
| 43 | + ...(typeof record.tag === "string" ? { method: structuralMethod(record.tag) } : {}), | |
| 44 | + }; | |
| 45 | + } catch { | |
| 46 | + return { valueType: "object" }; | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 16 | 50 | function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { |
| 17 | 51 | return { |
| 18 | 52 | method: structuralProtocolMethod(event.method), |
| @@ -137,11 +171,15 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" · 118 unmodified lines | ||
| 137 | 171 | protocolLogging: { |
| 138 | 172 | logIncoming: true, |
| 139 | 173 | logOutgoing: true, |
| 140 | − logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => | |
| 141 | − writeNativeAcpLog({ | |
| 142 | − kind: "protocol", | |
| 143 | − payload: formatAcpProtocolLogPayload(event), | |
| 144 | − }), | |
| 174 | + logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { | |
| 175 | + const filtered = filterTransientProtocolLog(event); | |
| 176 | + return filtered | |
| 177 | + ? writeNativeAcpLog({ | |
| 178 | + kind: "protocol", | |
| 179 | + payload: formatAcpProtocolLogPayload(filtered), | |
| 180 | + }) | |
| 181 | + : Effect.void; | |
| 182 | + }, | |
| 145 | 183 | } satisfies NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"]>, |
| 146 | 184 | } |
| 147 | 185 | : {}), |
| @@ -5,12 +5,11 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; · 4 unmodified lines | ||
| 5 | 5 | const testState = vi.hoisted(() => ({ |
| 6 | 6 | resources: [] as Array<unknown>, |
| 7 | 7 | assetState: "success" as "success" | "loading" | "failure", |
| 8 | − editorEnvironmentIds: [] as Array<unknown>, | |
| 9 | − remoteEnvironmentIds: [] as Array<unknown>, | |
| 10 | 8 | })); |
| 11 | 9 | |
| 12 | 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 13 | 11 | vi.mock("../assets/assetUrls", () => ({ |
| 12 | + useAssetUrlRefresh: () => vi.fn(), | |
| 14 | 13 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { |
| 15 | 14 | testState.resources.push(resource); |
| 16 | 15 | if (testState.assetState === "loading") return { _tag: "Loading" }; |
| @@ -25,23 +24,23 @@ vi.mock("../state/session", async (importOriginal) => ({ · 8 unmodified lines | ||
| 25 | 24 | ...(await importOriginal<typeof import("../state/session")>()), |
| 26 | 25 | usePreparedConnection: () => ({ _tag: "Loading" }), |
| 27 | 26 | })); |
| 28 | −vi.mock("../editorPreferences", () => ({ | |
| 29 | − useOpenInPreferredEditor: (environmentId: unknown) => { | |
| 30 | − testState.editorEnvironmentIds.push(environmentId); | |
| 31 | − return vi.fn(); | |
| 32 | − }, | |
| 33 | − usePreferredEditor: () => ["vscode", vi.fn()], | |
| 27 | +vi.mock("../state/entities", () => ({ | |
| 28 | + readThreadShell: () => null, | |
| 29 | + useProjects: () => [], | |
| 34 | 30 | })); |
| 35 | 31 | vi.mock("../remoteOpen", () => ({ |
| 36 | − useRemoteOpenResolution: (environmentId: unknown) => { | |
| 37 | − testState.remoteEnvironmentIds.push(environmentId); | |
| 38 | − return { | |
| 39 | − isResolved: true, | |
| 40 | − state: { mode: "local-exec", localExecutionUnavailableReason: null }, | |
| 41 | − }; | |
| 42 | − }, | |
| 32 | + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), | |
| 33 | +})); | |
| 34 | +vi.mock("../editorPreferences", () => ({ | |
| 35 | + useOpenInPreferredEditor: () => vi.fn(), | |
| 36 | + usePreferredEditor: () => [null, vi.fn()], | |
| 37 | +})); | |
| 38 | +vi.mock("~/lib/openPullRequestLink", () => ({ | |
| 39 | + findProjectForChangeRequest: () => undefined, | |
| 40 | + matchesLinkedPullRequestUrl: () => false, | |
| 41 | + parseChangeRequestUrl: () => null, | |
| 42 | + useOpenChangeRequestLink: () => vi.fn(), | |
| 43 | 43 | })); |
| 44 | −vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() })); | |
| 45 | 44 | |
| 46 | 45 | import ChatMarkdown from "./ChatMarkdown"; |
| 47 | 46 | import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; |
| @@ -93,8 +92,6 @@ describe("ChatMarkdown workspace images", () => { · 45 unmodified lines | ||
| 93 | 92 | beforeEach(() => { |
| 94 | 93 | testState.resources = []; |
| 95 | 94 | testState.assetState = "success"; |
| 96 | − testState.editorEnvironmentIds = []; | |
| 97 | − testState.remoteEnvironmentIds = []; | |
| 98 | 95 | }); |
| 99 | 96 | |
| 100 | 97 | it.each([ |
| @@ -110,52 +107,55 @@ describe("ChatMarkdown workspace images", () => { · 9 unmodified lines | ||
| 110 | 107 | |
| 111 | 108 | expect(testState.resources).toEqual([ |
| 112 | 109 | { |
| 113 | − _tag: "workspace-file", | |
| 110 | + _tag: "media-file", | |
| 114 | 111 | threadId: threadRef.threadId, |
| 115 | 112 | path: expectedPath, |
| 116 | 113 | }, |
| 117 | 114 | ]); |
| 118 | 115 | }); |
| 119 | 116 | |
| 120 | − it("loads relative workspace paths through signed asset URLs", () => { | |
| 121 | − const html = render(""); | |
| 117 | + it("loads every Windows workspace path form through a signed asset URL", () => { | |
| 118 | + const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg"; | |
| 119 | + const html = render( | |
| 120 | + [ | |
| 121 | + "", | |
| 122 | + ``, | |
| 123 | + ``, | |
| 124 | + "", | |
| 125 | + ].join("\n\n"), | |
| 126 | + ); | |
| 122 | 127 | |
| 123 | 128 | expect(testState.resources).toEqual([ |
| 124 | 129 | { |
| 125 | − _tag: "workspace-file", | |
| 130 | + _tag: "media-file", | |
| 126 | 131 | threadId: threadRef.threadId, |
| 127 | 132 | path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", |
| 128 | 133 | }, |
| 134 | + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, | |
| 135 | + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, | |
| 136 | + { | |
| 137 | + _tag: "media-file", | |
| 138 | + threadId: threadRef.threadId, | |
| 139 | + path: "\\\\server\\share\\workspace-image.svg", | |
| 140 | + }, | |
| 129 | 141 | ]); |
| 130 | − expect(html).toContain("https://signed.test/workspace-image.svg"); | |
| 142 | + expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); | |
| 143 | + expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); | |
| 144 | + expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); | |
| 145 | + expect(html).not.toContain("Image unavailable"); | |
| 131 | 146 | }); |
| 132 | 147 | |
| 133 | − it("preserves Windows drive links through markdown sanitization", () => { | |
| 134 | − const html = render(String.raw`[Open](C:\Users\shawn\project\src\main.ts)`); | |
| 135 | − expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); | |
| 136 | − expect(html).toContain("chat-markdown-file-link"); | |
| 137 | − }); | |
| 148 | + it("normalizes a drive-absolute src in raw image HTML", () => { | |
| 149 | + const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`); | |
| 138 | 150 | |
| 139 | − it("loads drive-absolute markdown and raw HTML images through assets", () => { | |
| 140 | − const html = render( | |
| 141 | − [ | |
| 142 | − "", | |
| 143 | − String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`, | |
| 144 | − ].join("\n\n"), | |
| 145 | − ); | |
| 146 | 151 | expect(testState.resources).toEqual([ |
| 147 | 152 | { |
| 148 | − _tag: "workspace-file", | |
| 149 | − threadId: threadRef.threadId, | |
| 150 | − path: "C:/Users/shawn/project/.t3/workspace-image.svg", | |
| 151 | − }, | |
| 152 | − { | |
| 153 | − _tag: "workspace-file", | |
| 153 | + _tag: "media-file", | |
| 154 | 154 | threadId: threadRef.threadId, |
| 155 | 155 | path: "D:/screens/workspace-image.svg", |
| 156 | 156 | }, |
| 157 | 157 | ]); |
| 158 | − expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(2); | |
| 158 | + expect(html).toContain("https://signed.test/workspace-image.svg"); | |
| 159 | 159 | }); |
| 160 | 160 | |
| 161 | 161 | it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { |
| @@ -261,6 +261,7 @@ describe("ChatMarkdown workspace images", () => { · 99 unmodified lines | ||
| 261 | 261 | |
| 262 | 262 | it("uses a static bounded-width placeholder while a signed asset URL loads", () => { |
| 263 | 263 | testState.assetState = "loading"; |
| 264 | + | |
| 264 | 265 | const html = render(""); |
| 265 | 266 | const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; |
| 266 | 267 | |
| @@ -269,25 +270,31 @@ describe("ChatMarkdown workspace images", () => { · 2 unmodified lines | ||
| 269 | 270 | expect(className?.split(" ")).toContain("w-64"); |
| 270 | 271 | }); |
| 271 | 272 | |
| 272 | − it("keeps remote images directly loadable", () => { | |
| 273 | − const html = render(""); | |
| 273 | + it("never passes a workspace source to a raw image when thread context is unavailable", () => { | |
| 274 | + const html = renderWithoutThread( | |
| 275 | + "", | |
| 276 | + ); | |
| 277 | + | |
| 274 | 278 | expect(testState.resources).toEqual([]); |
| 275 | − expect(html).toContain('src="https://example.com/image.png"'); | |
| 276 | − expect(html).toContain("max-h-[30rem]"); | |
| 279 | + expect(html).toContain("Image unavailable"); | |
| 280 | + expect(html).not.toContain("file://"); | |
| 277 | 281 | }); |
| 278 | 282 | |
| 279 | − it("uses the markdown owner's environment for file actions", () => { | |
| 280 | − const owningEnvironmentId = EnvironmentId.make("env-pull-request"); | |
| 283 | + it("blocks unsupported image schemes instead of passing them to a raw image", () => { | |
| 284 | + const html = render(""); | |
| 281 | 285 | |
| 282 | − renderToStaticMarkup( | |
| 283 | − <ChatMarkdown | |
| 284 | − cwd="/workspace/project" | |
| 285 | − environmentId={owningEnvironmentId} | |
| 286 | − text="[Open](src/main.ts)" | |
| 287 | − />, | |
| 288 | − ); | |
| 286 | + expect(testState.resources).toEqual([]); | |
| 287 | + expect(html).toContain("Image unavailable"); | |
| 288 | + expect(html).not.toContain("content://"); | |
| 289 | + }); | |
| 289 | 290 | |
| 290 | − expect(testState.editorEnvironmentIds).toEqual([owningEnvironmentId]); | |
| 291 | − expect(testState.remoteEnvironmentIds).toEqual([owningEnvironmentId]); | |
| 291 | + it("keeps remote images directly loadable", () => { | |
| 292 | + const html = render(""); | |
| 293 | + | |
| 294 | + expect(testState.resources).toEqual([]); | |
| 295 | + expect(html).toContain('src="https://example.com/image.png"'); | |
| 296 | + expect(html).toContain("max-w-[min(100%,30rem)]"); | |
| 297 | + expect(html).toContain("max-h-[30rem]"); | |
| 298 | + expect(html).not.toContain("Image unavailable"); | |
| 292 | 299 | }); |
| 293 | 300 | }); |
| @@ -24,10 +24,10 @@ import { · 23 unmodified lines | ||
| 24 | 24 | deriveComposerSendState, |
| 25 | 25 | dismissBranchMismatchForSession, |
| 26 | 26 | getStartedThreadModelChangeBlockReason, |
| 27 | + isVideoPreviewRequestCurrent, | |
| 28 | + hasEnvironmentReconnectWarningGraceElapsed, | |
| 27 | 29 | hasServerAcknowledgedLocalDispatch, |
| 28 | 30 | isBranchMismatchDismissedForSession, |
| 29 | − isVideoPreviewRequestCurrent, | |
| 30 | − loadVideoPreviewUrl, | |
| 31 | 31 | reconcileMountedTerminalThreadIds, |
| 32 | 32 | reconcileRetainedMountedThreadIds, |
| 33 | 33 | resolveDraftPromotionNavigationTarget, |
| @@ -17,6 +17,12 @@ import { · 16 unmodified lines | ||
| 17 | 17 | type RunId, |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | 19 | import * as DateTime from "effect/DateTime"; |
| 20 | +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | |
| 21 | +import { | |
| 22 | + squashAtomCommandFailure, | |
| 23 | + type AtomCommandResult, | |
| 24 | +} from "@t3tools/client-runtime/state/runtime"; | |
| 25 | +import { videoMimeType } from "@t3tools/shared/video"; | |
| 20 | 26 | import { |
| 21 | 27 | appendCodexArtifactTemplateUsePrompt, |
| 22 | 28 | codexArtifactTemplateUsePrompt, |
| @@ -39,11 +39,6 @@ import { · 38 unmodified lines | ||
| 39 | 39 | threadWokeAt, |
| 40 | 40 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 41 | 41 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 42 | −import { | |
| 43 | − resolveDisplayedThreadPr, | |
| 44 | − threadChangeRequestSnapshotsAtom, | |
| 45 | − useLinkedThreadPullRequest, | |
| 46 | −} from "./ThreadStatusIndicators"; | |
| 47 | 42 | import { |
| 48 | 43 | deriveThreadActivityRun, |
| 49 | 44 | deriveLatestThreadRun, |
| @@ -74,7 +69,12 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; · 24 unmodified lines | ||
| 74 | 69 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 75 | 70 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 76 | 71 | import { truncate } from "@t3tools/shared/String"; |
| 77 | −import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | |
| 72 | +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; | |
| 73 | +import { | |
| 74 | + getTerminalLabel, | |
| 75 | + nextTerminalId, | |
| 76 | + resolveTerminalSessionLabel, | |
| 77 | +} from "@t3tools/shared/terminalLabels"; | |
| 78 | 78 | import { Debouncer } from "@tanstack/react-pacer"; |
| 79 | 79 | import { useAtomValue } from "@effect/atom-react"; |
| 80 | 80 | import { |
| @@ -325,8 +325,14 @@ import { · 244 unmodified lines | ||
| 325 | 325 | ThreadErrorBanner, |
| 326 | 326 | } from "./chat/ThreadErrorBanner"; |
| 327 | 327 | import { resolveThreadPr } from "./ThreadStatusIndicators"; |
| 328 | −import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | |
| 329 | 328 | import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl"; |
| 329 | +import { | |
| 330 | + resolveDisplayedThreadPr, | |
| 331 | + threadPullRequestRefreshSource, | |
| 332 | + threadChangeRequestSnapshotsAtom, | |
| 333 | + useLinkedThreadPullRequest, | |
| 334 | +} from "./ThreadStatusIndicators"; | |
| 335 | +import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | |
| 330 | 336 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 331 | 337 | import { resolveThreadSyncPhase } from "../threadSync"; |
| 332 | 338 | import { |
| @@ -1338,6 +1344,7 @@ function ChatViewContent(props: ChatViewProps) { · 1005 unmodified lines | ||
| 1338 | 1344 | }); |
| 1339 | 1345 | const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 1340 | 1346 | reportFailure: false, |
| 1347 | + refresh: true, | |
| 1341 | 1348 | }); |
| 1342 | 1349 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { |
| 1343 | 1350 | reportFailure: false, |
| @@ -1907,7 +1914,7 @@ function ChatViewContent(props: ChatViewProps) { · 563 unmodified lines | ||
| 1907 | 1914 | >({}); |
| 1908 | 1915 | const activePullRequestSurfaceId = |
| 1909 | 1916 | activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; |
| 1910 | − const handlePullRequestTabStatusChange = useCallback( | |
| 1917 | + const updatePullRequestTabStatusFromPanel = useCallback( | |
| 1911 | 1918 | (status: PullRequestTabStatus) => { |
| 1912 | 1919 | const id = activePullRequestSurfaceId; |
| 1913 | 1920 | if (id === undefined) return; |
| @@ -1915,6 +1922,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | ||
| 1915 | 1922 | }, |
| 1916 | 1923 | [activePullRequestSurfaceId], |
| 1917 | 1924 | ); |
| 1925 | + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); | |
| 1926 | + const sidebarPrRefreshKeyRef = useRef<string | null>(null); | |
| 1918 | 1927 | const activeFileSurface = |
| 1919 | 1928 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1920 | 1929 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| @@ -2758,14 +2767,8 @@ function ChatViewContent(props: ChatViewProps) { · 837 unmodified lines | ||
| 2758 | 2767 | toastManager.add({ type: "error", title: "The environment is not connected." }); |
| 2759 | 2768 | return; |
| 2760 | 2769 | } |
| 2761 | − const videoMime = videoMimeType(attachment); | |
| 2762 | − const isVideo = videoMime !== null; | |
| 2770 | + const isVideo = videoMimeType(attachment) !== null; | |
| 2763 | 2771 | const action = isVideo ? "play" : "download"; |
| 2764 | − const videoPreviewAbortController = isVideo ? new AbortController() : null; | |
| 2765 | − if (isVideo) { | |
| 2766 | − videoPreviewAbortControllerRef.current?.abort(); | |
| 2767 | − videoPreviewAbortControllerRef.current = videoPreviewAbortController; | |
| 2768 | − } | |
| 2769 | 2772 | const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; |
| 2770 | 2773 | const isCurrentRequest = () => |
| 2771 | 2774 | !isVideo || |
| @@ -2775,75 +2778,37 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines | ||
| 2775 | 2778 | videoPreviewRequestId, |
| 2776 | 2779 | videoPreviewRequestIdRef.current, |
| 2777 | 2780 | ); |
| 2778 | − const finishVideoPreviewRequest = () => { | |
| 2779 | − if (videoPreviewRequestIdRef.current === videoPreviewRequestId) { | |
| 2780 | − setOpeningVideoAttachmentId(null); | |
| 2781 | − videoPreviewAbortControllerRef.current = null; | |
| 2782 | − } | |
| 2783 | − }; | |
| 2784 | 2781 | if (isVideo) setOpeningVideoAttachmentId(attachment.id); |
| 2785 | 2782 | |
| 2786 | − // fileName and mimeType ride in the signed claims so videos render | |
| 2787 | − // inline while other files keep their real download name and type. | |
| 2788 | − const result = await createAttachmentAssetUrl({ | |
| 2789 | − environmentId, | |
| 2790 | − input: { | |
| 2791 | − resource: { | |
| 2792 | − _tag: "attachment", | |
| 2793 | − attachmentId: attachment.id, | |
| 2794 | − fileName: attachment.name, | |
| 2795 | − mimeType: videoMime ?? attachment.mimeType, | |
| 2796 | − }, | |
| 2797 | − }, | |
| 2798 | − }); | |
| 2799 | − if (!isCurrentRequest()) { | |
| 2800 | − finishVideoPreviewRequest(); | |
| 2801 | − return; | |
| 2802 | − } | |
| 2803 | − if (result._tag === "Failure") { | |
| 2804 | − finishVideoPreviewRequest(); | |
| 2805 | − const error = squashAtomCommandFailure(result); | |
| 2806 | − toastManager.add({ | |
| 2807 | − type: "error", | |
| 2808 | − title: "Could not " + action + " " + attachment.name, | |
| 2809 | − description: error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 2783 | + try { | |
| 2784 | + const url = await resolveFileAttachmentUrl({ | |
| 2785 | + attachment, | |
| 2786 | + environmentId, | |
| 2787 | + httpBaseUrl: connection.httpBaseUrl, | |
| 2788 | + createAssetUrl: createAttachmentAssetUrl, | |
| 2810 | 2789 | }); |
| 2811 | − return; | |
| 2812 | − } | |
| 2813 | − | |
| 2814 | − const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); | |
| 2815 | − if (!url) { | |
| 2816 | − finishVideoPreviewRequest(); | |
| 2817 | − toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name }); | |
| 2818 | − return; | |
| 2819 | − } | |
| 2820 | − if (isVideo) { | |
| 2821 | − try { | |
| 2822 | − const previewUrl = await loadVideoPreviewUrl(url, videoPreviewAbortController?.signal); | |
| 2823 | − if (!isCurrentRequest()) { | |
| 2824 | − revokeBlobPreviewUrl(previewUrl); | |
| 2825 | − return; | |
| 2826 | − } | |
| 2790 | + if (!isCurrentRequest()) return; | |
| 2791 | + if (isVideo) { | |
| 2827 | 2792 | setExpandedImage({ |
| 2828 | − images: [{ src: previewUrl, name: attachment.name, type: "video" }], | |
| 2793 | + images: [{ src: url, name: attachment.name, type: "video" }], | |
| 2829 | 2794 | index: 0, |
| 2830 | 2795 | }); |
| 2831 | − } catch (error) { | |
| 2832 | − if (!isCurrentRequest()) return; | |
| 2833 | − toastManager.add({ | |
| 2834 | − type: "error", | |
| 2835 | − title: "Could not play " + attachment.name, | |
| 2836 | − description: error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 2837 | − }); | |
| 2838 | − } finally { | |
| 2839 | − finishVideoPreviewRequest(); | |
| 2796 | + return; | |
| 2840 | 2797 | } |
| 2841 | − return; | |
| 2798 | + const anchor = document.createElement("a"); | |
| 2799 | + anchor.href = url; | |
| 2800 | + anchor.download = attachment.name; | |
| 2801 | + anchor.click(); | |
| 2802 | + } catch (error) { | |
| 2803 | + if (!isCurrentRequest()) return; | |
| 2804 | + toastManager.add({ | |
| 2805 | + type: "error", | |
| 2806 | + title: "Could not " + action + " " + attachment.name, | |
| 2807 | + description: error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 2808 | + }); | |
| 2809 | + } finally { | |
| 2810 | + if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); | |
| 2842 | 2811 | } |
| 2843 | − const anchor = document.createElement("a"); | |
| 2844 | − anchor.href = url; | |
| 2845 | − anchor.download = attachment.name; | |
| 2846 | − anchor.click(); | |
| 2847 | 2812 | }, |
| 2848 | 2813 | [createAttachmentAssetUrl, environmentId, routeThreadKey], |
| 2849 | 2814 | ); |
| @@ -4885,8 +4850,14 @@ function ChatViewContent(props: ChatViewProps) { · 2035 unmodified lines | ||
| 4885 | 4850 | ); |
| 4886 | 4851 | // The server-projected settled state keeps the banner and sidebar in sync. |
| 4887 | 4852 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| 4888 | − const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); | |
| 4889 | − const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); | |
| 4853 | + const autoSettleAfterDays = useEnvironmentSettings( | |
| 4854 | + environmentId, | |
| 4855 | + (settings) => settings.sidebarAutoSettleAfterDays, | |
| 4856 | + ); | |
| 4857 | + const autoSettleOnMerge = useEnvironmentSettings( | |
| 4858 | + environmentId, | |
| 4859 | + (settings) => settings.sidebarAutoSettleOnMerge, | |
| 4860 | + ); | |
| 4890 | 4861 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); |
| 4891 | 4862 | // The thread's own change request, placed against the project it belongs to. |
| 4892 | 4863 | const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; |
| @@ -4902,6 +4873,97 @@ function ChatViewContent(props: ChatViewProps) { · 9 unmodified lines | ||
| 4902 | 4873 | linkedPullRequest: linkedThreadPullRequest, |
| 4903 | 4874 | linkedPullRequestStatus, |
| 4904 | 4875 | }); |
| 4876 | + const handlePullRequestTabStatusChange = useCallback( | |
| 4877 | + (status: PullRequestTabStatus) => { | |
| 4878 | + updatePullRequestTabStatusFromPanel(status); | |
| 4879 | + const source = threadPullRequestRefreshSource({ | |
| 4880 | + panel: status, | |
| 4881 | + thread: { | |
| 4882 | + repository: threadRepository, | |
| 4883 | + number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, | |
| 4884 | + state: activeThreadPr?.state ?? null, | |
| 4885 | + linked: linkedThreadPullRequest !== null, | |
| 4886 | + }, | |
| 4887 | + }); | |
| 4888 | + if (source === null) { | |
| 4889 | + sidebarPrRefreshKeyRef.current = null; | |
| 4890 | + return; | |
| 4891 | + } | |
| 4892 | + const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; | |
| 4893 | + if (sidebarPrRefreshKeyRef.current === refreshKey) return; | |
| 4894 | + sidebarPrRefreshKeyRef.current = refreshKey; | |
| 4895 | + | |
| 4896 | + if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { | |
| 4897 | + appAtomRegistry.refresh( | |
| 4898 | + linkedPullRequestDetailAtom({ | |
| 4899 | + environmentId: activeThreadRef.environmentId, | |
| 4900 | + input: { | |
| 4901 | + projectId: linkedThreadPullRequest.projectId, | |
| 4902 | + repository: linkedThreadPullRequest.repository, | |
| 4903 | + number: linkedThreadPullRequest.number, | |
| 4904 | + }, | |
| 4905 | + }), | |
| 4906 | + ); | |
| 4907 | + return; | |
| 4908 | + } | |
| 4909 | + if (source === "vcs" && activeThreadRef && gitCwd !== null) { | |
| 4910 | + void refreshVcsStatus({ | |
| 4911 | + environmentId: activeThreadRef.environmentId, | |
| 4912 | + input: { cwd: gitCwd }, | |
| 4913 | + }).then(() => { | |
| 4914 | + if (sidebarPrRefreshKeyRef.current === refreshKey) { | |
| 4915 | + sidebarPrRefreshKeyRef.current = null; | |
| 4916 | + } | |
| 4917 | + }); | |
| 4918 | + } | |
| 4919 | + }, | |
| 4920 | + [ | |
| 4921 | + activeThreadKey, | |
| 4922 | + activeThreadPr?.number, | |
| 4923 | + activeThreadPr?.state, | |
| 4924 | + activeThreadRef, | |
| 4925 | + gitCwd, | |
| 4926 | + linkedThreadPullRequest, | |
| 4927 | + refreshVcsStatus, | |
| 4928 | + threadRepository, | |
| 4929 | + updatePullRequestTabStatusFromPanel, | |
| 4930 | + ], | |
| 4931 | + ); | |
| 4932 | + const activeThreadReferenceCopyTarget = useMemo( | |
| 4933 | + () => | |
| 4934 | + activeThreadId === null || !isServerThread | |
| 4935 | + ? null | |
| 4936 | + : resolveThreadReferenceCopyTarget({ | |
| 4937 | + threadId: activeThreadId, | |
| 4938 | + linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, | |
| 4939 | + detectedPullRequestUrl: activeThreadPr?.url ?? null, | |
| 4940 | + }), | |
| 4941 | + [activeThreadId, activeThreadPr?.url, isServerThread, linkedThreadPullRequest?.url], | |
| 4942 | + ); | |
| 4943 | + const copyActiveThreadReference = useCallback(() => { | |
| 4944 | + const target = activeThreadReferenceCopyTarget; | |
| 4945 | + if (target === null) return; | |
| 4946 | + void writeTextToClipboard(target.value, target.clipboardTarget).then( | |
| 4947 | + (didCopy) => { | |
| 4948 | + if (!didCopy) return; | |
| 4949 | + toastManager.add({ | |
| 4950 | + type: "success", | |
| 4951 | + title: target.successTitle, | |
| 4952 | + description: target.value, | |
| 4953 | + }); | |
| 4954 | + }, | |
| 4955 | + (error) => { | |
| 4956 | + console.error(error); | |
| 4957 | + toastManager.add( | |
| 4958 | + stackedThreadToast({ | |
| 4959 | + type: "error", | |
| 4960 | + title: target.failureTitle, | |
| 4961 | + description: error instanceof Error ? error.message : "An error occurred.", | |
| 4962 | + }), | |
| 4963 | + ); | |
| 4964 | + }, | |
| 4965 | + ); | |
| 4966 | + }, [activeThreadReferenceCopyTarget]); | |
| 4905 | 4967 | // The right panel offers the thread's own change request, so it can only offer it once the |
| 4906 | 4968 | // branch has one; until then the picker says so rather than opening an empty panel. |
| 4907 | 4969 | const addPullRequestSurface = useCallback(() => { |
| @@ -4950,21 +5012,13 @@ function ChatViewContent(props: ChatViewProps) { · 42 unmodified lines | ||
| 4950 | 5012 | if (activeThreadRef === null || activeThreadWokeAt === null) return; |
| 4951 | 5013 | markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); |
| 4952 | 5014 | }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); |
| 4953 | − // Mirror of the sidebar's Woke pill for the open thread. It uses the same | |
| 4954 | − // visit comparison and change request settle rule. | |
| 5015 | + // Mirror of the sidebar's Woke pill for the open thread. | |
| 4955 | 5016 | const activeThreadLastVisitedAt = useUiStateStore((store) => |
| 4956 | 5017 | activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], |
| 4957 | 5018 | ); |
| 4958 | 5019 | const activeThreadWokeVisible = useMemo(() => { |
| 4959 | 5020 | if (activeThreadWokeAt === null) return false; |
| 4960 | − if ( | |
| 4961 | − changeRequestAutoSettles(activeThreadChangeRequest, { | |
| 4962 | − autoSettleOnMerge, | |
| 4963 | − thread: activeThreadShell, | |
| 4964 | − }) | |
| 4965 | − ) { | |
| 4966 | − return false; | |
| 4967 | − } | |
| 5021 | + if (activeThreadShell?.settledOverride === "settled") return false; | |
| 4968 | 5022 | const wokeAtMs = Date.parse(activeThreadWokeAt); |
| 4969 | 5023 | if (Number.isNaN(wokeAtMs)) return false; |
| 4970 | 5024 | // Having the thread open counts as a visit at completedAt (the effect |
| @@ -4984,7 +5038,6 @@ function ChatViewContent(props: ChatViewProps) { · 13 unmodified lines | ||
| 4984 | 5038 | }, [ |
| 4985 | 5039 | activeLatestRun?.completedAt, |
| 4986 | 5040 | activeThreadLastVisitedAt, |
| 4987 | − activeThreadChangeRequest, | |
| 4988 | 5041 | activeThreadShell, |
| 4989 | 5042 | activeThreadWokeAt, |
| 4990 | 5043 | autoSettleOnMerge, |
| @@ -5006,8 +5059,6 @@ function ChatViewContent(props: ChatViewProps) { · 15 unmodified lines | ||
| 5006 | 5059 | nowMinute, |
| 5007 | 5060 | supportsSettlement, |
| 5008 | 5061 | ]); |
| 5009 | − const activeThreadSettled = | |
| 5010 | − supportsSettlement && activeThreadShell?.settledOverride === "settled"; | |
| 5011 | 5062 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { |
| 5012 | 5063 | reportFailure: false, |
| 5013 | 5064 | }); |
| @@ -5394,6 +5445,13 @@ function ChatViewContent(props: ChatViewProps) { · 380 unmodified lines | ||
| 5394 | 5445 | }); |
| 5395 | 5446 | if (!command) return; |
| 5396 | 5447 | |
| 5448 | + if (command === "thread.copyReference") { | |
| 5449 | + event.preventDefault(); | |
| 5450 | + event.stopPropagation(); | |
| 5451 | + if (!event.repeat) copyActiveThreadReference(); | |
| 5452 | + return; | |
| 5453 | + } | |
| 5454 | + | |
| 5397 | 5455 | if (command === "thread.settle") { |
| 5398 | 5456 | event.preventDefault(); |
| 5399 | 5457 | event.stopPropagation(); |
| @@ -5561,6 +5619,7 @@ function ChatViewContent(props: ChatViewProps) { · 161 unmodified lines | ||
| 5561 | 5619 | supportsPinning, |
| 5562 | 5620 | supportsSettlement, |
| 5563 | 5621 | confirmAndUnpinThread, |
| 5622 | + copyActiveThreadReference, | |
| 5564 | 5623 | toggleRightPanel, |
| 5565 | 5624 | toggleThreadPanel, |
| 5566 | 5625 | toggleTerminalVisibility, |
| @@ -7512,6 +7571,7 @@ function ChatViewContent(props: ChatViewProps) { · 1945 unmodified lines | ||
| 7512 | 7571 | } |
| 7513 | 7572 | > |
| 7514 | 7573 | <DraftHeroHeadline |
| 7574 | + draftId={draftId} | |
| 7515 | 7575 | activeProjectRef={activeProjectRef} |
| 7516 | 7576 | activeProjectTitle={activeProject?.title ?? null} |
| 7517 | 7577 | /> |
| @@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- · 18 unmodified lines | ||
| 19 | 19 | import { CSS } from "@dnd-kit/utilities"; |
| 20 | 20 | import { |
| 21 | 21 | canSnooze, |
| 22 | + effectiveSettled, | |
| 22 | 23 | effectiveSnoozed, |
| 23 | 24 | threadWokeAt, |
| 24 | 25 | } from "@t3tools/client-runtime/state/thread-settled"; |
| @@ -99,7 +100,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; · 74 unmodified lines | ||
| 99 | 100 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 100 | 101 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; |
| 101 | 102 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 102 | −import { useClientSettings } from "../hooks/useSettings"; | |
| 103 | +import { useClientSettings, usePrimarySettings } from "../hooks/useSettings"; | |
| 103 | 104 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 104 | 105 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 105 | 106 | import { useNowMinute } from "../hooks/useNowMinute"; |
| @@ -171,7 +172,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 65 unmodified lines | ||
| 171 | 172 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 172 | 173 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| 173 | 174 | import { |
| 174 | − deriveProviderInstanceEntries, | |
| 175 | + deriveProviderEntriesByEnvironment, | |
| 175 | 176 | shouldShowInstanceBadge, |
| 176 | 177 | type ProviderInstanceEntry, |
| 177 | 178 | } from "../providerInstances"; |
| @@ -851,10 +852,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 673 unmodified lines | ||
| 851 | 852 | const isWoke = |
| 852 | 853 | wokeAtDate !== null && |
| 853 | 854 | (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && |
| 854 | − !changeRequestAutoSettles(pr, { | |
| 855 | − autoSettleOnMerge: props.autoSettleOnMerge, | |
| 856 | − thread, | |
| 857 | − }); | |
| 855 | + thread.settledOverride !== "settled"; | |
| 858 | 856 | // In-flight rows (working, or waiting on approval/input) fade as a whole: |
| 859 | 857 | // there is nothing for the user to do yet, so prominence is reserved for |
| 860 | 858 | // rows that need a human — done (unread), read-but-unsettled, failed, and |
| @@ -1772,6 +1770,8 @@ export default function Sidebar() { · 911 unmodified lines | ||
| 1772 | 1770 | const { isMobile, setOpenMobile } = useSidebar(); |
| 1773 | 1771 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 1774 | 1772 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 1773 | + const autoSettleAfterDays = usePrimarySettings((s) => s.sidebarAutoSettleAfterDays); | |
| 1774 | + const autoSettleOnMerge = usePrimarySettings((s) => s.sidebarAutoSettleOnMerge); | |
| 1775 | 1775 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| 1776 | 1776 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 1777 | 1777 | const timestampFormat = useClientSettings((s) => s.timestampFormat); |
| @@ -1925,15 +1925,20 @@ export default function Sidebar() { · 147 unmodified lines | ||
| 1925 | 1925 | () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), |
| 1926 | 1926 | [sidebarProjectSortOrder, threads, unsortedProjectGroups], |
| 1927 | 1927 | ); |
| 1928 | − const serverProviders = useAtomValue(primaryServerProvidersAtom); | |
| 1929 | − const providerEntryByInstanceId = useMemo( | |
| 1928 | + const projectGroupsRef = useRef(projectGroups); | |
| 1929 | + projectGroupsRef.current = projectGroups; | |
| 1930 | + const serverConfigs = useAtomValue(environmentServerConfigsAtom); | |
| 1931 | + // Threads on non-primary environments (T3 Connect, hosted) resolve their | |
| 1932 | + // provider entry from their own environment's config: default instance ids | |
| 1933 | + // are driver slugs, so a flat map would collide across environments. | |
| 1934 | + const providerEntriesByEnvironment = useMemo( | |
| 1930 | 1935 | () => |
| 1931 | − new Map( | |
| 1932 | − deriveProviderInstanceEntries(serverProviders).map( | |
| 1933 | − (entry) => [entry.instanceId as string, entry] as const, | |
| 1936 | + deriveProviderEntriesByEnvironment( | |
| 1937 | + [...serverConfigs].map( | |
| 1938 | + ([environmentId, config]) => [environmentId, config.providers] as const, | |
| 1934 | 1939 | ), |
| 1935 | 1940 | ), |
| 1936 | − [serverProviders], | |
| 1941 | + [serverConfigs], | |
| 1937 | 1942 | ); |
| 1938 | 1943 | const projectCwdByKey = useMemo( |
| 1939 | 1944 | () => |
| @@ -2081,11 +2086,8 @@ export default function Sidebar() { · 141 unmodified lines | ||
| 2081 | 2086 | clearSelection(); |
| 2082 | 2087 | }, [clearSelection, projectScopeKey]); |
| 2083 | 2088 | |
| 2084 | − const handleProjectSettings = useCallback( | |
| 2085 | − (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => { | |
| 2086 | − event.preventDefault(); | |
| 2087 | − event.stopPropagation(); | |
| 2088 | − dispatchProjectScopeMenu({ type: "project-settings-opened" }); | |
| 2089 | + const openProjectSettings = useCallback( | |
| 2090 | + (projectGroup: SidebarProjectSnapshot) => { | |
| 2089 | 2091 | if (isMobile) { |
| 2090 | 2092 | setOpenMobile(false); |
| 2091 | 2093 | } |
| @@ -2118,7 +2120,6 @@ export default function Sidebar() { · 26 unmodified lines | ||
| 2118 | 2120 | // the partition works directly off live shells: no archived-snapshot |
| 2119 | 2121 | // merging, no optimistic holds. Archived threads remain hidden here — |
| 2120 | 2122 | // archive keeps its original "remove from sidebar" meaning. |
| 2121 | − const serverConfigs = useAtomValue(environmentServerConfigsAtom); | |
| 2122 | 2123 | const { |
| 2123 | 2124 | pinnedThreads, |
| 2124 | 2125 | reorderablePinnedKeys, |
| @@ -2133,6 +2134,7 @@ export default function Sidebar() { · 8 unmodified lines | ||
| 2133 | 2134 | // memo exactly at the next wake boundary. |
| 2134 | 2135 | void snoozeWakeTick; |
| 2135 | 2136 | const preciseNow = new Date().toISOString(); |
| 2137 | + const now = `${nowMinute}:00.000Z`; | |
| 2136 | 2138 | // Subagent child threads live in the parent's Agents surface, not the |
| 2137 | 2139 | // sidebar roster (v2 models them as real threads with lineage). |
| 2138 | 2140 | const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); |
| @@ -2209,16 +2211,7 @@ export default function Sidebar() { · 70 unmodified lines | ||
| 2209 | 2211 | settledThreads: sortSettledThreadsForSidebar(settled), |
| 2210 | 2212 | snoozeNow: preciseNow, |
| 2211 | 2213 | }; |
| 2212 | − }, [ | |
| 2213 | − autoSettleAfterDays, | |
| 2214 | − autoSettleOnMerge, | |
| 2215 | − changeRequestSnapshotByKey, | |
| 2216 | − nowMinute, | |
| 2217 | − scopedProjectKeys, | |
| 2218 | − serverConfigs, | |
| 2219 | − snoozeWakeTick, | |
| 2220 | − threads, | |
| 2221 | − ]); | |
| 2214 | + }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); | |
| 2222 | 2215 | |
| 2223 | 2216 | const threadSearchInputRef = useRef<HTMLInputElement>(null); |
| 2224 | 2217 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); |
| @@ -3788,7 +3781,10 @@ export default function Sidebar() { · 1563 unmodified lines | ||
| 3788 | 3781 | ) ?? null |
| 3789 | 3782 | } |
| 3790 | 3783 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} |
| 3791 | − providerEntryByInstanceId={providerEntryByInstanceId} | |
| 3784 | + providerEntryByInstanceId={ | |
| 3785 | + providerEntriesByEnvironment.get(thread.environmentId) ?? | |
| 3786 | + EMPTY_PROVIDER_ENTRIES | |
| 3787 | + } | |
| 3792 | 3788 | isHighlighted={activeSearchResultIndex === index} |
| 3793 | 3789 | isRouteActive={routeThreadKey === threadKey} |
| 3794 | 3790 | resultId={`sidebar-thread-search-result-${index}`} |
| @@ -3894,7 +3890,10 @@ export default function Sidebar() { · 99 unmodified lines | ||
| 3894 | 3890 | `${thread.environmentId}:${thread.projectId}`, |
| 3895 | 3891 | ) ?? null |
| 3896 | 3892 | } |
| 3897 | − providerEntryByInstanceId={providerEntryByInstanceId} | |
| 3893 | + providerEntryByInstanceId={ | |
| 3894 | + providerEntriesByEnvironment.get(thread.environmentId) ?? | |
| 3895 | + EMPTY_PROVIDER_ENTRIES | |
| 3896 | + } | |
| 3898 | 3897 | timestampFormat={timestampFormat} |
| 3899 | 3898 | onThreadClick={handleThreadClick} |
| 3900 | 3899 | onThreadActivate={navigateToThread} |
| @@ -4099,4 +4098,5 @@ export default function Sidebar() { · 198 unmodified lines | ||
| 4099 | 4098 | <SidebarChromeFooter /> |
| 4100 | 4099 | </> |
| 4101 | 4100 | ); |
| 4101 | + const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map(); | |
| 4102 | 4102 | } |
| @@ -172,6 +172,7 @@ import { · 171 unmodified lines | ||
| 172 | 172 | submitComposerDraft, |
| 173 | 173 | } from "./composerSubmission"; |
| 174 | 174 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; |
| 175 | +import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | |
| 175 | 176 | |
| 176 | 177 | function ComposerVideoThumbnail({ file }: { file: File }) { |
| 177 | 178 | const setVideo = useCallback( |
| @@ -1580,7 +1580,7 @@ describe("deriveMessagesTimelineRows", () => { · 1579 unmodified lines | ||
| 1580 | 1580 | "assistant-final-entry", |
| 1581 | 1581 | "user-followup-entry", |
| 1582 | 1582 | "working-indicator-row", |
| 1583 | − "thinking-indicator-row", | |
| 1583 | + "live-activity-row", | |
| 1584 | 1584 | ]); |
| 1585 | 1585 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); |
| 1586 | 1586 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); |
| @@ -1618,14 +1618,15 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines | ||
| 1618 | 1618 | revertTurnCountByUserMessageId: new Map(), |
| 1619 | 1619 | }); |
| 1620 | 1620 | |
| 1621 | − // The settled tool stays visible in past tense instead of vanishing, and | |
| 1622 | − // the thinking indicator marks that the turn itself is still running. | |
| 1623 | − expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); | |
| 1621 | + // A successfully settled command keeps the shared live-activity row alive | |
| 1622 | + // between actions (#9098) instead of dropping to past tense, so no extra | |
| 1623 | + // thinking row appears while the turn is still running. | |
| 1624 | + expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); | |
| 1624 | 1625 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ |
| 1625 | − active: false, | |
| 1626 | + active: true, | |
| 1627 | + id: "live-activity-row", | |
| 1626 | 1628 | entry: { id: "work-1" }, |
| 1627 | 1629 | }); |
| 1628 | − expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); | |
| 1629 | 1630 | }); |
| 1630 | 1631 | |
| 1631 | 1632 | it("does not fold the active in-progress turn", () => { |
| @@ -1675,7 +1676,7 @@ describe("deriveMessagesTimelineRows", () => { · 43 unmodified lines | ||
| 1675 | 1676 | expect(rows.map((row) => row.id)).toEqual([ |
| 1676 | 1677 | "working-indicator-row", |
| 1677 | 1678 | "assistant-thought-entry", |
| 1678 | − "work-live:work-entry-1", | |
| 1679 | + "live-activity-row", | |
| 1679 | 1680 | ]); |
| 1680 | 1681 | const liveRow = rows.find((row) => row.kind === "work-live"); |
| 1681 | 1682 | expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1"); |
| @@ -1948,8 +1949,8 @@ describe("computeStableMessagesTimelineRows", () => { · 266 unmodified lines | ||
| 1948 | 1949 | initial, |
| 1949 | 1950 | ); |
| 1950 | 1951 | |
| 1951 | − const initialThinking = initial.byId.get("thinking-indicator-row"); | |
| 1952 | − const updatedThinking = updated.byId.get("thinking-indicator-row"); | |
| 1952 | + const initialThinking = initial.byId.get("live-activity-row"); | |
| 1953 | + const updatedThinking = updated.byId.get("live-activity-row"); | |
| 1953 | 1954 | expect(initialThinking).toMatchObject({ kind: "thinking" }); |
| 1954 | 1955 | expect(updatedThinking).toBe(initialThinking); |
| 1955 | 1956 | expect(updated.result.at(-1)).toBe(updatedThinking); |
| @@ -1,9 +1,6 @@ | ||
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 3 | 3 | import { |
| 4 | − normalizeCompactToolLabel, | |
| 5 | − omitSupersededLifecycleMarkers, | |
| 6 | − resolveWorkEntryToolPresentation, | |
| 7 | 4 | summarizeToolGroup, |
| 8 | 5 | toolGroupSummaryKind, |
| 9 | 6 | type ToolGroupSummaryKind, |
| @@ -18,6 +15,7 @@ import { · 8 unmodified lines | ||
| 18 | 15 | formatDuration, |
| 19 | 16 | timelineEntryIsPersistentResourceCard, |
| 20 | 17 | workEntryDisplayIndicatesToolFailure, |
| 18 | + workEntryIndicatesToolSuccess, | |
| 21 | 19 | workEntryIndicatesToolNeutralStatus, |
| 22 | 20 | workLogEntryIsToolLike, |
| 23 | 21 | type TimelineEntry, |
| @@ -337,200 +335,6 @@ export function computeMessageDurationStart( · 313 unmodified lines | ||
| 337 | 335 | return result; |
| 338 | 336 | } |
| 339 | 337 | |
| 340 | −export function normalizeCompactToolLabel(value: string): string { | |
| 341 | − return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | |
| 342 | −} | |
| 343 | − | |
| 344 | −type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update"; | |
| 345 | −export type ToolGroupSummaryKind = | |
| 346 | − | ToolGroupAction | |
| 347 | − | "dynamic-tool" | |
| 348 | − | "agent-tool" | |
| 349 | − | "tone-tool" | |
| 350 | − | "mixed"; | |
| 351 | − | |
| 352 | −export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean { | |
| 353 | − return ( | |
| 354 | − entry.itemType === "file_search" || | |
| 355 | − (entry.itemType === "web_search" && | |
| 356 | − /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))) | |
| 357 | − ); | |
| 358 | −} | |
| 359 | − | |
| 360 | −export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { | |
| 361 | − if ( | |
| 362 | − entry.itemType === "dynamic_tool" && | |
| 363 | − /^read(?:\s+file)?$/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) | |
| 364 | − ) { | |
| 365 | − return "read"; | |
| 366 | − } | |
| 367 | − if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) { | |
| 368 | − return "edit"; | |
| 369 | − } | |
| 370 | − if (entry.itemType === "command_execution" || entry.command) { | |
| 371 | − return "command"; | |
| 372 | − } | |
| 373 | − if (workLogEntryIsLocalCodeSearch(entry)) return "code-search"; | |
| 374 | − if (entry.itemType === "web_search") return "search"; | |
| 375 | − return workLogEntryIsToolLike(entry) ? "other" : "update"; | |
| 376 | −} | |
| 377 | − | |
| 378 | −function toolGroupActionCount( | |
| 379 | − action: ToolGroupAction, | |
| 380 | − entries: ReadonlyArray<WorkLogEntry>, | |
| 381 | −): number { | |
| 382 | − if (action !== "edit") return entries.length; | |
| 383 | − | |
| 384 | − const changedFiles = new Set<string>(); | |
| 385 | − let editsWithoutFileDetails = 0; | |
| 386 | − for (const entry of entries) { | |
| 387 | − if (!entry.changedFiles || entry.changedFiles.length === 0) { | |
| 388 | − editsWithoutFileDetails += 1; | |
| 389 | − continue; | |
| 390 | − } | |
| 391 | − for (const file of entry.changedFiles) changedFiles.add(file); | |
| 392 | − } | |
| 393 | − return changedFiles.size + editsWithoutFileDetails; | |
| 394 | −} | |
| 395 | − | |
| 396 | −function toolGroupActionLabel(action: ToolGroupAction, count: number): string { | |
| 397 | − switch (action) { | |
| 398 | − case "read": | |
| 399 | − return `Read ${count} ${count === 1 ? "file" : "files"}`; | |
| 400 | − case "edit": | |
| 401 | − return `Changed ${count} ${count === 1 ? "file" : "files"}`; | |
| 402 | − case "command": | |
| 403 | − return `Ran ${count} ${count === 1 ? "command" : "commands"}`; | |
| 404 | − case "search": | |
| 405 | − return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; | |
| 406 | − case "code-search": | |
| 407 | − return `Searched code ${count} ${count === 1 ? "time" : "times"}`; | |
| 408 | − case "other": | |
| 409 | − return `Used ${count} ${count === 1 ? "tool" : "tools"}`; | |
| 410 | − case "update": | |
| 411 | − return `Received ${count} ${count === 1 ? "update" : "updates"}`; | |
| 412 | − } | |
| 413 | −} | |
| 414 | − | |
| 415 | −function t3ToolSummaryCall(entry: WorkLogEntry): T3ToolSummaryCall { | |
| 416 | − const item = entry.structuredPayload ?? entry.projectedItem?.item; | |
| 417 | − const data = | |
| 418 | − entry.toolData !== null && typeof entry.toolData === "object" | |
| 419 | − ? (entry.toolData as Record<string, unknown>) | |
| 420 | − : undefined; | |
| 421 | − return { | |
| 422 | − input: item?.type === "dynamic_tool" ? item.input : data?.input, | |
| 423 | − output: item?.type === "dynamic_tool" ? item.output : data?.output, | |
| 424 | − // A status/read result may describe a failed child. Only the call's own lifecycle | |
| 425 | − // and MCP error envelope determine whether the orchestration action failed. | |
| 426 | − outcome: | |
| 427 | − entry.toolLifecycleStatus === "failed" || | |
| 428 | − entry.toolLifecycleStatus === "declined" || | |
| 429 | − entry.tone === "error" | |
| 430 | − ? "failed" | |
| 431 | − : entry.toolLifecycleStatus === "completed" | |
| 432 | − ? "completed" | |
| 433 | − : "unfinished", | |
| 434 | − }; | |
| 435 | −} | |
| 436 | − | |
| 437 | −function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction): number { | |
| 438 | − switch (action) { | |
| 439 | − case "command": | |
| 440 | − case "edit": | |
| 441 | − case "delegate": | |
| 442 | − case "task-cancel": | |
| 443 | − case "thread-create": | |
| 444 | − case "thread-send": | |
| 445 | − case "thread-interrupt": | |
| 446 | − case "schedule-create": | |
| 447 | − case "schedule-update": | |
| 448 | − case "schedule-delete": | |
| 449 | − return 0; | |
| 450 | − case "other": | |
| 451 | − case "update": | |
| 452 | − return 2; | |
| 453 | − default: | |
| 454 | − return 1; | |
| 455 | − } | |
| 456 | −} | |
| 457 | − | |
| 458 | −/** Summarizes at most two action categories; every omitted call still counts in the remainder. */ | |
| 459 | −export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): { | |
| 460 | − summary: string; | |
| 461 | − hasFailure: boolean; | |
| 462 | −} { | |
| 463 | − const groups = new Map< | |
| 464 | − ToolGroupAction | T3McpToolSummaryAction, | |
| 465 | − { | |
| 466 | − action: ToolGroupAction; | |
| 467 | − t3Action: T3McpToolSummaryAction | null; | |
| 468 | − entries: WorkLogEntry[]; | |
| 469 | − } | |
| 470 | − >(); | |
| 471 | − for (const entry of entries) { | |
| 472 | − const item = entry.structuredPayload ?? entry.projectedItem?.item; | |
| 473 | − const t3Action = resolveT3McpToolSummaryAction( | |
| 474 | − (item?.type === "dynamic_tool" ? item.toolName : null) ?? entry.toolTitle ?? entry.label, | |
| 475 | − ); | |
| 476 | − const action = toolGroupAction(entry); | |
| 477 | − const key = t3Action ?? action; | |
| 478 | − const group = groups.get(key); | |
| 479 | − if (group) group.entries.push(entry); | |
| 480 | − else groups.set(key, { action, t3Action, entries: [entry] }); | |
| 481 | − } | |
| 482 | − const summaries = [...groups].map(([action, group], index) => ({ | |
| 483 | − index, | |
| 484 | − count: group.entries.length, | |
| 485 | − priority: summaryActionPriority(action), | |
| 486 | − ...(group.t3Action | |
| 487 | − ? summarizeT3ToolCalls(group.t3Action, group.entries.map(t3ToolSummaryCall)) | |
| 488 | − : { | |
| 489 | − label: toolGroupActionLabel( | |
| 490 | − group.action, | |
| 491 | − toolGroupActionCount(group.action, group.entries), | |
| 492 | − ), | |
| 493 | − failedCount: group.entries.filter(workEntryDisplayIndicatesToolFailure).length, | |
| 494 | − }), | |
| 495 | − })); | |
| 496 | − const selected = summaries | |
| 497 | − .toSorted((a, b) => a.priority - b.priority || a.index - b.index) | |
| 498 | − .slice(0, 2) | |
| 499 | − .sort((a, b) => a.index - b.index); | |
| 500 | − const labels = selected.map(({ label }) => label); | |
| 501 | − const remainingCount = entries.length - selected.reduce((count, group) => count + group.count, 0); | |
| 502 | − if (remainingCount > 0) { | |
| 503 | − labels.push(`Performed ${remainingCount} other ${remainingCount === 1 ? "action" : "actions"}`); | |
| 504 | − } | |
| 505 | − const sentenceLabels = labels.map((label, index) => | |
| 506 | − index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), | |
| 507 | − ); | |
| 508 | − const summary = | |
| 509 | − sentenceLabels.length < 3 | |
| 510 | − ? sentenceLabels.join(" and ") | |
| 511 | − : `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; | |
| 512 | − return { summary, hasFailure: summaries.some((group) => group.failedCount > 0) }; | |
| 513 | −} | |
| 514 | − | |
| 515 | −function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupSummaryKind { | |
| 516 | − const actions = new Set(entries.map(toolGroupAction)); | |
| 517 | − if (actions.size !== 1) return "mixed"; | |
| 518 | − | |
| 519 | − const action = actions.values().next().value!; | |
| 520 | − if (action !== "other") return action; | |
| 521 | − | |
| 522 | − const fallbackKinds = new Set( | |
| 523 | − entries.map((entry): ToolGroupSummaryKind => { | |
| 524 | − if (entry.itemType === "dynamic_tool") return "dynamic-tool"; | |
| 525 | − if (entry.itemType === "subagent") return "agent-tool"; | |
| 526 | − if (entry.tone === "thinking") return "agent-tool"; | |
| 527 | − if (entry.tone === "tool") return "tone-tool"; | |
| 528 | − return "other"; | |
| 529 | − }), | |
| 530 | − ); | |
| 531 | − return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; | |
| 532 | −} | |
| 533 | − | |
| 534 | 338 | function workGroupId(timelineEntryId: string): string { |
| 535 | 339 | return `work-group:${timelineEntryId}`; |
| 536 | 340 | } |
| @@ -911,21 +715,26 @@ export function deriveMessagesTimelineRows(input: { · 374 unmodified lines | ||
| 911 | 715 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => |
| 912 | 716 | workEntryIsActiveTurnActivity(entry.entry), |
| 913 | 717 | ); |
| 914 | − const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; | |
| 718 | + const latestToolKeepsActivityLive = | |
| 719 | + latestRunningToolEntry !== undefined || | |
| 720 | + (latestVisibleToolEntry !== undefined && | |
| 721 | + workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); | |
| 915 | 722 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; |
| 916 | 723 | const activeWorkRow = |
| 917 | − activeWorkAnchor && displayedToolEntry | |
| 724 | + activeWorkAnchor && latestVisibleToolEntry | |
| 918 | 725 | ? (() => { |
| 919 | 726 | const groupId = workGroupId(activeWorkAnchor.id); |
| 920 | 727 | return { |
| 921 | 728 | kind: "work-live" as const, |
| 922 | − id: `work-live:${activeWorkAnchor.id}`, | |
| 729 | + id: latestToolKeepsActivityLive | |
| 730 | + ? LIVE_ACTIVITY_ROW_ID | |
| 731 | + : `work-live:${activeWorkAnchor.id}`, | |
| 923 | 732 | createdAt: activeWorkAnchor.createdAt, |
| 924 | − entry: displayedToolEntry.entry, | |
| 733 | + entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, | |
| 925 | 734 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), |
| 926 | 735 | groupId, |
| 927 | 736 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, |
| 928 | − active: latestRunningToolEntry !== undefined, | |
| 737 | + active: latestToolKeepsActivityLive, | |
| 929 | 738 | }; |
| 930 | 739 | })() |
| 931 | 740 | : null; |
| @@ -939,11 +748,11 @@ export function deriveMessagesTimelineRows(input: { · 7 unmodified lines | ||
| 939 | 748 | createdAt: input.activeTurnStartedAt ?? null, |
| 940 | 749 | }); |
| 941 | 750 | }; |
| 942 | − let hasLiveWorkRow = false; | |
| 751 | + let hasActivityRow = false; | |
| 943 | 752 | const appendActiveWorkRows = () => { |
| 944 | 753 | if (activeWorkRow === null) return; |
| 945 | 754 | nextRows.push(activeWorkRow); |
| 946 | − hasLiveWorkRow ||= activeWorkRow.active; | |
| 755 | + hasActivityRow ||= activeWorkRow.active; | |
| 947 | 756 | if (!activeWorkRow.expanded) return; |
| 948 | 757 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { |
| 949 | 758 | nextRows.push({ |
| @@ -1059,7 +868,6 @@ export function deriveMessagesTimelineRows(input: { · 109 unmodified lines | ||
| 1059 | 868 | const groupId = workGroupId(timelineEntry.id); |
| 1060 | 869 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 1061 | 870 | const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; |
| 1062 | − hasLiveWorkRow = true; | |
| 1063 | 871 | nextRows.push({ |
| 1064 | 872 | kind: "work-live", |
| 1065 | 873 | id: `work-live:${timelineEntry.id}`, |
| @@ -1070,6 +878,7 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines | ||
| 1070 | 878 | expanded, |
| 1071 | 879 | active: true, |
| 1072 | 880 | }); |
| 881 | + hasActivityRow = true; | |
| 1073 | 882 | if (expanded) { |
| 1074 | 883 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
| 1075 | 884 | nextRows.push({ |
| @@ -1192,10 +1001,10 @@ export function deriveMessagesTimelineRows(input: { · 116 unmodified lines | ||
| 1192 | 1001 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 1193 | 1002 | appendWorkingRow(); |
| 1194 | 1003 | } |
| 1195 | − if (input.isWorking && !hasLiveWorkRow) { | |
| 1004 | + if (input.isWorking && !hasActivityRow) { | |
| 1196 | 1005 | nextRows.push({ |
| 1197 | 1006 | kind: "thinking", |
| 1198 | − id: "thinking-indicator-row", | |
| 1007 | + id: LIVE_ACTIVITY_ROW_ID, | |
| 1199 | 1008 | createdAt: input.activeTurnStartedAt ?? null, |
| 1200 | 1009 | }); |
| 1201 | 1010 | } |
| @@ -64,6 +64,7 @@ import { · 63 unmodified lines | ||
| 64 | 64 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 65 | 65 | import { |
| 66 | 66 | BotIcon, |
| 67 | + BrainIcon, | |
| 67 | 68 | CheckIcon, |
| 68 | 69 | ChevronDownIcon, |
| 69 | 70 | ChevronRightIcon, |
| @@ -2092,7 +2093,7 @@ function ThinkingTimelineRow() { · 2022 unmodified lines | ||
| 2092 | 2093 | // Reserve the activity row during setup so the handoff keeps the same height. |
| 2093 | 2094 | return ( |
| 2094 | 2095 | <div className="min-h-7"> |
| 2095 | − {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />} | |
| 2096 | + {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />} | |
| 2096 | 2097 | </div> |
| 2097 | 2098 | ); |
| 2098 | 2099 | } |
| @@ -2778,6 +2779,7 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { · 679 unmodified lines | ||
| 2778 | 2779 | |
| 2779 | 2780 | type WorkEntryIconName = |
| 2780 | 2781 | | "bot" |
| 2782 | + | "brain" | |
| 2781 | 2783 | | "check" |
| 2782 | 2784 | | "circle-alert" |
| 2783 | 2785 | | "eye" |
| @@ -2795,6 +2797,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN · 11 unmodified lines | ||
| 2795 | 2797 | switch (name) { |
| 2796 | 2798 | case "bot": |
| 2797 | 2799 | return <BotIcon className={className} aria-hidden />; |
| 2800 | + case "brain": | |
| 2801 | + return <BrainIcon className={className} aria-hidden />; | |
| 2798 | 2802 | case "check": |
| 2799 | 2803 | return <CheckIcon className={className} aria-hidden />; |
| 2800 | 2804 | case "circle-alert": |
| @@ -2849,8 +2853,8 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { · 48 unmodified lines | ||
| 2849 | 2853 | } |
| 2850 | 2854 | if (tone === "thinking") { |
| 2851 | 2855 | return { |
| 2852 | − iconName: "bot", | |
| 2853 | − className: "text-foreground/92", | |
| 2856 | + iconName: "brain", | |
| 2857 | + className: "text-foreground", | |
| 2854 | 2858 | }; |
| 2855 | 2859 | } |
| 2856 | 2860 | if (tone === "info") { |
| @@ -203,6 +203,7 @@ describe("external chat link context menu", () => { · 202 unmodified lines | ||
| 203 | 203 | "https://example.com/docs?topic=security#links", |
| 204 | 204 | ], |
| 205 | 205 | ["HTTP://EXAMPLE.COM", "http://example.com/"], |
| 206 | + ["//example.com/path", "https://example.com/path"], | |
| 206 | 207 | ])("resolves the safe external web-link href for %s as %s", (href, expected) => { |
| 207 | 208 | expect(resolveExternalWebLinkHref(href)).toBe(expected); |
| 208 | 209 | }); |
| @@ -213,7 +214,6 @@ describe("external chat link context menu", () => { · 4 unmodified lines | ||
| 213 | 214 | "data:text/html,<script>alert(document.domain)</script>", |
| 214 | 215 | "file:///tmp/example.txt", |
| 215 | 216 | "mailto:hello@example.com", |
| 216 | − "//example.com/path", | |
| 217 | 217 | "/relative/path", |
| 218 | 218 | "not a URL", |
| 219 | 219 | "", |
| @@ -2254,31 +2254,8 @@ export function GeneralSettingsPanel() { · 2253 unmodified lines | ||
| 2254 | 2254 | /> |
| 2255 | 2255 | } |
| 2256 | 2256 | /> |
| 2257 | − ) : null | |
| 2258 | − } | |
| 2259 | − control={ | |
| 2260 | − <Switch | |
| 2261 | − checked={settings.sidebarAutoSettleAfterDays !== null} | |
| 2262 | − onCheckedChange={(checked) => | |
| 2263 | − updateSettings({ | |
| 2264 | − sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, | |
| 2265 | − }) | |
| 2266 | − } | |
| 2267 | − aria-label="Auto-settle inactive threads" | |
| 2268 | − /> | |
| 2269 | − } | |
| 2270 | − /> | |
| 2271 | − {settings.sidebarAutoSettleAfterDays !== null ? ( | |
| 2272 | − <SettingsRow | |
| 2273 | − title={searchableSetting("days-before-auto-settle").title} | |
| 2274 | − description="Any new activity un-settles a thread automatically." | |
| 2275 | − control={ | |
| 2276 | − <AutoSettleDaysInput | |
| 2277 | − value={settings.sidebarAutoSettleAfterDays} | |
| 2278 | − onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })} | |
| 2279 | − /> | |
| 2280 | − } | |
| 2281 | − /> | |
| 2257 | + ) : null} | |
| 2258 | + </> | |
| 2282 | 2259 | ) : null} |
| 2283 | 2260 | </SettingsSection> |
| 2284 | 2261 | |
| @@ -147,6 +147,7 @@ describe("searchSettings", () => { · 146 unmodified lines | ||
| 147 | 147 | hasProviderSettingsEnvironment: false, |
| 148 | 148 | canManageLocalBackend: false, |
| 149 | 149 | isWslSettingsRowVisible: false, |
| 150 | + hasThreadAutoSettlement: false, | |
| 150 | 151 | }); |
| 151 | 152 | |
| 152 | 153 | const gatedIds = new Set<string>([ |
| @@ -160,10 +161,30 @@ describe("searchSettings", () => { · 7 unmodified lines | ||
| 160 | 161 | "t3-connect", |
| 161 | 162 | "tailscale-https", |
| 162 | 163 | "wsl-backend", |
| 164 | + "auto-settle-inactive-threads", | |
| 165 | + "auto-settle-merged-threads", | |
| 166 | + "days-before-auto-settle", | |
| 163 | 167 | ]); |
| 164 | 168 | expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); |
| 165 | 169 | }); |
| 166 | 170 | |
| 171 | + it("shows automatic settlement settings when the server supports them", () => { | |
| 172 | + const available = filterAvailableSettingsSearchItems({ | |
| 173 | + hasCloudPublicConfig: false, | |
| 174 | + hasPrimaryEnvironment: false, | |
| 175 | + hasProviderSettingsEnvironment: false, | |
| 176 | + canManageLocalBackend: false, | |
| 177 | + isWslSettingsRowVisible: false, | |
| 178 | + hasThreadAutoSettlement: true, | |
| 179 | + }); | |
| 180 | + | |
| 181 | + expect(searchSettings("auto-settle", available).map((item) => item.id)).toEqual([ | |
| 182 | + "auto-settle-inactive-threads", | |
| 183 | + "auto-settle-merged-threads", | |
| 184 | + "days-before-auto-settle", | |
| 185 | + ]); | |
| 186 | + }); | |
| 187 | + | |
| 167 | 188 | it("keeps catalog result ids unique", () => { |
| 168 | 189 | const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); |
| 169 | 190 | expect(new Set(ids).size).toBe(ids.length); |
| @@ -31,6 +31,7 @@ export interface SettingsSearchItem { · 30 unmodified lines | ||
| 31 | 31 | readonly providerSettingsOnly?: boolean; |
| 32 | 32 | readonly localBackendManagementOnly?: boolean; |
| 33 | 33 | readonly wslAvailableOnly?: boolean; |
| 34 | + readonly requiresThreadAutoSettlement?: boolean; | |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | 37 | export interface SettingsSearchAvailability { |
| @@ -39,6 +40,7 @@ export interface SettingsSearchAvailability { · 2 unmodified lines | ||
| 39 | 40 | readonly hasProviderSettingsEnvironment: boolean; |
| 40 | 41 | readonly canManageLocalBackend: boolean; |
| 41 | 42 | readonly isWslSettingsRowVisible: boolean; |
| 43 | + readonly hasThreadAutoSettlement: boolean; | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | /** |
| @@ -155,12 +157,14 @@ export const SETTINGS_SEARCH_ITEMS = [ · 110 unmodified lines | ||
| 155 | 157 | title: "Auto-settle inactive threads", |
| 156 | 158 | to: "/settings/general", |
| 157 | 159 | searchTerms: ["sidebar inactivity days no activity automatically"], |
| 160 | + requiresThreadAutoSettlement: true, | |
| 158 | 161 | }, |
| 159 | 162 | { |
| 160 | 163 | id: "auto-settle-merged-threads", |
| 161 | 164 | title: "Auto-settle merged threads", |
| 162 | 165 | to: "/settings/general", |
| 163 | 166 | searchTerms: ["pull request merge closed automatically sidebar"], |
| 167 | + requiresThreadAutoSettlement: true, | |
| 164 | 168 | }, |
| 165 | 169 | { |
| 166 | 170 | id: "days-before-auto-settle", |
| @@ -168,6 +172,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line | ||
| 168 | 172 | to: "/settings/general", |
| 169 | 173 | targetId: "auto-settle-inactive-threads", |
| 170 | 174 | searchTerms: ["thread timeout activity sidebar"], |
| 175 | + requiresThreadAutoSettlement: true, | |
| 171 | 176 | }, |
| 172 | 177 | { |
| 173 | 178 | id: "time-format", |
| @@ -250,7 +255,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 76 unmodified lines | ||
| 250 | 255 | id: "quit-confirmation", |
| 251 | 256 | title: "Quit shortcut", |
| 252 | 257 | to: "/settings/general", |
| 253 | − searchTerms: ["confirmation shortcut desktop app exit"], | |
| 258 | + searchTerms: ["confirmation desktop app exit direct hold double click press twice"], | |
| 254 | 259 | desktopOnly: true, |
| 255 | 260 | }, |
| 256 | 261 | { |
| @@ -491,7 +496,8 @@ export function filterAvailableSettingsSearchItems( · 234 unmodified lines | ||
| 491 | 496 | (!item.primaryOnly || availability.hasPrimaryEnvironment) && |
| 492 | 497 | (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && |
| 493 | 498 | (!item.localBackendManagementOnly || availability.canManageLocalBackend) && |
| 494 | − (!item.wslAvailableOnly || availability.isWslSettingsRowVisible), | |
| 499 | + (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && | |
| 500 | + (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement), | |
| 495 | 501 | ); |
| 496 | 502 | } |
| 497 | 503 | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { useMemo } from "react"; |
| 2 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | 3 | import { AuthAccessWriteScope } from "@t3tools/contracts"; |
| 3 | 4 | |
| 4 | 5 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| @@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; · 2 unmodified lines | ||
| 7 | 8 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 8 | 9 | import { useEnvironmentQuery } from "~/state/query"; |
| 9 | 10 | import { usePrimarySessionState } from "~/environments/primary"; |
| 11 | +import { primaryServerConfigAtom } from "~/state/server"; | |
| 10 | 12 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; |
| 11 | 13 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; |
| 12 | 14 | import { filterAvailableSettingsSearchItems } from "./settingsSearch"; |
| @@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() { · 2 unmodified lines | ||
| 15 | 17 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 16 | 18 | const { environments } = useEnvironments(); |
| 17 | 19 | const primarySessionState = usePrimarySessionState(); |
| 20 | + const primaryServerConfig = useAtomValue(primaryServerConfigAtom); | |
| 18 | 21 | const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); |
| 19 | 22 | const canManageLocalBackend = |
| 20 | 23 | isElectron || |
| @@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() { · 17 unmodified lines | ||
| 38 | 41 | state: desktopWsl.data, |
| 39 | 42 | error: desktopWsl.error, |
| 40 | 43 | }), |
| 44 | + hasThreadAutoSettlement: | |
| 45 | + primaryServerConfig?.environment.capabilities.threadAutoSettlement === true, | |
| 41 | 46 | }), |
| 42 | − [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId], | |
| 47 | + [ | |
| 48 | + canManageLocalBackend, | |
| 49 | + desktopWsl.data, | |
| 50 | + desktopWsl.error, | |
| 51 | + environments, | |
| 52 | + primaryEnvironmentId, | |
| 53 | + primaryServerConfig, | |
| 54 | + ], | |
| 43 | 55 | ); |
| 44 | 56 | } |
| @@ -45,6 +45,7 @@ import { · 44 unmodified lines | ||
| 45 | 45 | type TerminalContextDraft, |
| 46 | 46 | ensureInlineTerminalContextPlaceholders, |
| 47 | 47 | normalizeTerminalContextText, |
| 48 | + stripInlineTerminalContextPlaceholders, | |
| 48 | 49 | } from "./lib/terminalContext"; |
| 49 | 50 | import { |
| 50 | 51 | type ElementContextDraft, |
| @@ -660,6 +661,13 @@ interface ComposerDraftStoreState { · 609 unmodified lines | ||
| 660 | 661 | * prompt stash. Session-bound context stays in the source draft. |
| 661 | 662 | */ |
| 662 | 663 | clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; |
| 664 | + /** | |
| 665 | + * Moves prompt text and transferable attachments into another composer. | |
| 666 | + * Attachments over the destination limit and uploaded files that belong to | |
| 667 | + * another environment stay in the source draft. Terminal and element | |
| 668 | + * context, preview annotations, and review comments also stay in the source. | |
| 669 | + */ | |
| 670 | + moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; | |
| 663 | 671 | } |
| 664 | 672 | |
| 665 | 673 | export interface EffectiveComposerModelState { |
| @@ -3900,6 +3908,117 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 3234 unmodified lines | ||
| 3900 | 3908 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 3901 | 3909 | }); |
| 3902 | 3910 | }, |
| 3911 | + moveComposerPromptAndImages: (from, to) => { | |
| 3912 | + const fromKey = resolveComposerDraftKey(get(), from) ?? ""; | |
| 3913 | + const toKey = resolveComposerDraftKey(get(), to) ?? ""; | |
| 3914 | + if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) { | |
| 3915 | + return; | |
| 3916 | + } | |
| 3917 | + set((state) => { | |
| 3918 | + const source = state.draftsByThreadKey[fromKey]; | |
| 3919 | + if (!source) { | |
| 3920 | + return state; | |
| 3921 | + } | |
| 3922 | + const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft(); | |
| 3923 | + const destinationEnvironmentId = | |
| 3924 | + typeof to === "string" | |
| 3925 | + ? (state.draftThreadsByThreadKey[toKey]?.environmentId ?? | |
| 3926 | + parseScopedThreadKey(toKey)?.environmentId ?? | |
| 3927 | + null) | |
| 3928 | + : to.environmentId; | |
| 3929 | + // A file the destination already holds (same id, or same | |
| 3930 | + // metadata key) stays behind instead of duplicating there. | |
| 3931 | + const destinationFileIds = new Set(destination.files.map((file) => file.id)); | |
| 3932 | + const destinationFileKeys = new Set(destination.files.map(composerFileDedupKey)); | |
| 3933 | + const transferableFiles = source.files.filter( | |
| 3934 | + (file) => | |
| 3935 | + (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) && | |
| 3936 | + !destinationFileIds.has(file.id) && | |
| 3937 | + !destinationFileKeys.has(composerFileDedupKey(file)), | |
| 3938 | + ); | |
| 3939 | + const remainingAttachmentSlots = Math.max( | |
| 3940 | + 0, | |
| 3941 | + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - | |
| 3942 | + destination.images.length - | |
| 3943 | + destination.files.length, | |
| 3944 | + ); | |
| 3945 | + const movedImages = source.images.slice(0, remainingAttachmentSlots); | |
| 3946 | + const movedImageIds = new Set(movedImages.map((image) => image.id)); | |
| 3947 | + const retainedImages = source.images.filter((image) => !movedImageIds.has(image.id)); | |
| 3948 | + const movedFiles = transferableFiles | |
| 3949 | + .slice(0, remainingAttachmentSlots - movedImages.length) | |
| 3950 | + .map((file) => { | |
| 3951 | + // A byte-backed file moving across environments re-uploads at | |
| 3952 | + // the destination. Keeping the source-environment upload id | |
| 3953 | + // would mark it uploaded after a reload with no local bytes | |
| 3954 | + // and no valid upload anywhere the destination can reach. The | |
| 3955 | + // upload queue keeps the source upload as fallback, then | |
| 3956 | + // deletes it after the destination upload succeeds. Abandoned | |
| 3957 | + // uploads still expire through the server sweep. | |
| 3958 | + if ( | |
| 3959 | + file.file === null || | |
| 3960 | + file.uploadEnvironmentId === undefined || | |
| 3961 | + file.uploadEnvironmentId === destinationEnvironmentId | |
| 3962 | + ) { | |
| 3963 | + return file; | |
| 3964 | + } | |
| 3965 | + const { uploadedAttachmentId: _a, uploadEnvironmentId: _e, ...rest } = file; | |
| 3966 | + return rest; | |
| 3967 | + }); | |
| 3968 | + const movedFileIds = new Set(movedFiles.map((file) => file.id)); | |
| 3969 | + const retainedFiles = source.files.filter((file) => !movedFileIds.has(file.id)); | |
| 3970 | + // Inline placeholders reference the source's terminal contexts, | |
| 3971 | + // which stay behind; re-anchor the moved prompt to whatever | |
| 3972 | + // contexts the destination already holds. | |
| 3973 | + const movedPrompt = ensureInlineTerminalContextPlaceholders( | |
| 3974 | + stripInlineTerminalContextPlaceholders(source.prompt), | |
| 3975 | + destination.terminalContexts.length, | |
| 3976 | + ); | |
| 3977 | + const nextDestination: ComposerThreadDraftState = { | |
| 3978 | + ...destination, | |
| 3979 | + prompt: movedPrompt, | |
| 3980 | + images: [...destination.images, ...movedImages], | |
| 3981 | + files: [...destination.files, ...movedFiles], | |
| 3982 | + nonPersistedImageIds: [ | |
| 3983 | + ...destination.nonPersistedImageIds, | |
| 3984 | + ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)), | |
| 3985 | + ], | |
| 3986 | + persistedAttachments: [ | |
| 3987 | + ...destination.persistedAttachments, | |
| 3988 | + ...source.persistedAttachments.filter((attachment) => | |
| 3989 | + movedImageIds.has(attachment.id), | |
| 3990 | + ), | |
| 3991 | + ], | |
| 3992 | + }; | |
| 3993 | + // Same clearing shape as clearComposerPromptAndImages, but the | |
| 3994 | + // preview URLs are NOT revoked: the images moved and their blobs | |
| 3995 | + // are still referenced from the destination. | |
| 3996 | + const nextSource: ComposerThreadDraftState = { | |
| 3997 | + ...source, | |
| 3998 | + prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length), | |
| 3999 | + images: retainedImages, | |
| 4000 | + files: retainedFiles, | |
| 4001 | + nonPersistedImageIds: source.nonPersistedImageIds.filter( | |
| 4002 | + (imageId) => !movedImageIds.has(imageId), | |
| 4003 | + ), | |
| 4004 | + persistedAttachments: source.persistedAttachments.filter( | |
| 4005 | + (attachment) => !movedImageIds.has(attachment.id), | |
| 4006 | + ), | |
| 4007 | + }; | |
| 4008 | + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | |
| 4009 | + if (shouldRemoveDraft(nextSource)) { | |
| 4010 | + delete nextDraftsByThreadKey[fromKey]; | |
| 4011 | + } else { | |
| 4012 | + nextDraftsByThreadKey[fromKey] = nextSource; | |
| 4013 | + } | |
| 4014 | + if (shouldRemoveDraft(nextDestination)) { | |
| 4015 | + delete nextDraftsByThreadKey[toKey]; | |
| 4016 | + } else { | |
| 4017 | + nextDraftsByThreadKey[toKey] = nextDestination; | |
| 4018 | + } | |
| 4019 | + return { draftsByThreadKey: nextDraftsByThreadKey }; | |
| 4020 | + }); | |
| 4021 | + }, | |
| 3903 | 4022 | }; |
| 3904 | 4023 | }, |
| 3905 | 4024 | { |
| @@ -23,7 +23,7 @@ import { · 22 unmodified lines | ||
| 23 | 23 | selectProjectGroupingSettings, |
| 24 | 24 | } from "../logicalProject"; |
| 25 | 25 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; |
| 26 | −import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; | |
| 26 | +import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities"; | |
| 27 | 27 | import { |
| 28 | 28 | hasExplicitComposerModelSelection, |
| 29 | 29 | resolveNewDraftStartFromOrigin, |
| @@ -0,0 +1,587 @@ | ||
| 1 | +import { | |
| 2 | + ProjectId, | |
| 3 | + ProviderInstanceId, | |
| 4 | + ThreadId, | |
| 5 | + TurnId, | |
| 6 | + type OrchestrationThreadShell, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import { describe, expect, it } from "vite-plus/test"; | |
| 9 | + | |
| 10 | +import { | |
| 11 | + canSettle, | |
| 12 | + changeRequestAutoSettles, | |
| 13 | + effectiveSettled, | |
| 14 | + hasQueuedTurnStart, | |
| 15 | + threadLastActivityAt, | |
| 16 | + type ChangeRequestStateLike, | |
| 17 | +} from "./threadSettled.ts"; | |
| 18 | + | |
| 19 | +const NOW = "2026-04-10T00:00:00.000Z"; | |
| 20 | +const FRESH = "2026-04-09T00:00:00.000Z"; | |
| 21 | +const STALE = "2026-04-06T23:59:59.999Z"; | |
| 22 | + | |
| 23 | +describe("changeRequestAutoSettles", () => { | |
| 24 | + it.each([ | |
| 25 | + ["open", true, false], | |
| 26 | + ["merged", true, true], | |
| 27 | + ["merged", false, false], | |
| 28 | + ["closed", false, true], | |
| 29 | + [null, false, false], | |
| 30 | + ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => { | |
| 31 | + expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe( | |
| 32 | + expected, | |
| 33 | + ); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z"; | |
| 37 | + const idleThread = { | |
| 38 | + createdAt: THREAD_CREATED_AT, | |
| 39 | + latestUserMessageAt: null, | |
| 40 | + latestTurn: null, | |
| 41 | + }; | |
| 42 | + | |
| 43 | + it("ignores a terminal change request last touched before the thread existed", () => { | |
| 44 | + for (const state of ["merged", "closed"] as const) { | |
| 45 | + expect( | |
| 46 | + changeRequestAutoSettles( | |
| 47 | + { state, updatedAt: "2026-03-31T23:59:59.999Z" }, | |
| 48 | + { thread: idleThread }, | |
| 49 | + ), | |
| 50 | + ).toBe(false); | |
| 51 | + } | |
| 52 | + }); | |
| 53 | + | |
| 54 | + it("settles on a terminal change request touched at or after the thread's latest event", () => { | |
| 55 | + for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) { | |
| 56 | + expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe( | |
| 57 | + true, | |
| 58 | + ); | |
| 59 | + } | |
| 60 | + }); | |
| 61 | + | |
| 62 | + it("never re-settles a thread revived after the merge", () => { | |
| 63 | + // Settling on a merge happens once: a user message newer than the PR's | |
| 64 | + // last activity means the conversation outlived the PR. | |
| 65 | + const revived = { | |
| 66 | + createdAt: THREAD_CREATED_AT, | |
| 67 | + latestUserMessageAt: "2026-04-05T00:00:00.000Z", | |
| 68 | + latestTurn: null, | |
| 69 | + }; | |
| 70 | + expect( | |
| 71 | + changeRequestAutoSettles( | |
| 72 | + { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" }, | |
| 73 | + { thread: revived }, | |
| 74 | + ), | |
| 75 | + ).toBe(false); | |
| 76 | + // A merge landing after the revival still settles. | |
| 77 | + expect( | |
| 78 | + changeRequestAutoSettles( | |
| 79 | + { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" }, | |
| 80 | + { thread: revived }, | |
| 81 | + ), | |
| 82 | + ).toBe(true); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it("still settles when the merge lands during an in-flight turn", () => { | |
| 86 | + // Anchor is user-initiated activity only: the agent finishing a turn | |
| 87 | + // after the merge must not block the settle the merge earned. | |
| 88 | + const midTurnMerge = { | |
| 89 | + createdAt: THREAD_CREATED_AT, | |
| 90 | + latestUserMessageAt: "2026-04-02T00:00:00.000Z", | |
| 91 | + latestTurn: { | |
| 92 | + turnId: TurnId.make("turn-mid"), | |
| 93 | + state: "completed" as const, | |
| 94 | + requestedAt: "2026-04-02T00:00:00.000Z", | |
| 95 | + startedAt: "2026-04-02T00:00:05.000Z", | |
| 96 | + completedAt: "2026-04-02T00:20:00.000Z", | |
| 97 | + assistantMessageId: null, | |
| 98 | + }, | |
| 99 | + }; | |
| 100 | + expect( | |
| 101 | + changeRequestAutoSettles( | |
| 102 | + { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" }, | |
| 103 | + { thread: midTurnMerge }, | |
| 104 | + ), | |
| 105 | + ).toBe(true); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it("falls back to settling when either timestamp is missing or malformed", () => { | |
| 109 | + expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true); | |
| 110 | + expect( | |
| 111 | + changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }), | |
| 112 | + ).toBe(true); | |
| 113 | + expect( | |
| 114 | + changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}), | |
| 115 | + ).toBe(true); | |
| 116 | + expect( | |
| 117 | + changeRequestAutoSettles( | |
| 118 | + { state: "merged", updatedAt: "not-a-date" }, | |
| 119 | + { thread: idleThread }, | |
| 120 | + ), | |
| 121 | + ).toBe(true); | |
| 122 | + }); | |
| 123 | +}); | |
| 124 | + | |
| 125 | +function makeShell(input: { | |
| 126 | + readonly settledOverride?: "settled" | "active" | null; | |
| 127 | + readonly activityAt: string | null; | |
| 128 | + readonly sessionStatus?: "starting" | "running"; | |
| 129 | + readonly pending?: "approval" | "user-input"; | |
| 130 | +}): OrchestrationThreadShell { | |
| 131 | + const threadId = ThreadId.make("thread-1"); | |
| 132 | + return { | |
| 133 | + id: threadId, | |
| 134 | + projectId: ProjectId.make("project-1"), | |
| 135 | + title: "Thread", | |
| 136 | + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 137 | + runtimeMode: "full-access", | |
| 138 | + interactionMode: "default", | |
| 139 | + branch: null, | |
| 140 | + worktreePath: null, | |
| 141 | + latestTurn: | |
| 142 | + input.activityAt === null | |
| 143 | + ? null | |
| 144 | + : { | |
| 145 | + turnId: TurnId.make("turn-1"), | |
| 146 | + state: "completed", | |
| 147 | + requestedAt: input.activityAt, | |
| 148 | + startedAt: null, | |
| 149 | + completedAt: null, | |
| 150 | + assistantMessageId: null, | |
| 151 | + }, | |
| 152 | + createdAt: "2026-04-01T00:00:00.000Z", | |
| 153 | + updatedAt: NOW, | |
| 154 | + archivedAt: null, | |
| 155 | + settledOverride: input.settledOverride ?? null, | |
| 156 | + settledAt: input.settledOverride === "settled" ? NOW : null, | |
| 157 | + session: | |
| 158 | + input.sessionStatus === undefined | |
| 159 | + ? null | |
| 160 | + : { | |
| 161 | + threadId, | |
| 162 | + status: input.sessionStatus, | |
| 163 | + providerName: "Codex", | |
| 164 | + runtimeMode: "full-access", | |
| 165 | + activeTurnId: null, | |
| 166 | + lastError: null, | |
| 167 | + updatedAt: NOW, | |
| 168 | + }, | |
| 169 | + latestUserMessageAt: null, | |
| 170 | + hasPendingApprovals: input.pending === "approval", | |
| 171 | + hasPendingUserInput: input.pending === "user-input", | |
| 172 | + hasActionableProposedPlan: false, | |
| 173 | + }; | |
| 174 | +} | |
| 175 | + | |
| 176 | +describe("threadLastActivityAt", () => { | |
| 177 | + it("returns the latest real user or turn activity and ignores thread/session updates", () => { | |
| 178 | + const shell = makeShell({ activityAt: null, sessionStatus: "running" }); | |
| 179 | + const withActivity: OrchestrationThreadShell = { | |
| 180 | + ...shell, | |
| 181 | + latestUserMessageAt: "2026-04-04T00:00:00.000Z", | |
| 182 | + latestTurn: { | |
| 183 | + turnId: TurnId.make("turn-1"), | |
| 184 | + state: "completed", | |
| 185 | + requestedAt: "2026-04-03T00:00:00.000Z", | |
| 186 | + startedAt: "2026-04-05T00:00:00.000Z", | |
| 187 | + completedAt: "2026-04-06T00:00:00.000Z", | |
| 188 | + assistantMessageId: null, | |
| 189 | + }, | |
| 190 | + }; | |
| 191 | + | |
| 192 | + expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); | |
| 193 | + expect(threadLastActivityAt(shell)).toBeNull(); | |
| 194 | + }); | |
| 195 | +}); | |
| 196 | + | |
| 197 | +describe("effectiveSettled", () => { | |
| 198 | + const overrideCases = [null, "settled", "active"] as const; | |
| 199 | + const changeRequestStates = [undefined, "open", "merged"] as const; | |
| 200 | + const inactivityCases = [ | |
| 201 | + ["fresh", FRESH], | |
| 202 | + ["stale", STALE], | |
| 203 | + ["no-activity", null], | |
| 204 | + ] as const; | |
| 205 | + const runningCases = [false, true] as const; | |
| 206 | + const pendingCases = [undefined, "approval", "user-input"] as const; | |
| 207 | + const truthTable = overrideCases.flatMap((settledOverride) => | |
| 208 | + changeRequestStates.flatMap((changeRequestState) => | |
| 209 | + inactivityCases.flatMap(([inactivity, activityAt]) => | |
| 210 | + runningCases.flatMap((running) => | |
| 211 | + pendingCases.map((pending) => ({ | |
| 212 | + settledOverride, | |
| 213 | + changeRequestState, | |
| 214 | + inactivity, | |
| 215 | + activityAt, | |
| 216 | + running, | |
| 217 | + pending, | |
| 218 | + // Settled iff nothing blocks (pending work / live session) AND | |
| 219 | + // the override says settled, or (with no override) a merged PR | |
| 220 | + // or staleness auto-settles. The "active" pin suppresses both | |
| 221 | + // auto signals, and an open PR suppresses the inactivity path: | |
| 222 | + // a thread with a PR out for review is never done, however quiet. | |
| 223 | + expected: | |
| 224 | + pending === undefined && | |
| 225 | + !running && | |
| 226 | + (settledOverride === "settled" || | |
| 227 | + (settledOverride === null && | |
| 228 | + (changeRequestState === "merged" || | |
| 229 | + (changeRequestState !== "open" && inactivity === "stale")))), | |
| 230 | + })), | |
| 231 | + ), | |
| 232 | + ), | |
| 233 | + ), | |
| 234 | + ); | |
| 235 | + | |
| 236 | + it.each(truthTable)( | |
| 237 | + "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", | |
| 238 | + ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { | |
| 239 | + const shell = makeShell({ | |
| 240 | + settledOverride, | |
| 241 | + activityAt, | |
| 242 | + ...(running ? { sessionStatus: "running" as const } : {}), | |
| 243 | + ...(pending === undefined ? {} : { pending }), | |
| 244 | + }); | |
| 245 | + const changeRequestOptions = | |
| 246 | + changeRequestState === undefined | |
| 247 | + ? {} | |
| 248 | + : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } }; | |
| 249 | + | |
| 250 | + expect( | |
| 251 | + effectiveSettled(shell, { | |
| 252 | + now: NOW, | |
| 253 | + autoSettleAfterDays: 3, | |
| 254 | + ...changeRequestOptions, | |
| 255 | + }), | |
| 256 | + ).toBe(expected); | |
| 257 | + }, | |
| 258 | + ); | |
| 259 | + | |
| 260 | + it("treats closed change requests like merged ones", () => { | |
| 261 | + const shell = makeShell({ activityAt: null }); | |
| 262 | + expect( | |
| 263 | + effectiveSettled(shell, { | |
| 264 | + now: NOW, | |
| 265 | + autoSettleAfterDays: null, | |
| 266 | + changeRequest: { state: "closed" }, | |
| 267 | + }), | |
| 268 | + ).toBe(true); | |
| 269 | + }); | |
| 270 | + | |
| 271 | + it("settles immediately when a change request merges or closes", () => { | |
| 272 | + const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); | |
| 273 | + for (const changeRequestState of ["merged", "closed"] as const) { | |
| 274 | + expect( | |
| 275 | + effectiveSettled(recentlyActive, { | |
| 276 | + now: NOW, | |
| 277 | + autoSettleAfterDays: null, | |
| 278 | + changeRequest: { state: changeRequestState }, | |
| 279 | + }), | |
| 280 | + ).toBe(true); | |
| 281 | + } | |
| 282 | + }); | |
| 283 | + | |
| 284 | + it("ignores a change request that merged before the thread's latest event", () => { | |
| 285 | + // A new thread started at a worktree root inherits the branch's old | |
| 286 | + // merged PR, and a revived thread outlives its merge; neither settles | |
| 287 | + // the live conversation. | |
| 288 | + const fresh = makeShell({ activityAt: FRESH }); | |
| 289 | + for (const state of ["merged", "closed"] as const) { | |
| 290 | + expect( | |
| 291 | + effectiveSettled(fresh, { | |
| 292 | + now: NOW, | |
| 293 | + autoSettleAfterDays: null, | |
| 294 | + changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" }, | |
| 295 | + }), | |
| 296 | + ).toBe(false); | |
| 297 | + } | |
| 298 | + // A merge during the thread's life still settles it. | |
| 299 | + expect( | |
| 300 | + effectiveSettled(fresh, { | |
| 301 | + now: NOW, | |
| 302 | + autoSettleAfterDays: null, | |
| 303 | + changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" }, | |
| 304 | + }), | |
| 305 | + ).toBe(true); | |
| 306 | + }); | |
| 307 | + | |
| 308 | + it("can keep a merged change request active", () => { | |
| 309 | + const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); | |
| 310 | + expect( | |
| 311 | + effectiveSettled(recentlyActive, { | |
| 312 | + now: NOW, | |
| 313 | + autoSettleAfterDays: null, | |
| 314 | + autoSettleOnMerge: false, | |
| 315 | + changeRequest: { state: "merged" }, | |
| 316 | + }), | |
| 317 | + ).toBe(false); | |
| 318 | + | |
| 319 | + expect( | |
| 320 | + effectiveSettled(recentlyActive, { | |
| 321 | + now: NOW, | |
| 322 | + autoSettleAfterDays: null, | |
| 323 | + autoSettleOnMerge: false, | |
| 324 | + changeRequest: { state: "closed" }, | |
| 325 | + }), | |
| 326 | + ).toBe(true); | |
| 327 | + }); | |
| 328 | + | |
| 329 | + it("never auto-settles a stale thread with an open change request", () => { | |
| 330 | + const stale = makeShell({ activityAt: STALE }); | |
| 331 | + expect( | |
| 332 | + effectiveSettled(stale, { | |
| 333 | + now: NOW, | |
| 334 | + autoSettleAfterDays: 3, | |
| 335 | + changeRequest: { state: "open" }, | |
| 336 | + }), | |
| 337 | + ).toBe(false); | |
| 338 | + // An explicit user settle still wins: open PR only blocks the auto path. | |
| 339 | + const settled = makeShell({ settledOverride: "settled", activityAt: STALE }); | |
| 340 | + expect( | |
| 341 | + effectiveSettled(settled, { | |
| 342 | + now: NOW, | |
| 343 | + autoSettleAfterDays: 3, | |
| 344 | + changeRequest: { state: "open" }, | |
| 345 | + }), | |
| 346 | + ).toBe(true); | |
| 347 | + }); | |
| 348 | + | |
| 349 | + it("keeps an explicitly un-settled merged-PR thread active", () => { | |
| 350 | + const shell = makeShell({ | |
| 351 | + settledOverride: "active", | |
| 352 | + activityAt: "2026-04-09T23:59:59.999Z", | |
| 353 | + }); | |
| 354 | + expect( | |
| 355 | + effectiveSettled(shell, { | |
| 356 | + now: NOW, | |
| 357 | + autoSettleAfterDays: null, | |
| 358 | + changeRequest: { state: "merged" }, | |
| 359 | + }), | |
| 360 | + ).toBe(false); | |
| 361 | + }); | |
| 362 | + | |
| 363 | + it("never settles a starting session, even with a settled override", () => { | |
| 364 | + const shell = makeShell({ | |
| 365 | + settledOverride: "settled", | |
| 366 | + activityAt: STALE, | |
| 367 | + sessionStatus: "starting", | |
| 368 | + }); | |
| 369 | + expect( | |
| 370 | + effectiveSettled(shell, { | |
| 371 | + now: NOW, | |
| 372 | + autoSettleAfterDays: 3, | |
| 373 | + changeRequest: { state: "merged" }, | |
| 374 | + }), | |
| 375 | + ).toBe(false); | |
| 376 | + }); | |
| 377 | + | |
| 378 | + it("keeps a new turn active from queued through starting and running", () => { | |
| 379 | + const requestedAt = "2026-04-09T12:00:00.000Z"; | |
| 380 | + const transitionNow = "2026-04-09T12:00:30.000Z"; | |
| 381 | + const base = makeShell({ | |
| 382 | + settledOverride: null, | |
| 383 | + activityAt: STALE, | |
| 384 | + }); | |
| 385 | + const queued: OrchestrationThreadShell = { | |
| 386 | + ...base, | |
| 387 | + latestUserMessageAt: requestedAt, | |
| 388 | + latestTurn: null, | |
| 389 | + session: null, | |
| 390 | + }; | |
| 391 | + const starting: OrchestrationThreadShell = { | |
| 392 | + ...queued, | |
| 393 | + session: { | |
| 394 | + threadId: queued.id, | |
| 395 | + status: "starting", | |
| 396 | + providerName: "Codex", | |
| 397 | + runtimeMode: "full-access", | |
| 398 | + activeTurnId: null, | |
| 399 | + lastError: null, | |
| 400 | + updatedAt: requestedAt, | |
| 401 | + }, | |
| 402 | + }; | |
| 403 | + const running: OrchestrationThreadShell = { | |
| 404 | + ...starting, | |
| 405 | + session: { | |
| 406 | + ...starting.session!, | |
| 407 | + status: "running", | |
| 408 | + activeTurnId: TurnId.make("turn-new"), | |
| 409 | + }, | |
| 410 | + }; | |
| 411 | + | |
| 412 | + for (const shell of [queued, starting, running]) { | |
| 413 | + expect( | |
| 414 | + effectiveSettled(shell, { | |
| 415 | + now: transitionNow, | |
| 416 | + autoSettleAfterDays: 3, | |
| 417 | + changeRequest: { state: "merged" }, | |
| 418 | + }), | |
| 419 | + ).toBe(false); | |
| 420 | + } | |
| 421 | + }); | |
| 422 | + | |
| 423 | + it("uses a strict inactivity boundary and honors a null threshold", () => { | |
| 424 | + const boundary = makeShell({ | |
| 425 | + activityAt: "2026-04-07T00:00:00.000Z", | |
| 426 | + }); | |
| 427 | + const stale = makeShell({ activityAt: STALE }); | |
| 428 | + | |
| 429 | + expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); | |
| 430 | + expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); | |
| 431 | + }); | |
| 432 | +}); | |
| 433 | + | |
| 434 | +describe("hasQueuedTurnStart", () => { | |
| 435 | + const QUEUED_AT = "2026-04-09T12:00:00.000Z"; | |
| 436 | + // Within the adoption grace window of the queued message. | |
| 437 | + const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" }; | |
| 438 | + | |
| 439 | + it("flags a user message no turn has picked up, within the grace window", () => { | |
| 440 | + const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; | |
| 441 | + expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true); | |
| 442 | + | |
| 443 | + const staleTurn = { | |
| 444 | + ...makeShell({ activityAt: FRESH }), | |
| 445 | + latestUserMessageAt: QUEUED_AT, | |
| 446 | + }; | |
| 447 | + expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true); | |
| 448 | + }); | |
| 449 | + | |
| 450 | + it("expires after the grace window: an unadopted message is a failed start, not queued work", () => { | |
| 451 | + const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; | |
| 452 | + expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false); | |
| 453 | + // Historical shells (e.g. from servers that never carried latestTurn) | |
| 454 | + // must never read as queued. | |
| 455 | + expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false); | |
| 456 | + }); | |
| 457 | + | |
| 458 | + it("clears once a turn adopts the message or the start fails", () => { | |
| 459 | + const adopted = { | |
| 460 | + ...makeShell({ activityAt: QUEUED_AT }), | |
| 461 | + latestUserMessageAt: QUEUED_AT, | |
| 462 | + }; | |
| 463 | + expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false); | |
| 464 | + | |
| 465 | + const failed = makeShell({ activityAt: FRESH }); | |
| 466 | + const failedShell = { | |
| 467 | + ...failed, | |
| 468 | + latestUserMessageAt: QUEUED_AT, | |
| 469 | + session: { | |
| 470 | + threadId: failed.id, | |
| 471 | + status: "error" as const, | |
| 472 | + providerName: "Codex", | |
| 473 | + runtimeMode: "full-access" as const, | |
| 474 | + activeTurnId: null, | |
| 475 | + lastError: "boom", | |
| 476 | + updatedAt: NOW, | |
| 477 | + }, | |
| 478 | + }; | |
| 479 | + expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false); | |
| 480 | + }); | |
| 481 | + | |
| 482 | + it("is quiet without user messages", () => { | |
| 483 | + expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false); | |
| 484 | + }); | |
| 485 | + | |
| 486 | + it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => { | |
| 487 | + // Message timestamps originate on other devices; a clock an hour ahead | |
| 488 | + // must not hold the queued state for the whole skew. | |
| 489 | + const skewed = { | |
| 490 | + latestUserMessageAt: "2026-04-09T13:00:00.000Z", | |
| 491 | + latestTurn: null, | |
| 492 | + session: null, | |
| 493 | + }; | |
| 494 | + expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false); | |
| 495 | + // A small negative age (within the grace window) still reads as queued. | |
| 496 | + const slightlyAhead = { | |
| 497 | + latestUserMessageAt: "2026-04-09T12:00:30.000Z", | |
| 498 | + latestTurn: null, | |
| 499 | + session: null, | |
| 500 | + }; | |
| 501 | + expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true); | |
| 502 | + }); | |
| 503 | +}); | |
| 504 | + | |
| 505 | +describe("canSettle", () => { | |
| 506 | + it("blocks every state effectiveSettled refuses to classify as settled", () => { | |
| 507 | + expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true); | |
| 508 | + expect( | |
| 509 | + canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }), | |
| 510 | + ).toBe(false); | |
| 511 | + expect( | |
| 512 | + canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }), | |
| 513 | + ).toBe(false); | |
| 514 | + expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe( | |
| 515 | + false, | |
| 516 | + ); | |
| 517 | + expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe( | |
| 518 | + false, | |
| 519 | + ); | |
| 520 | + }); | |
| 521 | + | |
| 522 | + it("blocks settling a queued turn start, only within the grace window", () => { | |
| 523 | + const queued = { | |
| 524 | + ...makeShell({ activityAt: FRESH }), | |
| 525 | + latestUserMessageAt: "2026-04-09T12:00:00.000Z", | |
| 526 | + }; | |
| 527 | + const justAfter = "2026-04-09T12:00:30.000Z"; | |
| 528 | + expect(canSettle(queued, { now: justAfter })).toBe(false); | |
| 529 | + // effectiveSettled must agree: queued work never auto-settles either, | |
| 530 | + // even with a merged PR. | |
| 531 | + expect( | |
| 532 | + effectiveSettled(queued, { | |
| 533 | + now: justAfter, | |
| 534 | + autoSettleAfterDays: 3, | |
| 535 | + changeRequest: { state: "merged" }, | |
| 536 | + }), | |
| 537 | + ).toBe(false); | |
| 538 | + // Past the window the message is a failed/stale start: settleable again. | |
| 539 | + expect(canSettle(queued, { now: NOW })).toBe(true); | |
| 540 | + }); | |
| 541 | + | |
| 542 | + it("lets a server-accepted settle overrule the clock-derived queued blocker", () => { | |
| 543 | + // The settle action ran with wall-clock `now` (past the grace window); | |
| 544 | + // the list partition re-evaluates with a minute-floored `now` that is | |
| 545 | + // still INSIDE the window. settledAt >= message time proves the server | |
| 546 | + // already adjudicated this exact message, so the row must not snap back | |
| 547 | + // to active until the coarser clock catches up. | |
| 548 | + const messageAt = "2026-04-09T12:00:00.000Z"; | |
| 549 | + const flooredNow = "2026-04-09T12:01:00.000Z"; | |
| 550 | + const base = makeShell({ settledOverride: "settled", activityAt: null }); | |
| 551 | + const settledAfterMessage = { | |
| 552 | + ...base, | |
| 553 | + latestUserMessageAt: messageAt, | |
| 554 | + settledAt: "2026-04-09T12:02:10.000Z", | |
| 555 | + }; | |
| 556 | + expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); | |
| 557 | + expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( | |
| 558 | + true, | |
| 559 | + ); | |
| 560 | + | |
| 561 | + // A message NEWER than settledAt is genuinely new work: still blocked | |
| 562 | + // until the server's auto-unsettle lands. | |
| 563 | + const messageAfterSettle = { | |
| 564 | + ...base, | |
| 565 | + latestUserMessageAt: "2026-04-09T12:03:00.000Z", | |
| 566 | + settledAt: "2026-04-09T12:02:10.000Z", | |
| 567 | + }; | |
| 568 | + expect( | |
| 569 | + effectiveSettled(messageAfterSettle, { | |
| 570 | + now: "2026-04-09T12:03:30.000Z", | |
| 571 | + autoSettleAfterDays: 3, | |
| 572 | + }), | |
| 573 | + ).toBe(false); | |
| 574 | + }); | |
| 575 | + | |
| 576 | + it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { | |
| 577 | + // Anything canSettle rejects must render as active even when the user | |
| 578 | + // settled it earlier. | |
| 579 | + const blocked = makeShell({ | |
| 580 | + settledOverride: "settled", | |
| 581 | + activityAt: FRESH, | |
| 582 | + pending: "user-input", | |
| 583 | + }); | |
| 584 | + expect(canSettle(blocked, { now: NOW })).toBe(false); | |
| 585 | + expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); | |
| 586 | + }); | |
| 587 | +}); | |
| @@ -138,7 +138,6 @@ export function threadLastActivityAt(shell: SettlementThreadShell): string | nul · 137 unmodified lines | ||
| 138 | 138 | * such threads would be permanently unsettleable. |
| 139 | 139 | */ |
| 140 | 140 | export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; |
| 141 | −const DAY_MS = 24 * 60 * 60 * 1_000; | |
| 142 | 141 | |
| 143 | 142 | /** |
| 144 | 143 | * A user message no turn has picked up yet: the turn.start command was |
| @@ -70,9 +70,12 @@ function testSession( · 69 unmodified lines | ||
| 70 | 70 | ): RpcSession.RpcSession { |
| 71 | 71 | return { |
| 72 | 72 | client, |
| 73 | − initialConfig: Effect.succeed({ | |
| 74 | − threadResumeCompletionMarker: config?.completionMarker === true, | |
| 75 | − } as never), | |
| 73 | + initialConfig: Effect.succeed( | |
| 74 | + config?.completionMarker === true | |
| 75 | + ? ({ threadResumeCompletionMarker: true } as never) | |
| 76 | + : ({} as never), | |
| 77 | + ), | |
| 78 | + subscribeServerConfig: (input) => client.subscribeServerConfig(input), | |
| 76 | 79 | ready: Effect.void, |
| 77 | 80 | probe: Effect.void, |
| 78 | 81 | closed: Effect.never, |
| @@ -444,7 +444,7 @@ describe("resolveViewedImageAsset", () => { · 443 unmodified lines | ||
| 444 | 444 | }), |
| 445 | 445 | ).toEqual({ |
| 446 | 446 | resource: { |
| 447 | − _tag: "workspace-file", | |
| 447 | + _tag: "media-file", | |
| 448 | 448 | threadId, |
| 449 | 449 | path: "/workspace/screens/logo.svg", |
| 450 | 450 | }, |
| @@ -135,7 +135,7 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin · 134 unmodified lines | ||
| 135 | 135 | } |
| 136 | 136 | |
| 137 | 137 | export interface ViewedImageAsset { |
| 138 | − readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>; | |
| 138 | + readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>; | |
| 139 | 139 | readonly alt: string; |
| 140 | 140 | readonly srcFragment: string; |
| 141 | 141 | } |
| @@ -165,7 +165,7 @@ export function resolveViewedImageAsset( · 23 unmodified lines | ||
| 165 | 165 | return { |
| 166 | 166 | resource: attachmentId |
| 167 | 167 | ? { _tag: "attachment", attachmentId } |
| 168 | − : { _tag: "workspace-file", threadId: input.threadId, path }, | |
| 168 | + : { _tag: "media-file", threadId: input.threadId, path }, | |
| 169 | 169 | alt: path.split(/[\\/]/).at(-1) ?? "image", |
| 170 | 170 | srcFragment: markdownImageSourceFragment(source), |
| 171 | 171 | }; |
| @@ -88,7 +88,7 @@ import { EnvironmentId } from "./baseSchemas.ts"; · 87 unmodified lines | ||
| 88 | 88 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; |
| 89 | 89 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 90 | 90 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 91 | −import type { ClientSettings } from "./settings.ts"; | |
| 91 | +import type { ClientSettings, QuitConfirmationMode } from "./settings.ts"; | |
| 92 | 92 | import type { EditorId } from "./editor.ts"; |
| 93 | 93 | import type { |
| 94 | 94 | SourceControlCloneRepositoryInput, |
| @@ -98,6 +98,10 @@ import type { · 3 unmodified lines | ||
| 98 | 98 | SourceControlRepositoryInfo, |
| 99 | 99 | SourceControlRepositoryLookupInput, |
| 100 | 100 | } from "./sourceControl.ts"; |
| 101 | +import type { | |
| 102 | + DesktopAppActivationRequest, | |
| 103 | + DesktopAppActivationResponse, | |
| 104 | +} from "./desktopAppActivation.ts"; | |
| 101 | 105 | |
| 102 | 106 | export interface ContextMenuItem<T extends string = string> { |
| 103 | 107 | id: T; |
| @@ -1103,11 +1107,10 @@ export interface DesktopBridge { · 999 unmodified lines | ||
| 1103 | 1107 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; |
| 1104 | 1108 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 1105 | 1109 | /** |
| 1106 | − * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, | |
| 1107 | − * "up" when it is released before the hold completes. Optional: older | |
| 1108 | − * desktop builds never emit it. | |
| 1110 | + * Quit-confirmation hint pushes. Optional: older desktop builds never emit | |
| 1111 | + * them. | |
| 1109 | 1112 | */ |
| 1110 | − onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; | |
| 1113 | + onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; | |
| 1111 | 1114 | getWindowFullscreenState: () => boolean; |
| 1112 | 1115 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; |
| 1113 | 1116 | getUpdateState: () => Promise<DesktopUpdateState>; |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | −import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; | |
| 2 | +import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | applyClaudePromptEffortPrefix, |
| @@ -40,9 +40,11 @@ const codexCaps: ModelCapabilities = createModelCapabilities({ · 34 unmodified lines | ||
| 40 | 40 | |
| 41 | 41 | describe("model slug normalization", () => { |
| 42 | 42 | it("preserves exact custom slugs instead of expanding provider aliases", () => { |
| 43 | + // Claude aliases now resolve through the model catalog (#9084), so the | |
| 44 | + // provider alias table passes unknown slugs through unchanged. | |
| 43 | 45 | const claude = ProviderDriverKind.make("claudeAgent"); |
| 44 | 46 | |
| 45 | − expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5"); | |
| 47 | + expect(normalizeModelSlug("opus", claude)).toBe("opus"); | |
| 46 | 48 | expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); |
| 47 | 49 | }); |
| 48 | 50 | }); |
| @@ -241,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a · 240 unmodified lines | ||
| 241 | 241 | * Number of columns to render items in. |
| 242 | 242 | * @default 1 |
| 243 | 243 | diff --git a/react-native.js b/react-native.js |
| 244 | −index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c3ffe83af 100644 | |
| 244 | +index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df752a843de0 100644 | |
| 245 | 245 | --- a/react-native.js |
| 246 | 246 | +++ b/react-native.js |
| 247 | 247 | @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { |
| @@ -278,7 +278,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 30 unmodified lines | ||
| 278 | 278 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { |
| 279 | 279 | set$(ctx, "readyToRender", true); |
| 280 | 280 | setAdaptiveRender(ctx, "normal", "ready"); |
| 281 | −@@ -1090,7 +1090,7 @@ function getRawContentLength(ctx) { | |
| 281 | +@@ -1090,7 +1099,7 @@ function getRawContentLength(ctx) { | |
| 282 | 282 | function getAlignItemsAtEndPadding(ctx) { |
| 283 | 283 | const { state } = ctx; |
| 284 | 284 | const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; |
| @@ -287,7 +287,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines | ||
| 287 | 287 | } |
| 288 | 288 | function updateContentMetricsState(ctx) { |
| 289 | 289 | const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; |
| 290 | −@@ -1115,6 +1115,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { | |
| 290 | +@@ -1115,6 +1124,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { | |
| 291 | 291 | totalSize += add; |
| 292 | 292 | } |
| 293 | 293 | if (prevTotalSize !== totalSize) { |
| @@ -298,7 +298,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines | ||
| 298 | 298 | if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { |
| 299 | 299 | state.pendingTotalSize = totalSize; |
| 300 | 300 | } else { |
| 301 | −@@ -1304,18 +1308,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 301 | +@@ -1304,18 +1317,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 302 | 302 | } |
| 303 | 303 | |
| 304 | 304 | // src/core/clampScrollOffset.ts |
| @@ -324,7 +324,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 19 unmodified lines | ||
| 324 | 324 | return clampedOffset; |
| 325 | 325 | } |
| 326 | 326 | |
| 327 | −@@ -1451,10 +1460,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 327 | +@@ -1451,10 +1469,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 328 | 328 | finishScrollTo(ctx); |
| 329 | 329 | } |
| 330 | 330 | } |
| @@ -337,7 +337,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines | ||
| 337 | 337 | x: ctx.state.props.horizontal ? offset : 0, |
| 338 | 338 | y: ctx.state.props.horizontal ? 0 : offset |
| 339 | 339 | }); |
| 340 | −@@ -1503,7 +1512,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 340 | +@@ -1503,7 +1521,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 341 | 341 | ); |
| 342 | 342 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); |
| 343 | 343 | } else if (shouldRetryUnalignedEndScroll) { |
| @@ -349,7 +349,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 5 unmodified lines | ||
| 349 | 349 | scheduleFallbackCheck(100); |
| 350 | 350 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { |
| 351 | 351 | finishScrollTo(ctx); |
| 352 | −@@ -1560,15 +1572,28 @@ function doMaintainScrollAtEnd(ctx) { | |
| 352 | +@@ -1560,15 +1581,28 @@ function doMaintainScrollAtEnd(ctx) { | |
| 353 | 353 | } = state; |
| 354 | 354 | const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); |
| 355 | 355 | const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); |
| @@ -379,7 +379,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 23 unmodified lines | ||
| 379 | 379 | } |
| 380 | 380 | if (!state.maintainingScrollAtEnd) { |
| 381 | 381 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; |
| 382 | −@@ -1591,9 +1616,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 382 | +@@ -1591,9 +1625,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 383 | 383 | y: 0 |
| 384 | 384 | }); |
| 385 | 385 | } else { |
| @@ -401,7 +401,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 15 unmodified lines | ||
| 401 | 401 | } |
| 402 | 402 | setTimeout( |
| 403 | 403 | () => { |
| 404 | −@@ -1624,6 +1658,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 404 | +@@ -1624,6 +1667,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 405 | 405 | function requestAdjust(ctx, positionDiff, dataChanged) { |
| 406 | 406 | const state = ctx.state; |
| 407 | 407 | if (Math.abs(positionDiff) > 0.1) { |
| @@ -412,7 +412,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines | ||
| 412 | 412 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; |
| 413 | 413 | const doit = () => { |
| 414 | 414 | if (needsScrollWorkaround) { |
| 415 | −@@ -1728,7 +1766,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 415 | +@@ -1728,7 +1775,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 416 | 416 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { |
| 417 | 417 | return 0; |
| 418 | 418 | } |
| @@ -423,7 +423,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines | ||
| 423 | 423 | const clampDelta = maxScroll - state.scroll; |
| 424 | 424 | if (unresolvedAmount < 0) { |
| 425 | 425 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); |
| 426 | −@@ -1790,7 +1830,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 426 | +@@ -1790,7 +1839,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 427 | 427 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); |
| 428 | 428 | return true; |
| 429 | 429 | } |
| @@ -432,7 +432,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines | ||
| 432 | 432 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); |
| 433 | 433 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; |
| 434 | 434 | if (isAtExpectedNativeClamp) { |
| 435 | −@@ -1923,7 +1963,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 435 | +@@ -1923,7 +1972,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 436 | 436 | if (diff > 0) { |
| 437 | 437 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); |
| 438 | 438 | } else { |
| @@ -441,7 +441,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines | ||
| 441 | 441 | state.scroll = maxScroll; |
| 442 | 442 | state.scrollPending = maxScroll; |
| 443 | 443 | diff = 0; |
| 444 | −@@ -2320,8 +2360,121 @@ function scrollToIndex(ctx, { | |
| 444 | +@@ -2320,8 +2369,121 @@ function scrollToIndex(ctx, { | |
| 445 | 445 | } |
| 446 | 446 | |
| 447 | 447 | // src/core/initialScroll.ts |
| @@ -563,7 +563,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 115 unmodified lines | ||
| 563 | 563 | const requestedIndex = target.index; |
| 564 | 564 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; |
| 565 | 565 | const itemSize = getItemSizeAtIndex(ctx, index); |
| 566 | −@@ -2747,7 +2900,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 566 | +@@ -2747,7 +2909,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 567 | 567 | return; |
| 568 | 568 | } |
| 569 | 569 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { |
| @@ -574,7 +574,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines | ||
| 574 | 574 | if (!shouldKeepEndTargetAlive) { |
| 575 | 575 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { |
| 576 | 576 | clearPendingInitialScrollFooterLayout(ctx, { |
| 577 | −@@ -4672,7 +4827,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 577 | +@@ -4672,7 +4836,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 578 | 578 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 579 | 579 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 580 | 580 | isReady = !hasUnknownTailSize; |
| @@ -584,7 +584,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 3 unmodified lines | ||
| 584 | 584 | } else if (anchorIndex >= 0) { |
| 585 | 585 | isReady = false; |
| 586 | 586 | } |
| 587 | −@@ -4692,6 +4848,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 587 | +@@ -4692,6 +4857,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 588 | 588 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 589 | 589 | } |
| 590 | 590 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); |
| @@ -597,7 +597,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines | ||
| 597 | 597 | } |
| 598 | 598 | return nextSize; |
| 599 | 599 | } |
| 600 | −@@ -5715,6 +5877,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 600 | +@@ -5715,6 +5886,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 601 | 601 | horizontal |
| 602 | 602 | }) { |
| 603 | 603 | const ctx = useStateContext(); |
| @@ -605,7 +605,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | ||
| 605 | 605 | const columnWrapperStyle = ctx.columnWrapperStyle; |
| 606 | 606 | const animSize = useValue$("totalSize"); |
| 607 | 607 | const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); |
| 608 | −@@ -5725,6 +5888,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 608 | +@@ -5725,6 +5897,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 609 | 609 | opacity: isVisible ? 1 : 0, |
| 610 | 610 | width: animSize |
| 611 | 611 | } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; |
| @@ -619,7 +619,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 7 unmodified lines | ||
| 619 | 619 | if (columnWrapperStyle) { |
| 620 | 620 | const { columnGap, rowGap, gap } = columnWrapperStyle; |
| 621 | 621 | const gapX = columnGap || gap || 0; |
| 622 | −@@ -5745,7 +5915,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 622 | +@@ -5745,7 +5924,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 623 | 623 | } |
| 624 | 624 | } |
| 625 | 625 | } |
| @@ -629,7 +629,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 3 unmodified lines | ||
| 629 | 629 | }); |
| 630 | 630 | var Containers = typedMemo(function Containers2({ |
| 631 | 631 | freshDataTransitionEpoch, |
| 632 | −@@ -5896,7 +6067,12 @@ var StyleSheet = ReactNative.StyleSheet; | |
| 632 | +@@ -5896,7 +6076,12 @@ var StyleSheet = ReactNative.StyleSheet; | |
| 633 | 633 | |
| 634 | 634 | // src/components/ListComponent.tsx |
| 635 | 635 | var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { |
| @@ -642,7 +642,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines | ||
| 642 | 642 | if (alignItemsAtEndPadding <= 0) { |
| 643 | 643 | return null; |
| 644 | 644 | } |
| 645 | −@@ -5929,8 +6105,12 @@ var ListComponent = typedMemo(function ListComponent2({ | |
| 645 | +@@ -5929,8 +6114,12 @@ var ListComponent = typedMemo(function ListComponent2({ | |
| 646 | 646 | refScrollView, |
| 647 | 647 | renderScrollComponent, |
| 648 | 648 | onLayoutFooter, |
| @@ -655,7 +655,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines | ||
| 655 | 655 | scrollAdjustHandler, |
| 656 | 656 | snapToIndices, |
| 657 | 657 | stickyHeaderConfig, |
| 658 | −@@ -6001,7 +6181,17 @@ var ListComponent = typedMemo(function ListComponent2({ | |
| 658 | +@@ -6001,7 +6190,17 @@ var ListComponent = typedMemo(function ListComponent2({ | |
| 659 | 659 | SnapOrScroll, |
| 660 | 660 | { |
| 661 | 661 | ...rest, |
| @@ -674,7 +674,16 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 12 unmodified lines | ||
| 674 | 674 | contentContainerStyle: [ |
| 675 | 675 | horizontal ? { height: "100%" } : {}, |
| 676 | 676 | contentContainerStyle, |
| 677 | −@@ -7075,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 677 | +@@ -6751,7 +6950,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { | |
| 678 | + endBuffered: state.endBuffered, | |
| 679 | + getAverageItemSizes: () => getAverageItemSizes(state), | |
| 680 | + indexByKey: (key) => state.indexByKey.get(key), | |
| 681 | +- isAtEnd: peek$(ctx, "isAtEnd"), | |
| 682 | ++ isAtEnd: getIsAtEnd(ctx), | |
| 683 | + isAtStart: peek$(ctx, "isAtStart"), | |
| 684 | + isEndReached: state.isEndReached, | |
| 685 | + isNearEnd: peek$(ctx, "isNearEnd"), | |
| 686 | +@@ -7075,6 +7274,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 678 | 687 | dataVersion, |
| 679 | 688 | drawDistance = 250, |
| 680 | 689 | contentInsetEndAdjustment, |
| @@ -682,7 +691,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | ||
| 682 | 691 | estimatedItemSize = 100, |
| 683 | 692 | estimatedListSize, |
| 684 | 693 | extraData, |
| 685 | −@@ -7132,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 694 | +@@ -7132,10 +7332,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 686 | 695 | const animatedPropsInternal = props.animatedPropsInternal; |
| 687 | 696 | const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; |
| 688 | 697 | const positionComponentInternal = props.positionComponentInternal; |
| @@ -695,7 +704,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines | ||
| 695 | 704 | stickyPositionComponentInternal: _stickyPositionComponentInternal, |
| 696 | 705 | ...restProps |
| 697 | 706 | } = rest; |
| 698 | −@@ -7200,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 707 | +@@ -7200,7 +7402,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 699 | 708 | const combinedRef = useCombinedRef(refScroller, refScrollView); |
| 700 | 709 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); |
| 701 | 710 | const stickyHeaderIndices = stickyHeaderIndicesProp; |
| @@ -704,7 +713,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines | ||
| 704 | 713 | const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); |
| 705 | 714 | const alwaysRenderIndices = React2.useMemo(() => { |
| 706 | 715 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); |
| 707 | −@@ -7341,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 716 | +@@ -7341,6 +7543,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 708 | 717 | contentContainerAlignItems: contentContainerStyle.alignItems, |
| 709 | 718 | contentInset, |
| 710 | 719 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, |
| @@ -712,7 +721,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | ||
| 712 | 721 | data: dataProp, |
| 713 | 722 | dataKey, |
| 714 | 723 | dataVersion, |
| 715 | −@@ -7372,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 724 | +@@ -7372,6 +7575,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 716 | 725 | renderItem, |
| 717 | 726 | rtl, |
| 718 | 727 | snapToIndices, |
| @@ -720,7 +729,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | ||
| 720 | 729 | stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], |
| 721 | 730 | stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), |
| 722 | 731 | stickyPositionComponentInternal, |
| 723 | −@@ -7423,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 732 | +@@ -7423,6 +7627,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 724 | 733 | return void 0; |
| 725 | 734 | } |
| 726 | 735 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); |
| @@ -734,7 +743,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 7 unmodified lines | ||
| 734 | 743 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; |
| 735 | 744 | }, [usesBootstrapInitialScroll]); |
| 736 | 745 | React2.useLayoutEffect(() => { |
| 737 | −@@ -7547,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 746 | +@@ -7547,6 +7758,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 738 | 747 | [ |
| 739 | 748 | dataKey, |
| 740 | 749 | dataVersion, |
| @@ -742,7 +751,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | ||
| 742 | 751 | memoizedLastItemKeys.join(","), |
| 743 | 752 | numColumnsProp, |
| 744 | 753 | nextScrollAxisGap, |
| 745 | −@@ -7643,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 754 | +@@ -7643,6 +7855,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 746 | 755 | () => ({ |
| 747 | 756 | getRenderedItem: (key) => getRenderedItem(ctx, key), |
| 748 | 757 | onMomentumScrollEnd: (event) => { |
| @@ -750,7 +759,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | ||
| 750 | 759 | checkFinishedScrollFallback(ctx); |
| 751 | 760 | if (state.props.onMomentumScrollEnd) { |
| 752 | 761 | state.props.onMomentumScrollEnd(event); |
| 753 | −@@ -7651,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 762 | +@@ -7651,6 +7864,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 754 | 763 | onScroll: (event) => onScroll(ctx, event), |
| 755 | 764 | onScrollBeginDrag: (event) => { |
| 756 | 765 | var _a4, _b2; |
| @@ -759,7 +768,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines | ||
| 759 | 768 | prepareReachedEdgeForNextUserScroll(ctx); |
| 760 | 769 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); |
| 761 | 770 | }, |
| 762 | −@@ -7676,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 771 | +@@ -7676,11 +7891,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 763 | 772 | ListFooterComponent, |
| 764 | 773 | ListFooterComponentStyle, |
| 765 | 774 | ListHeaderComponent, |
| @@ -779,7 +788,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 13 unmodified lines | ||
| 779 | 788 | recycleItems, |
| 780 | 789 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { |
| 781 | 790 | diff --git a/react-native.mjs b/react-native.mjs |
| 782 | −index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318d57e29dc 100644 | |
| 791 | +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff7e3ad022 100644 | |
| 783 | 792 | --- a/react-native.mjs |
| 784 | 793 | +++ b/react-native.mjs |
| 785 | 794 | @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { |
| @@ -816,7 +825,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 30 unmodified lines | ||
| 816 | 825 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { |
| 817 | 826 | set$(ctx, "readyToRender", true); |
| 818 | 827 | setAdaptiveRender(ctx, "normal", "ready"); |
| 819 | −@@ -1069,7 +1069,7 @@ function getRawContentLength(ctx) { | |
| 828 | +@@ -1069,7 +1078,7 @@ function getRawContentLength(ctx) { | |
| 820 | 829 | function getAlignItemsAtEndPadding(ctx) { |
| 821 | 830 | const { state } = ctx; |
| 822 | 831 | const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; |
| @@ -825,7 +834,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines | ||
| 825 | 834 | } |
| 826 | 835 | function updateContentMetricsState(ctx) { |
| 827 | 836 | const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; |
| 828 | −@@ -1094,6 +1094,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { | |
| 837 | +@@ -1094,6 +1103,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { | |
| 829 | 838 | totalSize += add; |
| 830 | 839 | } |
| 831 | 840 | if (prevTotalSize !== totalSize) { |
| @@ -836,7 +845,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines | ||
| 836 | 845 | if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { |
| 837 | 846 | state.pendingTotalSize = totalSize; |
| 838 | 847 | } else { |
| 839 | −@@ -1283,18 +1287,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 848 | +@@ -1283,18 +1296,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 840 | 849 | } |
| 841 | 850 | |
| 842 | 851 | // src/core/clampScrollOffset.ts |
| @@ -862,7 +871,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 19 unmodified lines | ||
| 862 | 871 | return clampedOffset; |
| 863 | 872 | } |
| 864 | 873 | |
| 865 | −@@ -1430,10 +1439,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 874 | +@@ -1430,10 +1448,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 866 | 875 | finishScrollTo(ctx); |
| 867 | 876 | } |
| 868 | 877 | } |
| @@ -875,7 +884,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines | ||
| 875 | 884 | x: ctx.state.props.horizontal ? offset : 0, |
| 876 | 885 | y: ctx.state.props.horizontal ? 0 : offset |
| 877 | 886 | }); |
| 878 | −@@ -1482,7 +1491,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 887 | +@@ -1482,7 +1500,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 879 | 888 | ); |
| 880 | 889 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); |
| 881 | 890 | } else if (shouldRetryUnalignedEndScroll) { |
| @@ -887,7 +896,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 5 unmodified lines | ||
| 887 | 896 | scheduleFallbackCheck(100); |
| 888 | 897 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { |
| 889 | 898 | finishScrollTo(ctx); |
| 890 | −@@ -1539,15 +1551,28 @@ function doMaintainScrollAtEnd(ctx) { | |
| 899 | +@@ -1539,15 +1560,28 @@ function doMaintainScrollAtEnd(ctx) { | |
| 891 | 900 | } = state; |
| 892 | 901 | const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); |
| 893 | 902 | const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); |
| @@ -917,7 +926,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 23 unmodified lines | ||
| 917 | 926 | } |
| 918 | 927 | if (!state.maintainingScrollAtEnd) { |
| 919 | 928 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; |
| 920 | −@@ -1570,9 +1595,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 929 | +@@ -1570,9 +1604,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 921 | 930 | y: 0 |
| 922 | 931 | }); |
| 923 | 932 | } else { |
| @@ -939,7 +948,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 15 unmodified lines | ||
| 939 | 948 | } |
| 940 | 949 | setTimeout( |
| 941 | 950 | () => { |
| 942 | −@@ -1603,6 +1637,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 951 | +@@ -1603,6 +1646,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 943 | 952 | function requestAdjust(ctx, positionDiff, dataChanged) { |
| 944 | 953 | const state = ctx.state; |
| 945 | 954 | if (Math.abs(positionDiff) > 0.1) { |
| @@ -950,7 +959,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines | ||
| 950 | 959 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; |
| 951 | 960 | const doit = () => { |
| 952 | 961 | if (needsScrollWorkaround) { |
| 953 | −@@ -1707,7 +1745,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 962 | +@@ -1707,7 +1754,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 954 | 963 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { |
| 955 | 964 | return 0; |
| 956 | 965 | } |
| @@ -961,7 +970,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines | ||
| 961 | 970 | const clampDelta = maxScroll - state.scroll; |
| 962 | 971 | if (unresolvedAmount < 0) { |
| 963 | 972 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); |
| 964 | −@@ -1769,7 +1809,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 973 | +@@ -1769,7 +1818,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 965 | 974 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); |
| 966 | 975 | return true; |
| 967 | 976 | } |
| @@ -970,7 +979,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines | ||
| 970 | 979 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); |
| 971 | 980 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; |
| 972 | 981 | if (isAtExpectedNativeClamp) { |
| 973 | −@@ -1902,7 +1942,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 982 | +@@ -1902,7 +1951,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 974 | 983 | if (diff > 0) { |
| 975 | 984 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); |
| 976 | 985 | } else { |
| @@ -979,7 +988,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines | ||
| 979 | 988 | state.scroll = maxScroll; |
| 980 | 989 | state.scrollPending = maxScroll; |
| 981 | 990 | diff = 0; |
| 982 | −@@ -2299,8 +2339,121 @@ function scrollToIndex(ctx, { | |
| 991 | +@@ -2299,8 +2348,121 @@ function scrollToIndex(ctx, { | |
| 983 | 992 | } |
| 984 | 993 | |
| 985 | 994 | // src/core/initialScroll.ts |
| @@ -1101,7 +1110,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 115 unmodified lines | ||
| 1101 | 1110 | const requestedIndex = target.index; |
| 1102 | 1111 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; |
| 1103 | 1112 | const itemSize = getItemSizeAtIndex(ctx, index); |
| 1104 | −@@ -2726,7 +2879,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 1113 | +@@ -2726,7 +2888,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 1105 | 1114 | return; |
| 1106 | 1115 | } |
| 1107 | 1116 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { |
| @@ -1112,7 +1121,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines | ||
| 1112 | 1121 | if (!shouldKeepEndTargetAlive) { |
| 1113 | 1122 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { |
| 1114 | 1123 | clearPendingInitialScrollFooterLayout(ctx, { |
| 1115 | −@@ -4651,7 +4806,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 1124 | +@@ -4651,7 +4815,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 1116 | 1125 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 1117 | 1126 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 1118 | 1127 | isReady = !hasUnknownTailSize; |
| @@ -1122,7 +1131,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 3 unmodified lines | ||
| 1122 | 1131 | } else if (anchorIndex >= 0) { |
| 1123 | 1132 | isReady = false; |
| 1124 | 1133 | } |
| 1125 | −@@ -4671,6 +4827,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 1134 | +@@ -4671,6 +4836,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 1126 | 1135 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 1127 | 1136 | } |
| 1128 | 1137 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); |
| @@ -1135,7 +1144,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines | ||
| 1135 | 1144 | } |
| 1136 | 1145 | return nextSize; |
| 1137 | 1146 | } |
| 1138 | −@@ -5694,6 +5856,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 1147 | +@@ -5694,6 +5865,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 1139 | 1148 | horizontal |
| 1140 | 1149 | }) { |
| 1141 | 1150 | const ctx = useStateContext(); |
| @@ -1143,7 +1152,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | ||
| 1143 | 1152 | const columnWrapperStyle = ctx.columnWrapperStyle; |
| 1144 | 1153 | const animSize = useValue$("totalSize"); |
| 1145 | 1154 | const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); |
| 1146 | −@@ -5704,6 +5867,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 1155 | +@@ -5704,6 +5876,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 1147 | 1156 | opacity: isVisible ? 1 : 0, |
| 1148 | 1157 | width: animSize |
| 1149 | 1158 | } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; |
| @@ -1157,7 +1166,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 7 unmodified lines | ||
| 1157 | 1166 | if (columnWrapperStyle) { |
| 1158 | 1167 | const { columnGap, rowGap, gap } = columnWrapperStyle; |
| 1159 | 1168 | const gapX = columnGap || gap || 0; |
| 1160 | −@@ -5724,7 +5894,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 1169 | +@@ -5724,7 +5903,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ | |
| 1161 | 1170 | } |
| 1162 | 1171 | } |
| 1163 | 1172 | } |
| @@ -1167,7 +1176,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 3 unmodified lines | ||
| 1167 | 1176 | }); |
| 1168 | 1177 | var Containers = typedMemo(function Containers2({ |
| 1169 | 1178 | freshDataTransitionEpoch, |
| 1170 | −@@ -5875,7 +6046,12 @@ var StyleSheet = StyleSheet$1; | |
| 1179 | +@@ -5875,7 +6055,12 @@ var StyleSheet = StyleSheet$1; | |
| 1171 | 1180 | |
| 1172 | 1181 | // src/components/ListComponent.tsx |
| 1173 | 1182 | var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { |
| @@ -1180,7 +1189,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines | ||
| 1180 | 1189 | if (alignItemsAtEndPadding <= 0) { |
| 1181 | 1190 | return null; |
| 1182 | 1191 | } |
| 1183 | −@@ -5908,8 +6084,12 @@ var ListComponent = typedMemo(function ListComponent2({ | |
| 1192 | +@@ -5908,8 +6093,12 @@ var ListComponent = typedMemo(function ListComponent2({ | |
| 1184 | 1193 | refScrollView, |
| 1185 | 1194 | renderScrollComponent, |
| 1186 | 1195 | onLayoutFooter, |
| @@ -1193,7 +1202,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines | ||
| 1193 | 1202 | scrollAdjustHandler, |
| 1194 | 1203 | snapToIndices, |
| 1195 | 1204 | stickyHeaderConfig, |
| 1196 | −@@ -5980,7 +6160,17 @@ var ListComponent = typedMemo(function ListComponent2({ | |
| 1205 | +@@ -5980,7 +6169,17 @@ var ListComponent = typedMemo(function ListComponent2({ | |
| 1197 | 1206 | SnapOrScroll, |
| 1198 | 1207 | { |
| 1199 | 1208 | ...rest, |
| @@ -1212,7 +1221,16 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 12 unmodified lines | ||
| 1212 | 1221 | contentContainerStyle: [ |
| 1213 | 1222 | horizontal ? { height: "100%" } : {}, |
| 1214 | 1223 | contentContainerStyle, |
| 1215 | −@@ -7054,6 +7244,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1224 | +@@ -6730,7 +6929,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { | |
| 1225 | + endBuffered: state.endBuffered, | |
| 1226 | + getAverageItemSizes: () => getAverageItemSizes(state), | |
| 1227 | + indexByKey: (key) => state.indexByKey.get(key), | |
| 1228 | +- isAtEnd: peek$(ctx, "isAtEnd"), | |
| 1229 | ++ isAtEnd: getIsAtEnd(ctx), | |
| 1230 | + isAtStart: peek$(ctx, "isAtStart"), | |
| 1231 | + isEndReached: state.isEndReached, | |
| 1232 | + isNearEnd: peek$(ctx, "isNearEnd"), | |
| 1233 | +@@ -7054,6 +7253,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1216 | 1234 | dataVersion, |
| 1217 | 1235 | drawDistance = 250, |
| 1218 | 1236 | contentInsetEndAdjustment, |
| @@ -1220,7 +1238,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | ||
| 1220 | 1238 | estimatedItemSize = 100, |
| 1221 | 1239 | estimatedListSize, |
| 1222 | 1240 | extraData, |
| 1223 | −@@ -7111,10 +7302,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1241 | +@@ -7111,10 +7311,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1224 | 1242 | const animatedPropsInternal = props.animatedPropsInternal; |
| 1225 | 1243 | const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; |
| 1226 | 1244 | const positionComponentInternal = props.positionComponentInternal; |
| @@ -1233,7 +1251,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines | ||
| 1233 | 1251 | stickyPositionComponentInternal: _stickyPositionComponentInternal, |
| 1234 | 1252 | ...restProps |
| 1235 | 1253 | } = rest; |
| 1236 | −@@ -7179,7 +7372,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1254 | +@@ -7179,7 +7381,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1237 | 1255 | const combinedRef = useCombinedRef(refScroller, refScrollView); |
| 1238 | 1256 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); |
| 1239 | 1257 | const stickyHeaderIndices = stickyHeaderIndicesProp; |
| @@ -1242,7 +1260,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines | ||
| 1242 | 1260 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); |
| 1243 | 1261 | const alwaysRenderIndices = useMemo(() => { |
| 1244 | 1262 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); |
| 1245 | −@@ -7320,6 +7513,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1263 | +@@ -7320,6 +7522,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1246 | 1264 | contentContainerAlignItems: contentContainerStyle.alignItems, |
| 1247 | 1265 | contentInset, |
| 1248 | 1266 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, |
| @@ -1250,7 +1268,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | ||
| 1250 | 1268 | data: dataProp, |
| 1251 | 1269 | dataKey, |
| 1252 | 1270 | dataVersion, |
| 1253 | −@@ -7351,6 +7545,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1271 | +@@ -7351,6 +7554,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1254 | 1272 | renderItem, |
| 1255 | 1273 | rtl, |
| 1256 | 1274 | snapToIndices, |
| @@ -1258,7 +1276,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | ||
| 1258 | 1276 | stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], |
| 1259 | 1277 | stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), |
| 1260 | 1278 | stickyPositionComponentInternal, |
| 1261 | −@@ -7402,6 +7597,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1279 | +@@ -7402,6 +7606,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1262 | 1280 | return void 0; |
| 1263 | 1281 | } |
| 1264 | 1282 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); |
| @@ -1272,7 +1290,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 7 unmodified lines | ||
| 1272 | 1290 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; |
| 1273 | 1291 | }, [usesBootstrapInitialScroll]); |
| 1274 | 1292 | useLayoutEffect(() => { |
| 1275 | −@@ -7526,6 +7728,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1293 | +@@ -7526,6 +7737,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1276 | 1294 | [ |
| 1277 | 1295 | dataKey, |
| 1278 | 1296 | dataVersion, |
| @@ -1280,7 +1298,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | ||
| 1280 | 1298 | memoizedLastItemKeys.join(","), |
| 1281 | 1299 | numColumnsProp, |
| 1282 | 1300 | nextScrollAxisGap, |
| 1283 | −@@ -7622,6 +7825,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1301 | +@@ -7622,6 +7834,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1284 | 1302 | () => ({ |
| 1285 | 1303 | getRenderedItem: (key) => getRenderedItem(ctx, key), |
| 1286 | 1304 | onMomentumScrollEnd: (event) => { |
| @@ -1288,7 +1306,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | ||
| 1288 | 1306 | checkFinishedScrollFallback(ctx); |
| 1289 | 1307 | if (state.props.onMomentumScrollEnd) { |
| 1290 | 1308 | state.props.onMomentumScrollEnd(event); |
| 1291 | −@@ -7630,6 +7834,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1309 | +@@ -7630,6 +7843,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1292 | 1310 | onScroll: (event) => onScroll(ctx, event), |
| 1293 | 1311 | onScrollBeginDrag: (event) => { |
| 1294 | 1312 | var _a4, _b2; |
| @@ -1297,7 +1315,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines | ||
| 1297 | 1315 | prepareReachedEdgeForNextUserScroll(ctx); |
| 1298 | 1316 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); |
| 1299 | 1317 | }, |
| 1300 | −@@ -7655,11 +7861,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1318 | +@@ -7655,11 +7870,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 1301 | 1319 | ListFooterComponent, |
| 1302 | 1320 | ListFooterComponentStyle, |
| 1303 | 1321 | ListHeaderComponent, |
| @@ -1408,7 +1426,7 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613 · 104 unmodified lines | ||
| 1408 | 1426 | * Number of columns to render items in. |
| 1409 | 1427 | * @default 1 |
| 1410 | 1428 | diff --git a/reanimated.js b/reanimated.js |
| 1411 | −index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71ff8ce9f4d 100644 | |
| 1429 | +index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644 | |
| 1412 | 1430 | --- a/reanimated.js |
| 1413 | 1431 | +++ b/reanimated.js |
| 1414 | 1432 | @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| @@ -1454,7 +1472,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 39 unmodified lines | ||
| 1454 | 1472 | ref: refView, |
| 1455 | 1473 | style: viewStyle, |
| 1456 | 1474 | ...rest |
| 1457 | −@@ -141,6 +162,101 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 1475 | +@@ -141,6 +162,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 1458 | 1476 | children |
| 1459 | 1477 | ); |
| 1460 | 1478 | }); |
| @@ -1468,12 +1486,12 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 7 unmodified lines | ||
| 1468 | 1486 | + const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0); |
| 1469 | 1487 | + const animationRunRef = React__namespace.useRef(0); |
| 1470 | 1488 | + const isAnimatingRef = React__namespace.useRef(false); |
| 1471 | −+ const [isAnimating, setIsAnimating] = React__namespace.useState(false); | |
| 1472 | 1489 | + const transition = props.layoutTransition; |
| 1473 | 1490 | + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; |
| 1474 | 1491 | + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; |
| 1475 | −+ const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible; | |
| 1476 | −+ const useAnimatedAxis = isAnimating || startsEligibleAnimation; | |
| 1492 | ++ // Keep the animated size attached after transitions finish. Reanimated | |
| 1493 | ++ // retains removed animated props, so switching to a static size leaves | |
| 1494 | ++ // native scroll bounds stuck while logical measurements keep changing. | |
| 1477 | 1495 | + const animatedStyle = Reanimated.useAnimatedStyle(() => { |
| 1478 | 1496 | + const size = Math.max(0, baseSize.value + animatedDelta.value); |
| 1479 | 1497 | + return horizontal ? { width: size } : { height: size }; |
| @@ -1483,7 +1501,6 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 3 unmodified lines | ||
| 1483 | 1501 | + return; |
| 1484 | 1502 | + } |
| 1485 | 1503 | + isAnimatingRef.current = false; |
| 1486 | −+ setIsAnimating(false); | |
| 1487 | 1504 | + const state = ctx.state; |
| 1488 | 1505 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { |
| 1489 | 1506 | + return; |
| @@ -1500,13 +1517,15 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 10 unmodified lines | ||
| 1500 | 1517 | + const previousLogicalSize = previousLogicalSizeRef.current; |
| 1501 | 1518 | + const delta = logicalSize - previousLogicalSize; |
| 1502 | 1519 | + const isNewEpoch = epoch !== previousEpochRef.current; |
| 1503 | −+ const isNewEligibleEpoch = canAnimate && isNewEpoch && state.contentSizeAnimationEligible; | |
| 1520 | ++ // Retarget measurements received mid-animation, even in the same epoch. | |
| 1521 | ++ // Updating only the base keeps the old delta and can shrink scroll bounds. | |
| 1522 | ++ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible); | |
| 1504 | 1523 | + previousLogicalSizeRef.current = logicalSize; |
| 1505 | 1524 | + previousEpochRef.current = epoch; |
| 1506 | 1525 | + if (!delta) { |
| 1507 | 1526 | + return; |
| 1508 | 1527 | + } |
| 1509 | −+ if (!isNewEligibleEpoch) { | |
| 1528 | ++ if (!shouldAnimate) { | |
| 1510 | 1529 | + baseSize.value = logicalSize; |
| 1511 | 1530 | + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { |
| 1512 | 1531 | + state.contentSizeAnimationEligible = false; |
| @@ -1530,7 +1549,6 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 17 unmodified lines | ||
| 1530 | 1549 | + state.contentSizeAnimationActiveSignals.add(signalName); |
| 1531 | 1550 | + const wasAnimating = isAnimatingRef.current; |
| 1532 | 1551 | + isAnimatingRef.current = true; |
| 1533 | −+ setIsAnimating(true); | |
| 1534 | 1552 | + const run = ++animationRunRef.current; |
| 1535 | 1553 | + Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { |
| 1536 | 1554 | + "worklet"; |
| @@ -1545,18 +1563,12 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 8 unmodified lines | ||
| 1545 | 1563 | + })); |
| 1546 | 1564 | + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); |
| 1547 | 1565 | + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); |
| 1548 | −+ // Keep the content container under one animated size owner because detaching an | |
| 1549 | −+ // animated dimension can leave its native override behind. The alignment spacer | |
| 1550 | −+ // is empty, so it can safely remount as a numeric View for keyboard geometry. | |
| 1551 | −+ const isContentContainer = signalName === "totalSize"; | |
| 1552 | −+ const sizeStyle = isContentContainer || useAnimatedAxis ? animatedStyle : horizontal ? { width: logicalSize } : { height: logicalSize }; | |
| 1553 | −+ const SizeView = isContentContainer || useAnimatedAxis ? Reanimated__default.default.View : ReactNative.View; | |
| 1554 | −+ return /* @__PURE__ */ React__namespace.createElement(SizeView, { ...rest, style: [style, sizeStyle] }, children); | |
| 1566 | ++ return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, animatedStyle] }, children); | |
| 1555 | 1567 | +}); |
| 1556 | 1568 | function setSharedValueValue(sharedValue, value) { |
| 1557 | 1569 | if (!sharedValue) { |
| 1558 | 1570 | return; |
| 1559 | −@@ -245,10 +361,19 @@ var LegendListForwardedRef = typedMemo( | |
| 1571 | +@@ -245,10 +355,19 @@ var LegendListForwardedRef = typedMemo( | |
| 1560 | 1572 | ); |
| 1561 | 1573 | }; |
| 1562 | 1574 | }, [hasItemLayoutAnimation, recycleItems]); |
| @@ -1577,7 +1589,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 14 unmodified lines | ||
| 1577 | 1589 | renderScrollComponent: renderReanimatedScrollComponent, |
| 1578 | 1590 | ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} |
| 1579 | 1591 | diff --git a/reanimated.mjs b/reanimated.mjs |
| 1580 | −index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a5725398942 100644 | |
| 1592 | +index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644 | |
| 1581 | 1593 | --- a/reanimated.mjs |
| 1582 | 1594 | +++ b/reanimated.mjs |
| 1583 | 1595 | @@ -1,7 +1,7 @@ |
| @@ -1632,7 +1644,7 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 48 unmodified lines | ||
| 1632 | 1644 | ref: refView, |
| 1633 | 1645 | style: viewStyle, |
| 1634 | 1646 | ...rest |
| 1635 | −@@ -117,6 +138,101 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 1647 | +@@ -117,6 +138,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 1636 | 1648 | children |
| 1637 | 1649 | ); |
| 1638 | 1650 | }); |
| @@ -1646,12 +1658,12 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 7 unmodified lines | ||
| 1646 | 1658 | + const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0); |
| 1647 | 1659 | + const animationRunRef = React.useRef(0); |
| 1648 | 1660 | + const isAnimatingRef = React.useRef(false); |
| 1649 | −+ const [isAnimating, setIsAnimating] = React.useState(false); | |
| 1650 | 1661 | + const transition = props.layoutTransition; |
| 1651 | 1662 | + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; |
| 1652 | 1663 | + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; |
| 1653 | −+ const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible; | |
| 1654 | −+ const useAnimatedAxis = isAnimating || startsEligibleAnimation; | |
| 1664 | ++ // Keep the animated size attached after transitions finish. Reanimated | |
| 1665 | ++ // retains removed animated props, so switching to a static size leaves | |
| 1666 | ++ // native scroll bounds stuck while logical measurements keep changing. | |
| 1655 | 1667 | + const animatedStyle = useAnimatedStyle(() => { |
| 1656 | 1668 | + const size = Math.max(0, baseSize.value + animatedDelta.value); |
| 1657 | 1669 | + return horizontal ? { width: size } : { height: size }; |
| @@ -1661,7 +1673,6 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 3 unmodified lines | ||
| 1661 | 1673 | + return; |
| 1662 | 1674 | + } |
| 1663 | 1675 | + isAnimatingRef.current = false; |
| 1664 | −+ setIsAnimating(false); | |
| 1665 | 1676 | + const state = ctx.state; |
| 1666 | 1677 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { |
| 1667 | 1678 | + return; |
| @@ -1678,13 +1689,15 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 10 unmodified lines | ||
| 1678 | 1689 | + const previousLogicalSize = previousLogicalSizeRef.current; |
| 1679 | 1690 | + const delta = logicalSize - previousLogicalSize; |
| 1680 | 1691 | + const isNewEpoch = epoch !== previousEpochRef.current; |
| 1681 | −+ const isNewEligibleEpoch = canAnimate && isNewEpoch && state.contentSizeAnimationEligible; | |
| 1692 | ++ // Retarget measurements received mid-animation, even in the same epoch. | |
| 1693 | ++ // Updating only the base keeps the old delta and can shrink scroll bounds. | |
| 1694 | ++ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible); | |
| 1682 | 1695 | + previousLogicalSizeRef.current = logicalSize; |
| 1683 | 1696 | + previousEpochRef.current = epoch; |
| 1684 | 1697 | + if (!delta) { |
| 1685 | 1698 | + return; |
| 1686 | 1699 | + } |
| 1687 | −+ if (!isNewEligibleEpoch) { | |
| 1700 | ++ if (!shouldAnimate) { | |
| 1688 | 1701 | + baseSize.value = logicalSize; |
| 1689 | 1702 | + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { |
| 1690 | 1703 | + state.contentSizeAnimationEligible = false; |
| @@ -1708,7 +1721,6 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 17 unmodified lines | ||
| 1708 | 1721 | + state.contentSizeAnimationActiveSignals.add(signalName); |
| 1709 | 1722 | + const wasAnimating = isAnimatingRef.current; |
| 1710 | 1723 | + isAnimatingRef.current = true; |
| 1711 | −+ setIsAnimating(true); | |
| 1712 | 1724 | + const run = ++animationRunRef.current; |
| 1713 | 1725 | + runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { |
| 1714 | 1726 | + "worklet"; |
| @@ -1723,18 +1735,12 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 8 unmodified lines | ||
| 1723 | 1735 | + })); |
| 1724 | 1736 | + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); |
| 1725 | 1737 | + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); |
| 1726 | −+ // Keep the content container under one animated size owner because detaching an | |
| 1727 | −+ // animated dimension can leave its native override behind. The alignment spacer | |
| 1728 | −+ // is empty, so it can safely remount as a numeric View for keyboard geometry. | |
| 1729 | −+ const isContentContainer = signalName === "totalSize"; | |
| 1730 | −+ const sizeStyle = isContentContainer || useAnimatedAxis ? animatedStyle : horizontal ? { width: logicalSize } : { height: logicalSize }; | |
| 1731 | −+ const SizeView = isContentContainer || useAnimatedAxis ? Reanimated.View : View; | |
| 1732 | −+ return /* @__PURE__ */ React.createElement(SizeView, { ...rest, style: [style, sizeStyle] }, children); | |
| 1738 | ++ return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, animatedStyle] }, children); | |
| 1733 | 1739 | +}); |
| 1734 | 1740 | function setSharedValueValue(sharedValue, value) { |
| 1735 | 1741 | if (!sharedValue) { |
| 1736 | 1742 | return; |
| 1737 | −@@ -221,10 +337,19 @@ var LegendListForwardedRef = typedMemo( | |
| 1743 | +@@ -221,10 +331,19 @@ var LegendListForwardedRef = typedMemo( | |
| 1738 | 1744 | ); |
| 1739 | 1745 | }; |
| 1740 | 1746 | }, [hasItemLayoutAnimation, recycleItems]); |
| @@ -472,7 +472,7 @@ importers: · 471 unmodified lines | ||
| 472 | 472 | dependencies: |
| 473 | 473 | '@anthropic-ai/claude-agent-sdk': |
| 474 | 474 | specifier: ^0.3.205 |
| 475 | − version: 0.3.251(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) | |
| 475 | + version: 0.3.252(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) | |
| 476 | 476 | '@connectrpc/connect': |
| 477 | 477 | specifier: 1.7.0 |
| 478 | 478 | version: 1.7.0(@bufbuild/protobuf@1.10.0) |
| @@ -1012,8 +1012,8 @@ packages: · 533 unmodified lines | ||
| 1012 | 1012 | '@alchemy.run/node-utils@0.0.5': |
| 1013 | 1013 | resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} |
| 1014 | 1014 | |
| 1015 | − '@anthropic-ai/claude-agent-sdk@0.3.251': | |
| 1016 | − resolution: {integrity: sha512-DqSi8mH2tQYRlVV0G+lJnQ/WbjJZ/a+8cJ3vPuYoqh8esIIvXHm1ZOXV1UPGsFYRnbBytEoiSGitguEXd+sQ+Q==} | |
| 1015 | + '@anthropic-ai/claude-agent-sdk@0.3.252': | |
| 1016 | + resolution: {integrity: sha512-hCkyZFn3J46aAMNqS6AZbYz91FaLUmX5VvJOzYZqzlVBJN47OxXQugqOzqa6b6GOZRmwiqW2ck8J8TE7bQZswQ==} | |
| 1017 | 1017 | engines: {node: '>=18.0.0'} |
| 1018 | 1018 | peerDependencies: |
| 1019 | 1019 | '@anthropic-ai/sdk': '>=0.93.0' |
| @@ -10540,7 +10540,7 @@ snapshots: · 9520 unmodified lines | ||
| 10540 | 10540 | |
| 10541 | 10541 | '@alchemy.run/node-utils@0.0.5': {} |
| 10542 | 10542 | |
| 10543 | − '@anthropic-ai/claude-agent-sdk@0.3.251(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': | |
| 10543 | + '@anthropic-ai/claude-agent-sdk@0.3.252(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': | |
| 10544 | 10544 | dependencies: |
| 10545 | 10545 | '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) |
| 10546 | 10546 | '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) |