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

Showing the changes to apps/mobile/src (5 of 47 files). Show all

Unified · Split

5 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
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);