Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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…",
Expand Down Expand Up @@ -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}
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -240,6 +245,8 @@ function isFreshTimestamp(input: string): boolean {
}

export interface ThreadFeedProps {
readonly worktreeSetup?: WorktreeSetupCardProps | null;
readonly setupWorkingStartedAt?: string | null;
readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
readonly dispatchingMessageId: MessageId | null;
readonly onEditPendingMessage: (message: QueuedThreadMessage) => void;
Expand Down Expand Up @@ -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,
Expand All @@ -2287,6 +2296,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
viewportWidth,
}),
[
props.worktreeSetup,
props.setupWorkingStartedAt,
props.dispatchingMessageId,
unsettledTurnId,
copiedRowId,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2765,10 +2779,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
skills: props.skills,
onUseArtifactTemplate: props.onUseArtifactTemplate,
})}
{props.worktreeSetup && info.index === setupAnchorIndex ? (
<WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} />
) : props.setupWorkingStartedAt && info.index === setupAnchorIndex ? (
<WorktreeWorkingHeader startedAt={props.setupWorkingStartedAt} />
) : null}
</ThreadMediaVisibility>
</Animated.View>
),
[
props.worktreeSetup,
props.setupWorkingStartedAt,
props.threadId,
setupAnchorIndex,
props.dispatchingMessageId,
props.onEditPendingMessage,
copiedRowId,
Expand Down Expand Up @@ -2944,6 +2967,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
ListHeaderComponent={
<>
{usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
{setupAnchorIndex < 0 && props.worktreeSetup ? (
<WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} />
) : null}
{props.loadEarlier != null ? (
<Pressable
onPress={props.loadEarlier.onLoadEarlier}
Expand All @@ -2964,6 +2990,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
/>
</View>
{presentedFeed.length === 0 &&
!props.worktreeSetup &&
props.activeWorkStartedAt === null &&
props.contentPresentation.kind === "ready" ? (
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
Expand Down
149 changes: 131 additions & 18 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<string | null>(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"
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
)
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;
Expand Down Expand Up @@ -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
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
? handleWorkLocally
: null,
}
: null
}
activePendingApproval={requests.activePendingApproval}
respondingApprovalId={requests.respondingApprovalId}
activePendingUserInput={requests.activePendingUserInput}
Expand Down Expand Up @@ -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}
Expand Down
50 changes: 50 additions & 0 deletions apps/mobile/src/features/threads/use-worktree-setup.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading
Loading