diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 4f631eb2a1b4..e7b4650e2117 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,3 +1,4 @@ +import type { WorktreeSetupCardProps } from "./worktree-setup-card"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { @@ -106,6 +107,8 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps { + readonly worktreeSetup?: WorktreeSetupCardProps | null; + readonly setupWorkingStartedAt?: string | null; readonly selectedThread: OrchestrationThreadShell; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; @@ -361,6 +364,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return null; } if (props.creationState?.kind === "preparing") { + // The setup header already reports progress in the feed. + if (props.worktreeSetup) return null; return { kind: "preparing", label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", @@ -873,6 +878,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadId={props.selectedThread.id} workspaceRoot={props.threadCwd} feed={props.selectedThreadFeed} + worktreeSetup={props.worktreeSetup} + setupWorkingStartedAt={props.setupWorkingStartedAt} queuedMessages={props.queuedMessages} dispatchingMessageId={props.dispatchingMessageId} onEditPendingMessage={handleEditPendingMessage} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ce94ebe8cab8..585354f7baf2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,3 +1,8 @@ +import { + WorktreeWorkingHeader, + WorktreeSetupCard, + type WorktreeSetupCardProps, +} from "./worktree-setup-card"; import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; @@ -240,6 +245,8 @@ function isFreshTimestamp(input: string): boolean { } export interface ThreadFeedProps { + readonly worktreeSetup?: WorktreeSetupCardProps | null; + readonly setupWorkingStartedAt?: string | null; readonly queuedMessages: ReadonlyArray; readonly dispatchingMessageId: MessageId | null; readonly onEditPendingMessage: (message: QueuedThreadMessage) => void; @@ -2273,6 +2280,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // even when the final message update arrives before the turn settles. const listAppearanceData = useMemo( () => ({ + worktreeSetup: props.worktreeSetup, + setupWorkingStartedAt: props.setupWorkingStartedAt, dispatchingMessageId: props.dispatchingMessageId, unsettledTurnId, copiedRowId, @@ -2287,6 +2296,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { viewportWidth, }), [ + props.worktreeSetup, + props.setupWorkingStartedAt, props.dispatchingMessageId, unsettledTurnId, copiedRowId, @@ -2454,6 +2465,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, ], ); + const setupAnchorIndex = presentedFeed.findIndex( + (entry) => entry.type === "message" && entry.message.role === "user", + ); // The empty↔filled key below remounts the list and resets its imperative // content-inset override. Seed the fresh instance synchronously with the // current overlay height before the scroll integration's next reaction; @@ -2765,10 +2779,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { skills: props.skills, onUseArtifactTemplate: props.onUseArtifactTemplate, })} + {props.worktreeSetup && info.index === setupAnchorIndex ? ( + + ) : props.setupWorkingStartedAt && info.index === setupAnchorIndex ? ( + + ) : null} ), [ + props.worktreeSetup, + props.setupWorkingStartedAt, + props.threadId, + setupAnchorIndex, props.dispatchingMessageId, props.onEditPendingMessage, copiedRowId, @@ -2944,6 +2967,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ListHeaderComponent={ <> {usesNativeAutomaticInsets ? null : } + {setupAnchorIndex < 0 && props.worktreeSetup ? ( + + ) : null} {props.loadEarlier != null ? ( {presentedFeed.length === 0 && + !props.worktreeSetup && props.activeWorkStartedAt === null && props.contentPresentation.kind === "ready" ? ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index aee64d006f2c..9d0410c7e9c2 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,3 +1,11 @@ +import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { + getComposerDraftSnapshot, + clearComposerDraftContent, +} from "../../state/use-composer-drafts"; +import { useWorktreeSetup } from "./use-worktree-setup"; +import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, @@ -8,6 +16,8 @@ import { import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { + CommandId, + MessageId, DEFAULT_SERVER_SETTINGS, EnvironmentId, ThreadId, @@ -809,23 +819,100 @@ function ThreadRouteContent( }), ); }, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]); - // A worktree bootstrap records a running setup on the thread before its - // turn, so a thread opened from another device (or after a restart) shows - // the same preparing state the sending client does. A starting session is - // not enough on its own: an ordinary first turn projects one too. - const awaitingBootstrapTurn = useMemo( - () => - selectedThreadDetail !== null && - selectedThreadDetail.latestTurn === null && - selectedThreadDetail.activities.some( - (activity) => - activity.kind === "worktree-setup" && - typeof activity.payload === "object" && - activity.payload !== null && - (activity.payload as { phase?: unknown }).phase === "running", - ), - [selectedThreadDetail], - ); + const worktreeSetup = useWorktreeSetup({ + environmentId: selectedThread?.environmentId ?? null, + threadId: selectedThread?.id ?? null, + activities: selectedThreadDetail?.activities ?? [], + preparing: + selectedThreadCreation?.message.creation?.workspaceMode === "worktree" && + selectedThreadCreation.outcome == null, + turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null, + followUpSent: + composer.selectedThreadFeed.filter( + (entry) => entry.type === "message" && entry.message.role === "user", + ).length + + composer.selectedThreadQueuedMessages.length > + 1, + }); + const awaitingBootstrapTurn = + worktreeSetup?.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup); + const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup); + const handleCancelWorktreeSetup = useCallback(() => { + if (!selectedThread) return; + void cancelWorktreeSetup({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id }, + }); + }, [cancelWorktreeSetup, selectedThread]); + const [localResendMessageId, setLocalResendMessageId] = useState(null); + const handleWorkLocally = useCallback(async () => { + if (!selectedThread || !selectedThreadCreation) return; + const result = await cancelWorktreeSetup({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id }, + }); + if (result._tag === "Success" && result.value.cancelled) { + setLocalResendMessageId(selectedThreadCreation.message.messageId); + } + }, [cancelWorktreeSetup, selectedThread, selectedThreadCreation]); + // Wait for the outbox to restore the cancelled send before queuing its replacement. + useEffect(() => { + const pending = selectedThreadCreation; + if ( + !localResendMessageId || + pending?.message.messageId !== localResendMessageId || + pending.outcome?.kind !== "failed" + ) + return; + setLocalResendMessageId(null); + const original = pending.message; + if (!original.creation) return; + const metadata = makeTurnCommandMetadata(); + const replacement = { + ...original, + commandId: CommandId.make(metadata.commandId), + messageId: MessageId.make(metadata.messageId), + threadId: ThreadId.make(metadata.threadId), + createdAt: metadata.createdAt, + creation: { + ...original.creation, + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + }, + }; + void enqueueThreadOutboxMessage(replacement) + .then(() => { + const draftKey = restoredNewTaskDraftKey(original.messageId); + const restored = getComposerDraftSnapshot(draftKey); + // Leave any edits made during cancellation in their recovery draft. + if ( + restored.text === original.text && + JSON.stringify(restored.context) === JSON.stringify(original.context) && + restored.attachments.length === original.attachments.length && + restored.attachments.every( + (attachment, index) => attachment.id === original.attachments[index]?.id, + ) + ) { + clearComposerDraftContent(draftKey, { deferAttachmentCleanup: true }); + } + clearPendingThreadCreationOutcome( + scopedThreadKey(original.environmentId, original.threadId), + ); + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(replacement.environmentId), + threadId: String(replacement.threadId), + }), + ); + }) + .catch((error) => + Alert.alert( + "Could not work locally", + error instanceof Error ? error.message : String(error), + ), + ); + }, [localResendMessageId, navigation, selectedThreadCreation]); const creationState = ((): ThreadDetailScreenProps["creationState"] => { if (selectedThreadCreation === null) { return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null; @@ -910,6 +997,32 @@ function ThreadRouteContent( activeWorkStartedAt={composer.activeWorkStartedAt} isCompacting={composer.isCompacting} creationState={creationState} + setupWorkingStartedAt={ + composer.activeWorkStartedAt !== null && + selectedThreadDetail?.activities.some( + (activity) => activity.kind === "worktree-setup", + ) && + composer.selectedThreadFeed.filter( + (entry) => entry.type === "message" && entry.message.role === "user", + ).length <= 1 + ? composer.activeWorkStartedAt + : null + } + worktreeSetup={ + worktreeSetup + ? { + snapshot: worktreeSetup, + turnStartedAt: selectedThreadDetail?.latestTurn?.startedAt ?? null, + working: composer.activeWorkStartedAt !== null, + turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null, + onCancel: handleCancelWorktreeSetup, + onWorkLocally: + selectedThreadCreation?.outcome == null && selectedThreadCreation + ? handleWorkLocally + : null, + } + : null + } activePendingApproval={requests.activePendingApproval} respondingApprovalId={requests.respondingApprovalId} activePendingUserInput={requests.activePendingUserInput} @@ -937,7 +1050,7 @@ function ThreadRouteContent( onNativePasteText={composer.onNativePasteText} onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} - onStopThread={handleStopThread} + onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} onSendMessage={composer.onSendMessage} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} diff --git a/apps/mobile/src/features/threads/use-worktree-setup.ts b/apps/mobile/src/features/threads/use-worktree-setup.ts new file mode 100644 index 000000000000..655535767942 --- /dev/null +++ b/apps/mobile/src/features/threads/use-worktree-setup.ts @@ -0,0 +1,50 @@ +import type { EnvironmentId, ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; +import { + findRecordedWorktreeSetup, + resolveVisibleWorktreeSetup, +} from "@t3tools/client-runtime/worktree-setup"; +import { useEffect, useState } from "react"; +import { useEnvironmentQuery } from "../../state/query"; +import { vcsEnvironment } from "../../state/vcs"; + +/** Retain the last live snapshot when its subscription closes after setup. */ +export function useWorktreeSetup(input: { + environmentId: EnvironmentId | null; + threadId: ThreadId | null; + activities: ReadonlyArray<{ kind: string; payload: unknown }>; + preparing: boolean; + turnStarted: boolean; + followUpSent: boolean; +}) { + const key = JSON.stringify([input.environmentId, input.threadId]); + const [held, setHeld] = useState<{ key: string; snapshot: WorktreeSetupSnapshot } | null>(null); + const live = held?.key === key ? held.snapshot : null; + const recorded = input.threadId + ? findRecordedWorktreeSetup(input.activities, input.threadId) + : null; + const latest = resolveVisibleWorktreeSetup({ + live, + recorded, + turnStarted: false, + followUpSent: false, + }); + const query = useEnvironmentQuery( + input.environmentId && + input.threadId && + (latest?.phase === "running" || (!latest && input.preparing)) + ? vcsEnvironment.worktreeSetup({ + environmentId: input.environmentId, + input: { threadId: input.threadId }, + }) + : null, + ); + useEffect(() => { + if (query.data?.threadId === input.threadId) setHeld({ key, snapshot: query.data }); + }, [key, input.threadId, query.data]); + return resolveVisibleWorktreeSetup({ + live: query.data ?? live, + recorded, + turnStarted: input.turnStarted, + followUpSent: input.followUpSent, + }); +} diff --git a/apps/mobile/src/features/threads/worktree-setup-card.tsx b/apps/mobile/src/features/threads/worktree-setup-card.tsx new file mode 100644 index 000000000000..52154bcc8bcd --- /dev/null +++ b/apps/mobile/src/features/threads/worktree-setup-card.tsx @@ -0,0 +1,391 @@ +import { + worktreeSetupStageLabel, + type WorktreeSetupSnapshot, + type WorktreeSetupStage, +} from "@t3tools/contracts"; +import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, AppState, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AppText as Text } from "../../components/AppText"; +import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; +import { WorktreeSetupSheet } from "./worktree-setup-sheet"; +import { ShimmeringWorkContent } from "./thread-work-log"; + +export interface WorktreeSetupCardProps { + snapshot: WorktreeSetupSnapshot; + turnStarted: boolean; + turnStartedAt: string | null; + working: boolean; + onCancel: () => void; + onWorkLocally: (() => void) | null; +} + +function elapsed(start: string | null, end: string | null, now: number) { + const duration = (end ? Date.parse(end) : now) - (start ? Date.parse(start) : NaN); + return Number.isFinite(duration) ? formatDuration(Math.max(0, duration)) : null; +} + +const icons: Record = { + pending: "circle", + running: "clock", + done: "checkmark", + skipped: "minus", + failed: "xmark", + warning: "exclamationmark.triangle", +}; + +/** Setup stages collapse into the working header once the agent's turn is live. */ +export function WorktreeSetupCard(props: WorktreeSetupCardProps) { + const { snapshot, turnStarted, turnStartedAt, working } = props; + const handedOff = turnStarted && worktreeSetupAgentStarted(snapshot); + const running = snapshot.phase === "running"; + const backgroundSetup = handedOff && running; + const scriptName = snapshot.setupScript?.name ?? "Setup script"; + const [detailsOpen, setDetailsOpen] = useState(false); + const now = useSetupClock(running || working); + const failed = + snapshot.phase === "failed" || snapshot.stages.some((stage) => stage.status === "failed"); + const label = + handedOff && working + ? `Working for ${elapsed(turnStartedAt, null, now) ?? "0s"}` + : running + ? handedOff + ? "Setup continues…" + : "Setting up worktree…" + : snapshot.phase === "cancelled" + ? "Worktree setup cancelled" + : snapshot.phase === "failed" + ? "Worktree setup failed" + : failed + ? "Setup script failed" + : "Worktree ready"; + + return ( + + + + {!handedOff ? ( + + {elapsed(snapshot.startedAt, snapshot.endedAt, now)} + + ) : null} + setDetailsOpen(true)} + className="min-h-11 max-w-[55%] justify-center px-2" + > + + {backgroundSetup ? ( + + ) : failed ? ( + + ) : null} + + {backgroundSetup ? scriptName : "Details"} + + {!backgroundSetup ? ( + + ) : null} + + + + {!handedOff ? ( + + {snapshot.stages + .filter((stage) => stage.id !== "agent") + .map((stage) => ( + + ))} + + ) : null} + {detailsOpen ? ( + setDetailsOpen(false)} /> + ) : null} + + ); +} + +export function WorktreeWorkingHeader({ startedAt }: { startedAt: string }) { + const now = useSetupClock(true); + return ( + + + + + + ); +} + +function HeaderLabel({ + label, + active, + failed = false, +}: { + label: string; + active: boolean; + failed?: boolean; +}) { + return active ? ( + + ) : ( + + {label} + + ); +} + +function useSetupClock(active: boolean) { + const [now, setNow] = useState(Date.now); + const [appState, setAppState] = useState(AppState.currentState); + useEffect(() => { + const subscription = AppState.addEventListener("change", setAppState); + return () => subscription.remove(); + }, []); + useEffect(() => { + if (!active || appState !== "active") return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [active, appState]); + return now; +} + +function SetupDetailsSheet({ + snapshot, + turnStarted, + onCancel, + onWorkLocally, + onClose, + now, +}: WorktreeSetupCardProps & { onClose: () => void; now: number }) { + const insets = useSafeAreaInsets(); + const [bodyHeight, setBodyHeight] = useState(0); + const canCancel = snapshot.phase === "running" && !turnStarted; + return ( + + setBodyHeight(height)} + contentContainerStyle={{ + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: Math.max(20, insets.bottom), + }} + > + {/* Agent startup is the header handoff, not a fifth setup step. */} + {snapshot.stages + .filter((stage) => stage.id !== "agent") + .map((stage) => ( + + + {stage.id === "setup-script" && + (stage.status === "running" || stage.status === "failed" || stage.tail.length > 0) ? ( + + ) : null} + + ))} + {snapshot.phase === "failed" && snapshot.error ? ( + + {snapshot.error} + + ) : null} + {canCancel ? ( + + { + onClose(); + onCancel(); + }} + className="min-h-11 justify-center px-2" + > + Cancel setup + + {onWorkLocally ? ( + { + onClose(); + onWorkLocally(); + }} + className="min-h-11 justify-center px-2" + > + Work locally + + ) : null} + + ) : null} + + + ); +} + +function StageRow({ + stage, + scriptName, + now, + compact = false, + animate = true, +}: { + stage: WorktreeSetupStage; + scriptName: string | null; + now: number; + compact?: boolean; + animate?: boolean; +}) { + const label = + stage.id === "setup-script" + ? (scriptName ?? worktreeSetupStageLabel(stage.id)) + : worktreeSetupStageLabel(stage.id); + const detail = + stage.status === "pending" + ? null + : stage.status === "skipped" + ? (stage.detail ?? "skipped") + : stage.id === "checkout" && stage.status === "running" && stage.percent !== null + ? `${stage.percent}%` + : stage.detail; + return ( + + + {stage.status === "running" && animate ? ( + + ) : ( + + )} + + {stage.status === "running" && animate ? ( + + ) : ( + + {label} + + )} + {detail ? ( + + {detail} + + ) : null} + {stage.status !== "pending" && stage.status !== "skipped" ? ( + + {elapsed(stage.startedAt, stage.endedAt, now)} + + ) : null} + + ); +} + +const OUTPUT_TAIL_SLOTS = [0, 1, 2, 3] as const; + +/** Fixed four-line output window, shown only in Details. */ +function OutputTail({ lines, failed }: { lines: ReadonlyArray; failed: boolean }) { + return ( + + {OUTPUT_TAIL_SLOTS.map((slot) => ( + + {lines[lines.length - OUTPUT_TAIL_SLOTS.length + slot] || "\u00a0"} + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx b/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx new file mode 100644 index 000000000000..267414a6ad10 --- /dev/null +++ b/apps/mobile/src/features/threads/worktree-setup-sheet.android.tsx @@ -0,0 +1,47 @@ +import { Host, ModalBottomSheet, RNHostView } from "@expo/ui/jetpack-compose"; +import { Pressable, View, useWindowDimensions } from "react-native"; +import { withUniwind } from "uniwind"; +import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text } from "../../components/AppText"; +import type { WorktreeSetupSheetProps } from "./worktree-setup-sheet"; + +const NativeBottomSheet = withUniwind(ModalBottomSheet, { + containerColor: { fromClassName: "containerColorClassName", styleProperty: "accentColor" }, +}); + +export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { + const window = useWindowDimensions(); + return ( + + + + + + Done + + } + /> + {children} + + + + + ); +} diff --git a/apps/mobile/src/features/threads/worktree-setup-sheet.tsx b/apps/mobile/src/features/threads/worktree-setup-sheet.tsx new file mode 100644 index 000000000000..99bd9e066e3e --- /dev/null +++ b/apps/mobile/src/features/threads/worktree-setup-sheet.tsx @@ -0,0 +1,66 @@ +import { useState, type ReactElement } from "react"; +import { Modal, View } from "react-native"; +import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; +import { withUniwind } from "uniwind"; +import { ContextSheetSize } from "../../components/ContextSheetSize"; + +const NativeScreen = withUniwind(Screen); +const NativeHeader = withUniwind(ScreenStackHeaderConfig, { + backgroundColor: { fromClassName: "backgroundColorClassName", styleProperty: "backgroundColor" }, + color: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, + titleColor: { fromClassName: "titleColorClassName", styleProperty: "accentColor" }, +}); + +export interface WorktreeSetupSheetProps { + children: ReactElement; + height: number; + onClose: () => void; +} + +export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) { + const [headerHeight, setHeaderHeight] = useState(44); + return ( + + + + {/* The nested stack supplies UIKit's navigation bar inside the sheet. */} + + setHeaderHeight(event.nativeEvent.headerHeight)} + className="flex-1 bg-sheet-solid" + > + {children} + + + + + + ); +} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 6533458da512..acda68455420 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -943,6 +943,26 @@ describe("buildThreadFeed", () => { } }); + it("leaves failed setup snapshots to the setup card", () => { + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-setup-failed"), + projectId: ProjectId.make("project-1"), + title: "Failed setup", + activities: [ + makeActivity({ + id: EventId.make("worktree-failed"), + kind: "worktree-setup", + summary: "Worktree setup failed", + createdAt: "2026-08-30T00:00:00.000Z", + tone: "error", + }), + ], + }), + ); + expect(feed).toEqual([]); + }); + it.each(["setup-script.requested", "setup-script.started"])( "keeps error-toned %s notices visible", (kind) => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 68ef02f48571..a32843ce3f73 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -425,8 +425,12 @@ function deriveWorkLogEntries( const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of foldUserInputActivities(ordered)) { - // Mobile has no setup card, so a failed setup surfaces as an error row. - if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; + // The setup card owns its snapshot, including failed and cancelled outcomes. + if ( + isWorktreeSetupActivity(activity.kind) && + (activity.tone !== "error" || activity.kind === "worktree-setup") + ) + continue; if (activity.kind === "tool.started") continue; // Like web: an agent's task.started row anchors its batch. It has a fixed // id and timestamp, unlike progress ticks, whose stable per-task id is diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index eae48ac5fa29..bc68a9fcfa01 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -18,8 +18,6 @@ import { type ThreadId, type ThreadLinkedPullRequest, type TurnId, - WORKTREE_SETUP_ACTIVITY_KIND, - WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; @@ -42,7 +40,6 @@ import { type TurnDiffSummary, } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; @@ -258,58 +255,10 @@ export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): b return false; } -const decodeWorktreeSetupSnapshot = Schema.decodeUnknownOption(WorktreeSetupSnapshot); - -/** - * The worktree setup the server recorded on the thread, if any: running once - * the bootstrap created the thread, then the settled outcome. It is what a - * reload or a second client renders, and what tells them to attach the live - * stream while it still says running. - */ -export function findRecordedWorktreeSetup( - activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, - threadId: ThreadId, -): WorktreeSetupSnapshot | null { - for (let index = activities.length - 1; index >= 0; index -= 1) { - const activity = activities[index]!; - if (activity.kind !== WORKTREE_SETUP_ACTIVITY_KIND) continue; - const decoded = decodeWorktreeSetupSnapshot(activity.payload); - if (Option.isSome(decoded) && decoded.value.threadId === threadId) return decoded.value; - } - return null; -} - -/** - * Which setup snapshot the timeline shows, if any. The live stream wins while - * it has a newer sequence; the recorded activity covers everything else. A - * running setup always shows. The setup belongs to the thread's first turn: - * once the user has sent a follow-up it is history and nothing about it is - * shown again, whatever its outcome. Within that first turn, a clean finish - * leaves no trace once the turn is live (the setup is a means to the reply, - * not part of the conversation), while a failed script, a failed setup, or a - * cancelled one stays so the outcome, exit code, and terminal are reachable. - * Before the turn is live everything stays so nothing collapses in the - * handoff gap. Visibility never depends on whether a turn happens to be - * running, which would make the row come and go. - */ -export function resolveVisibleWorktreeSetup(input: { - live: WorktreeSetupSnapshot | null; - recorded: WorktreeSetupSnapshot | null; - turnStarted: boolean; - /** The user sent a message after the one that created the worktree. */ - followUpSent: boolean; -}): WorktreeSetupSnapshot | null { - const snapshot = - input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) - ? input.live - : input.recorded; - if (!snapshot) return null; - if (snapshot.phase === "running") return snapshot; - if (input.followUpSent) return null; - if (snapshot.phase !== "done") return snapshot; - if (!input.turnStarted) return snapshot; - return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null; -} +export { + findRecordedWorktreeSetup, + resolveVisibleWorktreeSetup, +} from "@t3tools/client-runtime/worktree-setup"; export function resolveDraftHeroState(input: { isLocalDraftThread: boolean; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 699e50fb1d8c..8a82168c20dc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,3 +1,5 @@ +import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; @@ -1465,11 +1467,6 @@ export function deriveMessagesTimelineRows(input: { export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; -/** True once the bootstrap handed off to the agent (async setup script may still run). */ -export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { - return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); -} - type MessagesTimelineRowsInput = Parameters[0]; export interface MessagesTimelineRowsProjection { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index b4a3d5da118d..65e13c251edc 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./worktree-setup": { + "types": "./src/worktreeSetup.ts", + "default": "./src/worktreeSetup.ts" + }, "./load-balancing": { "types": "./src/load-balancing.ts", "default": "./src/load-balancing.ts" diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 48d30fc488dc..9f9f049d29fe 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -13,7 +13,7 @@ import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; /** * Activities the worktree setup card already represents. The settled record - * is rendered by the card on web (and mobile's status row), never as a + * is rendered by the card on web and mobile, never as a * worklog entry, so it is hidden from the activity feed even when it failed. */ export function isWorktreeSetupActivity(kind: string): boolean { diff --git a/packages/client-runtime/src/worktreeSetup.ts b/packages/client-runtime/src/worktreeSetup.ts new file mode 100644 index 000000000000..7b74f94950cd --- /dev/null +++ b/packages/client-runtime/src/worktreeSetup.ts @@ -0,0 +1,64 @@ +import { + WORKTREE_SETUP_ACTIVITY_KIND, + WorktreeSetupSnapshot, + type ThreadId, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const decodeWorktreeSetupSnapshot = Schema.decodeUnknownOption(WorktreeSetupSnapshot); + +/** + * The worktree setup the server recorded on the thread, if any: running once + * the bootstrap created the thread, then the settled outcome. It is what a + * reload or a second client renders, and what tells them to attach the live + * stream while it still says running. + */ +export function findRecordedWorktreeSetup( + activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, + threadId: ThreadId, +): WorktreeSetupSnapshot | null { + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]!; + if (activity.kind !== WORKTREE_SETUP_ACTIVITY_KIND) continue; + const decoded = decodeWorktreeSetupSnapshot(activity.payload); + if (Option.isSome(decoded) && decoded.value.threadId === threadId) return decoded.value; + } + return null; +} + +/** + * Which setup snapshot the timeline shows, if any. The live stream wins while + * it has a newer sequence; the recorded activity covers everything else. A + * running setup always shows. The setup belongs to the thread's first turn: + * once the user has sent a follow-up it is history and nothing about it is + * shown again, whatever its outcome. Within that first turn, a clean finish + * leaves no trace once the turn is live (the setup is a means to the reply, + * not part of the conversation), while a failed script, a failed setup, or a + * cancelled one stays so the outcome, exit code, and terminal are reachable. + * Before the turn is live everything stays so nothing collapses in the + * handoff gap. Visibility never depends on whether a turn happens to be + * running, which would make the row come and go. + */ +export function resolveVisibleWorktreeSetup(input: { + live: WorktreeSetupSnapshot | null; + recorded: WorktreeSetupSnapshot | null; + turnStarted: boolean; + /** The user sent a message after the one that created the worktree. */ + followUpSent: boolean; +}): WorktreeSetupSnapshot | null { + const snapshot = + input.live && (!input.recorded || input.live.sequence >= input.recorded.sequence) + ? input.live + : input.recorded; + if (!snapshot) return null; + if (snapshot.phase === "running") return snapshot; + if (input.followUpSent) return null; + if (snapshot.phase !== "done") return snapshot; + if (!input.turnStarted) return snapshot; + return snapshot.stages.some((stage) => stage.status === "failed") ? snapshot : null; +} + +export function worktreeSetupAgentStarted(snapshot: WorktreeSetupSnapshot): boolean { + return snapshot.stages.some((stage) => stage.id === "agent" && stage.status === "done"); +}