History

Expose V2 thread workflows on mobile

Julius Marminge committed 4 months ago

Commit beab58b1 parent 2fde64e16 files · +1024 −217Browse files

Unified · Split

16 files
  1. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +14 −11
  2. apps/mobile/src/features/threads/ThreadFeed.tsx +137 −104
  3. apps/mobile/src/features/threads/ThreadQueueControl.tsx +115 −0
  4. apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx +295 −0
  5. apps/mobile/src/features/threads/thread-work-log.tsx +63 −45
  6. apps/mobile/src/lib/threadActivity.test.ts +51 −0
  7. apps/mobile/src/lib/threadActivity.ts +22 −2
  8. apps/web/src/components/chat/MessagesTimeline.tsx +5 −11
  9. apps/web/src/components/chat/QueuedRunsControl.tsx +8 −26
  10. apps/web/src/components/chat/ThreadRelationshipsControl.tsx +8 −10
  11. apps/web/src/routes/debug.orchestration-v2.tsx +1 −1
  12. packages/client-runtime/package.json +6 −6
  13. packages/client-runtime/src/state/threadRelationships.test.ts +59 −1
  14. packages/client-runtime/src/state/threadRelationships.ts +0 −0
  15. packages/client-runtime/src/state/threadWorkflows.test.ts +133 −0
  16. packages/client-runtime/src/state/threadWorkflows.ts +107 −0
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+14−11
@@ -39,6 +39,8 @@ import { · 38 unmodified lines
3939 ThreadComposer,
4040 } from "./ThreadComposer";
4141 import { ThreadFeed } from "./ThreadFeed";
42+import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";
43+import { ThreadQueueControl } from "./ThreadQueueControl";
4244 import type { ThreadContentPresentation } from "./threadContentPresentation";
4345
4446 export interface ThreadDetailScreenProps {
@@ -354,6 +356,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 309 unmodified lines
354356 key={props.selectedThread.id}
355357 environmentId={props.environmentId}
356358 threadId={props.selectedThread.id}
359+ threadTitle={props.selectedThread.title}
357360 workspaceRoot={props.threadCwd}
358361 feed={props.selectedThreadFeed}
359362 contentPresentation={props.contentPresentation}
@@ -365,6 +368,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 5 unmodified lines
365368 contentInsetEndAdjustment={contentInsetEndAdjustment}
366369 contentTopInset={headerHeight}
367370 contentBottomInset={estimatedOverlayHeight}
371+ topAccessory={
372+ <ThreadRelationshipsBanner
373+ environmentId={props.environmentId}
374+ threadId={props.selectedThread.id}
375+ />
376+ }
368377 layoutVariant={layoutVariant}
369378 skills={selectedProviderSkills}
370379 />
@@ -384,17 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 13 unmodified lines
384393 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
385394 ) : null}
386395
387− {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
388− {showContent ? (
389− <KeyboardStickyView
390− style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
391− offset={{ closed: 0, opened: 0 }}
392− >
393− {/* No paddingTop here: the overlay's measured height becomes the
394− list's bottom inset, so any padding above the pill/composer
395− pushes the resting content floor up by the same amount. */}
396− <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
397− <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
396+ <ThreadQueueControl
397+ environmentId={props.environmentId}
398+ threadId={props.selectedThread.id}
399+ />
400+
398401 {props.activePendingApproval || props.activePendingUserInput ? (
399402 <Animated.View
400403 className="shrink-0 gap-3 px-4 pb-3"
apps/mobile/src/features/threads/ThreadFeed.tsx+137−104
@@ -1,7 +1,15 @@
11 import * as Haptics from "expo-haptics";
22 import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
33 import { type LegendListRef } from "@legendapp/list/react-native";
4−import type { EnvironmentId, MessageId, RunId, ThreadId } from "@t3tools/contracts";
4+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
5+import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
6+import {
7+ ThreadId,
8+ type EnvironmentId,
9+ type MessageId,
10+ type OrchestrationV2ProjectedTurnItem,
11+ type RunId,
12+} from "@t3tools/contracts";
513 import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
614 import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
715 import { SymbolView } from "../../components/AppSymbol";
@@ -78,15 +86,11 @@ import { · 70 unmodified lines
7886 } from "../review/nativeReviewDiffAdapter";
7987 import { buildReviewParsedDiff } from "../review/reviewModel";
8088 import { cn } from "../../lib/cn";
81−import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";
82−import {
83− resolveMarkdownFontSizes,
84− resolveNativeMarkdownTypography,
85− scaledTypographyLineHeight,
86−} from "../../lib/appearancePreferences";
87−import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
88−import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
89−import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
89+import type { LayoutVariant } from "../../lib/layout";
90+import { buildThreadFilesNavigation } from "../../lib/routes";
91+import { buildThreadRoutePath } from "../../lib/routes";
92+import { uuidv4 } from "../../lib/uuid";
93+import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";
9094 import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
9195 import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
9296 import {
@@ -103,6 +107,10 @@ import { · 10 unmodified lines
103107 } from "./thread-work-log";
104108 import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
105109 import { useAssetUrl } from "../../state/assets";
110+import { appAtomRegistry } from "../../state/atom-registry";
111+import { environmentThreadShells, threadEnvironment } from "../../state/threads";
112+import { useAtomCommand } from "../../state/use-atom-command";
113+import { useV2ItemSupport } from "../../state/v2-item-support";
106114 import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
107115
108116 const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
@@ -147,6 +155,7 @@ function isFreshTimestamp(input: string): boolean { · 38 unmodified lines
147155 export interface ThreadFeedProps {
148156 readonly environmentId: EnvironmentId;
149157 readonly threadId: ThreadId;
158+ readonly threadTitle: string;
150159 readonly workspaceRoot?: string | null;
151160 readonly feed: ReadonlyArray<ThreadFeedEntry>;
152161 readonly contentPresentation: ThreadContentPresentation;
@@ -158,13 +167,89 @@ export interface ThreadFeedProps { · 5 unmodified lines
158167 readonly contentInsetEndAdjustment: SharedValue<number>;
159168 readonly contentTopInset?: number;
160169 readonly contentBottomInset?: number;
161− readonly contentMaxWidth?: number;
170+ readonly topAccessory?: ReactNode;
162171 readonly layoutVariant?: LayoutVariant;
163172 readonly usesAutomaticContentInsets?: boolean;
164173 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
165174 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
166175 }
167176
177+async function waitForThreadShell(environmentId: EnvironmentId, threadId: ThreadId): Promise<void> {
178+ const atom = environmentThreadShells.threadShellAtom(scopeThreadRef(environmentId, threadId));
179+ const deadline = Date.now() + 2_000;
180+ while (appAtomRegistry.get(atom) === null && Date.now() < deadline) {
181+ await new Promise<void>((resolve) => setTimeout(resolve, 40));
182+ }
183+}
184+
185+function AssistantForkButton(props: {
186+ readonly environmentId: EnvironmentId;
187+ readonly iconColor: ColorValue;
188+ readonly projectedItem: OrchestrationV2ProjectedTurnItem;
189+ readonly sourceTitle: string;
190+}) {
191+ const support = useV2ItemSupport({
192+ environmentId: props.environmentId,
193+ sourceThreadId: props.projectedItem.sourceThreadId,
194+ sourceItemId: props.projectedItem.sourceItemId,
195+ });
196+ const forkFromRun = useAtomCommand(threadEnvironment.forkFromRun, "fork from response");
197+ const router = useRouter();
198+ const [busy, setBusy] = useState(false);
199+ const canFork = canForkProjectedAssistantItem({
200+ projectedItem: props.projectedItem,
201+ capabilities: support.providerSession?.capabilities,
202+ });
203+ const runId = props.projectedItem.item.runId;
204+
205+ if (!canFork || runId === null) return null;
206+
207+ return (
208+ <Pressable
209+ accessibilityRole="button"
210+ accessibilityLabel="Fork from this response"
211+ disabled={busy}
212+ onPress={() => {
213+ const targetThreadId = ThreadId.make(uuidv4());
214+ setBusy(true);
215+ void Haptics.selectionAsync();
216+ void forkFromRun({
217+ environmentId: props.environmentId,
218+ input: {
219+ sourceThreadId: props.projectedItem.sourceThreadId,
220+ targetThreadId,
221+ runId,
222+ title: `${props.sourceTitle} fork`,
223+ },
224+ })
225+ .then(async (result) => {
226+ if (result._tag !== "Success") return;
227+ await waitForThreadShell(props.environmentId, targetThreadId);
228+ router.push(
229+ buildThreadRoutePath({
230+ environmentId: props.environmentId,
231+ threadId: targetThreadId,
232+ }),
233+ );
234+ })
235+ .finally(() => setBusy(false));
236+ }}
237+ className="h-7 w-7 items-center justify-center disabled:opacity-40"
238+ >
239+ {busy ? (
240+ <ActivityIndicator size="small" />
241+ ) : (
242+ <SymbolView
243+ name="arrow.triangle.branch"
244+ size={13}
245+ tintColor={props.iconColor}
246+ type="monochrome"
247+ />
248+ )}
249+ </Pressable>
250+ );
251+}
252+
168253 function MessageAttachmentImage(props: {
169254 readonly environmentId: EnvironmentId;
170255 readonly attachmentId: string;
@@ -835,6 +920,7 @@ function renderFeedEntry( · 664 unmodified lines
835920 readonly reviewCommentColors: ReviewCommentColors;
836921 readonly reviewCommentBubbleWidth: number;
837922 readonly userBubbleMaxWidth: number;
923+ readonly threadTitle: string;
838924 },
839925 ) {
840926 const entry = info.item;
@@ -894,10 +980,12 @@ function renderFeedEntry( · 53 unmodified lines
894980 if (isUser) {
895981 const enterAnimated = isFreshTimestamp(message.createdAt);
896982 return (
897− <Animated.View
898− className="mb-5 items-end"
899− {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}
900− >
983+ <View className="mb-5 items-end">
984+ {message.createdBy === "agent" ? (
985+ <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
986+ Sent by another agent
987+ </Text>
988+ ) : null}
901989 <View
902990 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
903991 style={{
@@ -989,6 +1077,12 @@ function renderFeedEntry( · 85 unmodified lines
9891077 })}
9901078 {showAssistantMeta ? (
9911079 <View className="mt-1 flex-row items-center gap-1">
1080+ <AssistantForkButton
1081+ environmentId={props.environmentId}
1082+ iconColor={iconSubtleColor}
1083+ projectedItem={message.projectedItem}
1084+ sourceTitle={props.threadTitle}
1085+ />
9921086 <CopyTextButton
9931087 accessibilityLabel="Copy message"
9941088 text={message.text}
@@ -1744,6 +1838,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 749 unmodified lines
17441838 reviewCommentColors,
17451839 reviewCommentBubbleWidth,
17461840 userBubbleMaxWidth,
1841+ threadTitle: props.threadTitle,
17471842 skills: props.skills,
17481843 workspaceRoot: props.workspaceRoot,
17491844 }),
@@ -1765,6 +1860,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 15 unmodified lines
17651860 onToggleWorkGroup,
17661861 onToggleWorkRow,
17671862 props.environmentId,
1863+ props.threadTitle,
17681864 props.skills,
17691865 props.workspaceRoot,
17701866 ],
@@ -1813,95 +1909,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 42 unmodified lines
18131909 // render into — which is why the header blur was missing on threads.
18141910 scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 },
18151911 }
1816− : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
1817− {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1818− itemLayoutAnimation={feedItemLayoutTransition}
1819− // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
1820− // lets its scroll math clamp programmatic scrolls to -headerInset
1821− // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
1822− // content rest below the transparent header rather than at frame top.
1823− contentInsetStartAdjustment={usesNativeAutomaticInsets ? anchorTopInset : 0}
1824− contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1825− // UIKit's automatic behavior adds the safe-area bottom on top of the
1826− // raw contentInset the keyboard integration writes. The detail screen
1827− // under-reports the composer inset by this amount (see
1828− // ThreadDetailScreen); this tells LegendList's scroll math about the
1829− // extra so programmatic end scrolls land at the true resting offset.
1830− contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0}
1831− // The keyboard integration's offset math (end pinning, max scroll)
1832− // must add the same UIKit-added extra, or its keyboard-open end
1833− // targets land one safe-area short of the true resting offset.
1834− adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0}
1835− freeze={props.freeze}
1836− // Animated: on send, the optimistic message's dataChange fires
1837− // maintainScrollAtEnd before any render-cycle suppression could
1838− // engage — an instant snap there teleports the feed to the anchor
1839− // instead of scrolling to it. Keeping it enabled (animated) during
1840− // anchor scrolls also lets it correct a scroll that landed on a
1841− // stale end target once the anchor row finishes measuring.
1842− maintainScrollAtEnd={
1843− disclosureToggleSettling
1844− ? false
1845− : {
1846− animated: true,
1847− on: {
1848− dataChange: true,
1849− itemLayout: true,
1850− layout: true,
1851− },
1852− }
1853− }
1854− maintainVisibleContentPosition={maintainVisibleContentPosition}
1855− data={presentedFeed}
1856− extraData={listAppearanceData}
1857− renderItem={renderItem}
1858− keyExtractor={(entry) => entry.id}
1859− getItemType={(entry) =>
1860− entry.type === "message" ? `message:${entry.message.role}` : entry.type
1861− }
1862− getFixedItemSize={getFixedItemSize}
1863− // Measure rows well before they scroll into view so estimate→actual
1864− // corrections land offscreen instead of under the user's finger.
1865− drawDistance={500}
1866− keyboardShouldPersistTaps="always"
1867− keyboardDismissMode="none"
1868− keyboardLiftBehavior="whenAtEnd"
1869− // Seed the list's scroll math with the real viewport before its own
1870− // onLayout: the empty→filled remount can then tell at mount that
1871− // short content underflows the viewport and skip programmatic
1872− // positioning entirely (any offset write during screen attach races
1873− // UIKit's adjustedContentInset application and lands high or low).
1874− {...(viewportHeight > 0 && viewportWidth > 0
1875− ? { estimatedListSize: { height: viewportHeight, width: viewportWidth } }
1876− : {})}
1877− // RN's native scrollTo command clamps targets to a floor of
1878− // -contentInset.top using the RAW inset — under automatic insets the
1879− // header inset only exists in adjustedContentInset, so scrolls to
1880− // negative offsets (content top below the transparent header) get
1881− // clamped to 0. This prop disables that clamp; UIKit still bounces
1882− // user overscroll back to the adjusted rest position.
1883− scrollToOverflowEnabled
1884− estimatedItemSize={180}
1885− // Chat-style bottom alignment: when a thread is shorter than the
1886− // viewport, pad above the content so messages rest just above the
1887− // composer instead of under the header. No effect on threads that
1888− // overflow the viewport (the padding clamps to zero).
1889− alignItemsAtEnd
1890− initialScrollAtEnd
1891− onScroll={handleScroll}
1892− scrollEventThrottle={16}
1893− ListHeaderComponent={
1894− usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />
1895− }
1896− contentContainerStyle={{
1897− paddingTop: 12,
1898− paddingHorizontal: contentHorizontalPadding,
1899− }}
1900− />
1901− </View>
1902− {props.feed.length === 0 &&
1903− props.activeWorkStartedAt === null &&
1904− props.contentPresentation.kind === "ready" ? (
1912+ }
1913+ maintainVisibleContentPosition
1914+ data={presentedFeed}
1915+ extraData={listAppearanceData}
1916+ renderItem={renderItem}
1917+ keyExtractor={(entry) => entry.id}
1918+ getItemType={(entry) =>
1919+ entry.type === "message" ? `message:${entry.message.role}` : entry.type
1920+ }
1921+ keyboardShouldPersistTaps="always"
1922+ keyboardDismissMode="none"
1923+ keyboardLiftBehavior="whenAtEnd"
1924+ estimatedItemSize={180}
1925+ initialScrollAtEnd
1926+ ListHeaderComponent={
1927+ <>
1928+ <View style={{ height: topContentInset }} />
1929+ {props.topAccessory}
1930+ </>
1931+ }
1932+ contentContainerStyle={{
1933+ paddingTop: 12,
1934+ paddingHorizontal: horizontalPadding,
1935+ }}
1936+ />
1937+ {props.feed.length === 0 ? (
19051938 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
19061939 <ThreadFeedPlaceholder
19071940 title="No conversation yet"
apps/mobile/src/features/threads/ThreadQueueControl.tsx+115−0
@@ -0,0 +1,115 @@
1+import * as Haptics from "expo-haptics";
2+import { SymbolView } from "expo-symbols";
3+import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows";
4+import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
5+import { useMemo, useState } from "react";
6+import { Pressable, ScrollView, View } from "react-native";
7+
8+import { AppText as Text } from "../../components/AppText";
9+import { useThemeColor } from "../../lib/useThemeColor";
10+import { threadEnvironment } from "../../state/threads";
11+import { useAtomCommand } from "../../state/use-atom-command";
12+import { useThreadProjection } from "../../state/use-thread-detail";
13+
14+export function ThreadQueueControl(props: {
15+ readonly environmentId: EnvironmentId;
16+ readonly threadId: ThreadId;
17+}) {
18+ const scoped = useThreadProjection(props);
19+ const workflow = useMemo(
20+ () => (scoped ? deriveThreadQueueWorkflowState(scoped.projection) : null),
21+ [scoped],
22+ );
23+ const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun, "reorder queued message");
24+ const promote = useAtomCommand(threadEnvironment.promoteQueuedRun, "promote queued message");
25+ const [busyRunId, setBusyRunId] = useState<RunId | null>(null);
26+ const iconColor = useThemeColor("--color-icon-subtle");
27+
28+ if (!workflow || workflow.queuedRuns.length === 0) return null;
29+
30+ const move = async (runId: RunId, beforeRunId: RunId | null) => {
31+ setBusyRunId(runId);
32+ void Haptics.selectionAsync();
33+ await reorder({
34+ environmentId: props.environmentId,
35+ input: { threadId: props.threadId, runId, beforeRunId },
36+ });
37+ setBusyRunId(null);
38+ };
39+
40+ const steer = async (queuedRunId: RunId) => {
41+ if (!workflow.activeRun || !workflow.canPromoteToSteer) return;
42+ setBusyRunId(queuedRunId);
43+ void Haptics.selectionAsync();
44+ await promote({
45+ environmentId: props.environmentId,
46+ input: {
47+ threadId: props.threadId,
48+ queuedRunId,
49+ targetRunId: workflow.activeRun.id,
50+ },
51+ });
52+ setBusyRunId(null);
53+ };
54+
55+ return (
56+ <View className="mx-4 mb-3 overflow-hidden rounded-2xl border border-neutral-300/60 bg-card dark:border-white/[0.1]">
57+ <View className="flex-row items-center gap-2 border-b border-neutral-300/50 px-3 py-2 dark:border-white/[0.08]">
58+ <SymbolView name="list.number" size={13} tintColor={iconColor} type="monochrome" />
59+ <Text className="font-t3-medium text-xs text-foreground">Queue</Text>
60+ <Text className="ml-auto text-2xs tabular-nums text-foreground-muted">
61+ {workflow.queuedRuns.length}
62+ </Text>
63+ </View>
64+ <ScrollView style={{ maxHeight: 156 }} contentContainerStyle={{ paddingVertical: 4 }}>
65+ {workflow.queuedRuns.map(({ run, text }, index) => (
66+ <View key={run.id} className="min-h-11 flex-row items-center gap-1.5 px-2">
67+ <Text className="w-5 text-right text-3xs tabular-nums text-foreground-muted">
68+ {index + 1}
69+ </Text>
70+ <Text className="min-w-0 flex-1 text-xs text-foreground" numberOfLines={1}>
71+ {text}
72+ </Text>
73+ <Pressable
74+ accessibilityRole="button"
75+ accessibilityLabel="Move queued message up"
76+ disabled={busyRunId !== null || !workflow.canReorder || index === 0}
77+ onPress={() => void move(run.id, workflow.queuedRuns[index - 1]?.run.id ?? null)}
78+ className="h-9 w-9 items-center justify-center disabled:opacity-30"
79+ >
80+ <SymbolView name="arrow.up" size={13} tintColor={iconColor} type="monochrome" />
81+ </Pressable>
82+ <Pressable
83+ accessibilityRole="button"
84+ accessibilityLabel="Move queued message down"
85+ disabled={
86+ busyRunId !== null ||
87+ !workflow.canReorder ||
88+ index === workflow.queuedRuns.length - 1
89+ }
90+ onPress={() => void move(run.id, workflow.queuedRuns[index + 2]?.run.id ?? null)}
91+ className="h-9 w-9 items-center justify-center disabled:opacity-30"
92+ >
93+ <SymbolView name="arrow.down" size={13} tintColor={iconColor} type="monochrome" />
94+ </Pressable>
95+ <Pressable
96+ accessibilityRole="button"
97+ accessibilityLabel="Promote queued message to steer"
98+ disabled={busyRunId !== null || !workflow.canPromoteToSteer}
99+ onPress={() => void steer(run.id)}
100+ className="min-h-8 flex-row items-center gap-1 rounded-lg border border-neutral-300/60 px-2 disabled:opacity-30 dark:border-white/[0.1]"
101+ >
102+ <SymbolView
103+ name="arrow.turn.left.up"
104+ size={12}
105+ tintColor={iconColor}
106+ type="monochrome"
107+ />
108+ <Text className="font-t3-medium text-2xs text-foreground">Steer</Text>
109+ </Pressable>
110+ </View>
111+ ))}
112+ </ScrollView>
113+ </View>
114+ );
115+}
apps/mobile/src/features/threads/ThreadRelationshipsBanner.tsx+295−0
@@ -0,0 +1,295 @@
1+import * as Haptics from "expo-haptics";
2+import { SymbolView, type SFSymbol } from "expo-symbols";
3+import {
4+ deriveThreadRelationshipGraph,
5+ immediateThreadRelationships,
6+ resolveMergeBackTargetThreadId,
7+ type ThreadRelationshipEdge,
8+} from "@t3tools/client-runtime/state/thread-relationships";
9+import { canDetachThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
10+import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
11+import { useRouter } from "expo-router";
12+import { useMemo, useState } from "react";
13+import { ActivityIndicator, Modal, Pressable, ScrollView, View } from "react-native";
14+import { useSafeAreaInsets } from "react-native-safe-area-context";
15+
16+import { AppText as Text } from "../../components/AppText";
17+import { buildThreadRoutePath } from "../../lib/routes";
18+import { useThemeColor } from "../../lib/useThemeColor";
19+import { useThreadShells } from "../../state/entities";
20+import { threadEnvironment } from "../../state/threads";
21+import { useAtomCommand } from "../../state/use-atom-command";
22+import { useThreadProjection } from "../../state/use-thread-detail";
23+import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots";
24+
25+function relationshipLabel(edge: ThreadRelationshipEdge, currentThreadId: ThreadId): string {
26+ if (edge.kind === "transfer") {
27+ return edge.sourceThreadId === currentThreadId ? "Context sent to" : "Context received from";
28+ }
29+ if (edge.kind === "subagent") {
30+ return edge.sourceThreadId === currentThreadId ? "Subagent" : "Parent agent";
31+ }
32+ return edge.sourceThreadId === currentThreadId ? "Fork" : "Forked from";
33+}
34+
35+function relationshipSymbol(edge: ThreadRelationshipEdge): SFSymbol {
36+ if (edge.kind === "subagent") return "person.2";
37+ if (edge.kind === "transfer") return "arrow.left.arrow.right";
38+ return "arrow.triangle.branch";
39+}
40+
41+function threadAvailability(
42+ thread: OrchestrationV2ThreadShell | null,
43+ missing: boolean,
44+): string | null {
45+ if (missing) return "Unavailable";
46+ if (thread?.deletedAt !== null && thread?.deletedAt !== undefined) return "Deleted";
47+ if (thread?.archivedAt !== null && thread?.archivedAt !== undefined) return "Archived";
48+ return null;
49+}
50+
51+const EMPTY_THREAD_SHELLS: ReadonlyArray<OrchestrationV2ThreadShell> = [];
52+
53+export function ThreadRelationshipsBanner(props: {
54+ readonly environmentId: EnvironmentId;
55+ readonly threadId: ThreadId;
56+}) {
57+ const scopedProjection = useThreadProjection(props);
58+ const projection = scopedProjection?.projection ?? null;
59+ const threadShells = useThreadShells();
60+ const environmentIds = useMemo(() => [props.environmentId], [props.environmentId]);
61+ const archived = useArchivedThreadSnapshots(environmentIds);
62+ const archivedShells =
63+ archived.snapshots.find((entry) => entry.environmentId === props.environmentId)?.snapshot
64+ .threads ?? EMPTY_THREAD_SHELLS;
65+ const shells = useMemo<ReadonlyArray<OrchestrationV2ThreadShell>>(() => {
66+ const environmentShells: OrchestrationV2ThreadShell[] = [];
67+ for (const thread of threadShells) {
68+ if (thread.environmentId === props.environmentId) {
69+ environmentShells.push(thread.source);
70+ }
71+ }
72+ environmentShells.push(...archivedShells);
73+ return environmentShells;
74+ }, [archivedShells, props.environmentId, threadShells]);
75+ const graph = useMemo(
76+ () => deriveThreadRelationshipGraph({ threads: shells, projection }),
77+ [projection, shells],
78+ );
79+ const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
80+ const rows = useMemo(
81+ () =>
82+ immediateThreadRelationships(graph, props.threadId).toSorted(
83+ (left, right) =>
84+ Number(right.threadId === mergeTargetThreadId) -
85+ Number(left.threadId === mergeTargetThreadId),
86+ ),
87+ [graph, mergeTargetThreadId, props.threadId],
88+ );
89+ const latestCompletedRun = projection?.runs.findLast((run) => run.status === "completed") ?? null;
90+ const canMerge = mergeTargetThreadId !== null && latestCompletedRun !== null;
91+ const canDetach = projection ? canDetachThreadProviderSession(projection) : false;
92+ const [visible, setVisible] = useState(false);
93+ const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null);
94+ const router = useRouter();
95+ const mergeBack = useAtomCommand(threadEnvironment.mergeBack, "merge thread back");
96+ const stopSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
97+ const insets = useSafeAreaInsets();
98+ const backdropColor = useThemeColor("--color-backdrop");
99+ const sheetColor = useThemeColor("--color-sheet");
100+ const iconColor = useThemeColor("--color-icon-subtle");
101+
102+ if (rows.length === 0 && !canDetach) return null;
103+
104+ const primaryParent = rows.find(({ edge }) => edge.targetThreadId === props.threadId) ?? rows[0];
105+ const primaryNode = primaryParent ? graph.nodes.get(primaryParent.threadId) : null;
106+ const summary = primaryParent
107+ ? `${relationshipLabel(primaryParent.edge, props.threadId)}: ${primaryNode?.thread?.title ?? "related thread"}`
108+ : "Agent session connected";
109+
110+ const openThread = (threadId: ThreadId, archivedThread: boolean) => {
111+ setVisible(false);
112+ void Haptics.selectionAsync();
113+ router.push(
114+ archivedThread
115+ ? "/settings/archive"
116+ : buildThreadRoutePath({ environmentId: props.environmentId, threadId }),
117+ );
118+ };
119+
120+ const merge = async () => {
121+ if (!canMerge || mergeTargetThreadId === null || latestCompletedRun === null) return;
122+ setBusyAction("merge");
123+ const result = await mergeBack({
124+ environmentId: props.environmentId,
125+ input: {
126+ sourceThreadId: props.threadId,
127+ targetThreadId: mergeTargetThreadId,
128+ runId: latestCompletedRun.id,
129+ },
130+ });
131+ setBusyAction(null);
132+ if (result._tag === "Success") openThread(mergeTargetThreadId, false);
133+ };
134+
135+ const detach = async () => {
136+ if (!canDetach) return;
137+ setBusyAction("detach");
138+ await stopSession({
139+ environmentId: props.environmentId,
140+ input: { threadId: props.threadId },
141+ });
142+ setBusyAction(null);
143+ };
144+
145+ return (
146+ <>
147+ <Pressable
148+ accessibilityRole="button"
149+ accessibilityLabel={`${summary}. Show thread relationships`}
150+ onPress={() => {
151+ void Haptics.selectionAsync();
152+ setVisible(true);
153+ }}
154+ className="mb-4 min-h-11 flex-row items-center gap-2 rounded-xl border border-neutral-300/60 bg-card px-3 py-2.5 dark:border-white/[0.1]"
155+ >
156+ <SymbolView
157+ name={primaryParent ? relationshipSymbol(primaryParent.edge) : "link"}
158+ size={14}
159+ tintColor={iconColor}
160+ type="monochrome"
161+ />
162+ <Text className="min-w-0 flex-1 font-t3-medium text-xs text-foreground" numberOfLines={1}>
163+ {summary}
164+ </Text>
165+ {rows.length > 1 ? (
166+ <Text className="text-2xs tabular-nums text-foreground-muted">+{rows.length - 1}</Text>
167+ ) : null}
168+ <SymbolView name="chevron.right" size={12} tintColor={iconColor} type="monochrome" />
169+ </Pressable>
170+
171+ <Modal
172+ transparent
173+ animationType="fade"
174+ visible={visible}
175+ statusBarTranslucent
176+ onRequestClose={() => setVisible(false)}
177+ >
178+ <View className="flex-1 justify-end">
179+ <View
180+ pointerEvents="none"
181+ className="absolute inset-0"
182+ style={{ backgroundColor: backdropColor }}
183+ />
184+ <Pressable className="absolute inset-0" onPress={() => setVisible(false)} />
185+ <View
186+ className="max-h-[78%] rounded-t-[28px] px-4 pt-3"
187+ style={{ backgroundColor: sheetColor, paddingBottom: Math.max(insets.bottom, 16) }}
188+ >
189+ <View className="mb-4 h-1 w-10 self-center rounded-full bg-neutral-400/40" />
190+ <View className="mb-3 flex-row items-center justify-between px-1">
191+ <View>
192+ <Text className="font-t3-bold text-lg text-foreground">Thread lineage</Text>
193+ <Text className="text-xs text-foreground-muted">
194+ {rows.length} related {rows.length === 1 ? "thread" : "threads"}
195+ </Text>
196+ </View>
197+ <Pressable
198+ accessibilityRole="button"
199+ accessibilityLabel="Close thread lineage"
200+ onPress={() => setVisible(false)}
201+ className="h-10 w-10 items-center justify-center rounded-full bg-neutral-200/70 dark:bg-white/[0.07]"
202+ >
203+ <SymbolView name="xmark" size={14} tintColor={iconColor} type="monochrome" />
204+ </Pressable>
205+ </View>
206+
207+ <ScrollView contentContainerStyle={{ gap: 8 }} showsVerticalScrollIndicator={false}>
208+ {rows.map(({ threadId, edge }) => {
209+ const node = graph.nodes.get(threadId);
210+ const availability = threadAvailability(
211+ node?.thread ?? null,
212+ node?.missing ?? true,
213+ );
214+ const archivedThread = availability === "Archived";
215+ const disabled = availability === "Unavailable" || availability === "Deleted";
216+ return (
217+ <Pressable
218+ key={`${edge.kind}:${threadId}`}
219+ accessibilityRole={disabled ? undefined : "link"}
220+ accessibilityState={{ disabled }}
221+ disabled={disabled}
222+ onPress={() => openThread(threadId, archivedThread)}
223+ className="min-h-14 flex-row items-center gap-3 rounded-2xl border border-neutral-300/50 bg-card px-3 py-2.5 dark:border-white/[0.08]"
224+ >
225+ <View className="h-8 w-8 items-center justify-center rounded-full bg-neutral-200/60 dark:bg-white/[0.06]">
226+ <SymbolView
227+ name={relationshipSymbol(edge)}
228+ size={14}
229+ tintColor={iconColor}
230+ type="monochrome"
231+ />
232+ </View>
233+ <View className="min-w-0 flex-1">
234+ <Text className="text-3xs uppercase tracking-wide text-foreground-muted">
235+ {relationshipLabel(edge, props.threadId)}
236+ </Text>
237+ <Text className="font-t3-medium text-sm text-foreground" numberOfLines={1}>
238+ {node?.thread?.title ?? threadId}
239+ </Text>
240+ </View>
241+ {availability ? (
242+ <Text className="text-2xs text-foreground-muted">{availability}</Text>
243+ ) : (
244+ <SymbolView
245+ name="chevron.right"
246+ size={12}
247+ tintColor={iconColor}
248+ type="monochrome"
249+ />
250+ )}
251+ </Pressable>
252+ );
253+ })}
254+ </ScrollView>
255+
256+ {canMerge || canDetach ? (
257+ <View className="mt-4 gap-2 border-t border-neutral-300/50 pt-4 dark:border-white/[0.08]">
258+ {canMerge ? (
259+ <Pressable
260+ accessibilityRole="button"
261+ disabled={busyAction !== null}
262+ onPress={() => void merge()}
263+ className="min-h-11 flex-row items-center justify-center gap-2 rounded-xl bg-primary px-3"
264+ >
265+ {busyAction === "merge" ? (
266+ <ActivityIndicator color="white" />
267+ ) : (
268+ <SymbolView name="arrow.triangle.merge" size={14} tintColor="white" />
269+ )}
270+ <Text className="font-t3-medium text-sm text-primary-foreground">
271+ Merge back to source
272+ </Text>
273+ </Pressable>
274+ ) : null}
275+ {canDetach ? (
276+ <Pressable
277+ accessibilityRole="button"
278+ disabled={busyAction !== null}
279+ onPress={() => void detach()}
280+ className="min-h-11 flex-row items-center justify-center gap-2 rounded-xl border border-neutral-300/60 px-3 dark:border-white/[0.1]"
281+ >
282+ {busyAction === "detach" ? <ActivityIndicator /> : null}
283+ <Text className="font-t3-medium text-sm text-foreground">
284+ Disconnect agent session
285+ </Text>
286+ </Pressable>
287+ ) : null}
288+ </View>
289+ ) : null}
290+ </View>
291+ </View>
292+ </Modal>
293+ </>
294+ );
295+}
apps/mobile/src/features/threads/thread-work-log.tsx+63−45
@@ -1,12 +1,15 @@
11 import * as Haptics from "expo-haptics";
22 import { SymbolView, type SFSymbol } from "expo-symbols";
3−import type { EnvironmentId } from "@t3tools/contracts";
3+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4+import { useRouter } from "expo-router";
45 import { LayoutAnimation, Pressable, useColorScheme, View } from "react-native";
56
67 import { AppText as Text } from "../../components/AppText";
78 import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
89 import { cn } from "../../lib/cn";
10+import { buildThreadRoutePath } from "../../lib/routes";
911 import type { ThreadFeedActivity } from "../../lib/threadActivity";
12+import { useV2ItemSupport } from "../../state/v2-item-support";
1013 import { ThreadActivityInspector } from "./ThreadActivityInspector";
1114
1215 const WORK_LOG_LAYOUT_ANIMATION = {
@@ -71,51 +74,56 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { · 58 unmodified lines
7174 }
7275 }
7376
74−// Entering fades only for rows created moments ago: rows remount whenever the
75−// list scrolls them back into view, and old rows must not replay an entrance.
76−const FRESH_ROW_WINDOW_MS = 3_000;
77−function isFreshRow(createdAt: string): boolean {
78− const timestamp = Date.parse(createdAt);
79− return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
80−}
81−
82−// Tool-like activities with a neutral status carry no signal worth a row.
83−export function visibleWorkLogActivities(
84− activities: ReadonlyArray<ThreadFeedActivity>,
85−): ReadonlyArray<ThreadFeedActivity> {
86− return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral"));
87−}
77+function ThreadActivityThreadLink(props: {
78+ readonly activity: ThreadFeedActivity;
79+ readonly environmentId: EnvironmentId;
80+ readonly iconColor: import("react-native").ColorValue;
81+}) {
82+ const row = props.activity.projectedItem;
83+ const support = useV2ItemSupport({
84+ environmentId: props.environmentId,
85+ sourceThreadId: row.sourceThreadId,
86+ sourceItemId: row.sourceItemId,
87+ });
88+ const router = useRouter();
89+ const item = row.item;
90+ let targetThreadId: ThreadId | null = null;
91+ let label = "Open related thread";
8892
89−// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log
90−// rows are single-line (numberOfLines={1}) inside a min-height that stays
91−// taller than the text at every supported base font size (text-xs reaches
92−// 23px at the 22pt maximum, under the 32px min-h-8), so row height is
93−// deterministic. The "work log" label has no such clamp — its height follows
94−// the scaled text-2xs line height. Values mirror the classNames below — keep
95−// them in sync; a mismatch only costs a one-time correction on measure.
96−const WORK_ROW_HEIGHT = 32; // min-h-8
97−const WORK_ROW_GAP = 1; // gap-px
98−const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label
99−const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1
93+ if (item.type === "thread_created") {
94+ targetThreadId = item.targetThreadId;
95+ label = "Open created thread";
96+ } else if (item.type === "subagent") {
97+ targetThreadId = support.subagent?.childThreadId ?? item.childThreadId;
98+ label = "Open subagent thread";
99+ } else if (item.type === "fork") {
100+ targetThreadId =
101+ item.targetThreadId === row.sourceThreadId && item.source.type === "run"
102+ ? item.source.threadId
103+ : item.targetThreadId;
104+ label = targetThreadId === item.targetThreadId ? "Open forked thread" : "Open parent thread";
105+ }
100106
101−export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
107+ if (targetThreadId === null) return null;
102108
103−export function collapsedWorkLogHeight(
104− activities: ReadonlyArray<ThreadFeedActivity>,
105− baseFontSize: number,
106−): number {
107− const rows = visibleWorkLogActivities(activities);
108− if (rows.length === 0) {
109− return 0;
110− }
111− const onlyToolRows = rows.every((row) => row.toolLike);
112− const headerHeight =
113− scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.caption, baseFontSize) + WORK_LOG_HEADER_PADDING;
114109 return (
115− WORK_LOG_BOTTOM_MARGIN +
116− (onlyToolRows ? 0 : headerHeight) +
117− rows.length * WORK_ROW_HEIGHT +
118− (rows.length - 1) * WORK_ROW_GAP
110+ <Pressable
111+ accessibilityRole="link"
112+ accessibilityLabel={label}
113+ onPress={() => {
114+ void Haptics.selectionAsync();
115+ router.push(
116+ buildThreadRoutePath({
117+ environmentId: props.environmentId,
118+ threadId: targetThreadId,
119+ }),
120+ );
121+ }}
122+ className="mx-2 mb-2 min-h-9 flex-row items-center justify-center gap-1.5 rounded-lg border border-neutral-300/50 px-2 dark:border-white/[0.08]"
123+ >
124+ <Text className="font-t3-medium text-2xs text-foreground">{label}</Text>
125+ <SymbolView name="arrow.right" size={11} tintColor={props.iconColor} type="monochrome" />
126+ </Pressable>
119127 );
120128 }
121129
@@ -157,9 +165,12 @@ export function ThreadWorkLog(props: { · 35 unmodified lines
157165 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
158166
159167 return (
160− <Animated.View
168+ <View
161169 key={row.id}
162− {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})}
170+ className={cn(
171+ row.prominent &&
172+ "mb-2 overflow-hidden rounded-xl border border-neutral-300/60 bg-card dark:border-white/[0.1]",
173+ )}
163174 >
164175 <Pressable
165176 accessibilityRole={canExpand ? "button" : undefined}
@@ -258,7 +269,14 @@ export function ThreadWorkLog(props: { · 92 unmodified lines
258269 />
259270 </View>
260271 ) : null}
261− </Animated.View>
272+ {row.prominent ? (
273+ <ThreadActivityThreadLink
274+ activity={row}
275+ environmentId={props.environmentId}
276+ iconColor={props.iconSubtleColor}
277+ />
278+ ) : null}
279+ </View>
262280 );
263281 })}
264282 </View>
apps/mobile/src/lib/threadActivity.test.ts+51−0
@@ -129,6 +129,57 @@ describe("buildThreadFeed", () => { · 128 unmodified lines
129129 "inherited",
130130 "synthetic",
131131 ]);
132+ expect(activities.at(-1)?.prominent).toBe(true);
133+ });
134+
135+ it("keeps orchestration relationship cards visible when a completed run is folded", () => {
136+ const { providerThreadId: _providerThreadId, ...forkBase } = base(
137+ "item-fork",
138+ "2026-06-20T00:00:02.500Z",
139+ 2,
140+ );
141+ const feed = buildThreadFeed([
142+ projected(userMessage(), 0),
143+ projected(command(), 1),
144+ projected(
145+ {
146+ ...forkBase,
147+ type: "fork",
148+ source: { type: "run", threadId, runId },
149+ targetThreadId: sourceThreadId,
150+ },
151+ 2,
152+ ),
153+ projected(assistantMessage(), 3),
154+ ]);
155+
156+ const collapsed = deriveThreadFeedPresentation(
157+ feed,
158+ {
159+ runId,
160+ status: "completed",
161+ startedAt: "2026-06-20T00:00:01.000Z",
162+ completedAt: "2026-06-20T00:00:03.000Z",
163+ },
164+ new Set(),
165+ );
166+
167+ expect(
168+ collapsed.some(
169+ (entry) =>
170+ entry.type === "activity-group" &&
171+ entry.activities.some((activity) => activity.projectedItem.item.type === "fork"),
172+ ),
173+ ).toBe(true);
174+ expect(
175+ collapsed.some(
176+ (entry) =>
177+ entry.type === "activity-group" &&
178+ entry.activities.some(
179+ (activity) => activity.projectedItem.item.type === "command_execution",
180+ ),
181+ ),
182+ ).toBe(false);
132183 });
133184
134185 it("folds settled V2 run work while keeping the terminal assistant message visible", () => {
apps/mobile/src/lib/threadActivity.ts+22−2
@@ -49,6 +49,7 @@ export interface ThreadFeedActivity { · 48 unmodified lines
4949 | "wrench"
5050 | "zap";
5151 readonly toolLike: boolean;
52+ readonly prominent: boolean;
5253 readonly status: "success" | "failure" | "neutral" | null;
5354 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
5455 }
@@ -149,6 +150,10 @@ function itemIsToolLike(item: OrchestrationV2TurnItem): boolean { · 94 unmodified lines
149150 );
150151 }
151152
153+function itemIsProminent(item: OrchestrationV2TurnItem): boolean {
154+ return item.type === "fork" || item.type === "thread_created" || item.type === "subagent";
155+}
156+
152157 function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {
153158 if (!itemIsToolLike(item)) return null;
154159 if (item.type === "error" || item.status === "failed") return "failure";
@@ -316,6 +321,7 @@ function toFeedActivity(row: OrchestrationV2ProjectedTurnItem): ThreadFeedActivi · 161 unmodified lines
316321 )
317322 .join("\n"),
318323 toolLike: itemIsToolLike(item),
324+ prominent: itemIsProminent(item),
319325 status: itemStatus(item),
320326 projectedItem: row,
321327 };
@@ -339,7 +345,12 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th · 17 unmodified lines
339345 continue;
340346 }
341347 const previous = grouped.at(-1);
342− if (previous?.type === "activity-group" && previous.runId === entry.runId) {
348+ if (
349+ previous?.type === "activity-group" &&
350+ previous.runId === entry.runId &&
351+ !entry.activity.prominent &&
352+ !previous.activities.some((activity) => activity.prominent)
353+ ) {
343354 grouped[grouped.length - 1] = {
344355 ...previous,
345356 activities: [...previous.activities, entry.activity],
@@ -435,7 +446,16 @@ function deriveThreadFeedRunFolds( · 89 unmodified lines
435446 }
436447 const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId);
437448 const hiddenEntryIds = new Set(
438− group.entries.filter((entry) => entry.id !== terminalAssistantId).map((entry) => entry.id),
449+ group.entries
450+ .filter(
451+ (entry) =>
452+ entry.id !== terminalAssistantId &&
453+ !(
454+ entry.type === "activity-group" &&
455+ entry.activities.some((activity) => activity.prominent)
456+ ),
457+ )
458+ .map((entry) => entry.id),
439459 );
440460 const firstEntry = group.entries[0];
441461 const lastEntry = group.entries.at(-1);
apps/web/src/components/chat/MessagesTimeline.tsx+5−11
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 type ThreadId,
1010 } from "@t3tools/contracts";
1111 import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
12+import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
1213 import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
1314 import {
1415 createContext,
@@ -1191,17 +1192,10 @@ function AssistantForkButton({ · 1176 unmodified lines
11911192 sourceThreadId: projectedItem.sourceThreadId,
11921193 sourceItemId: projectedItem.sourceItemId,
11931194 });
1194− const capabilities = support.providerSession?.capabilities;
1195− const canForkNatively =
1196− capabilities?.threads.canForkThread === true &&
1197− capabilities.threads.canForkFromTurn === true &&
1198− capabilities.identity.nativeThreadIds === "strong";
1199− const canFork =
1200− projectedItem.item.runId !== null &&
1201− projectedItem.item.status === "completed" &&
1202− (capabilities === undefined ||
1203− canForkNatively ||
1204− capabilities.context.supportsFullThreadHandoff === true);
1195+ const canFork = canForkProjectedAssistantItem({
1196+ projectedItem,
1197+ capabilities: support.providerSession?.capabilities,
1198+ });
12051199
12061200 if (!canFork || projectedItem.item.runId === null) return null;
12071201 const runId = projectedItem.item.runId;
apps/web/src/components/chat/QueuedRunsControl.tsx+8−26
@@ -1,4 +1,5 @@
11 import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2+import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows";
23 import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
34 import { ArrowDownIcon, ArrowUpIcon, CornerUpRightIcon, ListOrderedIcon } from "lucide-react";
45 import { useMemo, useState } from "react";
@@ -18,31 +19,12 @@ export function QueuedRunsControl(props: { · 13 unmodified lines
1819 const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun);
1920 const promote = useAtomCommand(threadEnvironment.promoteQueuedRun);
2021 const [busyRunId, setBusyRunId] = useState<RunId | null>(null);
21− const queued = useMemo(
22− () =>
23− (projection?.runs ?? [])
24− .filter((run) => run.status === "queued")
25− .toSorted(
26− (left, right) =>
27− (left.queuePosition ?? left.ordinal) - (right.queuePosition ?? right.ordinal) ||
28− left.ordinal - right.ordinal,
29− )
30− .map((run) => ({
31− run,
32− text:
33− projection?.messages.find((message) => message.id === run.userMessageId)?.text ??
34− "Queued message",
35− })),
22+ const workflow = useMemo(
23+ () => (projection ? deriveThreadQueueWorkflowState(projection) : null),
3624 [projection],
3725 );
38− const activeRun =
39− projection?.runs.findLast(
40− (run) =>
41− run.status === "preparing" ||
42− run.status === "starting" ||
43− run.status === "running" ||
44− run.status === "waiting",
45− ) ?? null;
26+ const queued = workflow?.queuedRuns ?? [];
27+ const activeRun = workflow?.activeRun ?? null;
4628
4729 if (queued.length === 0) return null;
4830
@@ -83,7 +65,7 @@ export function QueuedRunsControl(props: { · 34 unmodified lines
8365 size="icon-xs"
8466 variant="ghost"
8567 aria-label="Move queued message up"
86− disabled={busyRunId !== null || index === 0}
68+ disabled={busyRunId !== null || !workflow?.canReorder || index === 0}
8769 onClick={() => void move(run.id, queued[index - 1]?.run.id ?? null)}
8870 >
8971 <ArrowUpIcon className="size-3" />
@@ -92,7 +74,7 @@ export function QueuedRunsControl(props: { · 2 unmodified lines
9274 size="icon-xs"
9375 variant="ghost"
9476 aria-label="Move queued message down"
95− disabled={busyRunId !== null || index === queued.length - 1}
77+ disabled={busyRunId !== null || !workflow?.canReorder || index === queued.length - 1}
9678 onClick={() => void move(run.id, queued[index + 2]?.run.id ?? null)}
9779 >
9880 <ArrowDownIcon className="size-3" />
@@ -100,7 +82,7 @@ export function QueuedRunsControl(props: { · 1 unmodified line
10082 <Button
10183 size="xs"
10284 variant="outline"
103− disabled={busyRunId !== null || activeRun === null}
85+ disabled={busyRunId !== null || !workflow?.canPromoteToSteer}
10486 title={activeRun === null ? "There is no active run to steer" : "Promote to steer"}
10587 onClick={() => void steer(run.id)}
10688 >
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+8−10
@@ -1,4 +1,11 @@
11 import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2+import {
3+ deriveThreadRelationshipGraph,
4+ immediateThreadRelationships,
5+ resolveMergeBackTargetThreadId,
6+ type ThreadRelationshipEdge,
7+} from "@t3tools/client-runtime/state/thread-relationships";
8+import { canDetachThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
29 import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
310 import { useNavigate } from "@tanstack/react-router";
411 import {
@@ -13,12 +20,6 @@ import { · 8 unmodified lines
1320 import { useState } from "react";
1421
1522 import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
16−import {
17− deriveThreadRelationshipGraph,
18− immediateThreadRelationships,
19− resolveMergeBackTargetThreadId,
20− type ThreadRelationshipEdge,
21−} from "../../lib/threadRelationships";
2223 import { buildThreadRouteParams } from "../../threadRoutes";
2324 import { useThreadProjection, useThreadShells } from "../../state/entities";
2425 import { threadEnvironment } from "../../state/threads";
@@ -91,10 +92,7 @@ export function ThreadRelationshipsPanel(props: { · 66 unmodified lines
9192 Number(left.threadId === mergeTargetThreadId),
9293 );
9394 const canMerge = mergeTargetThreadId !== null && latestCompletedRun !== null;
94− const canDetach =
95− projection?.providerSessions.some(
96− (session) => session.status !== "stopped" && session.status !== "error",
97− ) ?? false;
95+ const canDetach = projection ? canDetachThreadProviderSession(projection) : false;
9896
9997 if (relationshipRows.length === 0) {
10098 return null;
apps/web/src/routes/debug.orchestration-v2.tsx+1−1
@@ -31,7 +31,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; · 30 unmodified lines
3131
3232 import { ProviderModelPicker } from "../components/chat/ProviderModelPicker";
3333 import { usePrimarySettings } from "../hooks/useSettings";
34−import { resolveMergeBackTargetThreadId } from "../lib/threadRelationships";
34+import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
3535 import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils";
3636 import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection";
3737 import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders";
packages/client-runtime/package.json+6−6
@@ -135,13 +135,13 @@ · 134 unmodified lines
135135 "types": "./src/state/threadSort.ts",
136136 "default": "./src/state/threadSort.ts"
137137 },
138− "./state/thread-settled": {
139− "types": "./src/state/threadSettled.ts",
140− "default": "./src/state/threadSettled.ts"
138+ "./state/thread-relationships": {
139+ "types": "./src/state/threadRelationships.ts",
140+ "default": "./src/state/threadRelationships.ts"
141141 },
142− "./state/thread-search": {
143− "types": "./src/state/threadSearch.ts",
144− "default": "./src/state/threadSearch.ts"
142+ "./state/thread-workflows": {
143+ "types": "./src/state/threadWorkflows.ts",
144+ "default": "./src/state/threadWorkflows.ts"
145145 },
146146 "./state/vcs": {
147147 "types": "./src/state/vcs.ts",
packages/client-runtime/src/state/threadRelationships.test.tsrenamed from apps/web/src/lib/threadRelationships.test.ts+59−1
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
77 relatedThreadIds,
88 resolveMergeBackTargetThreadId,
99 walkThreadRelationships,
10−} from "./threadRelationships";
10+} from "./threadRelationships.ts";
1111
1212 describe("thread relationships", () => {
1313 it("keeps missing parents and cycles navigable without recursive traversal", () => {
@@ -48,6 +48,64 @@ describe("thread relationships", () => { · 34 unmodified lines
4848 ]);
4949 });
5050
51+ it("combines subagent and transfer edges with archived shell state", () => {
52+ const parent = ThreadId.make("thread-parent");
53+ const child = ThreadId.make("thread-child");
54+ const transferTarget = ThreadId.make("thread-transfer");
55+ const graph = deriveThreadRelationshipGraph({
56+ threads: [
57+ {
58+ id: parent,
59+ title: "Parent",
60+ status: "completed",
61+ archivedAt: null,
62+ forkedFrom: null,
63+ lineage: { rootThreadId: parent, parentThreadId: null, relationshipToParent: null },
64+ },
65+ {
66+ id: child,
67+ title: "Subagent",
68+ status: "completed",
69+ archivedAt: "2026-06-24T00:00:00.000Z",
70+ forkedFrom: null,
71+ lineage: {
72+ rootThreadId: parent,
73+ parentThreadId: parent,
74+ relationshipToParent: "subagent",
75+ },
76+ },
77+ ] as never,
78+ projection: {
79+ thread: { id: parent },
80+ subagents: [{ childThreadId: child, status: "completed" }],
81+ contextTransfers: [
82+ {
83+ sourceThreadId: child,
84+ targetThreadId: transferTarget,
85+ status: "completed",
86+ },
87+ ],
88+ } as never,
89+ });
90+
91+ expect(graph.nodes.get(child)?.thread?.archivedAt).not.toBeNull();
92+ expect(graph.nodes.get(transferTarget)?.missing).toBe(true);
93+ expect(graph.edges).toEqual(
94+ expect.arrayContaining([
95+ expect.objectContaining({
96+ sourceThreadId: parent,
97+ targetThreadId: child,
98+ kind: "subagent",
99+ }),
100+ expect.objectContaining({
101+ sourceThreadId: child,
102+ targetThreadId: transferTarget,
103+ kind: "transfer",
104+ }),
105+ ]),
106+ );
107+ });
108+
51109 it("resolves merge-back only for forks and prefers the recorded fork source", () => {
52110 const source = ThreadId.make("thread-source");
53111 const fallbackParent = ThreadId.make("thread-parent");
packages/client-runtime/src/state/threadRelationships.tsrenamed from apps/web/src/lib/threadRelationships.ts+0−0

File renamed without changes.

packages/client-runtime/src/state/threadWorkflows.test.ts+133−0
@@ -0,0 +1,133 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ canDetachThreadProviderSession,
5+ canForkProjectedAssistantItem,
6+ deriveThreadQueueWorkflowState,
7+} from "./threadWorkflows.ts";
8+
9+const capabilities = (input?: {
10+ readonly queued?: boolean;
11+ readonly steer?: boolean;
12+ readonly restartSteer?: boolean;
13+ readonly nativeFork?: boolean;
14+ readonly portableFork?: boolean;
15+}) =>
16+ ({
17+ turns: {
18+ supportsQueuedMessages: input?.queued ?? false,
19+ supportsActiveSteering: input?.steer ?? false,
20+ supportsSteeringByInterruptRestart: input?.restartSteer ?? false,
21+ },
22+ threads: {
23+ canForkThread: input?.nativeFork ?? false,
24+ canForkFromTurn: input?.nativeFork ?? false,
25+ },
26+ identity: { nativeThreadIds: input?.nativeFork ? "strong" : "none" },
27+ context: { supportsFullThreadHandoff: input?.portableFork ?? false },
28+ }) as never;
29+
30+describe("thread workflows", () => {
31+ it("sorts queued messages and gates reorder and promotion from capabilities", () => {
32+ const state = deriveThreadQueueWorkflowState({
33+ thread: { id: "thread", activeProviderThreadId: "provider-thread" },
34+ runs: [
35+ { id: "active", status: "running", providerThreadId: "provider-thread", ordinal: 1 },
36+ { id: "later", status: "queued", userMessageId: "message-later", ordinal: 3 },
37+ {
38+ id: "first",
39+ status: "queued",
40+ userMessageId: "message-first",
41+ ordinal: 2,
42+ queuePosition: 1,
43+ },
44+ ],
45+ messages: [
46+ { id: "message-first", text: "First" },
47+ { id: "message-later", text: "Later" },
48+ ],
49+ providerThreads: [
50+ {
51+ id: "provider-thread",
52+ appThreadId: "thread",
53+ providerSessionId: "provider-session",
54+ },
55+ ],
56+ providerSessions: [
57+ {
58+ id: "provider-session",
59+ status: "running",
60+ capabilities: capabilities({ queued: true, restartSteer: true }),
61+ },
62+ ],
63+ } as never);
64+
65+ expect(state.queuedRuns.map(({ run, text }) => [run.id, text])).toEqual([
66+ ["first", "First"],
67+ ["later", "Later"],
68+ ]);
69+ expect(state.activeRun?.id).toBe("active");
70+ expect(state.canReorder).toBe(true);
71+ expect(state.canPromoteToSteer).toBe(true);
72+ });
73+
74+ it("does not expose known unsupported queue or fork actions", () => {
75+ const projection = {
76+ thread: { id: "thread", activeProviderThreadId: "provider-thread" },
77+ runs: [{ id: "queued", status: "queued", userMessageId: "message", ordinal: 1 }],
78+ messages: [],
79+ providerThreads: [
80+ {
81+ id: "provider-thread",
82+ appThreadId: "thread",
83+ providerSessionId: "provider-session",
84+ },
85+ ],
86+ providerSessions: [
87+ {
88+ id: "provider-session",
89+ status: "ready",
90+ capabilities: capabilities(),
91+ },
92+ ],
93+ } as never;
94+ const queue = deriveThreadQueueWorkflowState(projection);
95+ const projectedItem = {
96+ item: { type: "assistant_message", runId: "run", status: "completed" },
97+ } as never;
98+
99+ expect(queue.canReorder).toBe(false);
100+ expect(queue.canPromoteToSteer).toBe(false);
101+ expect(canForkProjectedAssistantItem({ projectedItem, capabilities: capabilities() })).toBe(
102+ false,
103+ );
104+ expect(canDetachThreadProviderSession(projection)).toBe(true);
105+ });
106+
107+ it("allows native, portable, and capability-unknown exact-run forks", () => {
108+ const projectedItem = {
109+ item: { type: "assistant_message", runId: "run", status: "completed" },
110+ } as never;
111+
112+ expect(
113+ canForkProjectedAssistantItem({
114+ projectedItem,
115+ capabilities: capabilities({ nativeFork: true }),
116+ }),
117+ ).toBe(true);
118+ expect(
119+ canForkProjectedAssistantItem({
120+ projectedItem,
121+ capabilities: capabilities({ portableFork: true }),
122+ }),
123+ ).toBe(true);
124+ expect(canForkProjectedAssistantItem({ projectedItem })).toBe(true);
125+ expect(
126+ canForkProjectedAssistantItem({
127+ projectedItem: {
128+ item: { type: "assistant_message", runId: "run", status: "running" },
129+ } as never,
130+ }),
131+ ).toBe(false);
132+ });
133+});
packages/client-runtime/src/state/threadWorkflows.ts+107−0
@@ -0,0 +1,107 @@
1+import type {
2+ OrchestrationV2ProjectedTurnItem,
3+ OrchestrationV2ProviderCapabilities,
4+ OrchestrationV2ThreadProjection,
5+} from "@t3tools/contracts";
6+
7+type Projection = OrchestrationV2ThreadProjection;
8+type Run = Projection["runs"][number];
9+type ProviderSession = Projection["providerSessions"][number];
10+
11+const ACTIVE_RUN_STATUSES = new Set<Run["status"]>(["preparing", "starting", "running", "waiting"]);
12+
13+export interface QueuedThreadRun {
14+ readonly run: Run;
15+ readonly text: string;
16+}
17+
18+export interface ThreadQueueWorkflowState {
19+ readonly activeRun: Run | null;
20+ readonly queuedRuns: ReadonlyArray<QueuedThreadRun>;
21+ readonly canReorder: boolean;
22+ readonly canPromoteToSteer: boolean;
23+}
24+
25+export function resolveActiveThreadRun(projection: Projection): Run | null {
26+ return projection.runs.findLast((run) => ACTIVE_RUN_STATUSES.has(run.status)) ?? null;
27+}
28+
29+export function resolveThreadProviderSession(projection: Projection): ProviderSession | null {
30+ const activeRun = resolveActiveThreadRun(projection);
31+ const providerThreadId = activeRun?.providerThreadId ?? projection.thread.activeProviderThreadId;
32+ const activeProviderThread =
33+ providerThreadId === null
34+ ? null
35+ : (projection.providerThreads.find((thread) => thread.id === providerThreadId) ?? null);
36+ const attachedProviderThread =
37+ activeProviderThread ??
38+ projection.providerThreads.find(
39+ (thread) => thread.appThreadId === projection.thread.id && thread.providerSessionId !== null,
40+ ) ??
41+ null;
42+ const sessionId = attachedProviderThread?.providerSessionId ?? null;
43+ if (sessionId !== null) {
44+ return projection.providerSessions.find((session) => session.id === sessionId) ?? null;
45+ }
46+ return (
47+ projection.providerSessions.findLast(
48+ (session) => session.status !== "stopped" && session.status !== "error",
49+ ) ?? null
50+ );
51+}
52+
53+export function deriveThreadQueueWorkflowState(projection: Projection): ThreadQueueWorkflowState {
54+ const activeRun = resolveActiveThreadRun(projection);
55+ const session = resolveThreadProviderSession(projection);
56+ const capabilities = session?.capabilities.turns;
57+ const queuedRuns = projection.runs
58+ .filter((run) => run.status === "queued")
59+ .toSorted(
60+ (left, right) =>
61+ (left.queuePosition ?? left.ordinal) - (right.queuePosition ?? right.ordinal) ||
62+ left.ordinal - right.ordinal,
63+ )
64+ .map((run) => ({
65+ run,
66+ text:
67+ projection.messages.find((message) => message.id === run.userMessageId)?.text ??
68+ "Queued message",
69+ }));
70+
71+ return {
72+ activeRun,
73+ queuedRuns,
74+ canReorder: capabilities?.supportsQueuedMessages === true,
75+ canPromoteToSteer:
76+ activeRun !== null &&
77+ (capabilities?.supportsActiveSteering === true ||
78+ capabilities?.supportsSteeringByInterruptRestart === true),
79+ };
80+}
81+
82+export function canForkProjectedAssistantItem(input: {
83+ readonly projectedItem: OrchestrationV2ProjectedTurnItem;
84+ readonly capabilities?: OrchestrationV2ProviderCapabilities | undefined;
85+}): boolean {
86+ const item = input.projectedItem.item;
87+ if (item.type !== "assistant_message" || item.runId === null || item.status !== "completed") {
88+ return false;
89+ }
90+ if (input.capabilities === undefined) {
91+ // Historical and inherited rows may outlive their provider-session record.
92+ // Keep the portable server-side fallback available when capability evidence
93+ // is absent; a known incapable provider is rejected below.
94+ return true;
95+ }
96+ const capabilities = input.capabilities;
97+ const canForkNatively =
98+ capabilities.threads.canForkThread &&
99+ capabilities.threads.canForkFromTurn &&
100+ capabilities.identity.nativeThreadIds === "strong";
101+ return canForkNatively || capabilities.context.supportsFullThreadHandoff;
102+}
103+
104+export function canDetachThreadProviderSession(projection: Projection): boolean {
105+ const session = resolveThreadProviderSession(projection);
106+ return session !== null && session.status !== "stopped" && session.status !== "error";
107+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t