authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-16 18:42:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 18:42:24-07:00
log4abb07eac3d47543b5804e8039633ac8ad7f67d2
tree5db3100b50a27003ef9cb9d4f532d34d59b9d2fb
parent3bee4c286e586c2eb7f8d196a65b4b5ea6a95241
signature Signed by PGP key B5690EEEBB952194

feat(mobile): port worktree setup progress and agent handoff (#12177)


14 files changed, 820 insertions(+), 81 deletions(-)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx+7
......@@ -1,3 +1,4 @@
1import type { WorktreeSetupCardProps } from "./worktree-setup-card";
12import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
23import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
34import {
......@@ -106,6 +107,8 @@ import type { ThreadContentPresentation } from "./threadContentPresentation";
106107import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
107108
108109export interface ThreadDetailScreenProps {
110 readonly worktreeSetup?: WorktreeSetupCardProps | null;
111 readonly setupWorkingStartedAt?: string | null;
109112 readonly selectedThread: OrchestrationThreadShell;
110113 readonly contentPresentation: ThreadContentPresentation;
111114 readonly screenTone: StatusTone;
......@@ -361,6 +364,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
361364 return null;
362365 }
363366 if (props.creationState?.kind === "preparing") {
367 // The setup header already reports progress in the feed.
368 if (props.worktreeSetup) return null;
364369 return {
365370 kind: "preparing",
366371 label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…",
......@@ -873,6 +878,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
873878 threadId={props.selectedThread.id}
874879 workspaceRoot={props.threadCwd}
875880 feed={props.selectedThreadFeed}
881 worktreeSetup={props.worktreeSetup}
882 setupWorkingStartedAt={props.setupWorkingStartedAt}
876883 queuedMessages={props.queuedMessages}
877884 dispatchingMessageId={props.dispatchingMessageId}
878885 onEditPendingMessage={handleEditPendingMessage}
apps/mobile/src/features/threads/ThreadFeed.tsx+27
......@@ -1,3 +1,8 @@
1import {
2 WorktreeWorkingHeader,
3 WorktreeSetupCard,
4 type WorktreeSetupCardProps,
5} from "./worktree-setup-card";
16import * as Haptics from "expo-haptics";
27import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
38import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native";
......@@ -240,6 +245,8 @@ function isFreshTimestamp(input: string): boolean {
240245}
241246
242247export interface ThreadFeedProps {
248 readonly worktreeSetup?: WorktreeSetupCardProps | null;
249 readonly setupWorkingStartedAt?: string | null;
243250 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
244251 readonly dispatchingMessageId: MessageId | null;
245252 readonly onEditPendingMessage: (message: QueuedThreadMessage) => void;
......@@ -2273,6 +2280,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22732280 // even when the final message update arrives before the turn settles.
22742281 const listAppearanceData = useMemo(
22752282 () => ({
2283 worktreeSetup: props.worktreeSetup,
2284 setupWorkingStartedAt: props.setupWorkingStartedAt,
22762285 dispatchingMessageId: props.dispatchingMessageId,
22772286 unsettledTurnId,
22782287 copiedRowId,
......@@ -2287,6 +2296,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22872296 viewportWidth,
22882297 }),
22892298 [
2299 props.worktreeSetup,
2300 props.setupWorkingStartedAt,
22902301 props.dispatchingMessageId,
22912302 unsettledTurnId,
22922303 copiedRowId,
......@@ -2454,6 +2465,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
24542465 props.latestTurn,
24552466 ],
24562467 );
2468 const setupAnchorIndex = presentedFeed.findIndex(
2469 (entry) => entry.type === "message" && entry.message.role === "user",
2470 );
24572471 // The empty↔filled key below remounts the list and resets its imperative
24582472 // content-inset override. Seed the fresh instance synchronously with the
24592473 // current overlay height before the scroll integration's next reaction;
......@@ -2765,10 +2779,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27652779 skills: props.skills,
27662780 onUseArtifactTemplate: props.onUseArtifactTemplate,
27672781 })}
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}
27682787 </ThreadMediaVisibility>
27692788 </Animated.View>
27702789 ),
27712790 [
2791 props.worktreeSetup,
2792 props.setupWorkingStartedAt,
2793 props.threadId,
2794 setupAnchorIndex,
27722795 props.dispatchingMessageId,
27732796 props.onEditPendingMessage,
27742797 copiedRowId,
......@@ -2944,6 +2967,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
29442967 ListHeaderComponent={
29452968 <>
29462969 {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
2970 {setupAnchorIndex < 0 && props.worktreeSetup ? (
2971 <WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} />
2972 ) : null}
29472973 {props.loadEarlier != null ? (
29482974 <Pressable
29492975 onPress={props.loadEarlier.onLoadEarlier}
......@@ -2964,6 +2990,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
29642990 />
29652991 </View>
29662992 {presentedFeed.length === 0 &&
2993 !props.worktreeSetup &&
29672994 props.activeWorkStartedAt === null &&
29682995 props.contentPresentation.kind === "ready" ? (
29692996 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+131-18
......@@ -1,3 +1,11 @@
1import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
2import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
3import {
4 getComposerDraftSnapshot,
5 clearComposerDraftContent,
6} from "../../state/use-composer-drafts";
7import { useWorktreeSetup } from "./use-worktree-setup";
8import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
19import { NativeStackScreenOptions } from "../../native/StackHeader";
210import {
311 StackActions,
......@@ -8,6 +16,8 @@ import {
816import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
917import * as Option from "effect/Option";
1018import {
19 CommandId,
20 MessageId,
1121 DEFAULT_SERVER_SETTINGS,
1222 EnvironmentId,
1323 ThreadId,
......@@ -809,23 +819,100 @@ function ThreadRouteContent(
809819 }),
810820 );
811821 }, [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]);
829916 const creationState = ((): ThreadDetailScreenProps["creationState"] => {
830917 if (selectedThreadCreation === null) {
831918 return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null;
......@@ -910,6 +997,32 @@ function ThreadRouteContent(
910997 activeWorkStartedAt={composer.activeWorkStartedAt}
911998 isCompacting={composer.isCompacting}
912999 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 }
9131026 activePendingApproval={requests.activePendingApproval}
9141027 respondingApprovalId={requests.respondingApprovalId}
9151028 activePendingUserInput={requests.activePendingUserInput}
......@@ -937,7 +1050,7 @@ function ThreadRouteContent(
9371050 onNativePasteText={composer.onNativePasteText}
9381051 onRemoveDraftImage={composer.onRemoveDraftImage}
9391052 serverConfig={serverConfig}
940 onStopThread={handleStopThread}
1053 onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
9411054 onSendMessage={composer.onSendMessage}
9421055 onReconnectEnvironment={handleReconnectEnvironment}
9431056 onUpdateThreadModelSelection={composer.onUpdateModelSelection}
apps/mobile/src/features/threads/use-worktree-setup.ts created+50
......@@ -0,0 +1,50 @@
1import type { EnvironmentId, ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts";
2import {
3 findRecordedWorktreeSetup,
4 resolveVisibleWorktreeSetup,
5} from "@t3tools/client-runtime/worktree-setup";
6import { useEffect, useState } from "react";
7import { useEnvironmentQuery } from "../../state/query";
8import { vcsEnvironment } from "../../state/vcs";
9
10/** Retain the last live snapshot when its subscription closes after setup. */
11export 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 @@
1import {
2 worktreeSetupStageLabel,
3 type WorktreeSetupSnapshot,
4 type WorktreeSetupStage,
5} from "@t3tools/contracts";
6import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
7import { formatDuration } from "@t3tools/shared/orchestrationTiming";
8import { useEffect, useState } from "react";
9import { ActivityIndicator, AppState, Pressable, ScrollView, View } from "react-native";
10import { useSafeAreaInsets } from "react-native-safe-area-context";
11import { AppText as Text } from "../../components/AppText";
12import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
13import { WorktreeSetupSheet } from "./worktree-setup-sheet";
14import { ShimmeringWorkContent } from "./thread-work-log";
15
16export interface WorktreeSetupCardProps {
17 snapshot: WorktreeSetupSnapshot;
18 turnStarted: boolean;
19 turnStartedAt: string | null;
20 working: boolean;
21 onCancel: () => void;
22 onWorkLocally: (() => void) | null;
23}
24
25function 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
30const 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. */
40export 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
148export 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
159function 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
185function 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
200function 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
271function 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
362const OUTPUT_TAIL_SLOTS = [0, 1, 2, 3] as const;
363
364/** Fixed four-line output window, shown only in Details. */
365function 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 @@
1import { Host, ModalBottomSheet, RNHostView } from "@expo/ui/jetpack-compose";
2import { Pressable, View, useWindowDimensions } from "react-native";
3import { withUniwind } from "uniwind";
4import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
5import { AppText as Text } from "../../components/AppText";
6import type { WorktreeSetupSheetProps } from "./worktree-setup-sheet";
7
8const NativeBottomSheet = withUniwind(ModalBottomSheet, {
9 containerColor: { fromClassName: "containerColorClassName", styleProperty: "accentColor" },
10});
11
12export 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 @@
1import { useState, type ReactElement } from "react";
2import { Modal, View } from "react-native";
3import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
4import { withUniwind } from "uniwind";
5import { ContextSheetSize } from "../../components/ContextSheetSize";
6
7const NativeScreen = withUniwind(Screen);
8const NativeHeader = withUniwind(ScreenStackHeaderConfig, {
9 backgroundColor: { fromClassName: "backgroundColorClassName", styleProperty: "backgroundColor" },
10 color: { fromClassName: "tintColorClassName", styleProperty: "accentColor" },
11 titleColor: { fromClassName: "titleColorClassName", styleProperty: "accentColor" },
12});
13
14export interface WorktreeSetupSheetProps {
15 children: ReactElement;
16 height: number;
17 onClose: () => void;
18}
19
20export 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", () => {
943943 }
944944 });
945945
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
946966 it.each(["setup-script.requested", "setup-script.started"])(
947967 "keeps error-toned %s notices visible",
948968 (kind) => {
apps/mobile/src/lib/threadActivity.ts+6-2
......@@ -425,8 +425,12 @@ function deriveWorkLogEntries(
425425 const ordered = Arr.sort(activities, activityOrder);
426426 const entries: DerivedWorkLogEntry[] = [];
427427 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;
430434 if (activity.kind === "tool.started") continue;
431435 // Like web: an agent's task.started row anchors its batch. It has a fixed
432436 // 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 {
1818 type ThreadId,
1919 type ThreadLinkedPullRequest,
2020 type TurnId,
21 WORKTREE_SETUP_ACTIVITY_KIND,
22 WorktreeSetupSnapshot,
2321} from "@t3tools/contracts";
2422import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
2523import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
......@@ -42,7 +40,6 @@ import {
4240 type TurnDiffSummary,
4341} from "../types";
4442import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
45import * as Option from "effect/Option";
4643import * as Schema from "effect/Schema";
4744import { appAtomRegistry } from "../rpc/atomRegistry";
4845import { environmentThreadDetails } from "../state/threads";
......@@ -258,58 +255,10 @@ export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): b
258255 return false;
259256}
260257
261const 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 */
269export 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 */
295export 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}
258export {
259 findRecordedWorktreeSetup,
260 resolveVisibleWorktreeSetup,
261} from "@t3tools/client-runtime/worktree-setup";
313262
314263export function resolveDraftHeroState(input: {
315264 isLocalDraftThread: boolean;
apps/web/src/components/chat/MessagesTimeline.logic.ts+2-5
......@@ -1,3 +1,5 @@
1import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
2export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
13import * as Equal from "effect/Equal";
24import { shallow } from "zustand/vanilla/shallow";
35import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
......@@ -1465,11 +1467,6 @@ export function deriveMessagesTimelineRows(input: {
14651467
14661468export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
14671469
1468/** True once the bootstrap handed off to the agent (async setup script may still run). */
1469export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean {
1470 return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done");
1471}
1472
14731470type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0];
14741471
14751472export interface MessagesTimelineRowsProjection {
packages/client-runtime/package.json+4
......@@ -3,6 +3,10 @@
33 "private": true,
44 "type": "module",
55 "exports": {
6 "./worktree-setup": {
7 "types": "./src/worktreeSetup.ts",
8 "default": "./src/worktreeSetup.ts"
9 },
610 "./load-balancing": {
711 "types": "./src/load-balancing.ts",
812 "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";
1313
1414/**
1515 * 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
1717 * worklog entry, so it is hidden from the activity feed even when it failed.
1818 */
1919export function isWorktreeSetupActivity(kind: string): boolean {
packages/client-runtime/src/worktreeSetup.ts created+64
......@@ -0,0 +1,64 @@
1import {
2 WORKTREE_SETUP_ACTIVITY_KIND,
3 WorktreeSetupSnapshot,
4 type ThreadId,
5} from "@t3tools/contracts";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8
9const 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 */
17export 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 */
43export 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
62export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean {
63 return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done");
64}