Unified · Split
| @@ -39,6 +39,8 @@ import { · 38 unmodified lines | |||
| 39 | ThreadComposer, | 39 | ThreadComposer, |
| 40 | } from "./ThreadComposer"; | 40 | } from "./ThreadComposer"; |
| 41 | import { ThreadFeed } from "./ThreadFeed"; | 41 | import { ThreadFeed } from "./ThreadFeed"; |
| 42 | import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner"; | ||
| 43 | import { ThreadQueueControl } from "./ThreadQueueControl"; | ||
| 42 | import type { ThreadContentPresentation } from "./threadContentPresentation"; | 44 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 43 | 45 | ||
| 44 | export interface ThreadDetailScreenProps { | 46 | export 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 | <KeyboardStickyView | 398 | 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.View | 402 | <Animated.View |
| 400 | className="shrink-0 gap-3 px-4 pb-3" | 403 | className="shrink-0 gap-3 px-4 pb-3" |
| @@ -1,7 +1,15 @@ | |||
| 1 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { type LegendListRef } from "@legendapp/list/react-native"; | 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 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 13 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | 14 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 7 | import { SymbolView } from "../../components/AppSymbol"; | 15 | import { SymbolView } from "../../components/AppSymbol"; |
| @@ -78,15 +86,11 @@ import { · 70 unmodified lines | |||
| 78 | } from "../review/nativeReviewDiffAdapter"; | 86 | } from "../review/nativeReviewDiffAdapter"; |
| 79 | import { buildReviewParsedDiff } from "../review/reviewModel"; | 87 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 80 | import { cn } from "../../lib/cn"; | 88 | import { cn } from "../../lib/cn"; |
| 81 | import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout"; | 89 | import type { LayoutVariant } from "../../lib/layout"; |
| 82 | import { | 90 | import { buildThreadFilesNavigation } from "../../lib/routes"; |
| 83 | resolveMarkdownFontSizes, | 91 | import { buildThreadRoutePath } from "../../lib/routes"; |
| 84 | resolveNativeMarkdownTypography, | 92 | import { uuidv4 } from "../../lib/uuid"; |
| 85 | scaledTypographyLineHeight, | 93 | import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 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"; | ||
| 90 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 94 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| 91 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | 95 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 92 | import { | 96 | import { |
| @@ -103,6 +107,10 @@ import { · 10 unmodified lines | |||
| 103 | } from "./thread-work-log"; | 107 | } from "./thread-work-log"; |
| 104 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | 108 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 105 | import { useAssetUrl } from "../../state/assets"; | 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 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | 114 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| 107 | 115 | ||
| 108 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { | 116 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { |
| @@ -147,6 +155,7 @@ function isFreshTimestamp(input: string): boolean { · 38 unmodified lines | |||
| 147 | export interface ThreadFeedProps { | 155 | export 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 | ||
| 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 | function MessageAttachmentImage(props: { | 253 | function 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.View | 983 | <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 | <View | 989 | <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 | <CopyTextButton | 1086 | <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 -headerInset | 1916 | renderItem={renderItem} |
| 1821 | // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short | 1917 | 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 the | 1921 | keyboardShouldPersistTaps="always" |
| 1826 | // raw contentInset the keyboard integration writes. The detail screen | 1922 | keyboardDismissMode="none" |
| 1827 | // under-reports the composer inset by this amount (see | 1923 | keyboardLiftBehavior="whenAtEnd" |
| 1828 | // ThreadDetailScreen); this tells LegendList's scroll math about the | 1924 | 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 end | 1928 | <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 fires | 1932 | contentContainerStyle={{ |
| 1837 | // maintainScrollAtEnd before any render-cycle suppression could | 1933 | paddingTop: 12, |
| 1838 | // engage — an instant snap there teleports the feed to the anchor | 1934 | paddingHorizontal: horizontalPadding, |
| 1839 | // instead of scrolling to it. Keeping it enabled (animated) during | 1935 | }} |
| 1840 | // anchor scrolls also lets it correct a scroll that landed on a | 1936 | /> |
| 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 | <ThreadFeedPlaceholder | 1939 | <ThreadFeedPlaceholder |
| 1907 | title="No conversation yet" | 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 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 2 | import { SymbolView, type SFSymbol } from "expo-symbols"; | 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 | import { LayoutAnimation, Pressable, useColorScheme, View } from "react-native"; | 5 | import { LayoutAnimation, Pressable, useColorScheme, View } from "react-native"; |
| 5 | 6 | ||
| 6 | import { AppText as Text } from "../../components/AppText"; | 7 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; | 8 | import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; |
| 8 | import { cn } from "../../lib/cn"; | 9 | import { cn } from "../../lib/cn"; |
| 10 | import { buildThreadRoutePath } from "../../lib/routes"; | ||
| 9 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; | 11 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 12 | import { useV2ItemSupport } from "../../state/v2-item-support"; | ||
| 10 | import { ThreadActivityInspector } from "./ThreadActivityInspector"; | 13 | import { ThreadActivityInspector } from "./ThreadActivityInspector"; |
| 11 | 14 | ||
| 12 | const WORK_LOG_LAYOUT_ANIMATION = { | 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 | 77 | function ThreadActivityThreadLink(props: { |
| 75 | // list scrolls them back into view, and old rows must not replay an entrance. | 78 | readonly activity: ThreadFeedActivity; |
| 76 | const FRESH_ROW_WINDOW_MS = 3_000; | 79 | readonly environmentId: EnvironmentId; |
| 77 | function 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, |
| 83 | export 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-log | 93 | if (item.type === "thread_created") { |
| 90 | // rows are single-line (numberOfLines={1}) inside a min-height that stays | 94 | targetThreadId = item.targetThreadId; |
| 91 | // taller than the text at every supported base font size (text-xs reaches | 95 | label = "Open created thread"; |
| 92 | // 23px at the 22pt maximum, under the 32px min-h-8), so row height is | 96 | } else if (item.type === "subagent") { |
| 93 | // deterministic. The "work log" label has no such clamp — its height follows | 97 | targetThreadId = support.subagent?.childThreadId ?? item.childThreadId; |
| 94 | // the scaled text-2xs line height. Values mirror the classNames below — keep | 98 | label = "Open subagent thread"; |
| 95 | // them in sync; a mismatch only costs a one-time correction on measure. | 99 | } else if (item.type === "fork") { |
| 96 | const WORK_ROW_HEIGHT = 32; // min-h-8 | 100 | targetThreadId = |
| 97 | const WORK_ROW_GAP = 1; // gap-px | 101 | item.targetThreadId === row.sourceThreadId && item.source.type === "run" |
| 98 | const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label | 102 | ? item.source.threadId |
| 99 | const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 | 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 | 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_GAP | 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 | 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.View | 168 | <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 | <Pressable | 175 | <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> |
| @@ -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", () => { |
| @@ -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 | ||
| 153 | function itemIsProminent(item: OrchestrationV2TurnItem): boolean { | ||
| 154 | return item.type === "fork" || item.type === "thread_created" || item.type === "subagent"; | ||
| 155 | } | ||
| 156 | |||
| 152 | function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] { | 157 | function 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); |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | type ThreadId, | 9 | type ThreadId, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 11 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 12 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; | ||
| 12 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 13 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 13 | import { | 14 | import { |
| 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; |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows"; | ||
| 2 | import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { ArrowDownIcon, ArrowUpIcon, CornerUpRightIcon, ListOrderedIcon } from "lucide-react"; | 4 | import { ArrowDownIcon, ArrowUpIcon, CornerUpRightIcon, ListOrderedIcon } from "lucide-react"; |
| 4 | import { useMemo, useState } from "react"; | 5 | import { 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 | <Button | 82 | <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 | > |
| @@ -1,4 +1,11 @@ | |||
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 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 | import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; | 9 | import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; |
| 3 | import { useNavigate } from "@tanstack/react-router"; | 10 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | import { | 11 | import { |
| @@ -13,12 +20,6 @@ import { · 8 unmodified lines | |||
| 13 | import { useState } from "react"; | 20 | import { useState } from "react"; |
| 14 | 21 | ||
| 15 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | 22 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 16 | import { | ||
| 17 | deriveThreadRelationshipGraph, | ||
| 18 | immediateThreadRelationships, | ||
| 19 | resolveMergeBackTargetThreadId, | ||
| 20 | type ThreadRelationshipEdge, | ||
| 21 | } from "../../lib/threadRelationships"; | ||
| 22 | import { buildThreadRouteParams } from "../../threadRoutes"; | 23 | import { buildThreadRouteParams } from "../../threadRoutes"; |
| 23 | import { useThreadProjection, useThreadShells } from "../../state/entities"; | 24 | import { useThreadProjection, useThreadShells } from "../../state/entities"; |
| 24 | import { threadEnvironment } from "../../state/threads"; | 25 | import { 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; |
| @@ -31,7 +31,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; · 30 unmodified lines | |||
| 31 | 31 | ||
| 32 | import { ProviderModelPicker } from "../components/chat/ProviderModelPicker"; | 32 | import { ProviderModelPicker } from "../components/chat/ProviderModelPicker"; |
| 33 | import { usePrimarySettings } from "../hooks/useSettings"; | 33 | import { usePrimarySettings } from "../hooks/useSettings"; |
| 34 | import { resolveMergeBackTargetThreadId } from "../lib/threadRelationships"; | 34 | import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships"; |
| 35 | import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils"; | 35 | import { newCommandId, newMessageId, newProjectId, newThreadId } from "../lib/utils"; |
| 36 | import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection"; | 36 | import { type AppModelOption, getAppModelOptionsForInstance } from "../modelSelection"; |
| 37 | import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders"; | 37 | import { deriveOrchestrationV2DebugProviderSnapshots } from "../orchestrationV2DebugProviders"; |
| @@ -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", |
| @@ -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 | ||
| 12 | describe("thread relationships", () => { | 12 | describe("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"); |
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 | } | ||