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
681 onTouchCancel={handleFeedTouchCancel}681 onTouchCancel={handleFeedTouchCancel}
682 >682 >
683 <ThreadFeed683 <ThreadFeed
684 key={props.selectedThread.id}684 key={selectedThreadKey}
685 environmentId={props.environmentId}685 environmentId={props.environmentId}
686 threadId={props.selectedThread.id}686 threadId={props.selectedThread.id}
687 workspaceRoot={props.threadCwd}687 workspaceRoot={props.threadCwd}
apps/mobile/src/features/threads/ThreadFeed.tsx+196−89
@@ -1,6 +1,6 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
4import { scopeThreadRef } from "@t3tools/client-runtime/environment";4import { scopeThreadRef } from "@t3tools/client-runtime/environment";
5import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";5import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
6import {6import {
@@ -79,7 +79,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 72 unmodified lines
79import Animated, {79import Animated, {
80 FadeIn,80 FadeIn,
81 FadeInUp,81 FadeInUp,
82 FadeOut,
83 LinearTransition,82 LinearTransition,
84 type SharedValue,83 type SharedValue,
85} from "react-native-reanimated";84} from "react-native-reanimated";
@@ -150,13 +149,19 @@ import { · 64 unmodified lines
150 type ThreadFeedLiveFollowEvent,149 type ThreadFeedLiveFollowEvent,
151} from "./thread-feed-live-follow";150} from "./thread-feed-live-follow";
152import {151import {
152 ThreadDisclosureChevron,
153 THREAD_DISCLOSURE_TRANSITION_MS,153 THREAD_DISCLOSURE_TRANSITION_MS,
154 ThreadWorkGroupToggle,154 ThreadWorkGroupToggle,
155 ThreadWorkLog,155 ThreadWorkLog,
156} from "./thread-work-log";156} from "./thread-work-log";
157import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";157import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
158import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";158import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
159import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets";159import {
160 assetEnvironment,
161 useAssetUrl,
162 useAssetUrlState,
163 useRefreshAssetUrl,
164} from "../../state/assets";
160import { useAtomQueryRunner } from "../../state/use-atom-query-runner";165import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
161import { usePreparedConnection } from "../../state/session";166import { usePreparedConnection } from "../../state/session";
162import * as Option from "effect/Option";167import * as Option from "effect/Option";
@@ -191,9 +196,10 @@ function formatMessageTime(input: string): string { · 28 unmodified lines
191}196}
192 197
193const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);198const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
194const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);199// Let neighboring rows move out of the new rows' space before showing their text.
195const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);200const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
196const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();201 THREAD_DISCLOSURE_TRANSITION_MS,
202).duration(140);
197 203
198// Entering animations must only play for rows born just now — LegendList204// Entering animations must only play for rows born just now — LegendList
199// remounts rows when they scroll back into view, and replaying an entrance for205// remounts rows when they scroll back into view, and replaying an entrance for
@@ -340,6 +346,7 @@ function MessageAttachmentFile(props: { · 140 unmodified lines
340}) {346}) {
341 const sourceIdentifier = useId();347 const sourceIdentifier = useId();
342 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {348 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
349 refresh: true,
343 reportFailure: false,350 reportFailure: false,
344 });351 });
345 const preparedConnection = usePreparedConnection(props.environmentId);352 const preparedConnection = usePreparedConnection(props.environmentId);
@@ -557,9 +564,11 @@ function ThreadMarkdownImageView(props: { · 211 unmodified lines
557 readonly sourceKey: string;564 readonly sourceKey: string;
558 readonly unavailable: boolean;565 readonly unavailable: boolean;
559 readonly alt: string | null;566 readonly alt: string | null;
567 readonly actionsSource?: MediaActionsSource;
560 readonly onPressPreview: (source: FilePreviewSource) => void;568 readonly onPressPreview: (source: FilePreviewSource) => void;
561}) {569}) {
562 const sourceIdentifier = useId();570 const sourceIdentifier = useId();
571 const mediaActions = useMediaActions(props.actionsSource);
563 const [availableWidth, setAvailableWidth] = useState(0);572 const [availableWidth, setAvailableWidth] = useState(0);
564 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);573 const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
565 const [failedUri, setFailedUri] = useState<string | null>(null);574 const [failedUri, setFailedUri] = useState<string | null>(null);
@@ -602,36 +611,51 @@ function ThreadMarkdownImageView(props: { · 36 unmodified lines
602 ) : (611 ) : (
603 <ActivityIndicator />612 <ActivityIndicator />
604 )}613 )}
614 {props.actionsSource ? (
615 <View className="absolute right-1 top-1">
616 <MediaActionsMenu media={mediaActions} />
617 </View>
618 ) : null}
605 </View>619 </View>
606 ) : (620 ) : (
607 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>621 <PresentationSource identifier={sourceIdentifier} style={{ alignSelf: "flex-start" }}>
608 <Pressable622 <View>
609 accessibilityRole="imagebutton"623 <MediaActionsMenu media={mediaActions}>
610 accessibilityLabel={props.alt ?? "Markdown image"}624 <Pressable
611 onPress={() =>625 accessibilityRole="imagebutton"
612 props.onPressPreview({626 accessibilityLabel={props.alt ?? "Markdown image"}
613 kind: "image",627 onPress={() =>
614 uri: props.uri!,628 props.onPressPreview({
615 name: props.alt ?? "Image",629 kind: "image",
616 sourceIdentifier,630 uri: props.uri!,
617 })631 name: props.alt ?? "Image",
618 }632 sourceIdentifier,
619 style={{ alignSelf: "flex-start" }}633 actionsSource: props.actionsSource,
620 >634 })
621 <View635 }
622 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"636 style={{ alignSelf: "flex-start" }}
623 style={{637 >
624 ...frameStyle,638 <View
625 }}639 className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg"
626 >640 style={{
627 <ThreadMarkdownImageRequest641 ...frameStyle,
628 key={props.uri}642 }}
629 uri={props.uri}643 >
630 onLoad={setSourceSize}644 <ThreadMarkdownImageRequest
631 onError={() => setFailedUri(props.uri)}645 key={props.uri}
632 />646 uri={props.uri}
633 </View>647 onLoad={setSourceSize}
634 </Pressable>648 onError={() => setFailedUri(props.uri)}
649 />
650 </View>
651 </Pressable>
652 </MediaActionsMenu>
653 {props.actionsSource ? (
654 <View className="absolute right-1 top-1">
655 <MediaActionsMenu media={mediaActions} />
656 </View>
657 ) : null}
658 </View>
635 </PresentationSource>659 </PresentationSource>
636 )}660 )}
637 {props.alt ? (661 {props.alt ? (
@@ -678,9 +702,10 @@ function ThreadMarkdownImageRequest(props: { · 40 unmodified lines
678/** Environment-hosted image that loads through a signed asset URL. */702/** Environment-hosted image that loads through a signed asset URL. */
679function ThreadMarkdownImage(props: {703function ThreadMarkdownImage(props: {
680 readonly environmentId: EnvironmentId;704 readonly environmentId: EnvironmentId;
681 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;705 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
682 readonly alt: string | null;706 readonly alt: string | null;
683 readonly srcFragment?: string;707 readonly srcFragment?: string;
708 readonly actionsSource?: MediaActionsSource;
684 readonly onPressPreview: (source: FilePreviewSource) => void;709 readonly onPressPreview: (source: FilePreviewSource) => void;
685}) {710}) {
686 const assetUrl = useAssetUrlState(props.environmentId, props.resource);711 const assetUrl = useAssetUrlState(props.environmentId, props.resource);
@@ -695,11 +720,59 @@ function ThreadMarkdownImage(props: { · 8 unmodified lines
695 }720 }
696 unavailable={assetUrl._tag === "Failure"}721 unavailable={assetUrl._tag === "Failure"}
697 alt={props.alt}722 alt={props.alt}
723 actionsSource={props.actionsSource}
698 onPressPreview={props.onPressPreview}724 onPressPreview={props.onPressPreview}
699 />725 />
700 );726 );
701}727}
702 728
729const ThreadMediaVisibleContext = createContext(false);
730// LegendList only computes hook visibility when the list has a viewability config.
731const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };
732
733function ThreadMediaVisibility(props: { readonly children: ReactNode }) {
734 const [visible, setVisible] = useState(false);
735 useViewabilityAmount<ThreadFeedEntry>(
736 useCallback((token) => setVisible(token.sizeVisible > 0), []),
737 );
738 return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>;
739}
740
741function ThreadMarkdownVideo(props: {
742 readonly source: MediaVideoPreviewSource;
743 readonly onExpand: (source: MediaVideoPreviewSource) => void;
744}) {
745 const { source } = props;
746 const visible = useContext(ThreadMediaVisibleContext);
747 const thumbnailKey = mediaVideoThumbnailKey(source);
748 const asset = useAssetUrlState(
749 "environmentId" in source ? source.environmentId : null,
750 "resource" in source ? source.resource : null,
751 );
752 const refreshAssetUrl = useRefreshAssetUrl(
753 "environmentId" in source ? source.environmentId : null,
754 "resource" in source ? source.resource : null,
755 );
756 const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
757 return (
758 <MediaVideoPlayer
759 key={thumbnailKey}
760 uri={uri}
761 resolvePlaybackUri={
762 "resource" in source
763 ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl())
764 : undefined
765 }
766 name={source.name}
767 thumbnailKey={thumbnailKey}
768 thumbnailVisible={visible}
769 unavailable={"resource" in source && asset._tag === "Failure"}
770 actionsSource={source.actionsSource}
771 onExpand={() => props.onExpand(source)}
772 />
773 );
774}
775
703function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {776function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) {
704 return (777 return (
705 <ThreadMarkdownImageView778 <ThreadMarkdownImageView
@@ -1287,21 +1360,16 @@ function useMarkdownStyles( · 581 unmodified lines
1287 title: node.title ?? null,1360 title: node.title ?? null,
1288 }) ?? undefined)1361 }) ?? undefined)
1289 : undefined,1362 : undefined,
1290 code_inline: ({ content }) => {1363 code_inline: ({ content }) => (
1291 const value = content ?? "";1364 <MarkdownInlineCode
1292 return (1365 content={content ?? ""}
1293 <NativeText1366 textColor={inlineTextColor}
1294 className="font-mono"1367 codeColor={inlineCodeTextColor}
1295 style={{1368 fontSize={markdownFontSizes.codeBlockFontSize}
1296 color: inlineCodeTextColor,1369 lineHeight={markdownFontSizes.bodyLineHeight}
1297 fontSize: markdownFontSizes.codeBlockFontSize,1370 onLinkPress={onLinkPress}
1298 lineHeight: markdownFontSizes.bodyLineHeight,1371 />
1299 }}1372 ),
1300 >
1301 {value}
1302 </NativeText>
1303 );
1304 },
1305 ...(preserveSoftBreaks1373 ...(preserveSoftBreaks
1306 ? {1374 ? {
1307 soft_break: () => <NativeText>{"\n"}</NativeText>,1375 soft_break: () => <NativeText>{"\n"}</NativeText>,
@@ -1504,7 +1572,7 @@ function renderFeedEntry( · 196 unmodified lines
1504 accessibilityState={{ expanded: entry.expanded }}1572 accessibilityState={{ expanded: entry.expanded }}
1505 onPress={() => props.onToggleTurnFold(entry.runId)}1573 onPress={() => props.onToggleTurnFold(entry.runId)}
1506 hitSlop={4}1574 hitSlop={4}
1507 className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"1575 className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
1508 >1576 >
1509 <Text1577 <Text
1510 key={props.workRowSizing.textSizeKey}1578 key={props.workRowSizing.textSizeKey}
@@ -1516,8 +1584,7 @@ function renderFeedEntry( · 5 unmodified lines
1516 expanded={entry.expanded}1584 expanded={entry.expanded}
1517 collapsedDirection="right"1585 collapsedDirection="right"
1518 size={15}1586 size={15}
1519 tintColorClassName={"accent-icon-subtle"}1587 tintColor={iconSubtleColor}
1520 type="monochrome"
1521 />1588 />
1522 </Pressable>1589 </Pressable>
1523 );1590 );
@@ -2092,7 +2159,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 568 unmodified lines
2092 const [endFollowEnabled, setEndFollowEnabled] = useState(true);2159 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
2093 const endFollowEnabledRef = useRef(true);2160 const endFollowEnabledRef = useRef(true);
2094 // A "user scroll session" spans from drag start through the end of its2161 // A "user scroll session" spans from drag start through the end of its
2095 // momentum; only motion inside a session can break follow, so MVCP2162 // momentum; scroll events only break follow inside that session, so MVCP
2096 // compensations and programmatic scrolls never strand a follower.2163 // compensations and programmatic scrolls never strand a follower.
2097 const userScrollSessionRef = useRef(false);2164 const userScrollSessionRef = useRef(false);
2098 const setEndFollow = useCallback(2165 const setEndFollow = useCallback(
@@ -2253,14 +2320,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 154 unmodified lines
2253 );2320 );
2254 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(2321 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
2255 (image) => {2322 (image) => {
2323 const media = resolveMarkdownMediaPreview(image.href, {
2324 environmentId: props.environmentId,
2325 threadId: props.threadId,
2326 workspaceRoot: props.workspaceRoot,
2327 imageEmbed: true,
2328 });
2329 if (media?.kind === "video") {
2330 return (
2331 <ThreadMarkdownVideo
2332 key={image.href}
2333 source={{ ...media.source, name: image.alt ?? media.source.name }}
2334 onExpand={(source) => setExpandedVideo((current) => current ?? source)}
2335 />
2336 );
2337 }
2256 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);2338 const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null);
2257 if (imageSource._tag === "Direct") {2339 if (imageSource._tag === "Direct") {
2258 return (2340 return (
2259 <ThreadMarkdownImageView2341 <ThreadMarkdownImageView
2260 uri={imageSource.uri}2342 uri={normalizeNativeMarkdownUrl(imageSource.uri)}
2261 sourceKey={imageSource.uri}2343 sourceKey={imageSource.uri}
2262 unavailable={false}2344 unavailable={false}
2263 alt={image.alt}2345 alt={image.alt}
2346 actionsSource={media?.source.actionsSource}
2264 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}2347 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
2265 />2348 />
2266 );2349 );
@@ -2272,12 +2355,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 5 unmodified lines
2272 <ThreadMarkdownImage2355 <ThreadMarkdownImage
2273 environmentId={props.environmentId}2356 environmentId={props.environmentId}
2274 resource={{2357 resource={{
2275 _tag: "workspace-file",2358 _tag: "media-file",
2276 threadId: props.threadId,2359 threadId: props.threadId,
2277 path: imageSource.path,2360 path: imageSource.path,
2278 }}2361 }}
2279 alt={image.alt}2362 alt={image.alt}
2280 srcFragment={markdownImageSourceFragment(image.href)}2363 srcFragment={markdownImageSourceFragment(image.href)}
2364 actionsSource={media?.source.actionsSource}
2281 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}2365 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
2282 />2366 />
2283 );2367 );
@@ -2290,12 +2374,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 6 unmodified lines
2290 threadId: props.threadId,2374 threadId: props.threadId,
2291 workspaceRoot: props.workspaceRoot,2375 workspaceRoot: props.workspaceRoot,
2292 });2376 });
2377 const media = viewedImage
2378 ? resolveMarkdownMediaPreview(image.href, {
2379 environmentId: props.environmentId,
2380 threadId: props.threadId,
2381 workspaceRoot: props.workspaceRoot,
2382 imageEmbed: true,
2383 })
2384 : null;
2385 const actionsSource = media?.source.actionsSource;
2293 return viewedImage ? (2386 return viewedImage ? (
2294 <ThreadMarkdownImage2387 <ThreadMarkdownImage
2295 environmentId={props.environmentId}2388 environmentId={props.environmentId}
2296 resource={viewedImage.resource}2389 resource={viewedImage.resource}
2297 alt={viewedImage.alt}2390 alt={viewedImage.alt}
2298 srcFragment={viewedImage.srcFragment}2391 srcFragment={viewedImage.srcFragment}
2392 actionsSource={
2393 actionsSource && "resource" in actionsSource
2394 ? { ...actionsSource, resource: viewedImage.resource }
2395 : undefined
2396 }
2299 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}2397 onPressPreview={(source) => setExpandedFile((current) => current ?? source)}
2300 />2398 />
2301 ) : null;2399 ) : null;
@@ -2572,13 +2670,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 270 unmodified lines
2572 }2670 }
2573 disclosureSettleFrameRef.current = requestAnimationFrame(() => {2671 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
2574 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {2672 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
2673 // A disclosure can leave the reader above the end without a drag.
2674 // Reconcile follow before a later layout or resume can re-pin it.
2675 const listState = props.listRef.current?.getState();
2676 if (listState) {
2677 transitionEndFollow({
2678 type: "disclosure-settled",
2679 isAtEnd: listState.isAtEnd,
2680 userScrollSessionActive: userScrollSessionRef.current,
2681 });
2682 }
2575 disclosureAnchorKeyRef.current = null;2683 disclosureAnchorKeyRef.current = null;
2576 setDisclosureToggleSettling(false);2684 setDisclosureToggleSettling(false);
2577 disclosureSettleFrameRef.current = null;2685 disclosureSettleFrameRef.current = null;
2578 disclosureSettleSecondFrameRef.current = null;2686 disclosureSettleSecondFrameRef.current = null;
2579 });2687 });
2580 });2688 });
2581 }, []);2689 }, [props.listRef, transitionEndFollow]);
2582 2690
2583 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {2691 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
2584 disclosureAnchorKeyRef.current = anchorKey;2692 disclosureAnchorKeyRef.current = anchorKey;
@@ -2713,44 +2821,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 128 unmodified lines
2713 (info: { item: ThreadFeedEntry; index: number }) => (2821 (info: { item: ThreadFeedEntry; index: number }) => (
2714 <Animated.View2822 <Animated.View
2715 key={info.item.id}2823 key={info.item.id}
2716 entering={2824 entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
2717 disclosureEnteringEntryIds.has(info.item.id)
2718 ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
2719 : undefined
2720 }
2721 exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
2722 >2825 >
2723 {renderFeedEntry(info, {2826 <ThreadMediaVisibility>
2724 environmentId: props.environmentId,2827 {renderFeedEntry(info, {
2725 threadId: props.threadId,2828 environmentId: props.environmentId,
2726 copiedRowId,2829 threadId: props.threadId,
2727 expandedWorkRows,2830 copiedRowId,
2728 terminalAssistantMessageIds,2831 expandedWorkRows,
2729 unsettledTurnId,2832 terminalAssistantMessageIds,
2730 onCopyWorkRow,2833 unsettledTurnId,
2731 onToggleWorkGroup,2834 onCopyWorkRow,
2732 onToggleWorkRow,2835 onToggleWorkGroup,
2733 onToggleTurnFold,2836 onToggleWorkRow,
2734 onPressPreview,2837 onToggleTurnFold,
2735 onPressVideo,2838 onPressPreview,
2736 onMarkdownLinkPress,2839 onPressVideo,
2737 renderMarkdownImage,2840 onMarkdownLinkPress,
2738 renderViewedImage,2841 renderMarkdownImage,
2739 iconSubtleColor,2842 renderViewedImage,
2740 userBubbleColor,2843 iconSubtleColor,
2741 markdownStyles,2844 userBubbleColor,
2742 reviewCommentColors,2845 markdownStyles,
2743 reviewCommentBubbleWidth,2846 reviewCommentColors,
2744 userBubbleMaxWidth,2847 reviewCommentBubbleWidth,
2745 threadTitle: props.threadTitle,2848 userBubbleMaxWidth,
2746 skills: props.skills,2849 threadTitle: props.threadTitle,
2747 workspaceRoot: props.workspaceRoot,2850 skills: props.skills,
2748 })}2851 workspaceRoot: props.workspaceRoot,
2852 })}
2853 </ThreadMediaVisibility>
2749 </Animated.View>2854 </Animated.View>
2750 ),2855 ),
2751 [2856 [
2752 copiedRowId,2857 copiedRowId,
2753 disclosureEnteringEntryIds,2858 disclosureToggleSettling,
2754 expandedWorkRows,2859 expandedWorkRows,
2755 workRowSizing,2860 workRowSizing,
2756 workGroupScrollPositions,2861 workGroupScrollPositions,
@@ -2863,6 +2968,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 106 unmodified lines
2863 data={presentedFeed}2968 data={presentedFeed}
2864 extraData={listAppearanceData}2969 extraData={listAppearanceData}
2865 renderItem={renderItem}2970 renderItem={renderItem}
2971 viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG}
2866 keyExtractor={(entry) => entry.id}2972 keyExtractor={(entry) => entry.id}
2867 getItemType={(entry) =>2973 getItemType={(entry) =>
2868 entry.type === "message" ? `message:${entry.message.role}` : entry.type2974 entry.type === "message" ? `message:${entry.message.role}` : entry.type
@@ -2938,4 +3044,5 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 69 unmodified lines
2938 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />3044 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
2939 </>3045 </>
2940 );3046 );
3047 const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
2941});3048});
apps/mobile/src/features/threads/thread-list-v2-items.tsx+26−3
@@ -23,10 +23,13 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; · 22 unmodified lines
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
24import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";24import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
25import {25import {
26 resolveThreadListV2SnoozeMenuSelection,26 resolveThreadListV2ChangeRequestState,
27 resolveThreadListV2SnoozeGateExpiryMs,27 resolveThreadListV2SnoozeGateExpiryMs,
28 resolveThreadListV2SnoozeMenuSelection,
28 resolveThreadListV2Status,29 resolveThreadListV2Status,
29 resolveThreadListV2SwipeActions,30 resolveThreadListV2SwipeActions,
31 threadHasUnseenCompletion,
32 type ThreadListV2ChangeRequestState,
30 type ThreadListV2Status,33 type ThreadListV2Status,
31} from "./threadListV2";34} from "./threadListV2";
32import { ThreadSearchMatchExcerpt } from "./thread-search-match";35import { ThreadSearchMatchExcerpt } from "./thread-search-match";
@@ -368,6 +371,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 335 unmodified lines
368 readonly canMovePinnedDown?: boolean;371 readonly canMovePinnedDown?: boolean;
369 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;372 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
370 readonly onSwipeableClose: (methods: SwipeableMethods) => void;373 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
374 /** Reports this row's live PR (state + last activity) for the partition's
375 merge and close rules. Mirrors web's onChangeRequestState. */
376 readonly onChangeRequestState?: (
377 threadKey: string,
378 changeRequest: ThreadListV2ChangeRequestState | null,
379 ) => void;
371 readonly projectCwd?: string | null;380 readonly projectCwd?: string | null;
372 readonly searchMatch?: EnvironmentThreadSearchMatch;381 readonly searchMatch?: EnvironmentThreadSearchMatch;
373 readonly searchQuery?: string;382 readonly searchQuery?: string;
@@ -390,11 +399,24 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 16 unmodified lines
390 onPinThread,399 onPinThread,
391 onUnpinThread,400 onUnpinThread,
392 onMovePinnedThread,401 onMovePinnedThread,
402 onChangeRequestState,
393 } = props;403 } = props;
394 const snoozedRow = props.snoozed === true;404 const snoozedRow = props.snoozed === true;
395 const pinnedRow = props.pinned === true;405 const pinnedRow = props.pinned === true;
396 406
397 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);407 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
408 const prState = pr?.state ?? null;
409 const prUpdatedAt = pr?.updatedAt ?? null;
410 const threadKey = `${thread.environmentId}:${thread.id}`;
411 useEffect(() => {
412 const changeRequest = resolveThreadListV2ChangeRequestState({
413 linkedPullRequest: thread.linkedPullRequest,
414 state: prState,
415 updatedAt: prUpdatedAt,
416 });
417 if (changeRequest === undefined) return;
418 onChangeRequestState?.(threadKey, changeRequest);
419 }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);
398 420
399 const theme = useUniwindTheme();421 const theme = useUniwindTheme();
400 const screenColor = theme["--color-screen"];422 const screenColor = theme["--color-screen"];
@@ -438,8 +460,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { · 37 unmodified lines
438 );460 );
439 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);461 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
440 462
441 // Swipe: the v2 primary action is the lifecycle transition. Un-settling a463 // Swipe: the v2 primary action is the lifecycle transition. Every settled
442 // settled row keeps it active until new activity clears the user override.464 // row can un-settle — explicit settles clear the override, auto-settled
465 // rows get pinned active until real activity clears the pin.
443 const canUnsettle = variant === "slim";466 const canUnsettle = variant === "slim";
444 const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);467 const [snoozeGateTick, bumpSnoozeGateTick] = useState(0);
445 const snoozeGateExpiryMs = props.snoozeSupported468 const snoozeGateExpiryMs = props.snoozeSupported
apps/mobile/src/features/threads/threadListV2.test.ts+3−1
@@ -578,7 +578,9 @@ describe("buildThreadListV2Items", () => { · 577 unmodified lines
578 ],578 ],
579 environmentId: null,579 environmentId: null,
580 searchQuery: "",580 searchQuery: "",
581 now: "2026-06-02T00:01:07.500Z",581 // Minute-floored partition clock vs precise snooze clock.
582 now: "2026-06-02T00:01:00.000Z",
583 snoozeNow: "2026-06-02T00:01:07.500Z",
582 });584 });
583 585
584 expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]);586 expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]);
apps/mobile/src/features/threads/threadListV2.ts+70−8
@@ -1,11 +1,15 @@
1import {1import {
2 effectiveSettled,
2 effectiveSnoozed,3 effectiveSnoozed,
3 hasQueuedTurnStart,4 hasQueuedTurnStart,
4 QUEUED_TURN_START_GRACE_MS,5 QUEUED_TURN_START_GRACE_MS,
5 resolveSnoozePresets,6 resolveSnoozePresets,
6 snoozeWakeLabel,7 snoozeWakeLabel,
7} from "@t3tools/client-runtime/state/thread-settled";8} from "@t3tools/client-runtime/state/thread-settled";
8import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";9import type {
10 ChangeRequestSettleSource,
11 SnoozePreset,
12} from "@t3tools/client-runtime/state/thread-settled";
9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";14import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
11import {15import {
@@ -13,7 +17,7 @@ import { · 1 unmodified line
13 resolveSettledThreadTimestamp,17 resolveSettledThreadTimestamp,
14 sortPinnedThreadsByOrderKey,18 sortPinnedThreadsByOrderKey,
15} from "@t3tools/client-runtime/state/thread-sort";19} from "@t3tools/client-runtime/state/thread-sort";
16import type { EnvironmentId, ProjectId } from "@t3tools/contracts";20import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts";
17 21
18import type { PendingNewTask } from "../../state/use-pending-new-tasks";22import type { PendingNewTask } from "../../state/use-pending-new-tasks";
19 23
@@ -32,6 +36,35 @@ export { snoozeWakeLabel }; · 12 unmodified lines
32export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready";36export type ThreadListV2Status = "approval" | "input" | "working" | "waiting" | "failed" | "ready";
33export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";37export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";
34 38
39export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource {
40 readonly linkedPullRequestKey?: string | null;
41}
42
43function linkedPullRequestKey(
44 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
45): string | null {
46 if (linkedPullRequest == null) return null;
47 return JSON.stringify([
48 linkedPullRequest.projectId,
49 linkedPullRequest.repository.toLowerCase(),
50 linkedPullRequest.number,
51 ]);
52}
53
54/** Keep the previous linked PR state while its detail query reloads. */
55export function resolveThreadListV2ChangeRequestState(input: {
56 readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined;
57 readonly state: ChangeRequestSettleSource["state"] | null;
58 readonly updatedAt: string | null;
59}): ThreadListV2ChangeRequestState | null | undefined {
60 if (input.state === null) return input.linkedPullRequest == null ? null : undefined;
61 return {
62 state: input.state,
63 updatedAt: input.updatedAt,
64 linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest),
65 };
66}
67
35export function resolveThreadListV2SnoozeMenuSelection(input: {68export function resolveThreadListV2SnoozeMenuSelection(input: {
36 readonly event: string;69 readonly event: string;
37 readonly displayedPresets: ReadonlyArray<SnoozePreset>;70 readonly displayedPresets: ReadonlyArray<SnoozePreset>;
@@ -329,7 +362,8 @@ export function buildThreadListV2ListItems(input: { · 291 unmodified lines
329 362
330/**363/**
331 * Partitions visible threads into the active card block (creation order) and364 * Partitions visible threads into the active card block (creation order) and
332 * the settled recency tail, matching the web v2 list.365 * the settled recency tail, matching the web v2 list. Mobile stores these
366 * auto-settle preferences per device.
333 */367 */
334export function buildThreadListV2Items(input: {368export function buildThreadListV2Items(input: {
335 readonly threads: ReadonlyArray<EnvironmentThreadShell>;369 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
@@ -340,6 +374,8 @@ export function buildThreadListV2Items(input: { · 4 unmodified lines
340 }> | null;374 }> | null;
341 readonly searchQuery: string;375 readonly searchQuery: string;
342 readonly matchedThreadKeys?: ReadonlySet<string>;376 readonly matchedThreadKeys?: ReadonlySet<string>;
377 /** Per-row PR reported up by visible rows ("env:threadId" keys). */
378 readonly changeRequestByKey?: ReadonlyMap<string, ThreadListV2ChangeRequestState>;
343 /** Environments whose server supports thread.settle/unsettle. Threads on379 /** Environments whose server supports thread.settle/unsettle. Threads on
344 other environments never classify as settled — the user could neither380 other environments never classify as settled — the user could neither
345 un-settle nor pin them. Absent = no gating (tests). */381 un-settle nor pin them. Absent = no gating (tests). */
@@ -347,10 +383,17 @@ export function buildThreadListV2Items(input: { · 1 unmodified line
347 /** Environments whose server supports thread.snooze/unsnooze. Same383 /** Environments whose server supports thread.snooze/unsnooze. Same
348 contract as settlementEnvironmentIds. */384 contract as settlementEnvironmentIds. */
349 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;385 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
386 readonly autoSettleAfterDays?: number;
387 readonly autoSettleOnMerge?: boolean;
350 /** Max settled rows to render; the rest are counted, not built. */388 /** Max settled rows to render; the rest are counted, not built. */
351 readonly settledLimit?: number;389 readonly settledLimit?: number;
352 /** Second-precise clock used for time-based classification. */390 /** Injectable for tests; defaults to now. */
353 readonly now: string;391 readonly now?: string;
392 /** Second-precise clock for snooze classification. Callers pass a
393 minute-quantized `now` for memoization; snooze wake times are
394 second-precise, so classifying with the floored minute would hold a
395 woken thread hidden for up to a minute. Defaults to `now`. */
396 readonly snoozeNow?: string;
354 /** Expands the snoozed shelf into rows. Collapsed is the default. */397 /** Expands the snoozed shelf into rows. Collapsed is the default. */
355 readonly snoozedShelfExpanded?: boolean;398 readonly snoozedShelfExpanded?: boolean;
356 /** Expands the settled shelf into rows. Expanded is the default. */399 /** Expands the settled shelf into rows. Expanded is the default. */
@@ -359,7 +402,10 @@ export function buildThreadListV2Items(input: { · 2 unmodified lines
359 a split-view detail can never lose its navigation row. */402 a split-view detail can never lose its navigation row. */
360 readonly selectedThreadKey?: string | null;403 readonly selectedThreadKey?: string | null;
361}): ThreadListV2Layout {404}): ThreadListV2Layout {
362 const now = input.now;405 const now = input.now ?? new Date().toISOString();
406 const snoozeNow = input.snoozeNow ?? now;
407 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
408 const autoSettleOnMerge = input.autoSettleOnMerge ?? true;
363 const query = input.searchQuery.trim().toLocaleLowerCase();409 const query = input.searchQuery.trim().toLocaleLowerCase();
364 const projectKeys = input.projectRefs410 const projectKeys = input.projectRefs
365 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))411 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
@@ -392,8 +438,16 @@ export function buildThreadListV2Items(input: { · 26 unmodified lines
392 }438 }
393 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;439 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
394 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;440 const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true;
441 const cachedChangeRequest =
442 input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
443 const changeRequest =
444 cachedChangeRequest !== null &&
445 (cachedChangeRequest.linkedPullRequestKey ?? null) ===
446 linkedPullRequestKey(thread.linkedPullRequest)
447 ? cachedChangeRequest
448 : null;
395 // Snooze outranks settlement and pinning until the thread wakes.449 // Snooze outranks settlement and pinning until the thread wakes.
396 if (supportsSnooze && effectiveSnoozed(thread, { now })) {450 if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) {
397 snoozed.push(thread);451 snoozed.push(thread);
398 if (452 if (
399 thread.snoozedUntil != null &&453 thread.snoozedUntil != null &&
@@ -404,7 +458,15 @@ export function buildThreadListV2Items(input: { · 4 unmodified lines
404 }458 }
405 continue;459 continue;
406 }460 }
407 if (supportsSettlement && thread.settledOverride === "settled") {461 if (
462 supportsSettlement &&
463 effectiveSettled(thread, {
464 now,
465 autoSettleAfterDays,
466 autoSettleOnMerge,
467 changeRequest,
468 })
469 ) {
408 settled.push(thread);470 settled.push(thread);
409 } else if (thread.pinnedAt != null) {471 } else if (thread.pinnedAt != null) {
410 pinned.push(thread);472 pinned.push(thread);