History

fix: reconcile main's round-18 features after the rebase

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.

Julius Marminge committed 2 months ago

Commit 7b13bca1 parent 786a25847 files · +1855 −2616Browse files

Unified · Split

47 files
  1. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +1 −1
  2. apps/mobile/src/features/threads/ThreadFeed.tsx +196 −89
  3. apps/mobile/src/features/threads/thread-list-v2-items.tsx +26 −3
  4. apps/mobile/src/features/threads/threadListV2.test.ts +3 −1
  5. apps/mobile/src/features/threads/threadListV2.ts +70 −8
  6. apps/server/src/claudeModelOptions.ts +14 −11
  7. apps/server/src/http.test.ts +177 −3
  8. apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +7 −2
  9. apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +30 −0
  10. apps/server/src/orchestration/Layers/OrchestrationEngine.ts +0 −33
  11. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +10 −23
  12. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +70 −258
  13. apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +0 −4
  14. apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts +0 −250
  15. apps/server/src/orchestration/ThreadLiveEventCoalescer.ts +0 −241
  16. apps/server/src/orchestration/ThreadSettlementReactor.test.ts +0 −858
  17. apps/server/src/orchestration/ThreadSettlementReactor.ts +0 −249
  18. apps/server/src/orchestration/decider.ts +1 −0
  19. apps/server/src/persistence/Layers/OrchestrationEventStore.ts +11 −10
  20. apps/server/src/persistence/Services/OrchestrationEventStore.ts +3 −2
  21. apps/server/src/provider/Drivers/ClaudeDriver.ts +24 −19
  22. apps/server/src/provider/acp/AcpNativeLogging.ts +44 −6
  23. apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +64 −57
  24. apps/web/src/components/ChatView.logic.test.ts +2 −2
  25. apps/web/src/components/ChatView.logic.ts +6 −0
  26. apps/web/src/components/ChatView.tsx +151 −91
  27. apps/web/src/components/Sidebar.tsx +30 −30
  28. apps/web/src/components/chat/ChatComposer.tsx +1 −0
  29. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +10 −9
  30. apps/web/src/components/chat/MessagesTimeline.logic.ts +16 −207
  31. apps/web/src/components/chat/MessagesTimeline.tsx +7 −3
  32. apps/web/src/components/chat/externalLinkContextMenu.test.ts +1 −1
  33. apps/web/src/components/settings/SettingsPanels.tsx +2 −25
  34. apps/web/src/components/settings/settingsSearch.test.ts +21 −0
  35. apps/web/src/components/settings/settingsSearch.ts +8 −2
  36. apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +13 −1
  37. apps/web/src/composerDraftStore.ts +119 −0
  38. apps/web/src/hooks/useHandleNewThread.ts +1 −1
  39. packages/client-runtime/src/state/threadSettled.test.ts +587 −0
  40. packages/client-runtime/src/state/threadSettled.ts +0 −1
  41. packages/client-runtime/src/state/threads-sync.test.ts +6 −3
  42. packages/client-runtime/src/work-log/presentation.test.ts +1 −1
  43. packages/client-runtime/src/work-log/presentation.ts +2 −2
  44. packages/contracts/src/ipc.ts +8 −5
  45. packages/shared/src/model.test.ts +4 −2
  46. patches/@legendapp__list@3.3.5.patch +104 −98
  47. pnpm-lock.yaml +4 −4
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+1−1
@@ -681,7 +681,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 680 unmodified lines
681 onTouchCancel={handleFeedTouchCancel}681 onTouchCancel={handleFeedTouchCancel}
682 >682 >
683 <ThreadFeed683 <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}
apps/mobile/src/features/threads/ThreadFeed.tsx+196−89
@@ -1,6 +1,6 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
4import { scopeThreadRef } from "@t3tools/client-runtime/environment";4import { scopeThreadRef } from "@t3tools/client-runtime/environment";
5import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";5import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
6import {6import {
@@ -79,7 +79,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 72 unmodified lines
79import Animated, {79import 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";
152import {151import {
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";
157import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";157import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
158import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";158import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
159import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets";159import {
160 assetEnvironment,
161 useAssetUrl,
162 useAssetUrlState,
163 useRefreshAssetUrl,
164} from "../../state/assets";
160import { useAtomQueryRunner } from "../../state/use-atom-query-runner";165import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
161import { usePreparedConnection } from "../../state/session";166import { usePreparedConnection } from "../../state/session";
162import * as Option from "effect/Option";167import * as Option from "effect/Option";
@@ -191,9 +196,10 @@ function formatMessageTime(input: string): string { · 28 unmodified lines
191}196}
192 197
193const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);198const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
194const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);199// Let neighboring rows move out of the new rows' space before showing their text.
195const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);200const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
196const 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 — LegendList204// Entering animations must only play for rows born just now — LegendList
199// remounts rows when they scroll back into view, and replaying an entrance for205// 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 <Pressable622 <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 <View635 }
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 <ThreadMarkdownImageRequest641 ...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. */
679function ThreadMarkdownImage(props: {703function 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
729const ThreadMediaVisibleContext = createContext(false);
730// LegendList only computes hook visibility when the list has a viewability config.
731const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };
732
733function 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
741function 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
703function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {776function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
704 return (777 return (
705 <ThreadMarkdownImageView778 <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 <NativeText1366 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 ...(preserveSoftBreaks1373 ...(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 <Text1577 <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 its2161 // A "user scroll session" spans from drag start through the end of its
2095 // momentum; only motion inside a session can break follow, so MVCP2162 // 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 <ThreadMarkdownImageView2341 <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 <ThreadMarkdownImage2355 <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 <ThreadMarkdownImage2387 <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.View2822 <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.type2974 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});
apps/mobile/src/features/threads/thread-list-v2-items.tsx+26−3
@@ -23,10 +23,13 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; · 22 unmodified lines
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
24import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";24import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
25import {25import {
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";
32import { ThreadSearchMatchExcerpt } from "./thread-search-match";35import { 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 a463 // 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.snoozeSupported468 const snoozeGateExpiryMs = props.snoozeSupported
apps/mobile/src/features/threads/threadListV2.test.ts+3−1
@@ -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"]);
apps/mobile/src/features/threads/threadListV2.ts+70−8
@@ -1,11 +1,15 @@
1import {1import {
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";
8import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";9import type {
10 ChangeRequestSettleSource,
11 SnoozePreset,
12} from "@t3tools/client-runtime/state/thread-settled";
9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";14import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
11import {15import {
@@ -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";
16import type { EnvironmentId, ProjectId } from "@t3tools/contracts";20import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
17 21
18import type { PendingNewTask } from "../../state/use-pending-new-tasks";22import type { PendingNewTask } from "../../state/use-pending-new-tasks";
19 23
@@ -32,6 +36,35 @@ export { snoozeWakeLabel }; · 12 unmodified lines
32export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready";36export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready";
33export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";37export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
34 38
39export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource {
40 readonly linkedPullRequestKey?: string | null;
41}
42
43function 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. */
55export 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
35export function resolveThreadListV2SnoozeMenuSelection(input: {68export 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) and364 * 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 */
334export function buildThreadListV2Items(input: {368export 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 on379 /** Environments whose server supports thread.settle/unsettle. Threads on
344 other environments never classify as settled — the user could neither380 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. Same383 /** 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.projectRefs410 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);
apps/server/src/claudeModelOptions.ts+14−11
@@ -7,12 +7,14 @@ import { · 6 unmodified lines
7} from "@t3tools/shared/model";7} from "@t3tools/shared/model";
8 8
9import {9import {
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
17export interface CompiledClaudeModelSelection {19export 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. */
26export function compileClaudeModelSelection(28export 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,
apps/server/src/http.test.ts+177−3
@@ -1,12 +1,13 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { describe, vi } from "vite-plus/test";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
5import * as Path from "effect/Path";8import * as Path from "effect/Path";
6import { HttpRouter, HttpServerResponse } from "effect/unstable/http";9import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
7import { describe } from "vite-plus/test";10import { openMediaFile } from "./assets/MediaFile.ts";
8import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
9import * as NodeServices from "@effect/platform-node/NodeServices";
10 11
11import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";12import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";
12 13
@@ -70,6 +71,179 @@ describe("browser API CORS", () => { · 57 unmodified lines
70const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);71const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);
71 72
72describe("video asset byte ranges", () => {73describe("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;
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+7−2
@@ -77,7 +77,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; · 76 unmodified lines
77import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";77import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";
78import { ServerConfig } from "../../config.ts";78import { ServerConfig } from "../../config.ts";
79import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";79import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";
80import { resolveClaudeContextWindow } from "../../provider/Layers/ClaudeProvider.ts";80import {
81 BUNDLED_CLAUDE_MODEL_CATALOG,
82 resolveClaudeCatalogContextWindow,
83} from "../../provider/ClaudeModelCatalog.ts";
81import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";84import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
82import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";85import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";
83import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";86import { 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
140export function claudeProviderTurnTokenUsage(145export function claudeProviderTurnTokenUsage(
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+30−0
@@ -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) =>
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+0−33
@@ -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) =>
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+10−23
@@ -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 !== undefined1038 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 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+70−258
@@ -77,9 +77,6 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); · 76 unmodified lines
77// activity window. Applying the limit in SQL avoids decoding an unbounded77// 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.
79const THREAD_DETAIL_ACTIVITY_LIMIT = 500;79const 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.
82const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25;
83const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(80const 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);
107const ProjectionThreadActivityIdRowSchema = Schema.Struct({
108 activityId: ProjectionThreadActivity.fields.activityId,
109});
110const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;104const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
111const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({105const 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});
169const ThreadActivityIdsLookupInput = Schema.Struct({
170 activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId),
171});
172// Windowed reads order turns by the stable keyset (anchor, turn key), where163// Windowed reads order turns by the stable keyset (anchor, turn key), where
173// anchor is requested_at and turn key is164// anchor is requested_at and turn key is
174// COALESCE(turn_id, ''). Both are event-derived, so cursors survive the165// 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
375function 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
390function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {366function 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
1479pending_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_id1425 SELECT request_id, thread_id
1481 FROM projection_pending_approvals1426 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 = 11485 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 SELECT1488 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 === undefined2729 : 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 === undefined2750 )
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 of2911 // Bounds pathological fan-out: one user turn that spawned hundreds of
3096 // subagent turns still pages in bounded chunks, at the cost of splitting the2912 // 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 }
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+0−4
@@ -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,
apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts+0−250
@@ -1,250 +0,0 @@
1import {
2 EventId,
3 MessageId,
4 ThreadId,
5 TurnId,
6 OrchestrationEvent,
7 type OrchestrationThreadActivity,
8} from "@t3tools/contracts";
9import { it } from "@effect/vitest";
10import * as Clock from "effect/Clock";
11import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";
14import * as TestClock from "effect/testing/TestClock";
15import { describe, expect } from "vite-plus/test";
16
17import {
18 coalesceLiveToolUpdatedEvents,
19 makeThreadLiveEventCoalescer,
20} from "./ThreadLiveEventCoalescer.ts";
21
22const threadId = ThreadId.make("thread-coalescer-test");
23const turnId = TurnId.make("turn-coalescer-test");
24const encodeEvent = Schema.encodeSync(Schema.fromJsonString(OrchestrationEvent));
25
26function 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
67function 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
92describe("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});
apps/server/src/orchestration/ThreadLiveEventCoalescer.ts+0−241
@@ -1,241 +0,0 @@
1import type {
2 OrchestrationEvent,
3 OrchestrationGetSnapshotError,
4 OrchestrationThreadStreamItem,
5} from "@t3tools/contracts";
6import * as Deferred from "effect/Deferred";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as Predicate from "effect/Predicate";
11import * as Queue from "effect/Queue";
12import * as Semaphore from "effect/Semaphore";
13import * as Stream from "effect/Stream";
14
15import { projectActivityEvent } from "./ActivityPayloadProjection.ts";
16import { makeLiveStreamBudget, type RetainedLiveItem } from "./LiveStreamBudget.ts";
17
18const COALESCE_WINDOW = Duration.millis(50);
19const MAX_PENDING_UPDATES = 512;
20
21export type ThreadLiveInput =
22 | { readonly kind: "event"; readonly event: OrchestrationEvent }
23 | { readonly kind: "synchronized" };
24
25function isToolUpdated(event: OrchestrationEvent): boolean {
26 return (
27 event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated"
28 );
29}
30
31function 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
39function 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 */
56export 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
96export 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);
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+0−858
@@ -1,858 +0,0 @@
1import {
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";
15import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
16import { assert, describe, it } from "@effect/vitest";
17import * as Crypto from "effect/Crypto";
18import * as Deferred from "effect/Deferred";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as PubSub from "effect/PubSub";
23import * as Queue from "effect/Queue";
24import * as Ref from "effect/Ref";
25import * as Stream from "effect/Stream";
26import { TestClock } from "effect/testing";
27
28import { GitManager } from "../git/GitManager.ts";
29import {
30 PullRequestService,
31 type PullRequestMergeEvent,
32} from "../pullRequest/PullRequestService.ts";
33import { ServerActivation } from "../serverActivation.ts";
34import { ServerSettingsService } from "../serverSettings.ts";
35import { OrchestrationCommandInvariantError } from "./Errors.ts";
36import {
37 OrchestrationEngineService,
38 type OrchestrationEngineShape,
39} from "./Services/OrchestrationEngine.ts";
40import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
41import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";
42
43const NOW = "2026-08-28T12:00:00.000Z";
44const PROJECT_ID = ProjectId.make("settlement-project");
45const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");
46
47type AutoSettleCommand = Extract<OrchestrationCommand, { readonly type: "thread.auto-settle" }>;
48
49const testCrypto = Crypto.make({
50 randomBytes: (size) => new Uint8Array(size).fill(1),
51 digest: (_algorithm, data) => Effect.succeed(data),
52});
53
54function 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
69function 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
100function 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
112function 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
133interface 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
144const 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
270const 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
281describe("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});
apps/server/src/orchestration/ThreadSettlementReactor.ts+0−249
@@ -1,249 +0,0 @@
1import { CommandId } from "@t3tools/contracts";
2import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Schedule from "effect/Schedule";
11import type * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
13
14import * as GitManager from "../git/GitManager.ts";
15import * as PullRequestService from "../pullRequest/PullRequestService.ts";
16import * as ServerSettings from "../serverSettings.ts";
17import { forkParked } from "../serverActivation.ts";
18import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
19import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
20import {
21 isAutoSettlementCandidate,
22 resolveAutoSettlementAt,
23 type SettlementPullRequest,
24} from "./ThreadSettlementPolicy.ts";
25
26export 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
34export 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
249export const layer = Layer.effect(ThreadSettlementReactor, make);
apps/server/src/orchestration/decider.ts+1−0
@@ -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";
7import * as DateTime from "effect/DateTime";8import * as DateTime from "effect/DateTime";
8import * as Crypto from "effect/Crypto";9import * as Crypto from "effect/Crypto";
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+11−10
@@ -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 sequence652 FROM orchestration_events
653 FROM orchestration_events653 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 1658 ])}
659 `,659 LIMIT 1
660 `,
660 });661 });
661 662
662 const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) =>663 const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) =>
apps/server/src/persistence/Services/OrchestrationEventStore.ts+3−2
@@ -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 being129 * 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}
apps/server/src/provider/Drivers/ClaudeDriver.ts+24−19
@@ -40,6 +40,7 @@ import { · 39 unmodified lines
40} from "../Layers/ClaudeProvider.ts";40} from "../Layers/ClaudeProvider.ts";
41import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";41import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
42import * as ModelManifest from "../ModelManifest.ts";42import * as ModelManifest from "../ModelManifest.ts";
43import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
43import {44import {
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 so168 // 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 classify180 // Start the TTL-gated refresh without delaying provider readiness. The
175 // with the in-memory manifest, so a slow or hung fetch never delays the181 // 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 }) =>
apps/server/src/provider/acp/AcpNativeLogging.ts+44−6
@@ -1,5 +1,5 @@
1import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts";1import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
2import { causeErrorTag } from "@t3tools/shared/observability";2import { causeErrorTag, errorTag } from "@t3tools/shared/observability";
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import * as Crypto from "effect/Crypto";4import * as Crypto from "effect/Crypto";
5import * as DateTime from "effect/DateTime";5import * as DateTime from "effect/DateTime";
@@ -13,6 +13,40 @@ import { · 7 unmodified lines
13} from "../NativeProtocolLogging.ts";13} from "../NativeProtocolLogging.ts";
14import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";14import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
15 15
16const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
17
18function structuralMethod(value: string): string {
19 return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown";
20}
21
22function 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
16function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) {50function 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 : {}),
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+64−57
@@ -5,12 +5,11 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; · 4 unmodified lines
5const testState = vi.hoisted(() => ({5const 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
12vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));10vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
13vi.mock("../assets/assetUrls", () => ({11vi.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}));
28vi.mock("../editorPreferences", () => ({27vi.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}));
35vi.mock("../remoteOpen", () => ({31vi.mock("../remoteOpen", () => ({
36 useRemoteOpenResolution: (environmentId: unknown) => {32 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
37 testState.remoteEnvironmentIds.push(environmentId);33}));
38 return {34vi.mock("../editorPreferences", () => ({
39 isResolved: true,35 useOpenInPreferredEditor: () => vi.fn(),
40 state: { mode: "local-exec", localExecutionUnavailableReason: null },36 usePreferredEditor: () => [null, vi.fn()],
41 };37}));
42 },38vi.mock("~/lib/openPullRequestLink", () => ({
39 findProjectForChangeRequest: () => undefined,
40 matchesLinkedPullRequestUrl: () => false,
41 parseChangeRequestUrl: () => null,
42 useOpenChangeRequestLink: () => vi.fn(),
43}));43}));
44vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
45 44
46import ChatMarkdown from "./ChatMarkdown";45import ChatMarkdown from "./ChatMarkdown";
47import { FileMarkdownPreview } from "./files/FileMarkdownPreview";46import { 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("![relative](.t3/workspace-image.svg)");118 const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg";
119 const html = render(
120 [
121 "![relative](.t3/workspace-image.svg)",
122 `![absolute](${imagePath})`,
123 `![file URL](file:///${imagePath})`,
124 "![UNC file URL](file://server/share/workspace-image.svg)",
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 "![absolute](C:/Users/shawn/project/.t3/workspace-image.svg)",
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("![loading](.t3/workspace-image.svg)");265 const html = render("![loading](.t3/workspace-image.svg)");
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("![remote](https://example.com/image.png)");274 const html = renderWithoutThread(
275 "![file URL](file:///C:/Users/shawn/project/workspace-image.svg)",
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("![unsupported](content://media/image/1)");
281 285
282 renderToStaticMarkup(286 expect(testState.resources).toEqual([]);
283 <ChatMarkdown287 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("![remote](https://example.com/image.png)");
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});
apps/web/src/components/ChatView.logic.test.ts+2−2
@@ -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,
apps/web/src/components/ChatView.logic.ts+6−0
@@ -17,6 +17,12 @@ import { · 16 unmodified lines
17 type RunId,17 type RunId,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import * as DateTime from "effect/DateTime";19import * as DateTime from "effect/DateTime";
20import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
21import {
22 squashAtomCommandFailure,
23 type AtomCommandResult,
24} from "@t3tools/client-runtime/state/runtime";
25import { videoMimeType } from "@t3tools/shared/video";
20import {26import {
21 appendCodexArtifactTemplateUsePrompt,27 appendCodexArtifactTemplateUsePrompt,
22 codexArtifactTemplateUsePrompt,28 codexArtifactTemplateUsePrompt,
apps/web/src/components/ChatView.tsx+151−91
@@ -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";
41import { useThreadActions } from "../hooks/useThreadActions";41import { useThreadActions } from "../hooks/useThreadActions";
42import {
43 resolveDisplayedThreadPr,
44 threadChangeRequestSnapshotsAtom,
45 useLinkedThreadPullRequest,
46} from "./ThreadStatusIndicators";
47import {42import {
48 deriveThreadActivityRun,43 deriveThreadActivityRun,
49 deriveLatestThreadRun,44 deriveLatestThreadRun,
@@ -74,7 +69,12 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; · 24 unmodified lines
74import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";69import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
75import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";70import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
76import { truncate } from "@t3tools/shared/String";71import { truncate } from "@t3tools/shared/String";
77import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";72import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
73import {
74 getTerminalLabel,
75 nextTerminalId,
76 resolveTerminalSessionLabel,
77} from "@t3tools/shared/terminalLabels";
78import { Debouncer } from "@tanstack/react-pacer";78import { Debouncer } from "@tanstack/react-pacer";
79import { useAtomValue } from "@effect/atom-react";79import { useAtomValue } from "@effect/atom-react";
80import {80import {
@@ -325,8 +325,14 @@ import { · 244 unmodified lines
325 ThreadErrorBanner,325 ThreadErrorBanner,
326} from "./chat/ThreadErrorBanner";326} from "./chat/ThreadErrorBanner";
327import { resolveThreadPr } from "./ThreadStatusIndicators";327import { resolveThreadPr } from "./ThreadStatusIndicators";
328import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
329import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl";328import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl";
329import {
330 resolveDisplayedThreadPr,
331 threadPullRequestRefreshSource,
332 threadChangeRequestSnapshotsAtom,
333 useLinkedThreadPullRequest,
334} from "./ThreadStatusIndicators";
335import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
330import { ComposerSurface } from "./chat/ComposerSurface";336import { ComposerSurface } from "./chat/ComposerSurface";
331import { resolveThreadSyncPhase } from "../threadSync";337import { resolveThreadSyncPhase } from "../threadSync";
332import {338import {
@@ -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 render2783 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 the4967 // 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 same5015 // 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 effect5024 // 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 <DraftHeroHeadline7573 <DraftHeroHeadline
7574 draftId={draftId}
7515 activeProjectRef={activeProjectRef}7575 activeProjectRef={activeProjectRef}
7516 activeProjectTitle={activeProject?.title ?? null}7576 activeProjectTitle={activeProject?.title ?? null}
7517 />7577 />
apps/web/src/components/Sidebar.tsx+30−30
@@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- · 18 unmodified lines
19import { CSS } from "@dnd-kit/utilities";19import { CSS } from "@dnd-kit/utilities";
20import {20import {
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
99import { useHandleNewThread } from "../hooks/useHandleNewThread";100import { useHandleNewThread } from "../hooks/useHandleNewThread";
100import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";101import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
101import { startNewThreadFromContext } from "../lib/chatThreadActions";102import { startNewThreadFromContext } from "../lib/chatThreadActions";
102import { useClientSettings } from "../hooks/useSettings";103import { useClientSettings, usePrimarySettings } from "../hooks/useSettings";
103import { useCopyToClipboard } from "../hooks/useCopyToClipboard";104import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
104import { useLocalStorage } from "../hooks/useLocalStorage";105import { useLocalStorage } from "../hooks/useLocalStorage";
105import { useNowMinute } from "../hooks/useNowMinute";106import { useNowMinute } from "../hooks/useNowMinute";
@@ -171,7 +172,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 65 unmodified lines
171import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";172import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
172import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";173import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
173import {174import {
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 for857 // 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, and858 // 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-snapshot2120 // 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 the2138 // 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 ) ?? null3781 ) ?? 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 ) ?? null3891 ) ?? 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}
apps/web/src/components/chat/ChatComposer.tsx+1−0
@@ -172,6 +172,7 @@ import { · 171 unmodified lines
172 submitComposerDraft,172 submitComposerDraft,
173} from "./composerSubmission";173} from "./composerSubmission";
174import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";174import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
175import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
175 176
176function ComposerVideoThumbnail({ file }: { file: File }) {177function ComposerVideoThumbnail({ file }: { file: File }) {
177 const setVideo = useCallback(178 const setVideo = useCallback(
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+10−9
@@ -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, and1621 // 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);
apps/web/src/components/chat/MessagesTimeline.logic.ts+16−207
@@ -1,9 +1,6 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
3import {3import {
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
340export function normalizeCompactToolLabel(value: string): string {
341 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
342}
343
344type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";
345export type ToolGroupSummaryKind =
346 | ToolGroupAction
347 | "dynamic-tool"
348 | "agent-tool"
349 | "tone-tool"
350 | "mixed";
351
352export 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
360export 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
378function 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
396function 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
415function 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
437function 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. */
459export 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
515function 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
534function workGroupId(timelineEntryId: string): string {338function 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 && displayedToolEntry724 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 }
apps/web/src/components/chat/MessagesTimeline.tsx+7−3
@@ -64,6 +64,7 @@ import { · 63 unmodified lines
64import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";64import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
65import {65import {
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
2779type WorkEntryIconName =2780type 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") {
apps/web/src/components/chat/externalLinkContextMenu.test.ts+1−1
@@ -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 "",
apps/web/src/components/settings/SettingsPanels.tsx+2−25
@@ -2254,31 +2254,8 @@ export function GeneralSettingsPanel() { · 2253 unmodified lines
2254 />2254 />
2255 }2255 }
2256 />2256 />
2257 ) : null2257 ) : 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
apps/web/src/components/settings/settingsSearch.test.ts+21−0
@@ -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);
apps/web/src/components/settings/settingsSearch.ts+8−2
@@ -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
36export interface SettingsSearchAvailability {37export 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
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+13−1
@@ -1,4 +1,5 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2import { useAtomValue } from "@effect/atom-react";
2import { AuthAccessWriteScope } from "@t3tools/contracts";3import { AuthAccessWriteScope } from "@t3tools/contracts";
3 4
4import { hasCloudPublicConfig } from "~/cloud/publicConfig";5import { hasCloudPublicConfig } from "~/cloud/publicConfig";
@@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; · 2 unmodified lines
7import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";8import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
8import { useEnvironmentQuery } from "~/state/query";9import { useEnvironmentQuery } from "~/state/query";
9import { usePrimarySessionState } from "~/environments/primary";10import { usePrimarySessionState } from "~/environments/primary";
11import { primaryServerConfigAtom } from "~/state/server";
10import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";12import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
11import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";13import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
12import { filterAvailableSettingsSearchItems } from "./settingsSearch";14import { 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}
apps/web/src/composerDraftStore.ts+119−0
@@ -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";
49import {50import {
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
665export interface EffectiveComposerModelState {673export 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 {
apps/web/src/hooks/useHandleNewThread.ts+1−1
@@ -23,7 +23,7 @@ import { · 22 unmodified lines
23 selectProjectGroupingSettings,23 selectProjectGroupingSettings,
24} from "../logicalProject";24} from "../logicalProject";
25import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";25import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
26import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";26import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities";
27import {27import {
28 hasExplicitComposerModelSelection,28 hasExplicitComposerModelSelection,
29 resolveNewDraftStartFromOrigin,29 resolveNewDraftStartFromOrigin,
packages/client-runtime/src/state/threadSettled.test.ts+587−0
@@ -0,0 +1,587 @@
1import {
2 ProjectId,
3 ProviderInstanceId,
4 ThreadId,
5 TurnId,
6 type OrchestrationThreadShell,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 canSettle,
12 changeRequestAutoSettles,
13 effectiveSettled,
14 hasQueuedTurnStart,
15 threadLastActivityAt,
16 type ChangeRequestStateLike,
17} from "./threadSettled.ts";
18
19const NOW = "2026-04-10T00:00:00.000Z";
20const FRESH = "2026-04-09T00:00:00.000Z";
21const STALE = "2026-04-06T23:59:59.999Z";
22
23describe("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
125function 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
176describe("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
197describe("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
434describe("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
505describe("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});
packages/client-runtime/src/state/threadSettled.ts+0−1
@@ -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 */
140export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;140export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
141const 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 was143 * A user message no turn has picked up yet: the turn.start command was
packages/client-runtime/src/state/threads-sync.test.ts+6−3
@@ -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,
packages/client-runtime/src/work-log/presentation.test.ts+1−1
@@ -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 },
packages/client-runtime/src/work-log/presentation.ts+2−2
@@ -135,7 +135,7 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin · 134 unmodified lines
135}135}
136 136
137export interface ViewedImageAsset {137export 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: attachmentId166 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 };
packages/contracts/src/ipc.ts+8−5
@@ -88,7 +88,7 @@ import { EnvironmentId } from "./baseSchemas.ts"; · 87 unmodified lines
88import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";88import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
89import { AdvertisedEndpoint } from "./remoteAccess.ts";89import { AdvertisedEndpoint } from "./remoteAccess.ts";
90import { ExecutionEnvironmentDescriptor } from "./environment.ts";90import { ExecutionEnvironmentDescriptor } from "./environment.ts";
91import type { ClientSettings } from "./settings.ts";91import type { ClientSettings, QuitConfirmationMode } from "./settings.ts";
92import type { EditorId } from "./editor.ts";92import type { EditorId } from "./editor.ts";
93import type {93import 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";
101import type {
102 DesktopAppActivationRequest,
103 DesktopAppActivationResponse,
104} from "./desktopAppActivation.ts";
101 105
102export interface ContextMenuItem<T extends string = string> {106export 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: older1111 * 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>;
packages/shared/src/model.test.ts+4−2
@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";2import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
3 3
4import {4import {
5 applyClaudePromptEffortPrefix,5 applyClaudePromptEffortPrefix,
@@ -40,9 +40,11 @@ const codexCaps: ModelCapabilities = createModelCapabilities({ · 34 unmodified lines
40 40
41describe("model slug normalization", () => {41describe("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});
patches/@legendapp__list@3.3.5.patch+104−98
@@ -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 1242 * @default 1
243diff --git a/react-native.js b/react-native.js243diff --git a/react-native.js b/react-native.js
244index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c3ffe83af 100644244index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df752a843de0 100644
245--- a/react-native.js245--- a/react-native.js
246+++ b/react-native.js246+++ 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.ts304 // 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 : offset338 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: 0383 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.ts447 // 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 horizontal601 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: animSize610 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.tsx634 // 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, forwarded677@@ -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, forwarded694@@ -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 ...restProps705 ...restProps
697 } = rest;706 } = rest;
698@@ -7200,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded707@@ -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, forwarded716@@ -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, forwarded724@@ -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, forwarded732@@ -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, forwarded746@@ -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, forwarded754@@ -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, forwarded762@@ -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, forwarded771@@ -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, {
781diff --git a/react-native.mjs b/react-native.mjs790diff --git a/react-native.mjs b/react-native.mjs
782index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318d57e29dc 100644791index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff7e3ad022 100644
783--- a/react-native.mjs792--- a/react-native.mjs
784+++ b/react-native.mjs793+++ 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.ts851 // 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 : offset885 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: 0930 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.ts994 // 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 horizontal1148 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: animSize1157 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.tsx1181 // 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, forwarded1224@@ -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, forwarded1241@@ -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 ...restProps1252 ...restProps
1235 } = rest;1253 } = rest;
1236@@ -7179,7 +7372,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded1254@@ -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, forwarded1263@@ -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, forwarded1271@@ -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, forwarded1279@@ -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, forwarded1293@@ -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, forwarded1301@@ -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, forwarded1309@@ -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, forwarded1318@@ -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 11427 * @default 1
1410diff --git a/reanimated.js b/reanimated.js1428diff --git a/reanimated.js b/reanimated.js
1411index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71ff8ce9f4d 1006441429index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644
1412--- a/reanimated.js1430--- a/reanimated.js
1413+++ b/reanimated.js1431+++ 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 ...rest1474 ...rest
1457@@ -141,6 +162,101 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(1475@@ -141,6 +162,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1458 children1476 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 an1566+ 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 } : {}
1579diff --git a/reanimated.mjs b/reanimated.mjs1591diff --git a/reanimated.mjs b/reanimated.mjs
1580index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a5725398942 1006441592index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644
1581--- a/reanimated.mjs1593--- a/reanimated.mjs
1582+++ b/reanimated.mjs1594+++ 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 ...rest1646 ...rest
1635@@ -117,6 +138,101 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(1647@@ -117,6 +138,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1636 children1648 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 an1738+ 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]);
pnpm-lock.yaml+4−4
@@ -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.205474 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.0477 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)