Unified · Split
| @@ -39,6 +39,8 @@ import { · 38 unmodified lines | ||
| 39 | 39 | ThreadComposer, |
| 40 | 40 | } from "./ThreadComposer"; |
| 41 | 41 | import { ThreadFeed } from "./ThreadFeed"; |
| 42 | +import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner"; | |
| 43 | +import { ThreadQueueControl } from "./ThreadQueueControl"; | |
| 42 | 44 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 43 | 45 | |
| 44 | 46 | export interface ThreadDetailScreenProps { |
| @@ -354,6 +356,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 309 unmodified lines | ||
| 354 | 356 | key={props.selectedThread.id} |
| 355 | 357 | environmentId={props.environmentId} |
| 356 | 358 | threadId={props.selectedThread.id} |
| 359 | + threadTitle={props.selectedThread.title} | |
| 357 | 360 | workspaceRoot={props.threadCwd} |
| 358 | 361 | feed={props.selectedThreadFeed} |
| 359 | 362 | contentPresentation={props.contentPresentation} |
| @@ -365,6 +368,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 5 unmodified lines | ||
| 365 | 368 | contentInsetEndAdjustment={contentInsetEndAdjustment} |
| 366 | 369 | contentTopInset={headerHeight} |
| 367 | 370 | contentBottomInset={estimatedOverlayHeight} |
| 371 | + topAccessory={ | |
| 372 | + <ThreadRelationshipsBanner | |
| 373 | + environmentId={props.environmentId} | |
| 374 | + threadId={props.selectedThread.id} | |
| 375 | + /> | |
| 376 | + } | |
| 368 | 377 | layoutVariant={layoutVariant} |
| 369 | 378 | skills={selectedProviderSkills} |
| 370 | 379 | /> |
| @@ -384,17 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 13 unmodified lines | ||
| 384 | 393 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> |
| 385 | 394 | ) : null} |
| 386 | 395 | |
| 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 | + | |
| 398 | 401 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 399 | 402 | <Animated.View |
| 400 | 403 | className="shrink-0 gap-3 px-4 pb-3" |
| @@ -1,7 +1,15 @@ | ||
| 1 | 1 | import * as Haptics from "expo-haptics"; |
| 2 | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | 3 | 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"; | |
| 5 | 13 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | 14 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 7 | 15 | import { SymbolView } from "../../components/AppSymbol"; |
| @@ -78,15 +86,11 @@ import { · 70 unmodified lines | ||
| 78 | 86 | } from "../review/nativeReviewDiffAdapter"; |
| 79 | 87 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 80 | 88 | 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"; | |
| 90 | 94 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 91 | 95 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 92 | 96 | import { |
| @@ -103,6 +107,10 @@ import { · 10 unmodified lines | ||
| 103 | 107 | } from "./thread-work-log"; |
| 104 | 108 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 105 | 109 | 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"; | |
| 106 | 114 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| 107 | 115 | |
| 108 | 116 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { |
| @@ -147,6 +155,7 @@ function isFreshTimestamp(input: string): boolean { · 38 unmodified lines | ||
| 147 | 155 | export interface ThreadFeedProps { |
| 148 | 156 | readonly environmentId: EnvironmentId; |
| 149 | 157 | readonly threadId: ThreadId; |
| 158 | + readonly threadTitle: string; | |
| 150 | 159 | readonly workspaceRoot?: string | null; |
| 151 | 160 | readonly feed: ReadonlyArray<ThreadFeedEntry>; |
| 152 | 161 | readonly contentPresentation: ThreadContentPresentation; |
| @@ -158,13 +167,89 @@ export interface ThreadFeedProps { · 5 unmodified lines | ||
| 158 | 167 | readonly contentInsetEndAdjustment: SharedValue<number>; |
| 159 | 168 | readonly contentTopInset?: number; |
| 160 | 169 | readonly contentBottomInset?: number; |
| 161 | − readonly contentMaxWidth?: number; | |
| 170 | + readonly topAccessory?: ReactNode; | |
| 162 | 171 | readonly layoutVariant?: LayoutVariant; |
| 163 | 172 | readonly usesAutomaticContentInsets?: boolean; |
| 164 | 173 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; |
| 165 | 174 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 166 | 175 | } |
| 167 | 176 | |
| 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 | + | |
| 168 | 253 | function MessageAttachmentImage(props: { |
| 169 | 254 | readonly environmentId: EnvironmentId; |
| 170 | 255 | readonly attachmentId: string; |
| @@ -835,6 +920,7 @@ function renderFeedEntry( · 664 unmodified lines | ||
| 835 | 920 | readonly reviewCommentColors: ReviewCommentColors; |
| 836 | 921 | readonly reviewCommentBubbleWidth: number; |
| 837 | 922 | readonly userBubbleMaxWidth: number; |
| 923 | + readonly threadTitle: string; | |
| 838 | 924 | }, |
| 839 | 925 | ) { |
| 840 | 926 | const entry = info.item; |
| @@ -894,10 +980,12 @@ function renderFeedEntry( · 53 unmodified lines | ||
| 894 | 980 | if (isUser) { |
| 895 | 981 | const enterAnimated = isFreshTimestamp(message.createdAt); |
| 896 | 982 | 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} | |
| 901 | 989 | <View |
| 902 | 990 | className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5" |
| 903 | 991 | style={{ |
| @@ -989,6 +1077,12 @@ function renderFeedEntry( · 85 unmodified lines | ||
| 989 | 1077 | })} |
| 990 | 1078 | {showAssistantMeta ? ( |
| 991 | 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 | 1086 | <CopyTextButton |
| 993 | 1087 | accessibilityLabel="Copy message" |
| 994 | 1088 | text={message.text} |
| @@ -1744,6 +1838,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 749 unmodified lines | ||
| 1744 | 1838 | reviewCommentColors, |
| 1745 | 1839 | reviewCommentBubbleWidth, |
| 1746 | 1840 | userBubbleMaxWidth, |
| 1841 | + threadTitle: props.threadTitle, | |
| 1747 | 1842 | skills: props.skills, |
| 1748 | 1843 | workspaceRoot: props.workspaceRoot, |
| 1749 | 1844 | }), |
| @@ -1765,6 +1860,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 15 unmodified lines | ||
| 1765 | 1860 | onToggleWorkGroup, |
| 1766 | 1861 | onToggleWorkRow, |
| 1767 | 1862 | props.environmentId, |
| 1863 | + props.threadTitle, | |
| 1768 | 1864 | props.skills, |
| 1769 | 1865 | props.workspaceRoot, |
| 1770 | 1866 | ], |
| @@ -1813,95 +1909,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 42 unmodified lines | ||
| 1813 | 1909 | // render into — which is why the header blur was missing on threads. |
| 1814 | 1910 | scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 }, |
| 1815 | 1911 | } |
| 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 ? ( | |
| 1905 | 1938 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> |
| 1906 | 1939 | <ThreadFeedPlaceholder |
| 1907 | 1940 | title="No conversation yet" |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -1,12 +1,15 @@ | ||
| 1 | 1 | import * as Haptics from "expo-haptics"; |
| 2 | 2 | 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"; | |
| 4 | 5 | import { LayoutAnimation, Pressable, useColorScheme, View } from "react-native"; |
| 5 | 6 | |
| 6 | 7 | import { AppText as Text } from "../../components/AppText"; |
| 7 | 8 | import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; |
| 8 | 9 | import { cn } from "../../lib/cn"; |
| 10 | +import { buildThreadRoutePath } from "../../lib/routes"; | |
| 9 | 11 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 12 | +import { useV2ItemSupport } from "../../state/v2-item-support"; | |
| 10 | 13 | import { ThreadActivityInspector } from "./ThreadActivityInspector"; |
| 11 | 14 | |
| 12 | 15 | const 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 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"; | |
| 88 | 92 | |
| 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 | + } | |
| 100 | 106 | |
| 101 | −export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4) | |
| 107 | + if (targetThreadId === null) return null; | |
| 102 | 108 | |
| 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; | |
| 114 | 109 | 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> | |
| 119 | 127 | ); |
| 120 | 128 | } |
| 121 | 129 | |
| @@ -157,9 +165,12 @@ export function ThreadWorkLog(props: { · 35 unmodified lines | ||
| 157 | 165 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
| 158 | 166 | |
| 159 | 167 | return ( |
| 160 | − <Animated.View | |
| 168 | + <View | |
| 161 | 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 | 175 | <Pressable |
| 165 | 176 | accessibilityRole={canExpand ? "button" : undefined} |
| @@ -258,7 +269,14 @@ export function ThreadWorkLog(props: { · 92 unmodified lines | ||
| 258 | 269 | /> |
| 259 | 270 | </View> |
| 260 | 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 | 282 | </View> |
| @@ -129,6 +129,57 @@ describe("buildThreadFeed", () => { · 128 unmodified lines | ||
| 129 | 129 | "inherited", |
| 130 | 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 | 185 | it("folds settled V2 run work while keeping the terminal assistant message visible", () => { |
| @@ -49,6 +49,7 @@ export interface ThreadFeedActivity { · 48 unmodified lines | ||
| 49 | 49 | | "wrench" |
| 50 | 50 | | "zap"; |
| 51 | 51 | readonly toolLike: boolean; |
| 52 | + readonly prominent: boolean; | |
| 52 | 53 | readonly status: "success" | "failure" | "neutral" | null; |
| 53 | 54 | readonly projectedItem: OrchestrationV2ProjectedTurnItem; |
| 54 | 55 | } |
| @@ -149,6 +150,10 @@ function itemIsToolLike(item: OrchestrationV2TurnItem): boolean { · 94 unmodified lines | ||
| 149 | 150 | ); |
| 150 | 151 | } |
| 151 | 152 | |
| 153 | +function itemIsProminent(item: OrchestrationV2TurnItem): boolean { | |
| 154 | + return item.type === "fork" || item.type === "thread_created" || item.type === "subagent"; | |
| 155 | +} | |
| 156 | + | |
| 152 | 157 | function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] { |
| 153 | 158 | if (!itemIsToolLike(item)) return null; |
| 154 | 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 | 322 | .join("\n"), |
| 318 | 323 | toolLike: itemIsToolLike(item), |
| 324 | + prominent: itemIsProminent(item), | |
| 319 | 325 | status: itemStatus(item), |
| 320 | 326 | projectedItem: row, |
| 321 | 327 | }; |
| @@ -339,7 +345,12 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th · 17 unmodified lines | ||
| 339 | 345 | continue; |
| 340 | 346 | } |
| 341 | 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 | 354 | grouped[grouped.length - 1] = { |
| 344 | 355 | ...previous, |
| 345 | 356 | activities: [...previous.activities, entry.activity], |
| @@ -435,7 +446,16 @@ function deriveThreadFeedRunFolds( · 89 unmodified lines | ||
| 435 | 446 | } |
| 436 | 447 | const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId); |
| 437 | 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 | 460 | const firstEntry = group.entries[0]; |
| 441 | 461 | const lastEntry = group.entries.at(-1); |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | type ThreadId, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 12 | +import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; | |
| 12 | 13 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 13 | 14 | import { |
| 14 | 15 | createContext, |
| @@ -1191,17 +1192,10 @@ function AssistantForkButton({ · 1176 unmodified lines | ||
| 1191 | 1192 | sourceThreadId: projectedItem.sourceThreadId, |
| 1192 | 1193 | sourceItemId: projectedItem.sourceItemId, |
| 1193 | 1194 | }); |
| 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 | + }); | |
| 1205 | 1199 | |
| 1206 | 1200 | if (!canFork || projectedItem.item.runId === null) return null; |
| 1207 | 1201 | const runId = projectedItem.item.runId; |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | +import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows"; | |
| 2 | 3 | import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; |
| 3 | 4 | import { ArrowDownIcon, ArrowUpIcon, CornerUpRightIcon, ListOrderedIcon } from "lucide-react"; |
| 4 | 5 | import { useMemo, useState } from "react"; |
| @@ -18,31 +19,12 @@ export function QueuedRunsControl(props: { · 13 unmodified lines | ||
| 18 | 19 | const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun); |
| 19 | 20 | const promote = useAtomCommand(threadEnvironment.promoteQueuedRun); |
| 20 | 21 | 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), | |
| 36 | 24 | [projection], |
| 37 | 25 | ); |
| 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; | |
| 46 | 28 | |
| 47 | 29 | if (queued.length === 0) return null; |
| 48 | 30 | |
| @@ -83,7 +65,7 @@ export function QueuedRunsControl(props: { · 34 unmodified lines | ||
| 83 | 65 | size="icon-xs" |
| 84 | 66 | variant="ghost" |
| 85 | 67 | aria-label="Move queued message up" |
| 86 | − disabled={busyRunId !== null || index === 0} | |
| 68 | + disabled={busyRunId !== null || !workflow?.canReorder || index === 0} | |
| 87 | 69 | onClick={() => void move(run.id, queued[index - 1]?.run.id ?? null)} |
| 88 | 70 | > |
| 89 | 71 | <ArrowUpIcon className="size-3" /> |
| @@ -92,7 +74,7 @@ export function QueuedRunsControl(props: { · 2 unmodified lines | ||
| 92 | 74 | size="icon-xs" |
| 93 | 75 | variant="ghost" |
| 94 | 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 | 78 | onClick={() => void move(run.id, queued[index + 2]?.run.id ?? null)} |
| 97 | 79 | > |
| 98 | 80 | <ArrowDownIcon className="size-3" /> |
| @@ -100,7 +82,7 @@ export function QueuedRunsControl(props: { · 1 unmodified line | ||
| 100 | 82 | <Button |
| 101 | 83 | size="xs" |
| 102 | 84 | variant="outline" |
| 103 | − disabled={busyRunId !== null || activeRun === null} | |
| 85 | + disabled={busyRunId !== null || !workflow?.canPromoteToSteer} | |
| 104 | 86 | title={activeRun === null ? "There is no active run to steer" : "Promote to steer"} |
| 105 | 87 | onClick={() => void steer(run.id)} |
| 106 | 88 | > |
| @@ -1,4 +1,11 @@ | ||
| 1 | 1 | 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"; | |
| 2 | 9 | import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; |
| 3 | 10 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | 11 | import { |
| @@ -13,12 +20,6 @@ import { · 8 unmodified lines | ||
| 13 | 20 | import { useState } from "react"; |
| 14 | 21 | |
| 15 | 22 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 16 | −import { | |
| 17 | − deriveThreadRelationshipGraph, | |
| 18 | − immediateThreadRelationships, | |
| 19 | − resolveMergeBackTargetThreadId, | |
| 20 | − type ThreadRelationshipEdge, | |
| 21 | −} from "../../lib/threadRelationships"; | |
| 22 | 23 | import { buildThreadRouteParams } from "../../threadRoutes"; |
| 23 | 24 | import { useThreadProjection, useThreadShells } from "../../state/entities"; |
| 24 | 25 | import { threadEnvironment } from "../../state/threads"; |
| @@ -91,10 +92,7 @@ export function ThreadRelationshipsPanel(props: { · 66 unmodified lines | ||
| 91 | 92 | Number(left.threadId === mergeTargetThreadId), |
| 92 | 93 | ); |
| 93 | 94 | 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; | |
| 98 | 96 | |
| 99 | 97 | if (relationshipRows.length === 0) { |
| 100 | 98 | return null; |
| @@ -31,7 +31,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; · 30 unmodified lines | ||
| 31 | 31 | |
| 32 | 32 | import { ProviderModelPicker } from "../components/chat/ProviderModelPicker"; |
| 33 | 33 | import { usePrimarySettings } from "../hooks/useSettings"; |
| 34 | −import { resolveMergeBackTargetThreadId } from "../lib/threadRelationships"; | |
| 34 | +import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships"; | |
| 35 | 35 | import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils"; |
| 36 | 36 | import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection"; |
| 37 | 37 | import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders"; |
| @@ -135,13 +135,13 @@ · 134 unmodified lines | ||
| 135 | 135 | "types": "./src/state/threadSort.ts", |
| 136 | 136 | "default": "./src/state/threadSort.ts" |
| 137 | 137 | }, |
| 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" | |
| 141 | 141 | }, |
| 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" | |
| 145 | 145 | }, |
| 146 | 146 | "./state/vcs": { |
| 147 | 147 | "types": "./src/state/vcs.ts", |
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | relatedThreadIds, |
| 8 | 8 | resolveMergeBackTargetThreadId, |
| 9 | 9 | walkThreadRelationships, |
| 10 | −} from "./threadRelationships"; | |
| 10 | +} from "./threadRelationships.ts"; | |
| 11 | 11 | |
| 12 | 12 | describe("thread relationships", () => { |
| 13 | 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 | 109 | it("resolves merge-back only for forks and prefers the recorded fork source", () => { |
| 52 | 110 | const source = ThreadId.make("thread-source"); |
| 53 | 111 | const fallbackParent = ThreadId.make("thread-parent"); |
File renamed without changes.
| @@ -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 | +}); | |
| @@ -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 | +} | |