| 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,3 +1,4 @@ |
| 1 | import type { WorktreeSetupCardProps } from "./worktree-setup-card"; | ||
| 1 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; | 2 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; |
| 2 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | 3 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 3 | import { | 4 | import { |
| ... | @@ -106,6 +107,8 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; | ... | @@ -106,6 +107,8 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 106 | import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; | 107 | import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; |
| 107 | 108 | ||
| 108 | export interface ThreadDetailScreenProps { | 109 | export interface ThreadDetailScreenProps { |
| 110 | readonly worktreeSetup?: WorktreeSetupCardProps | null; | ||
| 111 | readonly setupWorkingStartedAt?: string | null; | ||
| 109 | readonly selectedThread: OrchestrationThreadShell; | 112 | readonly selectedThread: OrchestrationThreadShell; |
| 110 | readonly contentPresentation: ThreadContentPresentation; | 113 | readonly contentPresentation: ThreadContentPresentation; |
| 111 | readonly screenTone: StatusTone; | 114 | readonly screenTone: StatusTone; |
| ... | @@ -361,6 +364,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -361,6 +364,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 361 | return null; | 364 | return null; |
| 362 | } | 365 | } |
| 363 | if (props.creationState?.kind === "preparing") { | 366 | if (props.creationState?.kind === "preparing") { |
| 367 | // The setup header already reports progress in the feed. | ||
| 368 | if (props.worktreeSetup) return null; | ||
| 364 | return { | 369 | return { |
| 365 | kind: "preparing", | 370 | kind: "preparing", |
| 366 | label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", | 371 | label: props.creationState.preparingWorktree ? "Setting up worktree…" : "Starting…", |
| ... | @@ -873,6 +878,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -873,6 +878,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 873 | threadId={props.selectedThread.id} | 878 | threadId={props.selectedThread.id} |
| 874 | workspaceRoot={props.threadCwd} | 879 | workspaceRoot={props.threadCwd} |
| 875 | feed={props.selectedThreadFeed} | 880 | feed={props.selectedThreadFeed} |
| 881 | worktreeSetup={props.worktreeSetup} | ||
| 882 | setupWorkingStartedAt={props.setupWorkingStartedAt} | ||
| 876 | queuedMessages={props.queuedMessages} | 883 | queuedMessages={props.queuedMessages} |
| 877 | dispatchingMessageId={props.dispatchingMessageId} | 884 | dispatchingMessageId={props.dispatchingMessageId} |
| 878 | onEditPendingMessage={handleEditPendingMessage} | 885 | onEditPendingMessage={handleEditPendingMessage} |
apps/mobile/src/features/threads/ThreadFeed.tsx+27| ... | @@ -1,3 +1,8 @@ | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | ||
| 2 | WorktreeWorkingHeader, | ||
| 3 | WorktreeSetupCard, | ||
| 4 | type WorktreeSetupCardProps, | ||
| 5 | } from "./worktree-setup-card"; | ||
| 1 | import * as Haptics from "expo-haptics"; | 6 | import * as Haptics from "expo-haptics"; |
| 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | 7 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; | 8 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; |
| ... | @@ -240,6 +245,8 @@ function isFreshTimestamp(input: string): boolean { | ... | @@ -240,6 +245,8 @@ function isFreshTimestamp(input: string): boolean { |
| 240 | } | 245 | } |
| 241 | 246 | ||
| 242 | export interface ThreadFeedProps { | 247 | export interface ThreadFeedProps { |
| 248 | readonly worktreeSetup?: WorktreeSetupCardProps | null; | ||
| 249 | readonly setupWorkingStartedAt?: string | null; | ||
| 243 | readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; | 250 | readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>; |
| 244 | readonly dispatchingMessageId: MessageId | null; | 251 | readonly dispatchingMessageId: MessageId | null; |
| 245 | readonly onEditPendingMessage: (message: QueuedThreadMessage) => void; | 252 | readonly onEditPendingMessage: (message: QueuedThreadMessage) => void; |
| ... | @@ -2273,6 +2280,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2273,6 +2280,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2273 | // even when the final message update arrives before the turn settles. | 2280 | // even when the final message update arrives before the turn settles. |
| 2274 | const listAppearanceData = useMemo( | 2281 | const listAppearanceData = useMemo( |
| 2275 | () => ({ | 2282 | () => ({ |
| 2283 | worktreeSetup: props.worktreeSetup, | ||
| 2284 | setupWorkingStartedAt: props.setupWorkingStartedAt, | ||
| 2276 | dispatchingMessageId: props.dispatchingMessageId, | 2285 | dispatchingMessageId: props.dispatchingMessageId, |
| 2277 | unsettledTurnId, | 2286 | unsettledTurnId, |
| 2278 | copiedRowId, | 2287 | copiedRowId, |
| ... | @@ -2287,6 +2296,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2287,6 +2296,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2287 | viewportWidth, | 2296 | viewportWidth, |
| 2288 | }), | 2297 | }), |
| 2289 | [ | 2298 | [ |
| 2299 | props.worktreeSetup, | ||
| 2300 | props.setupWorkingStartedAt, | ||
| 2290 | props.dispatchingMessageId, | 2301 | props.dispatchingMessageId, |
| 2291 | unsettledTurnId, | 2302 | unsettledTurnId, |
| 2292 | copiedRowId, | 2303 | copiedRowId, |
| ... | @@ -2454,6 +2465,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2454,6 +2465,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2454 | props.latestTurn, | 2465 | props.latestTurn, |
| 2455 | ], | 2466 | ], |
| 2456 | ); | 2467 | ); |
| 2468 | const setupAnchorIndex = presentedFeed.findIndex( | ||
| 2469 | (entry) => entry.type === "message" && entry.message.role === "user", | ||
| 2470 | ); | ||
| 2457 | // The empty↔filled key below remounts the list and resets its imperative | 2471 | // The empty↔filled key below remounts the list and resets its imperative |
| 2458 | // content-inset override. Seed the fresh instance synchronously with the | 2472 | // content-inset override. Seed the fresh instance synchronously with the |
| 2459 | // current overlay height before the scroll integration's next reaction; | 2473 | // current overlay height before the scroll integration's next reaction; |
| ... | @@ -2765,10 +2779,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2765,10 +2779,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2765 | skills: props.skills, | 2779 | skills: props.skills, |
| 2766 | onUseArtifactTemplate: props.onUseArtifactTemplate, | 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 | </ThreadMediaVisibility> | 2787 | </ThreadMediaVisibility> |
| 2769 | </Animated.View> | 2788 | </Animated.View> |
| 2770 | ), | 2789 | ), |
| 2771 | [ | 2790 | [ |
| 2791 | props.worktreeSetup, | ||
| 2792 | props.setupWorkingStartedAt, | ||
| 2793 | props.threadId, | ||
| 2794 | setupAnchorIndex, | ||
| 2772 | props.dispatchingMessageId, | 2795 | props.dispatchingMessageId, |
| 2773 | props.onEditPendingMessage, | 2796 | props.onEditPendingMessage, |
| 2774 | copiedRowId, | 2797 | copiedRowId, |
| ... | @@ -2944,6 +2967,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2944,6 +2967,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2944 | ListHeaderComponent={ | 2967 | ListHeaderComponent={ |
| 2945 | <> | 2968 | <> |
| 2946 | {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />} | 2969 | {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />} |
| 2970 | {setupAnchorIndex < 0 && props.worktreeSetup ? ( | ||
| 2971 | <WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} /> | ||
| 2972 | ) : null} | ||
| 2947 | {props.loadEarlier != null ? ( | 2973 | {props.loadEarlier != null ? ( |
| 2948 | <Pressable | 2974 | <Pressable |
| 2949 | onPress={props.loadEarlier.onLoadEarlier} | 2975 | onPress={props.loadEarlier.onLoadEarlier} |
| ... | @@ -2964,6 +2990,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2964,6 +2990,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2964 | /> | 2990 | /> |
| 2965 | </View> | 2991 | </View> |
| 2966 | {presentedFeed.length === 0 && | 2992 | {presentedFeed.length === 0 && |
| 2993 | !props.worktreeSetup && | ||
| 2967 | props.activeWorkStartedAt === null && | 2994 | props.activeWorkStartedAt === null && |
| 2968 | props.contentPresentation.kind === "ready" ? ( | 2995 | props.contentPresentation.kind === "ready" ? ( |
| 2969 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> | 2996 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+131-18| ... | @@ -1,3 +1,11 @@ | ... | @@ -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 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 9 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | import { | 10 | import { |
| 3 | StackActions, | 11 | StackActions, |
| ... | @@ -8,6 +16,8 @@ import { | ... | @@ -8,6 +16,8 @@ import { |
| 8 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 16 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 9 | import * as Option from "effect/Option"; | 17 | import * as Option from "effect/Option"; |
| 10 | import { | 18 | import { |
| 19 | CommandId, | ||
| 20 | MessageId, | ||
| 11 | DEFAULT_SERVER_SETTINGS, | 21 | DEFAULT_SERVER_SETTINGS, |
| 12 | EnvironmentId, | 22 | EnvironmentId, |
| 13 | ThreadId, | 23 | ThreadId, |
| ... | @@ -809,23 +819,100 @@ function ThreadRouteContent( | ... | @@ -809,23 +819,100 @@ function ThreadRouteContent( |
| 809 | }), | 819 | }), |
| 810 | ); | 820 | ); |
| 811 | }, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]); | 821 | }, [navigation, routeThreadIdentity, selectedThreadCreation, selectedThreadProject]); |
| 812 | // A worktree bootstrap records a running setup on the thread before its | 822 | const worktreeSetup = useWorktreeSetup({ |
| 813 | // turn, so a thread opened from another device (or after a restart) shows | 823 | environmentId: selectedThread?.environmentId ?? null, |
| 814 | // the same preparing state the sending client does. A starting session is | 824 | threadId: selectedThread?.id ?? null, |
| 815 | // not enough on its own: an ordinary first turn projects one too. | 825 | activities: selectedThreadDetail?.activities ?? [], |
| 816 | const awaitingBootstrapTurn = useMemo( | 826 | preparing: |
| 817 | () => | 827 | selectedThreadCreation?.message.creation?.workspaceMode === "worktree" && |
| 818 | selectedThreadDetail !== null && | 828 | selectedThreadCreation.outcome == null, |
| 819 | selectedThreadDetail.latestTurn === null && | 829 | turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null, |
| 820 | selectedThreadDetail.activities.some( | 830 | followUpSent: |
| 821 | (activity) => | 831 | composer.selectedThreadFeed.filter( |
| 822 | activity.kind === "worktree-setup" && | 832 | (entry) => entry.type === "message" && entry.message.role === "user", |
| 823 | typeof activity.payload === "object" && | 833 | ).length + |
| 824 | activity.payload !== null && | 834 | composer.selectedThreadQueuedMessages.length > |
| 825 | (activity.payload as { phase?: unknown }).phase === "running", | 835 | 1, |
| 826 | ), | 836 | }); |
| 827 | [selectedThreadDetail], | 837 | const awaitingBootstrapTurn = |
| 828 | ); | 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 | const creationState = ((): ThreadDetailScreenProps["creationState"] => { | 916 | const creationState = ((): ThreadDetailScreenProps["creationState"] => { |
| 830 | if (selectedThreadCreation === null) { | 917 | if (selectedThreadCreation === null) { |
| 831 | return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null; | 918 | return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null; |
| ... | @@ -910,6 +997,32 @@ function ThreadRouteContent( | ... | @@ -910,6 +997,32 @@ function ThreadRouteContent( |
| 910 | activeWorkStartedAt={composer.activeWorkStartedAt} | 997 | activeWorkStartedAt={composer.activeWorkStartedAt} |
| 911 | isCompacting={composer.isCompacting} | 998 | isCompacting={composer.isCompacting} |
| 912 | creationState={creationState} | 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 | activePendingApproval={requests.activePendingApproval} | 1026 | activePendingApproval={requests.activePendingApproval} |
| 914 | respondingApprovalId={requests.respondingApprovalId} | 1027 | respondingApprovalId={requests.respondingApprovalId} |
| 915 | activePendingUserInput={requests.activePendingUserInput} | 1028 | activePendingUserInput={requests.activePendingUserInput} |
| ... | @@ -937,7 +1050,7 @@ function ThreadRouteContent( | ... | @@ -937,7 +1050,7 @@ function ThreadRouteContent( |
| 937 | onNativePasteText={composer.onNativePasteText} | 1050 | onNativePasteText={composer.onNativePasteText} |
| 938 | onRemoveDraftImage={composer.onRemoveDraftImage} | 1051 | onRemoveDraftImage={composer.onRemoveDraftImage} |
| 939 | serverConfig={serverConfig} | 1052 | serverConfig={serverConfig} |
| 940 | onStopThread={handleStopThread} | 1053 | onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} |
| 941 | onSendMessage={composer.onSendMessage} | 1054 | onSendMessage={composer.onSendMessage} |
| 942 | onReconnectEnvironment={handleReconnectEnvironment} | 1055 | onReconnectEnvironment={handleReconnectEnvironment} |
| 943 | onUpdateThreadModelSelection={composer.onUpdateModelSelection} | 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,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 | it.each(["setup-script.requested", "setup-script.started"])( | 966 | it.each(["setup-script.requested", "setup-script.started"])( |
| 947 | "keeps error-toned %s notices visible", | 967 | "keeps error-toned %s notices visible", |
| 948 | (kind) => { | 968 | (kind) => { |
apps/mobile/src/lib/threadActivity.ts+6-2| ... | @@ -425,8 +425,12 @@ function deriveWorkLogEntries( | ... | @@ -425,8 +425,12 @@ function deriveWorkLogEntries( |
| 425 | const ordered = Arr.sort(activities, activityOrder); | 425 | const ordered = Arr.sort(activities, activityOrder); |
| 426 | const entries: DerivedWorkLogEntry[] = []; | 426 | const entries: DerivedWorkLogEntry[] = []; |
| 427 | for (const activity of foldUserInputActivities(ordered)) { | 427 | for (const activity of foldUserInputActivities(ordered)) { |
| 428 | // Mobile has no setup card, so a failed setup surfaces as an error row. | 428 | // The setup card owns its snapshot, including failed and cancelled outcomes. |
| 429 | if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; | 429 | if ( |
| 430 | isWorktreeSetupActivity(activity.kind) && | ||
| 431 | (activity.tone !== "error" || activity.kind === "worktree-setup") | ||
| 432 | ) | ||
| 433 | continue; | ||
| 430 | if (activity.kind === "tool.started") continue; | 434 | if (activity.kind === "tool.started") continue; |
| 431 | // Like web: an agent's task.started row anchors its batch. It has a fixed | 435 | // Like web: an agent's task.started row anchors its batch. It has a fixed |
| 432 | // id and timestamp, unlike progress ticks, whose stable per-task id is | 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,8 +18,6 @@ import { |
| 18 | type ThreadId, | 18 | type ThreadId, |
| 19 | type ThreadLinkedPullRequest, | 19 | type ThreadLinkedPullRequest, |
| 20 | type TurnId, | 20 | type TurnId, |
| 21 | WORKTREE_SETUP_ACTIVITY_KIND, | ||
| 22 | WorktreeSetupSnapshot, | ||
| 23 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 24 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 22 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 25 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 23 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| ... | @@ -42,7 +40,6 @@ import { | ... | @@ -42,7 +40,6 @@ import { |
| 42 | type TurnDiffSummary, | 40 | type TurnDiffSummary, |
| 43 | } from "../types"; | 41 | } from "../types"; |
| 44 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 42 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 45 | import * as Option from "effect/Option"; | ||
| 46 | import * as Schema from "effect/Schema"; | 43 | import * as Schema from "effect/Schema"; |
| 47 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 44 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 48 | import { environmentThreadDetails } from "../state/threads"; | 45 | import { environmentThreadDetails } from "../state/threads"; |
| ... | @@ -258,58 +255,10 @@ export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): b | ... | @@ -258,58 +255,10 @@ export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): b |
| 258 | return false; | 255 | return false; |
| 259 | } | 256 | } |
| 260 | 257 | ||
| 261 | const decodeWorktreeSetupSnapshot = Schema.decodeUnknownOption(WorktreeSetupSnapshot); | 258 | export { |
| 262 | 259 | findRecordedWorktreeSetup, | |
| 263 | /** | 260 | resolveVisibleWorktreeSetup, |
| 264 | * The worktree setup the server recorded on the thread, if any: running once | 261 | } from "@t3tools/client-runtime/worktree-setup"; |
| 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 | } | ||
| 313 | 262 | ||
| 314 | export function resolveDraftHeroState(input: { | 263 | export function resolveDraftHeroState(input: { |
| 315 | isLocalDraftThread: boolean; | 264 | isLocalDraftThread: boolean; |
apps/web/src/components/chat/MessagesTimeline.logic.ts+2-5| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; | ||
| 2 | export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; | ||
| 1 | import * as Equal from "effect/Equal"; | 3 | import * as Equal from "effect/Equal"; |
| 2 | import { shallow } from "zustand/vanilla/shallow"; | 4 | import { shallow } from "zustand/vanilla/shallow"; |
| 3 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; | 5 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| ... | @@ -1465,11 +1467,6 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -1465,11 +1467,6 @@ export function deriveMessagesTimelineRows(input: { |
| 1465 | 1467 | ||
| 1466 | export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row"; | 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 | type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0]; | 1470 | type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0]; |
| 1474 | 1471 | ||
| 1475 | export interface MessagesTimelineRowsProjection { | 1472 | export interface MessagesTimelineRowsProjection { |
packages/client-runtime/package.json+4| ... | @@ -3,6 +3,10 @@ | ... | @@ -3,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./worktree-setup": { | ||
| 7 | "types": "./src/worktreeSetup.ts", | ||
| 8 | "default": "./src/worktreeSetup.ts" | ||
| 9 | }, | ||
| 6 | "./load-balancing": { | 10 | "./load-balancing": { |
| 7 | "types": "./src/load-balancing.ts", | 11 | "types": "./src/load-balancing.ts", |
| 8 | "default": "./src/load-balancing.ts" | 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,7 +13,7 @@ import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 13 | 13 | ||
| 14 | /** | 14 | /** |
| 15 | * Activities the worktree setup card already represents. The settled record | 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 | * worklog entry, so it is hidden from the activity feed even when it failed. | 17 | * worklog entry, so it is hidden from the activity feed even when it failed. |
| 18 | */ | 18 | */ |
| 19 | export function isWorktreeSetupActivity(kind: string): boolean { | 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 | } | ||