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
39 ThreadComposer,39 ThreadComposer,
40} from "./ThreadComposer";40} from "./ThreadComposer";
41import { ThreadFeed } from "./ThreadFeed";41import { ThreadFeed } from "./ThreadFeed";
42import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";
43import { ThreadQueueControl } from "./ThreadQueueControl";
42import type { ThreadContentPresentation } from "./threadContentPresentation";44import type { ThreadContentPresentation } from "./threadContentPresentation";
43 45
44export interface ThreadDetailScreenProps {46export interface ThreadDetailScreenProps {
@@ -354,6 +356,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 309 unmodified lines
354 key={props.selectedThread.id}356 key={props.selectedThread.id}
355 environmentId={props.environmentId}357 environmentId={props.environmentId}
356 threadId={props.selectedThread.id}358 threadId={props.selectedThread.id}
359 threadTitle={props.selectedThread.title}
357 workspaceRoot={props.threadCwd}360 workspaceRoot={props.threadCwd}
358 feed={props.selectedThreadFeed}361 feed={props.selectedThreadFeed}
359 contentPresentation={props.contentPresentation}362 contentPresentation={props.contentPresentation}
@@ -365,6 +368,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 5 unmodified lines
365 contentInsetEndAdjustment={contentInsetEndAdjustment}368 contentInsetEndAdjustment={contentInsetEndAdjustment}
366 contentTopInset={headerHeight}369 contentTopInset={headerHeight}
367 contentBottomInset={estimatedOverlayHeight}370 contentBottomInset={estimatedOverlayHeight}
371 topAccessory={
372 <ThreadRelationshipsBanner
373 environmentId={props.environmentId}
374 threadId={props.selectedThread.id}
375 />
376 }
368 layoutVariant={layoutVariant}377 layoutVariant={layoutVariant}
369 skills={selectedProviderSkills}378 skills={selectedProviderSkills}
370 />379 />
@@ -384,17 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 13 unmodified lines
384 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />393 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
385 ) : null}394 ) : null}
386 395
387 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}396 <ThreadQueueControl
388 {showContent ? (397 environmentId={props.environmentId}
389 <KeyboardStickyView398 threadId={props.selectedThread.id}
390 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}399 />
391 offset={{ closed: 0, opened: 0 }}400
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 }}>
398 {props.activePendingApproval || props.activePendingUserInput ? (401 {props.activePendingApproval || props.activePendingUserInput ? (
399 <Animated.View402 <Animated.View
400 className="shrink-0 gap-3 px-4 pb-3"403 className="shrink-0 gap-3 px-4 pb-3"
apps/mobile/src/features/threads/ThreadFeed.tsx+137−104
@@ -1,7 +1,15 @@
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 { type LegendListRef } from "@legendapp/list/react-native";
4import type { EnvironmentId, MessageId, RunId, ThreadId } from "@t3tools/contracts";4import { scopeThreadRef } from "@t3tools/client-runtime/environment";
5import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
6import {
7 ThreadId,
8 type EnvironmentId,
9 type MessageId,
10 type OrchestrationV2ProjectedTurnItem,
11 type RunId,
12} from "@t3tools/contracts";
5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";13import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
6import { formatElapsed } from "@t3tools/shared/orchestrationTiming";14import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
7import { SymbolView } from "../../components/AppSymbol";15import { SymbolView } from "../../components/AppSymbol";
@@ -78,15 +86,11 @@ import { · 70 unmodified lines
78} from "../review/nativeReviewDiffAdapter";86} from "../review/nativeReviewDiffAdapter";
79import { buildReviewParsedDiff } from "../review/reviewModel";87import { buildReviewParsedDiff } from "../review/reviewModel";
80import { cn } from "../../lib/cn";88import { cn } from "../../lib/cn";
81import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";89import type { LayoutVariant } from "../../lib/layout";
82import {90import { buildThreadFilesNavigation } from "../../lib/routes";
83 resolveMarkdownFontSizes,91import { buildThreadRoutePath } from "../../lib/routes";
84 resolveNativeMarkdownTypography,92import { uuidv4 } from "../../lib/uuid";
85 scaledTypographyLineHeight,93import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography";
86} from "../../lib/appearancePreferences";
87import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
88import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
89import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
90import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";94import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
91import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";95import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
92import {96import {
@@ -103,6 +107,10 @@ import { · 10 unmodified lines
103} from "./thread-work-log";107} from "./thread-work-log";
104import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";108import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
105import { useAssetUrl } from "../../state/assets";109import { useAssetUrl } from "../../state/assets";
110import { appAtomRegistry } from "../../state/atom-registry";
111import { environmentThreadShells, threadEnvironment } from "../../state/threads";
112import { useAtomCommand } from "../../state/use-atom-command";
113import { useV2ItemSupport } from "../../state/v2-item-support";
106import { resolveWorkspaceRelativeFilePath } from "../files/filePath";114import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
107 115
108const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {116const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
@@ -147,6 +155,7 @@ function isFreshTimestamp(input: string): boolean { · 38 unmodified lines
147export interface ThreadFeedProps {155export interface ThreadFeedProps {
148 readonly environmentId: EnvironmentId;156 readonly environmentId: EnvironmentId;
149 readonly threadId: ThreadId;157 readonly threadId: ThreadId;
158 readonly threadTitle: string;
150 readonly workspaceRoot?: string | null;159 readonly workspaceRoot?: string | null;
151 readonly feed: ReadonlyArray<ThreadFeedEntry>;160 readonly feed: ReadonlyArray<ThreadFeedEntry>;
152 readonly contentPresentation: ThreadContentPresentation;161 readonly contentPresentation: ThreadContentPresentation;
@@ -158,13 +167,89 @@ export interface ThreadFeedProps { · 5 unmodified lines
158 readonly contentInsetEndAdjustment: SharedValue<number>;167 readonly contentInsetEndAdjustment: SharedValue<number>;
159 readonly contentTopInset?: number;168 readonly contentTopInset?: number;
160 readonly contentBottomInset?: number;169 readonly contentBottomInset?: number;
161 readonly contentMaxWidth?: number;170 readonly topAccessory?: ReactNode;
162 readonly layoutVariant?: LayoutVariant;171 readonly layoutVariant?: LayoutVariant;
163 readonly usesAutomaticContentInsets?: boolean;172 readonly usesAutomaticContentInsets?: boolean;
164 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;173 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
165 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;174 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
166}175}
167 176
177async 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
185function 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
168function MessageAttachmentImage(props: {253function MessageAttachmentImage(props: {
169 readonly environmentId: EnvironmentId;254 readonly environmentId: EnvironmentId;
170 readonly attachmentId: string;255 readonly attachmentId: string;
@@ -835,6 +920,7 @@ function renderFeedEntry( · 664 unmodified lines
835 readonly reviewCommentColors: ReviewCommentColors;920 readonly reviewCommentColors: ReviewCommentColors;
836 readonly reviewCommentBubbleWidth: number;921 readonly reviewCommentBubbleWidth: number;
837 readonly userBubbleMaxWidth: number;922 readonly userBubbleMaxWidth: number;
923 readonly threadTitle: string;
838 },924 },
839) {925) {
840 const entry = info.item;926 const entry = info.item;
@@ -894,10 +980,12 @@ function renderFeedEntry( · 53 unmodified lines
894 if (isUser) {980 if (isUser) {
895 const enterAnimated = isFreshTimestamp(message.createdAt);981 const enterAnimated = isFreshTimestamp(message.createdAt);
896 return (982 return (
897 <Animated.View983 <View className="mb-5 items-end">
898 className="mb-5 items-end"984 {message.createdBy === "agent" ? (
899 {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}985 <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
900 >986 Sent by another agent
987 </Text>
988 ) : null}
901 <View989 <View
902 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"990 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
903 style={{991 style={{
@@ -989,6 +1077,12 @@ function renderFeedEntry( · 85 unmodified lines
989 })}1077 })}
990 {showAssistantMeta ? (1078 {showAssistantMeta ? (
991 <View className="mt-1 flex-row items-center gap-1">1079 <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 />
992 <CopyTextButton1086 <CopyTextButton
993 accessibilityLabel="Copy message"1087 accessibilityLabel="Copy message"
994 text={message.text}1088 text={message.text}
@@ -1744,6 +1838,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 749 unmodified lines
1744 reviewCommentColors,1838 reviewCommentColors,
1745 reviewCommentBubbleWidth,1839 reviewCommentBubbleWidth,
1746 userBubbleMaxWidth,1840 userBubbleMaxWidth,
1841 threadTitle: props.threadTitle,
1747 skills: props.skills,1842 skills: props.skills,
1748 workspaceRoot: props.workspaceRoot,1843 workspaceRoot: props.workspaceRoot,
1749 }),1844 }),
@@ -1765,6 +1860,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 15 unmodified lines
1765 onToggleWorkGroup,1860 onToggleWorkGroup,
1766 onToggleWorkRow,1861 onToggleWorkRow,
1767 props.environmentId,1862 props.environmentId,
1863 props.threadTitle,
1768 props.skills,1864 props.skills,
1769 props.workspaceRoot,1865 props.workspaceRoot,
1770 ],1866 ],
@@ -1813,95 +1909,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 42 unmodified lines
1813 // render into — which is why the header blur was missing on threads.1909 // render into — which is why the header blur was missing on threads.
1814 scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 },1910 scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 },
1815 }1911 }
1816 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}1912 }
1817 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}1913 maintainVisibleContentPosition
1818 itemLayoutAnimation={feedItemLayoutTransition}1914 data={presentedFeed}
1819 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):1915 extraData={listAppearanceData}
1820 // lets its scroll math clamp programmatic scrolls to -headerInset1916 renderItem={renderItem}
1821 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short1917 keyExtractor={(entry) => entry.id}
1822 // content rest below the transparent header rather than at frame top.1918 getItemType={(entry) =>
1823 contentInsetStartAdjustment={usesNativeAutomaticInsets ? anchorTopInset : 0}1919 entry.type === "message" ? `message:${entry.message.role}` : entry.type
1824 contentInsetEndAdjustment={props.contentInsetEndAdjustment}1920 }
1825 // UIKit's automatic behavior adds the safe-area bottom on top of the1921 keyboardShouldPersistTaps="always"
1826 // raw contentInset the keyboard integration writes. The detail screen1922 keyboardDismissMode="none"
1827 // under-reports the composer inset by this amount (see1923 keyboardLiftBehavior="whenAtEnd"
1828 // ThreadDetailScreen); this tells LegendList's scroll math about the1924 estimatedItemSize={180}
1829 // extra so programmatic end scrolls land at the true resting offset.1925 initialScrollAtEnd
1830 contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0}1926 ListHeaderComponent={
1831 // The keyboard integration's offset math (end pinning, max scroll)1927 <>
1832 // must add the same UIKit-added extra, or its keyboard-open end1928 <View style={{ height: topContentInset }} />
1833 // targets land one safe-area short of the true resting offset.1929 {props.topAccessory}
1834 adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0}1930 </>
1835 freeze={props.freeze}1931 }
1836 // Animated: on send, the optimistic message's dataChange fires1932 contentContainerStyle={{
1837 // maintainScrollAtEnd before any render-cycle suppression could1933 paddingTop: 12,
1838 // engage — an instant snap there teleports the feed to the anchor1934 paddingHorizontal: horizontalPadding,
1839 // instead of scrolling to it. Keeping it enabled (animated) during1935 }}
1840 // anchor scrolls also lets it correct a scroll that landed on a1936 />
1841 // stale end target once the anchor row finishes measuring.1937 {props.feed.length === 0 ? (
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" ? (
1905 <View pointerEvents="none" style={StyleSheet.absoluteFill}>1938 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
1906 <ThreadFeedPlaceholder1939 <ThreadFeedPlaceholder
1907 title="No conversation yet"1940 title="No conversation yet"
apps/mobile/src/features/threads/ThreadQueueControl.tsx+115−0
@@ -0,0 +1,115 @@
1import * as Haptics from "expo-haptics";
2import { SymbolView } from "expo-symbols";
3import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows";
4import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
5import { useMemo, useState } from "react";
6import { Pressable, ScrollView, View } from "react-native";
7
8import { AppText as Text } from "../../components/AppText";
9import { useThemeColor } from "../../lib/useThemeColor";
10import { threadEnvironment } from "../../state/threads";
11import { useAtomCommand } from "../../state/use-atom-command";
12import { useThreadProjection } from "../../state/use-thread-detail";
13
14export 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 @@
1import * as Haptics from "expo-haptics";
2import { SymbolView, type SFSymbol } from "expo-symbols";
3import {
4 deriveThreadRelationshipGraph,
5 immediateThreadRelationships,
6 resolveMergeBackTargetThreadId,
7 type ThreadRelationshipEdge,
8} from "@t3tools/client-runtime/state/thread-relationships";
9import { canDetachThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
10import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
11import { useRouter } from "expo-router";
12import { useMemo, useState } from "react";
13import { ActivityIndicator, Modal, Pressable, ScrollView, View } from "react-native";
14import { useSafeAreaInsets } from "react-native-safe-area-context";
15
16import { AppText as Text } from "../../components/AppText";
17import { buildThreadRoutePath } from "../../lib/routes";
18import { useThemeColor } from "../../lib/useThemeColor";
19import { useThreadShells } from "../../state/entities";
20import { threadEnvironment } from "../../state/threads";
21import { useAtomCommand } from "../../state/use-atom-command";
22import { useThreadProjection } from "../../state/use-thread-detail";
23import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots";
24
25function 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
35function 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
41function 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
51const EMPTY_THREAD_SHELLS: ReadonlyArray<OrchestrationV2ThreadShell> = [];
52
53export 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 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { SymbolView, type SFSymbol } from "expo-symbols";2import { SymbolView, type SFSymbol } from "expo-symbols";
3import type { EnvironmentId } from "@t3tools/contracts";3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import { useRouter } from "expo-router";
4import { LayoutAnimation, Pressable, useColorScheme, View } from "react-native";5import { LayoutAnimation, Pressable, useColorScheme, View } from "react-native";
5 6
6import { AppText as Text } from "../../components/AppText";7import { AppText as Text } from "../../components/AppText";
7import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";8import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
8import { cn } from "../../lib/cn";9import { cn } from "../../lib/cn";
10import { buildThreadRoutePath } from "../../lib/routes";
9import type { ThreadFeedActivity } from "../../lib/threadActivity";11import type { ThreadFeedActivity } from "../../lib/threadActivity";
12import { useV2ItemSupport } from "../../state/v2-item-support";
10import { ThreadActivityInspector } from "./ThreadActivityInspector";13import { ThreadActivityInspector } from "./ThreadActivityInspector";
11 14
12const WORK_LOG_LAYOUT_ANIMATION = {15const WORK_LOG_LAYOUT_ANIMATION = {
@@ -71,51 +74,56 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { · 58 unmodified lines
71 }74 }
72}75}
73 76
74// Entering fades only for rows created moments ago: rows remount whenever the77function ThreadActivityThreadLink(props: {
75// list scrolls them back into view, and old rows must not replay an entrance.78 readonly activity: ThreadFeedActivity;
76const FRESH_ROW_WINDOW_MS = 3_000;79 readonly environmentId: EnvironmentId;
77function isFreshRow(createdAt: string): boolean {80 readonly iconColor: import("react-native").ColorValue;
78 const timestamp = Date.parse(createdAt);81}) {
79 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;82 const row = props.activity.projectedItem;
80}83 const support = useV2ItemSupport({
81 84 environmentId: props.environmentId,
82// Tool-like activities with a neutral status carry no signal worth a row.85 sourceThreadId: row.sourceThreadId,
83export function visibleWorkLogActivities(86 sourceItemId: row.sourceItemId,
84 activities: ReadonlyArray<ThreadFeedActivity>,87 });
85): ReadonlyArray<ThreadFeedActivity> {88 const router = useRouter();
86 return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral"));89 const item = row.item;
87}90 let targetThreadId: ThreadId | null = null;
91 let label = "Open related thread";
88 92
89// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log93 if (item.type === "thread_created") {
90// rows are single-line (numberOfLines={1}) inside a min-height that stays94 targetThreadId = item.targetThreadId;
91// taller than the text at every supported base font size (text-xs reaches95 label = "Open created thread";
92// 23px at the 22pt maximum, under the 32px min-h-8), so row height is96 } else if (item.type === "subagent") {
93// deterministic. The "work log" label has no such clamp — its height follows97 targetThreadId = support.subagent?.childThreadId ?? item.childThreadId;
94// the scaled text-2xs line height. Values mirror the classNames below — keep98 label = "Open subagent thread";
95// them in sync; a mismatch only costs a one-time correction on measure.99 } else if (item.type === "fork") {
96const WORK_ROW_HEIGHT = 32; // min-h-8100 targetThreadId =
97const WORK_ROW_GAP = 1; // gap-px101 item.targetThreadId === row.sourceThreadId && item.source.type === "run"
98const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label102 ? item.source.threadId
99const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1103 : item.targetThreadId;
104 label = targetThreadId === item.targetThreadId ? "Open forked thread" : "Open parent thread";
105 }
100 106
101export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)107 if (targetThreadId === null) return null;
102 108
103export 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;
114 return (109 return (
115 WORK_LOG_BOTTOM_MARGIN +110 <Pressable
116 (onlyToolRows ? 0 : headerHeight) +111 accessibilityRole="link"
117 rows.length * WORK_ROW_HEIGHT +112 accessibilityLabel={label}
118 (rows.length - 1) * WORK_ROW_GAP113 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>
119 );127 );
120}128}
121 129
@@ -157,9 +165,12 @@ export function ThreadWorkLog(props: { · 35 unmodified lines
157 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";165 const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
158 166
159 return (167 return (
160 <Animated.View168 <View
161 key={row.id}169 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 )}
163 >174 >
164 <Pressable175 <Pressable
165 accessibilityRole={canExpand ? "button" : undefined}176 accessibilityRole={canExpand ? "button" : undefined}
@@ -258,7 +269,14 @@ export function ThreadWorkLog(props: { · 92 unmodified lines
258 />269 />
259 </View>270 </View>
260 ) : null}271 ) : 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>
262 );280 );
263 })}281 })}
264 </View>282 </View>
apps/mobile/src/lib/threadActivity.test.ts+51−0
@@ -129,6 +129,57 @@ describe("buildThreadFeed", () => { · 128 unmodified lines
129 "inherited",129 "inherited",
130 "synthetic",130 "synthetic",
131 ]);131 ]);
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);
132 });183 });
133 184
134 it("folds settled V2 run work while keeping the terminal assistant message visible", () => {185 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
49 | "wrench"49 | "wrench"
50 | "zap";50 | "zap";
51 readonly toolLike: boolean;51 readonly toolLike: boolean;
52 readonly prominent: boolean;
52 readonly status: "success" | "failure" | "neutral" | null;53 readonly status: "success" | "failure" | "neutral" | null;
53 readonly projectedItem: OrchestrationV2ProjectedTurnItem;54 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
54}55}
@@ -149,6 +150,10 @@ function itemIsToolLike(item: OrchestrationV2TurnItem): boolean { · 94 unmodified lines
149 );150 );
150}151}
151 152
153function itemIsProminent(item: OrchestrationV2TurnItem): boolean {
154 return item.type === "fork" || item.type === "thread_created" || item.type === "subagent";
155}
156
152function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {157function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {
153 if (!itemIsToolLike(item)) return null;158 if (!itemIsToolLike(item)) return null;
154 if (item.type === "error" || item.status === "failed") return "failure";159 if (item.type === "error" || item.status === "failed") return "failure";
@@ -316,6 +321,7 @@ function toFeedActivity(row: OrchestrationV2ProjectedTurnItem): ThreadFeedActivi · 161 unmodified lines
316 )321 )
317 .join("\n"),322 .join("\n"),
318 toolLike: itemIsToolLike(item),323 toolLike: itemIsToolLike(item),
324 prominent: itemIsProminent(item),
319 status: itemStatus(item),325 status: itemStatus(item),
320 projectedItem: row,326 projectedItem: row,
321 };327 };
@@ -339,7 +345,12 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th · 17 unmodified lines
339 continue;345 continue;
340 }346 }
341 const previous = grouped.at(-1);347 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 ) {
343 grouped[grouped.length - 1] = {354 grouped[grouped.length - 1] = {
344 ...previous,355 ...previous,
345 activities: [...previous.activities, entry.activity],356 activities: [...previous.activities, entry.activity],
@@ -435,7 +446,16 @@ function deriveThreadFeedRunFolds( · 89 unmodified lines
435 }446 }
436 const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId);447 const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId);
437 const hiddenEntryIds = new Set(448 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),
439 );459 );
440 const firstEntry = group.entries[0];460 const firstEntry = group.entries[0];
441 const lastEntry = group.entries.at(-1);461 const lastEntry = group.entries.at(-1);
apps/web/src/components/chat/MessagesTimeline.tsx+5−11
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 type ThreadId,9 type ThreadId,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";11import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
12import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
12import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";13import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
13import {14import {
14 createContext,15 createContext,
@@ -1191,17 +1192,10 @@ function AssistantForkButton({ · 1176 unmodified lines
1191 sourceThreadId: projectedItem.sourceThreadId,1192 sourceThreadId: projectedItem.sourceThreadId,
1192 sourceItemId: projectedItem.sourceItemId,1193 sourceItemId: projectedItem.sourceItemId,
1193 });1194 });
1194 const capabilities = support.providerSession?.capabilities;1195 const canFork = canForkProjectedAssistantItem({
1195 const canForkNatively =1196 projectedItem,
1196 capabilities?.threads.canForkThread === true &&1197 capabilities: support.providerSession?.capabilities,
1197 capabilities.threads.canForkFromTurn === true &&1198 });
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);
1205 1199
1206 if (!canFork || projectedItem.item.runId === null) return null;1200 if (!canFork || projectedItem.item.runId === null) return null;
1207 const runId = projectedItem.item.runId;1201 const runId = projectedItem.item.runId;
apps/web/src/components/chat/QueuedRunsControl.tsx+8−26
@@ -1,4 +1,5 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows";
2import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
3import { ArrowDownIcon, ArrowUpIcon, CornerUpRightIcon, ListOrderedIcon } from "lucide-react";4import { ArrowDownIcon, ArrowUpIcon, CornerUpRightIcon, ListOrderedIcon } from "lucide-react";
4import { useMemo, useState } from "react";5import { useMemo, useState } from "react";
@@ -18,31 +19,12 @@ export function QueuedRunsControl(props: { · 13 unmodified lines
18 const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun);19 const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun);
19 const promote = useAtomCommand(threadEnvironment.promoteQueuedRun);20 const promote = useAtomCommand(threadEnvironment.promoteQueuedRun);
20 const [busyRunId, setBusyRunId] = useState<RunId | null>(null);21 const [busyRunId, setBusyRunId] = useState<RunId | null>(null);
21 const queued = useMemo(22 const workflow = useMemo(
22 () =>23 () => (projection ? deriveThreadQueueWorkflowState(projection) : null),
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 })),
36 [projection],24 [projection],
37 );25 );
38 const activeRun =26 const queued = workflow?.queuedRuns ?? [];
39 projection?.runs.findLast(27 const activeRun = workflow?.activeRun ?? null;
40 (run) =>
41 run.status === "preparing" ||
42 run.status === "starting" ||
43 run.status === "running" ||
44 run.status === "waiting",
45 ) ?? null;
46 28
47 if (queued.length === 0) return null;29 if (queued.length === 0) return null;
48 30
@@ -83,7 +65,7 @@ export function QueuedRunsControl(props: { · 34 unmodified lines
83 size="icon-xs"65 size="icon-xs"
84 variant="ghost"66 variant="ghost"
85 aria-label="Move queued message up"67 aria-label="Move queued message up"
86 disabled={busyRunId !== null || index === 0}68 disabled={busyRunId !== null || !workflow?.canReorder || index === 0}
87 onClick={() => void move(run.id, queued[index - 1]?.run.id ?? null)}69 onClick={() => void move(run.id, queued[index - 1]?.run.id ?? null)}
88 >70 >
89 <ArrowUpIcon className="size-3" />71 <ArrowUpIcon className="size-3" />
@@ -92,7 +74,7 @@ export function QueuedRunsControl(props: { · 2 unmodified lines
92 size="icon-xs"74 size="icon-xs"
93 variant="ghost"75 variant="ghost"
94 aria-label="Move queued message down"76 aria-label="Move queued message down"
95 disabled={busyRunId !== null || index === queued.length - 1}77 disabled={busyRunId !== null || !workflow?.canReorder || index === queued.length - 1}
96 onClick={() => void move(run.id, queued[index + 2]?.run.id ?? null)}78 onClick={() => void move(run.id, queued[index + 2]?.run.id ?? null)}
97 >79 >
98 <ArrowDownIcon className="size-3" />80 <ArrowDownIcon className="size-3" />
@@ -100,7 +82,7 @@ export function QueuedRunsControl(props: { · 1 unmodified line
100 <Button82 <Button
101 size="xs"83 size="xs"
102 variant="outline"84 variant="outline"
103 disabled={busyRunId !== null || activeRun === null}85 disabled={busyRunId !== null || !workflow?.canPromoteToSteer}
104 title={activeRun === null ? "There is no active run to steer" : "Promote to steer"}86 title={activeRun === null ? "There is no active run to steer" : "Promote to steer"}
105 onClick={() => void steer(run.id)}87 onClick={() => void steer(run.id)}
106 >88 >
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+8−10
@@ -1,4 +1,11 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import {
3 deriveThreadRelationshipGraph,
4 immediateThreadRelationships,
5 resolveMergeBackTargetThreadId,
6 type ThreadRelationshipEdge,
7} from "@t3tools/client-runtime/state/thread-relationships";
8import { canDetachThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
2import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";9import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
3import { useNavigate } from "@tanstack/react-router";10import { useNavigate } from "@tanstack/react-router";
4import {11import {
@@ -13,12 +20,6 @@ import { · 8 unmodified lines
13import { useState } from "react";20import { useState } from "react";
14 21
15import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";22import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
16import {
17 deriveThreadRelationshipGraph,
18 immediateThreadRelationships,
19 resolveMergeBackTargetThreadId,
20 type ThreadRelationshipEdge,
21} from "../../lib/threadRelationships";
22import { buildThreadRouteParams } from "../../threadRoutes";23import { buildThreadRouteParams } from "../../threadRoutes";
23import { useThreadProjection, useThreadShells } from "../../state/entities";24import { useThreadProjection, useThreadShells } from "../../state/entities";
24import { threadEnvironment } from "../../state/threads";25import { threadEnvironment } from "../../state/threads";
@@ -91,10 +92,7 @@ export function ThreadRelationshipsPanel(props: { · 66 unmodified lines
91 Number(left.threadId === mergeTargetThreadId),92 Number(left.threadId === mergeTargetThreadId),
92 );93 );
93 const canMerge = mergeTargetThreadId !== null && latestCompletedRun !== null;94 const canMerge = mergeTargetThreadId !== null && latestCompletedRun !== null;
94 const canDetach =95 const canDetach = projection ? canDetachThreadProviderSession(projection) : false;
95 projection?.providerSessions.some(
96 (session) => session.status !== "stopped" && session.status !== "error",
97 ) ?? false;
98 96
99 if (relationshipRows.length === 0) {97 if (relationshipRows.length === 0) {
100 return null;98 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
31 31
32import { ProviderModelPicker } from "../components/chat/ProviderModelPicker";32import { ProviderModelPicker } from "../components/chat/ProviderModelPicker";
33import { usePrimarySettings } from "../hooks/useSettings";33import { usePrimarySettings } from "../hooks/useSettings";
34import { resolveMergeBackTargetThreadId } from "../lib/threadRelationships";34import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
35import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils";35import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils";
36import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection";36import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection";
37import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders";37import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders";
packages/client-runtime/package.json+6−6
@@ -135,13 +135,13 @@ · 134 unmodified lines
135 "types": "./src/state/threadSort.ts",135 "types": "./src/state/threadSort.ts",
136 "default": "./src/state/threadSort.ts"136 "default": "./src/state/threadSort.ts"
137 },137 },
138 "./state/thread-settled": {138 "./state/thread-relationships": {
139 "types": "./src/state/threadSettled.ts",139 "types": "./src/state/threadRelationships.ts",
140 "default": "./src/state/threadSettled.ts"140 "default": "./src/state/threadRelationships.ts"
141 },141 },
142 "./state/thread-search": {142 "./state/thread-workflows": {
143 "types": "./src/state/threadSearch.ts",143 "types": "./src/state/threadWorkflows.ts",
144 "default": "./src/state/threadSearch.ts"144 "default": "./src/state/threadWorkflows.ts"
145 },145 },
146 "./state/vcs": {146 "./state/vcs": {
147 "types": "./src/state/vcs.ts",147 "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
7 relatedThreadIds,7 relatedThreadIds,
8 resolveMergeBackTargetThreadId,8 resolveMergeBackTargetThreadId,
9 walkThreadRelationships,9 walkThreadRelationships,
10} from "./threadRelationships";10} from "./threadRelationships.ts";
11 11
12describe("thread relationships", () => {12describe("thread relationships", () => {
13 it("keeps missing parents and cycles navigable without recursive traversal", () => {13 it("keeps missing parents and cycles navigable without recursive traversal", () => {
@@ -48,6 +48,64 @@ describe("thread relationships", () => { · 34 unmodified lines
48 ]);48 ]);
49 });49 });
50 50
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
51 it("resolves merge-back only for forks and prefers the recorded fork source", () => {109 it("resolves merge-back only for forks and prefers the recorded fork source", () => {
52 const source = ThreadId.make("thread-source");110 const source = ThreadId.make("thread-source");
53 const fallbackParent = ThreadId.make("thread-parent");111 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 canDetachThreadProviderSession,
5 canForkProjectedAssistantItem,
6 deriveThreadQueueWorkflowState,
7} from "./threadWorkflows.ts";
8
9const 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
30describe("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 @@
1import type {
2 OrchestrationV2ProjectedTurnItem,
3 OrchestrationV2ProviderCapabilities,
4 OrchestrationV2ThreadProjection,
5} from "@t3tools/contracts";
6
7type Projection = OrchestrationV2ThreadProjection;
8type Run = Projection["runs"][number];
9type ProviderSession = Projection["providerSessions"][number];
10
11const ACTIVE_RUN_STATUSES = new Set<Run["status"]>(["preparing", "starting", "running", "waiting"]);
12
13export interface QueuedThreadRun {
14 readonly run: Run;
15 readonly text: string;
16}
17
18export interface ThreadQueueWorkflowState {
19 readonly activeRun: Run | null;
20 readonly queuedRuns: ReadonlyArray<QueuedThreadRun>;
21 readonly canReorder: boolean;
22 readonly canPromoteToSteer: boolean;
23}
24
25export function resolveActiveThreadRun(projection: Projection): Run | null {
26 return projection.runs.findLast((run) => ACTIVE_RUN_STATUSES.has(run.status)) ?? null;
27}
28
29export 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
53export 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
82export 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
104export 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