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
4 changes: 3 additions & 1 deletion .agents/upstream-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The maintainer authorized compatible catch-up and routine adaptations that prese

## Active cycle

[Upstream integration cycle #598](https://github.com/pylon-code/pylon/issues/598) owns the next five thread-reading and preview fixes within frozen head `6d1d549441be84f19696ab59ed7e2fbf305280d4`. Cycles #585, #591 and #594 are complete. Merge is authorized after review and green verification; the review cursor remains unchanged.
[Upstream integration cycle #605](https://github.com/pylon-code/pylon/issues/605) owns worktree setup progress, cancellation and mobile handoff within frozen head `6d1d549441be84f19696ab59ed7e2fbf305280d4`. The [existing-port audit #604](https://github.com/pylon-code/pylon/issues/604) reconciles 22 sources already present and records the remaining range after cycle #598. Merge is authorized after review and green verification; the review cursor remains unchanged.

## Latest cycle

Expand Down Expand Up @@ -191,3 +191,5 @@ The review cursor stays unchanged; unrelated sources in this range remain unclas

| Thread reading positions and visible answers / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `f17165a76b1a782831eb6a05a4383b27283df15d` (#12144), `3486451525ecaf3a8379aa6d08c885c7cc5335e3` (#11363), `03e135577cbf861c2948981601c070dab038ea11` (#11461) | Adopted with Pylon delegation rows, terminal notices, action-required prominence and static working/delegating treatment retained. Session-scoped reading anchors and existing turn/work disclosures restore across navigation; citations and manual gestures take priority. Missing rows fall back to offsets; measured correction is bounded. No reload persistence or native mobile position cache; mobile answer visibility is included. | Cycle #598; implementation PR records review, focused tests, scoped checks and isolated browser evidence. |
| Preview identity and focus recovery / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `c542b781c6c4c766e66dcb764b2142d95600e890` (#7110), `cfeaca41ae27bdf2c203158d378c87c7308fea2a` (#11444) | Adopted: preview sessions retain native Electron/Pylon User-Agent and window-return refocus leaves iframe/webview focus alone. Permission policy and session ownership unchanged. Sites can identify the native embedded browser; no claim of bypassing third-party authentication policies. | Cycle #598; implementation PR records effective-UA regression, focus tests, types/lint and interaction evidence. |

| Worktree setup progress and cancellation / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | Ten full source SHAs enumerated in [PR #608](https://github.com/pylon-code/pylon/pull/608), from #6208 and #11372 through #12177 | Adopted with Pylon admission, source epochs, compaction, inline context and mobile outbox retained. Live stages, required/background scripts, prompt persistence, cancellation and draft recovery span web and mobile. Capability-gated subscriptions support older hosts; setup history is bounded, and server restart ends live tracking. iOS uses the existing React Native modal layout so setup actions receive touches. | Cycle #605; PR #608 records combined review, regression coverage, scoped checks and uploaded web/iOS evidence. |
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 EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window";
import {
Expand Down Expand Up @@ -132,6 +133,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 @@ -498,6 +501,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 @@ -992,6 +997,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,4 +1,9 @@
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
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 @@ -234,6 +239,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 @@ -2262,6 +2269,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 @@ -2275,6 +2284,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
viewportWidth,
}),
[
props.worktreeSetup,
props.setupWorkingStartedAt,
props.dispatchingMessageId,
unsettledTurnId,
copiedRowId,
Expand Down Expand Up @@ -2441,6 +2452,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 @@ -2725,10 +2739,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
rollbackCommandPending: props.rollbackCommandPending,
onRevertMessage: props.onRevertMessage,
})}
{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 @@ -2913,6 +2936,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 @@ -2933,6 +2959,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
134 changes: 132 additions & 2 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 @@ -1013,9 +1023,103 @@ function ThreadRouteContent(
}),
);
}, [navigation, selectedThreadCreation, selectedThreadProject]);
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"
)
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 null;
return awaitingBootstrapTurn ? { kind: "preparing", preparingWorktree: true } : null;
}
const hold =
selectedThreadCreation.outcome === null
Expand Down Expand Up @@ -1119,6 +1223,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}
Expand Down Expand Up @@ -1158,7 +1288,7 @@ function ThreadRouteContent(
onNativePasteImages={composer.onNativePasteImages}
onRemoveDraftImage={composer.onRemoveDraftImage}
serverConfig={serverConfig}
onStopThread={handleStopThread}
onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread}
onReloadSessionResources={handleReloadSessionResources}
onRefineSessionHarness={handleRefineSessionHarness}
onAskSessionSideQuestion={handleAskSessionSideQuestion}
Expand Down
57 changes: 57 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,57 @@
import type { EnvironmentId, ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts";
import {
findRecordedWorktreeSetup,
resolveVisibleWorktreeSetup,
} from "@t3tools/client-runtime/worktree-setup";
import { useAtomValue } from "@effect/atom-react";
import { environmentServerConfigsAtom } from "../../state/server";
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 configs = useAtomValue(environmentServerConfigsAtom);
const supported =
input.environmentId !== null &&
configs.get(input.environmentId)?.environment.capabilities.worktreeSetupTracking === true;
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(
supported &&
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