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
681681 onTouchCancel={handleFeedTouchCancel}
682682 >
683683 <ThreadFeed
684− key={props.selectedThread.id}
684+ key={selectedThreadKey}
685685 environmentId={props.environmentId}
686686 threadId={props.selectedThread.id}
687687 workspaceRoot={props.threadCwd}
apps/mobile/src/features/threads/ThreadFeed.tsx+196−89
@@ -1,6 +1,6 @@
11 import * as Haptics from "expo-haptics";
22 import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3−import { type LegendListRef } from "@legendapp/list/react-native";
3+import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
44 import { scopeThreadRef } from "@t3tools/client-runtime/environment";
55 import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
66 import {
@@ -79,7 +79,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 72 unmodified lines
7979 import Animated, {
8080 FadeIn,
8181 FadeInUp,
82− FadeOut,
8382 LinearTransition,
8483 type SharedValue,
8584 } from "react-native-reanimated";
@@ -150,13 +149,19 @@ import { · 64 unmodified lines
150149 type ThreadFeedLiveFollowEvent,
151150 } from "./thread-feed-live-follow";
152151 import {
152+ ThreadDisclosureChevron,
153153 THREAD_DISCLOSURE_TRANSITION_MS,
154154 ThreadWorkGroupToggle,
155155 ThreadWorkLog,
156156 } from "./thread-work-log";
157157 import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
158158 import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
159−import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets";
159+import {
160+ assetEnvironment,
161+ useAssetUrl,
162+ useAssetUrlState,
163+ useRefreshAssetUrl,
164+} from "../../state/assets";
160165 import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
161166 import { usePreparedConnection } from "../../state/session";
162167 import * as Option from "effect/Option";
@@ -191,9 +196,10 @@ function formatMessageTime(input: string): string { · 28 unmodified lines
191196 }
192197
193198 const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
194−const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
195−const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
196−const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
199+// Let neighboring rows move out of the new rows' space before showing their text.
200+const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
201+ THREAD_DISCLOSURE_TRANSITION_MS,
202+).duration(140);
197203
198204 // Entering animations must only play for rows born just now — LegendList
199205 // remounts rows when they scroll back into view, and replaying an entrance for
@@ -340,6 +346,7 @@ function MessageAttachmentFile(props: { · 140 unmodified lines
340346 }) {
341347 const sourceIdentifier = useId();
342348 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
349+ refresh: true,
343350 reportFailure: false,
344351 });
345352 const preparedConnection = usePreparedConnection(props.environmentId);
@@ -557,9 +564,11 @@ function ThreadMarkdownImageView(props: { · 211 unmodified lines
557564 readonly sourceKey: string;
558565 readonly unavailable: boolean;
559566 readonly alt: string | null;
567+ readonly actionsSource?: MediaActionsSource;
560568 readonly onPressPreview: (source: FilePreviewSource) => void;
561569 }) {
562570 const sourceIdentifier = useId();
571+ const mediaActions = useMediaActions(props.actionsSource);
563572 const [availableWidth, setAvailableWidth] = useState(0);
564573 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
565574 const [failedUri, setFailedUri] = useState<string | null>(null);
@@ -602,36 +611,51 @@ function ThreadMarkdownImageView(props: { · 36 unmodified lines
602611 ) : (
603612 <ActivityIndicator />
604613 )}
614+ {props.actionsSource ? (
615+ <View className="absolute right-1 top-1">
616+ <MediaActionsMenu media={mediaActions} />
617+ </View>
618+ ) : null}
605619 </View>
606620 ) : (
607621 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
608− <Pressable
609− accessibilityRole="imagebutton"
610− accessibilityLabel={props.alt ?? "Markdown image"}
611− onPress={() =>
612− props.onPressPreview({
613− kind: "image",
614− uri: props.uri!,
615− name: props.alt ?? "Image",
616− sourceIdentifier,
617− })
618− }
619− style={{ alignSelf: "flex-start" }}
620− >
621− <View
622− className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
623− style={{
624− ...frameStyle,
625− }}
626− >
627− <ThreadMarkdownImageRequest
628− key={props.uri}
629− uri={props.uri}
630− onLoad={setSourceSize}
631− onError={() => setFailedUri(props.uri)}
632− />
633− </View>
634− </Pressable>
622+ <View>
623+ <MediaActionsMenu media={mediaActions}>
624+ <Pressable
625+ accessibilityRole="imagebutton"
626+ accessibilityLabel={props.alt ?? "Markdown image"}
627+ onPress={() =>
628+ props.onPressPreview({
629+ kind: "image",
630+ uri: props.uri!,
631+ name: props.alt ?? "Image",
632+ sourceIdentifier,
633+ actionsSource: props.actionsSource,
634+ })
635+ }
636+ style={{ alignSelf: "flex-start" }}
637+ >
638+ <View
639+ className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
640+ style={{
641+ ...frameStyle,
642+ }}
643+ >
644+ <ThreadMarkdownImageRequest
645+ key={props.uri}
646+ uri={props.uri}
647+ onLoad={setSourceSize}
648+ onError={() => setFailedUri(props.uri)}
649+ />
650+ </View>
651+ </Pressable>
652+ </MediaActionsMenu>
653+ {props.actionsSource ? (
654+ <View className="absolute right-1 top-1">
655+ <MediaActionsMenu media={mediaActions} />
656+ </View>
657+ ) : null}
658+ </View>
635659 </PresentationSource>
636660 )}
637661 {props.alt ? (
@@ -678,9 +702,10 @@ function ThreadMarkdownImageRequest(props: { · 40 unmodified lines
678702 /** Environment-hosted image that loads through a signed asset URL. */
679703 function ThreadMarkdownImage(props: {
680704 readonly environmentId: EnvironmentId;
681− readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
705+ readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
682706 readonly alt: string | null;
683707 readonly srcFragment?: string;
708+ readonly actionsSource?: MediaActionsSource;
684709 readonly onPressPreview: (source: FilePreviewSource) => void;
685710 }) {
686711 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
@@ -695,11 +720,59 @@ function ThreadMarkdownImage(props: { · 8 unmodified lines
695720 }
696721 unavailable={assetUrl._tag === "Failure"}
697722 alt={props.alt}
723+ actionsSource={props.actionsSource}
698724 onPressPreview={props.onPressPreview}
699725 />
700726 );
701727 }
702728
729+const ThreadMediaVisibleContext = createContext(false);
730+// LegendList only computes hook visibility when the list has a viewability config.
731+const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };
732+
733+function ThreadMediaVisibility(props: { readonly children: ReactNode }) {
734+ const [visible, setVisible] = useState(false);
735+ useViewabilityAmount<ThreadFeedEntry>(
736+ useCallback((token) => setVisible(token.sizeVisible > 0), []),
737+ );
738+ return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>;
739+}
740+
741+function ThreadMarkdownVideo(props: {
742+ readonly source: MediaVideoPreviewSource;
743+ readonly onExpand: (source: MediaVideoPreviewSource) => void;
744+}) {
745+ const { source } = props;
746+ const visible = useContext(ThreadMediaVisibleContext);
747+ const thumbnailKey = mediaVideoThumbnailKey(source);
748+ const asset = useAssetUrlState(
749+ "environmentId" in source ? source.environmentId : null,
750+ "resource" in source ? source.resource : null,
751+ );
752+ const refreshAssetUrl = useRefreshAssetUrl(
753+ "environmentId" in source ? source.environmentId : null,
754+ "resource" in source ? source.resource : null,
755+ );
756+ const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
757+ return (
758+ <MediaVideoPlayer
759+ key={thumbnailKey}
760+ uri={uri}
761+ resolvePlaybackUri={
762+ "resource" in source
763+ ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl())
764+ : undefined
765+ }
766+ name={source.name}
767+ thumbnailKey={thumbnailKey}
768+ thumbnailVisible={visible}
769+ unavailable={"resource" in source && asset._tag === "Failure"}
770+ actionsSource={source.actionsSource}
771+ onExpand={() => props.onExpand(source)}
772+ />
773+ );
774+}
775+
703776 function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
704777 return (
705778 <ThreadMarkdownImageView
@@ -1287,21 +1360,16 @@ function useMarkdownStyles( · 581 unmodified lines
12871360 title: node.title ?? null,
12881361 }) ?? undefined)
12891362 : undefined,
1290− code_inline: ({ content }) => {
1291− const value = content ?? "";
1292− return (
1293− <NativeText
1294− className="font-mono"
1295− style={{
1296− color: inlineCodeTextColor,
1297− fontSize: markdownFontSizes.codeBlockFontSize,
1298− lineHeight: markdownFontSizes.bodyLineHeight,
1299− }}
1300− >
1301− {value}
1302− </NativeText>
1303− );
1304− },
1363+ code_inline: ({ content }) => (
1364+ <MarkdownInlineCode
1365+ content={content ?? ""}
1366+ textColor={inlineTextColor}
1367+ codeColor={inlineCodeTextColor}
1368+ fontSize={markdownFontSizes.codeBlockFontSize}
1369+ lineHeight={markdownFontSizes.bodyLineHeight}
1370+ onLinkPress={onLinkPress}
1371+ />
1372+ ),
13051373 ...(preserveSoftBreaks
13061374 ? {
13071375 soft_break: () => <NativeText>{"\n"}</NativeText>,
@@ -1504,7 +1572,7 @@ function renderFeedEntry( · 196 unmodified lines
15041572 accessibilityState={{ expanded: entry.expanded }}
15051573 onPress={() => props.onToggleTurnFold(entry.runId)}
15061574 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"
15081576 >
15091577 <Text
15101578 key={props.workRowSizing.textSizeKey}
@@ -1516,8 +1584,7 @@ function renderFeedEntry( · 5 unmodified lines
15161584 expanded={entry.expanded}
15171585 collapsedDirection="right"
15181586 size={15}
1519− tintColorClassName={"accent-icon-subtle"}
1520− type="monochrome"
1587+ tintColor={iconSubtleColor}
15211588 />
15221589 </Pressable>
15231590 );
@@ -2092,7 +2159,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 568 unmodified lines
20922159 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
20932160 const endFollowEnabledRef = useRef(true);
20942161 // A "user scroll session" spans from drag start through the end of its
2095− // momentum; only motion inside a session can break follow, so MVCP
2162+ // momentum; scroll events only break follow inside that session, so MVCP
20962163 // compensations and programmatic scrolls never strand a follower.
20972164 const userScrollSessionRef = useRef(false);
20982165 const setEndFollow = useCallback(
@@ -2253,14 +2320,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 154 unmodified lines
22532320 );
22542321 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
22552322 (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+ }
22562338 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);
22572339 if (imageSource._tag === "Direct") {
22582340 return (
22592341 <ThreadMarkdownImageView
2260− uri={imageSource.uri}
2342+ uri={normalizeNativeMarkdownUrl(imageSource.uri)}
22612343 sourceKey={imageSource.uri}
22622344 unavailable={false}
22632345 alt={image.alt}
2346+ actionsSource={media?.source.actionsSource}
22642347 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
22652348 />
22662349 );
@@ -2272,12 +2355,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 5 unmodified lines
22722355 <ThreadMarkdownImage
22732356 environmentId={props.environmentId}
22742357 resource={{
2275− _tag: "workspace-file",
2358+ _tag: "media-file",
22762359 threadId: props.threadId,
22772360 path: imageSource.path,
22782361 }}
22792362 alt={image.alt}
22802363 srcFragment={markdownImageSourceFragment(image.href)}
2364+ actionsSource={media?.source.actionsSource}
22812365 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
22822366 />
22832367 );
@@ -2290,12 +2374,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 6 unmodified lines
22902374 threadId: props.threadId,
22912375 workspaceRoot: props.workspaceRoot,
22922376 });
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;
22932386 return viewedImage ? (
22942387 <ThreadMarkdownImage
22952388 environmentId={props.environmentId}
22962389 resource={viewedImage.resource}
22972390 alt={viewedImage.alt}
22982391 srcFragment={viewedImage.srcFragment}
2392+ actionsSource={
2393+ actionsSource && "resource" in actionsSource
2394+ ? { ...actionsSource, resource: viewedImage.resource }
2395+ : undefined
2396+ }
22992397 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
23002398 />
23012399 ) : null;
@@ -2572,13 +2670,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 270 unmodified lines
25722670 }
25732671 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
25742672 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+ }
25752683 disclosureAnchorKeyRef.current = null;
25762684 setDisclosureToggleSettling(false);
25772685 disclosureSettleFrameRef.current = null;
25782686 disclosureSettleSecondFrameRef.current = null;
25792687 });
25802688 });
2581− }, []);
2689+ }, [props.listRef, transitionEndFollow]);
25822690
25832691 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
25842692 disclosureAnchorKeyRef.current = anchorKey;
@@ -2713,44 +2821,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 128 unmodified lines
27132821 (info: { item: ThreadFeedEntry; index: number }) => (
27142822 <Animated.View
27152823 key={info.item.id}
2716− entering={
2717− disclosureEnteringEntryIds.has(info.item.id)
2718− ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
2719− : undefined
2720− }
2721− exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
2824+ entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
27222825 >
2723− {renderFeedEntry(info, {
2724− environmentId: props.environmentId,
2725− threadId: props.threadId,
2726− copiedRowId,
2727− expandedWorkRows,
2728− terminalAssistantMessageIds,
2729− unsettledTurnId,
2730− onCopyWorkRow,
2731− onToggleWorkGroup,
2732− onToggleWorkRow,
2733− onToggleTurnFold,
2734− onPressPreview,
2735− onPressVideo,
2736− onMarkdownLinkPress,
2737− renderMarkdownImage,
2738− renderViewedImage,
2739− iconSubtleColor,
2740− userBubbleColor,
2741− markdownStyles,
2742− reviewCommentColors,
2743− reviewCommentBubbleWidth,
2744− userBubbleMaxWidth,
2745− threadTitle: props.threadTitle,
2746− skills: props.skills,
2747− workspaceRoot: props.workspaceRoot,
2748− })}
2826+ <ThreadMediaVisibility>
2827+ {renderFeedEntry(info, {
2828+ environmentId: props.environmentId,
2829+ threadId: props.threadId,
2830+ copiedRowId,
2831+ expandedWorkRows,
2832+ terminalAssistantMessageIds,
2833+ unsettledTurnId,
2834+ onCopyWorkRow,
2835+ onToggleWorkGroup,
2836+ onToggleWorkRow,
2837+ onToggleTurnFold,
2838+ onPressPreview,
2839+ onPressVideo,
2840+ onMarkdownLinkPress,
2841+ renderMarkdownImage,
2842+ renderViewedImage,
2843+ iconSubtleColor,
2844+ userBubbleColor,
2845+ markdownStyles,
2846+ reviewCommentColors,
2847+ reviewCommentBubbleWidth,
2848+ userBubbleMaxWidth,
2849+ threadTitle: props.threadTitle,
2850+ skills: props.skills,
2851+ workspaceRoot: props.workspaceRoot,
2852+ })}
2853+ </ThreadMediaVisibility>
27492854 </Animated.View>
27502855 ),
27512856 [
27522857 copiedRowId,
2753− disclosureEnteringEntryIds,
2858+ disclosureToggleSettling,
27542859 expandedWorkRows,
27552860 workRowSizing,
27562861 workGroupScrollPositions,
@@ -2863,6 +2968,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 106 unmodified lines
28632968 data={presentedFeed}
28642969 extraData={listAppearanceData}
28652970 renderItem={renderItem}
2971+ viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG}
28662972 keyExtractor={(entry) => entry.id}
28672973 getItemType={(entry) =>
28682974 entry.type === "message" ? `message:${entry.message.role}` : entry.type
@@ -2938,4 +3044,5 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 69 unmodified lines
29383044 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
29393045 </>
29403046 );
3047+ const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
29413048 });
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
2323 import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2424 import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
2525 import {
26− resolveThreadListV2SnoozeMenuSelection,
26+ resolveThreadListV2ChangeRequestState,
2727 resolveThreadListV2SnoozeGateExpiryMs,
28+ resolveThreadListV2SnoozeMenuSelection,
2829 resolveThreadListV2Status,
2930 resolveThreadListV2SwipeActions,
31+ threadHasUnseenCompletion,
32+ type ThreadListV2ChangeRequestState,
3033 type ThreadListV2Status,
3134 } from "./threadListV2";
3235 import { ThreadSearchMatchExcerpt } from "./thread-search-match";
@@ -368,6 +371,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 335 unmodified lines
368371 readonly canMovePinnedDown?: boolean;
369372 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
370373 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;
371380 readonly projectCwd?: string | null;
372381 readonly searchMatch?: EnvironmentThreadSearchMatch;
373382 readonly searchQuery?: string;
@@ -390,11 +399,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 16 unmodified lines
390399 onPinThread,
391400 onUnpinThread,
392401 onMovePinnedThread,
402+ onChangeRequestState,
393403 } = props;
394404 const snoozedRow = props.snoozed === true;
395405 const pinnedRow = props.pinned === true;
396406
397407 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]);
398420
399421 const theme = useUniwindTheme();
400422 const screenColor = theme["--color-screen"];
@@ -438,8 +460,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 37 unmodified lines
438460 );
439461 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
440462
441− // Swipe: the v2 primary action is the lifecycle transition. Un-settling a
442− // settled row keeps it active until new activity clears the user override.
463+ // Swipe: the v2 primary action is the lifecycle transition. Every settled
464+ // row can un-settle — explicit settles clear the override, auto-settled
465+ // rows get pinned active until real activity clears the pin.
443466 const canUnsettle = variant === "slim";
444467 const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);
445468 const snoozeGateExpiryMs = props.snoozeSupported
apps/mobile/src/features/threads/threadListV2.test.ts+3−1
@@ -578,7 +578,9 @@ describe("buildThreadListV2Items", () => { · 577 unmodified lines
578578 ],
579579 environmentId: null,
580580 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",
582584 });
583585
584586 expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]);
apps/mobile/src/features/threads/threadListV2.ts+70−8
@@ -1,11 +1,15 @@
11 import {
2+ effectiveSettled,
23 effectiveSnoozed,
34 hasQueuedTurnStart,
45 QUEUED_TURN_START_GRACE_MS,
56 resolveSnoozePresets,
67 snoozeWakeLabel,
78 } from "@t3tools/client-runtime/state/thread-settled";
8−import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
9+import type {
10+ ChangeRequestSettleSource,
11+ SnoozePreset,
12+} from "@t3tools/client-runtime/state/thread-settled";
913 import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1014 import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
1115 import {
@@ -13,7 +17,7 @@ import { · 1 unmodified line
1317 resolveSettledThreadTimestamp,
1418 sortPinnedThreadsByOrderKey,
1519 } from "@t3tools/client-runtime/state/thread-sort";
16−import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
20+import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
1721
1822 import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1923
@@ -32,6 +36,35 @@ export { snoozeWakeLabel }; · 12 unmodified lines
3236 export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready";
3337 export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
3438
39+export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource {
40+ readonly linkedPullRequestKey?: string | null;
41+}
42+
43+function linkedPullRequestKey(
44+ linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
45+): string | null {
46+ if (linkedPullRequest == null) return null;
47+ return JSON.stringify([
48+ linkedPullRequest.projectId,
49+ linkedPullRequest.repository.toLowerCase(),
50+ linkedPullRequest.number,
51+ ]);
52+}
53+
54+/** Keep the previous linked PR state while its detail query reloads. */
55+export function resolveThreadListV2ChangeRequestState(input: {
56+ readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined;
57+ readonly state: ChangeRequestSettleSource["state"] | null;
58+ readonly updatedAt: string | null;
59+}): ThreadListV2ChangeRequestState | null | undefined {
60+ if (input.state === null) return input.linkedPullRequest == null ? null : undefined;
61+ return {
62+ state: input.state,
63+ updatedAt: input.updatedAt,
64+ linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest),
65+ };
66+}
67+
3568 export function resolveThreadListV2SnoozeMenuSelection(input: {
3669 readonly event: string;
3770 readonly displayedPresets: ReadonlyArray<SnoozePreset>;
@@ -329,7 +362,8 @@ export function buildThreadListV2ListItems(input: { · 291 unmodified lines
329362
330363 /**
331364 * 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.
333367 */
334368 export function buildThreadListV2Items(input: {
335369 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
@@ -340,6 +374,8 @@ export function buildThreadListV2Items(input: { · 4 unmodified lines
340374 }> | null;
341375 readonly searchQuery: string;
342376 readonly matchedThreadKeys?: ReadonlySet<string>;
377+ /** Per-row PR reported up by visible rows ("env:threadId" keys). */
378+ readonly changeRequestByKey?: ReadonlyMap<string, ThreadListV2ChangeRequestState>;
343379 /** Environments whose server supports thread.settle/unsettle. Threads on
344380 other environments never classify as settled — the user could neither
345381 un-settle nor pin them. Absent = no gating (tests). */
@@ -347,10 +383,17 @@ export function buildThreadListV2Items(input: { · 1 unmodified line
347383 /** Environments whose server supports thread.snooze/unsnooze. Same
348384 contract as settlementEnvironmentIds. */
349385 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
386+ readonly autoSettleAfterDays?: number;
387+ readonly autoSettleOnMerge?: boolean;
350388 /** Max settled rows to render; the rest are counted, not built. */
351389 readonly settledLimit?: number;
352− /** Second-precise clock used for time-based classification. */
353− readonly now: string;
390+ /** Injectable for tests; defaults to now. */
391+ readonly now?: string;
392+ /** Second-precise clock for snooze classification. Callers pass a
393+ minute-quantized `now` for memoization; snooze wake times are
394+ second-precise, so classifying with the floored minute would hold a
395+ woken thread hidden for up to a minute. Defaults to `now`. */
396+ readonly snoozeNow?: string;
354397 /** Expands the snoozed shelf into rows. Collapsed is the default. */
355398 readonly snoozedShelfExpanded?: boolean;
356399 /** Expands the settled shelf into rows. Expanded is the default. */
@@ -359,7 +402,10 @@ export function buildThreadListV2Items(input: { · 2 unmodified lines
359402 a split-view detail can never lose its navigation row. */
360403 readonly selectedThreadKey?: string | null;
361404 }): 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;
363409 const query = input.searchQuery.trim().toLocaleLowerCase();
364410 const projectKeys = input.projectRefs
365411 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
@@ -392,8 +438,16 @@ export function buildThreadListV2Items(input: { · 26 unmodified lines
392438 }
393439 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
394440 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;
395449 // Snooze outranks settlement and pinning until the thread wakes.
396− if (supportsSnooze && effectiveSnoozed(thread, { now })) {
450+ if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
397451 snoozed.push(thread);
398452 if (
399453 thread.snoozedUntil != null &&
@@ -404,7 +458,15 @@ export function buildThreadListV2Items(input: { · 4 unmodified lines
404458 }
405459 continue;
406460 }
407− if (supportsSettlement && thread.settledOverride === "settled") {
461+ if (
462+ supportsSettlement &&
463+ effectiveSettled(thread, {
464+ now,
465+ autoSettleAfterDays,
466+ autoSettleOnMerge,
467+ changeRequest,
468+ })
469+ ) {
408470 settled.push(thread);
409471 } else if (thread.pinnedAt != null) {
410472 pinned.push(thread);
apps/server/src/claudeModelOptions.ts+14−11
@@ -7,12 +7,14 @@ import { · 6 unmodified lines
77 } from "@t3tools/shared/model";
88
99 import {
10− getClaudeModelCapabilities,
11− isClaudeUltracodeEffort,
12− normalizeClaudeCliEffort,
13− resolveClaudeApiModelId,
14− resolveClaudeEffort,
15−} from "./provider/Layers/ClaudeProvider.ts";
10+ BUNDLED_CLAUDE_MODEL_CATALOG,
11+ getClaudeCatalogModelCapabilities,
12+ isClaudeCatalogUltracodeEffort,
13+ normalizeClaudeCatalogEffort,
14+ resolveClaudeCatalogApiModelId,
15+ resolveClaudeCatalogEffort,
16+ type ClaudeModelCatalog,
17+} from "./provider/ClaudeModelCatalog.ts";
1618
1719 export interface CompiledClaudeModelSelection {
1820 readonly apiModelId: string;
@@ -25,14 +27,15 @@ export interface CompiledClaudeModelSelection { · 6 unmodified lines
2527 /** Compile every Claude model option at the provider boundary. */
2628 export function compileClaudeModelSelection(
2729 selection: ModelSelection,
30+ catalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
2831 ): CompiledClaudeModelSelection {
29− const capabilities = getClaudeModelCapabilities(selection.model);
32+ const capabilities = getClaudeCatalogModelCapabilities(catalog, selection.model);
3033 const descriptors = getProviderOptionDescriptors({ caps: capabilities });
3134 const supportsBoolean = (id: string) =>
3235 descriptors.some((descriptor) => descriptor.type === "boolean" && descriptor.id === id);
3336 const rawEffort = getModelSelectionStringOptionValue(selection, "effort");
34− const resolvedEffort = resolveClaudeEffort(capabilities, rawEffort);
35− const effort = normalizeClaudeCliEffort(resolvedEffort, selection.model);
37+ const resolvedEffort = resolveClaudeCatalogEffort(catalog, selection.model, rawEffort);
38+ const effort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, selection.model);
3639 const fastMode = supportsBoolean("fastMode")
3740 ? getModelSelectionBooleanOptionValue(selection, "fastMode")
3841 : undefined;
@@ -42,9 +45,9 @@ export function compileClaudeModelSelection( · 3 unmodified lines
4245 const settings = {
4346 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
4447 ...(typeof fastMode === "boolean" ? { fastMode } : {}),
45− ...(isClaudeUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}),
48+ ...(isClaudeCatalogUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}),
4649 };
47− const apiModelId = resolveClaudeApiModelId(selection);
50+ const apiModelId = resolveClaudeCatalogApiModelId(catalog, selection);
4851 const promptEffort = resolvePromptInjectedEffort(capabilities, rawEffort) ?? undefined;
4952 return {
5053 apiModelId,
apps/server/src/http.test.ts+177−3
@@ -1,12 +1,13 @@
11 import { expect, it } from "@effect/vitest";
2+import { describe, vi } from "vite-plus/test";
3+import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4+import * as NodeServices from "@effect/platform-node/NodeServices";
25 import * as Effect from "effect/Effect";
36 import * as FileSystem from "effect/FileSystem";
47 import * as Layer from "effect/Layer";
58 import * as Path from "effect/Path";
69 import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
7−import { describe } from "vite-plus/test";
8−import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
9−import * as NodeServices from "@effect/platform-node/NodeServices";
10+import { openMediaFile } from "./assets/MediaFile.ts";
1011
1112 import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";
1213
@@ -70,6 +71,179 @@ describe("browser API CORS", () => { · 57 unmodified lines
7071 const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);
7172
7273 describe("video asset byte ranges", () => {
74+ it.effect("uses current descriptor metadata after an in-place truncate or extension", () =>
75+ Effect.gen(function* () {
76+ const fs = yield* FileSystem.FileSystem;
77+ const path = yield* Path.Path;
78+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" });
79+ const filePath = path.join(directory, "clip.mp4");
80+ for (const [contents, range, method, expected, status, contentRange] of [
81+ ["1234", undefined, "GET", "1234", 200, null],
82+ ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null],
83+ ["1234", "bytes=4-", "GET", "", 416, "bytes */4"],
84+ ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"],
85+ ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"],
86+ ["0123456789abcdef", undefined, "HEAD", "", 200, null],
87+ ["", undefined, "GET", "", 200, null],
88+ ["", "bytes=0-1", "GET", "", 416, "bytes */0"],
89+ ] as const) {
90+ yield* fs.writeFileString(filePath, "0123456789");
91+ const canonicalPath = yield* fs.realPath(filePath);
92+ const file = yield* openMediaFile(canonicalPath);
93+ if (!file) throw new Error("Expected an opened media file");
94+ yield* fs.writeFileString(filePath, contents);
95+ const response = HttpServerResponse.toWeb(
96+ yield* assetFileResponse(
97+ { path: canonicalPath, file, mimeType: "video/mp4" },
98+ range,
99+ undefined,
100+ method,
101+ ),
102+ );
103+ expect(response.status).toBe(status);
104+ expect(response.headers.get("content-range")).toBe(contentRange);
105+ if (status !== 416) {
106+ expect(response.headers.get("content-length")).toBe(
107+ String(method === "HEAD" ? contents.length : expected.length),
108+ );
109+ }
110+ expect(yield* Effect.promise(() => response.text())).toBe(expected);
111+ }
112+ }).pipe(Effect.provide(fileResponseLayer)),
113+ );
114+
115+ it.effect(
116+ "rejects unaddressable ranges before streaming and preserves small ranges on large files",
117+ () =>
118+ Effect.gen(function* () {
119+ const fs = yield* FileSystem.FileSystem;
120+ const path = yield* Path.Path;
121+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" });
122+ const filePath = path.join(directory, "clip.mp4");
123+ yield* fs.writeFileString(filePath, "0123456789");
124+ const canonicalPath = yield* fs.realPath(filePath);
125+ const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
126+ const size = unsafeOffset + 32n;
127+ for (const [range, status] of [
128+ [`bytes=${unsafeOffset}-${unsafeOffset}`, 416],
129+ [`bytes=0-${unsafeOffset}`, 416],
130+ ["bytes=-1", 416],
131+ [undefined, 413],
132+ ["bytes=0-1", 206],
133+ ] as const) {
134+ const file = yield* openMediaFile(canonicalPath);
135+ if (!file) throw new Error("Expected an opened media file");
136+ // Model a sparse file beyond the native stream's numeric addressing limit.
137+ const info = yield* Effect.promise(() => file.handle.stat({ bigint: true }));
138+ info.size = size;
139+ const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info);
140+ yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore()));
141+ const response = HttpServerResponse.toWeb(
142+ yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range),
143+ );
144+ expect(response.status).toBe(status);
145+ if (status === 416) {
146+ expect(response.headers.get("content-range")).toBe(`bytes */${size}`);
147+ expect(yield* Effect.promise(() => response.text())).toBe("");
148+ } else if (status === 206) {
149+ expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`);
150+ expect(yield* Effect.promise(() => response.text())).toBe("01");
151+ } else {
152+ expect(yield* Effect.promise(() => response.text())).toBe(
153+ "File is too large to preview.",
154+ );
155+ }
156+ }
157+ }).pipe(Effect.provide(fileResponseLayer)),
158+ );
159+
160+ it.effect("streams guarded file ranges, including suffixes and conditional requests", () =>
161+ Effect.gen(function* () {
162+ const fs = yield* FileSystem.FileSystem;
163+ const path = yield* Path.Path;
164+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" });
165+ const filePath = path.join(directory, "clip.mp4");
166+ yield* fs.writeFileString(filePath, "0123456789");
167+ const canonicalPath = yield* fs.realPath(filePath);
168+ for (const [range, ifRange, expected, status, contentRange] of [
169+ [undefined, undefined, "0123456789", 200, null],
170+ ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"],
171+ ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"],
172+ ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"],
173+ ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"],
174+ ["bytes=10-", undefined, "", 416, "bytes */10"],
175+ ["bytes=0-1", '"old-etag"', "0123456789", 200, null],
176+ ["bytes=0-1", "", "0123456789", 200, null],
177+ ] as const) {
178+ const file = yield* openMediaFile(canonicalPath);
179+ if (!file) throw new Error("Expected an opened media file");
180+ const response = HttpServerResponse.toWeb(
181+ yield* assetFileResponse(
182+ { path: canonicalPath, file, mimeType: "video/mp4" },
183+ range,
184+ ifRange,
185+ ),
186+ );
187+ expect(response.status).toBe(status);
188+ expect(response.headers.get("accept-ranges")).toBe("bytes");
189+ expect(response.headers.get("content-range")).toBe(contentRange);
190+ expect(response.headers.get("cache-control")).toBe("private, no-store");
191+ expect(response.headers.get("etag")).toBeNull();
192+ expect(response.headers.get("last-modified")).toBeNull();
193+ if (status !== 416)
194+ expect(response.headers.get("content-length")).toBe(String(expected.length));
195+ expect(yield* Effect.promise(() => response.text())).toBe(expected);
196+ }
197+ }).pipe(Effect.provide(fileResponseLayer)),
198+ );
199+
200+ it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () =>
201+ Effect.gen(function* () {
202+ const fs = yield* FileSystem.FileSystem;
203+ const path = yield* Path.Path;
204+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" });
205+ const filePath = path.join(directory, "clip.mp4");
206+ const bytes = new Uint8Array(1024 * 1024).fill(42);
207+ yield* fs.writeFile(filePath, bytes);
208+ const canonicalPath = yield* fs.realPath(filePath);
209+ for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) {
210+ const file = yield* Effect.scoped(
211+ Effect.gen(function* () {
212+ const file = yield* openMediaFile(canonicalPath);
213+ if (!file) throw new Error("Expected an opened media file");
214+ const response = HttpServerResponse.toWeb(
215+ yield* assetFileResponse(
216+ { path: canonicalPath, file, mimeType: "video/mp4" },
217+ mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-",
218+ undefined,
219+ mode === "HEAD" ? "HEAD" : "GET",
220+ ),
221+ );
222+ if (mode === "HEAD") {
223+ expect(response.status).toBe(200);
224+ expect(response.headers.get("content-length")).toBe(String(bytes.length));
225+ expect(response.headers.get("content-range")).toBeNull();
226+ expect(yield* Effect.promise(() => response.text())).toBe("");
227+ } else if (mode === "rejected") {
228+ expect(response.status).toBe(416);
229+ expect(yield* Effect.promise(() => response.text())).toBe("");
230+ } else if (mode === "cancelled") {
231+ const reader = response.body!.getReader();
232+ const first = yield* Effect.promise(() => reader.read());
233+ expect(first.done).toBe(false);
234+ expect(first.value!.byteLength).toBeLessThan(bytes.length);
235+ yield* Effect.promise(() => reader.cancel());
236+ } else {
237+ expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer);
238+ }
239+ return file;
240+ }),
241+ );
242+ expect(file.handle.fd).toBe(-1);
243+ }
244+ }).pipe(Effect.provide(fileResponseLayer)),
245+ );
246+
73247 it.effect("streams exactly the requested bytes and leaves full downloads intact", () =>
74248 Effect.gen(function* () {
75249 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
7777 import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";
7878 import { ServerConfig } from "../../config.ts";
7979 import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";
80−import { resolveClaudeContextWindow } from "../../provider/Layers/ClaudeProvider.ts";
80+import {
81+ BUNDLED_CLAUDE_MODEL_CATALOG,
82+ resolveClaudeCatalogContextWindow,
83+} from "../../provider/ClaudeModelCatalog.ts";
8184 import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
8285 import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";
8386 import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
@@ -134,7 +137,9 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null { · 50 unmodified lines
134137 if (modelSelection.model === "claude-opus-4-6" || modelSelection.model === "claude-opus-4-7") {
135138 return 1_000_000;
136139 }
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;
138143 }
139144
140145 export 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
30273027 }),
30283028 ),
30293029 );
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);
30303060 }).pipe(
30313061 Effect.mapError(
30323062 (cause) =>
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+0−33
@@ -166,42 +166,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 165 unmodified lines
166166 });
167167 }
168168
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();
199169 const eventBase = yield* decideOrchestrationCommand({
200170 command: envelope.command,
201171 readModel: commandReadModel,
202− ...(Option.isSome(userInputActivity)
203− ? { userInputActivity: userInputActivity.value }
204− : {}),
205172 }).pipe(
206173 Effect.provideService(Crypto.Crypto, crypto),
207174 Effect.mapError((cause) =>
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+10−23
@@ -1018,34 +1018,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1017 unmodified lines
10181018 return;
10191019
10201020 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−
10411021 const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({
10421022 messageId: event.payload.messageId,
10431023 });
10441024 const previousMessage = Option.getOrUndefined(existingMessage);
10451025 const nextText = Option.match(existingMessage, {
10461026 onNone: () => event.payload.text,
1047− onSome: (message) =>
1048− event.payload.text.length === 0 ? message.text : event.payload.text,
1027+ onSome: (message) => {
1028+ if (event.payload.streaming) {
1029+ return `${message.text}${event.payload.text}`;
1030+ }
1031+ if (event.payload.text.length === 0) {
1032+ return message.text;
1033+ }
1034+ return event.payload.text;
1035+ },
10491036 });
10501037 const nextAttachments =
10511038 event.payload.attachments !== undefined
@@ -1060,7 +1047,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 8 unmodified lines
10601047 role: event.payload.role,
10611048 text: nextText,
10621049 ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
1063− isStreaming: false,
1050+ isStreaming: event.payload.streaming,
10641051 createdAt: previousMessage?.createdAt ?? event.payload.createdAt,
10651052 updatedAt: event.payload.updatedAt,
10661053 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+70−258
@@ -77,9 +77,6 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); · 76 unmodified lines
7777 // activity window. Applying the limit in SQL avoids decoding an unbounded
7878 // payload_json set before the projector can enforce that invariant.
7979 const THREAD_DETAIL_ACTIVITY_LIMIT = 500;
80−// Snapshot payloads are decoded and projected in small sequential batches so
81−// one client read does not retain the raw payloads for the full activity window.
82−const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25;
8380 const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
8481 Struct.assign({
8582 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
@@ -104,9 +101,6 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( · 18 unmodified lines
104101 sequence: Schema.NullOr(NonNegativeInt),
105102 }),
106103 );
107−const ProjectionThreadActivityIdRowSchema = Schema.Struct({
108− activityId: ProjectionThreadActivity.fields.activityId,
109−});
110104 const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
111105 const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
112106 id: ThreadId,
@@ -166,9 +160,6 @@ const ThreadActivityKindsLookupInput = Schema.Struct({ · 53 unmodified lines
166160 threadId: ThreadId,
167161 activityKinds: Schema.Array(Schema.String),
168162 });
169−const ThreadActivityIdsLookupInput = Schema.Struct({
170− activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId),
171−});
172163 // Windowed reads order turns by the stable keyset (anchor, turn key), where
173164 // anchor is requested_at and turn key is
174165 // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the
@@ -372,21 +363,6 @@ function mapProposedPlanRow( · 197 unmodified lines
372363 };
373364 }
374365
375−function mapThreadActivityRow(
376− row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>,
377−): OrchestrationThreadActivity {
378− return {
379− id: row.activityId,
380− tone: row.tone,
381− kind: row.kind,
382− summary: row.summary,
383− payload: row.payload,
384− turnId: row.turnId,
385− createdAt: row.createdAt,
386− ...(row.sequence !== null ? { sequence: row.sequence } : {}),
387− };
388−}
389−
390366 function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
391367 return (cause: unknown): ProjectionRepositoryError =>
392368 Schema.isSchemaError(cause)
@@ -1187,44 +1163,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 794 unmodified lines
11871163 `,
11881164 });
11891165
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−
12281166 const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({
12291167 Request: ThreadActivityKindsLookupInput,
12301168 Result: ProjectionThreadActivityDbRowSchema,
@@ -1475,8 +1413,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 244 unmodified lines
14751413 `,
14761414 });
14771415
1478− const pinnedThreadActivityIdsCte = (threadId: string) => sql`
1479−pending_approval_requests AS (
1416+ // Blocking request payloads must remain available even if they predate the
1417+ // recent activity window. Each CTE returns at most one unresolved row per
1418+ // request, so the merge below stays bounded by actionable work.
1419+ const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({
1420+ Request: ThreadIdLookupInput,
1421+ Result: ProjectionThreadActivityDbRowSchema,
1422+ execute: ({ threadId }) =>
1423+ sql`
1424+ WITH pending_approval_requests AS (
14801425 SELECT request_id, thread_id
14811426 FROM projection_pending_approvals
14821427 WHERE thread_id = ${threadId}
@@ -1540,17 +1485,6 @@ pending_approval_requests AS ( · 57 unmodified lines
15401485 WHERE request_order = 1
15411486 AND kind = 'user-input.requested'
15421487 )
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)}
15541488 SELECT
15551489 activity.activity_id AS "activityId",
15561490 activity.thread_id AS "threadId",
@@ -1568,17 +1502,6 @@ pending_approval_requests AS ( · 11 unmodified lines
15681502 `,
15691503 });
15701504
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−
15821505 const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({
15831506 Request: ThreadTurnRangeLookupInput,
15841507 Result: ProjectionThreadActivityDbRowSchema,
@@ -1646,48 +1569,6 @@ pending_approval_requests AS ( · 61 unmodified lines
16461569 `,
16471570 });
16481571
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−
16911572 const getFullThreadDiffContextRow = SqlSchema.findOneOption({
16921573 Request: FullThreadDiffContextLookupInput,
16931574 Result: ProjectionFullThreadDiffContextRowSchema,
@@ -2832,136 +2713,49 @@ pending_approval_requests AS ( · 1138 unmodified lines
28322713 readonly beforeTurnKey: string;
28332714 }
28342715
2835− type ThreadDetailActivityRead =
2836− | {
2837− readonly mode: "raw";
2838− readonly query?: ProjectionThreadDetailQuery;
2839− }
2840− | {
2841− readonly mode: "client";
2842− };
2843−
2844− const listProjectedThreadActivities = Effect.fn(
2845− "ProjectionSnapshotQuery.listProjectedThreadActivities",
2846− )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) {
2847− const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([
2848− (bounds === undefined
2849− ? listThreadActivityIdsByThread({ threadId })
2850− : listThreadActivityIdsByThreadWindow({ threadId, ...bounds })
2716+ const getThreadDetailByIdBounded = (
2717+ threadId: ThreadId,
2718+ bounds: ThreadDetailBounds | undefined,
2719+ query: ProjectionThreadDetailQuery | undefined = undefined,
2720+ ) =>
2721+ Effect.gen(function* () {
2722+ const activityRowsEffect = (
2723+ query?.activityKinds === undefined
2724+ ? bounds === undefined
2725+ ? listThreadActivityRowsByThread({ threadId })
2726+ : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
2727+ : query.activityKinds.length === 0
2728+ ? Effect.succeed([])
2729+ : listThreadActivityRowsByThreadAndKinds({
2730+ threadId,
2731+ activityKinds: query.activityKinds,
2732+ })
28512733 ).pipe(
28522734 Effect.mapError(
28532735 toPersistenceSqlOrDecodeError(
2854− "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query",
2855− "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows",
2736+ "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
2737+ "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
28562738 ),
28572739 ),
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,
28812740 );
2882− const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe(
2883− Effect.mapError(
2884− toPersistenceSqlOrDecodeError(
2885− "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query",
2886− "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows",
2887− ),
2888− ),
2889− );
2890− for (const row of batchRows) {
2891− activities.push(projectActivityPayload(mapThreadActivityRow(row)));
2892− }
2893− }
2894−
2895− return activities.toSorted(
2896− (left, right) =>
2897− (left.sequence ?? -1) - (right.sequence ?? -1) ||
2898− left.createdAt.localeCompare(right.createdAt) ||
2899− left.id.localeCompare(right.id),
2900− );
2901− });
2902−
2903− const getThreadDetailByIdBounded = (
2904− threadId: ThreadId,
2905− bounds: ThreadDetailBounds | undefined,
2906− activityRead: ThreadDetailActivityRead = { mode: "raw" },
2907− ) =>
2908− Effect.gen(function* () {
2909− const activitiesEffect =
2910− activityRead.mode === "client"
2911− ? listProjectedThreadActivities(threadId, bounds)
2912− : Effect.all([
2913− (activityRead.query?.activityKinds === undefined
2914− ? bounds === undefined
2915− ? listThreadActivityRowsByThread({ threadId })
2916− : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
2917− : activityRead.query.activityKinds.length === 0
2918− ? Effect.succeed([])
2919− : listThreadActivityRowsByThreadAndKinds({
2920− threadId,
2921− activityKinds: activityRead.query.activityKinds,
2922− })
2923− ).pipe(
2924− Effect.mapError(
2925− toPersistenceSqlOrDecodeError(
2926− "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
2927− "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
2928− ),
2741+ const pinnedActivityRowsEffect =
2742+ query?.activityKinds === undefined
2743+ ? listPinnedThreadActivityRowsByThread({ threadId }).pipe(
2744+ Effect.mapError(
2745+ toPersistenceSqlOrDecodeError(
2746+ "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",
2747+ "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",
29292748 ),
29302749 ),
2931− activityRead.query?.activityKinds === undefined
2932− ? listPinnedThreadActivityRowsByThread({ threadId }).pipe(
2933− Effect.mapError(
2934− toPersistenceSqlOrDecodeError(
2935− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",
2936− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",
2937− ),
2938− ),
2939− )
2940− : Effect.succeed([]),
2941− ]).pipe(
2942− Effect.map(([activityRows, pinnedActivityRows]) =>
2943− [
2944− ...new Map(
2945− [...activityRows, ...pinnedActivityRows].map(
2946− (row) => [row.activityId, row] as const,
2947− ),
2948− ).values(),
2949− ]
2950− .toSorted(
2951− (left, right) =>
2952− (left.sequence ?? -1) - (right.sequence ?? -1) ||
2953− left.createdAt.localeCompare(right.createdAt) ||
2954− left.activityId.localeCompare(right.activityId),
2955− )
2956− .map(mapThreadActivityRow),
2957− ),
2958− );
2750+ )
2751+ : Effect.succeed([]);
29592752
29602753 const [
29612754 threadRow,
29622755 messageRows,
29632756 proposedPlanRows,
2964− activities,
2757+ activityRows,
2758+ pinnedActivityRows,
29652759 checkpointRows,
29662760 latestTurnRow,
29672761 sessionRow,
@@ -2993,7 +2787,8 @@ pending_approval_requests AS ( · 25 unmodified lines
29932787 ),
29942788 ),
29952789 ),
2996− activitiesEffect,
2790+ activityRowsEffect,
2791+ pinnedActivityRowsEffect,
29972792 listCheckpointRowsByThread({ threadId }).pipe(
29982793 Effect.mapError(
29992794 toPersistenceSqlOrDecodeError(
@@ -3024,6 +2819,17 @@ pending_approval_requests AS ( · 24 unmodified lines
30242819 return Option.none<OrchestrationThread>();
30252820 }
30262821
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+
30272833 const thread = {
30282834 id: threadRow.value.threadId,
30292835 projectId: threadRow.value.projectId,
@@ -3061,7 +2867,21 @@ pending_approval_requests AS ( · 31 unmodified lines
30612867 return message;
30622868 }),
30632869 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+ }),
30652885 checkpoints: checkpointRows.map((row) => ({
30662886 turnId: row.turnId,
30672887 checkpointTurnCount: row.checkpointTurnCount,
@@ -3086,11 +2906,7 @@ pending_approval_requests AS ( · 18 unmodified lines
30862906 const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (
30872907 threadId,
30882908 query,
3089− ) =>
3090− getThreadDetailByIdBounded(threadId, undefined, {
3091− mode: "raw",
3092− ...(query === undefined ? {} : { query }),
3093− });
2909+ ) => getThreadDetailByIdBounded(threadId, undefined, query);
30942910
30952911 // Bounds pathological fan-out: one user turn that spawned hundreds of
30962912 // subagent turns still pages in bounded chunks, at the cost of splitting the
@@ -3114,9 +2930,7 @@ pending_approval_requests AS ( · 17 unmodified lines
31142930 .withTransaction(
31152931 Effect.gen(function* () {
31162932 if (window?.turnLimit === undefined) {
3117− const thread = yield* getThreadDetailByIdBounded(threadId, undefined, {
3118− mode: "client",
3119− });
2933+ const thread = yield* getThreadDetailById(threadId);
31202934 if (Option.isNone(thread)) {
31212935 return Option.none<OrchestrationThreadDetailSnapshot>();
31222936 }
@@ -3169,9 +2983,7 @@ pending_approval_requests AS ( · 46 unmodified lines
31692983 ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" }
31702984 : undefined;
31712985
3172− const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, {
3173− mode: "client",
3174− });
2986+ const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds);
31752987 if (Option.isNone(thread)) {
31762988 return Option.none<OrchestrationThreadDetailSnapshot>();
31772989 }
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+0−4
@@ -227,10 +227,6 @@ export interface ProjectionSnapshotQueryShape { · 226 unmodified lines
227227 * response carries `page` metadata (see `OrchestrationThreadDetailWindow`).
228228 * Without a window the full thread is returned with no `page` field —
229229 * 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.
234230 */
235231 readonly getThreadDetailSnapshot: (
236232 threadId: ThreadId,
apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts+0−250
@@ -1,250 +0,0 @@
1−import {
2− EventId,
3− MessageId,
4− ThreadId,
5− TurnId,
6− OrchestrationEvent,
7− type OrchestrationThreadActivity,
8−} from "@t3tools/contracts";
9−import { it } from "@effect/vitest";
10−import * as Clock from "effect/Clock";
11−import * as Effect from "effect/Effect";
12−import * as Schema from "effect/Schema";
13−import * as Stream from "effect/Stream";
14−import * as TestClock from "effect/testing/TestClock";
15−import { describe, expect } from "vite-plus/test";
16−
17−import {
18− coalesceLiveToolUpdatedEvents,
19− makeThreadLiveEventCoalescer,
20−} from "./ThreadLiveEventCoalescer.ts";
21−
22−const threadId = ThreadId.make("thread-coalescer-test");
23−const turnId = TurnId.make("turn-coalescer-test");
24−const encodeEvent = Schema.encodeSync(Schema.fromJsonString(OrchestrationEvent));
25−
26−function makeToolActivity(
27− sequence: number,
28− options: {
29− readonly kind?: "tool.updated" | "tool.completed";
30− readonly toolCallId?: string;
31− readonly turnId?: TurnId;
32− } = {},
33−): OrchestrationEvent {
34− const {
35− kind = "tool.updated",
36− toolCallId = "call-edit",
37− turnId: activityTurnId = turnId,
38− } = options;
39− const activity: OrchestrationThreadActivity = {
40− id: EventId.make(`activity-${sequence}`),
41− tone: "tool",
42− kind,
43− summary: "Editing app.ts",
44− payload: {
45− itemType: "file_change",
46− title: "Editing app.ts",
47− data: toolCallId ? { toolCallId } : {},
48− },
49− turnId: activityTurnId,
50− createdAt: "2026-01-01T00:00:01.000Z",
51− };
52− return {
53− sequence,
54− eventId: EventId.make(`event-${sequence}`),
55− aggregateKind: "thread",
56− aggregateId: threadId,
57− occurredAt: "2026-01-01T00:00:01.000Z",
58− commandId: null,
59− causationEventId: null,
60− correlationId: null,
61− metadata: {},
62− type: "thread.activity-appended",
63− payload: { threadId, activity },
64− };
65−}
66−
67−function makeMessage(sequence: number, text = "Still working"): OrchestrationEvent {
68− return {
69− sequence,
70− eventId: EventId.make(`event-${sequence}`),
71− aggregateKind: "thread",
72− aggregateId: threadId,
73− occurredAt: "2026-01-01T00:00:02.000Z",
74− commandId: null,
75− causationEventId: null,
76− correlationId: null,
77− metadata: {},
78− type: "thread.message-sent",
79− payload: {
80− threadId,
81− messageId: MessageId.make(`message-${sequence}`),
82− role: "assistant",
83− text,
84− turnId,
85− streaming: false,
86− createdAt: "2026-01-01T00:00:02.000Z",
87− updatedAt: "2026-01-01T00:00:02.000Z",
88− },
89− };
90−}
91−
92−describe("ThreadLiveEventCoalescer", () => {
93− it("coalesces only calls with a stable toolCallId", () => {
94− const events = [
95− makeToolActivity(1, { toolCallId: "call-a" }),
96− makeToolActivity(2, { toolCallId: "call-b" }),
97− makeToolActivity(3, { toolCallId: "call-a" }),
98− ];
99−
100− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]);
101− });
102−
103− it("preserves parallel same-label calls without a stable toolCallId", () => {
104− const events = [
105− makeToolActivity(1, { toolCallId: "" }),
106− makeToolActivity(2, { toolCallId: "" }),
107− makeToolActivity(3, { kind: "tool.completed", toolCallId: "" }),
108− ];
109−
110− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2, 3]);
111− });
112−
113− it("does not coalesce stable tool calls across turns", () => {
114− const events = [
115− makeToolActivity(1, { turnId: TurnId.make("turn-old") }),
116− makeToolActivity(2, { turnId: TurnId.make("turn-new") }),
117− ];
118−
119− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2]);
120− });
121−
122− it("flushes a stable update run before a completion boundary", () => {
123− const events = [
124− makeToolActivity(1),
125− makeToolActivity(2),
126− makeToolActivity(3, { kind: "tool.completed" }),
127− makeToolActivity(4),
128− ];
129−
130− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]);
131− });
132−
133− it.effect("flushes pending tool updates as soon as an unrelated event arrives", () =>
134− Effect.scoped(
135− Effect.gen(function* () {
136− const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" });
137− const startedAt = yield* Clock.currentTimeMillis;
138− yield* Effect.forEach(
139− Array.from({ length: 10 }, (_, index) => index + 2),
140− (sequence) => coalescer.offer({ kind: "event", event: makeToolActivity(sequence) }),
141− { discard: true },
142− );
143− yield* coalescer.offer({ kind: "event", event: makeMessage(12) });
144−
145− expect(yield* Clock.currentTimeMillis).toBe(startedAt);
146− expect(
147− (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) =>
148− item.kind === "event" ? item.event.sequence : item.kind,
149− ),
150− ).toEqual([11, 12]);
151− }),
152− ).pipe(Effect.provide(TestClock.layer())),
153− );
154−
155− it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () =>
156− Effect.scoped(
157− Effect.gen(function* () {
158− const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" });
159− const startedAt = yield* Clock.currentTimeMillis;
160− yield* coalescer.offer({ kind: "event", event: makeToolActivity(2) });
161− yield* coalescer.offer({ kind: "event", event: makeToolActivity(3) });
162− yield* coalescer.offer({ kind: "synchronized" });
163−
164− expect(yield* Clock.currentTimeMillis).toBe(startedAt);
165− expect(
166− (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) =>
167− item.kind === "event" ? item.event.sequence : item.kind,
168− ),
169− ).toEqual([3, "synchronized"]);
170− }),
171− ).pipe(Effect.provide(TestClock.layer())),
172− );
173−
174− it.effect("fails and clears pending updates when their serialized payload fills the budget", () =>
175− Effect.scoped(
176− Effect.gen(function* () {
177− const first = makeToolActivity(1);
178− const coalescer = yield* makeThreadLiveEventCoalescer({
179− coalesceWindow: "500 millis",
180− maxSerializedBytes: Buffer.byteLength(encodeEvent(first)),
181− });
182− yield* coalescer.offer({ kind: "event", event: first });
183− expect(yield* coalescer.usage).toEqual({
184− retainedItems: 1,
185− retainedSerializedBytes: Buffer.byteLength(encodeEvent(first)),
186− });
187−
188− const overflow = yield* coalescer
189− .offer({ kind: "event", event: makeToolActivity(2) })
190− .pipe(Effect.result);
191− expect(overflow._tag).toBe("Failure");
192− yield* coalescer.closed;
193− expect(yield* coalescer.usage).toEqual({ retainedItems: 0, retainedSerializedBytes: 0 });
194− const marker = yield* coalescer.offer({ kind: "synchronized" }).pipe(Effect.result);
195− expect(marker._tag).toBe("Failure");
196− const delivered = yield* coalescer.stream.pipe(Stream.runCollect, Effect.result);
197− expect(delivered._tag).toBe("Failure");
198− }),
199− ),
200− );
201−
202− it.effect("keeps the flush timer alive when an offer's shorter scope closes", () =>
203− Effect.scoped(
204− Effect.gen(function* () {
205− const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "50 millis" });
206− yield* Effect.scoped(coalescer.offer({ kind: "event", event: makeToolActivity(1) }));
207− yield* TestClock.adjust("50 millis");
208− const items = yield* coalescer.stream.pipe(Stream.take(1), Stream.runCollect);
209− expect(
210− items.map((item) => (item.kind === "event" ? item.event.sequence : item.kind)),
211− ).toEqual([1]);
212− }),
213− ),
214− );
215−
216− it.effect("keeps an unacknowledged batch charged and clears later events on overflow", () =>
217− Effect.scoped(
218− Effect.gen(function* () {
219− const coalescer = yield* makeThreadLiveEventCoalescer({ maxItems: 3 });
220− const first = makeMessage(1, "é".repeat(1_024));
221− yield* coalescer.offer({ kind: "event", event: first });
222−
223− yield* Effect.scoped(
224− Effect.gen(function* () {
225− const pull = yield* Stream.toPull(coalescer.stream);
226− const batch = yield* pull;
227− expect(
228− batch.map((item) => (item.kind === "event" ? item.event.sequence : null)),
229− ).toEqual([1]);
230− yield* coalescer.offer({ kind: "event", event: makeMessage(2) });
231− yield* coalescer.offer({ kind: "event", event: makeToolActivity(3) });
232− expect((yield* coalescer.usage).retainedItems).toBe(3);
233−
234− const overflow = yield* coalescer
235− .offer({ kind: "event", event: makeToolActivity(4, { kind: "tool.completed" }) })
236− .pipe(Effect.result);
237− expect(overflow._tag).toBe("Failure");
238− // Do not pull or acknowledge the batch. Cleanup must still finish.
239− yield* coalescer.closed;
240− expect(yield* coalescer.usage).toEqual({
241− retainedItems: 1,
242− retainedSerializedBytes: Buffer.byteLength(encodeEvent(first)),
243− });
244− }),
245− );
246− expect(yield* coalescer.usage).toEqual({ retainedItems: 0, retainedSerializedBytes: 0 });
247− }),
248− ),
249− );
250−});
apps/server/src/orchestration/ThreadLiveEventCoalescer.ts+0−241
@@ -1,241 +0,0 @@
1−import type {
2− OrchestrationEvent,
3− OrchestrationGetSnapshotError,
4− OrchestrationThreadStreamItem,
5−} from "@t3tools/contracts";
6−import * as Deferred from "effect/Deferred";
7−import * as Duration from "effect/Duration";
8−import * as Effect from "effect/Effect";
9−import * as Fiber from "effect/Fiber";
10−import * as Predicate from "effect/Predicate";
11−import * as Queue from "effect/Queue";
12−import * as Semaphore from "effect/Semaphore";
13−import * as Stream from "effect/Stream";
14−
15−import { projectActivityEvent } from "./ActivityPayloadProjection.ts";
16−import { makeLiveStreamBudget, type RetainedLiveItem } from "./LiveStreamBudget.ts";
17−
18−const COALESCE_WINDOW = Duration.millis(50);
19−const MAX_PENDING_UPDATES = 512;
20−
21−export type ThreadLiveInput =
22− | { readonly kind: "event"; readonly event: OrchestrationEvent }
23− | { readonly kind: "synchronized" };
24−
25−function isToolUpdated(event: OrchestrationEvent): boolean {
26− return (
27− event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated"
28− );
29−}
30−
31−function asTrimmedString(value: unknown): string | null {
32− if (!Predicate.isString(value)) {
33− return null;
34− }
35− const trimmed = value.trim();
36− return trimmed.length > 0 ? trimmed : null;
37−}
38−
39−function stableToolCallIdentity(event: OrchestrationEvent): string | null {
40− if (event.type !== "thread.activity-appended") {
41− return null;
42− }
43− const payload = event.payload.activity.payload;
44− if (!Predicate.isObject(payload)) {
45− return null;
46− }
47− const data = Predicate.isObject(payload.data) ? payload.data : null;
48− return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId);
49−}
50−
51−/**
52− * Retain only the latest in-flight update for each stable tool-call id in a
53− * live run. Anonymous calls pass through because labels are not unique when
54− * tools execute in parallel. Survivors remain in sequence order.
55− */
56−export function coalesceLiveToolUpdatedEvents(
57− events: ReadonlyArray<OrchestrationEvent>,
58−): ReadonlyArray<OrchestrationEvent> {
59− const survivors: Array<OrchestrationEvent> = [];
60− let pendingUpdates: Array<OrchestrationEvent> = [];
61−
62− const flushUpdates = () => {
63− const seen = new Set<string>();
64− const latestUpdates: Array<OrchestrationEvent> = [];
65− for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) {
66− const event = pendingUpdates[index]!;
67− const identity = stableToolCallIdentity(event);
68− const activity =
69− event.type === "thread.activity-appended" ? event.payload.activity : undefined;
70− const key = identity ? `${activity?.turnId ?? ""}\u0000${identity}` : null;
71− if (key && seen.has(key)) {
72− continue;
73− }
74− if (key) {
75− seen.add(key);
76− }
77− latestUpdates.push(event);
78− }
79− latestUpdates.reverse();
80− survivors.push(...latestUpdates);
81− pendingUpdates = [];
82− };
83−
84− for (const event of events) {
85− if (isToolUpdated(event)) {
86− pendingUpdates.push(event);
87− continue;
88− }
89− flushUpdates();
90− survivors.push(event);
91− }
92− flushUpdates();
93− return survivors;
94−}
95−
96−export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoalescer")(
97− function* (options?: {
98− readonly coalesceWindow?: Duration.Input;
99− readonly maxItems?: number;
100− readonly maxSerializedBytes?: number;
101− }) {
102− const coalescerScope = yield* Effect.scope;
103− const budget = yield* makeLiveStreamBudget(options);
104− const cleanupComplete = yield* Deferred.make<void>();
105− const output = yield* Queue.unbounded<
106− RetainedLiveItem<OrchestrationThreadStreamItem>,
107− OrchestrationGetSnapshotError
108− >();
109− const mutex = yield* Semaphore.make(1);
110− const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW;
111− let pendingUpdates: Array<RetainedLiveItem<OrchestrationEvent>> = [];
112− let windowGeneration = 0;
113− let windowFiber: Fiber.Fiber<void, never> | null = null;
114− let closed = false;
115−
116− const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () {
117− const fiber = windowFiber;
118− if (!fiber) {
119− return;
120− }
121− windowFiber = null;
122− yield* Fiber.interrupt(fiber);
123− });
124−
125− const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* () {
126− if (pendingUpdates.length === 0) {
127− return;
128− }
129− const items = yield* budget.replace(
130− pendingUpdates,
131− coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)).map((event) => ({
132− kind: "event" as const,
133− event,
134− })),
135− (item) => item.event,
136− );
137− pendingUpdates = [];
138− yield* Queue.offerAll(output, items);
139− }, Effect.uninterruptible);
140−
141− const flushWindow = (generation: number) =>
142− Effect.sleep(coalesceWindow).pipe(
143− Effect.andThen(
144− mutex.withPermits(1)(
145− Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)),
146− ),
147− ),
148− Effect.ensuring(
149− Effect.sync(() => {
150− if (generation === windowGeneration) {
151− windowFiber = null;
152− }
153− }),
154− ),
155− Effect.catchTags({ OrchestrationGetSnapshotError: () => Effect.void }),
156− );
157−
158− // Keep each source batch together so a synchronization marker cannot pass
159− // events already pulled from PubSub but still being coalesced.
160− const offerAll = Effect.fn("ThreadLiveEventCoalescer.offerAll")(function* (
161− inputs: ReadonlyArray<ThreadLiveInput>,
162− ) {
163− yield* mutex.withPermits(1)(
164− Effect.forEach(
165− inputs,
166− (input) =>
167− Effect.gen(function* () {
168− yield* budget.check;
169− if (input.kind === "event") {
170− // Retain only the client payload, not full persisted tool output.
171− yield* budget.retain(projectActivityEvent(input.event)).pipe(
172− Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))),
173− Effect.uninterruptible,
174− );
175− }
176− if (input.kind === "event" && isToolUpdated(input.event)) {
177− if (pendingUpdates.length === 1) {
178− const generation = ++windowGeneration;
179− windowFiber = yield* Effect.forkIn(flushWindow(generation), coalescerScope);
180− }
181− if (pendingUpdates.length >= MAX_PENDING_UPDATES) {
182− yield* cancelWindow();
183− windowGeneration += 1;
184− yield* flushPending();
185− }
186− return;
187− }
188−
189− yield* cancelWindow();
190− windowGeneration += 1;
191− // A non-update event closes the run immediately. The coalescer keeps
192− // that boundary after the final update from the run.
193− yield* flushPending();
194− if (input.kind === "synchronized") {
195− yield* budget.retain({ kind: "synchronized" as const }).pipe(
196− Effect.flatMap((marker) => Queue.offer(output, marker)),
197− Effect.uninterruptible,
198− );
199− }
200− }),
201− { discard: true },
202− ),
203− );
204− });
205−
206− const close = (error?: OrchestrationGetSnapshotError) =>
207− mutex.withPermits(1)(
208− Effect.gen(function* () {
209− if (closed) {
210− return;
211− }
212− closed = true;
213− windowGeneration += 1;
214− yield* cancelWindow();
215− budget.release(pendingUpdates);
216− pendingUpdates = [];
217− budget.release(yield* Queue.clear(output).pipe(Effect.orDie));
218− if (error) {
219− yield* Queue.fail(output, error);
220− }
221− yield* Queue.shutdown(output);
222− yield* Deferred.succeed(cleanupComplete, undefined);
223− }),
224− );
225−
226− yield* Effect.addFinalizer(() => close());
227− yield* budget.failed.pipe(
228− Effect.catchTags({ OrchestrationGetSnapshotError: close }),
229− Effect.forkScoped,
230− );
231−
232− return {
233− offer: (input: ThreadLiveInput) => offerAll([input]),
234− offerAll,
235− stream: budget.deliver(Stream.fromQueue(output)),
236− failed: budget.failed,
237− closed: Deferred.await(cleanupComplete),
238− usage: budget.usage,
239− } as const;
240− },
241−);
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+0−858
@@ -1,858 +0,0 @@
1−import {
2− DEFAULT_SERVER_SETTINGS,
3− ProjectId,
4− ProviderInstanceId,
5− PullRequestOperationError,
6− ThreadId,
7− type OrchestrationCommand,
8− type OrchestrationProjectShell,
9− type OrchestrationShellSnapshot,
10− type OrchestrationThreadShell,
11− type PullRequestSummary,
12− type ServerSettings,
13− type ServerSettingsPatch,
14−} from "@t3tools/contracts";
15−import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
16−import { assert, describe, it } from "@effect/vitest";
17−import * as Crypto from "effect/Crypto";
18−import * as Deferred from "effect/Deferred";
19−import * as Effect from "effect/Effect";
20−import * as FileSystem from "effect/FileSystem";
21−import * as Layer from "effect/Layer";
22−import * as PubSub from "effect/PubSub";
23−import * as Queue from "effect/Queue";
24−import * as Ref from "effect/Ref";
25−import * as Stream from "effect/Stream";
26−import { TestClock } from "effect/testing";
27−
28−import { GitManager } from "../git/GitManager.ts";
29−import {
30− PullRequestService,
31− type PullRequestMergeEvent,
32−} from "../pullRequest/PullRequestService.ts";
33−import { ServerActivation } from "../serverActivation.ts";
34−import { ServerSettingsService } from "../serverSettings.ts";
35−import { OrchestrationCommandInvariantError } from "./Errors.ts";
36−import {
37− OrchestrationEngineService,
38− type OrchestrationEngineShape,
39−} from "./Services/OrchestrationEngine.ts";
40−import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
41−import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";
42−
43−const NOW = "2026-08-28T12:00:00.000Z";
44−const PROJECT_ID = ProjectId.make("settlement-project");
45−const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");
46−
47−type AutoSettleCommand = Extract<OrchestrationCommand, { readonly type: "thread.auto-settle" }>;
48−
49−const testCrypto = Crypto.make({
50− randomBytes: (size) => new Uint8Array(size).fill(1),
51− digest: (_algorithm, data) => Effect.succeed(data),
52−});
53−
54−function makeProject(
55− id: ProjectId = PROJECT_ID,
56− workspaceRoot = "/workspace/project",
57−): OrchestrationProjectShell {
58− return {
59− id,
60− title: `Project ${id}`,
61− workspaceRoot,
62− defaultModelSelection: null,
63− scripts: [],
64− createdAt: "2026-08-01T00:00:00.000Z",
65− updatedAt: NOW,
66− };
67−}
68−
69−function makeThread(
70− id: string,
71− overrides: Partial<OrchestrationThreadShell> = {},
72−): OrchestrationThreadShell {
73− return {
74− id: ThreadId.make(id),
75− projectId: PROJECT_ID,
76− title: id,
77− modelSelection: {
78− instanceId: ProviderInstanceId.make("codex"),
79− model: "gpt-5",
80− },
81− runtimeMode: "full-access",
82− interactionMode: "default",
83− branch: null,
84− worktreePath: null,
85− latestTurn: null,
86− createdAt: "2026-08-01T00:00:00.000Z",
87− updatedAt: "2026-08-20T00:00:00.000Z",
88− archivedAt: null,
89− settledOverride: null,
90− settledAt: null,
91− session: null,
92− latestUserMessageAt: "2026-08-20T00:00:00.000Z",
93− hasPendingApprovals: false,
94− hasPendingUserInput: false,
95− hasActionableProposedPlan: false,
96− ...overrides,
97− };
98−}
99−
100−function makeSnapshot(
101− threads: ReadonlyArray<OrchestrationThreadShell>,
102− projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],
103−): OrchestrationShellSnapshot {
104− return {
105− snapshotSequence: 1,
106− projects,
107− threads,
108− updatedAt: NOW,
109− };
110−}
111−
112−function makePullRequestSummary(input: {
113− readonly projectId: ProjectId;
114− readonly repository: string;
115− readonly number: number;
116− readonly state: "open" | "closed" | "merged";
117− readonly updatedAt?: string;
118−}): PullRequestSummary {
119− return {
120− provider: "github",
121− projectId: input.projectId,
122− repository: input.repository,
123− number: input.number,
124− title: "Pull request",
125− url: `https://example.test/${input.repository}/pull/${input.number}`,
126− state: input.state,
127− headBranch: "feature",
128− baseBranch: "main",
129− updatedAt: input.updatedAt ?? NOW,
130− };
131−}
132−
133−interface HarnessOptions {
134− readonly snapshot: OrchestrationShellSnapshot;
135− readonly settings?: ServerSettings;
136− readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
137− readonly pullRequestSummary?: PullRequestService["Service"]["summary"];
138− readonly existingWorktreePaths?: ReadonlyArray<string>;
139− readonly onDispatch?: (
140− command: AutoSettleCommand,
141− ) => Effect.Effect<void, OrchestrationCommandInvariantError>;
142−}
143−
144−const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) {
145− const activation = yield* Deferred.make<void>();
146− const snapshots = yield* Ref.make(options.snapshot);
147− const snapshotReadCount = yield* Ref.make(0);
148− const snapshotReads = yield* Queue.unbounded<number>();
149− const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
150− const settingsChanges = yield* PubSub.unbounded<ServerSettings>();
151− const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>();
152− const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);
153− const branchCalls = yield* Ref.make<
154− ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
155− >([]);
156− const summaryCalls = yield* Ref.make<
157− ReadonlyArray<{
158− readonly projectId: ProjectId;
159− readonly repository: string;
160− readonly number: number;
161− }>
162− >([]);
163− const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
164− const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);
165−
166− const updateSettings = (patch: ServerSettingsPatch) =>
167− Effect.gen(function* () {
168− const next = applyServerSettingsPatch(yield* Ref.get(settings), patch);
169− yield* Ref.set(settings, next);
170− yield* PubSub.publish(settingsChanges, next);
171− return next;
172− });
173−
174− const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) =>
175− Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
176− Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
177− );
178− const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
179− Effect.gen(function* () {
180− yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
181− yield* Ref.update(summaryRecovery, (values) => [
182− ...values,
183− readOptions?.recoverTransientFailure,
184− ]);
185− return yield* (
186− options.pullRequestSummary?.(input, readOptions) ??
187− Effect.succeed(
188− makePullRequestSummary({
189− ...input,
190− state: "open",
191− }),
192− )
193− );
194− });
195−
196− const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
197− if (command.type !== "thread.auto-settle") {
198− return Effect.die(new Error(`Unexpected command: ${command.type}`));
199− }
200− return Ref.update(commands, (recorded) => [...recorded, command]).pipe(
201− Effect.andThen(options.onDispatch?.(command) ?? Effect.void),
202− Effect.as({ sequence: 1 }),
203− );
204− };
205−
206− const serverSettings = ServerSettingsService.of({
207− start: Effect.void,
208− ready: Effect.void,
209− getSettings: Ref.get(settings),
210− updateSettings,
211− streamChanges: Stream.fromPubSub(settingsChanges),
212− subscribeChanges: PubSub.subscribe(settingsChanges).pipe(
213− Effect.map((subscription) => Stream.fromSubscription(subscription)),
214− ),
215− });
216−
217− const dependencies = Layer.mergeAll(
218− Layer.mock(ProjectionSnapshotQuery)({
219− getShellSnapshot: () =>
220− Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(
221− Effect.tap((count) => Queue.offer(snapshotReads, count)),
222− Effect.andThen(Ref.get(snapshots)),
223− ),
224− }),
225− Layer.mock(GitManager)({
226− branchPullRequest,
227− invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]),
228− }),
229− Layer.mock(PullRequestService)({
230− summary: pullRequestSummary,
231− subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
232− Effect.map((subscription) => Stream.fromSubscription(subscription)),
233− ),
234− }),
235− Layer.mock(OrchestrationEngineService)({
236− readEvents: () => Stream.empty,
237− dispatch,
238− streamDomainEvents: Stream.empty,
239− latestSequence: Effect.succeed(0),
240− }),
241− Layer.succeed(ServerSettingsService, serverSettings),
242− Layer.succeed(ServerActivation, Deferred.await(activation)),
243− Layer.succeed(Crypto.Crypto, testCrypto),
244− FileSystem.layerNoop({
245− exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false),
246− }),
247− );
248−
249− return {
250− activation,
251− snapshots,
252− snapshotReadCount,
253− snapshotReads,
254− commands,
255− branchCalls,
256− summaryCalls,
257− summaryRecovery,
258− invalidatedCwds,
259− updateSettings,
260− publishMerge: PubSub.publish(mergedPullRequests, {
261− projectId: PROJECT_ID,
262− repository: "owner/repository",
263− number: 42,
264− mergedAt: NOW,
265− }),
266− layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),
267− };
268−});
269−
270−const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
271− reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"],
272− activation: Deferred.Deferred<void>,
273− snapshotReads: Queue.Queue<number>,
274−) {
275− yield* reactor.start();
276− yield* Deferred.succeed(activation, undefined);
277− yield* Queue.take(snapshotReads);
278− yield* reactor.drain;
279−});
280−
281−describe("ThreadSettlementReactor", () => {
282− it.effect("starts without clients and skips protected threads before pull request lookup", () =>
283− Effect.scoped(
284− Effect.gen(function* () {
285− yield* TestClock.setTime(Date.parse(NOW));
286− const linkedPullRequest = {
287− projectId: LINKED_PROJECT_ID,
288− repository: "owner/repository",
289− number: 42,
290− url: "https://example.test/owner/repository/pull/42",
291− } as const;
292− const skipped = [
293− makeThread("pending-approval", {
294− branch: "skip-approval",
295− hasPendingApprovals: true,
296− }),
297− makeThread("snoozed", {
298− branch: "skip-snoozed",
299− snoozedUntil: "2026-08-29T00:00:00.000Z",
300− }),
301− ];
302− const fixture = yield* makeHarness({
303− snapshot: makeSnapshot(
304− [
305− makeThread("inactive", { branch: "inactive-feature" }),
306− makeThread("closed-pr", { linkedPullRequest }),
307− ...skipped,
308− ],
309− [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
310− ),
311− branchPullRequest: () => Effect.succeed(null),
312− pullRequestSummary: (input) =>
313− Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })),
314− });
315−
316− yield* Effect.gen(function* () {
317− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
318− yield* reactor.start();
319− assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0);
320−
321− yield* Deferred.succeed(fixture.activation, undefined);
322− yield* Queue.take(fixture.snapshotReads);
323− yield* reactor.drain;
324−
325− const commands = yield* Ref.get(fixture.commands);
326− assert.deepStrictEqual(
327− commands
328− .map(({ threadId, snapshotSequence, settledAt }) => ({
329− threadId,
330− snapshotSequence,
331− settledAt,
332− }))
333− .sort((left, right) => left.threadId.localeCompare(right.threadId)),
334− [
335− {
336− threadId: ThreadId.make("closed-pr"),
337− snapshotSequence: 1,
338− settledAt: "2026-08-20T00:00:00.000Z",
339− },
340− {
341− threadId: ThreadId.make("inactive"),
342− snapshotSequence: 1,
343− settledAt: "2026-08-20T00:00:00.000Z",
344− },
345− ],
346− );
347− assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
348− { cwd: "/workspace/project", branch: "inactive-feature" },
349− ]);
350− assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
351− { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 },
352− ]);
353− assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]);
354− }).pipe(Effect.provide(fixture.layer));
355− }),
356− ),
357− );
358−
359− it.effect("reevaluates inactivity and pull request state once per minute", () =>
360− Effect.scoped(
361− Effect.gen(function* () {
362− yield* TestClock.setTime(Date.parse(NOW));
363− const pullRequest = yield* Ref.make<"open" | "merged">("open");
364− const fixture = yield* makeHarness({
365− snapshot: makeSnapshot([
366− makeThread("at-boundary", {
367− latestUserMessageAt: "2026-08-25T12:00:00.000Z",
368− }),
369− makeThread("open-pr", {
370− branch: "saved-feature",
371− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
372− }),
373− ]),
374− branchPullRequest: () =>
375− Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))),
376− });
377−
378− yield* Effect.gen(function* () {
379− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
380− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
381− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
382−
383− yield* Ref.set(pullRequest, "merged");
384− yield* TestClock.adjust("1 minute");
385− yield* Queue.take(fixture.snapshotReads);
386− yield* reactor.drain;
387−
388− assert.deepStrictEqual(
389− (yield* Ref.get(fixture.commands))
390− .map((command) => command.threadId)
391− .sort((left, right) => left.localeCompare(right)),
392− [ThreadId.make("at-boundary"), ThreadId.make("open-pr")],
393− );
394− assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2);
395− }).pipe(Effect.provide(fixture.layer));
396− }),
397− ),
398− );
399−
400− it.effect("reevaluates immediately after a pull request merge", () =>
401− Effect.scoped(
402− Effect.gen(function* () {
403− yield* TestClock.setTime(Date.parse(NOW));
404− const periodicLookupStarted = yield* Deferred.make<void>();
405− const releasePeriodicLookup = yield* Deferred.make<void>();
406− const mergedThreadSettled = yield* Deferred.make<void>();
407− const branchLookupCount = yield* Ref.make(0);
408− const fixture = yield* makeHarness({
409− snapshot: makeSnapshot([
410− makeThread("merged-in-app", {
411− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
412− linkedPullRequest: {
413− projectId: PROJECT_ID,
414− repository: "owner/repository",
415− number: 42,
416− url: "https://example.test/owner/repository/pull/42",
417− },
418− }),
419− makeThread("slow-periodic-lookup", {
420− branch: "another-feature",
421− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
422− }),
423− ]),
424− branchPullRequest: () =>
425− Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe(
426− Effect.flatMap((count) =>
427− count === 1
428− ? Effect.succeed({ state: "open" as const, updatedAt: NOW })
429− : Deferred.succeed(periodicLookupStarted, undefined).pipe(
430− Effect.andThen(Deferred.await(releasePeriodicLookup)),
431− Effect.as({ state: "open" as const, updatedAt: NOW }),
432− ),
433− ),
434− ),
435− onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
436− });
437−
438− yield* Effect.gen(function* () {
439− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
440− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
441− yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
442− yield* Deferred.await(periodicLookupStarted);
443−
444− yield* fixture.publishMerge;
445− yield* Deferred.await(mergedThreadSettled);
446−
447− assert.deepStrictEqual(
448− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
449− [ThreadId.make("merged-in-app")],
450− );
451− yield* Deferred.succeed(releasePeriodicLookup, undefined);
452− yield* reactor.drain;
453− }).pipe(Effect.provide(fixture.layer));
454− }),
455− ),
456− );
457−
458− it.effect(
459− "settles branch threads on a pull request merge without waiting for the next sweep",
460− () =>
461− Effect.scoped(
462− Effect.gen(function* () {
463− yield* TestClock.setTime(Date.parse(NOW));
464− const state = yield* Ref.make<"open" | "merged">("open");
465− const mergedThreadSettled = yield* Deferred.make<void>();
466− const fixture = yield* makeHarness({
467− snapshot: makeSnapshot([
468− makeThread("branch-thread", {
469− branch: "saved-feature",
470− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
471− }),
472− ]),
473− branchPullRequest: () =>
474− Ref.get(state).pipe(
475− Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })),
476− ),
477− onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
478− });
479−
480− yield* Effect.gen(function* () {
481− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
482− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
483− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
484− assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), []);
485−
486− yield* Ref.set(state, "merged");
487− yield* fixture.publishMerge;
488− yield* Deferred.await(mergedThreadSettled);
489−
490− assert.deepStrictEqual(
491− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
492− [ThreadId.make("branch-thread")],
493− );
494− assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), ["/workspace/project"]);
495− yield* reactor.drain;
496− }).pipe(Effect.provide(fixture.layer));
497− }),
498− ),
499− );
500−
501− it.effect("a merge does not settle threads linked to an unrelated pull request", () =>
502− Effect.scoped(
503− Effect.gen(function* () {
504− yield* TestClock.setTime(Date.parse(NOW));
505− const mergedThreadSettled = yield* Deferred.make<void>();
506− const mergeLookupStarted = yield* Deferred.make<void>();
507− const releaseMergeLookup = yield* Deferred.make<void>();
508− const lookupCount = yield* Ref.make(0);
509− const fixture = yield* makeHarness({
510− snapshot: makeSnapshot([
511− makeThread("merged-in-app", {
512− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
513− linkedPullRequest: {
514− projectId: PROJECT_ID,
515− repository: "owner/repository",
516− number: 42,
517− url: "https://example.test/owner/repository/pull/42",
518− },
519− }),
520− makeThread("unrelated-linked", {
521− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
522− linkedPullRequest: {
523− projectId: PROJECT_ID,
524− repository: "owner/repository",
525− number: 99,
526− url: "https://example.test/owner/repository/pull/99",
527− },
528− }),
529− ]),
530− pullRequestSummary: (input) =>
531− Ref.updateAndGet(lookupCount, (count) => count + 1).pipe(
532− // The initial sweep looks up both linked threads; the merge
533− // sweep only looks up the unrelated one, since the merged
534− // thread settles from the event itself.
535− Effect.tap((count) =>
536− count === 3 ? Deferred.succeed(mergeLookupStarted, undefined) : Effect.void,
537− ),
538− Effect.tap((count) =>
539− count === 3 ? Deferred.await(releaseMergeLookup) : Effect.void,
540− ),
541− Effect.map(() => makePullRequestSummary({ ...input, state: "open" })),
542− ),
543− onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
544− });
545−
546− yield* Effect.gen(function* () {
547− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
548− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
549− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
550−
551− yield* fixture.publishMerge;
552− yield* Deferred.await(mergeLookupStarted);
553− yield* Deferred.await(mergedThreadSettled);
554− yield* Deferred.succeed(releaseMergeLookup, undefined);
555− yield* reactor.drain;
556−
557− assert.deepStrictEqual(
558− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
559− [ThreadId.make("merged-in-app")],
560− );
561− assert.deepStrictEqual(
562− (yield* Ref.get(fixture.summaryCalls))
563− .map((call) => call.number)
564− .toSorted((left, right) => left - right),
565− [42, 99, 99],
566− );
567− }).pipe(Effect.provide(fixture.layer));
568− }),
569− ),
570− );
571−
572− it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () =>
573− Effect.scoped(
574− Effect.gen(function* () {
575− yield* TestClock.setTime(Date.parse(NOW));
576− const state = yield* Ref.make<"merged" | "closed">("merged");
577− const firstLookupStarted = yield* Deferred.make<void>();
578− const releaseFirstLookup = yield* Deferred.make<void>();
579− const laterLookupStarted = yield* Deferred.make<void>();
580− const releaseLaterLookup = yield* Deferred.make<void>();
581− const lookupCount = yield* Ref.make(0);
582− const fixture = yield* makeHarness({
583− snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]),
584− settings: {
585− ...DEFAULT_SERVER_SETTINGS,
586− sidebarAutoSettleAfterDays: null,
587− sidebarAutoSettleOnMerge: true,
588− },
589− branchPullRequest: () =>
590− Ref.updateAndGet(lookupCount, (count) => count + 1).pipe(
591− Effect.tap((count) =>
592− count === 1
593− ? Deferred.succeed(firstLookupStarted, undefined)
594− : count === 3
595− ? Deferred.succeed(laterLookupStarted, undefined)
596− : Effect.void,
597− ),
598− Effect.tap((count) =>
599− count === 1
600− ? Deferred.await(releaseFirstLookup)
601− : count === 3
602− ? Deferred.await(releaseLaterLookup)
603− : Effect.void,
604− ),
605− Effect.andThen(Ref.get(state)),
606− Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })),
607− ),
608− });
609−
610− yield* Effect.gen(function* () {
611− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
612− yield* reactor.start();
613− yield* Deferred.succeed(fixture.activation, undefined);
614− yield* Queue.take(fixture.snapshotReads);
615− yield* Deferred.await(firstLookupStarted);
616−
617− yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false });
618− yield* Deferred.succeed(releaseFirstLookup, undefined);
619− yield* Queue.take(fixture.snapshotReads);
620− yield* reactor.drain;
621− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
622− assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2);
623−
624− yield* Ref.set(state, "closed");
625− yield* fixture.updateSettings({ enableAgentBrowserAccess: false });
626− yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 });
627− yield* Deferred.await(laterLookupStarted);
628− yield* Deferred.succeed(releaseLaterLookup, undefined);
629− yield* reactor.drain;
630−
631− assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3);
632− assert.strictEqual(yield* Ref.get(lookupCount), 3);
633− assert.deepStrictEqual(
634− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
635− [ThreadId.make("settings-thread")],
636− );
637− }).pipe(Effect.provide(fixture.layer));
638− }),
639− ),
640− );
641−
642− it.effect("keeps an unknown pull request active and continues with other candidates", () =>
643− Effect.scoped(
644− Effect.gen(function* () {
645− yield* TestClock.setTime(Date.parse(NOW));
646− const fixture = yield* makeHarness({
647− snapshot: makeSnapshot(
648− [
649− makeThread("lookup-failed", {
650− linkedPullRequest: {
651− projectId: LINKED_PROJECT_ID,
652− repository: "owner/repository",
653− number: 9,
654− url: "https://example.test/owner/repository/pull/9",
655− },
656− }),
657− makeThread("inactive-without-pr"),
658− ],
659− [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
660− ),
661− pullRequestSummary: () =>
662− Effect.fail(
663− new PullRequestOperationError({
664− operation: "summary",
665− detail: "host unavailable",
666− }),
667− ),
668− });
669−
670− yield* Effect.gen(function* () {
671− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
672− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
673−
674− assert.deepStrictEqual(
675− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
676− [ThreadId.make("inactive-without-pr")],
677− );
678− assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
679− }).pipe(Effect.provide(fixture.layer));
680− }),
681− ),
682− );
683−
684− it.effect("keeps threads active when their pull request project is unavailable", () =>
685− Effect.scoped(
686− Effect.gen(function* () {
687− yield* TestClock.setTime(Date.parse(NOW));
688− const linkedPullRequest = {
689− projectId: LINKED_PROJECT_ID,
690− repository: "owner/repository",
691− number: 10,
692− url: "https://example.test/owner/repository/pull/10",
693− } as const;
694− const fixture = yield* makeHarness({
695− snapshot: makeSnapshot(
696− [
697− makeThread("missing-own-project", {
698− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
699− linkedPullRequest,
700− }),
701− makeThread("missing-branch-project", { branch: "saved-feature" }),
702− ],
703− [makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
704− ),
705− pullRequestSummary: (input) =>
706− Effect.succeed(makePullRequestSummary({ ...input, state: "open" })),
707− });
708−
709− yield* Effect.gen(function* () {
710− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
711− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
712−
713− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
714− assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
715− { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 },
716− ]);
717− assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
718− }).pipe(Effect.provide(fixture.layer));
719− }),
720− ),
721− );
722−
723− it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () =>
724− Effect.scoped(
725− Effect.gen(function* () {
726− yield* TestClock.setTime(Date.parse(NOW));
727− const linkedPullRequest = {
728− projectId: LINKED_PROJECT_ID,
729− repository: "owner/repository",
730− number: 77,
731− url: "https://example.test/owner/repository/pull/77",
732− } as const;
733− const fixture = yield* makeHarness({
734− snapshot: makeSnapshot(
735− [
736− makeThread("branch-one", {
737− branch: "saved-feature",
738− worktreePath: "/deleted/worktree-one",
739− }),
740− makeThread("branch-two", {
741− branch: "saved-feature",
742− worktreePath: "/deleted/worktree-two",
743− }),
744− makeThread("linked-one", { linkedPullRequest }),
745− makeThread("linked-two", { linkedPullRequest }),
746− ],
747− [
748− makeProject(PROJECT_ID, "/workspace/project-root"),
749− makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"),
750− ],
751− ),
752− branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }),
753− pullRequestSummary: (input) =>
754− Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),
755− });
756−
757− yield* Effect.gen(function* () {
758− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
759− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
760−
761− assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
762− { cwd: "/workspace/project-root", branch: "saved-feature" },
763− ]);
764− assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
765− { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 },
766− ]);
767− assert.deepStrictEqual(
768− new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)),
769− new Set([
770− ThreadId.make("branch-one"),
771− ThreadId.make("branch-two"),
772− ThreadId.make("linked-one"),
773− ThreadId.make("linked-two"),
774− ]),
775− );
776− }).pipe(Effect.provide(fixture.layer));
777− }),
778− ),
779− );
780−
781− it.effect("looks up the branch pull request from a thread's live worktree", () =>
782− Effect.scoped(
783− Effect.gen(function* () {
784− yield* TestClock.setTime(Date.parse(NOW));
785− const fixture = yield* makeHarness({
786− snapshot: makeSnapshot(
787− [
788− makeThread("live-worktree", {
789− branch: "feature/live",
790− worktreePath: "/workspace/project-root/.worktrees/live",
791− }),
792− makeThread("deleted-worktree", {
793− branch: "feature/deleted",
794− worktreePath: "/workspace/project-root/.worktrees/deleted",
795− }),
796− ],
797− [makeProject(PROJECT_ID, "/workspace/project-root")],
798− ),
799− existingWorktreePaths: ["/workspace/project-root/.worktrees/live"],
800− });
801−
802− yield* Effect.gen(function* () {
803− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
804− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
805−
806− assert.deepStrictEqual(
807− new Set(yield* Ref.get(fixture.branchCalls)),
808− new Set([
809− { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" },
810− { cwd: "/workspace/project-root", branch: "feature/deleted" },
811− ]),
812− );
813− }).pipe(Effect.provide(fixture.layer));
814− }),
815− ),
816− );
817−
818− it.effect("carries the snapshot guard and survives a stale dispatch rejection", () =>
819− Effect.scoped(
820− Effect.gen(function* () {
821− yield* TestClock.setTime(Date.parse(NOW));
822− const fixture = yield* makeHarness({
823− snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]),
824− onDispatch: (command) =>
825− command.threadId === ThreadId.make("stale")
826− ? Effect.fail(
827− new OrchestrationCommandInvariantError({
828− commandType: command.type,
829− detail: "thread changed after settlement evaluation",
830− }),
831− )
832− : Effect.void,
833− });
834−
835− yield* Effect.gen(function* () {
836− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
837− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
838−
839− const firstSweep = yield* Ref.get(fixture.commands);
840− assert.strictEqual(
841− firstSweep.find((command) => command.threadId === ThreadId.make("stale"))
842− ?.snapshotSequence,
843− 1,
844− );
845− assert.strictEqual(
846− firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")),
847− true,
848− );
849−
850− yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
851− yield* Queue.take(fixture.snapshotReads);
852− yield* reactor.drain;
853− assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4);
854− }).pipe(Effect.provide(fixture.layer));
855− }),
856− ),
857− );
858−});
apps/server/src/orchestration/ThreadSettlementReactor.ts+0−249
@@ -1,249 +0,0 @@
1−import { CommandId } from "@t3tools/contracts";
2−import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3−import * as Cause from "effect/Cause";
4−import * as Context from "effect/Context";
5−import * as Crypto from "effect/Crypto";
6−import * as DateTime from "effect/DateTime";
7−import * as Effect from "effect/Effect";
8−import * as FileSystem from "effect/FileSystem";
9−import * as Layer from "effect/Layer";
10−import * as Schedule from "effect/Schedule";
11−import type * as Scope from "effect/Scope";
12−import * as Stream from "effect/Stream";
13−
14−import * as GitManager from "../git/GitManager.ts";
15−import * as PullRequestService from "../pullRequest/PullRequestService.ts";
16−import * as ServerSettings from "../serverSettings.ts";
17−import { forkParked } from "../serverActivation.ts";
18−import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
19−import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
20−import {
21− isAutoSettlementCandidate,
22− resolveAutoSettlementAt,
23− type SettlementPullRequest,
24−} from "./ThreadSettlementPolicy.ts";
25−
26−export class ThreadSettlementReactor extends Context.Service<
27− ThreadSettlementReactor,
28− {
29− readonly start: () => Effect.Effect<void, never, Scope.Scope>;
30− readonly drain: Effect.Effect<void>;
31− }
32−>()("t3/orchestration/ThreadSettlementReactor") {}
33−
34−export const make = Effect.gen(function* () {
35− const engine = yield* OrchestrationEngine.OrchestrationEngineService;
36− const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
37− const settingsService = yield* ServerSettings.ServerSettingsService;
38− const git = yield* GitManager.GitManager;
39− const pullRequests = yield* PullRequestService.PullRequestService;
40− const crypto = yield* Crypto.Crypto;
41− const fileSystem = yield* FileSystem.FileSystem;
42−
43− const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* (
44− mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
45− ) {
46− const snapshot = yield* snapshots.getShellSnapshot();
47− const now = DateTime.formatIso(yield* DateTime.now);
48− const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
49− // A merge event re-sweeps every candidate, not just the threads linked to
50− // the merged pull request: most threads carry no link and settle from
51− // their branch lookup, which would otherwise wait for the next minute's
52− // sweep on a possibly stale cached answer.
53− const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));
54− // Use the same cwd as the sidebar so both paths share GitManager's PR cache.
55− const lookupCwdByThreadId = new Map<string, string>();
56− yield* Effect.forEach(
57− candidates,
58− (thread) =>
59− Effect.gen(function* () {
60− const project = projects.get(thread.projectId);
61− if (project === undefined || thread.linkedPullRequest != null) return;
62− const worktreeExists =
63− thread.worktreePath !== null &&
64− (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false)));
65− lookupCwdByThreadId.set(
66− thread.id,
67− worktreeExists && thread.worktreePath !== null
68− ? thread.worktreePath
69− : project.workspaceRoot,
70− );
71− }),
72− { concurrency: 8, discard: true },
73− );
74− if (mergedPullRequest !== null) {
75− // The merge just confirmed a terminal state the lookup caches can still
76− // call open (branch answers live two minutes, the sweep runs every
77− // minute). Drop the swept checkouts' cached answers so the merge settles
78− // its branch threads now instead of on a later sweep. Threads linked to
79− // the merged pull request settle from the event itself below and need no
80− // lookup, so they are absent from this map by construction.
81− const cwds = [...new Set(lookupCwdByThreadId.values())];
82− yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), {
83− concurrency: 8,
84− discard: true,
85− });
86− }
87− const lookupKey = (thread: (typeof candidates)[number]) => {
88− if (thread.linkedPullRequest != null) {
89− return JSON.stringify([
90− "linked",
91− thread.linkedPullRequest.projectId,
92− thread.linkedPullRequest.repository,
93− thread.linkedPullRequest.number,
94− ]);
95− }
96− if (thread.branch === null) return JSON.stringify(["none", thread.id]);
97− const cwd = lookupCwdByThreadId.get(thread.id);
98− return JSON.stringify(
99− cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch],
100− );
101− };
102− const groups = Map.groupBy(candidates, lookupKey);
103−
104− const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* (
105− thread: (typeof candidates)[number],
106− ) {
107− if (thread.linkedPullRequest != null) {
108− // The event carries the merged state, so only the threads linked to
109− // that exact pull request settle from it. Every other linked thread
110− // falls through to a fresh summary lookup below: the merge sweep
111− // covers all candidates, and an unrelated merge must never settle
112− // them.
113− if (
114− mergedPullRequest !== null &&
115− thread.linkedPullRequest.projectId === mergedPullRequest.projectId &&
116− thread.linkedPullRequest.repository.toLowerCase() ===
117− mergedPullRequest.repository.toLowerCase() &&
118− thread.linkedPullRequest.number === mergedPullRequest.number
119− ) {
120− return {
121− state: "merged",
122− updatedAt: mergedPullRequest.mergedAt,
123− } satisfies SettlementPullRequest;
124− }
125− if (!projects.has(thread.linkedPullRequest.projectId)) {
126− return yield* Effect.die(new Error("linked pull request project not found"));
127− }
128− const summary = yield* pullRequests.summary(
129− {
130− projectId: thread.linkedPullRequest.projectId,
131− repository: thread.linkedPullRequest.repository,
132− number: thread.linkedPullRequest.number,
133− },
134− { recoverTransientFailure: false },
135− );
136− return {
137− state: summary.state,
138− updatedAt: summary.updatedAt,
139− } satisfies SettlementPullRequest;
140− }
141− if (thread.branch === null) return null;
142− const cwd = lookupCwdByThreadId.get(thread.id);
143− if (cwd === undefined) {
144− return yield* Effect.die(new Error("thread project not found"));
145− }
146− return yield* git.branchPullRequest({ cwd, branch: thread.branch });
147− });
148−
149− yield* Effect.forEach(
150− groups.values(),
151− (group) =>
152− Effect.gen(function* () {
153− const pullRequest = yield* pullRequestFor(group[0]!);
154− yield* Effect.forEach(
155− group,
156− (thread) =>
157− Effect.gen(function* () {
158− const settings = yield* settingsService.getSettings;
159− const decisionNow = DateTime.formatIso(yield* DateTime.now);
160− const settledAt = resolveAutoSettlementAt({
161− thread,
162− pullRequest,
163− now: decisionNow,
164− autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
165− autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
166− });
167− if (settledAt === null) {
168− return;
169− }
170− const uuid = yield* crypto.randomUUIDv4;
171− yield* engine.dispatch({
172− type: "thread.auto-settle",
173− commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),
174− threadId: thread.id,
175− snapshotSequence: snapshot.snapshotSequence,
176− settledAt,
177− });
178− }).pipe(
179− Effect.catchCause((cause) =>
180− Cause.hasInterruptsOnly(cause)
181− ? Effect.failCause(cause)
182− : Effect.logWarning("automatic thread settlement skipped", {
183− threadId: thread.id,
184− cause: Cause.pretty(cause),
185− }),
186− ),
187− ),
188− { discard: true },
189− );
190− }).pipe(
191− Effect.catchCause((cause) =>
192− Cause.hasInterruptsOnly(cause)
193− ? Effect.failCause(cause)
194− : Effect.logWarning("automatic thread settlement skipped", {
195− threadIds: group.map((thread) => thread.id),
196− cause: Cause.pretty(cause),
197− }),
198− ),
199− ),
200− { concurrency: 8, discard: true },
201− );
202− });
203−
204− const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) =>
205− sweep(mergedPullRequest).pipe(
206− Effect.catchCause((cause) =>
207− Cause.hasInterruptsOnly(cause)
208− ? Effect.failCause(cause)
209− : Effect.logWarning("automatic thread settlement sweep failed", {
210− cause: Cause.pretty(cause),
211− }),
212− ),
213− );
214− const worker = yield* makeDrainableWorker(() => runSweep(null));
215−
216− const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn(
217− "ThreadSettlementReactor.start",
218− )(function* () {
219− const settingsChanges = yield* settingsService.subscribeChanges;
220− const mergedPullRequests = yield* pullRequests.subscribeMerges;
221− const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
222− let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
223− let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
224− yield* forkParked(
225− Effect.gen(function* () {
226− yield* worker.enqueue(undefined);
227− yield* worker.drain;
228− }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
229− );
230− yield* forkParked(
231− Stream.runForEach(settingsChanges, (settings) => {
232− if (
233− settings.sidebarAutoSettleAfterDays === lastAfterDays &&
234− settings.sidebarAutoSettleOnMerge === lastOnMerge
235− ) {
236− return Effect.void;
237− }
238− lastAfterDays = settings.sidebarAutoSettleAfterDays;
239− lastOnMerge = settings.sidebarAutoSettleOnMerge;
240− return worker.enqueue(undefined);
241− }),
242− );
243− yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep));
244− });
245−
246− return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"];
247−});
248−
249−export const layer = Layer.effect(ThreadSettlementReactor, make);
apps/server/src/orchestration/decider.ts+1−0
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
33 type OrchestrationCommand,
44 type OrchestrationEvent,
55 type OrchestrationReadModel,
6+ type OrchestrationThread,
67 } from "@t3tools/contracts/legacy-orchestration";
78 import * as DateTime from "effect/DateTime";
89 import * 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
647647 const findEventAfter = SqlSchema.findOneOption({
648648 Request: HasEventAfterRequestSchema,
649649 Result: Schema.Struct({ sequence: Schema.Number }),
650− execute: (request) =>
651− sql`
652− SELECT sequence
653− FROM orchestration_events
654− WHERE aggregate_kind = ${request.aggregateKind}
655− AND stream_id = ${request.aggregateId}
656− AND event_type = ${request.type}
657− AND sequence > ${request.sequenceExclusive}
658− LIMIT 1
659− `,
650+ execute: (request) => sql`
651+ SELECT sequence
652+ FROM orchestration_events
653+ WHERE aggregate_kind = ${request.aggregateKind}
654+ AND stream_id = ${request.aggregateId}
655+ AND ${sql.and([
656+ sql`sequence > ${request.sequenceExclusive}`,
657+ ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]),
658+ ])}
659+ LIMIT 1
660+ `,
660661 });
661662
662663 const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) =>
apps/server/src/persistence/Services/OrchestrationEventStore.ts+3−2
@@ -123,7 +123,8 @@ export interface OrchestrationEventStoreShape { · 122 unmodified lines
123123 readonly afterSequence?: number;
124124 }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>;
125125 /**
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.
127128 *
128129 * Used during replay to tell whether a later event supersedes the one being
129130 * applied, without streaming the rest of the log.
@@ -131,7 +132,7 @@ export interface OrchestrationEventStoreShape { · 1 unmodified line
131132 readonly hasEventAfter: (input: {
132133 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
133134 readonly aggregateId: string;
134− readonly type: OrchestrationEvent["type"];
135+ readonly type?: OrchestrationEvent["type"];
135136 readonly sequenceExclusive: number;
136137 }) => Effect.Effect<boolean, OrchestrationEventStoreError>;
137138 }
apps/server/src/provider/Drivers/ClaudeDriver.ts+24−19
@@ -40,6 +40,7 @@ import { · 39 unmodified lines
4040 } from "../Layers/ClaudeProvider.ts";
4141 import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
4242 import * as ModelManifest from "../ModelManifest.ts";
43+import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
4344 import {
4445 defaultProviderContinuationIdentity,
4546 type ProviderDriver,
@@ -114,6 +115,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 68 unmodified lines
114115 const serverSettings = yield* ServerSettingsService;
115116 const eventLoggers = yield* ProviderEventLoggers;
116117 const modelManifest = yield* ModelManifest.ModelManifest;
118+ const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog));
117119 const processEnv = mergeProviderInstanceEnvironment(environment);
118120 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
119121 driverKind: DRIVER_KIND,
@@ -157,7 +159,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 37 unmodified lines
157159 }),
158160 ),
159161 );
160− const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv);
162+ const textGeneration = yield* makeClaudeTextGeneration(
163+ effectiveConfig,
164+ processEnv,
165+ modelCatalog,
166+ );
161167
162168 // Per-instance capabilities cache: keyed on binary + resolved HOME so
163169 // account-specific probes never share auth metadata across instances.
@@ -171,22 +177,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 7 unmodified lines
171177 });
172178 const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);
173179
174− // Kick the TTL-gated manifest refresh in the background and classify
175− // with the in-memory manifest, so a slow or hung fetch never delays the
176− // provider check. A refresh that lands mid-probe applies on the next one.
180+ // Start the TTL-gated refresh without delaying provider readiness. The
181+ // next check observes a remote manifest after the background fetch lands.
177182 const checkProvider = modelManifest.refreshInBackground.pipe(
178183 Effect.andThen(
179− Effect.zipWith(
180− checkClaudeProviderStatus(
181− effectiveConfig,
182− () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
183− processEnv,
184− cwd,
184+ modelManifest.current.pipe(
185+ Effect.flatMap((manifest) =>
186+ checkClaudeProviderStatus(
187+ effectiveConfig,
188+ () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
189+ processEnv,
190+ cwd,
191+ resolveClaudeModelCatalog(manifest),
192+ ),
185193 ),
186− modelManifest.current,
187− (draft, manifest) =>
188− stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
189− { concurrent: true },
194+ Effect.map(stampIdentity),
190195 ),
191196 ),
192197 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
@@ -201,11 +206,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 8 unmodified lines
201206 streamSettings: snapshotSettings.streamSettings,
202207 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
203208 initialSnapshot: (settings) =>
204− Effect.zipWith(
205− makePendingClaudeProvider(settings.provider),
206− modelManifest.current,
207− (draft, manifest) =>
208− stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
209+ modelManifest.current.pipe(
210+ Effect.flatMap((manifest) =>
211+ makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)),
212+ ),
213+ Effect.map(stampIdentity),
209214 ),
210215 checkProvider,
211216 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
apps/server/src/provider/acp/AcpNativeLogging.ts+44−6
@@ -1,5 +1,5 @@
11 import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
2−import { causeErrorTag } from "@t3tools/shared/observability";
2+import { causeErrorTag, errorTag } from "@t3tools/shared/observability";
33 import * as Cause from "effect/Cause";
44 import * as Crypto from "effect/Crypto";
55 import * as DateTime from "effect/DateTime";
@@ -13,6 +13,40 @@ import { · 7 unmodified lines
1313 } from "../NativeProtocolLogging.ts";
1414 import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
1515
16+const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
17+
18+function structuralMethod(value: string): string {
19+ return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown";
20+}
21+
22+function summarizePayload(payload: unknown): Readonly<Record<string, unknown>> {
23+ if (payload === null) return { valueType: "null" };
24+ if (typeof payload === "string") {
25+ return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength };
26+ }
27+ if (payload instanceof Uint8Array) {
28+ return { valueType: "bytes", byteLength: payload.byteLength };
29+ }
30+ if (Array.isArray(payload)) {
31+ return { valueType: "array", itemCount: payload.length };
32+ }
33+ if (typeof payload !== "object") {
34+ return { valueType: typeof payload };
35+ }
36+
37+ try {
38+ const record = payload as Record<string, unknown>;
39+ return {
40+ valueType: "object",
41+ fieldCount: Object.keys(record).length,
42+ ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}),
43+ ...(typeof record.tag === "string" ? { method: structuralMethod(record.tag) } : {}),
44+ };
45+ } catch {
46+ return { valueType: "object" };
47+ }
48+}
49+
1650 function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) {
1751 return {
1852 method: structuralProtocolMethod(event.method),
@@ -137,11 +171,15 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" · 118 unmodified lines
137171 protocolLogging: {
138172 logIncoming: true,
139173 logOutgoing: true,
140− logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) =>
141− writeNativeAcpLog({
142− kind: "protocol",
143− payload: formatAcpProtocolLogPayload(event),
144− }),
174+ logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => {
175+ const filtered = filterTransientProtocolLog(event);
176+ return filtered
177+ ? writeNativeAcpLog({
178+ kind: "protocol",
179+ payload: formatAcpProtocolLogPayload(filtered),
180+ })
181+ : Effect.void;
182+ },
145183 } satisfies NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"]>,
146184 }
147185 : {}),
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
55 const testState = vi.hoisted(() => ({
66 resources: [] as Array<unknown>,
77 assetState: "success" as "success" | "loading" | "failure",
8− editorEnvironmentIds: [] as Array<unknown>,
9− remoteEnvironmentIds: [] as Array<unknown>,
108 }));
119
1210 vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
1311 vi.mock("../assets/assetUrls", () => ({
12+ useAssetUrlRefresh: () => vi.fn(),
1413 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
1514 testState.resources.push(resource);
1615 if (testState.assetState === "loading") return { _tag: "Loading" };
@@ -25,23 +24,23 @@ vi.mock("../state/session", async (importOriginal) => ({ · 8 unmodified lines
2524 ...(await importOriginal<typeof import("../state/session")>()),
2625 usePreparedConnection: () => ({ _tag: "Loading" }),
2726 }));
28−vi.mock("../editorPreferences", () => ({
29− useOpenInPreferredEditor: (environmentId: unknown) => {
30− testState.editorEnvironmentIds.push(environmentId);
31− return vi.fn();
32− },
33− usePreferredEditor: () => ["vscode", vi.fn()],
27+vi.mock("../state/entities", () => ({
28+ readThreadShell: () => null,
29+ useProjects: () => [],
3430 }));
3531 vi.mock("../remoteOpen", () => ({
36− useRemoteOpenResolution: (environmentId: unknown) => {
37− testState.remoteEnvironmentIds.push(environmentId);
38− return {
39− isResolved: true,
40− state: { mode: "local-exec", localExecutionUnavailableReason: null },
41− };
42− },
32+ useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
33+}));
34+vi.mock("../editorPreferences", () => ({
35+ useOpenInPreferredEditor: () => vi.fn(),
36+ usePreferredEditor: () => [null, vi.fn()],
37+}));
38+vi.mock("~/lib/openPullRequestLink", () => ({
39+ findProjectForChangeRequest: () => undefined,
40+ matchesLinkedPullRequestUrl: () => false,
41+ parseChangeRequestUrl: () => null,
42+ useOpenChangeRequestLink: () => vi.fn(),
4343 }));
44−vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
4544
4645 import ChatMarkdown from "./ChatMarkdown";
4746 import { FileMarkdownPreview } from "./files/FileMarkdownPreview";
@@ -93,8 +92,6 @@ describe("ChatMarkdown workspace images", () => { · 45 unmodified lines
9392 beforeEach(() => {
9493 testState.resources = [];
9594 testState.assetState = "success";
96− testState.editorEnvironmentIds = [];
97− testState.remoteEnvironmentIds = [];
9895 });
9996
10097 it.each([
@@ -110,52 +107,55 @@ describe("ChatMarkdown workspace images", () => { · 9 unmodified lines
110107
111108 expect(testState.resources).toEqual([
112109 {
113− _tag: "workspace-file",
110+ _tag: "media-file",
114111 threadId: threadRef.threadId,
115112 path: expectedPath,
116113 },
117114 ]);
118115 });
119116
120− it("loads relative workspace paths through signed asset URLs", () => {
121− const html = render("![relative](.t3/workspace-image.svg)");
117+ it("loads every Windows workspace path form through a signed asset URL", () => {
118+ const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg";
119+ const html = render(
120+ [
121+ "![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+ );
122127
123128 expect(testState.resources).toEqual([
124129 {
125− _tag: "workspace-file",
130+ _tag: "media-file",
126131 threadId: threadRef.threadId,
127132 path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg",
128133 },
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+ },
129141 ]);
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");
131146 });
132147
133− it("preserves Windows drive links through markdown sanitization", () => {
134− const html = render(String.raw`[Open](C:\Users\shawn\project\src\main.ts)`);
135− expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"');
136− expect(html).toContain("chat-markdown-file-link");
137− });
148+ it("normalizes a drive-absolute src in raw image HTML", () => {
149+ const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`);
138150
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− );
146151 expect(testState.resources).toEqual([
147152 {
148− _tag: "workspace-file",
149− threadId: threadRef.threadId,
150− path: "C:/Users/shawn/project/.t3/workspace-image.svg",
151− },
152− {
153− _tag: "workspace-file",
153+ _tag: "media-file",
154154 threadId: threadRef.threadId,
155155 path: "D:/screens/workspace-image.svg",
156156 },
157157 ]);
158− expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(2);
158+ expect(html).toContain("https://signed.test/workspace-image.svg");
159159 });
160160
161161 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
261261
262262 it("uses a static bounded-width placeholder while a signed asset URL loads", () => {
263263 testState.assetState = "loading";
264+
264265 const html = render("![loading](.t3/workspace-image.svg)");
265266 const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1];
266267
@@ -269,25 +270,31 @@ describe("ChatMarkdown workspace images", () => { · 2 unmodified lines
269270 expect(className?.split(" ")).toContain("w-64");
270271 });
271272
272− it("keeps remote images directly loadable", () => {
273− const html = render("![remote](https://example.com/image.png)");
273+ it("never passes a workspace source to a raw image when thread context is unavailable", () => {
274+ const html = renderWithoutThread(
275+ "![file URL](file:///C:/Users/shawn/project/workspace-image.svg)",
276+ );
277+
274278 expect(testState.resources).toEqual([]);
275− expect(html).toContain('src="https://example.com/image.png"');
276− expect(html).toContain("max-h-[30rem]");
279+ expect(html).toContain("Image unavailable");
280+ expect(html).not.toContain("file://");
277281 });
278282
279− it("uses the markdown owner's environment for file actions", () => {
280− const owningEnvironmentId = EnvironmentId.make("env-pull-request");
283+ it("blocks unsupported image schemes instead of passing them to a raw image", () => {
284+ const html = render("![unsupported](content://media/image/1)");
281285
282− renderToStaticMarkup(
283− <ChatMarkdown
284− cwd="/workspace/project"
285− environmentId={owningEnvironmentId}
286− text="[Open](src/main.ts)"
287− />,
288− );
286+ expect(testState.resources).toEqual([]);
287+ expect(html).toContain("Image unavailable");
288+ expect(html).not.toContain("content://");
289+ });
289290
290− expect(testState.editorEnvironmentIds).toEqual([owningEnvironmentId]);
291− expect(testState.remoteEnvironmentIds).toEqual([owningEnvironmentId]);
291+ it("keeps remote images directly loadable", () => {
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");
292299 });
293300 });
apps/web/src/components/ChatView.logic.test.ts+2−2
@@ -24,10 +24,10 @@ import { · 23 unmodified lines
2424 deriveComposerSendState,
2525 dismissBranchMismatchForSession,
2626 getStartedThreadModelChangeBlockReason,
27+ isVideoPreviewRequestCurrent,
28+ hasEnvironmentReconnectWarningGraceElapsed,
2729 hasServerAcknowledgedLocalDispatch,
2830 isBranchMismatchDismissedForSession,
29− isVideoPreviewRequestCurrent,
30− loadVideoPreviewUrl,
3131 reconcileMountedTerminalThreadIds,
3232 reconcileRetainedMountedThreadIds,
3333 resolveDraftPromotionNavigationTarget,
apps/web/src/components/ChatView.logic.ts+6−0
@@ -17,6 +17,12 @@ import { · 16 unmodified lines
1717 type RunId,
1818 } from "@t3tools/contracts";
1919 import * as DateTime from "effect/DateTime";
20+import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
21+import {
22+ squashAtomCommandFailure,
23+ type AtomCommandResult,
24+} from "@t3tools/client-runtime/state/runtime";
25+import { videoMimeType } from "@t3tools/shared/video";
2026 import {
2127 appendCodexArtifactTemplateUsePrompt,
2228 codexArtifactTemplateUsePrompt,
apps/web/src/components/ChatView.tsx+151−91
@@ -39,11 +39,6 @@ import { · 38 unmodified lines
3939 threadWokeAt,
4040 } from "@t3tools/client-runtime/state/thread-settled";
4141 import { useThreadActions } from "../hooks/useThreadActions";
42−import {
43− resolveDisplayedThreadPr,
44− threadChangeRequestSnapshotsAtom,
45− useLinkedThreadPullRequest,
46−} from "./ThreadStatusIndicators";
4742 import {
4843 deriveThreadActivityRun,
4944 deriveLatestThreadRun,
@@ -74,7 +69,12 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; · 24 unmodified lines
7469 import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
7570 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
7671 import { truncate } from "@t3tools/shared/String";
77−import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
72+import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
73+import {
74+ getTerminalLabel,
75+ nextTerminalId,
76+ resolveTerminalSessionLabel,
77+} from "@t3tools/shared/terminalLabels";
7878 import { Debouncer } from "@tanstack/react-pacer";
7979 import { useAtomValue } from "@effect/atom-react";
8080 import {
@@ -325,8 +325,14 @@ import { · 244 unmodified lines
325325 ThreadErrorBanner,
326326 } from "./chat/ThreadErrorBanner";
327327 import { resolveThreadPr } from "./ThreadStatusIndicators";
328−import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
329328 import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl";
329+import {
330+ resolveDisplayedThreadPr,
331+ threadPullRequestRefreshSource,
332+ threadChangeRequestSnapshotsAtom,
333+ useLinkedThreadPullRequest,
334+} from "./ThreadStatusIndicators";
335+import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
330336 import { ComposerSurface } from "./chat/ComposerSurface";
331337 import { resolveThreadSyncPhase } from "../threadSync";
332338 import {
@@ -1338,6 +1344,7 @@ function ChatViewContent(props: ChatViewProps) { · 1005 unmodified lines
13381344 });
13391345 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
13401346 reportFailure: false,
1347+ refresh: true,
13411348 });
13421349 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
13431350 reportFailure: false,
@@ -1907,7 +1914,7 @@ function ChatViewContent(props: ChatViewProps) { · 563 unmodified lines
19071914 >({});
19081915 const activePullRequestSurfaceId =
19091916 activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined;
1910− const handlePullRequestTabStatusChange = useCallback(
1917+ const updatePullRequestTabStatusFromPanel = useCallback(
19111918 (status: PullRequestTabStatus) => {
19121919 const id = activePullRequestSurfaceId;
19131920 if (id === undefined) return;
@@ -1915,6 +1922,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
19151922 },
19161923 [activePullRequestSurfaceId],
19171924 );
1925+ const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
1926+ const sidebarPrRefreshKeyRef = useRef<string | null>(null);
19181927 const activeFileSurface =
19191928 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
19201929 const activePreviewState = useThreadPreviewState(activeThreadRef);
@@ -2758,14 +2767,8 @@ function ChatViewContent(props: ChatViewProps) { · 837 unmodified lines
27582767 toastManager.add({ type: "error", title: "The environment is not connected." });
27592768 return;
27602769 }
2761− const videoMime = videoMimeType(attachment);
2762− const isVideo = videoMime !== null;
2770+ const isVideo = videoMimeType(attachment) !== null;
27632771 const action = isVideo ? "play" : "download";
2764− const videoPreviewAbortController = isVideo ? new AbortController() : null;
2765− if (isVideo) {
2766− videoPreviewAbortControllerRef.current?.abort();
2767− videoPreviewAbortControllerRef.current = videoPreviewAbortController;
2768− }
27692772 const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0;
27702773 const isCurrentRequest = () =>
27712774 !isVideo ||
@@ -2775,75 +2778,37 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines
27752778 videoPreviewRequestId,
27762779 videoPreviewRequestIdRef.current,
27772780 );
2778− const finishVideoPreviewRequest = () => {
2779− if (videoPreviewRequestIdRef.current === videoPreviewRequestId) {
2780− setOpeningVideoAttachmentId(null);
2781− videoPreviewAbortControllerRef.current = null;
2782− }
2783− };
27842781 if (isVideo) setOpeningVideoAttachmentId(attachment.id);
27852782
2786− // fileName and mimeType ride in the signed claims so videos render
2787− // inline while other files keep their real download name and type.
2788− const result = await createAttachmentAssetUrl({
2789− environmentId,
2790− input: {
2791− resource: {
2792− _tag: "attachment",
2793− attachmentId: attachment.id,
2794− fileName: attachment.name,
2795− mimeType: videoMime ?? attachment.mimeType,
2796− },
2797− },
2798− });
2799− if (!isCurrentRequest()) {
2800− finishVideoPreviewRequest();
2801− return;
2802− }
2803− if (result._tag === "Failure") {
2804− finishVideoPreviewRequest();
2805− const error = squashAtomCommandFailure(result);
2806− toastManager.add({
2807− type: "error",
2808− title: "Could not " + action + " " + attachment.name,
2809− description: error instanceof Error ? error.message : "The attachment is unavailable.",
2783+ try {
2784+ const url = await resolveFileAttachmentUrl({
2785+ attachment,
2786+ environmentId,
2787+ httpBaseUrl: connection.httpBaseUrl,
2788+ createAssetUrl: createAttachmentAssetUrl,
28102789 });
2811− return;
2812− }
2813−
2814− const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
2815− if (!url) {
2816− finishVideoPreviewRequest();
2817− toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name });
2818− return;
2819− }
2820− if (isVideo) {
2821− try {
2822− const previewUrl = await loadVideoPreviewUrl(url, videoPreviewAbortController?.signal);
2823− if (!isCurrentRequest()) {
2824− revokeBlobPreviewUrl(previewUrl);
2825− return;
2826− }
2790+ if (!isCurrentRequest()) return;
2791+ if (isVideo) {
28272792 setExpandedImage({
2828− images: [{ src: previewUrl, name: attachment.name, type: "video" }],
2793+ images: [{ src: url, name: attachment.name, type: "video" }],
28292794 index: 0,
28302795 });
2831− } catch (error) {
2832− if (!isCurrentRequest()) return;
2833− toastManager.add({
2834− type: "error",
2835− title: "Could not play " + attachment.name,
2836− description: error instanceof Error ? error.message : "The attachment is unavailable.",
2837− });
2838− } finally {
2839− finishVideoPreviewRequest();
2796+ return;
28402797 }
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);
28422811 }
2843− const anchor = document.createElement("a");
2844− anchor.href = url;
2845− anchor.download = attachment.name;
2846− anchor.click();
28472812 },
28482813 [createAttachmentAssetUrl, environmentId, routeThreadKey],
28492814 );
@@ -4885,8 +4850,14 @@ function ChatViewContent(props: ChatViewProps) { · 2035 unmodified lines
48854850 );
48864851 // The server-projected settled state keeps the banner and sidebar in sync.
48874852 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
4888− const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4889− const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
4853+ const autoSettleAfterDays = useEnvironmentSettings(
4854+ environmentId,
4855+ (settings) => settings.sidebarAutoSettleAfterDays,
4856+ );
4857+ const autoSettleOnMerge = useEnvironmentSettings(
4858+ environmentId,
4859+ (settings) => settings.sidebarAutoSettleOnMerge,
4860+ );
48904861 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
48914862 // The thread's own change request, placed against the project it belongs to.
48924863 const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null;
@@ -4902,6 +4873,97 @@ function ChatViewContent(props: ChatViewProps) { · 9 unmodified lines
49024873 linkedPullRequest: linkedThreadPullRequest,
49034874 linkedPullRequestStatus,
49044875 });
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]);
49054967 // The right panel offers the thread's own change request, so it can only offer it once the
49064968 // branch has one; until then the picker says so rather than opening an empty panel.
49074969 const addPullRequestSurface = useCallback(() => {
@@ -4950,21 +5012,13 @@ function ChatViewContent(props: ChatViewProps) { · 42 unmodified lines
49505012 if (activeThreadRef === null || activeThreadWokeAt === null) return;
49515013 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
49525014 }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
4953− // Mirror of the sidebar's Woke pill for the open thread. It uses the same
4954− // visit comparison and change request settle rule.
5015+ // Mirror of the sidebar's Woke pill for the open thread.
49555016 const activeThreadLastVisitedAt = useUiStateStore((store) =>
49565017 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
49575018 );
49585019 const activeThreadWokeVisible = useMemo(() => {
49595020 if (activeThreadWokeAt === null) return false;
4960− if (
4961− changeRequestAutoSettles(activeThreadChangeRequest, {
4962− autoSettleOnMerge,
4963− thread: activeThreadShell,
4964− })
4965− ) {
4966− return false;
4967− }
5021+ if (activeThreadShell?.settledOverride === "settled") return false;
49685022 const wokeAtMs = Date.parse(activeThreadWokeAt);
49695023 if (Number.isNaN(wokeAtMs)) return false;
49705024 // Having the thread open counts as a visit at completedAt (the effect
@@ -4984,7 +5038,6 @@ function ChatViewContent(props: ChatViewProps) { · 13 unmodified lines
49845038 }, [
49855039 activeLatestRun?.completedAt,
49865040 activeThreadLastVisitedAt,
4987− activeThreadChangeRequest,
49885041 activeThreadShell,
49895042 activeThreadWokeAt,
49905043 autoSettleOnMerge,
@@ -5006,8 +5059,6 @@ function ChatViewContent(props: ChatViewProps) { · 15 unmodified lines
50065059 nowMinute,
50075060 supportsSettlement,
50085061 ]);
5009− const activeThreadSettled =
5010− supportsSettlement && activeThreadShell?.settledOverride === "settled";
50115062 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
50125063 reportFailure: false,
50135064 });
@@ -5394,6 +5445,13 @@ function ChatViewContent(props: ChatViewProps) { · 380 unmodified lines
53945445 });
53955446 if (!command) return;
53965447
5448+ if (command === "thread.copyReference") {
5449+ event.preventDefault();
5450+ event.stopPropagation();
5451+ if (!event.repeat) copyActiveThreadReference();
5452+ return;
5453+ }
5454+
53975455 if (command === "thread.settle") {
53985456 event.preventDefault();
53995457 event.stopPropagation();
@@ -5561,6 +5619,7 @@ function ChatViewContent(props: ChatViewProps) { · 161 unmodified lines
55615619 supportsPinning,
55625620 supportsSettlement,
55635621 confirmAndUnpinThread,
5622+ copyActiveThreadReference,
55645623 toggleRightPanel,
55655624 toggleThreadPanel,
55665625 toggleTerminalVisibility,
@@ -7512,6 +7571,7 @@ function ChatViewContent(props: ChatViewProps) { · 1945 unmodified lines
75127571 }
75137572 >
75147573 <DraftHeroHeadline
7574+ draftId={draftId}
75157575 activeProjectRef={activeProjectRef}
75167576 activeProjectTitle={activeProject?.title ?? null}
75177577 />
apps/web/src/components/Sidebar.tsx+30−30
@@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- · 18 unmodified lines
1919 import { CSS } from "@dnd-kit/utilities";
2020 import {
2121 canSnooze,
22+ effectiveSettled,
2223 effectiveSnoozed,
2324 threadWokeAt,
2425 } from "@t3tools/client-runtime/state/thread-settled";
@@ -99,7 +100,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; · 74 unmodified lines
99100 import { useHandleNewThread } from "../hooks/useHandleNewThread";
100101 import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
101102 import { startNewThreadFromContext } from "../lib/chatThreadActions";
102−import { useClientSettings } from "../hooks/useSettings";
103+import { useClientSettings, usePrimarySettings } from "../hooks/useSettings";
103104 import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
104105 import { useLocalStorage } from "../hooks/useLocalStorage";
105106 import { useNowMinute } from "../hooks/useNowMinute";
@@ -171,7 +172,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 65 unmodified lines
171172 import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
172173 import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
173174 import {
174− deriveProviderInstanceEntries,
175+ deriveProviderEntriesByEnvironment,
175176 shouldShowInstanceBadge,
176177 type ProviderInstanceEntry,
177178 } from "../providerInstances";
@@ -851,10 +852,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 673 unmodified lines
851852 const isWoke =
852853 wokeAtDate !== null &&
853854 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
854− !changeRequestAutoSettles(pr, {
855− autoSettleOnMerge: props.autoSettleOnMerge,
856− thread,
857− });
855+ thread.settledOverride !== "settled";
858856 // In-flight rows (working, or waiting on approval/input) fade as a whole:
859857 // there is nothing for the user to do yet, so prominence is reserved for
860858 // rows that need a human — done (unread), read-but-unsettled, failed, and
@@ -1772,6 +1770,8 @@ export default function Sidebar() { · 911 unmodified lines
17721770 const { isMobile, setOpenMobile } = useSidebar();
17731771 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
17741772 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1773+ const autoSettleAfterDays = usePrimarySettings((s) => s.sidebarAutoSettleAfterDays);
1774+ const autoSettleOnMerge = usePrimarySettings((s) => s.sidebarAutoSettleOnMerge);
17751775 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
17761776 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
17771777 const timestampFormat = useClientSettings((s) => s.timestampFormat);
@@ -1925,15 +1925,20 @@ export default function Sidebar() { · 147 unmodified lines
19251925 () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),
19261926 [sidebarProjectSortOrder, threads, unsortedProjectGroups],
19271927 );
1928− const serverProviders = useAtomValue(primaryServerProvidersAtom);
1929− const providerEntryByInstanceId = useMemo(
1928+ const projectGroupsRef = useRef(projectGroups);
1929+ projectGroupsRef.current = projectGroups;
1930+ const serverConfigs = useAtomValue(environmentServerConfigsAtom);
1931+ // Threads on non-primary environments (T3 Connect, hosted) resolve their
1932+ // provider entry from their own environment's config: default instance ids
1933+ // are driver slugs, so a flat map would collide across environments.
1934+ const providerEntriesByEnvironment = useMemo(
19301935 () =>
1931− new Map(
1932− deriveProviderInstanceEntries(serverProviders).map(
1933− (entry) => [entry.instanceId as string, entry] as const,
1936+ deriveProviderEntriesByEnvironment(
1937+ [...serverConfigs].map(
1938+ ([environmentId, config]) => [environmentId, config.providers] as const,
19341939 ),
19351940 ),
1936− [serverProviders],
1941+ [serverConfigs],
19371942 );
19381943 const projectCwdByKey = useMemo(
19391944 () =>
@@ -2081,11 +2086,8 @@ export default function Sidebar() { · 141 unmodified lines
20812086 clearSelection();
20822087 }, [clearSelection, projectScopeKey]);
20832088
2084− const handleProjectSettings = useCallback(
2085− (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
2086− event.preventDefault();
2087− event.stopPropagation();
2088− dispatchProjectScopeMenu({ type: "project-settings-opened" });
2089+ const openProjectSettings = useCallback(
2090+ (projectGroup: SidebarProjectSnapshot) => {
20892091 if (isMobile) {
20902092 setOpenMobile(false);
20912093 }
@@ -2118,7 +2120,6 @@ export default function Sidebar() { · 26 unmodified lines
21182120 // the partition works directly off live shells: no archived-snapshot
21192121 // merging, no optimistic holds. Archived threads remain hidden here —
21202122 // archive keeps its original "remove from sidebar" meaning.
2121− const serverConfigs = useAtomValue(environmentServerConfigsAtom);
21222123 const {
21232124 pinnedThreads,
21242125 reorderablePinnedKeys,
@@ -2133,6 +2134,7 @@ export default function Sidebar() { · 8 unmodified lines
21332134 // memo exactly at the next wake boundary.
21342135 void snoozeWakeTick;
21352136 const preciseNow = new Date().toISOString();
2137+ const now = `${nowMinute}:00.000Z`;
21362138 // Subagent child threads live in the parent's Agents surface, not the
21372139 // sidebar roster (v2 models them as real threads with lineage).
21382140 const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys);
@@ -2209,16 +2211,7 @@ export default function Sidebar() { · 70 unmodified lines
22092211 settledThreads: sortSettledThreadsForSidebar(settled),
22102212 snoozeNow: preciseNow,
22112213 };
2212− }, [
2213− autoSettleAfterDays,
2214− autoSettleOnMerge,
2215− changeRequestSnapshotByKey,
2216− nowMinute,
2217− scopedProjectKeys,
2218− serverConfigs,
2219− snoozeWakeTick,
2220− threads,
2221− ]);
2214+ }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
22222215
22232216 const threadSearchInputRef = useRef<HTMLInputElement>(null);
22242217 const [threadSearchQuery, setThreadSearchQuery] = useState("");
@@ -3788,7 +3781,10 @@ export default function Sidebar() { · 1563 unmodified lines
37883781 ) ?? null
37893782 }
37903783 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
3791− providerEntryByInstanceId={providerEntryByInstanceId}
3784+ providerEntryByInstanceId={
3785+ providerEntriesByEnvironment.get(thread.environmentId) ??
3786+ EMPTY_PROVIDER_ENTRIES
3787+ }
37923788 isHighlighted={activeSearchResultIndex === index}
37933789 isRouteActive={routeThreadKey === threadKey}
37943790 resultId={`sidebar-thread-search-result-${index}`}
@@ -3894,7 +3890,10 @@ export default function Sidebar() { · 99 unmodified lines
38943890 `${thread.environmentId}:${thread.projectId}`,
38953891 ) ?? null
38963892 }
3897− providerEntryByInstanceId={providerEntryByInstanceId}
3893+ providerEntryByInstanceId={
3894+ providerEntriesByEnvironment.get(thread.environmentId) ??
3895+ EMPTY_PROVIDER_ENTRIES
3896+ }
38983897 timestampFormat={timestampFormat}
38993898 onThreadClick={handleThreadClick}
39003899 onThreadActivate={navigateToThread}
@@ -4099,4 +4098,5 @@ export default function Sidebar() { · 198 unmodified lines
40994098 <SidebarChromeFooter />
41004099 </>
41014100 );
4101+ const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();
41024102 }
apps/web/src/components/chat/ChatComposer.tsx+1−0
@@ -172,6 +172,7 @@ import { · 171 unmodified lines
172172 submitComposerDraft,
173173 } from "./composerSubmission";
174174 import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
175+import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
175176
176177 function ComposerVideoThumbnail({ file }: { file: File }) {
177178 const setVideo = useCallback(
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+10−9
@@ -1580,7 +1580,7 @@ describe("deriveMessagesTimelineRows", () => { · 1579 unmodified lines
15801580 "assistant-final-entry",
15811581 "user-followup-entry",
15821582 "working-indicator-row",
1583− "thinking-indicator-row",
1583+ "live-activity-row",
15841584 ]);
15851585 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
15861586 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
@@ -1618,14 +1618,15 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines
16181618 revertTurnCountByUserMessageId: new Map(),
16191619 });
16201620
1621− // The settled tool stays visible in past tense instead of vanishing, and
1622− // the thinking indicator marks that the turn itself is still running.
1623− expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]);
1621+ // A successfully settled command keeps the shared live-activity row alive
1622+ // between actions (#9098) instead of dropping to past tense, so no extra
1623+ // thinking row appears while the turn is still running.
1624+ expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
16241625 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
1625− active: false,
1626+ active: true,
1627+ id: "live-activity-row",
16261628 entry: { id: "work-1" },
16271629 });
1628− expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
16291630 });
16301631
16311632 it("does not fold the active in-progress turn", () => {
@@ -1675,7 +1676,7 @@ describe("deriveMessagesTimelineRows", () => { · 43 unmodified lines
16751676 expect(rows.map((row) => row.id)).toEqual([
16761677 "working-indicator-row",
16771678 "assistant-thought-entry",
1678− "work-live:work-entry-1",
1679+ "live-activity-row",
16791680 ]);
16801681 const liveRow = rows.find((row) => row.kind === "work-live");
16811682 expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1");
@@ -1948,8 +1949,8 @@ describe("computeStableMessagesTimelineRows", () => { · 266 unmodified lines
19481949 initial,
19491950 );
19501951
1951− const initialThinking = initial.byId.get("thinking-indicator-row");
1952− const updatedThinking = updated.byId.get("thinking-indicator-row");
1952+ const initialThinking = initial.byId.get("live-activity-row");
1953+ const updatedThinking = updated.byId.get("live-activity-row");
19531954 expect(initialThinking).toMatchObject({ kind: "thinking" });
19541955 expect(updatedThinking).toBe(initialThinking);
19551956 expect(updated.result.at(-1)).toBe(updatedThinking);
apps/web/src/components/chat/MessagesTimeline.logic.ts+16−207
@@ -1,9 +1,6 @@
11 import * as Equal from "effect/Equal";
22 import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
33 import {
4− normalizeCompactToolLabel,
5− omitSupersededLifecycleMarkers,
6− resolveWorkEntryToolPresentation,
74 summarizeToolGroup,
85 toolGroupSummaryKind,
96 type ToolGroupSummaryKind,
@@ -18,6 +15,7 @@ import { · 8 unmodified lines
1815 formatDuration,
1916 timelineEntryIsPersistentResourceCard,
2017 workEntryDisplayIndicatesToolFailure,
18+ workEntryIndicatesToolSuccess,
2119 workEntryIndicatesToolNeutralStatus,
2220 workLogEntryIsToolLike,
2321 type TimelineEntry,
@@ -337,200 +335,6 @@ export function computeMessageDurationStart( · 313 unmodified lines
337335 return result;
338336 }
339337
340−export function normalizeCompactToolLabel(value: string): string {
341− return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
342−}
343−
344−type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";
345−export type ToolGroupSummaryKind =
346− | ToolGroupAction
347− | "dynamic-tool"
348− | "agent-tool"
349− | "tone-tool"
350− | "mixed";
351−
352−export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
353− return (
354− entry.itemType === "file_search" ||
355− (entry.itemType === "web_search" &&
356− /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)))
357− );
358−}
359−
360−export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
361− if (
362− entry.itemType === "dynamic_tool" &&
363− /^read(?:\s+file)?$/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
364− ) {
365− return "read";
366− }
367− if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) {
368− return "edit";
369− }
370− if (entry.itemType === "command_execution" || entry.command) {
371− return "command";
372− }
373− if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
374− if (entry.itemType === "web_search") return "search";
375− return workLogEntryIsToolLike(entry) ? "other" : "update";
376−}
377−
378−function toolGroupActionCount(
379− action: ToolGroupAction,
380− entries: ReadonlyArray<WorkLogEntry>,
381−): number {
382− if (action !== "edit") return entries.length;
383−
384− const changedFiles = new Set<string>();
385− let editsWithoutFileDetails = 0;
386− for (const entry of entries) {
387− if (!entry.changedFiles || entry.changedFiles.length === 0) {
388− editsWithoutFileDetails += 1;
389− continue;
390− }
391− for (const file of entry.changedFiles) changedFiles.add(file);
392− }
393− return changedFiles.size + editsWithoutFileDetails;
394−}
395−
396−function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
397− switch (action) {
398− case "read":
399− return `Read ${count} ${count === 1 ? "file" : "files"}`;
400− case "edit":
401− return `Changed ${count} ${count === 1 ? "file" : "files"}`;
402− case "command":
403− return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
404− case "search":
405− return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
406− case "code-search":
407− return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
408− case "other":
409− return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
410− case "update":
411− return `Received ${count} ${count === 1 ? "update" : "updates"}`;
412− }
413−}
414−
415−function t3ToolSummaryCall(entry: WorkLogEntry): T3ToolSummaryCall {
416− const item = entry.structuredPayload ?? entry.projectedItem?.item;
417− const data =
418− entry.toolData !== null && typeof entry.toolData === "object"
419− ? (entry.toolData as Record<string, unknown>)
420− : undefined;
421− return {
422− input: item?.type === "dynamic_tool" ? item.input : data?.input,
423− output: item?.type === "dynamic_tool" ? item.output : data?.output,
424− // A status/read result may describe a failed child. Only the call's own lifecycle
425− // and MCP error envelope determine whether the orchestration action failed.
426− outcome:
427− entry.toolLifecycleStatus === "failed" ||
428− entry.toolLifecycleStatus === "declined" ||
429− entry.tone === "error"
430− ? "failed"
431− : entry.toolLifecycleStatus === "completed"
432− ? "completed"
433− : "unfinished",
434− };
435−}
436−
437−function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction): number {
438− switch (action) {
439− case "command":
440− case "edit":
441− case "delegate":
442− case "task-cancel":
443− case "thread-create":
444− case "thread-send":
445− case "thread-interrupt":
446− case "schedule-create":
447− case "schedule-update":
448− case "schedule-delete":
449− return 0;
450− case "other":
451− case "update":
452− return 2;
453− default:
454− return 1;
455− }
456−}
457−
458−/** Summarizes at most two action categories; every omitted call still counts in the remainder. */
459−export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): {
460− summary: string;
461− hasFailure: boolean;
462−} {
463− const groups = new Map<
464− ToolGroupAction | T3McpToolSummaryAction,
465− {
466− action: ToolGroupAction;
467− t3Action: T3McpToolSummaryAction | null;
468− entries: WorkLogEntry[];
469− }
470− >();
471− for (const entry of entries) {
472− const item = entry.structuredPayload ?? entry.projectedItem?.item;
473− const t3Action = resolveT3McpToolSummaryAction(
474− (item?.type === "dynamic_tool" ? item.toolName : null) ?? entry.toolTitle ?? entry.label,
475− );
476− const action = toolGroupAction(entry);
477− const key = t3Action ?? action;
478− const group = groups.get(key);
479− if (group) group.entries.push(entry);
480− else groups.set(key, { action, t3Action, entries: [entry] });
481− }
482− const summaries = [...groups].map(([action, group], index) => ({
483− index,
484− count: group.entries.length,
485− priority: summaryActionPriority(action),
486− ...(group.t3Action
487− ? summarizeT3ToolCalls(group.t3Action, group.entries.map(t3ToolSummaryCall))
488− : {
489− label: toolGroupActionLabel(
490− group.action,
491− toolGroupActionCount(group.action, group.entries),
492− ),
493− failedCount: group.entries.filter(workEntryDisplayIndicatesToolFailure).length,
494− }),
495− }));
496− const selected = summaries
497− .toSorted((a, b) => a.priority - b.priority || a.index - b.index)
498− .slice(0, 2)
499− .sort((a, b) => a.index - b.index);
500− const labels = selected.map(({ label }) => label);
501− const remainingCount = entries.length - selected.reduce((count, group) => count + group.count, 0);
502− if (remainingCount > 0) {
503− labels.push(`Performed ${remainingCount} other ${remainingCount === 1 ? "action" : "actions"}`);
504− }
505− const sentenceLabels = labels.map((label, index) =>
506− index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
507− );
508− const summary =
509− sentenceLabels.length < 3
510− ? sentenceLabels.join(" and ")
511− : `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
512− return { summary, hasFailure: summaries.some((group) => group.failedCount > 0) };
513−}
514−
515−function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupSummaryKind {
516− const actions = new Set(entries.map(toolGroupAction));
517− if (actions.size !== 1) return "mixed";
518−
519− const action = actions.values().next().value!;
520− if (action !== "other") return action;
521−
522− const fallbackKinds = new Set(
523− entries.map((entry): ToolGroupSummaryKind => {
524− if (entry.itemType === "dynamic_tool") return "dynamic-tool";
525− if (entry.itemType === "subagent") return "agent-tool";
526− if (entry.tone === "thinking") return "agent-tool";
527− if (entry.tone === "tool") return "tone-tool";
528− return "other";
529− }),
530− );
531− return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed";
532−}
533−
534338 function workGroupId(timelineEntryId: string): string {
535339 return `work-group:${timelineEntryId}`;
536340 }
@@ -911,21 +715,26 @@ export function deriveMessagesTimelineRows(input: { · 374 unmodified lines
911715 const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) =>
912716 workEntryIsActiveTurnActivity(entry.entry),
913717 );
914− const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry;
718+ const latestToolKeepsActivityLive =
719+ latestRunningToolEntry !== undefined ||
720+ (latestVisibleToolEntry !== undefined &&
721+ workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry));
915722 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
916723 const activeWorkRow =
917− activeWorkAnchor && displayedToolEntry
724+ activeWorkAnchor && latestVisibleToolEntry
918725 ? (() => {
919726 const groupId = workGroupId(activeWorkAnchor.id);
920727 return {
921728 kind: "work-live" as const,
922− id: `work-live:${activeWorkAnchor.id}`,
729+ id: latestToolKeepsActivityLive
730+ ? LIVE_ACTIVITY_ROW_ID
731+ : `work-live:${activeWorkAnchor.id}`,
923732 createdAt: activeWorkAnchor.createdAt,
924− entry: displayedToolEntry.entry,
733+ entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry,
925734 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
926735 groupId,
927736 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
928− active: latestRunningToolEntry !== undefined,
737+ active: latestToolKeepsActivityLive,
929738 };
930739 })()
931740 : null;
@@ -939,11 +748,11 @@ export function deriveMessagesTimelineRows(input: { · 7 unmodified lines
939748 createdAt: input.activeTurnStartedAt ?? null,
940749 });
941750 };
942− let hasLiveWorkRow = false;
751+ let hasActivityRow = false;
943752 const appendActiveWorkRows = () => {
944753 if (activeWorkRow === null) return;
945754 nextRows.push(activeWorkRow);
946− hasLiveWorkRow ||= activeWorkRow.active;
755+ hasActivityRow ||= activeWorkRow.active;
947756 if (!activeWorkRow.expanded) return;
948757 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
949758 nextRows.push({
@@ -1059,7 +868,6 @@ export function deriveMessagesTimelineRows(input: { · 109 unmodified lines
1059868 const groupId = workGroupId(timelineEntry.id);
1060869 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
1061870 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
1062− hasLiveWorkRow = true;
1063871 nextRows.push({
1064872 kind: "work-live",
1065873 id: `work-live:${timelineEntry.id}`,
@@ -1070,6 +878,7 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines
1070878 expanded,
1071879 active: true,
1072880 });
881+ hasActivityRow = true;
1073882 if (expanded) {
1074883 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
1075884 nextRows.push({
@@ -1192,10 +1001,10 @@ export function deriveMessagesTimelineRows(input: { · 116 unmodified lines
11921001 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
11931002 appendWorkingRow();
11941003 }
1195− if (input.isWorking && !hasLiveWorkRow) {
1004+ if (input.isWorking && !hasActivityRow) {
11961005 nextRows.push({
11971006 kind: "thinking",
1198− id: "thinking-indicator-row",
1007+ id: LIVE_ACTIVITY_ROW_ID,
11991008 createdAt: input.activeTurnStartedAt ?? null,
12001009 });
12011010 }
apps/web/src/components/chat/MessagesTimeline.tsx+7−3
@@ -64,6 +64,7 @@ import { · 63 unmodified lines
6464 import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
6565 import {
6666 BotIcon,
67+ BrainIcon,
6768 CheckIcon,
6869 ChevronDownIcon,
6970 ChevronRightIcon,
@@ -2092,7 +2093,7 @@ function ThinkingTimelineRow() { · 2022 unmodified lines
20922093 // Reserve the activity row during setup so the handoff keeps the same height.
20932094 return (
20942095 <div className="min-h-7">
2095− {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
2096+ {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />}
20962097 </div>
20972098 );
20982099 }
@@ -2778,6 +2779,7 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { · 679 unmodified lines
27782779
27792780 type WorkEntryIconName =
27802781 | "bot"
2782+ | "brain"
27812783 | "check"
27822784 | "circle-alert"
27832785 | "eye"
@@ -2795,6 +2797,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN · 11 unmodified lines
27952797 switch (name) {
27962798 case "bot":
27972799 return <BotIcon className={className} aria-hidden />;
2800+ case "brain":
2801+ return <BrainIcon className={className} aria-hidden />;
27982802 case "check":
27992803 return <CheckIcon className={className} aria-hidden />;
28002804 case "circle-alert":
@@ -2849,8 +2853,8 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { · 48 unmodified lines
28492853 }
28502854 if (tone === "thinking") {
28512855 return {
2852− iconName: "bot",
2853− className: "text-foreground/92",
2856+ iconName: "brain",
2857+ className: "text-foreground",
28542858 };
28552859 }
28562860 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
203203 "https://example.com/docs?topic=security#links",
204204 ],
205205 ["HTTP://EXAMPLE.COM", "http://example.com/"],
206+ ["//example.com/path", "https://example.com/path"],
206207 ])("resolves the safe external web-link href for %s as %s", (href, expected) => {
207208 expect(resolveExternalWebLinkHref(href)).toBe(expected);
208209 });
@@ -213,7 +214,6 @@ describe("external chat link context menu", () => { · 4 unmodified lines
213214 "data:text/html,<script>alert(document.domain)</script>",
214215 "file:///tmp/example.txt",
215216 "mailto:hello@example.com",
216− "//example.com/path",
217217 "/relative/path",
218218 "not a URL",
219219 "",
apps/web/src/components/settings/SettingsPanels.tsx+2−25
@@ -2254,31 +2254,8 @@ export function GeneralSettingsPanel() { · 2253 unmodified lines
22542254 />
22552255 }
22562256 />
2257− ) : null
2258− }
2259− control={
2260− <Switch
2261− checked={settings.sidebarAutoSettleAfterDays !== null}
2262− onCheckedChange={(checked) =>
2263− updateSettings({
2264− sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
2265− })
2266− }
2267− aria-label="Auto-settle inactive threads"
2268− />
2269− }
2270− />
2271− {settings.sidebarAutoSettleAfterDays !== null ? (
2272− <SettingsRow
2273− title={searchableSetting("days-before-auto-settle").title}
2274− description="Any new activity un-settles a thread automatically."
2275− control={
2276− <AutoSettleDaysInput
2277− value={settings.sidebarAutoSettleAfterDays}
2278− onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
2279− />
2280− }
2281− />
2257+ ) : null}
2258+ </>
22822259 ) : null}
22832260 </SettingsSection>
22842261
apps/web/src/components/settings/settingsSearch.test.ts+21−0
@@ -147,6 +147,7 @@ describe("searchSettings", () => { · 146 unmodified lines
147147 hasProviderSettingsEnvironment: false,
148148 canManageLocalBackend: false,
149149 isWslSettingsRowVisible: false,
150+ hasThreadAutoSettlement: false,
150151 });
151152
152153 const gatedIds = new Set<string>([
@@ -160,10 +161,30 @@ describe("searchSettings", () => { · 7 unmodified lines
160161 "t3-connect",
161162 "tailscale-https",
162163 "wsl-backend",
164+ "auto-settle-inactive-threads",
165+ "auto-settle-merged-threads",
166+ "days-before-auto-settle",
163167 ]);
164168 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
165169 });
166170
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+
167188 it("keeps catalog result ids unique", () => {
168189 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
169190 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
3131 readonly providerSettingsOnly?: boolean;
3232 readonly localBackendManagementOnly?: boolean;
3333 readonly wslAvailableOnly?: boolean;
34+ readonly requiresThreadAutoSettlement?: boolean;
3435 }
3536
3637 export interface SettingsSearchAvailability {
@@ -39,6 +40,7 @@ export interface SettingsSearchAvailability { · 2 unmodified lines
3940 readonly hasProviderSettingsEnvironment: boolean;
4041 readonly canManageLocalBackend: boolean;
4142 readonly isWslSettingsRowVisible: boolean;
43+ readonly hasThreadAutoSettlement: boolean;
4244 }
4345
4446 /**
@@ -155,12 +157,14 @@ export const SETTINGS_SEARCH_ITEMS = [ · 110 unmodified lines
155157 title: "Auto-settle inactive threads",
156158 to: "/settings/general",
157159 searchTerms: ["sidebar inactivity days no activity automatically"],
160+ requiresThreadAutoSettlement: true,
158161 },
159162 {
160163 id: "auto-settle-merged-threads",
161164 title: "Auto-settle merged threads",
162165 to: "/settings/general",
163166 searchTerms: ["pull request merge closed automatically sidebar"],
167+ requiresThreadAutoSettlement: true,
164168 },
165169 {
166170 id: "days-before-auto-settle",
@@ -168,6 +172,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line
168172 to: "/settings/general",
169173 targetId: "auto-settle-inactive-threads",
170174 searchTerms: ["thread timeout activity sidebar"],
175+ requiresThreadAutoSettlement: true,
171176 },
172177 {
173178 id: "time-format",
@@ -250,7 +255,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 76 unmodified lines
250255 id: "quit-confirmation",
251256 title: "Quit shortcut",
252257 to: "/settings/general",
253− searchTerms: ["confirmation shortcut desktop app exit"],
258+ searchTerms: ["confirmation desktop app exit direct hold double click press twice"],
254259 desktopOnly: true,
255260 },
256261 {
@@ -491,7 +496,8 @@ export function filterAvailableSettingsSearchItems( · 234 unmodified lines
491496 (!item.primaryOnly || availability.hasPrimaryEnvironment) &&
492497 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
493498 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
494− (!item.wslAvailableOnly || availability.isWslSettingsRowVisible),
499+ (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
500+ (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement),
495501 );
496502 }
497503
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+13−1
@@ -1,4 +1,5 @@
11 import { useMemo } from "react";
2+import { useAtomValue } from "@effect/atom-react";
23 import { AuthAccessWriteScope } from "@t3tools/contracts";
34
45 import { hasCloudPublicConfig } from "~/cloud/publicConfig";
@@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; · 2 unmodified lines
78 import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
89 import { useEnvironmentQuery } from "~/state/query";
910 import { usePrimarySessionState } from "~/environments/primary";
11+import { primaryServerConfigAtom } from "~/state/server";
1012 import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
1113 import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
1214 import { filterAvailableSettingsSearchItems } from "./settingsSearch";
@@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() { · 2 unmodified lines
1517 const primaryEnvironmentId = usePrimaryEnvironmentId();
1618 const { environments } = useEnvironments();
1719 const primarySessionState = usePrimarySessionState();
20+ const primaryServerConfig = useAtomValue(primaryServerConfigAtom);
1821 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
1922 const canManageLocalBackend =
2023 isElectron ||
@@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() { · 17 unmodified lines
3841 state: desktopWsl.data,
3942 error: desktopWsl.error,
4043 }),
44+ hasThreadAutoSettlement:
45+ primaryServerConfig?.environment.capabilities.threadAutoSettlement === true,
4146 }),
42− [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId],
47+ [
48+ canManageLocalBackend,
49+ desktopWsl.data,
50+ desktopWsl.error,
51+ environments,
52+ primaryEnvironmentId,
53+ primaryServerConfig,
54+ ],
4355 );
4456 }
apps/web/src/composerDraftStore.ts+119−0
@@ -45,6 +45,7 @@ import { · 44 unmodified lines
4545 type TerminalContextDraft,
4646 ensureInlineTerminalContextPlaceholders,
4747 normalizeTerminalContextText,
48+ stripInlineTerminalContextPlaceholders,
4849 } from "./lib/terminalContext";
4950 import {
5051 type ElementContextDraft,
@@ -660,6 +661,13 @@ interface ComposerDraftStoreState { · 609 unmodified lines
660661 * prompt stash. Session-bound context stays in the source draft.
661662 */
662663 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;
663671 }
664672
665673 export interface EffectiveComposerModelState {
@@ -3900,6 +3908,117 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 3234 unmodified lines
39003908 return { draftsByThreadKey: nextDraftsByThreadKey };
39013909 });
39023910 },
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+ },
39034022 };
39044023 },
39054024 {
apps/web/src/hooks/useHandleNewThread.ts+1−1
@@ -23,7 +23,7 @@ import { · 22 unmodified lines
2323 selectProjectGroupingSettings,
2424 } from "../logicalProject";
2525 import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
26−import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
26+import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities";
2727 import {
2828 hasExplicitComposerModelSelection,
2929 resolveNewDraftStartFromOrigin,
packages/client-runtime/src/state/threadSettled.test.ts+587−0
@@ -0,0 +1,587 @@
1+import {
2+ ProjectId,
3+ ProviderInstanceId,
4+ ThreadId,
5+ TurnId,
6+ type OrchestrationThreadShell,
7+} from "@t3tools/contracts";
8+import { describe, expect, it } from "vite-plus/test";
9+
10+import {
11+ canSettle,
12+ changeRequestAutoSettles,
13+ effectiveSettled,
14+ hasQueuedTurnStart,
15+ threadLastActivityAt,
16+ type ChangeRequestStateLike,
17+} from "./threadSettled.ts";
18+
19+const NOW = "2026-04-10T00:00:00.000Z";
20+const FRESH = "2026-04-09T00:00:00.000Z";
21+const STALE = "2026-04-06T23:59:59.999Z";
22+
23+describe("changeRequestAutoSettles", () => {
24+ it.each([
25+ ["open", true, false],
26+ ["merged", true, true],
27+ ["merged", false, false],
28+ ["closed", false, true],
29+ [null, false, false],
30+ ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => {
31+ expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe(
32+ expected,
33+ );
34+ });
35+
36+ const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";
37+ const idleThread = {
38+ createdAt: THREAD_CREATED_AT,
39+ latestUserMessageAt: null,
40+ latestTurn: null,
41+ };
42+
43+ it("ignores a terminal change request last touched before the thread existed", () => {
44+ for (const state of ["merged", "closed"] as const) {
45+ expect(
46+ changeRequestAutoSettles(
47+ { state, updatedAt: "2026-03-31T23:59:59.999Z" },
48+ { thread: idleThread },
49+ ),
50+ ).toBe(false);
51+ }
52+ });
53+
54+ it("settles on a terminal change request touched at or after the thread's latest event", () => {
55+ for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) {
56+ expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe(
57+ true,
58+ );
59+ }
60+ });
61+
62+ it("never re-settles a thread revived after the merge", () => {
63+ // Settling on a merge happens once: a user message newer than the PR's
64+ // last activity means the conversation outlived the PR.
65+ const revived = {
66+ createdAt: THREAD_CREATED_AT,
67+ latestUserMessageAt: "2026-04-05T00:00:00.000Z",
68+ latestTurn: null,
69+ };
70+ expect(
71+ changeRequestAutoSettles(
72+ { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" },
73+ { thread: revived },
74+ ),
75+ ).toBe(false);
76+ // A merge landing after the revival still settles.
77+ expect(
78+ changeRequestAutoSettles(
79+ { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" },
80+ { thread: revived },
81+ ),
82+ ).toBe(true);
83+ });
84+
85+ it("still settles when the merge lands during an in-flight turn", () => {
86+ // Anchor is user-initiated activity only: the agent finishing a turn
87+ // after the merge must not block the settle the merge earned.
88+ const midTurnMerge = {
89+ createdAt: THREAD_CREATED_AT,
90+ latestUserMessageAt: "2026-04-02T00:00:00.000Z",
91+ latestTurn: {
92+ turnId: TurnId.make("turn-mid"),
93+ state: "completed" as const,
94+ requestedAt: "2026-04-02T00:00:00.000Z",
95+ startedAt: "2026-04-02T00:00:05.000Z",
96+ completedAt: "2026-04-02T00:20:00.000Z",
97+ assistantMessageId: null,
98+ },
99+ };
100+ expect(
101+ changeRequestAutoSettles(
102+ { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" },
103+ { thread: midTurnMerge },
104+ ),
105+ ).toBe(true);
106+ });
107+
108+ it("falls back to settling when either timestamp is missing or malformed", () => {
109+ expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true);
110+ expect(
111+ changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),
112+ ).toBe(true);
113+ expect(
114+ changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),
115+ ).toBe(true);
116+ expect(
117+ changeRequestAutoSettles(
118+ { state: "merged", updatedAt: "not-a-date" },
119+ { thread: idleThread },
120+ ),
121+ ).toBe(true);
122+ });
123+});
124+
125+function makeShell(input: {
126+ readonly settledOverride?: "settled" | "active" | null;
127+ readonly activityAt: string | null;
128+ readonly sessionStatus?: "starting" | "running";
129+ readonly pending?: "approval" | "user-input";
130+}): OrchestrationThreadShell {
131+ const threadId = ThreadId.make("thread-1");
132+ return {
133+ id: threadId,
134+ projectId: ProjectId.make("project-1"),
135+ title: "Thread",
136+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
137+ runtimeMode: "full-access",
138+ interactionMode: "default",
139+ branch: null,
140+ worktreePath: null,
141+ latestTurn:
142+ input.activityAt === null
143+ ? null
144+ : {
145+ turnId: TurnId.make("turn-1"),
146+ state: "completed",
147+ requestedAt: input.activityAt,
148+ startedAt: null,
149+ completedAt: null,
150+ assistantMessageId: null,
151+ },
152+ createdAt: "2026-04-01T00:00:00.000Z",
153+ updatedAt: NOW,
154+ archivedAt: null,
155+ settledOverride: input.settledOverride ?? null,
156+ settledAt: input.settledOverride === "settled" ? NOW : null,
157+ session:
158+ input.sessionStatus === undefined
159+ ? null
160+ : {
161+ threadId,
162+ status: input.sessionStatus,
163+ providerName: "Codex",
164+ runtimeMode: "full-access",
165+ activeTurnId: null,
166+ lastError: null,
167+ updatedAt: NOW,
168+ },
169+ latestUserMessageAt: null,
170+ hasPendingApprovals: input.pending === "approval",
171+ hasPendingUserInput: input.pending === "user-input",
172+ hasActionableProposedPlan: false,
173+ };
174+}
175+
176+describe("threadLastActivityAt", () => {
177+ it("returns the latest real user or turn activity and ignores thread/session updates", () => {
178+ const shell = makeShell({ activityAt: null, sessionStatus: "running" });
179+ const withActivity: OrchestrationThreadShell = {
180+ ...shell,
181+ latestUserMessageAt: "2026-04-04T00:00:00.000Z",
182+ latestTurn: {
183+ turnId: TurnId.make("turn-1"),
184+ state: "completed",
185+ requestedAt: "2026-04-03T00:00:00.000Z",
186+ startedAt: "2026-04-05T00:00:00.000Z",
187+ completedAt: "2026-04-06T00:00:00.000Z",
188+ assistantMessageId: null,
189+ },
190+ };
191+
192+ expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z");
193+ expect(threadLastActivityAt(shell)).toBeNull();
194+ });
195+});
196+
197+describe("effectiveSettled", () => {
198+ const overrideCases = [null, "settled", "active"] as const;
199+ const changeRequestStates = [undefined, "open", "merged"] as const;
200+ const inactivityCases = [
201+ ["fresh", FRESH],
202+ ["stale", STALE],
203+ ["no-activity", null],
204+ ] as const;
205+ const runningCases = [false, true] as const;
206+ const pendingCases = [undefined, "approval", "user-input"] as const;
207+ const truthTable = overrideCases.flatMap((settledOverride) =>
208+ changeRequestStates.flatMap((changeRequestState) =>
209+ inactivityCases.flatMap(([inactivity, activityAt]) =>
210+ runningCases.flatMap((running) =>
211+ pendingCases.map((pending) => ({
212+ settledOverride,
213+ changeRequestState,
214+ inactivity,
215+ activityAt,
216+ running,
217+ pending,
218+ // Settled iff nothing blocks (pending work / live session) AND
219+ // the override says settled, or (with no override) a merged PR
220+ // or staleness auto-settles. The "active" pin suppresses both
221+ // auto signals, and an open PR suppresses the inactivity path:
222+ // a thread with a PR out for review is never done, however quiet.
223+ expected:
224+ pending === undefined &&
225+ !running &&
226+ (settledOverride === "settled" ||
227+ (settledOverride === null &&
228+ (changeRequestState === "merged" ||
229+ (changeRequestState !== "open" && inactivity === "stale")))),
230+ })),
231+ ),
232+ ),
233+ ),
234+ );
235+
236+ it.each(truthTable)(
237+ "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
238+ ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => {
239+ const shell = makeShell({
240+ settledOverride,
241+ activityAt,
242+ ...(running ? { sessionStatus: "running" as const } : {}),
243+ ...(pending === undefined ? {} : { pending }),
244+ });
245+ const changeRequestOptions =
246+ changeRequestState === undefined
247+ ? {}
248+ : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };
249+
250+ expect(
251+ effectiveSettled(shell, {
252+ now: NOW,
253+ autoSettleAfterDays: 3,
254+ ...changeRequestOptions,
255+ }),
256+ ).toBe(expected);
257+ },
258+ );
259+
260+ it("treats closed change requests like merged ones", () => {
261+ const shell = makeShell({ activityAt: null });
262+ expect(
263+ effectiveSettled(shell, {
264+ now: NOW,
265+ autoSettleAfterDays: null,
266+ changeRequest: { state: "closed" },
267+ }),
268+ ).toBe(true);
269+ });
270+
271+ it("settles immediately when a change request merges or closes", () => {
272+ const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
273+ for (const changeRequestState of ["merged", "closed"] as const) {
274+ expect(
275+ effectiveSettled(recentlyActive, {
276+ now: NOW,
277+ autoSettleAfterDays: null,
278+ changeRequest: { state: changeRequestState },
279+ }),
280+ ).toBe(true);
281+ }
282+ });
283+
284+ it("ignores a change request that merged before the thread's latest event", () => {
285+ // A new thread started at a worktree root inherits the branch's old
286+ // merged PR, and a revived thread outlives its merge; neither settles
287+ // the live conversation.
288+ const fresh = makeShell({ activityAt: FRESH });
289+ for (const state of ["merged", "closed"] as const) {
290+ expect(
291+ effectiveSettled(fresh, {
292+ now: NOW,
293+ autoSettleAfterDays: null,
294+ changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },
295+ }),
296+ ).toBe(false);
297+ }
298+ // A merge during the thread's life still settles it.
299+ expect(
300+ effectiveSettled(fresh, {
301+ now: NOW,
302+ autoSettleAfterDays: null,
303+ changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },
304+ }),
305+ ).toBe(true);
306+ });
307+
308+ it("can keep a merged change request active", () => {
309+ const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
310+ expect(
311+ effectiveSettled(recentlyActive, {
312+ now: NOW,
313+ autoSettleAfterDays: null,
314+ autoSettleOnMerge: false,
315+ changeRequest: { state: "merged" },
316+ }),
317+ ).toBe(false);
318+
319+ expect(
320+ effectiveSettled(recentlyActive, {
321+ now: NOW,
322+ autoSettleAfterDays: null,
323+ autoSettleOnMerge: false,
324+ changeRequest: { state: "closed" },
325+ }),
326+ ).toBe(true);
327+ });
328+
329+ it("never auto-settles a stale thread with an open change request", () => {
330+ const stale = makeShell({ activityAt: STALE });
331+ expect(
332+ effectiveSettled(stale, {
333+ now: NOW,
334+ autoSettleAfterDays: 3,
335+ changeRequest: { state: "open" },
336+ }),
337+ ).toBe(false);
338+ // An explicit user settle still wins: open PR only blocks the auto path.
339+ const settled = makeShell({ settledOverride: "settled", activityAt: STALE });
340+ expect(
341+ effectiveSettled(settled, {
342+ now: NOW,
343+ autoSettleAfterDays: 3,
344+ changeRequest: { state: "open" },
345+ }),
346+ ).toBe(true);
347+ });
348+
349+ it("keeps an explicitly un-settled merged-PR thread active", () => {
350+ const shell = makeShell({
351+ settledOverride: "active",
352+ activityAt: "2026-04-09T23:59:59.999Z",
353+ });
354+ expect(
355+ effectiveSettled(shell, {
356+ now: NOW,
357+ autoSettleAfterDays: null,
358+ changeRequest: { state: "merged" },
359+ }),
360+ ).toBe(false);
361+ });
362+
363+ it("never settles a starting session, even with a settled override", () => {
364+ const shell = makeShell({
365+ settledOverride: "settled",
366+ activityAt: STALE,
367+ sessionStatus: "starting",
368+ });
369+ expect(
370+ effectiveSettled(shell, {
371+ now: NOW,
372+ autoSettleAfterDays: 3,
373+ changeRequest: { state: "merged" },
374+ }),
375+ ).toBe(false);
376+ });
377+
378+ it("keeps a new turn active from queued through starting and running", () => {
379+ const requestedAt = "2026-04-09T12:00:00.000Z";
380+ const transitionNow = "2026-04-09T12:00:30.000Z";
381+ const base = makeShell({
382+ settledOverride: null,
383+ activityAt: STALE,
384+ });
385+ const queued: OrchestrationThreadShell = {
386+ ...base,
387+ latestUserMessageAt: requestedAt,
388+ latestTurn: null,
389+ session: null,
390+ };
391+ const starting: OrchestrationThreadShell = {
392+ ...queued,
393+ session: {
394+ threadId: queued.id,
395+ status: "starting",
396+ providerName: "Codex",
397+ runtimeMode: "full-access",
398+ activeTurnId: null,
399+ lastError: null,
400+ updatedAt: requestedAt,
401+ },
402+ };
403+ const running: OrchestrationThreadShell = {
404+ ...starting,
405+ session: {
406+ ...starting.session!,
407+ status: "running",
408+ activeTurnId: TurnId.make("turn-new"),
409+ },
410+ };
411+
412+ for (const shell of [queued, starting, running]) {
413+ expect(
414+ effectiveSettled(shell, {
415+ now: transitionNow,
416+ autoSettleAfterDays: 3,
417+ changeRequest: { state: "merged" },
418+ }),
419+ ).toBe(false);
420+ }
421+ });
422+
423+ it("uses a strict inactivity boundary and honors a null threshold", () => {
424+ const boundary = makeShell({
425+ activityAt: "2026-04-07T00:00:00.000Z",
426+ });
427+ const stale = makeShell({ activityAt: STALE });
428+
429+ expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
430+ expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false);
431+ });
432+});
433+
434+describe("hasQueuedTurnStart", () => {
435+ const QUEUED_AT = "2026-04-09T12:00:00.000Z";
436+ // Within the adoption grace window of the queued message.
437+ const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" };
438+
439+ it("flags a user message no turn has picked up, within the grace window", () => {
440+ const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
441+ expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true);
442+
443+ const staleTurn = {
444+ ...makeShell({ activityAt: FRESH }),
445+ latestUserMessageAt: QUEUED_AT,
446+ };
447+ expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true);
448+ });
449+
450+ it("expires after the grace window: an unadopted message is a failed start, not queued work", () => {
451+ const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
452+ expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false);
453+ // Historical shells (e.g. from servers that never carried latestTurn)
454+ // must never read as queued.
455+ expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false);
456+ });
457+
458+ it("clears once a turn adopts the message or the start fails", () => {
459+ const adopted = {
460+ ...makeShell({ activityAt: QUEUED_AT }),
461+ latestUserMessageAt: QUEUED_AT,
462+ };
463+ expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false);
464+
465+ const failed = makeShell({ activityAt: FRESH });
466+ const failedShell = {
467+ ...failed,
468+ latestUserMessageAt: QUEUED_AT,
469+ session: {
470+ threadId: failed.id,
471+ status: "error" as const,
472+ providerName: "Codex",
473+ runtimeMode: "full-access" as const,
474+ activeTurnId: null,
475+ lastError: "boom",
476+ updatedAt: NOW,
477+ },
478+ };
479+ expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false);
480+ });
481+
482+ it("is quiet without user messages", () => {
483+ expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false);
484+ });
485+
486+ it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => {
487+ // Message timestamps originate on other devices; a clock an hour ahead
488+ // must not hold the queued state for the whole skew.
489+ const skewed = {
490+ latestUserMessageAt: "2026-04-09T13:00:00.000Z",
491+ latestTurn: null,
492+ session: null,
493+ };
494+ expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false);
495+ // A small negative age (within the grace window) still reads as queued.
496+ const slightlyAhead = {
497+ latestUserMessageAt: "2026-04-09T12:00:30.000Z",
498+ latestTurn: null,
499+ session: null,
500+ };
501+ expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true);
502+ });
503+});
504+
505+describe("canSettle", () => {
506+ it("blocks every state effectiveSettled refuses to classify as settled", () => {
507+ expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true);
508+ expect(
509+ canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }),
510+ ).toBe(false);
511+ expect(
512+ canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }),
513+ ).toBe(false);
514+ expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe(
515+ false,
516+ );
517+ expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe(
518+ false,
519+ );
520+ });
521+
522+ it("blocks settling a queued turn start, only within the grace window", () => {
523+ const queued = {
524+ ...makeShell({ activityAt: FRESH }),
525+ latestUserMessageAt: "2026-04-09T12:00:00.000Z",
526+ };
527+ const justAfter = "2026-04-09T12:00:30.000Z";
528+ expect(canSettle(queued, { now: justAfter })).toBe(false);
529+ // effectiveSettled must agree: queued work never auto-settles either,
530+ // even with a merged PR.
531+ expect(
532+ effectiveSettled(queued, {
533+ now: justAfter,
534+ autoSettleAfterDays: 3,
535+ changeRequest: { state: "merged" },
536+ }),
537+ ).toBe(false);
538+ // Past the window the message is a failed/stale start: settleable again.
539+ expect(canSettle(queued, { now: NOW })).toBe(true);
540+ });
541+
542+ it("lets a server-accepted settle overrule the clock-derived queued blocker", () => {
543+ // The settle action ran with wall-clock `now` (past the grace window);
544+ // the list partition re-evaluates with a minute-floored `now` that is
545+ // still INSIDE the window. settledAt >= message time proves the server
546+ // already adjudicated this exact message, so the row must not snap back
547+ // to active until the coarser clock catches up.
548+ const messageAt = "2026-04-09T12:00:00.000Z";
549+ const flooredNow = "2026-04-09T12:01:00.000Z";
550+ const base = makeShell({ settledOverride: "settled", activityAt: null });
551+ const settledAfterMessage = {
552+ ...base,
553+ latestUserMessageAt: messageAt,
554+ settledAt: "2026-04-09T12:02:10.000Z",
555+ };
556+ expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);
557+ expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe(
558+ true,
559+ );
560+
561+ // A message NEWER than settledAt is genuinely new work: still blocked
562+ // until the server's auto-unsettle lands.
563+ const messageAfterSettle = {
564+ ...base,
565+ latestUserMessageAt: "2026-04-09T12:03:00.000Z",
566+ settledAt: "2026-04-09T12:02:10.000Z",
567+ };
568+ expect(
569+ effectiveSettled(messageAfterSettle, {
570+ now: "2026-04-09T12:03:30.000Z",
571+ autoSettleAfterDays: 3,
572+ }),
573+ ).toBe(false);
574+ });
575+
576+ it("agrees with effectiveSettled's blockers for explicitly settled shells", () => {
577+ // Anything canSettle rejects must render as active even when the user
578+ // settled it earlier.
579+ const blocked = makeShell({
580+ settledOverride: "settled",
581+ activityAt: FRESH,
582+ pending: "user-input",
583+ });
584+ expect(canSettle(blocked, { now: NOW })).toBe(false);
585+ expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
586+ });
587+});
packages/client-runtime/src/state/threadSettled.ts+0−1
@@ -138,7 +138,6 @@ export function threadLastActivityAt(shell: SettlementThreadShell): string | nul · 137 unmodified lines
138138 * such threads would be permanently unsettleable.
139139 */
140140 export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
141−const DAY_MS = 24 * 60 * 60 * 1_000;
142141
143142 /**
144143 * 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
7070 ): RpcSession.RpcSession {
7171 return {
7272 client,
73− initialConfig: Effect.succeed({
74− threadResumeCompletionMarker: config?.completionMarker === true,
75− } as never),
73+ initialConfig: Effect.succeed(
74+ config?.completionMarker === true
75+ ? ({ threadResumeCompletionMarker: true } as never)
76+ : ({} as never),
77+ ),
78+ subscribeServerConfig: (input) => client.subscribeServerConfig(input),
7679 ready: Effect.void,
7780 probe: Effect.void,
7881 closed: Effect.never,
packages/client-runtime/src/work-log/presentation.test.ts+1−1
@@ -444,7 +444,7 @@ describe("resolveViewedImageAsset", () => { · 443 unmodified lines
444444 }),
445445 ).toEqual({
446446 resource: {
447− _tag: "workspace-file",
447+ _tag: "media-file",
448448 threadId,
449449 path: "/workspace/screens/logo.svg",
450450 },
packages/client-runtime/src/work-log/presentation.ts+2−2
@@ -135,7 +135,7 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin · 134 unmodified lines
135135 }
136136
137137 export interface ViewedImageAsset {
138− readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;
138+ readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
139139 readonly alt: string;
140140 readonly srcFragment: string;
141141 }
@@ -165,7 +165,7 @@ export function resolveViewedImageAsset( · 23 unmodified lines
165165 return {
166166 resource: attachmentId
167167 ? { _tag: "attachment", attachmentId }
168− : { _tag: "workspace-file", threadId: input.threadId, path },
168+ : { _tag: "media-file", threadId: input.threadId, path },
169169 alt: path.split(/[\\/]/).at(-1) ?? "image",
170170 srcFragment: markdownImageSourceFragment(source),
171171 };
packages/contracts/src/ipc.ts+8−5
@@ -88,7 +88,7 @@ import { EnvironmentId } from "./baseSchemas.ts"; · 87 unmodified lines
8888 import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
8989 import { AdvertisedEndpoint } from "./remoteAccess.ts";
9090 import { ExecutionEnvironmentDescriptor } from "./environment.ts";
91−import type { ClientSettings } from "./settings.ts";
91+import type { ClientSettings, QuitConfirmationMode } from "./settings.ts";
9292 import type { EditorId } from "./editor.ts";
9393 import type {
9494 SourceControlCloneRepositoryInput,
@@ -98,6 +98,10 @@ import type { · 3 unmodified lines
9898 SourceControlRepositoryInfo,
9999 SourceControlRepositoryLookupInput,
100100 } from "./sourceControl.ts";
101+import type {
102+ DesktopAppActivationRequest,
103+ DesktopAppActivationResponse,
104+} from "./desktopAppActivation.ts";
101105
102106 export interface ContextMenuItem<T extends string = string> {
103107 id: T;
@@ -1103,11 +1107,10 @@ export interface DesktopBridge { · 999 unmodified lines
11031107 probeRemoteEditors?: () => Promise<readonly EditorId[]>;
11041108 onMenuAction: (listener: (action: string) => void) => () => void;
11051109 /**
1106− * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed,
1107− * "up" when it is released before the hold completes. Optional: older
1108− * desktop builds never emit it.
1110+ * Quit-confirmation hint pushes. Optional: older desktop builds never emit
1111+ * them.
11091112 */
1110− onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void;
1113+ onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void;
11111114 getWindowFullscreenState: () => boolean;
11121115 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
11131116 getUpdateState: () => Promise<DesktopUpdateState>;
packages/shared/src/model.test.ts+4−2
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "vite-plus/test";
2−import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
2+import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
33
44 import {
55 applyClaudePromptEffortPrefix,
@@ -40,9 +40,11 @@ const codexCaps: ModelCapabilities = createModelCapabilities({ · 34 unmodified lines
4040
4141 describe("model slug normalization", () => {
4242 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.
4345 const claude = ProviderDriverKind.make("claudeAgent");
4446
45− expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5");
47+ expect(normalizeModelSlug("opus", claude)).toBe("opus");
4648 expect(normalizeCustomModelSlug(" opus ")).toBe("opus");
4749 });
4850 });
patches/@legendapp__list@3.3.5.patch+104−98
@@ -241,7 +241,7 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a · 240 unmodified lines
241241 * Number of columns to render items in.
242242 * @default 1
243243 diff --git a/react-native.js b/react-native.js
244−index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c3ffe83af 100644
244+index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df752a843de0 100644
245245 --- a/react-native.js
246246 +++ b/react-native.js
247247 @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) {
@@ -278,7 +278,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 30 unmodified lines
278278 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
279279 set$(ctx, "readyToRender", true);
280280 setAdaptiveRender(ctx, "normal", "ready");
281−@@ -1090,7 +1090,7 @@ function getRawContentLength(ctx) {
281+@@ -1090,7 +1099,7 @@ function getRawContentLength(ctx) {
282282 function getAlignItemsAtEndPadding(ctx) {
283283 const { state } = ctx;
284284 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
287287 }
288288 function updateContentMetricsState(ctx) {
289289 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) {
291291 totalSize += add;
292292 }
293293 if (prevTotalSize !== totalSize) {
@@ -298,7 +298,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines
298298 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) {
299299 state.pendingTotalSize = totalSize;
300300 } else {
301−@@ -1304,18 +1308,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
301+@@ -1304,18 +1317,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
302302 }
303303
304304 // src/core/clampScrollOffset.ts
@@ -324,7 +324,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 19 unmodified lines
324324 return clampedOffset;
325325 }
326326
327−@@ -1451,10 +1460,10 @@ function checkFinishedScrollFrame(ctx) {
327+@@ -1451,10 +1469,10 @@ function checkFinishedScrollFrame(ctx) {
328328 finishScrollTo(ctx);
329329 }
330330 }
@@ -337,7 +337,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines
337337 x: ctx.state.props.horizontal ? offset : 0,
338338 y: ctx.state.props.horizontal ? 0 : offset
339339 });
340−@@ -1503,7 +1512,10 @@ function checkFinishedScrollFallback(ctx) {
340+@@ -1503,7 +1521,10 @@ function checkFinishedScrollFallback(ctx) {
341341 );
342342 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
343343 } else if (shouldRetryUnalignedEndScroll) {
@@ -349,7 +349,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 5 unmodified lines
349349 scheduleFallbackCheck(100);
350350 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
351351 finishScrollTo(ctx);
352−@@ -1560,15 +1572,28 @@ function doMaintainScrollAtEnd(ctx) {
352+@@ -1560,15 +1581,28 @@ function doMaintainScrollAtEnd(ctx) {
353353 } = state;
354354 const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
355355 const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
@@ -379,7 +379,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 23 unmodified lines
379379 }
380380 if (!state.maintainingScrollAtEnd) {
381381 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
382−@@ -1591,9 +1616,18 @@ function doMaintainScrollAtEnd(ctx) {
382+@@ -1591,9 +1625,18 @@ function doMaintainScrollAtEnd(ctx) {
383383 y: 0
384384 });
385385 } else {
@@ -401,7 +401,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 15 unmodified lines
401401 }
402402 setTimeout(
403403 () => {
404−@@ -1624,6 +1658,10 @@ function doMaintainScrollAtEnd(ctx) {
404+@@ -1624,6 +1667,10 @@ function doMaintainScrollAtEnd(ctx) {
405405 function requestAdjust(ctx, positionDiff, dataChanged) {
406406 const state = ctx.state;
407407 if (Math.abs(positionDiff) > 0.1) {
@@ -412,7 +412,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines
412412 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
413413 const doit = () => {
414414 if (needsScrollWorkaround) {
415−@@ -1728,7 +1766,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
415+@@ -1728,7 +1775,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
416416 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
417417 return 0;
418418 }
@@ -423,7 +423,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines
423423 const clampDelta = maxScroll - state.scroll;
424424 if (unresolvedAmount < 0) {
425425 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) {
427427 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
428428 return true;
429429 }
@@ -432,7 +432,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines
432432 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
433433 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
434434 if (isAtExpectedNativeClamp) {
435−@@ -1923,7 +1963,7 @@ function prepareMVCP(ctx, dataChanged) {
435+@@ -1923,7 +1972,7 @@ function prepareMVCP(ctx, dataChanged) {
436436 if (diff > 0) {
437437 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
438438 } else {
@@ -441,7 +441,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines
441441 state.scroll = maxScroll;
442442 state.scrollPending = maxScroll;
443443 diff = 0;
444−@@ -2320,8 +2360,121 @@ function scrollToIndex(ctx, {
444+@@ -2320,8 +2369,121 @@ function scrollToIndex(ctx, {
445445 }
446446
447447 // src/core/initialScroll.ts
@@ -563,7 +563,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 115 unmodified lines
563563 const requestedIndex = target.index;
564564 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
565565 const itemSize = getItemSizeAtIndex(ctx, index);
566−@@ -2747,7 +2900,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
566+@@ -2747,7 +2909,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
567567 return;
568568 }
569569 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
@@ -574,7 +574,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 4 unmodified lines
574574 if (!shouldKeepEndTargetAlive) {
575575 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
576576 clearPendingInitialScrollFooterLayout(ctx, {
577−@@ -4672,7 +4827,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
577+@@ -4672,7 +4836,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
578578 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
579579 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
580580 isReady = !hasUnknownTailSize;
@@ -584,7 +584,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 3 unmodified lines
584584 } else if (anchorIndex >= 0) {
585585 isReady = false;
586586 }
587−@@ -4692,6 +4848,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
587+@@ -4692,6 +4857,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
588588 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
589589 }
590590 (_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
597597 }
598598 return nextSize;
599599 }
600−@@ -5715,6 +5877,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
600+@@ -5715,6 +5886,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
601601 horizontal
602602 }) {
603603 const ctx = useStateContext();
@@ -605,7 +605,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line
605605 const columnWrapperStyle = ctx.columnWrapperStyle;
606606 const animSize = useValue$("totalSize");
607607 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({
609609 opacity: isVisible ? 1 : 0,
610610 width: animSize
611611 } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
@@ -619,7 +619,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 7 unmodified lines
619619 if (columnWrapperStyle) {
620620 const { columnGap, rowGap, gap } = columnWrapperStyle;
621621 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({
623623 }
624624 }
625625 }
@@ -629,7 +629,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 3 unmodified lines
629629 });
630630 var Containers = typedMemo(function Containers2({
631631 freshDataTransitionEpoch,
632−@@ -5896,7 +6067,12 @@ var StyleSheet = ReactNative.StyleSheet;
632+@@ -5896,7 +6076,12 @@ var StyleSheet = ReactNative.StyleSheet;
633633
634634 // src/components/ListComponent.tsx
635635 var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
@@ -642,7 +642,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines
642642 if (alignItemsAtEndPadding <= 0) {
643643 return null;
644644 }
645−@@ -5929,8 +6105,12 @@ var ListComponent = typedMemo(function ListComponent2({
645+@@ -5929,8 +6114,12 @@ var ListComponent = typedMemo(function ListComponent2({
646646 refScrollView,
647647 renderScrollComponent,
648648 onLayoutFooter,
@@ -655,7 +655,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines
655655 scrollAdjustHandler,
656656 snapToIndices,
657657 stickyHeaderConfig,
658−@@ -6001,7 +6181,17 @@ var ListComponent = typedMemo(function ListComponent2({
658+@@ -6001,7 +6190,17 @@ var ListComponent = typedMemo(function ListComponent2({
659659 SnapOrScroll,
660660 {
661661 ...rest,
@@ -674,7 +674,16 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 12 unmodified lines
674674 contentContainerStyle: [
675675 horizontal ? { height: "100%" } : {},
676676 contentContainerStyle,
677−@@ -7075,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
677+@@ -6751,7 +6950,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
678+ endBuffered: state.endBuffered,
679+ getAverageItemSizes: () => getAverageItemSizes(state),
680+ indexByKey: (key) => state.indexByKey.get(key),
681+- isAtEnd: peek$(ctx, "isAtEnd"),
682++ isAtEnd: getIsAtEnd(ctx),
683+ isAtStart: peek$(ctx, "isAtStart"),
684+ isEndReached: state.isEndReached,
685+ isNearEnd: peek$(ctx, "isNearEnd"),
686+@@ -7075,6 +7274,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
678687 dataVersion,
679688 drawDistance = 250,
680689 contentInsetEndAdjustment,
@@ -682,7 +691,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line
682691 estimatedItemSize = 100,
683692 estimatedListSize,
684693 extraData,
685−@@ -7132,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
694+@@ -7132,10 +7332,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
686695 const animatedPropsInternal = props.animatedPropsInternal;
687696 const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
688697 const positionComponentInternal = props.positionComponentInternal;
@@ -695,7 +704,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 6 unmodified lines
695704 stickyPositionComponentInternal: _stickyPositionComponentInternal,
696705 ...restProps
697706 } = rest;
698−@@ -7200,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
707+@@ -7200,7 +7402,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
699708 const combinedRef = useCombinedRef(refScroller, refScrollView);
700709 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
701710 const stickyHeaderIndices = stickyHeaderIndicesProp;
@@ -704,7 +713,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines
704713 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
705714 const alwaysRenderIndices = React2.useMemo(() => {
706715 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
707−@@ -7341,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
716+@@ -7341,6 +7543,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
708717 contentContainerAlignItems: contentContainerStyle.alignItems,
709718 contentInset,
710719 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
@@ -712,7 +721,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line
712721 data: dataProp,
713722 dataKey,
714723 dataVersion,
715−@@ -7372,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
724+@@ -7372,6 +7575,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
716725 renderItem,
717726 rtl,
718727 snapToIndices,
@@ -720,7 +729,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line
720729 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
721730 stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
722731 stickyPositionComponentInternal,
723−@@ -7423,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
732+@@ -7423,6 +7627,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
724733 return void 0;
725734 }
726735 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
@@ -734,7 +743,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 7 unmodified lines
734743 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
735744 }, [usesBootstrapInitialScroll]);
736745 React2.useLayoutEffect(() => {
737−@@ -7547,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
746+@@ -7547,6 +7758,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
738747 [
739748 dataKey,
740749 dataVersion,
@@ -742,7 +751,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line
742751 memoizedLastItemKeys.join(","),
743752 numColumnsProp,
744753 nextScrollAxisGap,
745−@@ -7643,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
754+@@ -7643,6 +7855,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
746755 () => ({
747756 getRenderedItem: (key) => getRenderedItem(ctx, key),
748757 onMomentumScrollEnd: (event) => {
@@ -750,7 +759,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 1 unmodified line
750759 checkFinishedScrollFallback(ctx);
751760 if (state.props.onMomentumScrollEnd) {
752761 state.props.onMomentumScrollEnd(event);
753−@@ -7651,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
762+@@ -7651,6 +7864,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
754763 onScroll: (event) => onScroll(ctx, event),
755764 onScrollBeginDrag: (event) => {
756765 var _a4, _b2;
@@ -759,7 +768,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 2 unmodified lines
759768 prepareReachedEdgeForNextUserScroll(ctx);
760769 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
761770 },
762−@@ -7676,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
771+@@ -7676,11 +7891,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
763772 ListFooterComponent,
764773 ListFooterComponentStyle,
765774 ListHeaderComponent,
@@ -779,7 +788,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..eb51ff0c375d7e5bd3473fca83995c7c · 13 unmodified lines
779788 recycleItems,
780789 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
781790 diff --git a/react-native.mjs b/react-native.mjs
782−index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318d57e29dc 100644
791+index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff7e3ad022 100644
783792 --- a/react-native.mjs
784793 +++ b/react-native.mjs
785794 @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) {
@@ -816,7 +825,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 30 unmodified lines
816825 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
817826 set$(ctx, "readyToRender", true);
818827 setAdaptiveRender(ctx, "normal", "ready");
819−@@ -1069,7 +1069,7 @@ function getRawContentLength(ctx) {
828+@@ -1069,7 +1078,7 @@ function getRawContentLength(ctx) {
820829 function getAlignItemsAtEndPadding(ctx) {
821830 const { state } = ctx;
822831 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
825834 }
826835 function updateContentMetricsState(ctx) {
827836 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) {
829838 totalSize += add;
830839 }
831840 if (prevTotalSize !== totalSize) {
@@ -836,7 +845,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines
836845 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) {
837846 state.pendingTotalSize = totalSize;
838847 } else {
839−@@ -1283,18 +1287,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
848+@@ -1283,18 +1296,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
840849 }
841850
842851 // src/core/clampScrollOffset.ts
@@ -862,7 +871,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 19 unmodified lines
862871 return clampedOffset;
863872 }
864873
865−@@ -1430,10 +1439,10 @@ function checkFinishedScrollFrame(ctx) {
874+@@ -1430,10 +1448,10 @@ function checkFinishedScrollFrame(ctx) {
866875 finishScrollTo(ctx);
867876 }
868877 }
@@ -875,7 +884,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines
875884 x: ctx.state.props.horizontal ? offset : 0,
876885 y: ctx.state.props.horizontal ? 0 : offset
877886 });
878−@@ -1482,7 +1491,10 @@ function checkFinishedScrollFallback(ctx) {
887+@@ -1482,7 +1500,10 @@ function checkFinishedScrollFallback(ctx) {
879888 );
880889 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
881890 } else if (shouldRetryUnalignedEndScroll) {
@@ -887,7 +896,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 5 unmodified lines
887896 scheduleFallbackCheck(100);
888897 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
889898 finishScrollTo(ctx);
890−@@ -1539,15 +1551,28 @@ function doMaintainScrollAtEnd(ctx) {
899+@@ -1539,15 +1560,28 @@ function doMaintainScrollAtEnd(ctx) {
891900 } = state;
892901 const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
893902 const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
@@ -917,7 +926,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 23 unmodified lines
917926 }
918927 if (!state.maintainingScrollAtEnd) {
919928 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
920−@@ -1570,9 +1595,18 @@ function doMaintainScrollAtEnd(ctx) {
929+@@ -1570,9 +1604,18 @@ function doMaintainScrollAtEnd(ctx) {
921930 y: 0
922931 });
923932 } else {
@@ -939,7 +948,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 15 unmodified lines
939948 }
940949 setTimeout(
941950 () => {
942−@@ -1603,6 +1637,10 @@ function doMaintainScrollAtEnd(ctx) {
951+@@ -1603,6 +1646,10 @@ function doMaintainScrollAtEnd(ctx) {
943952 function requestAdjust(ctx, positionDiff, dataChanged) {
944953 const state = ctx.state;
945954 if (Math.abs(positionDiff) > 0.1) {
@@ -950,7 +959,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines
950959 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
951960 const doit = () => {
952961 if (needsScrollWorkaround) {
953−@@ -1707,7 +1745,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
962+@@ -1707,7 +1754,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
954963 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
955964 return 0;
956965 }
@@ -961,7 +970,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines
961970 const clampDelta = maxScroll - state.scroll;
962971 if (unresolvedAmount < 0) {
963972 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) {
965974 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
966975 return true;
967976 }
@@ -970,7 +979,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines
970979 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
971980 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
972981 if (isAtExpectedNativeClamp) {
973−@@ -1902,7 +1942,7 @@ function prepareMVCP(ctx, dataChanged) {
982+@@ -1902,7 +1951,7 @@ function prepareMVCP(ctx, dataChanged) {
974983 if (diff > 0) {
975984 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
976985 } else {
@@ -979,7 +988,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines
979988 state.scroll = maxScroll;
980989 state.scrollPending = maxScroll;
981990 diff = 0;
982−@@ -2299,8 +2339,121 @@ function scrollToIndex(ctx, {
991+@@ -2299,8 +2348,121 @@ function scrollToIndex(ctx, {
983992 }
984993
985994 // src/core/initialScroll.ts
@@ -1101,7 +1110,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 115 unmodified lines
11011110 const requestedIndex = target.index;
11021111 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
11031112 const itemSize = getItemSizeAtIndex(ctx, index);
1104−@@ -2726,7 +2879,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
1113+@@ -2726,7 +2888,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
11051114 return;
11061115 }
11071116 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
@@ -1112,7 +1121,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 4 unmodified lines
11121121 if (!shouldKeepEndTargetAlive) {
11131122 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
11141123 clearPendingInitialScrollFooterLayout(ctx, {
1115−@@ -4651,7 +4806,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1124+@@ -4651,7 +4815,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
11161125 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
11171126 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
11181127 isReady = !hasUnknownTailSize;
@@ -1122,7 +1131,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 3 unmodified lines
11221131 } else if (anchorIndex >= 0) {
11231132 isReady = false;
11241133 }
1125−@@ -4671,6 +4827,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1134+@@ -4671,6 +4836,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
11261135 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
11271136 }
11281137 (_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
11351144 }
11361145 return nextSize;
11371146 }
1138−@@ -5694,6 +5856,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
1147+@@ -5694,6 +5865,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
11391148 horizontal
11401149 }) {
11411150 const ctx = useStateContext();
@@ -1143,7 +1152,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line
11431152 const columnWrapperStyle = ctx.columnWrapperStyle;
11441153 const animSize = useValue$("totalSize");
11451154 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({
11471156 opacity: isVisible ? 1 : 0,
11481157 width: animSize
11491158 } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
@@ -1157,7 +1166,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 7 unmodified lines
11571166 if (columnWrapperStyle) {
11581167 const { columnGap, rowGap, gap } = columnWrapperStyle;
11591168 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({
11611170 }
11621171 }
11631172 }
@@ -1167,7 +1176,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 3 unmodified lines
11671176 });
11681177 var Containers = typedMemo(function Containers2({
11691178 freshDataTransitionEpoch,
1170−@@ -5875,7 +6046,12 @@ var StyleSheet = StyleSheet$1;
1179+@@ -5875,7 +6055,12 @@ var StyleSheet = StyleSheet$1;
11711180
11721181 // src/components/ListComponent.tsx
11731182 var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
@@ -1180,7 +1189,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines
11801189 if (alignItemsAtEndPadding <= 0) {
11811190 return null;
11821191 }
1183−@@ -5908,8 +6084,12 @@ var ListComponent = typedMemo(function ListComponent2({
1192+@@ -5908,8 +6093,12 @@ var ListComponent = typedMemo(function ListComponent2({
11841193 refScrollView,
11851194 renderScrollComponent,
11861195 onLayoutFooter,
@@ -1193,7 +1202,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines
11931202 scrollAdjustHandler,
11941203 snapToIndices,
11951204 stickyHeaderConfig,
1196−@@ -5980,7 +6160,17 @@ var ListComponent = typedMemo(function ListComponent2({
1205+@@ -5980,7 +6169,17 @@ var ListComponent = typedMemo(function ListComponent2({
11971206 SnapOrScroll,
11981207 {
11991208 ...rest,
@@ -1212,7 +1221,16 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 12 unmodified lines
12121221 contentContainerStyle: [
12131222 horizontal ? { height: "100%" } : {},
12141223 contentContainerStyle,
1215−@@ -7054,6 +7244,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1224+@@ -6730,7 +6929,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
1225+ endBuffered: state.endBuffered,
1226+ getAverageItemSizes: () => getAverageItemSizes(state),
1227+ indexByKey: (key) => state.indexByKey.get(key),
1228+- isAtEnd: peek$(ctx, "isAtEnd"),
1229++ isAtEnd: getIsAtEnd(ctx),
1230+ isAtStart: peek$(ctx, "isAtStart"),
1231+ isEndReached: state.isEndReached,
1232+ isNearEnd: peek$(ctx, "isNearEnd"),
1233+@@ -7054,6 +7253,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
12161234 dataVersion,
12171235 drawDistance = 250,
12181236 contentInsetEndAdjustment,
@@ -1220,7 +1238,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line
12201238 estimatedItemSize = 100,
12211239 estimatedListSize,
12221240 extraData,
1223−@@ -7111,10 +7302,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1241+@@ -7111,10 +7311,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
12241242 const animatedPropsInternal = props.animatedPropsInternal;
12251243 const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
12261244 const positionComponentInternal = props.positionComponentInternal;
@@ -1233,7 +1251,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 6 unmodified lines
12331251 stickyPositionComponentInternal: _stickyPositionComponentInternal,
12341252 ...restProps
12351253 } = rest;
1236−@@ -7179,7 +7372,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1254+@@ -7179,7 +7381,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
12371255 const combinedRef = useCombinedRef(refScroller, refScrollView);
12381256 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
12391257 const stickyHeaderIndices = stickyHeaderIndicesProp;
@@ -1242,7 +1260,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines
12421260 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
12431261 const alwaysRenderIndices = useMemo(() => {
12441262 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
1245−@@ -7320,6 +7513,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1263+@@ -7320,6 +7522,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
12461264 contentContainerAlignItems: contentContainerStyle.alignItems,
12471265 contentInset,
12481266 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
@@ -1250,7 +1268,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line
12501268 data: dataProp,
12511269 dataKey,
12521270 dataVersion,
1253−@@ -7351,6 +7545,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1271+@@ -7351,6 +7554,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
12541272 renderItem,
12551273 rtl,
12561274 snapToIndices,
@@ -1258,7 +1276,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line
12581276 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
12591277 stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
12601278 stickyPositionComponentInternal,
1261−@@ -7402,6 +7597,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1279+@@ -7402,6 +7606,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
12621280 return void 0;
12631281 }
12641282 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
@@ -1272,7 +1290,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 7 unmodified lines
12721290 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
12731291 }, [usesBootstrapInitialScroll]);
12741292 useLayoutEffect(() => {
1275−@@ -7526,6 +7728,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1293+@@ -7526,6 +7737,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
12761294 [
12771295 dataKey,
12781296 dataVersion,
@@ -1280,7 +1298,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line
12801298 memoizedLastItemKeys.join(","),
12811299 numColumnsProp,
12821300 nextScrollAxisGap,
1283−@@ -7622,6 +7825,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1301+@@ -7622,6 +7834,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
12841302 () => ({
12851303 getRenderedItem: (key) => getRenderedItem(ctx, key),
12861304 onMomentumScrollEnd: (event) => {
@@ -1288,7 +1306,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 1 unmodified line
12881306 checkFinishedScrollFallback(ctx);
12891307 if (state.props.onMomentumScrollEnd) {
12901308 state.props.onMomentumScrollEnd(event);
1291−@@ -7630,6 +7834,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1309+@@ -7630,6 +7843,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
12921310 onScroll: (event) => onScroll(ctx, event),
12931311 onScrollBeginDrag: (event) => {
12941312 var _a4, _b2;
@@ -1297,7 +1315,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..700515b52c27a4ccd5343176121e7318 · 2 unmodified lines
12971315 prepareReachedEdgeForNextUserScroll(ctx);
12981316 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
12991317 },
1300−@@ -7655,11 +7861,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1318+@@ -7655,11 +7870,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
13011319 ListFooterComponent,
13021320 ListFooterComponentStyle,
13031321 ListHeaderComponent,
@@ -1408,7 +1426,7 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613 · 104 unmodified lines
14081426 * Number of columns to render items in.
14091427 * @default 1
14101428 diff --git a/reanimated.js b/reanimated.js
1411−index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71ff8ce9f4d 100644
1429+index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644
14121430 --- a/reanimated.js
14131431 +++ b/reanimated.js
14141432 @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
@@ -1454,7 +1472,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 39 unmodified lines
14541472 ref: refView,
14551473 style: viewStyle,
14561474 ...rest
1457−@@ -141,6 +162,101 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1475+@@ -141,6 +162,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
14581476 children
14591477 );
14601478 });
@@ -1468,12 +1486,12 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 7 unmodified lines
14681486 + const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0);
14691487 + const animationRunRef = React__namespace.useRef(0);
14701488 + const isAnimatingRef = React__namespace.useRef(false);
1471−+ const [isAnimating, setIsAnimating] = React__namespace.useState(false);
14721489 + const transition = props.layoutTransition;
14731490 + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function";
14741491 + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1475−+ const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible;
1476−+ const useAnimatedAxis = isAnimating || startsEligibleAnimation;
1492++ // Keep the animated size attached after transitions finish. Reanimated
1493++ // retains removed animated props, so switching to a static size leaves
1494++ // native scroll bounds stuck while logical measurements keep changing.
14771495 + const animatedStyle = Reanimated.useAnimatedStyle(() => {
14781496 + const size = Math.max(0, baseSize.value + animatedDelta.value);
14791497 + return horizontal ? { width: size } : { height: size };
@@ -1483,7 +1501,6 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 3 unmodified lines
14831501 + return;
14841502 + }
14851503 + isAnimatingRef.current = false;
1486−+ setIsAnimating(false);
14871504 + const state = ctx.state;
14881505 + if (state.contentSizeAnimationActiveEpoch !== epoch) {
14891506 + return;
@@ -1500,13 +1517,15 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 10 unmodified lines
15001517 + const previousLogicalSize = previousLogicalSizeRef.current;
15011518 + const delta = logicalSize - previousLogicalSize;
15021519 + 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);
15041523 + previousLogicalSizeRef.current = logicalSize;
15051524 + previousEpochRef.current = epoch;
15061525 + if (!delta) {
15071526 + return;
15081527 + }
1509−+ if (!isNewEligibleEpoch) {
1528++ if (!shouldAnimate) {
15101529 + baseSize.value = logicalSize;
15111530 + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) {
15121531 + state.contentSizeAnimationEligible = false;
@@ -1530,7 +1549,6 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 17 unmodified lines
15301549 + state.contentSizeAnimationActiveSignals.add(signalName);
15311550 + const wasAnimating = isAnimatingRef.current;
15321551 + isAnimatingRef.current = true;
1533−+ setIsAnimating(true);
15341552 + const run = ++animationRunRef.current;
15351553 + Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => {
15361554 + "worklet";
@@ -1545,18 +1563,12 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 8 unmodified lines
15451563 + }));
15461564 + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch);
15471565 + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]);
1548−+ // Keep the content container under one animated size owner because detaching an
1549−+ // animated dimension can leave its native override behind. The alignment spacer
1550−+ // is empty, so it can safely remount as a numeric View for keyboard geometry.
1551−+ const isContentContainer = signalName === "totalSize";
1552−+ const sizeStyle = isContentContainer || useAnimatedAxis ? animatedStyle : horizontal ? { width: logicalSize } : { height: logicalSize };
1553−+ const SizeView = isContentContainer || useAnimatedAxis ? Reanimated__default.default.View : ReactNative.View;
1554−+ return /* @__PURE__ */ React__namespace.createElement(SizeView, { ...rest, style: [style, sizeStyle] }, children);
1566++ return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, animatedStyle] }, children);
15551567 +});
15561568 function setSharedValueValue(sharedValue, value) {
15571569 if (!sharedValue) {
15581570 return;
1559−@@ -245,10 +361,19 @@ var LegendListForwardedRef = typedMemo(
1571+@@ -245,10 +355,19 @@ var LegendListForwardedRef = typedMemo(
15601572 );
15611573 };
15621574 }, [hasItemLayoutAnimation, recycleItems]);
@@ -1577,7 +1589,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..59e218cb79f56c34995fb86c5de5a71f · 14 unmodified lines
15771589 renderScrollComponent: renderReanimatedScrollComponent,
15781590 ...IsNewArchitecture ? { stickyPositionComponentInternal } : {}
15791591 diff --git a/reanimated.mjs b/reanimated.mjs
1580−index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a5725398942 100644
1592+index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644
15811593 --- a/reanimated.mjs
15821594 +++ b/reanimated.mjs
15831595 @@ -1,7 +1,7 @@
@@ -1632,7 +1644,7 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 48 unmodified lines
16321644 ref: refView,
16331645 style: viewStyle,
16341646 ...rest
1635−@@ -117,6 +138,101 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1647+@@ -117,6 +138,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
16361648 children
16371649 );
16381650 });
@@ -1646,12 +1658,12 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 7 unmodified lines
16461658 + const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0);
16471659 + const animationRunRef = React.useRef(0);
16481660 + const isAnimatingRef = React.useRef(false);
1649−+ const [isAnimating, setIsAnimating] = React.useState(false);
16501661 + const transition = props.layoutTransition;
16511662 + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function";
16521663 + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1653−+ const startsEligibleAnimation = canAnimate && logicalSize !== previousLogicalSizeRef.current && renderEpoch !== previousEpochRef.current && ctx.state.contentSizeAnimationEligible;
1654−+ const useAnimatedAxis = isAnimating || startsEligibleAnimation;
1664++ // Keep the animated size attached after transitions finish. Reanimated
1665++ // retains removed animated props, so switching to a static size leaves
1666++ // native scroll bounds stuck while logical measurements keep changing.
16551667 + const animatedStyle = useAnimatedStyle(() => {
16561668 + const size = Math.max(0, baseSize.value + animatedDelta.value);
16571669 + return horizontal ? { width: size } : { height: size };
@@ -1661,7 +1673,6 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 3 unmodified lines
16611673 + return;
16621674 + }
16631675 + isAnimatingRef.current = false;
1664−+ setIsAnimating(false);
16651676 + const state = ctx.state;
16661677 + if (state.contentSizeAnimationActiveEpoch !== epoch) {
16671678 + return;
@@ -1678,13 +1689,15 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 10 unmodified lines
16781689 + const previousLogicalSize = previousLogicalSizeRef.current;
16791690 + const delta = logicalSize - previousLogicalSize;
16801691 + 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);
16821695 + previousLogicalSizeRef.current = logicalSize;
16831696 + previousEpochRef.current = epoch;
16841697 + if (!delta) {
16851698 + return;
16861699 + }
1687−+ if (!isNewEligibleEpoch) {
1700++ if (!shouldAnimate) {
16881701 + baseSize.value = logicalSize;
16891702 + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) {
16901703 + state.contentSizeAnimationEligible = false;
@@ -1708,7 +1721,6 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 17 unmodified lines
17081721 + state.contentSizeAnimationActiveSignals.add(signalName);
17091722 + const wasAnimating = isAnimatingRef.current;
17101723 + isAnimatingRef.current = true;
1711−+ setIsAnimating(true);
17121724 + const run = ++animationRunRef.current;
17131725 + runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => {
17141726 + "worklet";
@@ -1723,18 +1735,12 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..bd3c680eb4abeca786d538ebf7b23a57 · 8 unmodified lines
17231735 + }));
17241736 + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch);
17251737 + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]);
1726−+ // Keep the content container under one animated size owner because detaching an
1727−+ // animated dimension can leave its native override behind. The alignment spacer
1728−+ // is empty, so it can safely remount as a numeric View for keyboard geometry.
1729−+ const isContentContainer = signalName === "totalSize";
1730−+ const sizeStyle = isContentContainer || useAnimatedAxis ? animatedStyle : horizontal ? { width: logicalSize } : { height: logicalSize };
1731−+ const SizeView = isContentContainer || useAnimatedAxis ? Reanimated.View : View;
1732−+ return /* @__PURE__ */ React.createElement(SizeView, { ...rest, style: [style, sizeStyle] }, children);
1738++ return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, animatedStyle] }, children);
17331739 +});
17341740 function setSharedValueValue(sharedValue, value) {
17351741 if (!sharedValue) {
17361742 return;
1737−@@ -221,10 +337,19 @@ var LegendListForwardedRef = typedMemo(
1743+@@ -221,10 +331,19 @@ var LegendListForwardedRef = typedMemo(
17381744 );
17391745 };
17401746 }, [hasItemLayoutAnimation, recycleItems]);
pnpm-lock.yaml+4−4
@@ -472,7 +472,7 @@ importers: · 471 unmodified lines
472472 dependencies:
473473 '@anthropic-ai/claude-agent-sdk':
474474 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)
476476 '@connectrpc/connect':
477477 specifier: 1.7.0
478478 version: 1.7.0(@bufbuild/protobuf@1.10.0)
@@ -1012,8 +1012,8 @@ packages: · 533 unmodified lines
10121012 '@alchemy.run/node-utils@0.0.5':
10131013 resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==}
10141014
1015− '@anthropic-ai/claude-agent-sdk@0.3.251':
1016− resolution: {integrity: sha512-DqSi8mH2tQYRlVV0G+lJnQ/WbjJZ/a+8cJ3vPuYoqh8esIIvXHm1ZOXV1UPGsFYRnbBytEoiSGitguEXd+sQ+Q==}
1015+ '@anthropic-ai/claude-agent-sdk@0.3.252':
1016+ resolution: {integrity: sha512-hCkyZFn3J46aAMNqS6AZbYz91FaLUmX5VvJOzYZqzlVBJN47OxXQugqOzqa6b6GOZRmwiqW2ck8J8TE7bQZswQ==}
10171017 engines: {node: '>=18.0.0'}
10181018 peerDependencies:
10191019 '@anthropic-ai/sdk': '>=0.93.0'
@@ -10540,7 +10540,7 @@ snapshots: · 9520 unmodified lines
1054010540
1054110541 '@alchemy.run/node-utils@0.0.5': {}
1054210542
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)':
1054410544 dependencies:
1054510545 '@anthropic-ai/sdk': 0.93.0(zod@4.4.3)
1054610546 '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)