| author | |
| committer | |
| log | 4abb07eac3d47543b5804e8039633ac8ad7f67d2 |
| tree | 5db3100b50a27003ef9cb9d4f532d34d59b9d2fb |
| parent | 3bee4c286e586c2eb7f8d196a65b4b5ea6a95241 |
| signature | Signed by PGP key B5690EEEBB952194 |
14 files changed, 820 insertions(+), 81 deletions(-)
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+7| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { WorktreeSetupCardProps } from "./worktree-setup-card"; | |
| 1 | 2 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; |
| 2 | 3 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 3 | 4 | import { |
| ... | ... | @@ -106,6 +107,8 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 106 | 107 | import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; |
| 107 | 108 | |
| 108 | 109 | export interface ThreadDetailScreenProps { |
| 110 | readonly worktreeSetup?: WorktreeSetupCardProps | null; | |
| 111 | readonly setupWorkingStartedAt?: string | null; | |
| 109 | 112 | readonly selectedThread: OrchestrationThreadShell; |
| 110 | 113 | readonly contentPresentation: ThreadContentPresentation; |
| 111 | 114 | readonly screenTone: StatusTone; |
| ... | ... | @@ -361,6 +364,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 361 | 364 | return null; |
| 362 | 365 | } |
| 363 | 366 | if (props.creationState?.kind === "preparing") { |
| 367 | // The setup header already reports progress in the feed. | |
| 368 | if (props.worktreeSetup) return null; | |
| 364 | 369 | return { |
| 365 | 370 | kind: "preparing", |
| 366 | 371 | label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", |
| ... | ... | @@ -873,6 +878,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 873 | 878 | threadId={props.selectedThread.id} |
| 874 | 879 | workspaceRoot={props.threadCwd} |
| 875 | 880 | feed={props.selectedThreadFeed} |
| 881 | worktreeSetup={props.worktreeSetup} | |
| 882 | setupWorkingStartedAt={props.setupWorkingStartedAt} | |
| 876 | 883 | queuedMessages={props.queuedMessages} |
| 877 | 884 | dispatchingMessageId={props.dispatchingMessageId} |
| 878 | 885 | onEditPendingMessage={handleEditPendingMessage} |
apps/mobile/src/features/threads/ThreadFeed.tsx+27| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | |
| 2 | WorktreeWorkingHeader, | |
| 3 | WorktreeSetupCard, | |
| 4 | type WorktreeSetupCardProps, | |
| 5 | } from "./worktree-setup-card"; | |
| 1 | 6 | import * as Haptics from "expo-haptics"; |
| 2 | 7 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | 8 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; |
| ... | ... | @@ -240,6 +245,8 @@ function isFreshTimestamp(input: string): boolean { |
| 240 | 245 | } |
| 241 | 246 | |
| 242 | 247 | export interface ThreadFeedProps { |
| 248 | readonly worktreeSetup?: WorktreeSetupCardProps | null; | |
| 249 | readonly setupWorkingStartedAt?: string | null; | |
| 243 | 250 | readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; |
| 244 | 251 | readonly dispatchingMessageId: MessageId | null; |
| 245 | 252 | readonly onEditPendingMessage: (message: QueuedThreadMessage) => void; |
| ... | ... | @@ -2273,6 +2280,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2273 | 2280 | // even when the final message update arrives before the turn settles. |
| 2274 | 2281 | const listAppearanceData = useMemo( |
| 2275 | 2282 | () => ({ |
| 2283 | worktreeSetup: props.worktreeSetup, | |
| 2284 | setupWorkingStartedAt: props.setupWorkingStartedAt, | |
| 2276 | 2285 | dispatchingMessageId: props.dispatchingMessageId, |
| 2277 | 2286 | unsettledTurnId, |
| 2278 | 2287 | copiedRowId, |
| ... | ... | @@ -2287,6 +2296,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2287 | 2296 | viewportWidth, |
| 2288 | 2297 | }), |
| 2289 | 2298 | [ |
| 2299 | props.worktreeSetup, | |
| 2300 | props.setupWorkingStartedAt, | |
| 2290 | 2301 | props.dispatchingMessageId, |
| 2291 | 2302 | unsettledTurnId, |
| 2292 | 2303 | copiedRowId, |
| ... | ... | @@ -2454,6 +2465,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2454 | 2465 | props.latestTurn, |
| 2455 | 2466 | ], |
| 2456 | 2467 | ); |
| 2468 | const setupAnchorIndex = presentedFeed.findIndex( | |
| 2469 | (entry) => entry.type === "message" && entry.message.role === "user", | |
| 2470 | ); | |
| 2457 | 2471 | // The empty↔filled key below remounts the list and resets its imperative |
| 2458 | 2472 | // content-inset override. Seed the fresh instance synchronously with the |
| 2459 | 2473 | // current overlay height before the scroll integration's next reaction; |
| ... | ... | @@ -2765,10 +2779,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2765 | 2779 | skills: props.skills, |
| 2766 | 2780 | onUseArtifactTemplate: props.onUseArtifactTemplate, |
| 2767 | 2781 | })} |
| 2782 | {props.worktreeSetup && info.index === setupAnchorIndex ? ( | |
| 2783 | <WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} /> | |
| 2784 | ) : props.setupWorkingStartedAt && info.index === setupAnchorIndex ? ( | |
| 2785 | <WorktreeWorkingHeader startedAt={props.setupWorkingStartedAt} /> | |
| 2786 | ) : null} | |
| 2768 | 2787 | </ThreadMediaVisibility> |
| 2769 | 2788 | </Animated.View> |
| 2770 | 2789 | ), |
| 2771 | 2790 | [ |
| 2791 | props.worktreeSetup, | |
| 2792 | props.setupWorkingStartedAt, | |
| 2793 | props.threadId, | |
| 2794 | setupAnchorIndex, | |
| 2772 | 2795 | props.dispatchingMessageId, |
| 2773 | 2796 | props.onEditPendingMessage, |
| 2774 | 2797 | copiedRowId, |
| ... | ... | @@ -2944,6 +2967,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2944 | 2967 | ListHeaderComponent={ |
| 2945 | 2968 | <> |
| 2946 | 2969 | {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />} |
| 2970 | {setupAnchorIndex < 0 && props.worktreeSetup ? ( | |
| 2971 | <WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} /> | |
| 2972 | ) : null} | |
| 2947 | 2973 | {props.loadEarlier != null ? ( |
| 2948 | 2974 | <Pressable |
| 2949 | 2975 | onPress={props.loadEarlier.onLoadEarlier} |
| ... | ... | @@ -2964,6 +2990,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2964 | 2990 | /> |
| 2965 | 2991 | </View> |
| 2966 | 2992 | {presentedFeed.length === 0 && |
| 2993 | !props.worktreeSetup && | |
| 2967 | 2994 | props.activeWorkStartedAt === null && |
| 2968 | 2995 | props.contentPresentation.kind === "ready" ? ( |
| 2969 | 2996 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+131-18| ... | ... | @@ -1,3 +1,11 @@ |
| 1 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | |
| 2 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; | |
| 3 | import { | |
| 4 | getComposerDraftSnapshot, | |
| 5 | clearComposerDraftContent, | |
| 6 | } from "../../state/use-composer-drafts"; | |
| 7 | import { useWorktreeSetup } from "./use-worktree-setup"; | |
| 8 | import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; | |
| 1 | 9 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | 10 | import { |
| 3 | 11 | StackActions, |
| ... | ... | @@ -8,6 +16,8 @@ import { |
| 8 | 16 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 9 | 17 | import * as Option from "effect/Option"; |
| 10 | 18 | import { |
| 19 | CommandId, | |
| 20 | MessageId, | |
| 11 | 21 | DEFAULT_SERVER_SETTINGS, |
| 12 | 22 | EnvironmentId, |
| 13 | 23 | ThreadId, |
| ... | ... | @@ -809,23 +819,100 @@ function ThreadRouteContent( |
| 809 | 819 | }), |
| 810 | 820 | ); |
| 811 | 821 | }, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]); |
| 812 | // A worktree bootstrap records a running setup on the thread before its | |
| 813 | // turn, so a thread opened from another device (or after a restart) shows | |
| 814 | // the same preparing state the sending client does. A starting session is | |
| 815 | // not enough on its own: an ordinary first turn projects one too. | |
| 816 | const awaitingBootstrapTurn = useMemo( | |
| 817 | () => | |
| 818 | selectedThreadDetail !== null && | |
| 819 | selectedThreadDetail.latestTurn === null && | |
| 820 | selectedThreadDetail.activities.some( | |
| 821 | (activity) => | |
| 822 | activity.kind === "worktree-setup" && | |
| 823 | typeof activity.payload === "object" && | |
| 824 | activity.payload !== null && | |
| 825 | (activity.payload as { phase?: unknown }).phase === "running", | |
| 826 | ), | |
| 827 | [selectedThreadDetail], | |
| 828 | ); | |
| 822 | const worktreeSetup = useWorktreeSetup({ | |
| 823 | environmentId: selectedThread?.environmentId ?? null, | |
| 824 | threadId: selectedThread?.id ?? null, | |
| 825 | activities: selectedThreadDetail?.activities ?? [], | |
| 826 | preparing: | |
| 827 | selectedThreadCreation?.message.creation?.workspaceMode === "worktree" && | |
| 828 | selectedThreadCreation.outcome == null, | |
| 829 | turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null, | |
| 830 | followUpSent: | |
| 831 | composer.selectedThreadFeed.filter( | |
| 832 | (entry) => entry.type === "message" && entry.message.role === "user", | |
| 833 | ).length + | |
| 834 | composer.selectedThreadQueuedMessages.length > | |
| 835 | 1, | |
| 836 | }); | |
| 837 | const awaitingBootstrapTurn = | |
| 838 | worktreeSetup?.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup); | |
| 839 | const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup); | |
| 840 | const handleCancelWorktreeSetup = useCallback(() => { | |
| 841 | if (!selectedThread) return; | |
| 842 | void cancelWorktreeSetup({ | |
| 843 | environmentId: selectedThread.environmentId, | |
| 844 | input: { threadId: selectedThread.id }, | |
| 845 | }); | |
| 846 | }, [cancelWorktreeSetup, selectedThread]); | |
| 847 | const [localResendMessageId, setLocalResendMessageId] = useState<string | null>(null); | |
| 848 | const handleWorkLocally = useCallback(async () => { | |
| 849 | if (!selectedThread || !selectedThreadCreation) return; | |
| 850 | const result = await cancelWorktreeSetup({ | |
| 851 | environmentId: selectedThread.environmentId, | |
| 852 | input: { threadId: selectedThread.id }, | |
| 853 | }); | |
| 854 | if (result._tag === "Success" && result.value.cancelled) { | |
| 855 | setLocalResendMessageId(selectedThreadCreation.message.messageId); | |
| 856 | } | |
| 857 | }, [cancelWorktreeSetup, selectedThread, selectedThreadCreation]); | |
| 858 | // Wait for the outbox to restore the cancelled send before queuing its replacement. | |
| 859 | useEffect(() => { | |
| 860 | const pending = selectedThreadCreation; | |
| 861 | if ( | |
| 862 | !localResendMessageId || | |
| 863 | pending?.message.messageId !== localResendMessageId || | |
| 864 | pending.outcome?.kind !== "failed" | |
| 865 | ) | |
| 866 | return; | |
| 867 | setLocalResendMessageId(null); | |
| 868 | const original = pending.message; | |
| 869 | if (!original.creation) return; | |
| 870 | const metadata = makeTurnCommandMetadata(); | |
| 871 | const replacement = { | |
| 872 | ...original, | |
| 873 | commandId: CommandId.make(metadata.commandId), | |
| 874 | messageId: MessageId.make(metadata.messageId), | |
| 875 | threadId: ThreadId.make(metadata.threadId), | |
| 876 | createdAt: metadata.createdAt, | |
| 877 | creation: { | |
| 878 | ...original.creation, | |
| 879 | workspaceMode: "local" as const, | |
| 880 | branch: null, | |
| 881 | worktreePath: null, | |
| 882 | }, | |
| 883 | }; | |
| 884 | void enqueueThreadOutboxMessage(replacement) | |
| 885 | .then(() => { | |
| 886 | const draftKey = restoredNewTaskDraftKey(original.messageId); | |
| 887 | const restored = getComposerDraftSnapshot(draftKey); | |
| 888 | // Leave any edits made during cancellation in their recovery draft. | |
| 889 | if ( | |
| 890 | restored.text === original.text && | |
| 891 | JSON.stringify(restored.context) === JSON.stringify(original.context) && | |
| 892 | restored.attachments.length === original.attachments.length && | |
| 893 | restored.attachments.every( | |
| 894 | (attachment, index) => attachment.id === original.attachments[index]?.id, | |
| 895 | ) | |
| 896 | ) { | |
| 897 | clearComposerDraftContent(draftKey, { deferAttachmentCleanup: true }); | |
| 898 | } | |
| 899 | clearPendingThreadCreationOutcome( | |
| 900 | scopedThreadKey(original.environmentId, original.threadId), | |
| 901 | ); | |
| 902 | navigation.dispatch( | |
| 903 | StackActions.replace("Thread", { | |
| 904 | environmentId: String(replacement.environmentId), | |
| 905 | threadId: String(replacement.threadId), | |
| 906 | }), | |
| 907 | ); | |
| 908 | }) | |
| 909 | .catch((error) => | |
| 910 | Alert.alert( | |
| 911 | "Could not work locally", | |
| 912 | error instanceof Error ? error.message : String(error), | |
| 913 | ), | |
| 914 | ); | |
| 915 | }, [localResendMessageId, navigation, selectedThreadCreation]); | |
| 829 | 916 | const creationState = ((): ThreadDetailScreenProps["creationState"] => { |
| 830 | 917 | if (selectedThreadCreation === null) { |
| 831 | 918 | return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null; |
| ... | ... | @@ -910,6 +997,32 @@ function ThreadRouteContent( |
| 910 | 997 | activeWorkStartedAt={composer.activeWorkStartedAt} |
| 911 | 998 | isCompacting={composer.isCompacting} |
| 912 | 999 | creationState={creationState} |
| 1000 | setupWorkingStartedAt={ | |
| 1001 | composer.activeWorkStartedAt !== null && | |
| 1002 | selectedThreadDetail?.activities.some( | |
| 1003 | (activity) => activity.kind === "worktree-setup", | |
| 1004 | ) && | |
| 1005 | composer.selectedThreadFeed.filter( | |
| 1006 | (entry) => entry.type === "message" && entry.message.role === "user", | |
| 1007 | ).length <= 1 | |
| 1008 | ? composer.activeWorkStartedAt | |
| 1009 | : null | |
| 1010 | } | |
| 1011 | worktreeSetup={ | |
| 1012 | worktreeSetup | |
| 1013 | ? { | |
| 1014 | snapshot: worktreeSetup, | |
| 1015 | turnStartedAt: selectedThreadDetail?.latestTurn?.startedAt ?? null, | |
| 1016 | working: composer.activeWorkStartedAt !== null, | |
| 1017 | turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null, | |
| 1018 | onCancel: handleCancelWorktreeSetup, | |
| 1019 | onWorkLocally: | |
| 1020 | selectedThreadCreation?.outcome == null && selectedThreadCreation | |
| 1021 | ? handleWorkLocally | |
| 1022 | : null, | |
| 1023 | } | |
| 1024 | : null | |
| 1025 | } | |
| 913 | 1026 | activePendingApproval={requests.activePendingApproval} |
| 914 | 1027 | respondingApprovalId={requests.respondingApprovalId} |
| 915 | 1028 | activePendingUserInput={requests.activePendingUserInput} |
| ... | ... | @@ -937,7 +1050,7 @@ function ThreadRouteContent( |
| 937 | 1050 | onNativePasteText={composer.onNativePasteText} |
| 938 | 1051 | onRemoveDraftImage={composer.onRemoveDraftImage} |
| 939 | 1052 | serverConfig={serverConfig} |
| 940 | onStopThread={handleStopThread} | |
| 1053 | onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} | |
| 941 | 1054 | onSendMessage={composer.onSendMessage} |
| 942 | 1055 | onReconnectEnvironment={handleReconnectEnvironment} |
| 943 | 1056 | onUpdateThreadModelSelection={composer.onUpdateModelSelection} |
apps/mobile/src/features/threads/use-worktree-setup.ts created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | import type { EnvironmentId, ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | findRecordedWorktreeSetup, | |
| 4 | resolveVisibleWorktreeSetup, | |
| 5 | } from "@t3tools/client-runtime/worktree-setup"; | |
| 6 | import { useEffect, useState } from "react"; | |
| 7 | import { useEnvironmentQuery } from "../../state/query"; | |
| 8 | import { vcsEnvironment } from "../../state/vcs"; | |
| 9 | ||
| 10 | /** Retain the last live snapshot when its subscription closes after setup. */ | |
| 11 | export function useWorktreeSetup(input: { | |
| 12 | environmentId: EnvironmentId | null; | |
| 13 | threadId: ThreadId | null; | |
| 14 | activities: ReadonlyArray<{ kind: string; payload: unknown }>; | |
| 15 | preparing: boolean; | |
| 16 | turnStarted: boolean; | |
| 17 | followUpSent: boolean; | |
| 18 | }) { | |
| 19 | const key = JSON.stringify([input.environmentId, input.threadId]); | |
| 20 | const [held, setHeld] = useState<{ key: string; snapshot: WorktreeSetupSnapshot } | null>(null); | |
| 21 | const live = held?.key === key ? held.snapshot : null; | |
| 22 | const recorded = input.threadId | |
| 23 | ? findRecordedWorktreeSetup(input.activities, input.threadId) | |
| 24 | : null; | |
| 25 | const latest = resolveVisibleWorktreeSetup({ | |
| 26 | live, | |
| 27 | recorded, | |
| 28 | turnStarted: false, | |
| 29 | followUpSent: false, | |
| 30 | }); | |
| 31 | const query = useEnvironmentQuery( | |
| 32 | input.environmentId && | |
| 33 | input.threadId && | |
| 34 | (latest?.phase === "running" || (!latest && input.preparing)) | |
| 35 | ? vcsEnvironment.worktreeSetup({ | |
| 36 | environmentId: input.environmentId, | |
| 37 | input: { threadId: input.threadId }, | |
| 38 | }) | |
| 39 | : null, | |
| 40 | ); | |
| 41 | useEffect(() => { | |
| 42 | if (query.data?.threadId === input.threadId) setHeld({ key, snapshot: query.data }); | |
| 43 | }, [key, input.threadId, query.data]); | |
| 44 | return resolveVisibleWorktreeSetup({ | |
| 45 | live: query.data ?? live, | |
| 46 | recorded, | |
| 47 | turnStarted: input.turnStarted, | |
| 48 | followUpSent: input.followUpSent, | |
| 49 | }); | |
| 50 | } |
apps/mobile/src/features/threads/worktree-setup-card.tsx created+391| ... | ... | @@ -0,0 +1,391 @@ |
| 1 | import { | |
| 2 | worktreeSetupStageLabel, | |
| 3 | type WorktreeSetupSnapshot, | |
| 4 | type WorktreeSetupStage, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; | |
| 7 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | |
| 8 | import { useEffect, useState } from "react"; | |
| 9 | import { ActivityIndicator, AppState, Pressable, ScrollView, View } from "react-native"; | |
| 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 11 | import { AppText as Text } from "../../components/AppText"; | |
| 12 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; | |
| 13 | import { WorktreeSetupSheet } from "./worktree-setup-sheet"; | |
| 14 | import { ShimmeringWorkContent } from "./thread-work-log"; | |
| 15 | ||
| 16 | export interface WorktreeSetupCardProps { | |
| 17 | snapshot: WorktreeSetupSnapshot; | |
| 18 | turnStarted: boolean; | |
| 19 | turnStartedAt: string | null; | |
| 20 | working: boolean; | |
| 21 | onCancel: () => void; | |
| 22 | onWorkLocally: (() => void) | null; | |
| 23 | } | |
| 24 | ||
| 25 | function elapsed(start: string | null, end: string | null, now: number) { | |
| 26 | const duration = (end ? Date.parse(end) : now) - (start ? Date.parse(start) : NaN); | |
| 27 | return Number.isFinite(duration) ? formatDuration(Math.max(0, duration)) : null; | |
| 28 | } | |
| 29 | ||
| 30 | const icons: Record<WorktreeSetupStage["status"], AppSymbolName> = { | |
| 31 | pending: "circle", | |
| 32 | running: "clock", | |
| 33 | done: "checkmark", | |
| 34 | skipped: "minus", | |
| 35 | failed: "xmark", | |
| 36 | warning: "exclamationmark.triangle", | |
| 37 | }; | |
| 38 | ||
| 39 | /** Setup stages collapse into the working header once the agent's turn is live. */ | |
| 40 | export function WorktreeSetupCard(props: WorktreeSetupCardProps) { | |
| 41 | const { snapshot, turnStarted, turnStartedAt, working } = props; | |
| 42 | const handedOff = turnStarted && worktreeSetupAgentStarted(snapshot); | |
| 43 | const running = snapshot.phase === "running"; | |
| 44 | const backgroundSetup = handedOff && running; | |
| 45 | const scriptName = snapshot.setupScript?.name ?? "Setup script"; | |
| 46 | const [detailsOpen, setDetailsOpen] = useState(false); | |
| 47 | const now = useSetupClock(running || working); | |
| 48 | const failed = | |
| 49 | snapshot.phase === "failed" || snapshot.stages.some((stage) => stage.status === "failed"); | |
| 50 | const label = | |
| 51 | handedOff && working | |
| 52 | ? `Working for ${elapsed(turnStartedAt, null, now) ?? "0s"}` | |
| 53 | : running | |
| 54 | ? handedOff | |
| 55 | ? "Setup continues…" | |
| 56 | : "Setting up worktree…" | |
| 57 | : snapshot.phase === "cancelled" | |
| 58 | ? "Worktree setup cancelled" | |
| 59 | : snapshot.phase === "failed" | |
| 60 | ? "Worktree setup failed" | |
| 61 | : failed | |
| 62 | ? "Setup script failed" | |
| 63 | : "Worktree ready"; | |
| 64 | ||
| 65 | return ( | |
| 66 | <View accessibilityLabel="Worktree setup" className="py-1"> | |
| 67 | <View className="min-h-11 flex-row items-center gap-2 border-b border-border px-1"> | |
| 68 | <HeaderLabel | |
| 69 | label={label} | |
| 70 | active={(running || working) && !detailsOpen} | |
| 71 | failed={failed && !working} | |
| 72 | /> | |
| 73 | {!handedOff ? ( | |
| 74 | <Text | |
| 75 | className="text-2xs text-adaptive-neutral-600-400" | |
| 76 | style={{ fontVariant: ["tabular-nums"] }} | |
| 77 | > | |
| 78 | {elapsed(snapshot.startedAt, snapshot.endedAt, now)} | |
| 79 | </Text> | |
| 80 | ) : null} | |
| 81 | <Pressable | |
| 82 | accessibilityRole="button" | |
| 83 | accessibilityLabel={ | |
| 84 | backgroundSetup | |
| 85 | ? `${scriptName} is still running. Show setup progress.` | |
| 86 | : "Worktree setup details" | |
| 87 | } | |
| 88 | accessibilityState={{ expanded: detailsOpen }} | |
| 89 | onPress={() => setDetailsOpen(true)} | |
| 90 | className="min-h-11 max-w-[55%] justify-center px-2" | |
| 91 | > | |
| 92 | <View | |
| 93 | className={ | |
| 94 | backgroundSetup | |
| 95 | ? "min-w-0 flex-row items-center gap-1 rounded-full border border-border px-2 py-1" | |
| 96 | : "flex-row items-center gap-1" | |
| 97 | } | |
| 98 | > | |
| 99 | {backgroundSetup ? ( | |
| 100 | <ActivityIndicator | |
| 101 | size="small" | |
| 102 | colorClassName="accent-adaptive-neutral-500-400" | |
| 103 | style={{ width: 12, height: 12, transform: [{ scale: 0.65 }] }} | |
| 104 | /> | |
| 105 | ) : failed ? ( | |
| 106 | <SymbolView | |
| 107 | name="exclamationmark.circle" | |
| 108 | size={12} | |
| 109 | tintColorClassName="accent-adaptive-rose-700-300" | |
| 110 | /> | |
| 111 | ) : null} | |
| 112 | <Text numberOfLines={1} className="shrink text-2xs text-adaptive-neutral-600-400"> | |
| 113 | {backgroundSetup ? scriptName : "Details"} | |
| 114 | </Text> | |
| 115 | {!backgroundSetup ? ( | |
| 116 | <SymbolView | |
| 117 | name="chevron.right" | |
| 118 | size={10} | |
| 119 | tintColorClassName="accent-adaptive-neutral-500-400" | |
| 120 | /> | |
| 121 | ) : null} | |
| 122 | </View> | |
| 123 | </Pressable> | |
| 124 | </View> | |
| 125 | {!handedOff ? ( | |
| 126 | <View className="pt-1 pb-2"> | |
| 127 | {snapshot.stages | |
| 128 | .filter((stage) => stage.id !== "agent") | |
| 129 | .map((stage) => ( | |
| 130 | <StageRow | |
| 131 | key={stage.id} | |
| 132 | stage={stage} | |
| 133 | scriptName={snapshot.setupScript?.name ?? null} | |
| 134 | now={now} | |
| 135 | compact | |
| 136 | animate={!detailsOpen} | |
| 137 | /> | |
| 138 | ))} | |
| 139 | </View> | |
| 140 | ) : null} | |
| 141 | {detailsOpen ? ( | |
| 142 | <SetupDetailsSheet {...props} now={now} onClose={() => setDetailsOpen(false)} /> | |
| 143 | ) : null} | |
| 144 | </View> | |
| 145 | ); | |
| 146 | } | |
| 147 | ||
| 148 | export function WorktreeWorkingHeader({ startedAt }: { startedAt: string }) { | |
| 149 | const now = useSetupClock(true); | |
| 150 | return ( | |
| 151 | <View className="py-1"> | |
| 152 | <View className="min-h-11 flex-row items-center border-b border-border px-1"> | |
| 153 | <HeaderLabel label={`Working for ${elapsed(startedAt, null, now) ?? "0s"}`} active /> | |
| 154 | </View> | |
| 155 | </View> | |
| 156 | ); | |
| 157 | } | |
| 158 | ||
| 159 | function HeaderLabel({ | |
| 160 | label, | |
| 161 | active, | |
| 162 | failed = false, | |
| 163 | }: { | |
| 164 | label: string; | |
| 165 | active: boolean; | |
| 166 | failed?: boolean; | |
| 167 | }) { | |
| 168 | return active ? ( | |
| 169 | <ShimmeringWorkContent icon="clock" iconSubtleColor="#737373" label={label} showIcon={false} /> | |
| 170 | ) : ( | |
| 171 | <Text | |
| 172 | numberOfLines={1} | |
| 173 | className={ | |
| 174 | failed | |
| 175 | ? "flex-1 text-sm text-adaptive-rose-700-300" | |
| 176 | : "flex-1 text-sm text-adaptive-neutral-600-400" | |
| 177 | } | |
| 178 | style={{ fontVariant: ["tabular-nums"] }} | |
| 179 | > | |
| 180 | {label} | |
| 181 | </Text> | |
| 182 | ); | |
| 183 | } | |
| 184 | ||
| 185 | function useSetupClock(active: boolean) { | |
| 186 | const [now, setNow] = useState(Date.now); | |
| 187 | const [appState, setAppState] = useState(AppState.currentState); | |
| 188 | useEffect(() => { | |
| 189 | const subscription = AppState.addEventListener("change", setAppState); | |
| 190 | return () => subscription.remove(); | |
| 191 | }, []); | |
| 192 | useEffect(() => { | |
| 193 | if (!active || appState !== "active") return; | |
| 194 | const timer = setInterval(() => setNow(Date.now()), 1000); | |
| 195 | return () => clearInterval(timer); | |
| 196 | }, [active, appState]); | |
| 197 | return now; | |
| 198 | } | |
| 199 | ||
| 200 | function SetupDetailsSheet({ | |
| 201 | snapshot, | |
| 202 | turnStarted, | |
| 203 | onCancel, | |
| 204 | onWorkLocally, | |
| 205 | onClose, | |
| 206 | now, | |
| 207 | }: WorktreeSetupCardProps & { onClose: () => void; now: number }) { | |
| 208 | const insets = useSafeAreaInsets(); | |
| 209 | const [bodyHeight, setBodyHeight] = useState(0); | |
| 210 | const canCancel = snapshot.phase === "running" && !turnStarted; | |
| 211 | return ( | |
| 212 | <WorktreeSetupSheet height={bodyHeight} onClose={onClose}> | |
| 213 | <ScrollView | |
| 214 | bounces={false} | |
| 215 | onContentSizeChange={(_width, height) => setBodyHeight(height)} | |
| 216 | contentContainerStyle={{ | |
| 217 | paddingHorizontal: 20, | |
| 218 | paddingTop: 12, | |
| 219 | paddingBottom: Math.max(20, insets.bottom), | |
| 220 | }} | |
| 221 | > | |
| 222 | {/* Agent startup is the header handoff, not a fifth setup step. */} | |
| 223 | {snapshot.stages | |
| 224 | .filter((stage) => stage.id !== "agent") | |
| 225 | .map((stage) => ( | |
| 226 | <View key={stage.id}> | |
| 227 | <StageRow stage={stage} scriptName={snapshot.setupScript?.name ?? null} now={now} /> | |
| 228 | {stage.id === "setup-script" && | |
| 229 | (stage.status === "running" || stage.status === "failed" || stage.tail.length > 0) ? ( | |
| 230 | <OutputTail lines={stage.tail} failed={stage.status === "failed"} /> | |
| 231 | ) : null} | |
| 232 | </View> | |
| 233 | ))} | |
| 234 | {snapshot.phase === "failed" && snapshot.error ? ( | |
| 235 | <Text selectable className="ml-8 mt-2 text-xs text-adaptive-rose-700-300"> | |
| 236 | {snapshot.error} | |
| 237 | </Text> | |
| 238 | ) : null} | |
| 239 | {canCancel ? ( | |
| 240 | <View className="mt-3 flex-row items-center justify-end gap-4 border-t border-border pt-1"> | |
| 241 | <Pressable | |
| 242 | accessibilityRole="button" | |
| 243 | accessibilityLabel="Cancel worktree setup" | |
| 244 | onPress={() => { | |
| 245 | onClose(); | |
| 246 | onCancel(); | |
| 247 | }} | |
| 248 | className="min-h-11 justify-center px-2" | |
| 249 | > | |
| 250 | <Text className="text-sm text-danger-foreground">Cancel setup</Text> | |
| 251 | </Pressable> | |
| 252 | {onWorkLocally ? ( | |
| 253 | <Pressable | |
| 254 | accessibilityRole="button" | |
| 255 | onPress={() => { | |
| 256 | onClose(); | |
| 257 | onWorkLocally(); | |
| 258 | }} | |
| 259 | className="min-h-11 justify-center px-2" | |
| 260 | > | |
| 261 | <Text className="text-sm text-foreground">Work locally</Text> | |
| 262 | </Pressable> | |
| 263 | ) : null} | |
| 264 | </View> | |
| 265 | ) : null} | |
| 266 | </ScrollView> | |
| 267 | </WorktreeSetupSheet> | |
| 268 | ); | |
| 269 | } | |
| 270 | ||
| 271 | function StageRow({ | |
| 272 | stage, | |
| 273 | scriptName, | |
| 274 | now, | |
| 275 | compact = false, | |
| 276 | animate = true, | |
| 277 | }: { | |
| 278 | stage: WorktreeSetupStage; | |
| 279 | scriptName: string | null; | |
| 280 | now: number; | |
| 281 | compact?: boolean; | |
| 282 | animate?: boolean; | |
| 283 | }) { | |
| 284 | const label = | |
| 285 | stage.id === "setup-script" | |
| 286 | ? (scriptName ?? worktreeSetupStageLabel(stage.id)) | |
| 287 | : worktreeSetupStageLabel(stage.id); | |
| 288 | const detail = | |
| 289 | stage.status === "pending" | |
| 290 | ? null | |
| 291 | : stage.status === "skipped" | |
| 292 | ? (stage.detail ?? "skipped") | |
| 293 | : stage.id === "checkout" && stage.status === "running" && stage.percent !== null | |
| 294 | ? `${stage.percent}%` | |
| 295 | : stage.detail; | |
| 296 | return ( | |
| 297 | <View | |
| 298 | accessibilityLabel={`${label}, ${stage.status}`} | |
| 299 | className={compact ? "min-h-8 flex-row items-center" : "min-h-11 flex-row items-center"} | |
| 300 | style={{ columnGap: 8, opacity: stage.status === "pending" ? 0.4 : 1 }} | |
| 301 | > | |
| 302 | <View className="w-6 items-center"> | |
| 303 | {stage.status === "running" && animate ? ( | |
| 304 | <ActivityIndicator | |
| 305 | size="small" | |
| 306 | colorClassName="accent-adaptive-neutral-500-400" | |
| 307 | style={{ transform: [{ scale: 0.75 }] }} | |
| 308 | /> | |
| 309 | ) : ( | |
| 310 | <SymbolView | |
| 311 | name={icons[stage.status]} | |
| 312 | size={14} | |
| 313 | tintColorClassName={ | |
| 314 | stage.status === "failed" | |
| 315 | ? "accent-adaptive-rose-700-300" | |
| 316 | : stage.status === "warning" | |
| 317 | ? "accent-warning-foreground" | |
| 318 | : "accent-adaptive-neutral-500-400" | |
| 319 | } | |
| 320 | /> | |
| 321 | )} | |
| 322 | </View> | |
| 323 | {stage.status === "running" && animate ? ( | |
| 324 | <ShimmeringWorkContent | |
| 325 | icon="clock" | |
| 326 | iconSubtleColor="#737373" | |
| 327 | label={label} | |
| 328 | showIcon={false} | |
| 329 | /> | |
| 330 | ) : ( | |
| 331 | <Text | |
| 332 | numberOfLines={1} | |
| 333 | className={ | |
| 334 | stage.status === "failed" | |
| 335 | ? "flex-1 text-sm text-adaptive-rose-700-300" | |
| 336 | : "flex-1 text-sm text-adaptive-neutral-600-400" | |
| 337 | } | |
| 338 | > | |
| 339 | {label} | |
| 340 | </Text> | |
| 341 | )} | |
| 342 | {detail ? ( | |
| 343 | <Text | |
| 344 | numberOfLines={1} | |
| 345 | className="max-w-[25%] shrink text-2xs text-adaptive-neutral-600-400" | |
| 346 | > | |
| 347 | {detail} | |
| 348 | </Text> | |
| 349 | ) : null} | |
| 350 | {stage.status !== "pending" && stage.status !== "skipped" ? ( | |
| 351 | <Text | |
| 352 | className="text-2xs text-adaptive-neutral-600-400" | |
| 353 | style={{ fontVariant: ["tabular-nums"] }} | |
| 354 | > | |
| 355 | {elapsed(stage.startedAt, stage.endedAt, now)} | |
| 356 | </Text> | |
| 357 | ) : null} | |
| 358 | </View> | |
| 359 | ); | |
| 360 | } | |
| 361 | ||
| 362 | const OUTPUT_TAIL_SLOTS = [0, 1, 2, 3] as const; | |
| 363 | ||
| 364 | /** Fixed four-line output window, shown only in Details. */ | |
| 365 | function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: boolean }) { | |
| 366 | return ( | |
| 367 | <View | |
| 368 | accessibilityLabel="Setup script output" | |
| 369 | className={ | |
| 370 | failed | |
| 371 | ? "mb-2 ml-8 rounded-md border border-adaptive-rose-700-300/20 bg-adaptive-rose-50-950 px-3 py-2" | |
| 372 | : "mb-2 ml-8 rounded-md border border-border bg-adaptive-neutral-100-900 px-3 py-2" | |
| 373 | } | |
| 374 | > | |
| 375 | {OUTPUT_TAIL_SLOTS.map((slot) => ( | |
| 376 | <Text | |
| 377 | key={slot} | |
| 378 | selectable | |
| 379 | numberOfLines={1} | |
| 380 | className={ | |
| 381 | failed | |
| 382 | ? "text-2xs leading-5 text-adaptive-rose-700-300 ios:font-[family-name:Menlo] android:font-mono" | |
| 383 | : "text-2xs leading-5 text-adaptive-neutral-600-400 ios:font-[family-name:Menlo] android:font-mono" | |
| 384 | } | |
| 385 | > | |
| 386 | {lines[lines.length - OUTPUT_TAIL_SLOTS.length + slot] || "\u00a0"} | |
| 387 | </Text> | |
| 388 | ))} | |
| 389 | </View> | |
| 390 | ); | |
| 391 | } |
apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import { Host, ModalBottomSheet, RNHostView } from "@expo/ui/jetpack-compose"; | |
| 2 | import { Pressable, View, useWindowDimensions } from "react-native"; | |
| 3 | import { withUniwind } from "uniwind"; | |
| 4 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; | |
| 5 | import { AppText as Text } from "../../components/AppText"; | |
| 6 | import type { WorktreeSetupSheetProps } from "./worktree-setup-sheet"; | |
| 7 | ||
| 8 | const NativeBottomSheet = withUniwind(ModalBottomSheet, { | |
| 9 | containerColor: { fromClassName: "containerColorClassName", styleProperty: "accentColor" }, | |
| 10 | }); | |
| 11 | ||
| 12 | export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { | |
| 13 | const window = useWindowDimensions(); | |
| 14 | return ( | |
| 15 | <Host style={{ position: "absolute", width: 0, height: 0 }}> | |
| 16 | <NativeBottomSheet | |
| 17 | onDismissRequest={onClose} | |
| 18 | skipPartiallyExpanded | |
| 19 | containerColorClassName="accent-sheet-solid" | |
| 20 | > | |
| 21 | <RNHostView matchContents> | |
| 22 | <View | |
| 23 | style={{ | |
| 24 | width: Math.min(window.width, 640), | |
| 25 | height: Math.min((height || 360) + 64, window.height * 0.85), | |
| 26 | }} | |
| 27 | > | |
| 28 | <AndroidSheetHeader | |
| 29 | title="Worktree setup" | |
| 30 | trailing={ | |
| 31 | <Pressable | |
| 32 | accessibilityRole="button" | |
| 33 | accessibilityLabel="Close setup details" | |
| 34 | onPress={onClose} | |
| 35 | className="min-h-11 justify-center px-2" | |
| 36 | > | |
| 37 | <Text className="font-t3-medium text-sm text-foreground">Done</Text> | |
| 38 | </Pressable> | |
| 39 | } | |
| 40 | /> | |
| 41 | {children} | |
| 42 | </View> | |
| 43 | </RNHostView> | |
| 44 | </NativeBottomSheet> | |
| 45 | </Host> | |
| 46 | ); | |
| 47 | } |
apps/mobile/src/features/threads/worktree-setup-sheet.tsx created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import { useState, type ReactElement } from "react"; | |
| 2 | import { Modal, View } from "react-native"; | |
| 3 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; | |
| 4 | import { withUniwind } from "uniwind"; | |
| 5 | import { ContextSheetSize } from "../../components/ContextSheetSize"; | |
| 6 | ||
| 7 | const NativeScreen = withUniwind(Screen); | |
| 8 | const NativeHeader = withUniwind(ScreenStackHeaderConfig, { | |
| 9 | backgroundColor: { fromClassName: "backgroundColorClassName", styleProperty: "backgroundColor" }, | |
| 10 | color: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, | |
| 11 | titleColor: { fromClassName: "titleColorClassName", styleProperty: "accentColor" }, | |
| 12 | }); | |
| 13 | ||
| 14 | export interface WorktreeSetupSheetProps { | |
| 15 | children: ReactElement; | |
| 16 | height: number; | |
| 17 | onClose: () => void; | |
| 18 | } | |
| 19 | ||
| 20 | export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { | |
| 21 | const [headerHeight, setHeaderHeight] = useState(44); | |
| 22 | return ( | |
| 23 | <Modal | |
| 24 | animationType="slide" | |
| 25 | presentationStyle="pageSheet" | |
| 26 | allowSwipeDismissal | |
| 27 | onRequestClose={onClose} | |
| 28 | > | |
| 29 | <View collapsable={false} className="flex-1 bg-sheet-solid"> | |
| 30 | <ContextSheetSize height={height + headerHeight} /> | |
| 31 | {/* The nested stack supplies UIKit's navigation bar inside the sheet. */} | |
| 32 | <ScreenStack style={{ flex: 1 }}> | |
| 33 | <NativeScreen | |
| 34 | activityState={2} | |
| 35 | enabled | |
| 36 | isNativeStack | |
| 37 | screenId="worktree-setup-details" | |
| 38 | onHeaderHeightChange={(event) => setHeaderHeight(event.nativeEvent.headerHeight)} | |
| 39 | className="flex-1 bg-sheet-solid" | |
| 40 | > | |
| 41 | {children} | |
| 42 | <NativeHeader | |
| 43 | title="Worktree setup" | |
| 44 | titleColorClassName="accent-foreground" | |
| 45 | tintColorClassName="accent-foreground" | |
| 46 | backgroundColorClassName="bg-sheet-solid" | |
| 47 | hideBackButton | |
| 48 | hideShadow | |
| 49 | translucent={false} | |
| 50 | headerRightBarButtonItems={[ | |
| 51 | { | |
| 52 | type: "button", | |
| 53 | title: "Done", | |
| 54 | variant: "done", | |
| 55 | accessibilityLabel: "Close setup details", | |
| 56 | identifier: "worktree-setup-done", | |
| 57 | onPress: onClose, | |
| 58 | }, | |
| 59 | ]} | |
| 60 | /> | |
| 61 | </NativeScreen> | |
| 62 | </ScreenStack> | |
| 63 | </View> | |
| 64 | </Modal> | |
| 65 | ); | |
| 66 | } |
apps/mobile/src/lib/threadActivity.test.ts+20| ... | ... | @@ -943,6 +943,26 @@ describe("buildThreadFeed", () => { |
| 943 | 943 | } |
| 944 | 944 | }); |
| 945 | 945 | |
| 946 | it("leaves failed setup snapshots to the setup card", () => { | |
| 947 | const feed = buildThreadFeed( | |
| 948 | makeThread({ | |
| 949 | id: ThreadId.make("thread-setup-failed"), | |
| 950 | projectId: ProjectId.make("project-1"), | |
| 951 | title: "Failed setup", | |
| 952 | activities: [ | |
| 953 | makeActivity({ | |
| 954 | id: EventId.make("worktree-failed"), | |
| 955 | kind: "worktree-setup", | |
| 956 | summary: "Worktree setup failed", | |
| 957 | createdAt: "2026-08-30T00:00:00.000Z", | |
| 958 | tone: "error", | |
| 959 | }), | |
| 960 | ], | |
| 961 | }), | |
| 962 | ); | |
| 963 | expect(feed).toEqual([]); | |
| 964 | }); | |
| 965 | ||
| 946 | 966 | it.each(["setup-script.requested", "setup-script.started"])( |
| 947 | 967 | "keeps error-toned %s notices visible", |
| 948 | 968 | (kind) => { |
apps/mobile/src/lib/threadActivity.ts+6-2| ... | ... | @@ -425,8 +425,12 @@ function deriveWorkLogEntries( |
| 425 | 425 | const ordered = Arr.sort(activities, activityOrder); |
| 426 | 426 | const entries: DerivedWorkLogEntry[] = []; |
| 427 | 427 | for (const activity of foldUserInputActivities(ordered)) { |
| 428 | // Mobile has no setup card, so a failed setup surfaces as an error row. | |
| 429 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; | |
| 428 | // The setup card owns its snapshot, including failed and cancelled outcomes. | |
| 429 | if ( | |
| 430 | isWorktreeSetupActivity(activity.kind) && | |
| 431 | (activity.tone !== "error" || activity.kind === "worktree-setup") | |
| 432 | ) | |
| 433 | continue; | |
| 430 | 434 | if (activity.kind === "tool.started") continue; |
| 431 | 435 | // Like web: an agent's task.started row anchors its batch. It has a fixed |
| 432 | 436 | // id and timestamp, unlike progress ticks, whose stable per-task id is |
apps/web/src/components/ChatView.logic.ts+4-55| ... | ... | @@ -18,8 +18,6 @@ import { |
| 18 | 18 | type ThreadId, |
| 19 | 19 | type ThreadLinkedPullRequest, |
| 20 | 20 | type TurnId, |
| 21 | WORKTREE_SETUP_ACTIVITY_KIND, | |
| 22 | WorktreeSetupSnapshot, | |
| 23 | 21 | } from "@t3tools/contracts"; |
| 24 | 22 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 25 | 23 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| ... | ... | @@ -42,7 +40,6 @@ import { |
| 42 | 40 | type TurnDiffSummary, |
| 43 | 41 | } from "../types"; |
| 44 | 42 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 45 | import * as Option from "effect/Option"; | |
| 46 | 43 | import * as Schema from "effect/Schema"; |
| 47 | 44 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 48 | 45 | import { environmentThreadDetails } from "../state/threads"; |
| ... | ... | @@ -258,58 +255,10 @@ export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): b |
| 258 | 255 | return false; |
| 259 | 256 | } |
| 260 | 257 | |
| 261 | const decodeWorktreeSetupSnapshot = Schema.decodeUnknownOption(WorktreeSetupSnapshot); | |
| 262 | ||
| 263 | /** | |
| 264 | * The worktree setup the server recorded on the thread, if any: running once | |
| 265 | * the bootstrap created the thread, then the settled outcome. It is what a | |
| 266 | * reload or a second client renders, and what tells them to attach the live | |
| 267 | * stream while it still says running. | |
| 268 | */ | |
| 269 | export function findRecordedWorktreeSetup( | |
| 270 | activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, | |
| 271 | threadId: ThreadId, | |
| 272 | ): WorktreeSetupSnapshot | null { | |
| 273 | for (let index = activities.length - 1; index >= 0; index -= 1) { | |
| 274 | const activity = activities[index]!; | |
| 275 | if (activity.kind !== WORKTREE_SETUP_ACTIVITY_KIND) continue; | |
| 276 | const decoded = decodeWorktreeSetupSnapshot(activity.payload); | |
| 277 | if (Option.isSome(decoded) && decoded.value.threadId === threadId) return decoded.value; | |
| 278 | } | |
| 279 | return null; | |
| 280 | } | |
| 281 | ||
| 282 | /** | |
| 283 | * Which setup snapshot the timeline shows, if any. The live stream wins while | |
| 284 | * it has a newer sequence; the recorded activity covers everything else. A | |
| 285 | * running setup always shows. The setup belongs to the thread's first turn: | |
| 286 | * once the user has sent a follow-up it is history and nothing about it is | |
| 287 | * shown again, whatever its outcome. Within that first turn, a clean finish | |
| 288 | * leaves no trace once the turn is live (the setup is a means to the reply, | |
| 289 | * not part of the conversation), while a failed script, a failed setup, or a | |
| 290 | * cancelled one stays so the outcome, exit code, and terminal are reachable. | |
| 291 | * Before the turn is live everything stays so nothing collapses in the | |
| 292 | * handoff gap. Visibility never depends on whether a turn happens to be | |
| 293 | * running, which would make the row come and go. | |
| 294 | */ | |
| 295 | export function resolveVisibleWorktreeSetup(input: { | |
| 296 | live: WorktreeSetupSnapshot | null; | |
| 297 | recorded: WorktreeSetupSnapshot | null; | |
| 298 | turnStarted: boolean; | |
| 299 | /** The user sent a message after the one that created the worktree. */ | |
| 300 | followUpSent: boolean; | |
| 301 | }): WorktreeSetupSnapshot | null { | |
| 302 | const snapshot = | |
| 303 | input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) | |
| 304 | ? input.live | |
| 305 | : input.recorded; | |
| 306 | if (!snapshot) return null; | |
| 307 | if (snapshot.phase === "running") return snapshot; | |
| 308 | if (input.followUpSent) return null; | |
| 309 | if (snapshot.phase !== "done") return snapshot; | |
| 310 | if (!input.turnStarted) return snapshot; | |
| 311 | return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null; | |
| 312 | } | |
| 258 | export { | |
| 259 | findRecordedWorktreeSetup, | |
| 260 | resolveVisibleWorktreeSetup, | |
| 261 | } from "@t3tools/client-runtime/worktree-setup"; | |
| 313 | 262 | |
| 314 | 263 | export function resolveDraftHeroState(input: { |
| 315 | 264 | isLocalDraftThread: boolean; |
apps/web/src/components/chat/MessagesTimeline.logic.ts+2-5| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; | |
| 2 | export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; | |
| 1 | 3 | import * as Equal from "effect/Equal"; |
| 2 | 4 | import { shallow } from "zustand/vanilla/shallow"; |
| 3 | 5 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| ... | ... | @@ -1465,11 +1467,6 @@ export function deriveMessagesTimelineRows(input: { |
| 1465 | 1467 | |
| 1466 | 1468 | export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; |
| 1467 | 1469 | |
| 1468 | /** True once the bootstrap handed off to the agent (async setup script may still run). */ | |
| 1469 | export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { | |
| 1470 | return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); | |
| 1471 | } | |
| 1472 | ||
| 1473 | 1470 | type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0]; |
| 1474 | 1471 | |
| 1475 | 1472 | export interface MessagesTimelineRowsProjection { |
packages/client-runtime/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./worktree-setup": { | |
| 7 | "types": "./src/worktreeSetup.ts", | |
| 8 | "default": "./src/worktreeSetup.ts" | |
| 9 | }, | |
| 6 | 10 | "./load-balancing": { |
| 7 | 11 | "types": "./src/load-balancing.ts", |
| 8 | 12 | "default": "./src/load-balancing.ts" |
packages/client-runtime/src/work-log/presentation.ts+1-1| ... | ... | @@ -13,7 +13,7 @@ import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 13 | 13 | |
| 14 | 14 | /** |
| 15 | 15 | * Activities the worktree setup card already represents. The settled record |
| 16 | * is rendered by the card on web (and mobile's status row), never as a | |
| 16 | * is rendered by the card on web and mobile, never as a | |
| 17 | 17 | * worklog entry, so it is hidden from the activity feed even when it failed. |
| 18 | 18 | */ |
| 19 | 19 | export function isWorktreeSetupActivity(kind: string): boolean { |
packages/client-runtime/src/worktreeSetup.ts created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | import { | |
| 2 | WORKTREE_SETUP_ACTIVITY_KIND, | |
| 3 | WorktreeSetupSnapshot, | |
| 4 | type ThreadId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | ||
| 9 | const decodeWorktreeSetupSnapshot = Schema.decodeUnknownOption(WorktreeSetupSnapshot); | |
| 10 | ||
| 11 | /** | |
| 12 | * The worktree setup the server recorded on the thread, if any: running once | |
| 13 | * the bootstrap created the thread, then the settled outcome. It is what a | |
| 14 | * reload or a second client renders, and what tells them to attach the live | |
| 15 | * stream while it still says running. | |
| 16 | */ | |
| 17 | export function findRecordedWorktreeSetup( | |
| 18 | activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, | |
| 19 | threadId: ThreadId, | |
| 20 | ): WorktreeSetupSnapshot | null { | |
| 21 | for (let index = activities.length - 1; index >= 0; index -= 1) { | |
| 22 | const activity = activities[index]!; | |
| 23 | if (activity.kind !== WORKTREE_SETUP_ACTIVITY_KIND) continue; | |
| 24 | const decoded = decodeWorktreeSetupSnapshot(activity.payload); | |
| 25 | if (Option.isSome(decoded) && decoded.value.threadId === threadId) return decoded.value; | |
| 26 | } | |
| 27 | return null; | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | * Which setup snapshot the timeline shows, if any. The live stream wins while | |
| 32 | * it has a newer sequence; the recorded activity covers everything else. A | |
| 33 | * running setup always shows. The setup belongs to the thread's first turn: | |
| 34 | * once the user has sent a follow-up it is history and nothing about it is | |
| 35 | * shown again, whatever its outcome. Within that first turn, a clean finish | |
| 36 | * leaves no trace once the turn is live (the setup is a means to the reply, | |
| 37 | * not part of the conversation), while a failed script, a failed setup, or a | |
| 38 | * cancelled one stays so the outcome, exit code, and terminal are reachable. | |
| 39 | * Before the turn is live everything stays so nothing collapses in the | |
| 40 | * handoff gap. Visibility never depends on whether a turn happens to be | |
| 41 | * running, which would make the row come and go. | |
| 42 | */ | |
| 43 | export function resolveVisibleWorktreeSetup(input: { | |
| 44 | live: WorktreeSetupSnapshot | null; | |
| 45 | recorded: WorktreeSetupSnapshot | null; | |
| 46 | turnStarted: boolean; | |
| 47 | /** The user sent a message after the one that created the worktree. */ | |
| 48 | followUpSent: boolean; | |
| 49 | }): WorktreeSetupSnapshot | null { | |
| 50 | const snapshot = | |
| 51 | input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) | |
| 52 | ? input.live | |
| 53 | : input.recorded; | |
| 54 | if (!snapshot) return null; | |
| 55 | if (snapshot.phase === "running") return snapshot; | |
| 56 | if (input.followUpSent) return null; | |
| 57 | if (snapshot.phase !== "done") return snapshot; | |
| 58 | if (!input.turnStarted) return snapshot; | |
| 59 | return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null; | |
| 60 | } | |
| 61 | ||
| 62 | export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { | |
| 63 | return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); | |
| 64 | } |