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 | onTouchCancel={handleFeedTouchCancel} | 681 | onTouchCancel={handleFeedTouchCancel} |
| 682 | > | 682 | > |
| 683 | <ThreadFeed | 683 | <ThreadFeed |
| 684 | key={props.selectedThread.id} | 684 | key={selectedThreadKey} |
| 685 | environmentId={props.environmentId} | 685 | environmentId={props.environmentId} |
| 686 | threadId={props.selectedThread.id} | 686 | threadId={props.selectedThread.id} |
| 687 | workspaceRoot={props.threadCwd} | 687 | workspaceRoot={props.threadCwd} |
| @@ -1,6 +1,6 @@ | |||
| 1 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | 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 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 4 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 5 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; | 5 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 6 | import { | 6 | import { |
| @@ -79,7 +79,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 72 unmodified lines | |||
| 79 | import Animated, { | 79 | import Animated, { |
| 80 | FadeIn, | 80 | FadeIn, |
| 81 | FadeInUp, | 81 | FadeInUp, |
| 82 | FadeOut, | ||
| 83 | LinearTransition, | 82 | LinearTransition, |
| 84 | type SharedValue, | 83 | type SharedValue, |
| 85 | } from "react-native-reanimated"; | 84 | } from "react-native-reanimated"; |
| @@ -150,13 +149,19 @@ import { · 64 unmodified lines | |||
| 150 | type ThreadFeedLiveFollowEvent, | 149 | type ThreadFeedLiveFollowEvent, |
| 151 | } from "./thread-feed-live-follow"; | 150 | } from "./thread-feed-live-follow"; |
| 152 | import { | 151 | import { |
| 152 | ThreadDisclosureChevron, | ||
| 153 | THREAD_DISCLOSURE_TRANSITION_MS, | 153 | THREAD_DISCLOSURE_TRANSITION_MS, |
| 154 | ThreadWorkGroupToggle, | 154 | ThreadWorkGroupToggle, |
| 155 | ThreadWorkLog, | 155 | ThreadWorkLog, |
| 156 | } from "./thread-work-log"; | 156 | } from "./thread-work-log"; |
| 157 | import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; | 157 | import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; |
| 158 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | 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 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; | 165 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 161 | import { usePreparedConnection } from "../../state/session"; | 166 | import { usePreparedConnection } from "../../state/session"; |
| 162 | import * as Option from "effect/Option"; | 167 | import * as Option from "effect/Option"; |
| @@ -191,9 +196,10 @@ function formatMessageTime(input: string): string { · 28 unmodified lines | |||
| 191 | } | 196 | } |
| 192 | 197 | ||
| 193 | const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); | 198 | const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); |
| 194 | const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140); | 199 | // Let neighboring rows move out of the new rows' space before showing their text. |
| 195 | const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120); | 200 | const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( |
| 196 | const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set(); | 201 | THREAD_DISCLOSURE_TRANSITION_MS, |
| 202 | ).duration(140); | ||
| 197 | 203 | ||
| 198 | // Entering animations must only play for rows born just now — LegendList | 204 | // Entering animations must only play for rows born just now — LegendList |
| 199 | // remounts rows when they scroll back into view, and replaying an entrance for | 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 | const sourceIdentifier = useId(); | 347 | const sourceIdentifier = useId(); |
| 342 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | 348 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 349 | refresh: true, | ||
| 343 | reportFailure: false, | 350 | reportFailure: false, |
| 344 | }); | 351 | }); |
| 345 | const preparedConnection = usePreparedConnection(props.environmentId); | 352 | const preparedConnection = usePreparedConnection(props.environmentId); |
| @@ -557,9 +564,11 @@ function ThreadMarkdownImageView(props: { · 211 unmodified lines | |||
| 557 | readonly sourceKey: string; | 564 | readonly sourceKey: string; |
| 558 | readonly unavailable: boolean; | 565 | readonly unavailable: boolean; |
| 559 | readonly alt: string | null; | 566 | readonly alt: string | null; |
| 567 | readonly actionsSource?: MediaActionsSource; | ||
| 560 | readonly onPressPreview: (source: FilePreviewSource) => void; | 568 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 561 | }) { | 569 | }) { |
| 562 | const sourceIdentifier = useId(); | 570 | const sourceIdentifier = useId(); |
| 571 | const mediaActions = useMediaActions(props.actionsSource); | ||
| 563 | const [availableWidth, setAvailableWidth] = useState(0); | 572 | const [availableWidth, setAvailableWidth] = useState(0); |
| 564 | const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); | 573 | const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); |
| 565 | const [failedUri, setFailedUri] = useState<string | null>(null); | 574 | const [failedUri, setFailedUri] = useState<string | null>(null); |
| @@ -602,36 +611,51 @@ function ThreadMarkdownImageView(props: { · 36 unmodified lines | |||
| 602 | ) : ( | 611 | ) : ( |
| 603 | <ActivityIndicator /> | 612 | <ActivityIndicator /> |
| 604 | )} | 613 | )} |
| 614 | {props.actionsSource ? ( | ||
| 615 | <View className="absolute right-1 top-1"> | ||
| 616 | <MediaActionsMenu media={mediaActions} /> | ||
| 617 | </View> | ||
| 618 | ) : null} | ||
| 605 | </View> | 619 | </View> |
| 606 | ) : ( | 620 | ) : ( |
| 607 | <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> | 621 | <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}> |
| 608 | <Pressable | 622 | <View> |
| 609 | accessibilityRole="imagebutton" | 623 | <MediaActionsMenu media={mediaActions}> |
| 610 | accessibilityLabel={props.alt ?? "Markdown image"} | 624 | <Pressable |
| 611 | onPress={() => | 625 | accessibilityRole="imagebutton" |
| 612 | props.onPressPreview({ | 626 | accessibilityLabel={props.alt ?? "Markdown image"} |
| 613 | kind: "image", | 627 | onPress={() => |
| 614 | uri: props.uri!, | 628 | props.onPressPreview({ |
| 615 | name: props.alt ?? "Image", | 629 | kind: "image", |
| 616 | sourceIdentifier, | 630 | uri: props.uri!, |
| 617 | }) | 631 | name: props.alt ?? "Image", |
| 618 | } | 632 | sourceIdentifier, |
| 619 | style={{ alignSelf: "flex-start" }} | 633 | actionsSource: props.actionsSource, |
| 620 | > | 634 | }) |
| 621 | <View | 635 | } |
| 622 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" | 636 | style={{ alignSelf: "flex-start" }} |
| 623 | style={{ | 637 | > |
| 624 | ...frameStyle, | 638 | <View |
| 625 | }} | 639 | className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" |
| 626 | > | 640 | style={{ |
| 627 | <ThreadMarkdownImageRequest | 641 | ...frameStyle, |
| 628 | key={props.uri} | 642 | }} |
| 629 | uri={props.uri} | 643 | > |
| 630 | onLoad={setSourceSize} | 644 | <ThreadMarkdownImageRequest |
| 631 | onError={() => setFailedUri(props.uri)} | 645 | key={props.uri} |
| 632 | /> | 646 | uri={props.uri} |
| 633 | </View> | 647 | onLoad={setSourceSize} |
| 634 | </Pressable> | 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 | </PresentationSource> | 659 | </PresentationSource> |
| 636 | )} | 660 | )} |
| 637 | {props.alt ? ( | 661 | {props.alt ? ( |
| @@ -678,9 +702,10 @@ function ThreadMarkdownImageRequest(props: { · 40 unmodified lines | |||
| 678 | /** Environment-hosted image that loads through a signed asset URL. */ | 702 | /** Environment-hosted image that loads through a signed asset URL. */ |
| 679 | function ThreadMarkdownImage(props: { | 703 | function ThreadMarkdownImage(props: { |
| 680 | readonly environmentId: EnvironmentId; | 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 | readonly alt: string | null; | 706 | readonly alt: string | null; |
| 683 | readonly srcFragment?: string; | 707 | readonly srcFragment?: string; |
| 708 | readonly actionsSource?: MediaActionsSource; | ||
| 684 | readonly onPressPreview: (source: FilePreviewSource) => void; | 709 | readonly onPressPreview: (source: FilePreviewSource) => void; |
| 685 | }) { | 710 | }) { |
| 686 | const assetUrl = useAssetUrlState(props.environmentId, props.resource); | 711 | const assetUrl = useAssetUrlState(props.environmentId, props.resource); |
| @@ -695,11 +720,59 @@ function ThreadMarkdownImage(props: { · 8 unmodified lines | |||
| 695 | } | 720 | } |
| 696 | unavailable={assetUrl._tag === "Failure"} | 721 | unavailable={assetUrl._tag === "Failure"} |
| 697 | alt={props.alt} | 722 | alt={props.alt} |
| 723 | actionsSource={props.actionsSource} | ||
| 698 | onPressPreview={props.onPressPreview} | 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 | function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { | 776 | function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { |
| 704 | return ( | 777 | return ( |
| 705 | <ThreadMarkdownImageView | 778 | <ThreadMarkdownImageView |
| @@ -1287,21 +1360,16 @@ function useMarkdownStyles( · 581 unmodified lines | |||
| 1287 | title: node.title ?? null, | 1360 | title: node.title ?? null, |
| 1288 | }) ?? undefined) | 1361 | }) ?? undefined) |
| 1289 | : undefined, | 1362 | : undefined, |
| 1290 | code_inline: ({ content }) => { | 1363 | code_inline: ({ content }) => ( |
| 1291 | const value = content ?? ""; | 1364 | <MarkdownInlineCode |
| 1292 | return ( | 1365 | content={content ?? ""} |
| 1293 | <NativeText | 1366 | textColor={inlineTextColor} |
| 1294 | className="font-mono" | 1367 | codeColor={inlineCodeTextColor} |
| 1295 | style={{ | 1368 | fontSize={markdownFontSizes.codeBlockFontSize} |
| 1296 | color: inlineCodeTextColor, | 1369 | lineHeight={markdownFontSizes.bodyLineHeight} |
| 1297 | fontSize: markdownFontSizes.codeBlockFontSize, | 1370 | onLinkPress={onLinkPress} |
| 1298 | lineHeight: markdownFontSizes.bodyLineHeight, | 1371 | /> |
| 1299 | }} | 1372 | ), |
| 1300 | > | ||
| 1301 | {value} | ||
| 1302 | </NativeText> | ||
| 1303 | ); | ||
| 1304 | }, | ||
| 1305 | ...(preserveSoftBreaks | 1373 | ...(preserveSoftBreaks |
| 1306 | ? { | 1374 | ? { |
| 1307 | soft_break: () => <NativeText>{"\n"}</NativeText>, | 1375 | soft_break: () => <NativeText>{"\n"}</NativeText>, |
| @@ -1504,7 +1572,7 @@ function renderFeedEntry( · 196 unmodified lines | |||
| 1504 | accessibilityState={{ expanded: entry.expanded }} | 1572 | accessibilityState={{ expanded: entry.expanded }} |
| 1505 | onPress={() => props.onToggleTurnFold(entry.runId)} | 1573 | onPress={() => props.onToggleTurnFold(entry.runId)} |
| 1506 | hitSlop={4} | 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 | <Text | 1577 | <Text |
| 1510 | key={props.workRowSizing.textSizeKey} | 1578 | key={props.workRowSizing.textSizeKey} |
| @@ -1516,8 +1584,7 @@ function renderFeedEntry( · 5 unmodified lines | |||
| 1516 | expanded={entry.expanded} | 1584 | expanded={entry.expanded} |
| 1517 | collapsedDirection="right" | 1585 | collapsedDirection="right" |
| 1518 | size={15} | 1586 | size={15} |
| 1519 | tintColorClassName={"accent-icon-subtle"} | 1587 | tintColor={iconSubtleColor} |
| 1520 | type="monochrome" | ||
| 1521 | /> | 1588 | /> |
| 1522 | </Pressable> | 1589 | </Pressable> |
| 1523 | ); | 1590 | ); |
| @@ -2092,7 +2159,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 568 unmodified lines | |||
| 2092 | const [endFollowEnabled, setEndFollowEnabled] = useState(true); | 2159 | const [endFollowEnabled, setEndFollowEnabled] = useState(true); |
| 2093 | const endFollowEnabledRef = useRef(true); | 2160 | const endFollowEnabledRef = useRef(true); |
| 2094 | // A "user scroll session" spans from drag start through the end of its | 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 | // compensations and programmatic scrolls never strand a follower. | 2163 | // compensations and programmatic scrolls never strand a follower. |
| 2097 | const userScrollSessionRef = useRef(false); | 2164 | const userScrollSessionRef = useRef(false); |
| 2098 | const setEndFollow = useCallback( | 2165 | const setEndFollow = useCallback( |
| @@ -2253,14 +2320,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 154 unmodified lines | |||
| 2253 | ); | 2320 | ); |
| 2254 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( | 2321 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( |
| 2255 | (image) => { | 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 | const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); | 2338 | const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); |
| 2257 | if (imageSource._tag === "Direct") { | 2339 | if (imageSource._tag === "Direct") { |
| 2258 | return ( | 2340 | return ( |
| 2259 | <ThreadMarkdownImageView | 2341 | <ThreadMarkdownImageView |
| 2260 | uri={imageSource.uri} | 2342 | uri={normalizeNativeMarkdownUrl(imageSource.uri)} |
| 2261 | sourceKey={imageSource.uri} | 2343 | sourceKey={imageSource.uri} |
| 2262 | unavailable={false} | 2344 | unavailable={false} |
| 2263 | alt={image.alt} | 2345 | alt={image.alt} |
| 2346 | actionsSource={media?.source.actionsSource} | ||
| 2264 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} | 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 | <ThreadMarkdownImage | 2355 | <ThreadMarkdownImage |
| 2273 | environmentId={props.environmentId} | 2356 | environmentId={props.environmentId} |
| 2274 | resource={{ | 2357 | resource={{ |
| 2275 | _tag: "workspace-file", | 2358 | _tag: "media-file", |
| 2276 | threadId: props.threadId, | 2359 | threadId: props.threadId, |
| 2277 | path: imageSource.path, | 2360 | path: imageSource.path, |
| 2278 | }} | 2361 | }} |
| 2279 | alt={image.alt} | 2362 | alt={image.alt} |
| 2280 | srcFragment={markdownImageSourceFragment(image.href)} | 2363 | srcFragment={markdownImageSourceFragment(image.href)} |
| 2364 | actionsSource={media?.source.actionsSource} | ||
| 2281 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} | 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 | threadId: props.threadId, | 2374 | threadId: props.threadId, |
| 2291 | workspaceRoot: props.workspaceRoot, | 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 | return viewedImage ? ( | 2386 | return viewedImage ? ( |
| 2294 | <ThreadMarkdownImage | 2387 | <ThreadMarkdownImage |
| 2295 | environmentId={props.environmentId} | 2388 | environmentId={props.environmentId} |
| 2296 | resource={viewedImage.resource} | 2389 | resource={viewedImage.resource} |
| 2297 | alt={viewedImage.alt} | 2390 | alt={viewedImage.alt} |
| 2298 | srcFragment={viewedImage.srcFragment} | 2391 | srcFragment={viewedImage.srcFragment} |
| 2392 | actionsSource={ | ||
| 2393 | actionsSource && "resource" in actionsSource | ||
| 2394 | ? { ...actionsSource, resource: viewedImage.resource } | ||
| 2395 | : undefined | ||
| 2396 | } | ||
| 2299 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} | 2397 | onPressPreview={(source) => setExpandedFile((current) => current ?? source)} |
| 2300 | /> | 2398 | /> |
| 2301 | ) : null; | 2399 | ) : null; |
| @@ -2572,13 +2670,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 270 unmodified lines | |||
| 2572 | } | 2670 | } |
| 2573 | disclosureSettleFrameRef.current = requestAnimationFrame(() => { | 2671 | disclosureSettleFrameRef.current = requestAnimationFrame(() => { |
| 2574 | disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { | 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 | disclosureAnchorKeyRef.current = null; | 2683 | disclosureAnchorKeyRef.current = null; |
| 2576 | setDisclosureToggleSettling(false); | 2684 | setDisclosureToggleSettling(false); |
| 2577 | disclosureSettleFrameRef.current = null; | 2685 | disclosureSettleFrameRef.current = null; |
| 2578 | disclosureSettleSecondFrameRef.current = null; | 2686 | disclosureSettleSecondFrameRef.current = null; |
| 2579 | }); | 2687 | }); |
| 2580 | }); | 2688 | }); |
| 2581 | }, []); | 2689 | }, [props.listRef, transitionEndFollow]); |
| 2582 | 2690 | ||
| 2583 | const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { | 2691 | const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { |
| 2584 | disclosureAnchorKeyRef.current = anchorKey; | 2692 | disclosureAnchorKeyRef.current = anchorKey; |
| @@ -2713,44 +2821,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 128 unmodified lines | |||
| 2713 | (info: { item: ThreadFeedEntry; index: number }) => ( | 2821 | (info: { item: ThreadFeedEntry; index: number }) => ( |
| 2714 | <Animated.View | 2822 | <Animated.View |
| 2715 | key={info.item.id} | 2823 | key={info.item.id} |
| 2716 | entering={ | 2824 | entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined} |
| 2717 | disclosureEnteringEntryIds.has(info.item.id) | ||
| 2718 | ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION | ||
| 2719 | : undefined | ||
| 2720 | } | ||
| 2721 | exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION} | ||
| 2722 | > | 2825 | > |
| 2723 | {renderFeedEntry(info, { | 2826 | <ThreadMediaVisibility> |
| 2724 | environmentId: props.environmentId, | 2827 | {renderFeedEntry(info, { |
| 2725 | threadId: props.threadId, | 2828 | environmentId: props.environmentId, |
| 2726 | copiedRowId, | 2829 | threadId: props.threadId, |
| 2727 | expandedWorkRows, | 2830 | copiedRowId, |
| 2728 | terminalAssistantMessageIds, | 2831 | expandedWorkRows, |
| 2729 | unsettledTurnId, | 2832 | terminalAssistantMessageIds, |
| 2730 | onCopyWorkRow, | 2833 | unsettledTurnId, |
| 2731 | onToggleWorkGroup, | 2834 | onCopyWorkRow, |
| 2732 | onToggleWorkRow, | 2835 | onToggleWorkGroup, |
| 2733 | onToggleTurnFold, | 2836 | onToggleWorkRow, |
| 2734 | onPressPreview, | 2837 | onToggleTurnFold, |
| 2735 | onPressVideo, | 2838 | onPressPreview, |
| 2736 | onMarkdownLinkPress, | 2839 | onPressVideo, |
| 2737 | renderMarkdownImage, | 2840 | onMarkdownLinkPress, |
| 2738 | renderViewedImage, | 2841 | renderMarkdownImage, |
| 2739 | iconSubtleColor, | 2842 | renderViewedImage, |
| 2740 | userBubbleColor, | 2843 | iconSubtleColor, |
| 2741 | markdownStyles, | 2844 | userBubbleColor, |
| 2742 | reviewCommentColors, | 2845 | markdownStyles, |
| 2743 | reviewCommentBubbleWidth, | 2846 | reviewCommentColors, |
| 2744 | userBubbleMaxWidth, | 2847 | reviewCommentBubbleWidth, |
| 2745 | threadTitle: props.threadTitle, | 2848 | userBubbleMaxWidth, |
| 2746 | skills: props.skills, | 2849 | threadTitle: props.threadTitle, |
| 2747 | workspaceRoot: props.workspaceRoot, | 2850 | skills: props.skills, |
| 2748 | })} | 2851 | workspaceRoot: props.workspaceRoot, |
| 2852 | })} | ||
| 2853 | </ThreadMediaVisibility> | ||
| 2749 | </Animated.View> | 2854 | </Animated.View> |
| 2750 | ), | 2855 | ), |
| 2751 | [ | 2856 | [ |
| 2752 | copiedRowId, | 2857 | copiedRowId, |
| 2753 | disclosureEnteringEntryIds, | 2858 | disclosureToggleSettling, |
| 2754 | expandedWorkRows, | 2859 | expandedWorkRows, |
| 2755 | workRowSizing, | 2860 | workRowSizing, |
| 2756 | workGroupScrollPositions, | 2861 | workGroupScrollPositions, |
| @@ -2863,6 +2968,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 106 unmodified lines | |||
| 2863 | data={presentedFeed} | 2968 | data={presentedFeed} |
| 2864 | extraData={listAppearanceData} | 2969 | extraData={listAppearanceData} |
| 2865 | renderItem={renderItem} | 2970 | renderItem={renderItem} |
| 2971 | viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} | ||
| 2866 | keyExtractor={(entry) => entry.id} | 2972 | keyExtractor={(entry) => entry.id} |
| 2867 | getItemType={(entry) => | 2973 | getItemType={(entry) => |
| 2868 | entry.type === "message" ? `message:${entry.message.role}` : entry.type | 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 | <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} /> | 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 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 23 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 24 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | 24 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; |
| 25 | import { | 25 | import { |
| 26 | resolveThreadListV2SnoozeMenuSelection, | 26 | resolveThreadListV2ChangeRequestState, |
| 27 | resolveThreadListV2SnoozeGateExpiryMs, | 27 | resolveThreadListV2SnoozeGateExpiryMs, |
| 28 | resolveThreadListV2SnoozeMenuSelection, | ||
| 28 | resolveThreadListV2Status, | 29 | resolveThreadListV2Status, |
| 29 | resolveThreadListV2SwipeActions, | 30 | resolveThreadListV2SwipeActions, |
| 31 | threadHasUnseenCompletion, | ||
| 32 | type ThreadListV2ChangeRequestState, | ||
| 30 | type ThreadListV2Status, | 33 | type ThreadListV2Status, |
| 31 | } from "./threadListV2"; | 34 | } from "./threadListV2"; |
| 32 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; | 35 | import { ThreadSearchMatchExcerpt } from "./thread-search-match"; |
| @@ -368,6 +371,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 335 unmodified lines | |||
| 368 | readonly canMovePinnedDown?: boolean; | 371 | readonly canMovePinnedDown?: boolean; |
| 369 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | 372 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 370 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | 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 | readonly projectCwd?: string | null; | 380 | readonly projectCwd?: string | null; |
| 372 | readonly searchMatch?: EnvironmentThreadSearchMatch; | 381 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 373 | readonly searchQuery?: string; | 382 | readonly searchQuery?: string; |
| @@ -390,11 +399,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 16 unmodified lines | |||
| 390 | onPinThread, | 399 | onPinThread, |
| 391 | onUnpinThread, | 400 | onUnpinThread, |
| 392 | onMovePinnedThread, | 401 | onMovePinnedThread, |
| 402 | onChangeRequestState, | ||
| 393 | } = props; | 403 | } = props; |
| 394 | const snoozedRow = props.snoozed === true; | 404 | const snoozedRow = props.snoozed === true; |
| 395 | const pinnedRow = props.pinned === true; | 405 | const pinnedRow = props.pinned === true; |
| 396 | 406 | ||
| 397 | const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); | 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 | const theme = useUniwindTheme(); | 421 | const theme = useUniwindTheme(); |
| 400 | const screenColor = theme["--color-screen"]; | 422 | const screenColor = theme["--color-screen"]; |
| @@ -438,8 +460,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 37 unmodified lines | |||
| 438 | ); | 460 | ); |
| 439 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); | 461 | const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); |
| 440 | 462 | ||
| 441 | // Swipe: the v2 primary action is the lifecycle transition. Un-settling a | 463 | // Swipe: the v2 primary action is the lifecycle transition. Every settled |
| 442 | // settled row keeps it active until new activity clears the user override. | 464 | // row can un-settle — explicit settles clear the override, auto-settled |
| 465 | // rows get pinned active until real activity clears the pin. | ||
| 443 | const canUnsettle = variant === "slim"; | 466 | const canUnsettle = variant === "slim"; |
| 444 | const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); | 467 | const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); |
| 445 | const snoozeGateExpiryMs = props.snoozeSupported | 468 | const snoozeGateExpiryMs = props.snoozeSupported |
| @@ -578,7 +578,9 @@ describe("buildThreadListV2Items", () => { · 577 unmodified lines | |||
| 578 | ], | 578 | ], |
| 579 | environmentId: null, | 579 | environmentId: null, |
| 580 | searchQuery: "", | 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 | expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); | 586 | expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); |
| @@ -1,11 +1,15 @@ | |||
| 1 | import { | 1 | import { |
| 2 | effectiveSettled, | ||
| 2 | effectiveSnoozed, | 3 | effectiveSnoozed, |
| 3 | hasQueuedTurnStart, | 4 | hasQueuedTurnStart, |
| 4 | QUEUED_TURN_START_GRACE_MS, | 5 | QUEUED_TURN_START_GRACE_MS, |
| 5 | resolveSnoozePresets, | 6 | resolveSnoozePresets, |
| 6 | snoozeWakeLabel, | 7 | snoozeWakeLabel, |
| 7 | } from "@t3tools/client-runtime/state/thread-settled"; | 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 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 13 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 10 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 14 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 11 | import { | 15 | import { |
| @@ -13,7 +17,7 @@ import { · 1 unmodified line | |||
| 13 | resolveSettledThreadTimestamp, | 17 | resolveSettledThreadTimestamp, |
| 14 | sortPinnedThreadsByOrderKey, | 18 | sortPinnedThreadsByOrderKey, |
| 15 | } from "@t3tools/client-runtime/state/thread-sort"; | 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 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 22 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 19 | 23 | ||
| @@ -32,6 +36,35 @@ export { snoozeWakeLabel }; · 12 unmodified lines | |||
| 32 | export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready"; | 36 | export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready"; |
| 33 | export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; | 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 | export function resolveThreadListV2SnoozeMenuSelection(input: { | 68 | export function resolveThreadListV2SnoozeMenuSelection(input: { |
| 36 | readonly event: string; | 69 | readonly event: string; |
| 37 | readonly displayedPresets: ReadonlyArray<SnoozePreset>; | 70 | readonly displayedPresets: ReadonlyArray<SnoozePreset>; |
| @@ -329,7 +362,8 @@ export function buildThreadListV2ListItems(input: { · 291 unmodified lines | |||
| 329 | 362 | ||
| 330 | /** | 363 | /** |
| 331 | * Partitions visible threads into the active card block (creation order) and | 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 | export function buildThreadListV2Items(input: { | 368 | export function buildThreadListV2Items(input: { |
| 335 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 369 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| @@ -340,6 +374,8 @@ export function buildThreadListV2Items(input: { · 4 unmodified lines | |||
| 340 | }> | null; | 374 | }> | null; |
| 341 | readonly searchQuery: string; | 375 | readonly searchQuery: string; |
| 342 | readonly matchedThreadKeys?: ReadonlySet<string>; | 376 | readonly matchedThreadKeys?: ReadonlySet<string>; |
| 377 | /** Per-row PR reported up by visible rows ("env:threadId" keys). */ | ||
| 378 | readonly changeRequestByKey?: ReadonlyMap<string, ThreadListV2ChangeRequestState>; | ||
| 343 | /** Environments whose server supports thread.settle/unsettle. Threads on | 379 | /** Environments whose server supports thread.settle/unsettle. Threads on |
| 344 | other environments never classify as settled — the user could neither | 380 | other environments never classify as settled — the user could neither |
| 345 | un-settle nor pin them. Absent = no gating (tests). */ | 381 | un-settle nor pin them. Absent = no gating (tests). */ |
| @@ -347,10 +383,17 @@ export function buildThreadListV2Items(input: { · 1 unmodified line | |||
| 347 | /** Environments whose server supports thread.snooze/unsnooze. Same | 383 | /** Environments whose server supports thread.snooze/unsnooze. Same |
| 348 | contract as settlementEnvironmentIds. */ | 384 | contract as settlementEnvironmentIds. */ |
| 349 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; | 385 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; |
| 386 | readonly autoSettleAfterDays?: number; | ||
| 387 | readonly autoSettleOnMerge?: boolean; | ||
| 350 | /** Max settled rows to render; the rest are counted, not built. */ | 388 | /** Max settled rows to render; the rest are counted, not built. */ |
| 351 | readonly settledLimit?: number; | 389 | readonly settledLimit?: number; |
| 352 | /** Second-precise clock used for time-based classification. */ | 390 | /** Injectable for tests; defaults to now. */ |
| 353 | readonly now: string; | 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 | /** Expands the snoozed shelf into rows. Collapsed is the default. */ | 397 | /** Expands the snoozed shelf into rows. Collapsed is the default. */ |
| 355 | readonly snoozedShelfExpanded?: boolean; | 398 | readonly snoozedShelfExpanded?: boolean; |
| 356 | /** Expands the settled shelf into rows. Expanded is the default. */ | 399 | /** Expands the settled shelf into rows. Expanded is the default. */ |
| @@ -359,7 +402,10 @@ export function buildThreadListV2Items(input: { · 2 unmodified lines | |||
| 359 | a split-view detail can never lose its navigation row. */ | 402 | a split-view detail can never lose its navigation row. */ |
| 360 | readonly selectedThreadKey?: string | null; | 403 | readonly selectedThreadKey?: string | null; |
| 361 | }): ThreadListV2Layout { | 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 | const query = input.searchQuery.trim().toLocaleLowerCase(); | 409 | const query = input.searchQuery.trim().toLocaleLowerCase(); |
| 364 | const projectKeys = input.projectRefs | 410 | const projectKeys = input.projectRefs |
| 365 | ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) | 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 | const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; | 439 | const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; |
| 394 | const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; | 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 | // Snooze outranks settlement and pinning until the thread wakes. | 449 | // Snooze outranks settlement and pinning until the thread wakes. |
| 396 | if (supportsSnooze && effectiveSnoozed(thread, { now })) { | 450 | if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { |
| 397 | snoozed.push(thread); | 451 | snoozed.push(thread); |
| 398 | if ( | 452 | if ( |
| 399 | thread.snoozedUntil != null && | 453 | thread.snoozedUntil != null && |
| @@ -404,7 +458,15 @@ export function buildThreadListV2Items(input: { · 4 unmodified lines | |||
| 404 | } | 458 | } |
| 405 | continue; | 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 | settled.push(thread); | 470 | settled.push(thread); |
| 409 | } else if (thread.pinnedAt != null) { | 471 | } else if (thread.pinnedAt != null) { |
| 410 | pinned.push(thread); | 472 | pinned.push(thread); |
| @@ -7,12 +7,14 @@ import { · 6 unmodified lines | |||
| 7 | } from "@t3tools/shared/model"; | 7 | } from "@t3tools/shared/model"; |
| 8 | 8 | ||
| 9 | import { | 9 | import { |
| 10 | getClaudeModelCapabilities, | 10 | BUNDLED_CLAUDE_MODEL_CATALOG, |
| 11 | isClaudeUltracodeEffort, | 11 | getClaudeCatalogModelCapabilities, |
| 12 | normalizeClaudeCliEffort, | 12 | isClaudeCatalogUltracodeEffort, |
| 13 | resolveClaudeApiModelId, | 13 | normalizeClaudeCatalogEffort, |
| 14 | resolveClaudeEffort, | 14 | resolveClaudeCatalogApiModelId, |
| 15 | } from "./provider/Layers/ClaudeProvider.ts"; | 15 | resolveClaudeCatalogEffort, |
| 16 | type ClaudeModelCatalog, | ||
| 17 | } from "./provider/ClaudeModelCatalog.ts"; | ||
| 16 | 18 | ||
| 17 | export interface CompiledClaudeModelSelection { | 19 | export interface CompiledClaudeModelSelection { |
| 18 | readonly apiModelId: string; | 20 | readonly apiModelId: string; |
| @@ -25,14 +27,15 @@ export interface CompiledClaudeModelSelection { · 6 unmodified lines | |||
| 25 | /** Compile every Claude model option at the provider boundary. */ | 27 | /** Compile every Claude model option at the provider boundary. */ |
| 26 | export function compileClaudeModelSelection( | 28 | export function compileClaudeModelSelection( |
| 27 | selection: ModelSelection, | 29 | selection: ModelSelection, |
| 30 | catalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, | ||
| 28 | ): CompiledClaudeModelSelection { | 31 | ): CompiledClaudeModelSelection { |
| 29 | const capabilities = getClaudeModelCapabilities(selection.model); | 32 | const capabilities = getClaudeCatalogModelCapabilities(catalog, selection.model); |
| 30 | const descriptors = getProviderOptionDescriptors({ caps: capabilities }); | 33 | const descriptors = getProviderOptionDescriptors({ caps: capabilities }); |
| 31 | const supportsBoolean = (id: string) => | 34 | const supportsBoolean = (id: string) => |
| 32 | descriptors.some((descriptor) => descriptor.type === "boolean" && descriptor.id === id); | 35 | descriptors.some((descriptor) => descriptor.type === "boolean" && descriptor.id === id); |
| 33 | const rawEffort = getModelSelectionStringOptionValue(selection, "effort"); | 36 | const rawEffort = getModelSelectionStringOptionValue(selection, "effort"); |
| 34 | const resolvedEffort = resolveClaudeEffort(capabilities, rawEffort); | 37 | const resolvedEffort = resolveClaudeCatalogEffort(catalog, selection.model, rawEffort); |
| 35 | const effort = normalizeClaudeCliEffort(resolvedEffort, selection.model); | 38 | const effort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, selection.model); |
| 36 | const fastMode = supportsBoolean("fastMode") | 39 | const fastMode = supportsBoolean("fastMode") |
| 37 | ? getModelSelectionBooleanOptionValue(selection, "fastMode") | 40 | ? getModelSelectionBooleanOptionValue(selection, "fastMode") |
| 38 | : undefined; | 41 | : undefined; |
| @@ -42,9 +45,9 @@ export function compileClaudeModelSelection( · 3 unmodified lines | |||
| 42 | const settings = { | 45 | const settings = { |
| 43 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), | 46 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), |
| 44 | ...(typeof fastMode === "boolean" ? { fastMode } : {}), | 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 | const promptEffort = resolvePromptInjectedEffort(capabilities, rawEffort) ?? undefined; | 51 | const promptEffort = resolvePromptInjectedEffort(capabilities, rawEffort) ?? undefined; |
| 49 | return { | 52 | return { |
| 50 | apiModelId, | 53 | apiModelId, |
| @@ -1,12 +1,13 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | 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 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 6 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 5 | import * as Path from "effect/Path"; | 8 | import * as Path from "effect/Path"; |
| 6 | import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; | 9 | import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; |
| 7 | import { describe } from "vite-plus/test"; | 10 | import { openMediaFile } from "./assets/MediaFile.ts"; |
| 8 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; | ||
| 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 10 | 11 | ||
| 11 | import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; | 12 | import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; |
| 12 | 13 | ||
| @@ -70,6 +71,179 @@ describe("browser API CORS", () => { · 57 unmodified lines | |||
| 70 | const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); | 71 | const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); |
| 71 | 72 | ||
| 72 | describe("video asset byte ranges", () => { | 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 | it.effect("streams exactly the requested bytes and leaves full downloads intact", () => | 247 | it.effect("streams exactly the requested bytes and leaves full downloads intact", () => |
| 74 | Effect.gen(function* () { | 248 | Effect.gen(function* () { |
| 75 | const fs = yield* FileSystem.FileSystem; | 249 | const fs = yield* FileSystem.FileSystem; |
| @@ -77,7 +77,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; · 76 unmodified lines | |||
| 77 | import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; | 77 | import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; |
| 78 | import { ServerConfig } from "../../config.ts"; | 78 | import { ServerConfig } from "../../config.ts"; |
| 79 | import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; | 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 | import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; | 84 | import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; |
| 82 | import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; | 85 | import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; |
| 83 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | 86 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| @@ -134,7 +137,9 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null { · 50 unmodified lines | |||
| 134 | if (modelSelection.model === "claude-opus-4-6" || modelSelection.model === "claude-opus-4-7") { | 137 | if (modelSelection.model === "claude-opus-4-6" || modelSelection.model === "claude-opus-4-7") { |
| 135 | return 1_000_000; | 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 | export function claudeProviderTurnTokenUsage( | 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 | }).pipe( | 3060 | }).pipe( |
| 3031 | Effect.mapError( | 3061 | Effect.mapError( |
| 3032 | (cause) => | 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 | const eventBase = yield* decideOrchestrationCommand({ | 169 | const eventBase = yield* decideOrchestrationCommand({ |
| 200 | command: envelope.command, | 170 | command: envelope.command, |
| 201 | readModel: commandReadModel, | 171 | readModel: commandReadModel, |
| 202 | ...(Option.isSome(userInputActivity) | ||
| 203 | ? { userInputActivity: userInputActivity.value } | ||
| 204 | : {}), | ||
| 205 | }).pipe( | 172 | }).pipe( |
| 206 | Effect.provideService(Crypto.Crypto, crypto), | 173 | Effect.provideService(Crypto.Crypto, crypto), |
| 207 | Effect.mapError((cause) => | 174 | Effect.mapError((cause) => |
| @@ -1018,34 +1018,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1017 unmodified lines | |||
| 1018 | return; | 1018 | return; |
| 1019 | 1019 | ||
| 1020 | case "thread.message-sent": { | 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 | const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ | 1021 | const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ |
| 1042 | messageId: event.payload.messageId, | 1022 | messageId: event.payload.messageId, |
| 1043 | }); | 1023 | }); |
| 1044 | const previousMessage = Option.getOrUndefined(existingMessage); | 1024 | const previousMessage = Option.getOrUndefined(existingMessage); |
| 1045 | const nextText = Option.match(existingMessage, { | 1025 | const nextText = Option.match(existingMessage, { |
| 1046 | onNone: () => event.payload.text, | 1026 | onNone: () => event.payload.text, |
| 1047 | onSome: (message) => | 1027 | onSome: (message) => { |
| 1048 | event.payload.text.length === 0 ? message.text : event.payload.text, | 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 | const nextAttachments = | 1037 | const nextAttachments = |
| 1051 | event.payload.attachments !== undefined | 1038 | event.payload.attachments !== undefined |
| @@ -1060,7 +1047,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 8 unmodified lines | |||
| 1060 | role: event.payload.role, | 1047 | role: event.payload.role, |
| 1061 | text: nextText, | 1048 | text: nextText, |
| 1062 | ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), | 1049 | ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), |
| 1063 | isStreaming: false, | 1050 | isStreaming: event.payload.streaming, |
| 1064 | createdAt: previousMessage?.createdAt ?? event.payload.createdAt, | 1051 | createdAt: previousMessage?.createdAt ?? event.payload.createdAt, |
| 1065 | updatedAt: event.payload.updatedAt, | 1052 | updatedAt: event.payload.updatedAt, |
| 1066 | }); | 1053 | }); |
| @@ -77,9 +77,6 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); · 76 unmodified lines | |||
| 77 | // activity window. Applying the limit in SQL avoids decoding an unbounded | 77 | // activity window. Applying the limit in SQL avoids decoding an unbounded |
| 78 | // payload_json set before the projector can enforce that invariant. | 78 | // payload_json set before the projector can enforce that invariant. |
| 79 | const THREAD_DETAIL_ACTIVITY_LIMIT = 500; | 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 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | 80 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 84 | Struct.assign({ | 81 | Struct.assign({ |
| 85 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), | 82 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| @@ -104,9 +101,6 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( · 18 unmodified lines | |||
| 104 | sequence: Schema.NullOr(NonNegativeInt), | 101 | sequence: Schema.NullOr(NonNegativeInt), |
| 105 | }), | 102 | }), |
| 106 | ); | 103 | ); |
| 107 | const ProjectionThreadActivityIdRowSchema = Schema.Struct({ | ||
| 108 | activityId: ProjectionThreadActivity.fields.activityId, | ||
| 109 | }); | ||
| 110 | const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; | 104 | const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; |
| 111 | const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ | 105 | const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ |
| 112 | id: ThreadId, | 106 | id: ThreadId, |
| @@ -166,9 +160,6 @@ const ThreadActivityKindsLookupInput = Schema.Struct({ · 53 unmodified lines | |||
| 166 | threadId: ThreadId, | 160 | threadId: ThreadId, |
| 167 | activityKinds: Schema.Array(Schema.String), | 161 | activityKinds: Schema.Array(Schema.String), |
| 168 | }); | 162 | }); |
| 169 | const ThreadActivityIdsLookupInput = Schema.Struct({ | ||
| 170 | activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId), | ||
| 171 | }); | ||
| 172 | // Windowed reads order turns by the stable keyset (anchor, turn key), where | 163 | // Windowed reads order turns by the stable keyset (anchor, turn key), where |
| 173 | // anchor is requested_at and turn key is | 164 | // anchor is requested_at and turn key is |
| 174 | // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the | 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 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | 366 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { |
| 391 | return (cause: unknown): ProjectionRepositoryError => | 367 | return (cause: unknown): ProjectionRepositoryError => |
| 392 | Schema.isSchemaError(cause) | 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 | const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ | 1166 | const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ |
| 1229 | Request: ThreadActivityKindsLookupInput, | 1167 | Request: ThreadActivityKindsLookupInput, |
| 1230 | Result: ProjectionThreadActivityDbRowSchema, | 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` | 1416 | // Blocking request payloads must remain available even if they predate the |
| 1479 | pending_approval_requests AS ( | 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 | SELECT request_id, thread_id | 1425 | SELECT request_id, thread_id |
| 1481 | FROM projection_pending_approvals | 1426 | FROM projection_pending_approvals |
| 1482 | WHERE thread_id = ${threadId} | 1427 | WHERE thread_id = ${threadId} |
| @@ -1540,17 +1485,6 @@ pending_approval_requests AS ( · 57 unmodified lines | |||
| 1540 | WHERE request_order = 1 | 1485 | WHERE request_order = 1 |
| 1541 | AND kind = 'user-input.requested' | 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 | SELECT | 1488 | SELECT |
| 1555 | activity.activity_id AS "activityId", | 1489 | activity.activity_id AS "activityId", |
| 1556 | activity.thread_id AS "threadId", | 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 | const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ | 1505 | const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ |
| 1583 | Request: ThreadTurnRangeLookupInput, | 1506 | Request: ThreadTurnRangeLookupInput, |
| 1584 | Result: ProjectionThreadActivityDbRowSchema, | 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 | const getFullThreadDiffContextRow = SqlSchema.findOneOption({ | 1572 | const getFullThreadDiffContextRow = SqlSchema.findOneOption({ |
| 1692 | Request: FullThreadDiffContextLookupInput, | 1573 | Request: FullThreadDiffContextLookupInput, |
| 1693 | Result: ProjectionFullThreadDiffContextRowSchema, | 1574 | Result: ProjectionFullThreadDiffContextRowSchema, |
| @@ -2832,136 +2713,49 @@ pending_approval_requests AS ( · 1138 unmodified lines | |||
| 2832 | readonly beforeTurnKey: string; | 2713 | readonly beforeTurnKey: string; |
| 2833 | } | 2714 | } |
| 2834 | 2715 | ||
| 2835 | type ThreadDetailActivityRead = | 2716 | const getThreadDetailByIdBounded = ( |
| 2836 | | { | 2717 | threadId: ThreadId, |
| 2837 | readonly mode: "raw"; | 2718 | bounds: ThreadDetailBounds | undefined, |
| 2838 | readonly query?: ProjectionThreadDetailQuery; | 2719 | query: ProjectionThreadDetailQuery | undefined = undefined, |
| 2839 | } | 2720 | ) => |
| 2840 | | { | 2721 | Effect.gen(function* () { |
| 2841 | readonly mode: "client"; | 2722 | const activityRowsEffect = ( |
| 2842 | }; | 2723 | query?.activityKinds === undefined |
| 2843 | 2724 | ? bounds === undefined | |
| 2844 | const listProjectedThreadActivities = Effect.fn( | 2725 | ? listThreadActivityRowsByThread({ threadId }) |
| 2845 | "ProjectionSnapshotQuery.listProjectedThreadActivities", | 2726 | : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) |
| 2846 | )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) { | 2727 | : query.activityKinds.length === 0 |
| 2847 | const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([ | 2728 | ? Effect.succeed([]) |
| 2848 | (bounds === undefined | 2729 | : listThreadActivityRowsByThreadAndKinds({ |
| 2849 | ? listThreadActivityIdsByThread({ threadId }) | 2730 | threadId, |
| 2850 | : listThreadActivityIdsByThreadWindow({ threadId, ...bounds }) | 2731 | activityKinds: query.activityKinds, |
| 2732 | }) | ||
| 2851 | ).pipe( | 2733 | ).pipe( |
| 2852 | Effect.mapError( | 2734 | Effect.mapError( |
| 2853 | toPersistenceSqlOrDecodeError( | 2735 | toPersistenceSqlOrDecodeError( |
| 2854 | "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query", | 2736 | "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", |
| 2855 | "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows", | 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( | 2741 | const pinnedActivityRowsEffect = |
| 2883 | Effect.mapError( | 2742 | query?.activityKinds === undefined |
| 2884 | toPersistenceSqlOrDecodeError( | 2743 | ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( |
| 2885 | "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query", | 2744 | Effect.mapError( |
| 2886 | "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows", | 2745 | toPersistenceSqlOrDecodeError( |
| 2887 | ), | 2746 | "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", |
| 2888 | ), | 2747 | "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", |
| 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 | ), | ||
| 2929 | ), | 2748 | ), |
| 2930 | ), | 2749 | ), |
| 2931 | activityRead.query?.activityKinds === undefined | 2750 | ) |
| 2932 | ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( | 2751 | : Effect.succeed([]); |
| 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 | ); | ||
| 2959 | 2752 | ||
| 2960 | const [ | 2753 | const [ |
| 2961 | threadRow, | 2754 | threadRow, |
| 2962 | messageRows, | 2755 | messageRows, |
| 2963 | proposedPlanRows, | 2756 | proposedPlanRows, |
| 2964 | activities, | 2757 | activityRows, |
| 2758 | pinnedActivityRows, | ||
| 2965 | checkpointRows, | 2759 | checkpointRows, |
| 2966 | latestTurnRow, | 2760 | latestTurnRow, |
| 2967 | sessionRow, | 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 | listCheckpointRowsByThread({ threadId }).pipe( | 2792 | listCheckpointRowsByThread({ threadId }).pipe( |
| 2998 | Effect.mapError( | 2793 | Effect.mapError( |
| 2999 | toPersistenceSqlOrDecodeError( | 2794 | toPersistenceSqlOrDecodeError( |
| @@ -3024,6 +2819,17 @@ pending_approval_requests AS ( · 24 unmodified lines | |||
| 3024 | return Option.none<OrchestrationThread>(); | 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 | const thread = { | 2833 | const thread = { |
| 3028 | id: threadRow.value.threadId, | 2834 | id: threadRow.value.threadId, |
| 3029 | projectId: threadRow.value.projectId, | 2835 | projectId: threadRow.value.projectId, |
| @@ -3061,7 +2867,21 @@ pending_approval_requests AS ( · 31 unmodified lines | |||
| 3061 | return message; | 2867 | return message; |
| 3062 | }), | 2868 | }), |
| 3063 | proposedPlans: proposedPlanRows.map(mapProposedPlanRow), | 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 | checkpoints: checkpointRows.map((row) => ({ | 2885 | checkpoints: checkpointRows.map((row) => ({ |
| 3066 | turnId: row.turnId, | 2886 | turnId: row.turnId, |
| 3067 | checkpointTurnCount: row.checkpointTurnCount, | 2887 | checkpointTurnCount: row.checkpointTurnCount, |
| @@ -3086,11 +2906,7 @@ pending_approval_requests AS ( · 18 unmodified lines | |||
| 3086 | const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( | 2906 | const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( |
| 3087 | threadId, | 2907 | threadId, |
| 3088 | query, | 2908 | query, |
| 3089 | ) => | 2909 | ) => getThreadDetailByIdBounded(threadId, undefined, query); |
| 3090 | getThreadDetailByIdBounded(threadId, undefined, { | ||
| 3091 | mode: "raw", | ||
| 3092 | ...(query === undefined ? {} : { query }), | ||
| 3093 | }); | ||
| 3094 | 2910 | ||
| 3095 | // Bounds pathological fan-out: one user turn that spawned hundreds of | 2911 | // Bounds pathological fan-out: one user turn that spawned hundreds of |
| 3096 | // subagent turns still pages in bounded chunks, at the cost of splitting the | 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 | .withTransaction( | 2930 | .withTransaction( |
| 3115 | Effect.gen(function* () { | 2931 | Effect.gen(function* () { |
| 3116 | if (window?.turnLimit === undefined) { | 2932 | if (window?.turnLimit === undefined) { |
| 3117 | const thread = yield* getThreadDetailByIdBounded(threadId, undefined, { | 2933 | const thread = yield* getThreadDetailById(threadId); |
| 3118 | mode: "client", | ||
| 3119 | }); | ||
| 3120 | if (Option.isNone(thread)) { | 2934 | if (Option.isNone(thread)) { |
| 3121 | return Option.none<OrchestrationThreadDetailSnapshot>(); | 2935 | return Option.none<OrchestrationThreadDetailSnapshot>(); |
| 3122 | } | 2936 | } |
| @@ -3169,9 +2983,7 @@ pending_approval_requests AS ( · 46 unmodified lines | |||
| 3169 | ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } | 2983 | ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } |
| 3170 | : undefined; | 2984 | : undefined; |
| 3171 | 2985 | ||
| 3172 | const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, { | 2986 | const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); |
| 3173 | mode: "client", | ||
| 3174 | }); | ||
| 3175 | if (Option.isNone(thread)) { | 2987 | if (Option.isNone(thread)) { |
| 3176 | return Option.none<OrchestrationThreadDetailSnapshot>(); | 2988 | return Option.none<OrchestrationThreadDetailSnapshot>(); |
| 3177 | } | 2989 | } |
| @@ -227,10 +227,6 @@ export interface ProjectionSnapshotQueryShape { · 226 unmodified lines | |||
| 227 | * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). | 227 | * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). |
| 228 | * Without a window the full thread is returned with no `page` field — | 228 | * Without a window the full thread is returned with no `page` field — |
| 229 | * pagination is strictly opt-in. | 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 | readonly getThreadDetailSnapshot: ( | 231 | readonly getThreadDetailSnapshot: ( |
| 236 | threadId: ThreadId, | 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 | type OrchestrationCommand, | 3 | type OrchestrationCommand, |
| 4 | type OrchestrationEvent, | 4 | type OrchestrationEvent, |
| 5 | type OrchestrationReadModel, | 5 | type OrchestrationReadModel, |
| 6 | type OrchestrationThread, | ||
| 6 | } from "@t3tools/contracts/legacy-orchestration"; | 7 | } from "@t3tools/contracts/legacy-orchestration"; |
| 7 | import * as DateTime from "effect/DateTime"; | 8 | import * as DateTime from "effect/DateTime"; |
| 8 | import * as Crypto from "effect/Crypto"; | 9 | import * as Crypto from "effect/Crypto"; |
| @@ -647,16 +647,17 @@ const makeEventStore = Effect.gen(function* () { · 646 unmodified lines | |||
| 647 | const findEventAfter = SqlSchema.findOneOption({ | 647 | const findEventAfter = SqlSchema.findOneOption({ |
| 648 | Request: HasEventAfterRequestSchema, | 648 | Request: HasEventAfterRequestSchema, |
| 649 | Result: Schema.Struct({ sequence: Schema.Number }), | 649 | Result: Schema.Struct({ sequence: Schema.Number }), |
| 650 | execute: (request) => | 650 | execute: (request) => sql` |
| 651 | sql` | 651 | SELECT sequence |
| 652 | SELECT sequence | 652 | FROM orchestration_events |
| 653 | FROM orchestration_events | 653 | WHERE aggregate_kind = ${request.aggregateKind} |
| 654 | WHERE aggregate_kind = ${request.aggregateKind} | 654 | AND stream_id = ${request.aggregateId} |
| 655 | AND stream_id = ${request.aggregateId} | 655 | AND ${sql.and([ |
| 656 | AND event_type = ${request.type} | 656 | sql`sequence > ${request.sequenceExclusive}`, |
| 657 | AND sequence > ${request.sequenceExclusive} | 657 | ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]), |
| 658 | LIMIT 1 | 658 | ])} |
| 659 | `, | 659 | LIMIT 1 |
| 660 | `, | ||
| 660 | }); | 661 | }); |
| 661 | 662 | ||
| 662 | const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => | 663 | const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => |
| @@ -123,7 +123,8 @@ export interface OrchestrationEventStoreShape { · 122 unmodified lines | |||
| 123 | readonly afterSequence?: number; | 123 | readonly afterSequence?: number; |
| 124 | }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>; | 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 | * Used during replay to tell whether a later event supersedes the one being | 129 | * Used during replay to tell whether a later event supersedes the one being |
| 129 | * applied, without streaming the rest of the log. | 130 | * applied, without streaming the rest of the log. |
| @@ -131,7 +132,7 @@ export interface OrchestrationEventStoreShape { · 1 unmodified line | |||
| 131 | readonly hasEventAfter: (input: { | 132 | readonly hasEventAfter: (input: { |
| 132 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; | 133 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; |
| 133 | readonly aggregateId: string; | 134 | readonly aggregateId: string; |
| 134 | readonly type: OrchestrationEvent["type"]; | 135 | readonly type?: OrchestrationEvent["type"]; |
| 135 | readonly sequenceExclusive: number; | 136 | readonly sequenceExclusive: number; |
| 136 | }) => Effect.Effect<boolean, OrchestrationEventStoreError>; | 137 | }) => Effect.Effect<boolean, OrchestrationEventStoreError>; |
| 137 | } | 138 | } |
| @@ -40,6 +40,7 @@ import { · 39 unmodified lines | |||
| 40 | } from "../Layers/ClaudeProvider.ts"; | 40 | } from "../Layers/ClaudeProvider.ts"; |
| 41 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 41 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 42 | import * as ModelManifest from "../ModelManifest.ts"; | 42 | import * as ModelManifest from "../ModelManifest.ts"; |
| 43 | import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; | ||
| 43 | import { | 44 | import { |
| 44 | defaultProviderContinuationIdentity, | 45 | defaultProviderContinuationIdentity, |
| 45 | type ProviderDriver, | 46 | type ProviderDriver, |
| @@ -114,6 +115,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 68 unmodified lines | |||
| 114 | const serverSettings = yield* ServerSettingsService; | 115 | const serverSettings = yield* ServerSettingsService; |
| 115 | const eventLoggers = yield* ProviderEventLoggers; | 116 | const eventLoggers = yield* ProviderEventLoggers; |
| 116 | const modelManifest = yield* ModelManifest.ModelManifest; | 117 | const modelManifest = yield* ModelManifest.ModelManifest; |
| 118 | const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); | ||
| 117 | const processEnv = mergeProviderInstanceEnvironment(environment); | 119 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 118 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ | 120 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ |
| 119 | driverKind: DRIVER_KIND, | 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 | // Per-instance capabilities cache: keyed on binary + resolved HOME so | 168 | // Per-instance capabilities cache: keyed on binary + resolved HOME so |
| 163 | // account-specific probes never share auth metadata across instances. | 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 | const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); | 178 | const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); |
| 173 | 179 | ||
| 174 | // Kick the TTL-gated manifest refresh in the background and classify | 180 | // Start the TTL-gated refresh without delaying provider readiness. The |
| 175 | // with the in-memory manifest, so a slow or hung fetch never delays the | 181 | // next check observes a remote manifest after the background fetch lands. |
| 176 | // provider check. A refresh that lands mid-probe applies on the next one. | ||
| 177 | const checkProvider = modelManifest.refreshInBackground.pipe( | 182 | const checkProvider = modelManifest.refreshInBackground.pipe( |
| 178 | Effect.andThen( | 183 | Effect.andThen( |
| 179 | Effect.zipWith( | 184 | modelManifest.current.pipe( |
| 180 | checkClaudeProviderStatus( | 185 | Effect.flatMap((manifest) => |
| 181 | effectiveConfig, | 186 | checkClaudeProviderStatus( |
| 182 | () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), | 187 | effectiveConfig, |
| 183 | processEnv, | 188 | () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), |
| 184 | cwd, | 189 | processEnv, |
| 190 | cwd, | ||
| 191 | resolveClaudeModelCatalog(manifest), | ||
| 192 | ), | ||
| 185 | ), | 193 | ), |
| 186 | modelManifest.current, | 194 | Effect.map(stampIdentity), |
| 187 | (draft, manifest) => | ||
| 188 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | ||
| 189 | { concurrent: true }, | ||
| 190 | ), | 195 | ), |
| 191 | ), | 196 | ), |
| 192 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 197 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| @@ -201,11 +206,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 8 unmodified lines | |||
| 201 | streamSettings: snapshotSettings.streamSettings, | 206 | streamSettings: snapshotSettings.streamSettings, |
| 202 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | 207 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| 203 | initialSnapshot: (settings) => | 208 | initialSnapshot: (settings) => |
| 204 | Effect.zipWith( | 209 | modelManifest.current.pipe( |
| 205 | makePendingClaudeProvider(settings.provider), | 210 | Effect.flatMap((manifest) => |
| 206 | modelManifest.current, | 211 | makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)), |
| 207 | (draft, manifest) => | 212 | ), |
| 208 | stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | 213 | Effect.map(stampIdentity), |
| 209 | ), | 214 | ), |
| 210 | checkProvider, | 215 | checkProvider, |
| 211 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => | 216 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => |
| @@ -1,5 +1,5 @@ | |||
| 1 | import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; | 1 | import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; |
| 2 | import { causeErrorTag } from "@t3tools/shared/observability"; | 2 | import { causeErrorTag, errorTag } from "@t3tools/shared/observability"; |
| 3 | import * as Cause from "effect/Cause"; | 3 | import * as Cause from "effect/Cause"; |
| 4 | import * as Crypto from "effect/Crypto"; | 4 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as DateTime from "effect/DateTime"; | 5 | import * as DateTime from "effect/DateTime"; |
| @@ -13,6 +13,40 @@ import { · 7 unmodified lines | |||
| 13 | } from "../NativeProtocolLogging.ts"; | 13 | } from "../NativeProtocolLogging.ts"; |
| 14 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 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 | function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { | 50 | function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { |
| 17 | return { | 51 | return { |
| 18 | method: structuralProtocolMethod(event.method), | 52 | method: structuralProtocolMethod(event.method), |
| @@ -137,11 +171,15 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" · 118 unmodified lines | |||
| 137 | protocolLogging: { | 171 | protocolLogging: { |
| 138 | logIncoming: true, | 172 | logIncoming: true, |
| 139 | logOutgoing: true, | 173 | logOutgoing: true, |
| 140 | logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => | 174 | logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { |
| 141 | writeNativeAcpLog({ | 175 | const filtered = filterTransientProtocolLog(event); |
| 142 | kind: "protocol", | 176 | return filtered |
| 143 | payload: formatAcpProtocolLogPayload(event), | 177 | ? writeNativeAcpLog({ |
| 144 | }), | 178 | kind: "protocol", |
| 179 | payload: formatAcpProtocolLogPayload(filtered), | ||
| 180 | }) | ||
| 181 | : Effect.void; | ||
| 182 | }, | ||
| 145 | } satisfies NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"]>, | 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 | const testState = vi.hoisted(() => ({ | 5 | const testState = vi.hoisted(() => ({ |
| 6 | resources: [] as Array<unknown>, | 6 | resources: [] as Array<unknown>, |
| 7 | assetState: "success" as "success" | "loading" | "failure", | 7 | assetState: "success" as "success" | "loading" | "failure", |
| 8 | editorEnvironmentIds: [] as Array<unknown>, | ||
| 9 | remoteEnvironmentIds: [] as Array<unknown>, | ||
| 10 | })); | 8 | })); |
| 11 | 9 | ||
| 12 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 13 | vi.mock("../assets/assetUrls", () => ({ | 11 | vi.mock("../assets/assetUrls", () => ({ |
| 12 | useAssetUrlRefresh: () => vi.fn(), | ||
| 14 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { | 13 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { |
| 15 | testState.resources.push(resource); | 14 | testState.resources.push(resource); |
| 16 | if (testState.assetState === "loading") return { _tag: "Loading" }; | 15 | if (testState.assetState === "loading") return { _tag: "Loading" }; |
| @@ -25,23 +24,23 @@ vi.mock("../state/session", async (importOriginal) => ({ · 8 unmodified lines | |||
| 25 | ...(await importOriginal<typeof import("../state/session")>()), | 24 | ...(await importOriginal<typeof import("../state/session")>()), |
| 26 | usePreparedConnection: () => ({ _tag: "Loading" }), | 25 | usePreparedConnection: () => ({ _tag: "Loading" }), |
| 27 | })); | 26 | })); |
| 28 | vi.mock("../editorPreferences", () => ({ | 27 | vi.mock("../state/entities", () => ({ |
| 29 | useOpenInPreferredEditor: (environmentId: unknown) => { | 28 | readThreadShell: () => null, |
| 30 | testState.editorEnvironmentIds.push(environmentId); | 29 | useProjects: () => [], |
| 31 | return vi.fn(); | ||
| 32 | }, | ||
| 33 | usePreferredEditor: () => ["vscode", vi.fn()], | ||
| 34 | })); | 30 | })); |
| 35 | vi.mock("../remoteOpen", () => ({ | 31 | vi.mock("../remoteOpen", () => ({ |
| 36 | useRemoteOpenResolution: (environmentId: unknown) => { | 32 | useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), |
| 37 | testState.remoteEnvironmentIds.push(environmentId); | 33 | })); |
| 38 | return { | 34 | vi.mock("../editorPreferences", () => ({ |
| 39 | isResolved: true, | 35 | useOpenInPreferredEditor: () => vi.fn(), |
| 40 | state: { mode: "local-exec", localExecutionUnavailableReason: null }, | 36 | usePreferredEditor: () => [null, vi.fn()], |
| 41 | }; | 37 | })); |
| 42 | }, | 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 | import ChatMarkdown from "./ChatMarkdown"; | 45 | import ChatMarkdown from "./ChatMarkdown"; |
| 47 | import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; | 46 | import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; |
| @@ -93,8 +92,6 @@ describe("ChatMarkdown workspace images", () => { · 45 unmodified lines | |||
| 93 | beforeEach(() => { | 92 | beforeEach(() => { |
| 94 | testState.resources = []; | 93 | testState.resources = []; |
| 95 | testState.assetState = "success"; | 94 | testState.assetState = "success"; |
| 96 | testState.editorEnvironmentIds = []; | ||
| 97 | testState.remoteEnvironmentIds = []; | ||
| 98 | }); | 95 | }); |
| 99 | 96 | ||
| 100 | it.each([ | 97 | it.each([ |
| @@ -110,52 +107,55 @@ describe("ChatMarkdown workspace images", () => { · 9 unmodified lines | |||
| 110 | 107 | ||
| 111 | expect(testState.resources).toEqual([ | 108 | expect(testState.resources).toEqual([ |
| 112 | { | 109 | { |
| 113 | _tag: "workspace-file", | 110 | _tag: "media-file", |
| 114 | threadId: threadRef.threadId, | 111 | threadId: threadRef.threadId, |
| 115 | path: expectedPath, | 112 | path: expectedPath, |
| 116 | }, | 113 | }, |
| 117 | ]); | 114 | ]); |
| 118 | }); | 115 | }); |
| 119 | 116 | ||
| 120 | it("loads relative workspace paths through signed asset URLs", () => { | 117 | it("loads every Windows workspace path form through a signed asset URL", () => { |
| 121 | const html = render(""); | 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 | expect(testState.resources).toEqual([ | 128 | expect(testState.resources).toEqual([ |
| 124 | { | 129 | { |
| 125 | _tag: "workspace-file", | 130 | _tag: "media-file", |
| 126 | threadId: threadRef.threadId, | 131 | threadId: threadRef.threadId, |
| 127 | path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", | 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", () => { | 148 | it("normalizes a drive-absolute src in raw image HTML", () => { |
| 134 | const html = render(String.raw`[Open](C:\Users\shawn\project\src\main.ts)`); | 149 | const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`); |
| 135 | expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); | ||
| 136 | expect(html).toContain("chat-markdown-file-link"); | ||
| 137 | }); | ||
| 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 | expect(testState.resources).toEqual([ | 151 | expect(testState.resources).toEqual([ |
| 147 | { | 152 | { |
| 148 | _tag: "workspace-file", | 153 | _tag: "media-file", |
| 149 | threadId: threadRef.threadId, | ||
| 150 | path: "C:/Users/shawn/project/.t3/workspace-image.svg", | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | _tag: "workspace-file", | ||
| 154 | threadId: threadRef.threadId, | 154 | threadId: threadRef.threadId, |
| 155 | path: "D:/screens/workspace-image.svg", | 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 | it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { | 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 | it("uses a static bounded-width placeholder while a signed asset URL loads", () => { | 262 | it("uses a static bounded-width placeholder while a signed asset URL loads", () => { |
| 263 | testState.assetState = "loading"; | 263 | testState.assetState = "loading"; |
| 264 | |||
| 264 | const html = render(""); | 265 | const html = render(""); |
| 265 | const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; | 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 | expect(className?.split(" ")).toContain("w-64"); | 270 | expect(className?.split(" ")).toContain("w-64"); |
| 270 | }); | 271 | }); |
| 271 | 272 | ||
| 272 | it("keeps remote images directly loadable", () => { | 273 | it("never passes a workspace source to a raw image when thread context is unavailable", () => { |
| 273 | const html = render(""); | 274 | const html = renderWithoutThread( |
| 275 | "", | ||
| 276 | ); | ||
| 277 | |||
| 274 | expect(testState.resources).toEqual([]); | 278 | expect(testState.resources).toEqual([]); |
| 275 | expect(html).toContain('src="https://example.com/image.png"'); | 279 | expect(html).toContain("Image unavailable"); |
| 276 | expect(html).toContain("max-h-[30rem]"); | 280 | expect(html).not.toContain("file://"); |
| 277 | }); | 281 | }); |
| 278 | 282 | ||
| 279 | it("uses the markdown owner's environment for file actions", () => { | 283 | it("blocks unsupported image schemes instead of passing them to a raw image", () => { |
| 280 | const owningEnvironmentId = EnvironmentId.make("env-pull-request"); | 284 | const html = render(""); |
| 281 | 285 | ||
| 282 | renderToStaticMarkup( | 286 | expect(testState.resources).toEqual([]); |
| 283 | <ChatMarkdown | 287 | expect(html).toContain("Image unavailable"); |
| 284 | cwd="/workspace/project" | 288 | expect(html).not.toContain("content://"); |
| 285 | environmentId={owningEnvironmentId} | 289 | }); |
| 286 | text="[Open](src/main.ts)" | ||
| 287 | />, | ||
| 288 | ); | ||
| 289 | 290 | ||
| 290 | expect(testState.editorEnvironmentIds).toEqual([owningEnvironmentId]); | 291 | it("keeps remote images directly loadable", () => { |
| 291 | expect(testState.remoteEnvironmentIds).toEqual([owningEnvironmentId]); | 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 | deriveComposerSendState, | 24 | deriveComposerSendState, |
| 25 | dismissBranchMismatchForSession, | 25 | dismissBranchMismatchForSession, |
| 26 | getStartedThreadModelChangeBlockReason, | 26 | getStartedThreadModelChangeBlockReason, |
| 27 | isVideoPreviewRequestCurrent, | ||
| 28 | hasEnvironmentReconnectWarningGraceElapsed, | ||
| 27 | hasServerAcknowledgedLocalDispatch, | 29 | hasServerAcknowledgedLocalDispatch, |
| 28 | isBranchMismatchDismissedForSession, | 30 | isBranchMismatchDismissedForSession, |
| 29 | isVideoPreviewRequestCurrent, | ||
| 30 | loadVideoPreviewUrl, | ||
| 31 | reconcileMountedTerminalThreadIds, | 31 | reconcileMountedTerminalThreadIds, |
| 32 | reconcileRetainedMountedThreadIds, | 32 | reconcileRetainedMountedThreadIds, |
| 33 | resolveDraftPromotionNavigationTarget, | 33 | resolveDraftPromotionNavigationTarget, |
| @@ -17,6 +17,12 @@ import { · 16 unmodified lines | |||
| 17 | type RunId, | 17 | type RunId, |
| 18 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 19 | import * as DateTime from "effect/DateTime"; | 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 | import { | 26 | import { |
| 21 | appendCodexArtifactTemplateUsePrompt, | 27 | appendCodexArtifactTemplateUsePrompt, |
| 22 | codexArtifactTemplateUsePrompt, | 28 | codexArtifactTemplateUsePrompt, |
| @@ -39,11 +39,6 @@ import { · 38 unmodified lines | |||
| 39 | threadWokeAt, | 39 | threadWokeAt, |
| 40 | } from "@t3tools/client-runtime/state/thread-settled"; | 40 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 41 | import { useThreadActions } from "../hooks/useThreadActions"; | 41 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 42 | import { | ||
| 43 | resolveDisplayedThreadPr, | ||
| 44 | threadChangeRequestSnapshotsAtom, | ||
| 45 | useLinkedThreadPullRequest, | ||
| 46 | } from "./ThreadStatusIndicators"; | ||
| 47 | import { | 42 | import { |
| 48 | deriveThreadActivityRun, | 43 | deriveThreadActivityRun, |
| 49 | deriveLatestThreadRun, | 44 | deriveLatestThreadRun, |
| @@ -74,7 +69,12 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; · 24 unmodified lines | |||
| 74 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | 69 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 75 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 70 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 76 | import { truncate } from "@t3tools/shared/String"; | 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 | import { Debouncer } from "@tanstack/react-pacer"; | 78 | import { Debouncer } from "@tanstack/react-pacer"; |
| 79 | import { useAtomValue } from "@effect/atom-react"; | 79 | import { useAtomValue } from "@effect/atom-react"; |
| 80 | import { | 80 | import { |
| @@ -325,8 +325,14 @@ import { · 244 unmodified lines | |||
| 325 | ThreadErrorBanner, | 325 | ThreadErrorBanner, |
| 326 | } from "./chat/ThreadErrorBanner"; | 326 | } from "./chat/ThreadErrorBanner"; |
| 327 | import { resolveThreadPr } from "./ThreadStatusIndicators"; | 327 | import { resolveThreadPr } from "./ThreadStatusIndicators"; |
| 328 | import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | ||
| 329 | import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl"; | 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 | import { ComposerSurface } from "./chat/ComposerSurface"; | 336 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 331 | import { resolveThreadSyncPhase } from "../threadSync"; | 337 | import { resolveThreadSyncPhase } from "../threadSync"; |
| 332 | import { | 338 | import { |
| @@ -1338,6 +1344,7 @@ function ChatViewContent(props: ChatViewProps) { · 1005 unmodified lines | |||
| 1338 | }); | 1344 | }); |
| 1339 | const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | 1345 | const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 1340 | reportFailure: false, | 1346 | reportFailure: false, |
| 1347 | refresh: true, | ||
| 1341 | }); | 1348 | }); |
| 1342 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { | 1349 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { |
| 1343 | reportFailure: false, | 1350 | reportFailure: false, |
| @@ -1907,7 +1914,7 @@ function ChatViewContent(props: ChatViewProps) { · 563 unmodified lines | |||
| 1907 | >({}); | 1914 | >({}); |
| 1908 | const activePullRequestSurfaceId = | 1915 | const activePullRequestSurfaceId = |
| 1909 | activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; | 1916 | activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; |
| 1910 | const handlePullRequestTabStatusChange = useCallback( | 1917 | const updatePullRequestTabStatusFromPanel = useCallback( |
| 1911 | (status: PullRequestTabStatus) => { | 1918 | (status: PullRequestTabStatus) => { |
| 1912 | const id = activePullRequestSurfaceId; | 1919 | const id = activePullRequestSurfaceId; |
| 1913 | if (id === undefined) return; | 1920 | if (id === undefined) return; |
| @@ -1915,6 +1922,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | |||
| 1915 | }, | 1922 | }, |
| 1916 | [activePullRequestSurfaceId], | 1923 | [activePullRequestSurfaceId], |
| 1917 | ); | 1924 | ); |
| 1925 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); | ||
| 1926 | const sidebarPrRefreshKeyRef = useRef<string | null>(null); | ||
| 1918 | const activeFileSurface = | 1927 | const activeFileSurface = |
| 1919 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; | 1928 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1920 | const activePreviewState = useThreadPreviewState(activeThreadRef); | 1929 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| @@ -2758,14 +2767,8 @@ function ChatViewContent(props: ChatViewProps) { · 837 unmodified lines | |||
| 2758 | toastManager.add({ type: "error", title: "The environment is not connected." }); | 2767 | toastManager.add({ type: "error", title: "The environment is not connected." }); |
| 2759 | return; | 2768 | return; |
| 2760 | } | 2769 | } |
| 2761 | const videoMime = videoMimeType(attachment); | 2770 | const isVideo = videoMimeType(attachment) !== null; |
| 2762 | const isVideo = videoMime !== null; | ||
| 2763 | const action = isVideo ? "play" : "download"; | 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 | const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; | 2772 | const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; |
| 2770 | const isCurrentRequest = () => | 2773 | const isCurrentRequest = () => |
| 2771 | !isVideo || | 2774 | !isVideo || |
| @@ -2775,75 +2778,37 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines | |||
| 2775 | videoPreviewRequestId, | 2778 | videoPreviewRequestId, |
| 2776 | videoPreviewRequestIdRef.current, | 2779 | videoPreviewRequestIdRef.current, |
| 2777 | ); | 2780 | ); |
| 2778 | const finishVideoPreviewRequest = () => { | ||
| 2779 | if (videoPreviewRequestIdRef.current === videoPreviewRequestId) { | ||
| 2780 | setOpeningVideoAttachmentId(null); | ||
| 2781 | videoPreviewAbortControllerRef.current = null; | ||
| 2782 | } | ||
| 2783 | }; | ||
| 2784 | if (isVideo) setOpeningVideoAttachmentId(attachment.id); | 2781 | if (isVideo) setOpeningVideoAttachmentId(attachment.id); |
| 2785 | 2782 | ||
| 2786 | // fileName and mimeType ride in the signed claims so videos render | 2783 | try { |
| 2787 | // inline while other files keep their real download name and type. | 2784 | const url = await resolveFileAttachmentUrl({ |
| 2788 | const result = await createAttachmentAssetUrl({ | 2785 | attachment, |
| 2789 | environmentId, | 2786 | environmentId, |
| 2790 | input: { | 2787 | httpBaseUrl: connection.httpBaseUrl, |
| 2791 | resource: { | 2788 | createAssetUrl: createAttachmentAssetUrl, |
| 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.", | ||
| 2810 | }); | 2789 | }); |
| 2811 | return; | 2790 | if (!isCurrentRequest()) return; |
| 2812 | } | 2791 | if (isVideo) { |
| 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 | } | ||
| 2827 | setExpandedImage({ | 2792 | setExpandedImage({ |
| 2828 | images: [{ src: previewUrl, name: attachment.name, type: "video" }], | 2793 | images: [{ src: url, name: attachment.name, type: "video" }], |
| 2829 | index: 0, | 2794 | index: 0, |
| 2830 | }); | 2795 | }); |
| 2831 | } catch (error) { | 2796 | return; |
| 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(); | ||
| 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 | [createAttachmentAssetUrl, environmentId, routeThreadKey], | 2813 | [createAttachmentAssetUrl, environmentId, routeThreadKey], |
| 2849 | ); | 2814 | ); |
| @@ -4885,8 +4850,14 @@ function ChatViewContent(props: ChatViewProps) { · 2035 unmodified lines | |||
| 4885 | ); | 4850 | ); |
| 4886 | // The server-projected settled state keeps the banner and sidebar in sync. | 4851 | // The server-projected settled state keeps the banner and sidebar in sync. |
| 4887 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); | 4852 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| 4888 | const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); | 4853 | const autoSettleAfterDays = useEnvironmentSettings( |
| 4889 | const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); | 4854 | environmentId, |
| 4855 | (settings) => settings.sidebarAutoSettleAfterDays, | ||
| 4856 | ); | ||
| 4857 | const autoSettleOnMerge = useEnvironmentSettings( | ||
| 4858 | environmentId, | ||
| 4859 | (settings) => settings.sidebarAutoSettleOnMerge, | ||
| 4860 | ); | ||
| 4890 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); | 4861 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); |
| 4891 | // The thread's own change request, placed against the project it belongs to. | 4862 | // The thread's own change request, placed against the project it belongs to. |
| 4892 | const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; | 4863 | const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; |
| @@ -4902,6 +4873,97 @@ function ChatViewContent(props: ChatViewProps) { · 9 unmodified lines | |||
| 4902 | linkedPullRequest: linkedThreadPullRequest, | 4873 | linkedPullRequest: linkedThreadPullRequest, |
| 4903 | linkedPullRequestStatus, | 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 | // The right panel offers the thread's own change request, so it can only offer it once the | 4967 | // The right panel offers the thread's own change request, so it can only offer it once the |
| 4906 | // branch has one; until then the picker says so rather than opening an empty panel. | 4968 | // branch has one; until then the picker says so rather than opening an empty panel. |
| 4907 | const addPullRequestSurface = useCallback(() => { | 4969 | const addPullRequestSurface = useCallback(() => { |
| @@ -4950,21 +5012,13 @@ function ChatViewContent(props: ChatViewProps) { · 42 unmodified lines | |||
| 4950 | if (activeThreadRef === null || activeThreadWokeAt === null) return; | 5012 | if (activeThreadRef === null || activeThreadWokeAt === null) return; |
| 4951 | markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); | 5013 | markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); |
| 4952 | }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); | 5014 | }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); |
| 4953 | // Mirror of the sidebar's Woke pill for the open thread. It uses the same | 5015 | // Mirror of the sidebar's Woke pill for the open thread. |
| 4954 | // visit comparison and change request settle rule. | ||
| 4955 | const activeThreadLastVisitedAt = useUiStateStore((store) => | 5016 | const activeThreadLastVisitedAt = useUiStateStore((store) => |
| 4956 | activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], | 5017 | activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], |
| 4957 | ); | 5018 | ); |
| 4958 | const activeThreadWokeVisible = useMemo(() => { | 5019 | const activeThreadWokeVisible = useMemo(() => { |
| 4959 | if (activeThreadWokeAt === null) return false; | 5020 | if (activeThreadWokeAt === null) return false; |
| 4960 | if ( | 5021 | if (activeThreadShell?.settledOverride === "settled") return false; |
| 4961 | changeRequestAutoSettles(activeThreadChangeRequest, { | ||
| 4962 | autoSettleOnMerge, | ||
| 4963 | thread: activeThreadShell, | ||
| 4964 | }) | ||
| 4965 | ) { | ||
| 4966 | return false; | ||
| 4967 | } | ||
| 4968 | const wokeAtMs = Date.parse(activeThreadWokeAt); | 5022 | const wokeAtMs = Date.parse(activeThreadWokeAt); |
| 4969 | if (Number.isNaN(wokeAtMs)) return false; | 5023 | if (Number.isNaN(wokeAtMs)) return false; |
| 4970 | // Having the thread open counts as a visit at completedAt (the effect | 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 | activeLatestRun?.completedAt, | 5039 | activeLatestRun?.completedAt, |
| 4986 | activeThreadLastVisitedAt, | 5040 | activeThreadLastVisitedAt, |
| 4987 | activeThreadChangeRequest, | ||
| 4988 | activeThreadShell, | 5041 | activeThreadShell, |
| 4989 | activeThreadWokeAt, | 5042 | activeThreadWokeAt, |
| 4990 | autoSettleOnMerge, | 5043 | autoSettleOnMerge, |
| @@ -5006,8 +5059,6 @@ function ChatViewContent(props: ChatViewProps) { · 15 unmodified lines | |||
| 5006 | nowMinute, | 5059 | nowMinute, |
| 5007 | supportsSettlement, | 5060 | supportsSettlement, |
| 5008 | ]); | 5061 | ]); |
| 5009 | const activeThreadSettled = | ||
| 5010 | supportsSettlement && activeThreadShell?.settledOverride === "settled"; | ||
| 5011 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { | 5062 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { |
| 5012 | reportFailure: false, | 5063 | reportFailure: false, |
| 5013 | }); | 5064 | }); |
| @@ -5394,6 +5445,13 @@ function ChatViewContent(props: ChatViewProps) { · 380 unmodified lines | |||
| 5394 | }); | 5445 | }); |
| 5395 | if (!command) return; | 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 | if (command === "thread.settle") { | 5455 | if (command === "thread.settle") { |
| 5398 | event.preventDefault(); | 5456 | event.preventDefault(); |
| 5399 | event.stopPropagation(); | 5457 | event.stopPropagation(); |
| @@ -5561,6 +5619,7 @@ function ChatViewContent(props: ChatViewProps) { · 161 unmodified lines | |||
| 5561 | supportsPinning, | 5619 | supportsPinning, |
| 5562 | supportsSettlement, | 5620 | supportsSettlement, |
| 5563 | confirmAndUnpinThread, | 5621 | confirmAndUnpinThread, |
| 5622 | copyActiveThreadReference, | ||
| 5564 | toggleRightPanel, | 5623 | toggleRightPanel, |
| 5565 | toggleThreadPanel, | 5624 | toggleThreadPanel, |
| 5566 | toggleTerminalVisibility, | 5625 | toggleTerminalVisibility, |
| @@ -7512,6 +7571,7 @@ function ChatViewContent(props: ChatViewProps) { · 1945 unmodified lines | |||
| 7512 | } | 7571 | } |
| 7513 | > | 7572 | > |
| 7514 | <DraftHeroHeadline | 7573 | <DraftHeroHeadline |
| 7574 | draftId={draftId} | ||
| 7515 | activeProjectRef={activeProjectRef} | 7575 | activeProjectRef={activeProjectRef} |
| 7516 | activeProjectTitle={activeProject?.title ?? null} | 7576 | activeProjectTitle={activeProject?.title ?? null} |
| 7517 | /> | 7577 | /> |
| @@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- · 18 unmodified lines | |||
| 19 | import { CSS } from "@dnd-kit/utilities"; | 19 | import { CSS } from "@dnd-kit/utilities"; |
| 20 | import { | 20 | import { |
| 21 | canSnooze, | 21 | canSnooze, |
| 22 | effectiveSettled, | ||
| 22 | effectiveSnoozed, | 23 | effectiveSnoozed, |
| 23 | threadWokeAt, | 24 | threadWokeAt, |
| 24 | } from "@t3tools/client-runtime/state/thread-settled"; | 25 | } from "@t3tools/client-runtime/state/thread-settled"; |
| @@ -99,7 +100,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; · 74 unmodified lines | |||
| 99 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 100 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 100 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; | 101 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; |
| 101 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; | 102 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 102 | import { useClientSettings } from "../hooks/useSettings"; | 103 | import { useClientSettings, usePrimarySettings } from "../hooks/useSettings"; |
| 103 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; | 104 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 104 | import { useLocalStorage } from "../hooks/useLocalStorage"; | 105 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 105 | import { useNowMinute } from "../hooks/useNowMinute"; | 106 | import { useNowMinute } from "../hooks/useNowMinute"; |
| @@ -171,7 +172,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 65 unmodified lines | |||
| 171 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; | 172 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 172 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; | 173 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| 173 | import { | 174 | import { |
| 174 | deriveProviderInstanceEntries, | 175 | deriveProviderEntriesByEnvironment, |
| 175 | shouldShowInstanceBadge, | 176 | shouldShowInstanceBadge, |
| 176 | type ProviderInstanceEntry, | 177 | type ProviderInstanceEntry, |
| 177 | } from "../providerInstances"; | 178 | } from "../providerInstances"; |
| @@ -851,10 +852,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 673 unmodified lines | |||
| 851 | const isWoke = | 852 | const isWoke = |
| 852 | wokeAtDate !== null && | 853 | wokeAtDate !== null && |
| 853 | (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && | 854 | (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && |
| 854 | !changeRequestAutoSettles(pr, { | 855 | thread.settledOverride !== "settled"; |
| 855 | autoSettleOnMerge: props.autoSettleOnMerge, | ||
| 856 | thread, | ||
| 857 | }); | ||
| 858 | // In-flight rows (working, or waiting on approval/input) fade as a whole: | 856 | // In-flight rows (working, or waiting on approval/input) fade as a whole: |
| 859 | // there is nothing for the user to do yet, so prominence is reserved for | 857 | // there is nothing for the user to do yet, so prominence is reserved for |
| 860 | // rows that need a human — done (unread), read-but-unsettled, failed, and | 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 | const { isMobile, setOpenMobile } = useSidebar(); | 1770 | const { isMobile, setOpenMobile } = useSidebar(); |
| 1773 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 1771 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 1774 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); | 1772 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 1773 | const autoSettleAfterDays = usePrimarySettings((s) => s.sidebarAutoSettleAfterDays); | ||
| 1774 | const autoSettleOnMerge = usePrimarySettings((s) => s.sidebarAutoSettleOnMerge); | ||
| 1775 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); | 1775 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| 1776 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); | 1776 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 1777 | const timestampFormat = useClientSettings((s) => s.timestampFormat); | 1777 | const timestampFormat = useClientSettings((s) => s.timestampFormat); |
| @@ -1925,15 +1925,20 @@ export default function Sidebar() { · 147 unmodified lines | |||
| 1925 | () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), | 1925 | () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), |
| 1926 | [sidebarProjectSortOrder, threads, unsortedProjectGroups], | 1926 | [sidebarProjectSortOrder, threads, unsortedProjectGroups], |
| 1927 | ); | 1927 | ); |
| 1928 | const serverProviders = useAtomValue(primaryServerProvidersAtom); | 1928 | const projectGroupsRef = useRef(projectGroups); |
| 1929 | const providerEntryByInstanceId = useMemo( | 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( | 1936 | deriveProviderEntriesByEnvironment( |
| 1932 | deriveProviderInstanceEntries(serverProviders).map( | 1937 | [...serverConfigs].map( |
| 1933 | (entry) => [entry.instanceId as string, entry] as const, | 1938 | ([environmentId, config]) => [environmentId, config.providers] as const, |
| 1934 | ), | 1939 | ), |
| 1935 | ), | 1940 | ), |
| 1936 | [serverProviders], | 1941 | [serverConfigs], |
| 1937 | ); | 1942 | ); |
| 1938 | const projectCwdByKey = useMemo( | 1943 | const projectCwdByKey = useMemo( |
| 1939 | () => | 1944 | () => |
| @@ -2081,11 +2086,8 @@ export default function Sidebar() { · 141 unmodified lines | |||
| 2081 | clearSelection(); | 2086 | clearSelection(); |
| 2082 | }, [clearSelection, projectScopeKey]); | 2087 | }, [clearSelection, projectScopeKey]); |
| 2083 | 2088 | ||
| 2084 | const handleProjectSettings = useCallback( | 2089 | const openProjectSettings = useCallback( |
| 2085 | (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => { | 2090 | (projectGroup: SidebarProjectSnapshot) => { |
| 2086 | event.preventDefault(); | ||
| 2087 | event.stopPropagation(); | ||
| 2088 | dispatchProjectScopeMenu({ type: "project-settings-opened" }); | ||
| 2089 | if (isMobile) { | 2091 | if (isMobile) { |
| 2090 | setOpenMobile(false); | 2092 | setOpenMobile(false); |
| 2091 | } | 2093 | } |
| @@ -2118,7 +2120,6 @@ export default function Sidebar() { · 26 unmodified lines | |||
| 2118 | // the partition works directly off live shells: no archived-snapshot | 2120 | // the partition works directly off live shells: no archived-snapshot |
| 2119 | // merging, no optimistic holds. Archived threads remain hidden here — | 2121 | // merging, no optimistic holds. Archived threads remain hidden here — |
| 2120 | // archive keeps its original "remove from sidebar" meaning. | 2122 | // archive keeps its original "remove from sidebar" meaning. |
| 2121 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); | ||
| 2122 | const { | 2123 | const { |
| 2123 | pinnedThreads, | 2124 | pinnedThreads, |
| 2124 | reorderablePinnedKeys, | 2125 | reorderablePinnedKeys, |
| @@ -2133,6 +2134,7 @@ export default function Sidebar() { · 8 unmodified lines | |||
| 2133 | // memo exactly at the next wake boundary. | 2134 | // memo exactly at the next wake boundary. |
| 2134 | void snoozeWakeTick; | 2135 | void snoozeWakeTick; |
| 2135 | const preciseNow = new Date().toISOString(); | 2136 | const preciseNow = new Date().toISOString(); |
| 2137 | const now = `${nowMinute}:00.000Z`; | ||
| 2136 | // Subagent child threads live in the parent's Agents surface, not the | 2138 | // Subagent child threads live in the parent's Agents surface, not the |
| 2137 | // sidebar roster (v2 models them as real threads with lineage). | 2139 | // sidebar roster (v2 models them as real threads with lineage). |
| 2138 | const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); | 2140 | const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); |
| @@ -2209,16 +2211,7 @@ export default function Sidebar() { · 70 unmodified lines | |||
| 2209 | settledThreads: sortSettledThreadsForSidebar(settled), | 2211 | settledThreads: sortSettledThreadsForSidebar(settled), |
| 2210 | snoozeNow: preciseNow, | 2212 | snoozeNow: preciseNow, |
| 2211 | }; | 2213 | }; |
| 2212 | }, [ | 2214 | }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); |
| 2213 | autoSettleAfterDays, | ||
| 2214 | autoSettleOnMerge, | ||
| 2215 | changeRequestSnapshotByKey, | ||
| 2216 | nowMinute, | ||
| 2217 | scopedProjectKeys, | ||
| 2218 | serverConfigs, | ||
| 2219 | snoozeWakeTick, | ||
| 2220 | threads, | ||
| 2221 | ]); | ||
| 2222 | 2215 | ||
| 2223 | const threadSearchInputRef = useRef<HTMLInputElement>(null); | 2216 | const threadSearchInputRef = useRef<HTMLInputElement>(null); |
| 2224 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); | 2217 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); |
| @@ -3788,7 +3781,10 @@ export default function Sidebar() { · 1563 unmodified lines | |||
| 3788 | ) ?? null | 3781 | ) ?? null |
| 3789 | } | 3782 | } |
| 3790 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} | 3783 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} |
| 3791 | providerEntryByInstanceId={providerEntryByInstanceId} | 3784 | providerEntryByInstanceId={ |
| 3785 | providerEntriesByEnvironment.get(thread.environmentId) ?? | ||
| 3786 | EMPTY_PROVIDER_ENTRIES | ||
| 3787 | } | ||
| 3792 | isHighlighted={activeSearchResultIndex === index} | 3788 | isHighlighted={activeSearchResultIndex === index} |
| 3793 | isRouteActive={routeThreadKey === threadKey} | 3789 | isRouteActive={routeThreadKey === threadKey} |
| 3794 | resultId={`sidebar-thread-search-result-${index}`} | 3790 | resultId={`sidebar-thread-search-result-${index}`} |
| @@ -3894,7 +3890,10 @@ export default function Sidebar() { · 99 unmodified lines | |||
| 3894 | `${thread.environmentId}:${thread.projectId}`, | 3890 | `${thread.environmentId}:${thread.projectId}`, |
| 3895 | ) ?? null | 3891 | ) ?? null |
| 3896 | } | 3892 | } |
| 3897 | providerEntryByInstanceId={providerEntryByInstanceId} | 3893 | providerEntryByInstanceId={ |
| 3894 | providerEntriesByEnvironment.get(thread.environmentId) ?? | ||
| 3895 | EMPTY_PROVIDER_ENTRIES | ||
| 3896 | } | ||
| 3898 | timestampFormat={timestampFormat} | 3897 | timestampFormat={timestampFormat} |
| 3899 | onThreadClick={handleThreadClick} | 3898 | onThreadClick={handleThreadClick} |
| 3900 | onThreadActivate={navigateToThread} | 3899 | onThreadActivate={navigateToThread} |
| @@ -4099,4 +4098,5 @@ export default function Sidebar() { · 198 unmodified lines | |||
| 4099 | <SidebarChromeFooter /> | 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 | submitComposerDraft, | 172 | submitComposerDraft, |
| 173 | } from "./composerSubmission"; | 173 | } from "./composerSubmission"; |
| 174 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; | 174 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; |
| 175 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | ||
| 175 | 176 | ||
| 176 | function ComposerVideoThumbnail({ file }: { file: File }) { | 177 | function ComposerVideoThumbnail({ file }: { file: File }) { |
| 177 | const setVideo = useCallback( | 178 | const setVideo = useCallback( |
| @@ -1580,7 +1580,7 @@ describe("deriveMessagesTimelineRows", () => { · 1579 unmodified lines | |||
| 1580 | "assistant-final-entry", | 1580 | "assistant-final-entry", |
| 1581 | "user-followup-entry", | 1581 | "user-followup-entry", |
| 1582 | "working-indicator-row", | 1582 | "working-indicator-row", |
| 1583 | "thinking-indicator-row", | 1583 | "live-activity-row", |
| 1584 | ]); | 1584 | ]); |
| 1585 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); | 1585 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); |
| 1586 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); | 1586 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); |
| @@ -1618,14 +1618,15 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines | |||
| 1618 | revertTurnCountByUserMessageId: new Map(), | 1618 | revertTurnCountByUserMessageId: new Map(), |
| 1619 | }); | 1619 | }); |
| 1620 | 1620 | ||
| 1621 | // The settled tool stays visible in past tense instead of vanishing, and | 1621 | // A successfully settled command keeps the shared live-activity row alive |
| 1622 | // the thinking indicator marks that the turn itself is still running. | 1622 | // between actions (#9098) instead of dropping to past tense, so no extra |
| 1623 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); | 1623 | // thinking row appears while the turn is still running. |
| 1624 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); | ||
| 1624 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ | 1625 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ |
| 1625 | active: false, | 1626 | active: true, |
| 1627 | id: "live-activity-row", | ||
| 1626 | entry: { id: "work-1" }, | 1628 | entry: { id: "work-1" }, |
| 1627 | }); | 1629 | }); |
| 1628 | expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); | ||
| 1629 | }); | 1630 | }); |
| 1630 | 1631 | ||
| 1631 | it("does not fold the active in-progress turn", () => { | 1632 | it("does not fold the active in-progress turn", () => { |
| @@ -1675,7 +1676,7 @@ describe("deriveMessagesTimelineRows", () => { · 43 unmodified lines | |||
| 1675 | expect(rows.map((row) => row.id)).toEqual([ | 1676 | expect(rows.map((row) => row.id)).toEqual([ |
| 1676 | "working-indicator-row", | 1677 | "working-indicator-row", |
| 1677 | "assistant-thought-entry", | 1678 | "assistant-thought-entry", |
| 1678 | "work-live:work-entry-1", | 1679 | "live-activity-row", |
| 1679 | ]); | 1680 | ]); |
| 1680 | const liveRow = rows.find((row) => row.kind === "work-live"); | 1681 | const liveRow = rows.find((row) => row.kind === "work-live"); |
| 1681 | expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1"); | 1682 | expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1"); |
| @@ -1948,8 +1949,8 @@ describe("computeStableMessagesTimelineRows", () => { · 266 unmodified lines | |||
| 1948 | initial, | 1949 | initial, |
| 1949 | ); | 1950 | ); |
| 1950 | 1951 | ||
| 1951 | const initialThinking = initial.byId.get("thinking-indicator-row"); | 1952 | const initialThinking = initial.byId.get("live-activity-row"); |
| 1952 | const updatedThinking = updated.byId.get("thinking-indicator-row"); | 1953 | const updatedThinking = updated.byId.get("live-activity-row"); |
| 1953 | expect(initialThinking).toMatchObject({ kind: "thinking" }); | 1954 | expect(initialThinking).toMatchObject({ kind: "thinking" }); |
| 1954 | expect(updatedThinking).toBe(initialThinking); | 1955 | expect(updatedThinking).toBe(initialThinking); |
| 1955 | expect(updated.result.at(-1)).toBe(updatedThinking); | 1956 | expect(updated.result.at(-1)).toBe(updatedThinking); |
| @@ -1,9 +1,6 @@ | |||
| 1 | import * as Equal from "effect/Equal"; | 1 | import * as Equal from "effect/Equal"; |
| 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; | 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 3 | import { | 3 | import { |
| 4 | normalizeCompactToolLabel, | ||
| 5 | omitSupersededLifecycleMarkers, | ||
| 6 | resolveWorkEntryToolPresentation, | ||
| 7 | summarizeToolGroup, | 4 | summarizeToolGroup, |
| 8 | toolGroupSummaryKind, | 5 | toolGroupSummaryKind, |
| 9 | type ToolGroupSummaryKind, | 6 | type ToolGroupSummaryKind, |
| @@ -18,6 +15,7 @@ import { · 8 unmodified lines | |||
| 18 | formatDuration, | 15 | formatDuration, |
| 19 | timelineEntryIsPersistentResourceCard, | 16 | timelineEntryIsPersistentResourceCard, |
| 20 | workEntryDisplayIndicatesToolFailure, | 17 | workEntryDisplayIndicatesToolFailure, |
| 18 | workEntryIndicatesToolSuccess, | ||
| 21 | workEntryIndicatesToolNeutralStatus, | 19 | workEntryIndicatesToolNeutralStatus, |
| 22 | workLogEntryIsToolLike, | 20 | workLogEntryIsToolLike, |
| 23 | type TimelineEntry, | 21 | type TimelineEntry, |
| @@ -337,200 +335,6 @@ export function computeMessageDurationStart( · 313 unmodified lines | |||
| 337 | return result; | 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 | function workGroupId(timelineEntryId: string): string { | 338 | function workGroupId(timelineEntryId: string): string { |
| 535 | return `work-group:${timelineEntryId}`; | 339 | return `work-group:${timelineEntryId}`; |
| 536 | } | 340 | } |
| @@ -911,21 +715,26 @@ export function deriveMessagesTimelineRows(input: { · 374 unmodified lines | |||
| 911 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => | 715 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => |
| 912 | workEntryIsActiveTurnActivity(entry.entry), | 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 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; | 722 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; |
| 916 | const activeWorkRow = | 723 | const activeWorkRow = |
| 917 | activeWorkAnchor && displayedToolEntry | 724 | activeWorkAnchor && latestVisibleToolEntry |
| 918 | ? (() => { | 725 | ? (() => { |
| 919 | const groupId = workGroupId(activeWorkAnchor.id); | 726 | const groupId = workGroupId(activeWorkAnchor.id); |
| 920 | return { | 727 | return { |
| 921 | kind: "work-live" as const, | 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 | createdAt: activeWorkAnchor.createdAt, | 732 | createdAt: activeWorkAnchor.createdAt, |
| 924 | entry: displayedToolEntry.entry, | 733 | entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, |
| 925 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), | 734 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), |
| 926 | groupId, | 735 | groupId, |
| 927 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, | 736 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, |
| 928 | active: latestRunningToolEntry !== undefined, | 737 | active: latestToolKeepsActivityLive, |
| 929 | }; | 738 | }; |
| 930 | })() | 739 | })() |
| 931 | : null; | 740 | : null; |
| @@ -939,11 +748,11 @@ export function deriveMessagesTimelineRows(input: { · 7 unmodified lines | |||
| 939 | createdAt: input.activeTurnStartedAt ?? null, | 748 | createdAt: input.activeTurnStartedAt ?? null, |
| 940 | }); | 749 | }); |
| 941 | }; | 750 | }; |
| 942 | let hasLiveWorkRow = false; | 751 | let hasActivityRow = false; |
| 943 | const appendActiveWorkRows = () => { | 752 | const appendActiveWorkRows = () => { |
| 944 | if (activeWorkRow === null) return; | 753 | if (activeWorkRow === null) return; |
| 945 | nextRows.push(activeWorkRow); | 754 | nextRows.push(activeWorkRow); |
| 946 | hasLiveWorkRow ||= activeWorkRow.active; | 755 | hasActivityRow ||= activeWorkRow.active; |
| 947 | if (!activeWorkRow.expanded) return; | 756 | if (!activeWorkRow.expanded) return; |
| 948 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { | 757 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { |
| 949 | nextRows.push({ | 758 | nextRows.push({ |
| @@ -1059,7 +868,6 @@ export function deriveMessagesTimelineRows(input: { · 109 unmodified lines | |||
| 1059 | const groupId = workGroupId(timelineEntry.id); | 868 | const groupId = workGroupId(timelineEntry.id); |
| 1060 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; | 869 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 1061 | const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; | 870 | const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; |
| 1062 | hasLiveWorkRow = true; | ||
| 1063 | nextRows.push({ | 871 | nextRows.push({ |
| 1064 | kind: "work-live", | 872 | kind: "work-live", |
| 1065 | id: `work-live:${timelineEntry.id}`, | 873 | id: `work-live:${timelineEntry.id}`, |
| @@ -1070,6 +878,7 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines | |||
| 1070 | expanded, | 878 | expanded, |
| 1071 | active: true, | 879 | active: true, |
| 1072 | }); | 880 | }); |
| 881 | hasActivityRow = true; | ||
| 1073 | if (expanded) { | 882 | if (expanded) { |
| 1074 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | 883 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
| 1075 | nextRows.push({ | 884 | nextRows.push({ |
| @@ -1192,10 +1001,10 @@ export function deriveMessagesTimelineRows(input: { · 116 unmodified lines | |||
| 1192 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { | 1001 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 1193 | appendWorkingRow(); | 1002 | appendWorkingRow(); |
| 1194 | } | 1003 | } |
| 1195 | if (input.isWorking && !hasLiveWorkRow) { | 1004 | if (input.isWorking && !hasActivityRow) { |
| 1196 | nextRows.push({ | 1005 | nextRows.push({ |
| 1197 | kind: "thinking", | 1006 | kind: "thinking", |
| 1198 | id: "thinking-indicator-row", | 1007 | id: LIVE_ACTIVITY_ROW_ID, |
| 1199 | createdAt: input.activeTurnStartedAt ?? null, | 1008 | createdAt: input.activeTurnStartedAt ?? null, |
| 1200 | }); | 1009 | }); |
| 1201 | } | 1010 | } |
| @@ -64,6 +64,7 @@ import { · 63 unmodified lines | |||
| 64 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; | 64 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 65 | import { | 65 | import { |
| 66 | BotIcon, | 66 | BotIcon, |
| 67 | BrainIcon, | ||
| 67 | CheckIcon, | 68 | CheckIcon, |
| 68 | ChevronDownIcon, | 69 | ChevronDownIcon, |
| 69 | ChevronRightIcon, | 70 | ChevronRightIcon, |
| @@ -2092,7 +2093,7 @@ function ThinkingTimelineRow() { · 2022 unmodified lines | |||
| 2092 | // Reserve the activity row during setup so the handoff keeps the same height. | 2093 | // Reserve the activity row during setup so the handoff keeps the same height. |
| 2093 | return ( | 2094 | return ( |
| 2094 | <div className="min-h-7"> | 2095 | <div className="min-h-7"> |
| 2095 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />} | 2096 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />} |
| 2096 | </div> | 2097 | </div> |
| 2097 | ); | 2098 | ); |
| 2098 | } | 2099 | } |
| @@ -2778,6 +2779,7 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { · 679 unmodified lines | |||
| 2778 | 2779 | ||
| 2779 | type WorkEntryIconName = | 2780 | type WorkEntryIconName = |
| 2780 | | "bot" | 2781 | | "bot" |
| 2782 | | "brain" | ||
| 2781 | | "check" | 2783 | | "check" |
| 2782 | | "circle-alert" | 2784 | | "circle-alert" |
| 2783 | | "eye" | 2785 | | "eye" |
| @@ -2795,6 +2797,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN · 11 unmodified lines | |||
| 2795 | switch (name) { | 2797 | switch (name) { |
| 2796 | case "bot": | 2798 | case "bot": |
| 2797 | return <BotIcon className={className} aria-hidden />; | 2799 | return <BotIcon className={className} aria-hidden />; |
| 2800 | case "brain": | ||
| 2801 | return <BrainIcon className={className} aria-hidden />; | ||
| 2798 | case "check": | 2802 | case "check": |
| 2799 | return <CheckIcon className={className} aria-hidden />; | 2803 | return <CheckIcon className={className} aria-hidden />; |
| 2800 | case "circle-alert": | 2804 | case "circle-alert": |
| @@ -2849,8 +2853,8 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { · 48 unmodified lines | |||
| 2849 | } | 2853 | } |
| 2850 | if (tone === "thinking") { | 2854 | if (tone === "thinking") { |
| 2851 | return { | 2855 | return { |
| 2852 | iconName: "bot", | 2856 | iconName: "brain", |
| 2853 | className: "text-foreground/92", | 2857 | className: "text-foreground", |
| 2854 | }; | 2858 | }; |
| 2855 | } | 2859 | } |
| 2856 | if (tone === "info") { | 2860 | if (tone === "info") { |
| @@ -203,6 +203,7 @@ describe("external chat link context menu", () => { · 202 unmodified lines | |||
| 203 | "https://example.com/docs?topic=security#links", | 203 | "https://example.com/docs?topic=security#links", |
| 204 | ], | 204 | ], |
| 205 | ["HTTP://EXAMPLE.COM", "http://example.com/"], | 205 | ["HTTP://EXAMPLE.COM", "http://example.com/"], |
| 206 | ["//example.com/path", "https://example.com/path"], | ||
| 206 | ])("resolves the safe external web-link href for %s as %s", (href, expected) => { | 207 | ])("resolves the safe external web-link href for %s as %s", (href, expected) => { |
| 207 | expect(resolveExternalWebLinkHref(href)).toBe(expected); | 208 | expect(resolveExternalWebLinkHref(href)).toBe(expected); |
| 208 | }); | 209 | }); |
| @@ -213,7 +214,6 @@ describe("external chat link context menu", () => { · 4 unmodified lines | |||
| 213 | "data:text/html,<script>alert(document.domain)</script>", | 214 | "data:text/html,<script>alert(document.domain)</script>", |
| 214 | "file:///tmp/example.txt", | 215 | "file:///tmp/example.txt", |
| 215 | "mailto:hello@example.com", | 216 | "mailto:hello@example.com", |
| 216 | "//example.com/path", | ||
| 217 | "/relative/path", | 217 | "/relative/path", |
| 218 | "not a URL", | 218 | "not a URL", |
| 219 | "", | 219 | "", |
| @@ -2254,31 +2254,8 @@ export function GeneralSettingsPanel() { · 2253 unmodified lines | |||
| 2254 | /> | 2254 | /> |
| 2255 | } | 2255 | } |
| 2256 | /> | 2256 | /> |
| 2257 | ) : null | 2257 | ) : null} |
| 2258 | } | 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 | /> | ||
| 2282 | ) : null} | 2259 | ) : null} |
| 2283 | </SettingsSection> | 2260 | </SettingsSection> |
| 2284 | 2261 | ||
| @@ -147,6 +147,7 @@ describe("searchSettings", () => { · 146 unmodified lines | |||
| 147 | hasProviderSettingsEnvironment: false, | 147 | hasProviderSettingsEnvironment: false, |
| 148 | canManageLocalBackend: false, | 148 | canManageLocalBackend: false, |
| 149 | isWslSettingsRowVisible: false, | 149 | isWslSettingsRowVisible: false, |
| 150 | hasThreadAutoSettlement: false, | ||
| 150 | }); | 151 | }); |
| 151 | 152 | ||
| 152 | const gatedIds = new Set<string>([ | 153 | const gatedIds = new Set<string>([ |
| @@ -160,10 +161,30 @@ describe("searchSettings", () => { · 7 unmodified lines | |||
| 160 | "t3-connect", | 161 | "t3-connect", |
| 161 | "tailscale-https", | 162 | "tailscale-https", |
| 162 | "wsl-backend", | 163 | "wsl-backend", |
| 164 | "auto-settle-inactive-threads", | ||
| 165 | "auto-settle-merged-threads", | ||
| 166 | "days-before-auto-settle", | ||
| 163 | ]); | 167 | ]); |
| 164 | expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); | 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 | it("keeps catalog result ids unique", () => { | 188 | it("keeps catalog result ids unique", () => { |
| 168 | const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); | 189 | const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); |
| 169 | expect(new Set(ids).size).toBe(ids.length); | 190 | expect(new Set(ids).size).toBe(ids.length); |
| @@ -31,6 +31,7 @@ export interface SettingsSearchItem { · 30 unmodified lines | |||
| 31 | readonly providerSettingsOnly?: boolean; | 31 | readonly providerSettingsOnly?: boolean; |
| 32 | readonly localBackendManagementOnly?: boolean; | 32 | readonly localBackendManagementOnly?: boolean; |
| 33 | readonly wslAvailableOnly?: boolean; | 33 | readonly wslAvailableOnly?: boolean; |
| 34 | readonly requiresThreadAutoSettlement?: boolean; | ||
| 34 | } | 35 | } |
| 35 | 36 | ||
| 36 | export interface SettingsSearchAvailability { | 37 | export interface SettingsSearchAvailability { |
| @@ -39,6 +40,7 @@ export interface SettingsSearchAvailability { · 2 unmodified lines | |||
| 39 | readonly hasProviderSettingsEnvironment: boolean; | 40 | readonly hasProviderSettingsEnvironment: boolean; |
| 40 | readonly canManageLocalBackend: boolean; | 41 | readonly canManageLocalBackend: boolean; |
| 41 | readonly isWslSettingsRowVisible: boolean; | 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 | title: "Auto-settle inactive threads", | 157 | title: "Auto-settle inactive threads", |
| 156 | to: "/settings/general", | 158 | to: "/settings/general", |
| 157 | searchTerms: ["sidebar inactivity days no activity automatically"], | 159 | searchTerms: ["sidebar inactivity days no activity automatically"], |
| 160 | requiresThreadAutoSettlement: true, | ||
| 158 | }, | 161 | }, |
| 159 | { | 162 | { |
| 160 | id: "auto-settle-merged-threads", | 163 | id: "auto-settle-merged-threads", |
| 161 | title: "Auto-settle merged threads", | 164 | title: "Auto-settle merged threads", |
| 162 | to: "/settings/general", | 165 | to: "/settings/general", |
| 163 | searchTerms: ["pull request merge closed automatically sidebar"], | 166 | searchTerms: ["pull request merge closed automatically sidebar"], |
| 167 | requiresThreadAutoSettlement: true, | ||
| 164 | }, | 168 | }, |
| 165 | { | 169 | { |
| 166 | id: "days-before-auto-settle", | 170 | id: "days-before-auto-settle", |
| @@ -168,6 +172,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line | |||
| 168 | to: "/settings/general", | 172 | to: "/settings/general", |
| 169 | targetId: "auto-settle-inactive-threads", | 173 | targetId: "auto-settle-inactive-threads", |
| 170 | searchTerms: ["thread timeout activity sidebar"], | 174 | searchTerms: ["thread timeout activity sidebar"], |
| 175 | requiresThreadAutoSettlement: true, | ||
| 171 | }, | 176 | }, |
| 172 | { | 177 | { |
| 173 | id: "time-format", | 178 | id: "time-format", |
| @@ -250,7 +255,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 76 unmodified lines | |||
| 250 | id: "quit-confirmation", | 255 | id: "quit-confirmation", |
| 251 | title: "Quit shortcut", | 256 | title: "Quit shortcut", |
| 252 | to: "/settings/general", | 257 | to: "/settings/general", |
| 253 | searchTerms: ["confirmation shortcut desktop app exit"], | 258 | searchTerms: ["confirmation desktop app exit direct hold double click press twice"], |
| 254 | desktopOnly: true, | 259 | desktopOnly: true, |
| 255 | }, | 260 | }, |
| 256 | { | 261 | { |
| @@ -491,7 +496,8 @@ export function filterAvailableSettingsSearchItems( · 234 unmodified lines | |||
| 491 | (!item.primaryOnly || availability.hasPrimaryEnvironment) && | 496 | (!item.primaryOnly || availability.hasPrimaryEnvironment) && |
| 492 | (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && | 497 | (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && |
| 493 | (!item.localBackendManagementOnly || availability.canManageLocalBackend) && | 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 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { AuthAccessWriteScope } from "@t3tools/contracts"; | 3 | import { AuthAccessWriteScope } from "@t3tools/contracts"; |
| 3 | 4 | ||
| 4 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | 5 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| @@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; · 2 unmodified lines | |||
| 7 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | 8 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 8 | import { useEnvironmentQuery } from "~/state/query"; | 9 | import { useEnvironmentQuery } from "~/state/query"; |
| 9 | import { usePrimarySessionState } from "~/environments/primary"; | 10 | import { usePrimarySessionState } from "~/environments/primary"; |
| 11 | import { primaryServerConfigAtom } from "~/state/server"; | ||
| 10 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; | 12 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; |
| 11 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; | 13 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; |
| 12 | import { filterAvailableSettingsSearchItems } from "./settingsSearch"; | 14 | import { filterAvailableSettingsSearchItems } from "./settingsSearch"; |
| @@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() { · 2 unmodified lines | |||
| 15 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 17 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 16 | const { environments } = useEnvironments(); | 18 | const { environments } = useEnvironments(); |
| 17 | const primarySessionState = usePrimarySessionState(); | 19 | const primarySessionState = usePrimarySessionState(); |
| 20 | const primaryServerConfig = useAtomValue(primaryServerConfigAtom); | ||
| 18 | const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); | 21 | const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); |
| 19 | const canManageLocalBackend = | 22 | const canManageLocalBackend = |
| 20 | isElectron || | 23 | isElectron || |
| @@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() { · 17 unmodified lines | |||
| 38 | state: desktopWsl.data, | 41 | state: desktopWsl.data, |
| 39 | error: desktopWsl.error, | 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 | type TerminalContextDraft, | 45 | type TerminalContextDraft, |
| 46 | ensureInlineTerminalContextPlaceholders, | 46 | ensureInlineTerminalContextPlaceholders, |
| 47 | normalizeTerminalContextText, | 47 | normalizeTerminalContextText, |
| 48 | stripInlineTerminalContextPlaceholders, | ||
| 48 | } from "./lib/terminalContext"; | 49 | } from "./lib/terminalContext"; |
| 49 | import { | 50 | import { |
| 50 | type ElementContextDraft, | 51 | type ElementContextDraft, |
| @@ -660,6 +661,13 @@ interface ComposerDraftStoreState { · 609 unmodified lines | |||
| 660 | * prompt stash. Session-bound context stays in the source draft. | 661 | * prompt stash. Session-bound context stays in the source draft. |
| 661 | */ | 662 | */ |
| 662 | clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; | 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 | export interface EffectiveComposerModelState { | 673 | export interface EffectiveComposerModelState { |
| @@ -3900,6 +3908,117 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 3234 unmodified lines | |||
| 3900 | return { draftsByThreadKey: nextDraftsByThreadKey }; | 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 | selectProjectGroupingSettings, | 23 | selectProjectGroupingSettings, |
| 24 | } from "../logicalProject"; | 24 | } from "../logicalProject"; |
| 25 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; | 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 | import { | 27 | import { |
| 28 | hasExplicitComposerModelSelection, | 28 | hasExplicitComposerModelSelection, |
| 29 | resolveNewDraftStartFromOrigin, | 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 | * such threads would be permanently unsettleable. | 138 | * such threads would be permanently unsettleable. |
| 139 | */ | 139 | */ |
| 140 | export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; | 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 | * A user message no turn has picked up yet: the turn.start command was | 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 | ): RpcSession.RpcSession { | 70 | ): RpcSession.RpcSession { |
| 71 | return { | 71 | return { |
| 72 | client, | 72 | client, |
| 73 | initialConfig: Effect.succeed({ | 73 | initialConfig: Effect.succeed( |
| 74 | threadResumeCompletionMarker: config?.completionMarker === true, | 74 | config?.completionMarker === true |
| 75 | } as never), | 75 | ? ({ threadResumeCompletionMarker: true } as never) |
| 76 | : ({} as never), | ||
| 77 | ), | ||
| 78 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 76 | ready: Effect.void, | 79 | ready: Effect.void, |
| 77 | probe: Effect.void, | 80 | probe: Effect.void, |
| 78 | closed: Effect.never, | 81 | closed: Effect.never, |
| @@ -444,7 +444,7 @@ describe("resolveViewedImageAsset", () => { · 443 unmodified lines | |||
| 444 | }), | 444 | }), |
| 445 | ).toEqual({ | 445 | ).toEqual({ |
| 446 | resource: { | 446 | resource: { |
| 447 | _tag: "workspace-file", | 447 | _tag: "media-file", |
| 448 | threadId, | 448 | threadId, |
| 449 | path: "/workspace/screens/logo.svg", | 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 | export interface ViewedImageAsset { | 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 | readonly alt: string; | 139 | readonly alt: string; |
| 140 | readonly srcFragment: string; | 140 | readonly srcFragment: string; |
| 141 | } | 141 | } |
| @@ -165,7 +165,7 @@ export function resolveViewedImageAsset( · 23 unmodified lines | |||
| 165 | return { | 165 | return { |
| 166 | resource: attachmentId | 166 | resource: attachmentId |
| 167 | ? { _tag: "attachment", attachmentId } | 167 | ? { _tag: "attachment", attachmentId } |
| 168 | : { _tag: "workspace-file", threadId: input.threadId, path }, | 168 | : { _tag: "media-file", threadId: input.threadId, path }, |
| 169 | alt: path.split(/[\\/]/).at(-1) ?? "image", | 169 | alt: path.split(/[\\/]/).at(-1) ?? "image", |
| 170 | srcFragment: markdownImageSourceFragment(source), | 170 | srcFragment: markdownImageSourceFragment(source), |
| 171 | }; | 171 | }; |
| @@ -88,7 +88,7 @@ import { EnvironmentId } from "./baseSchemas.ts"; · 87 unmodified lines | |||
| 88 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; | 88 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; |
| 89 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; | 89 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 90 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | 90 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 91 | import type { ClientSettings } from "./settings.ts"; | 91 | import type { ClientSettings, QuitConfirmationMode } from "./settings.ts"; |
| 92 | import type { EditorId } from "./editor.ts"; | 92 | import type { EditorId } from "./editor.ts"; |
| 93 | import type { | 93 | import type { |
| 94 | SourceControlCloneRepositoryInput, | 94 | SourceControlCloneRepositoryInput, |
| @@ -98,6 +98,10 @@ import type { · 3 unmodified lines | |||
| 98 | SourceControlRepositoryInfo, | 98 | SourceControlRepositoryInfo, |
| 99 | SourceControlRepositoryLookupInput, | 99 | SourceControlRepositoryLookupInput, |
| 100 | } from "./sourceControl.ts"; | 100 | } from "./sourceControl.ts"; |
| 101 | import type { | ||
| 102 | DesktopAppActivationRequest, | ||
| 103 | DesktopAppActivationResponse, | ||
| 104 | } from "./desktopAppActivation.ts"; | ||
| 101 | 105 | ||
| 102 | export interface ContextMenuItem<T extends string = string> { | 106 | export interface ContextMenuItem<T extends string = string> { |
| 103 | id: T; | 107 | id: T; |
| @@ -1103,11 +1107,10 @@ export interface DesktopBridge { · 999 unmodified lines | |||
| 1103 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; | 1107 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; |
| 1104 | onMenuAction: (listener: (action: string) => void) => () => void; | 1108 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 1105 | /** | 1109 | /** |
| 1106 | * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, | 1110 | * Quit-confirmation hint pushes. Optional: older desktop builds never emit |
| 1107 | * "up" when it is released before the hold completes. Optional: older | 1111 | * them. |
| 1108 | * desktop builds never emit it. | ||
| 1109 | */ | 1112 | */ |
| 1110 | onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; | 1113 | onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; |
| 1111 | getWindowFullscreenState: () => boolean; | 1114 | getWindowFullscreenState: () => boolean; |
| 1112 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; | 1115 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; |
| 1113 | getUpdateState: () => Promise<DesktopUpdateState>; | 1116 | getUpdateState: () => Promise<DesktopUpdateState>; |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | import { | 4 | import { |
| 5 | applyClaudePromptEffortPrefix, | 5 | applyClaudePromptEffortPrefix, |
| @@ -40,9 +40,11 @@ const codexCaps: ModelCapabilities = createModelCapabilities({ · 34 unmodified lines | |||
| 40 | 40 | ||
| 41 | describe("model slug normalization", () => { | 41 | describe("model slug normalization", () => { |
| 42 | it("preserves exact custom slugs instead of expanding provider aliases", () => { | 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 | const claude = ProviderDriverKind.make("claudeAgent"); | 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 | expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); | 48 | expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); |
| 47 | }); | 49 | }); |
| 48 | }); | 50 | }); |
| @@ -241,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a · 240 unmodified lines | |||
| 241 | * Number of columns to render items in. | 241 | * Number of columns to render items in. |
| 242 | * @default 1 | 242 | * @default 1 |
| 243 | diff --git a/react-native.js b/react-native.js | 243 | diff --git a/react-native.js b/react-native.js |
| 244 | index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c3ffe83af 100644 | 244 | index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df752a843de0 100644 |
| 245 | --- a/react-native.js | 245 | --- a/react-native.js |
| 246 | +++ b/react-native.js | 246 | +++ b/react-native.js |
| 247 | @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { | 247 | @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { |
| @@ -278,7 +278,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 30 unmodified lines | |||
| 278 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | 278 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { |
| 279 | set$(ctx, "readyToRender", true); | 279 | set$(ctx, "readyToRender", true); |
| 280 | setAdaptiveRender(ctx, "normal", "ready"); | 280 | setAdaptiveRender(ctx, "normal", "ready"); |
| 281 | @@ -1090,7 +1090,7 @@ function getRawContentLength(ctx) { | 281 | @@ -1090,7 +1099,7 @@ function getRawContentLength(ctx) { |
| 282 | function getAlignItemsAtEndPadding(ctx) { | 282 | function getAlignItemsAtEndPadding(ctx) { |
| 283 | const { state } = ctx; | 283 | const { state } = ctx; |
| 284 | const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; | 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 | function updateContentMetricsState(ctx) { | 288 | function updateContentMetricsState(ctx) { |
| 289 | const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; | 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 | totalSize += add; | 291 | totalSize += add; |
| 292 | } | 292 | } |
| 293 | if (prevTotalSize !== totalSize) { | 293 | if (prevTotalSize !== totalSize) { |
| @@ -298,7 +298,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines | |||
| 298 | if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { | 298 | if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { |
| 299 | state.pendingTotalSize = totalSize; | 299 | state.pendingTotalSize = totalSize; |
| 300 | } else { | 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 | // src/core/clampScrollOffset.ts | 304 | // src/core/clampScrollOffset.ts |
| @@ -324,7 +324,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 19 unmodified lines | |||
| 324 | return clampedOffset; | 324 | return clampedOffset; |
| 325 | } | 325 | } |
| 326 | 326 | ||
| 327 | @@ -1451,10 +1460,10 @@ function checkFinishedScrollFrame(ctx) { | 327 | @@ -1451,10 +1469,10 @@ function checkFinishedScrollFrame(ctx) { |
| 328 | finishScrollTo(ctx); | 328 | finishScrollTo(ctx); |
| 329 | } | 329 | } |
| 330 | } | 330 | } |
| @@ -337,7 +337,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines | |||
| 337 | x: ctx.state.props.horizontal ? offset : 0, | 337 | x: ctx.state.props.horizontal ? offset : 0, |
| 338 | y: ctx.state.props.horizontal ? 0 : offset | 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 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | 342 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); |
| 343 | } else if (shouldRetryUnalignedEndScroll) { | 343 | } else if (shouldRetryUnalignedEndScroll) { |
| @@ -349,7 +349,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 5 unmodified lines | |||
| 349 | scheduleFallbackCheck(100); | 349 | scheduleFallbackCheck(100); |
| 350 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | 350 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { |
| 351 | finishScrollTo(ctx); | 351 | finishScrollTo(ctx); |
| 352 | @@ -1560,15 +1572,28 @@ function doMaintainScrollAtEnd(ctx) { | 352 | @@ -1560,15 +1581,28 @@ function doMaintainScrollAtEnd(ctx) { |
| 353 | } = state; | 353 | } = state; |
| 354 | const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); | 354 | const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); |
| 355 | const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); | 355 | const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); |
| @@ -379,7 +379,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 23 unmodified lines | |||
| 379 | } | 379 | } |
| 380 | if (!state.maintainingScrollAtEnd) { | 380 | if (!state.maintainingScrollAtEnd) { |
| 381 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | 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 | y: 0 | 383 | y: 0 |
| 384 | }); | 384 | }); |
| 385 | } else { | 385 | } else { |
| @@ -401,7 +401,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 15 unmodified lines | |||
| 401 | } | 401 | } |
| 402 | setTimeout( | 402 | setTimeout( |
| 403 | () => { | 403 | () => { |
| 404 | @@ -1624,6 +1658,10 @@ function doMaintainScrollAtEnd(ctx) { | 404 | @@ -1624,6 +1667,10 @@ function doMaintainScrollAtEnd(ctx) { |
| 405 | function requestAdjust(ctx, positionDiff, dataChanged) { | 405 | function requestAdjust(ctx, positionDiff, dataChanged) { |
| 406 | const state = ctx.state; | 406 | const state = ctx.state; |
| 407 | if (Math.abs(positionDiff) > 0.1) { | 407 | if (Math.abs(positionDiff) > 0.1) { |
| @@ -412,7 +412,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines | |||
| 412 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; | 412 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; |
| 413 | const doit = () => { | 413 | const doit = () => { |
| 414 | if (needsScrollWorkaround) { | 414 | if (needsScrollWorkaround) { |
| 415 | @@ -1728,7 +1766,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | 415 | @@ -1728,7 +1775,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { |
| 416 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | 416 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { |
| 417 | return 0; | 417 | return 0; |
| 418 | } | 418 | } |
| @@ -423,7 +423,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines | |||
| 423 | const clampDelta = maxScroll - state.scroll; | 423 | const clampDelta = maxScroll - state.scroll; |
| 424 | if (unresolvedAmount < 0) { | 424 | if (unresolvedAmount < 0) { |
| 425 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | 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 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | 427 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); |
| 428 | return true; | 428 | return true; |
| 429 | } | 429 | } |
| @@ -432,7 +432,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines | |||
| 432 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | 432 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); |
| 433 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | 433 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; |
| 434 | if (isAtExpectedNativeClamp) { | 434 | if (isAtExpectedNativeClamp) { |
| 435 | @@ -1923,7 +1963,7 @@ function prepareMVCP(ctx, dataChanged) { | 435 | @@ -1923,7 +1972,7 @@ function prepareMVCP(ctx, dataChanged) { |
| 436 | if (diff > 0) { | 436 | if (diff > 0) { |
| 437 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | 437 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); |
| 438 | } else { | 438 | } else { |
| @@ -441,7 +441,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines | |||
| 441 | state.scroll = maxScroll; | 441 | state.scroll = maxScroll; |
| 442 | state.scrollPending = maxScroll; | 442 | state.scrollPending = maxScroll; |
| 443 | diff = 0; | 443 | diff = 0; |
| 444 | @@ -2320,8 +2360,121 @@ function scrollToIndex(ctx, { | 444 | @@ -2320,8 +2369,121 @@ function scrollToIndex(ctx, { |
| 445 | } | 445 | } |
| 446 | 446 | ||
| 447 | // src/core/initialScroll.ts | 447 | // src/core/initialScroll.ts |
| @@ -563,7 +563,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 115 unmodified lines | |||
| 563 | const requestedIndex = target.index; | 563 | const requestedIndex = target.index; |
| 564 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | 564 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; |
| 565 | const itemSize = getItemSizeAtIndex(ctx, index); | 565 | const itemSize = getItemSizeAtIndex(ctx, index); |
| 566 | @@ -2747,7 +2900,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | 566 | @@ -2747,7 +2909,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { |
| 567 | return; | 567 | return; |
| 568 | } | 568 | } |
| 569 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | 569 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { |
| @@ -574,7 +574,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines | |||
| 574 | if (!shouldKeepEndTargetAlive) { | 574 | if (!shouldKeepEndTargetAlive) { |
| 575 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | 575 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { |
| 576 | clearPendingInitialScrollFooterLayout(ctx, { | 576 | clearPendingInitialScrollFooterLayout(ctx, { |
| 577 | @@ -4672,7 +4827,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | 577 | @@ -4672,7 +4836,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 578 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); | 578 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 579 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); | 579 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 580 | isReady = !hasUnknownTailSize; | 580 | isReady = !hasUnknownTailSize; |
| @@ -584,7 +584,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 3 unmodified lines | |||
| 584 | } else if (anchorIndex >= 0) { | 584 | } else if (anchorIndex >= 0) { |
| 585 | isReady = false; | 585 | isReady = false; |
| 586 | } | 586 | } |
| 587 | @@ -4692,6 +4848,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | 587 | @@ -4692,6 +4857,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 588 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); | 588 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 589 | } | 589 | } |
| 590 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | 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 | return nextSize; | 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 | horizontal | 601 | horizontal |
| 602 | }) { | 602 | }) { |
| 603 | const ctx = useStateContext(); | 603 | const ctx = useStateContext(); |
| @@ -605,7 +605,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | |||
| 605 | const columnWrapperStyle = ctx.columnWrapperStyle; | 605 | const columnWrapperStyle = ctx.columnWrapperStyle; |
| 606 | const animSize = useValue$("totalSize"); | 606 | const animSize = useValue$("totalSize"); |
| 607 | const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); | 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 | opacity: isVisible ? 1 : 0, | 609 | opacity: isVisible ? 1 : 0, |
| 610 | width: animSize | 610 | width: animSize |
| 611 | } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; | 611 | } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; |
| @@ -619,7 +619,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 7 unmodified lines | |||
| 619 | if (columnWrapperStyle) { | 619 | if (columnWrapperStyle) { |
| 620 | const { columnGap, rowGap, gap } = columnWrapperStyle; | 620 | const { columnGap, rowGap, gap } = columnWrapperStyle; |
| 621 | const gapX = columnGap || gap || 0; | 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 | var Containers = typedMemo(function Containers2({ | 630 | var Containers = typedMemo(function Containers2({ |
| 631 | freshDataTransitionEpoch, | 631 | freshDataTransitionEpoch, |
| 632 | @@ -5896,7 +6067,12 @@ var StyleSheet = ReactNative.StyleSheet; | 632 | @@ -5896,7 +6076,12 @@ var StyleSheet = ReactNative.StyleSheet; |
| 633 | 633 | ||
| 634 | // src/components/ListComponent.tsx | 634 | // src/components/ListComponent.tsx |
| 635 | var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { | 635 | var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { |
| @@ -642,7 +642,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines | |||
| 642 | if (alignItemsAtEndPadding <= 0) { | 642 | if (alignItemsAtEndPadding <= 0) { |
| 643 | return null; | 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 | refScrollView, | 646 | refScrollView, |
| 647 | renderScrollComponent, | 647 | renderScrollComponent, |
| 648 | onLayoutFooter, | 648 | onLayoutFooter, |
| @@ -655,7 +655,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines | |||
| 655 | scrollAdjustHandler, | 655 | scrollAdjustHandler, |
| 656 | snapToIndices, | 656 | snapToIndices, |
| 657 | stickyHeaderConfig, | 657 | stickyHeaderConfig, |
| 658 | @@ -6001,7 +6181,17 @@ var ListComponent = typedMemo(function ListComponent2({ | 658 | @@ -6001,7 +6190,17 @@ var ListComponent = typedMemo(function ListComponent2({ |
| 659 | SnapOrScroll, | 659 | SnapOrScroll, |
| 660 | { | 660 | { |
| 661 | ...rest, | 661 | ...rest, |
| @@ -674,7 +674,16 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 12 unmodified lines | |||
| 674 | contentContainerStyle: [ | 674 | contentContainerStyle: [ |
| 675 | horizontal ? { height: "100%" } : {}, | 675 | horizontal ? { height: "100%" } : {}, |
| 676 | contentContainerStyle, | 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 | dataVersion, | 687 | dataVersion, |
| 679 | drawDistance = 250, | 688 | drawDistance = 250, |
| 680 | contentInsetEndAdjustment, | 689 | contentInsetEndAdjustment, |
| @@ -682,7 +691,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | |||
| 682 | estimatedItemSize = 100, | 691 | estimatedItemSize = 100, |
| 683 | estimatedListSize, | 692 | estimatedListSize, |
| 684 | extraData, | 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 | const animatedPropsInternal = props.animatedPropsInternal; | 695 | const animatedPropsInternal = props.animatedPropsInternal; |
| 687 | const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; | 696 | const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; |
| 688 | const positionComponentInternal = props.positionComponentInternal; | 697 | const positionComponentInternal = props.positionComponentInternal; |
| @@ -695,7 +704,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines | |||
| 695 | stickyPositionComponentInternal: _stickyPositionComponentInternal, | 704 | stickyPositionComponentInternal: _stickyPositionComponentInternal, |
| 696 | ...restProps | 705 | ...restProps |
| 697 | } = rest; | 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 | const combinedRef = useCombinedRef(refScroller, refScrollView); | 708 | const combinedRef = useCombinedRef(refScroller, refScrollView); |
| 700 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | 709 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); |
| 701 | const stickyHeaderIndices = stickyHeaderIndicesProp; | 710 | const stickyHeaderIndices = stickyHeaderIndicesProp; |
| @@ -704,7 +713,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines | |||
| 704 | const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); | 713 | const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); |
| 705 | const alwaysRenderIndices = React2.useMemo(() => { | 714 | const alwaysRenderIndices = React2.useMemo(() => { |
| 706 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | 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 | contentContainerAlignItems: contentContainerStyle.alignItems, | 717 | contentContainerAlignItems: contentContainerStyle.alignItems, |
| 709 | contentInset, | 718 | contentInset, |
| 710 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | 719 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, |
| @@ -712,7 +721,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | |||
| 712 | data: dataProp, | 721 | data: dataProp, |
| 713 | dataKey, | 722 | dataKey, |
| 714 | dataVersion, | 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 | renderItem, | 725 | renderItem, |
| 717 | rtl, | 726 | rtl, |
| 718 | snapToIndices, | 727 | snapToIndices, |
| @@ -720,7 +729,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | |||
| 720 | stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], | 729 | stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], |
| 721 | stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), | 730 | stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), |
| 722 | stickyPositionComponentInternal, | 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 | return void 0; | 733 | return void 0; |
| 725 | } | 734 | } |
| 726 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | 735 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); |
| @@ -734,7 +743,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 7 unmodified lines | |||
| 734 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | 743 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; |
| 735 | }, [usesBootstrapInitialScroll]); | 744 | }, [usesBootstrapInitialScroll]); |
| 736 | React2.useLayoutEffect(() => { | 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 | dataKey, | 748 | dataKey, |
| 740 | dataVersion, | 749 | dataVersion, |
| @@ -742,7 +751,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | |||
| 742 | memoizedLastItemKeys.join(","), | 751 | memoizedLastItemKeys.join(","), |
| 743 | numColumnsProp, | 752 | numColumnsProp, |
| 744 | nextScrollAxisGap, | 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 | getRenderedItem: (key) => getRenderedItem(ctx, key), | 756 | getRenderedItem: (key) => getRenderedItem(ctx, key), |
| 748 | onMomentumScrollEnd: (event) => { | 757 | onMomentumScrollEnd: (event) => { |
| @@ -750,7 +759,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line | |||
| 750 | checkFinishedScrollFallback(ctx); | 759 | checkFinishedScrollFallback(ctx); |
| 751 | if (state.props.onMomentumScrollEnd) { | 760 | if (state.props.onMomentumScrollEnd) { |
| 752 | state.props.onMomentumScrollEnd(event); | 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 | onScroll: (event) => onScroll(ctx, event), | 763 | onScroll: (event) => onScroll(ctx, event), |
| 755 | onScrollBeginDrag: (event) => { | 764 | onScrollBeginDrag: (event) => { |
| 756 | var _a4, _b2; | 765 | var _a4, _b2; |
| @@ -759,7 +768,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines | |||
| 759 | prepareReachedEdgeForNextUserScroll(ctx); | 768 | prepareReachedEdgeForNextUserScroll(ctx); |
| 760 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); | 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 | ListFooterComponent, | 772 | ListFooterComponent, |
| 764 | ListFooterComponentStyle, | 773 | ListFooterComponentStyle, |
| 765 | ListHeaderComponent, | 774 | ListHeaderComponent, |
| @@ -779,7 +788,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 13 unmodified lines | |||
| 779 | recycleItems, | 788 | recycleItems, |
| 780 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { | 789 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { |
| 781 | diff --git a/react-native.mjs b/react-native.mjs | 790 | diff --git a/react-native.mjs b/react-native.mjs |
| 782 | index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318d57e29dc 100644 | 791 | index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff7e3ad022 100644 |
| 783 | --- a/react-native.mjs | 792 | --- a/react-native.mjs |
| 784 | +++ b/react-native.mjs | 793 | +++ b/react-native.mjs |
| 785 | @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { | 794 | @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { |
| @@ -816,7 +825,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 30 unmodified lines | |||
| 816 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | 825 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { |
| 817 | set$(ctx, "readyToRender", true); | 826 | set$(ctx, "readyToRender", true); |
| 818 | setAdaptiveRender(ctx, "normal", "ready"); | 827 | setAdaptiveRender(ctx, "normal", "ready"); |
| 819 | @@ -1069,7 +1069,7 @@ function getRawContentLength(ctx) { | 828 | @@ -1069,7 +1078,7 @@ function getRawContentLength(ctx) { |
| 820 | function getAlignItemsAtEndPadding(ctx) { | 829 | function getAlignItemsAtEndPadding(ctx) { |
| 821 | const { state } = ctx; | 830 | const { state } = ctx; |
| 822 | const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; | 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 | function updateContentMetricsState(ctx) { | 835 | function updateContentMetricsState(ctx) { |
| 827 | const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; | 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 | totalSize += add; | 838 | totalSize += add; |
| 830 | } | 839 | } |
| 831 | if (prevTotalSize !== totalSize) { | 840 | if (prevTotalSize !== totalSize) { |
| @@ -836,7 +845,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines | |||
| 836 | if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { | 845 | if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { |
| 837 | state.pendingTotalSize = totalSize; | 846 | state.pendingTotalSize = totalSize; |
| 838 | } else { | 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 | // src/core/clampScrollOffset.ts | 851 | // src/core/clampScrollOffset.ts |
| @@ -862,7 +871,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 19 unmodified lines | |||
| 862 | return clampedOffset; | 871 | return clampedOffset; |
| 863 | } | 872 | } |
| 864 | 873 | ||
| 865 | @@ -1430,10 +1439,10 @@ function checkFinishedScrollFrame(ctx) { | 874 | @@ -1430,10 +1448,10 @@ function checkFinishedScrollFrame(ctx) { |
| 866 | finishScrollTo(ctx); | 875 | finishScrollTo(ctx); |
| 867 | } | 876 | } |
| 868 | } | 877 | } |
| @@ -875,7 +884,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines | |||
| 875 | x: ctx.state.props.horizontal ? offset : 0, | 884 | x: ctx.state.props.horizontal ? offset : 0, |
| 876 | y: ctx.state.props.horizontal ? 0 : offset | 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 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | 889 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); |
| 881 | } else if (shouldRetryUnalignedEndScroll) { | 890 | } else if (shouldRetryUnalignedEndScroll) { |
| @@ -887,7 +896,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 5 unmodified lines | |||
| 887 | scheduleFallbackCheck(100); | 896 | scheduleFallbackCheck(100); |
| 888 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | 897 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { |
| 889 | finishScrollTo(ctx); | 898 | finishScrollTo(ctx); |
| 890 | @@ -1539,15 +1551,28 @@ function doMaintainScrollAtEnd(ctx) { | 899 | @@ -1539,15 +1560,28 @@ function doMaintainScrollAtEnd(ctx) { |
| 891 | } = state; | 900 | } = state; |
| 892 | const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); | 901 | const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); |
| 893 | const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); | 902 | const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); |
| @@ -917,7 +926,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 23 unmodified lines | |||
| 917 | } | 926 | } |
| 918 | if (!state.maintainingScrollAtEnd) { | 927 | if (!state.maintainingScrollAtEnd) { |
| 919 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | 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 | y: 0 | 930 | y: 0 |
| 922 | }); | 931 | }); |
| 923 | } else { | 932 | } else { |
| @@ -939,7 +948,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 15 unmodified lines | |||
| 939 | } | 948 | } |
| 940 | setTimeout( | 949 | setTimeout( |
| 941 | () => { | 950 | () => { |
| 942 | @@ -1603,6 +1637,10 @@ function doMaintainScrollAtEnd(ctx) { | 951 | @@ -1603,6 +1646,10 @@ function doMaintainScrollAtEnd(ctx) { |
| 943 | function requestAdjust(ctx, positionDiff, dataChanged) { | 952 | function requestAdjust(ctx, positionDiff, dataChanged) { |
| 944 | const state = ctx.state; | 953 | const state = ctx.state; |
| 945 | if (Math.abs(positionDiff) > 0.1) { | 954 | if (Math.abs(positionDiff) > 0.1) { |
| @@ -950,7 +959,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines | |||
| 950 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; | 959 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; |
| 951 | const doit = () => { | 960 | const doit = () => { |
| 952 | if (needsScrollWorkaround) { | 961 | if (needsScrollWorkaround) { |
| 953 | @@ -1707,7 +1745,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | 962 | @@ -1707,7 +1754,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { |
| 954 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | 963 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { |
| 955 | return 0; | 964 | return 0; |
| 956 | } | 965 | } |
| @@ -961,7 +970,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines | |||
| 961 | const clampDelta = maxScroll - state.scroll; | 970 | const clampDelta = maxScroll - state.scroll; |
| 962 | if (unresolvedAmount < 0) { | 971 | if (unresolvedAmount < 0) { |
| 963 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | 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 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | 974 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); |
| 966 | return true; | 975 | return true; |
| 967 | } | 976 | } |
| @@ -970,7 +979,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines | |||
| 970 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | 979 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); |
| 971 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | 980 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; |
| 972 | if (isAtExpectedNativeClamp) { | 981 | if (isAtExpectedNativeClamp) { |
| 973 | @@ -1902,7 +1942,7 @@ function prepareMVCP(ctx, dataChanged) { | 982 | @@ -1902,7 +1951,7 @@ function prepareMVCP(ctx, dataChanged) { |
| 974 | if (diff > 0) { | 983 | if (diff > 0) { |
| 975 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | 984 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); |
| 976 | } else { | 985 | } else { |
| @@ -979,7 +988,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines | |||
| 979 | state.scroll = maxScroll; | 988 | state.scroll = maxScroll; |
| 980 | state.scrollPending = maxScroll; | 989 | state.scrollPending = maxScroll; |
| 981 | diff = 0; | 990 | diff = 0; |
| 982 | @@ -2299,8 +2339,121 @@ function scrollToIndex(ctx, { | 991 | @@ -2299,8 +2348,121 @@ function scrollToIndex(ctx, { |
| 983 | } | 992 | } |
| 984 | 993 | ||
| 985 | // src/core/initialScroll.ts | 994 | // src/core/initialScroll.ts |
| @@ -1101,7 +1110,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 115 unmodified lines | |||
| 1101 | const requestedIndex = target.index; | 1110 | const requestedIndex = target.index; |
| 1102 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | 1111 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; |
| 1103 | const itemSize = getItemSizeAtIndex(ctx, index); | 1112 | const itemSize = getItemSizeAtIndex(ctx, index); |
| 1104 | @@ -2726,7 +2879,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | 1113 | @@ -2726,7 +2888,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { |
| 1105 | return; | 1114 | return; |
| 1106 | } | 1115 | } |
| 1107 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | 1116 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { |
| @@ -1112,7 +1121,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines | |||
| 1112 | if (!shouldKeepEndTargetAlive) { | 1121 | if (!shouldKeepEndTargetAlive) { |
| 1113 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | 1122 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { |
| 1114 | clearPendingInitialScrollFooterLayout(ctx, { | 1123 | clearPendingInitialScrollFooterLayout(ctx, { |
| 1115 | @@ -4651,7 +4806,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | 1124 | @@ -4651,7 +4815,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 1116 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); | 1125 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 1117 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); | 1126 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 1118 | isReady = !hasUnknownTailSize; | 1127 | isReady = !hasUnknownTailSize; |
| @@ -1122,7 +1131,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 3 unmodified lines | |||
| 1122 | } else if (anchorIndex >= 0) { | 1131 | } else if (anchorIndex >= 0) { |
| 1123 | isReady = false; | 1132 | isReady = false; |
| 1124 | } | 1133 | } |
| 1125 | @@ -4671,6 +4827,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | 1134 | @@ -4671,6 +4836,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 1126 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); | 1135 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 1127 | } | 1136 | } |
| 1128 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | 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 | return nextSize; | 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 | horizontal | 1148 | horizontal |
| 1140 | }) { | 1149 | }) { |
| 1141 | const ctx = useStateContext(); | 1150 | const ctx = useStateContext(); |
| @@ -1143,7 +1152,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | |||
| 1143 | const columnWrapperStyle = ctx.columnWrapperStyle; | 1152 | const columnWrapperStyle = ctx.columnWrapperStyle; |
| 1144 | const animSize = useValue$("totalSize"); | 1153 | const animSize = useValue$("totalSize"); |
| 1145 | const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); | 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 | opacity: isVisible ? 1 : 0, | 1156 | opacity: isVisible ? 1 : 0, |
| 1148 | width: animSize | 1157 | width: animSize |
| 1149 | } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; | 1158 | } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; |
| @@ -1157,7 +1166,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 7 unmodified lines | |||
| 1157 | if (columnWrapperStyle) { | 1166 | if (columnWrapperStyle) { |
| 1158 | const { columnGap, rowGap, gap } = columnWrapperStyle; | 1167 | const { columnGap, rowGap, gap } = columnWrapperStyle; |
| 1159 | const gapX = columnGap || gap || 0; | 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 | var Containers = typedMemo(function Containers2({ | 1177 | var Containers = typedMemo(function Containers2({ |
| 1169 | freshDataTransitionEpoch, | 1178 | freshDataTransitionEpoch, |
| 1170 | @@ -5875,7 +6046,12 @@ var StyleSheet = StyleSheet$1; | 1179 | @@ -5875,7 +6055,12 @@ var StyleSheet = StyleSheet$1; |
| 1171 | 1180 | ||
| 1172 | // src/components/ListComponent.tsx | 1181 | // src/components/ListComponent.tsx |
| 1173 | var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { | 1182 | var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { |
| @@ -1180,7 +1189,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines | |||
| 1180 | if (alignItemsAtEndPadding <= 0) { | 1189 | if (alignItemsAtEndPadding <= 0) { |
| 1181 | return null; | 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 | refScrollView, | 1193 | refScrollView, |
| 1185 | renderScrollComponent, | 1194 | renderScrollComponent, |
| 1186 | onLayoutFooter, | 1195 | onLayoutFooter, |
| @@ -1193,7 +1202,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines | |||
| 1193 | scrollAdjustHandler, | 1202 | scrollAdjustHandler, |
| 1194 | snapToIndices, | 1203 | snapToIndices, |
| 1195 | stickyHeaderConfig, | 1204 | stickyHeaderConfig, |
| 1196 | @@ -5980,7 +6160,17 @@ var ListComponent = typedMemo(function ListComponent2({ | 1205 | @@ -5980,7 +6169,17 @@ var ListComponent = typedMemo(function ListComponent2({ |
| 1197 | SnapOrScroll, | 1206 | SnapOrScroll, |
| 1198 | { | 1207 | { |
| 1199 | ...rest, | 1208 | ...rest, |
| @@ -1212,7 +1221,16 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 12 unmodified lines | |||
| 1212 | contentContainerStyle: [ | 1221 | contentContainerStyle: [ |
| 1213 | horizontal ? { height: "100%" } : {}, | 1222 | horizontal ? { height: "100%" } : {}, |
| 1214 | contentContainerStyle, | 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 | dataVersion, | 1234 | dataVersion, |
| 1217 | drawDistance = 250, | 1235 | drawDistance = 250, |
| 1218 | contentInsetEndAdjustment, | 1236 | contentInsetEndAdjustment, |
| @@ -1220,7 +1238,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | |||
| 1220 | estimatedItemSize = 100, | 1238 | estimatedItemSize = 100, |
| 1221 | estimatedListSize, | 1239 | estimatedListSize, |
| 1222 | extraData, | 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 | const animatedPropsInternal = props.animatedPropsInternal; | 1242 | const animatedPropsInternal = props.animatedPropsInternal; |
| 1225 | const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; | 1243 | const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; |
| 1226 | const positionComponentInternal = props.positionComponentInternal; | 1244 | const positionComponentInternal = props.positionComponentInternal; |
| @@ -1233,7 +1251,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines | |||
| 1233 | stickyPositionComponentInternal: _stickyPositionComponentInternal, | 1251 | stickyPositionComponentInternal: _stickyPositionComponentInternal, |
| 1234 | ...restProps | 1252 | ...restProps |
| 1235 | } = rest; | 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 | const combinedRef = useCombinedRef(refScroller, refScrollView); | 1255 | const combinedRef = useCombinedRef(refScroller, refScrollView); |
| 1238 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | 1256 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); |
| 1239 | const stickyHeaderIndices = stickyHeaderIndicesProp; | 1257 | const stickyHeaderIndices = stickyHeaderIndicesProp; |
| @@ -1242,7 +1260,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines | |||
| 1242 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); | 1260 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); |
| 1243 | const alwaysRenderIndices = useMemo(() => { | 1261 | const alwaysRenderIndices = useMemo(() => { |
| 1244 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | 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 | contentContainerAlignItems: contentContainerStyle.alignItems, | 1264 | contentContainerAlignItems: contentContainerStyle.alignItems, |
| 1247 | contentInset, | 1265 | contentInset, |
| 1248 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | 1266 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, |
| @@ -1250,7 +1268,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | |||
| 1250 | data: dataProp, | 1268 | data: dataProp, |
| 1251 | dataKey, | 1269 | dataKey, |
| 1252 | dataVersion, | 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 | renderItem, | 1272 | renderItem, |
| 1255 | rtl, | 1273 | rtl, |
| 1256 | snapToIndices, | 1274 | snapToIndices, |
| @@ -1258,7 +1276,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | |||
| 1258 | stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], | 1276 | stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], |
| 1259 | stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), | 1277 | stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), |
| 1260 | stickyPositionComponentInternal, | 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 | return void 0; | 1280 | return void 0; |
| 1263 | } | 1281 | } |
| 1264 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | 1282 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); |
| @@ -1272,7 +1290,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 7 unmodified lines | |||
| 1272 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | 1290 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; |
| 1273 | }, [usesBootstrapInitialScroll]); | 1291 | }, [usesBootstrapInitialScroll]); |
| 1274 | useLayoutEffect(() => { | 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 | dataKey, | 1295 | dataKey, |
| 1278 | dataVersion, | 1296 | dataVersion, |
| @@ -1280,7 +1298,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | |||
| 1280 | memoizedLastItemKeys.join(","), | 1298 | memoizedLastItemKeys.join(","), |
| 1281 | numColumnsProp, | 1299 | numColumnsProp, |
| 1282 | nextScrollAxisGap, | 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 | getRenderedItem: (key) => getRenderedItem(ctx, key), | 1303 | getRenderedItem: (key) => getRenderedItem(ctx, key), |
| 1286 | onMomentumScrollEnd: (event) => { | 1304 | onMomentumScrollEnd: (event) => { |
| @@ -1288,7 +1306,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line | |||
| 1288 | checkFinishedScrollFallback(ctx); | 1306 | checkFinishedScrollFallback(ctx); |
| 1289 | if (state.props.onMomentumScrollEnd) { | 1307 | if (state.props.onMomentumScrollEnd) { |
| 1290 | state.props.onMomentumScrollEnd(event); | 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 | onScroll: (event) => onScroll(ctx, event), | 1310 | onScroll: (event) => onScroll(ctx, event), |
| 1293 | onScrollBeginDrag: (event) => { | 1311 | onScrollBeginDrag: (event) => { |
| 1294 | var _a4, _b2; | 1312 | var _a4, _b2; |
| @@ -1297,7 +1315,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines | |||
| 1297 | prepareReachedEdgeForNextUserScroll(ctx); | 1315 | prepareReachedEdgeForNextUserScroll(ctx); |
| 1298 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); | 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 | ListFooterComponent, | 1319 | ListFooterComponent, |
| 1302 | ListFooterComponentStyle, | 1320 | ListFooterComponentStyle, |
| 1303 | ListHeaderComponent, | 1321 | ListHeaderComponent, |
| @@ -1408,7 +1426,7 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613 · 104 unmodified lines | |||
| 1408 | * Number of columns to render items in. | 1426 | * Number of columns to render items in. |
| 1409 | * @default 1 | 1427 | * @default 1 |
| 1410 | diff --git a/reanimated.js b/reanimated.js | 1428 | diff --git a/reanimated.js b/reanimated.js |
| 1411 | index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71ff8ce9f4d 100644 | 1429 | index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644 |
| 1412 | --- a/reanimated.js | 1430 | --- a/reanimated.js |
| 1413 | +++ b/reanimated.js | 1431 | +++ b/reanimated.js |
| 1414 | @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | 1432 | @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| @@ -1454,7 +1472,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 39 unmodified lines | |||
| 1454 | ref: refView, | 1472 | ref: refView, |
| 1455 | style: viewStyle, | 1473 | style: viewStyle, |
| 1456 | ...rest | 1474 | ...rest |
| 1457 | @@ -141,6 +162,101 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | 1475 | @@ -141,6 +162,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| 1458 | children | 1476 | children |
| 1459 | ); | 1477 | ); |
| 1460 | }); | 1478 | }); |
| @@ -1468,12 +1486,12 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 7 unmodified lines | |||
| 1468 | + const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0); | 1486 | + const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0); |
| 1469 | + const animationRunRef = React__namespace.useRef(0); | 1487 | + const animationRunRef = React__namespace.useRef(0); |
| 1470 | + const isAnimatingRef = React__namespace.useRef(false); | 1488 | + const isAnimatingRef = React__namespace.useRef(false); |
| 1471 | + const [isAnimating, setIsAnimating] = React__namespace.useState(false); | ||
| 1472 | + const transition = props.layoutTransition; | 1489 | + const transition = props.layoutTransition; |
| 1473 | + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; | 1490 | + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; |
| 1474 | + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; | 1491 | + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; |
| 1475 | + const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible; | 1492 | + // Keep the animated size attached after transitions finish. Reanimated |
| 1476 | + const useAnimatedAxis = isAnimating || startsEligibleAnimation; | 1493 | + // retains removed animated props, so switching to a static size leaves |
| 1494 | + // native scroll bounds stuck while logical measurements keep changing. | ||
| 1477 | + const animatedStyle = Reanimated.useAnimatedStyle(() => { | 1495 | + const animatedStyle = Reanimated.useAnimatedStyle(() => { |
| 1478 | + const size = Math.max(0, baseSize.value + animatedDelta.value); | 1496 | + const size = Math.max(0, baseSize.value + animatedDelta.value); |
| 1479 | + return horizontal ? { width: size } : { height: size }; | 1497 | + return horizontal ? { width: size } : { height: size }; |
| @@ -1483,7 +1501,6 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 3 unmodified lines | |||
| 1483 | + return; | 1501 | + return; |
| 1484 | + } | 1502 | + } |
| 1485 | + isAnimatingRef.current = false; | 1503 | + isAnimatingRef.current = false; |
| 1486 | + setIsAnimating(false); | ||
| 1487 | + const state = ctx.state; | 1504 | + const state = ctx.state; |
| 1488 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { | 1505 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { |
| 1489 | + return; | 1506 | + return; |
| @@ -1500,13 +1517,15 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 10 unmodified lines | |||
| 1500 | + const previousLogicalSize = previousLogicalSizeRef.current; | 1517 | + const previousLogicalSize = previousLogicalSizeRef.current; |
| 1501 | + const delta = logicalSize - previousLogicalSize; | 1518 | + const delta = logicalSize - previousLogicalSize; |
| 1502 | + const isNewEpoch = epoch !== previousEpochRef.current; | 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 | + previousLogicalSizeRef.current = logicalSize; | 1523 | + previousLogicalSizeRef.current = logicalSize; |
| 1505 | + previousEpochRef.current = epoch; | 1524 | + previousEpochRef.current = epoch; |
| 1506 | + if (!delta) { | 1525 | + if (!delta) { |
| 1507 | + return; | 1526 | + return; |
| 1508 | + } | 1527 | + } |
| 1509 | + if (!isNewEligibleEpoch) { | 1528 | + if (!shouldAnimate) { |
| 1510 | + baseSize.value = logicalSize; | 1529 | + baseSize.value = logicalSize; |
| 1511 | + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { | 1530 | + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { |
| 1512 | + state.contentSizeAnimationEligible = false; | 1531 | + state.contentSizeAnimationEligible = false; |
| @@ -1530,7 +1549,6 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 17 unmodified lines | |||
| 1530 | + state.contentSizeAnimationActiveSignals.add(signalName); | 1549 | + state.contentSizeAnimationActiveSignals.add(signalName); |
| 1531 | + const wasAnimating = isAnimatingRef.current; | 1550 | + const wasAnimating = isAnimatingRef.current; |
| 1532 | + isAnimatingRef.current = true; | 1551 | + isAnimatingRef.current = true; |
| 1533 | + setIsAnimating(true); | ||
| 1534 | + const run = ++animationRunRef.current; | 1552 | + const run = ++animationRunRef.current; |
| 1535 | + Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { | 1553 | + Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { |
| 1536 | + "worklet"; | 1554 | + "worklet"; |
| @@ -1545,18 +1563,12 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 8 unmodified lines | |||
| 1545 | + })); | 1563 | + })); |
| 1546 | + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); | 1564 | + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); |
| 1547 | + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); | 1565 | + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); |
| 1548 | + // Keep the content container under one animated size owner because detaching an | 1566 | + return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, animatedStyle] }, children); |
| 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); | ||
| 1555 | +}); | 1567 | +}); |
| 1556 | function setSharedValueValue(sharedValue, value) { | 1568 | function setSharedValueValue(sharedValue, value) { |
| 1557 | if (!sharedValue) { | 1569 | if (!sharedValue) { |
| 1558 | return; | 1570 | return; |
| 1559 | @@ -245,10 +361,19 @@ var LegendListForwardedRef = typedMemo( | 1571 | @@ -245,10 +355,19 @@ var LegendListForwardedRef = typedMemo( |
| 1560 | ); | 1572 | ); |
| 1561 | }; | 1573 | }; |
| 1562 | }, [hasItemLayoutAnimation, recycleItems]); | 1574 | }, [hasItemLayoutAnimation, recycleItems]); |
| @@ -1577,7 +1589,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 14 unmodified lines | |||
| 1577 | renderScrollComponent: renderReanimatedScrollComponent, | 1589 | renderScrollComponent: renderReanimatedScrollComponent, |
| 1578 | ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} | 1590 | ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} |
| 1579 | diff --git a/reanimated.mjs b/reanimated.mjs | 1591 | diff --git a/reanimated.mjs b/reanimated.mjs |
| 1580 | index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a5725398942 100644 | 1592 | index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644 |
| 1581 | --- a/reanimated.mjs | 1593 | --- a/reanimated.mjs |
| 1582 | +++ b/reanimated.mjs | 1594 | +++ b/reanimated.mjs |
| 1583 | @@ -1,7 +1,7 @@ | 1595 | @@ -1,7 +1,7 @@ |
| @@ -1632,7 +1644,7 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 48 unmodified lines | |||
| 1632 | ref: refView, | 1644 | ref: refView, |
| 1633 | style: viewStyle, | 1645 | style: viewStyle, |
| 1634 | ...rest | 1646 | ...rest |
| 1635 | @@ -117,6 +138,101 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | 1647 | @@ -117,6 +138,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| 1636 | children | 1648 | children |
| 1637 | ); | 1649 | ); |
| 1638 | }); | 1650 | }); |
| @@ -1646,12 +1658,12 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 7 unmodified lines | |||
| 1646 | + const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0); | 1658 | + const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0); |
| 1647 | + const animationRunRef = React.useRef(0); | 1659 | + const animationRunRef = React.useRef(0); |
| 1648 | + const isAnimatingRef = React.useRef(false); | 1660 | + const isAnimatingRef = React.useRef(false); |
| 1649 | + const [isAnimating, setIsAnimating] = React.useState(false); | ||
| 1650 | + const transition = props.layoutTransition; | 1661 | + const transition = props.layoutTransition; |
| 1651 | + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; | 1662 | + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; |
| 1652 | + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; | 1663 | + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; |
| 1653 | + const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible; | 1664 | + // Keep the animated size attached after transitions finish. Reanimated |
| 1654 | + const useAnimatedAxis = isAnimating || startsEligibleAnimation; | 1665 | + // retains removed animated props, so switching to a static size leaves |
| 1666 | + // native scroll bounds stuck while logical measurements keep changing. | ||
| 1655 | + const animatedStyle = useAnimatedStyle(() => { | 1667 | + const animatedStyle = useAnimatedStyle(() => { |
| 1656 | + const size = Math.max(0, baseSize.value + animatedDelta.value); | 1668 | + const size = Math.max(0, baseSize.value + animatedDelta.value); |
| 1657 | + return horizontal ? { width: size } : { height: size }; | 1669 | + return horizontal ? { width: size } : { height: size }; |
| @@ -1661,7 +1673,6 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 3 unmodified lines | |||
| 1661 | + return; | 1673 | + return; |
| 1662 | + } | 1674 | + } |
| 1663 | + isAnimatingRef.current = false; | 1675 | + isAnimatingRef.current = false; |
| 1664 | + setIsAnimating(false); | ||
| 1665 | + const state = ctx.state; | 1676 | + const state = ctx.state; |
| 1666 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { | 1677 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { |
| 1667 | + return; | 1678 | + return; |
| @@ -1678,13 +1689,15 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 10 unmodified lines | |||
| 1678 | + const previousLogicalSize = previousLogicalSizeRef.current; | 1689 | + const previousLogicalSize = previousLogicalSizeRef.current; |
| 1679 | + const delta = logicalSize - previousLogicalSize; | 1690 | + const delta = logicalSize - previousLogicalSize; |
| 1680 | + const isNewEpoch = epoch !== previousEpochRef.current; | 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 | + previousLogicalSizeRef.current = logicalSize; | 1695 | + previousLogicalSizeRef.current = logicalSize; |
| 1683 | + previousEpochRef.current = epoch; | 1696 | + previousEpochRef.current = epoch; |
| 1684 | + if (!delta) { | 1697 | + if (!delta) { |
| 1685 | + return; | 1698 | + return; |
| 1686 | + } | 1699 | + } |
| 1687 | + if (!isNewEligibleEpoch) { | 1700 | + if (!shouldAnimate) { |
| 1688 | + baseSize.value = logicalSize; | 1701 | + baseSize.value = logicalSize; |
| 1689 | + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { | 1702 | + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { |
| 1690 | + state.contentSizeAnimationEligible = false; | 1703 | + state.contentSizeAnimationEligible = false; |
| @@ -1708,7 +1721,6 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 17 unmodified lines | |||
| 1708 | + state.contentSizeAnimationActiveSignals.add(signalName); | 1721 | + state.contentSizeAnimationActiveSignals.add(signalName); |
| 1709 | + const wasAnimating = isAnimatingRef.current; | 1722 | + const wasAnimating = isAnimatingRef.current; |
| 1710 | + isAnimatingRef.current = true; | 1723 | + isAnimatingRef.current = true; |
| 1711 | + setIsAnimating(true); | ||
| 1712 | + const run = ++animationRunRef.current; | 1724 | + const run = ++animationRunRef.current; |
| 1713 | + runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { | 1725 | + runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { |
| 1714 | + "worklet"; | 1726 | + "worklet"; |
| @@ -1723,18 +1735,12 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 8 unmodified lines | |||
| 1723 | + })); | 1735 | + })); |
| 1724 | + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); | 1736 | + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); |
| 1725 | + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); | 1737 | + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); |
| 1726 | + // Keep the content container under one animated size owner because detaching an | 1738 | + return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, animatedStyle] }, children); |
| 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); | ||
| 1733 | +}); | 1739 | +}); |
| 1734 | function setSharedValueValue(sharedValue, value) { | 1740 | function setSharedValueValue(sharedValue, value) { |
| 1735 | if (!sharedValue) { | 1741 | if (!sharedValue) { |
| 1736 | return; | 1742 | return; |
| 1737 | @@ -221,10 +337,19 @@ var LegendListForwardedRef = typedMemo( | 1743 | @@ -221,10 +331,19 @@ var LegendListForwardedRef = typedMemo( |
| 1738 | ); | 1744 | ); |
| 1739 | }; | 1745 | }; |
| 1740 | }, [hasItemLayoutAnimation, recycleItems]); | 1746 | }, [hasItemLayoutAnimation, recycleItems]); |
| @@ -472,7 +472,7 @@ importers: · 471 unmodified lines | |||
| 472 | dependencies: | 472 | dependencies: |
| 473 | '@anthropic-ai/claude-agent-sdk': | 473 | '@anthropic-ai/claude-agent-sdk': |
| 474 | specifier: ^0.3.205 | 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 | '@connectrpc/connect': | 476 | '@connectrpc/connect': |
| 477 | specifier: 1.7.0 | 477 | specifier: 1.7.0 |
| 478 | version: 1.7.0(@bufbuild/protobuf@1.10.0) | 478 | version: 1.7.0(@bufbuild/protobuf@1.10.0) |
| @@ -1012,8 +1012,8 @@ packages: · 533 unmodified lines | |||
| 1012 | '@alchemy.run/node-utils@0.0.5': | 1012 | '@alchemy.run/node-utils@0.0.5': |
| 1013 | resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} | 1013 | resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} |
| 1014 | 1014 | ||
| 1015 | '@anthropic-ai/claude-agent-sdk@0.3.251': | 1015 | '@anthropic-ai/claude-agent-sdk@0.3.252': |
| 1016 | resolution: {integrity: sha512-DqSi8mH2tQYRlVV0G+lJnQ/WbjJZ/a+8cJ3vPuYoqh8esIIvXHm1ZOXV1UPGsFYRnbBytEoiSGitguEXd+sQ+Q==} | 1016 | resolution: {integrity: sha512-hCkyZFn3J46aAMNqS6AZbYz91FaLUmX5VvJOzYZqzlVBJN47OxXQugqOzqa6b6GOZRmwiqW2ck8J8TE7bQZswQ==} |
| 1017 | engines: {node: '>=18.0.0'} | 1017 | engines: {node: '>=18.0.0'} |
| 1018 | peerDependencies: | 1018 | peerDependencies: |
| 1019 | '@anthropic-ai/sdk': '>=0.93.0' | 1019 | '@anthropic-ai/sdk': '>=0.93.0' |
| @@ -10540,7 +10540,7 @@ snapshots: · 9520 unmodified lines | |||
| 10540 | 10540 | ||
| 10541 | '@alchemy.run/node-utils@0.0.5': {} | 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 | dependencies: | 10544 | dependencies: |
| 10545 | '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) | 10545 | '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) |
| 10546 | '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) | 10546 | '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) |