diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index f52cb44cb064b..14e94fbd61740 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1305,6 +1305,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { void) | null; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; + /** Null while the thread's creation is queued: reading detail then caches a not-found as deleted. */ + readonly detailRef: ScopedThreadRef | null; readonly threadTitle: string; readonly workspaceRoot?: string | null; readonly feed: ReadonlyArray; @@ -2124,12 +2127,8 @@ export const ThreadFeed = memo(function ThreadFeed({ ...props }: ThreadFeedProps & { readonly ref?: Ref }) { const { listRef, onEndFollowEnabledChange, onHeaderMaterialVisibilityChange } = props; - const workflowOpening = useWorkflowOpening(props.environmentId, props.threadId); - const guestThreadRef = useMemo( - () => scopeThreadRef(props.environmentId, props.threadId), - [props.environmentId, props.threadId], - ); - const guestShare = useThreadGuestShare(guestThreadRef); + const workflowOpening = useWorkflowOpening(props.detailRef); + const guestShare = useThreadGuestShare(props.detailRef); const deviceType = useDeviceType(); const duoColumns = useNativeWorkspaceColumnsSupported() && isDuoDevice(deviceType); const openPullRequest = useOpenPullRequest(); diff --git a/apps/mobile/src/features/threads/thread-workflow-opening.tsx b/apps/mobile/src/features/threads/thread-workflow-opening.tsx index 5165a4ac016cf..b16a82b7190d9 100644 --- a/apps/mobile/src/features/threads/thread-workflow-opening.tsx +++ b/apps/mobile/src/features/threads/thread-workflow-opening.tsx @@ -6,7 +6,9 @@ import { workflowOpeningParentThreadId, type RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; +import { Atom } from "effect/reactivity"; import { useState } from "react"; import { Pressable, View } from "react-native"; @@ -20,8 +22,10 @@ import { useOpenLinkedThread } from "./useOpenLinkedThread"; const FOLDED_SCRIPT_LINES = 10; -export function useWorkflowOpening(environmentId: EnvironmentId, threadId: ThreadId) { - const threadAtom = environmentThreadDetails.threadAtom(scopeThreadRef(environmentId, threadId)); +const NO_THREAD_ATOM = Atom.make(null); + +export function useWorkflowOpening(ref: ScopedThreadRef | null) { + const threadAtom = ref === null ? NO_THREAD_ATOM : environmentThreadDetails.threadAtom(ref); // Selected as a primitive so an ordinary thread's updates never change the result. const parentId = useAtomValue(threadAtom, (thread) => thread === null @@ -35,7 +39,9 @@ export function useWorkflowOpening(environmentId: EnvironmentId, threadId: Threa parentId === null ? null : (thread?.projection ?? null), ); const parentSubagents = useAtomValue( - environmentThreadDetails.threadAtom(scopeThreadRef(environmentId, parentId ?? threadId)), + ref === null || parentId === null + ? NO_THREAD_ATOM + : environmentThreadDetails.threadAtom(scopeThreadRef(ref.environmentId, parentId)), (parent) => (parentId === null ? null : (parent?.projection.subagents ?? null)), ); return projection === null diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index a2b8859e76891..16a38733480c7 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -445,6 +445,7 @@ describe("ThreadInlineFilePreview playback", () => { createElement(ThreadFeed, { environmentId, threadId, + detailRef: null, threadTitle: "preview", workspaceRoot: "/repo", feed, @@ -1384,6 +1385,7 @@ describe("ThreadFeed previews", () => { createElement(ThreadFeed, { environmentId, threadId, + detailRef: null, threadTitle: "preview", workspaceRoot: "/repo", feed: feedFor(threadId), diff --git a/apps/mobile/src/lib/scopedEntities.ts b/apps/mobile/src/lib/scopedEntities.ts index 6464b3561919f..2f8dde6e571ba 100644 --- a/apps/mobile/src/lib/scopedEntities.ts +++ b/apps/mobile/src/lib/scopedEntities.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ProjectId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string { return `${environmentId}:${projectId}`; @@ -7,10 +7,3 @@ export function scopedProjectKey(environmentId: EnvironmentId, projectId: Projec export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId): string { return `${environmentId}:${threadId}`; } - -export function scopedRequestKey( - environmentId: EnvironmentId, - requestId: RuntimeRequestId, -): string { - return `${environmentId}:${requestId}`; -} diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts index 67c217081073e..9cf0fc11bea04 100644 --- a/apps/mobile/src/state/pending-thread-creation.test.ts +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { resolvePendingThreadCreation, + selectThreadCreationDetail, type PendingThreadCreation, } from "./pending-thread-creation"; import type { QueuedThreadMessage } from "./thread-outbox-model"; @@ -138,3 +139,51 @@ describe("resolvePendingThreadCreation", () => { ).toBeNull(); }); }); + +describe("selectThreadCreationDetail", () => { + const threadKey = `${creation.environmentId}:${creation.threadId}`; + const threadRef = { environmentId: creation.environmentId, threadId: creation.threadId }; + + it("withholds the detail ref until the creation is delivered", () => { + const queued = selectThreadCreationDetail({ + threadRef, + threadKey, + queuedMessagesByThreadKey: { [threadKey]: [creation] }, + outcomes: {}, + }); + expect(queued).toEqual({ + pendingCreation: { message: creation, outcome: null }, + detailRef: null, + }); + + const failed = selectThreadCreationDetail({ + threadRef, + threadKey, + queuedMessagesByThreadKey: {}, + outcomes: { [threadKey]: { kind: "failed", message: creation, reason: "Checkout failed" } }, + }); + expect(failed.pendingCreation?.outcome?.kind).toBe("failed"); + expect(failed.detailRef).toBeNull(); + + const delivered = selectThreadCreationDetail({ + threadRef, + threadKey, + queuedMessagesByThreadKey: {}, + outcomes: { [threadKey]: { kind: "delivered", message: creation } }, + }); + expect(delivered.pendingCreation?.outcome?.kind).toBe("delivered"); + expect(delivered.detailRef).toBe(threadRef); + }); + + it("does not hold back existing threads with only follow-ups queued", () => { + const { creation: _creation, ...followUp } = creation; + expect( + selectThreadCreationDetail({ + threadRef, + threadKey, + queuedMessagesByThreadKey: { [threadKey]: [followUp] }, + outcomes: {}, + }), + ).toEqual({ pendingCreation: null, detailRef: threadRef }); + }); +}); diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index 45e07d02fc24c..4f7457c7a2dac 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -21,6 +21,35 @@ export type PendingThreadCreation = { readonly outcome: PendingThreadCreationOutcome | null; }; +export function selectThreadCreationDetail(input: { + readonly threadRef: Ref | null; + readonly threadKey: string | null; + readonly queuedMessagesByThreadKey: Readonly< + Record | undefined> + >; + readonly outcomes: Readonly>; +}): { readonly pendingCreation: PendingThreadCreation | null; readonly detailRef: Ref | null } { + const pendingCreation = ((): PendingThreadCreation | null => { + if (input.threadKey === null) { + return null; + } + const queued = input.queuedMessagesByThreadKey[input.threadKey]?.find( + (message) => message.creation !== undefined, + ); + const outcome = input.outcomes[input.threadKey] ?? null; + const message = queued ?? outcome?.message ?? null; + return message === null ? null : { message, outcome }; + })(); + // The shell can arrive before creation is acknowledged. Wait for delivery + // before subscribing: an early not-found is cached as a deleted thread. + // Every detail reader must use this ref, not the route id. + const detailRef = + pendingCreation === null || pendingCreation.outcome?.kind === "delivered" + ? input.threadRef + : null; + return { pendingCreation, detailRef }; +} + export function resolvePendingThreadCreation(input: { readonly threadKey: string | null; readonly pending: PendingThreadCreation | null; diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 4c962cf5be650..5697ca63e5ea5 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -113,6 +113,7 @@ vi.mock("./threads", async () => { environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null), checkpointSummariesAtom: () => Atom.make([]), + pendingRequestsAtom: () => Atom.make(state.pendingRequests), }, threadEnvironment: { respondToApproval: state.approve, diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 1371b5206040b..462c4d6f0764c 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -16,19 +16,27 @@ import { composerAttachmentsStillUploading, } from "./composer-attachment-uploads"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; -import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/contracts"; +import { + type EnvironmentId, + type ProviderApprovalDecision, + type RuntimeRequestId, + type ThreadId, +} from "@t3tools/contracts"; import { type PendingThreadRequests, type ThreadUserInputQuestion, + onThreadRequestsResolved, + pruneResolvedUserInputDrafts, + resolvedQuestionDraftKeys, seedUserInputDraftAnswers, + userInputDraftKey, } from "@t3tools/client-runtime/state/thread-requests"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; -import { threadEnvironment } from "../state/threads"; -import { scopedRequestKey } from "../lib/scopedEntities"; +import { environmentThreadDetails, threadEnvironment } from "../state/threads"; import { buildPendingUserInputAnswers, setPendingUserInputCustomAnswer, @@ -47,6 +55,14 @@ const userInputDraftsByRequestKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); +// Writes start from the suggested answer the user was looking at. +function seededDraft( + drafts: Record | undefined, + question: ThreadUserInputQuestion, +): PendingUserInputDraftAnswer | undefined { + return seedUserInputDraftAnswers([question], drafts ?? {})[question.id]; +} + function setUserInputDraftOption( requestKey: string, question: ThreadUserInputQuestion, @@ -59,7 +75,7 @@ function setUserInputDraftOption( ...current[requestKey], [question.id]: togglePendingUserInputOptionSelection( question, - current[requestKey]?.[question.id], + seededDraft(current[requestKey], question), value, ), }, @@ -78,13 +94,75 @@ function setUserInputDraftCustomAnswer( ...current[requestKey], [question.id]: setPendingUserInputCustomAnswer( question, - current[requestKey]?.[question.id], + seededDraft(current[requestKey], question), customAnswer, ), }, }); } +/** Drops a thread's answer and attachment drafts for every request outside `pendingRequestIds`. */ +function pruneResolvedRequestDrafts( + environmentId: EnvironmentId, + threadId: ThreadId, + pendingRequestIds: ReadonlyArray, +): void { + const drafts = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); + const prunedDrafts = pruneResolvedUserInputDrafts( + drafts, + environmentId, + threadId, + pendingRequestIds, + ); + if (prunedDrafts !== drafts) appAtomRegistry.set(userInputDraftsByRequestKeyAtom, prunedDrafts); + + const prefix = questionAttachmentDraftPrefix(environmentId, threadId); + const attachmentDrafts = appAtomRegistry.get(questionAttachmentDraftsAtom(prefix)); + const counts = { ...appAtomRegistry.get(questionAttachmentPreparationAtom) }; + let changed = false; + for (const key of resolvedQuestionDraftKeys( + new Set([...Object.keys(attachmentDrafts), ...Object.keys(counts)]), + prefix, + pendingRequestIds, + )) { + if (attachmentDrafts[key]) clearComposerDraft(key); + if (key in counts) { + delete counts[key]; + changed = true; + } + } + if (changed) appAtomRegistry.set(questionAttachmentPreparationAtom, counts); +} + +// Resolution can come from anywhere: another device, an interrupt, the turn +// ending, or a reload over drafts for requests that resolved meanwhile. +onThreadRequestsResolved(({ environmentId, threadId, pendingRequestIds }) => + pruneResolvedRequestDrafts(environmentId, threadId, pendingRequestIds), +); + +/** + * Drops `answeredRequestId`'s drafts as soon as the server accepts the + * response, before the projection push that resolves it lands. + */ +function collectAnsweredRequestDrafts( + environmentId: EnvironmentId, + threadId: ThreadId, + answeredRequestId: RuntimeRequestId, +) { + const current = appAtomRegistry.get( + environmentThreadDetails.pendingRequestsAtom({ environmentId, threadId }), + ); + // Missing detail is not resolution. + if (current === null) return; + pruneResolvedRequestDrafts( + environmentId, + threadId, + [...current.approvals, ...current.userInputs] + .map((request) => request.requestId) + .filter((requestId) => requestId !== answeredRequestId), + ); +} + export function useSelectedThreadRequests() { const respondToApproval = useAtomCommand( threadEnvironment.respondToApproval, @@ -109,19 +187,6 @@ export function useSelectedThreadRequests() { const activePendingApproval = activePendingApprovals[0] ?? null; const activePendingUserInputs = pendingRequests?.userInputs ?? EMPTY_PENDING_REQUESTS.userInputs; const activePendingUserInput = activePendingUserInputs[0] ?? null; - useEffect(() => { - if (!activePendingUserInput || !selectedThreadShell) return; - const requestKey = scopedRequestKey( - selectedThreadShell.environmentId, - activePendingUserInput.requestId, - ); - const existing = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); - const drafts = existing[requestKey] ?? {}; - const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); - if (seeded !== drafts) { - appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...existing, [requestKey]: seeded }); - } - }, [activePendingUserInput, selectedThreadShell]); const questionServerConfigs = useServerConfigs(); const attachmentDrafts = useAtomValue( questionAttachmentDraftsAtom( @@ -132,36 +197,20 @@ export function useSelectedThreadRequests() { ); const preparationCounts = useAtomValue(questionAttachmentPreparationAtom); const uploadStates = useAtomValue(composerAttachmentUploadsAtom); - useEffect(() => { - if (!selectedThreadShell || !pendingRequests) return; - const prefix = questionAttachmentDraftPrefix( - selectedThreadShell.environmentId, - selectedThreadShell.id, - ); - const retained = new Set( - activePendingUserInputs.flatMap((request) => - request.questions.map((question) => - questionAttachmentDraftKey( - selectedThreadShell.environmentId, - selectedThreadShell.id, - request.requestId, - question.id, - ), - ), - ), - ); - const counts = { ...appAtomRegistry.get(questionAttachmentPreparationAtom) }; - let changed = false; - for (const key of new Set([...Object.keys(attachmentDrafts), ...Object.keys(counts)])) { - if (!key.startsWith(prefix) || retained.has(key)) continue; - if (attachmentDrafts[key]) clearComposerDraft(key); - if (key in counts) { - delete counts[key]; - changed = true; - } - } - if (changed) appAtomRegistry.set(questionAttachmentPreparationAtom, counts); - }, [activePendingUserInputs, attachmentDrafts, pendingRequests, selectedThreadShell]); + // Suggested answers are shown until the user edits them; only edits are stored. + const activePendingUserInputAnswerDrafts = + activePendingUserInput && selectedThreadShell + ? seedUserInputDraftAnswers( + activePendingUserInput.questions, + userInputDraftsByRequestKey[ + userInputDraftKey( + selectedThreadShell.environmentId, + selectedThreadShell.id, + activePendingUserInput.requestId, + ) + ] ?? {}, + ) + : {}; const activePendingUserInputDrafts = activePendingUserInput && selectedThreadShell ? Object.fromEntries( @@ -182,12 +231,7 @@ export function useSelectedThreadRequests() { return [ question.id, { - ...userInputDraftsByRequestKey[ - scopedRequestKey( - selectedThreadShell.environmentId, - activePendingUserInput.requestId, - ) - ]?.[question.id], + ...activePendingUserInputAnswerDrafts[question.id], attachmentCount: attachments.length, attachmentsBlocked: (attachments.length > 0 && @@ -214,7 +258,11 @@ export function useSelectedThreadRequests() { return; } - const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); + const requestKey = userInputDraftKey( + selectedThreadShell.environmentId, + selectedThreadShell.id, + requestId, + ); setUserInputDraftOption(requestKey, question, value); }, [selectedThreadShell], @@ -229,7 +277,11 @@ export function useSelectedThreadRequests() { return; } - const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); + const requestKey = userInputDraftKey( + selectedThreadShell.environmentId, + selectedThreadShell.id, + requestId, + ); setUserInputDraftCustomAnswer(requestKey, question, customAnswer); }, [activePendingUserInputs, selectedThreadShell], @@ -339,6 +391,13 @@ export function useSelectedThreadRequests() { setRespondingUserInputId((current) => current === activePendingUserInput.requestId ? null : current, ); + if (result._tag === "Success") { + collectAnsweredRequestDrafts( + selectedThreadShell.environmentId, + selectedThreadShell.id, + activePendingUserInput.requestId, + ); + } return result; }, [ activePendingUserInput, @@ -367,6 +426,13 @@ export function useSelectedThreadRequests() { setRespondingUserInputId((current) => current === activePendingUserInput.requestId ? null : current, ); + if (result._tag === "Success") { + collectAnsweredRequestDrafts( + selectedThreadShell.environmentId, + selectedThreadShell.id, + activePendingUserInput.requestId, + ); + } return result; }, [activePendingUserInput, dismissUserInput, selectedThreadShell]); diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d1e0d94df8677..17a4a26a1862b 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -26,6 +26,7 @@ import { resolvePendingThreadCreation, pendingThreadCreationOutcomesAtom, pendingThreadCreationShell, + selectThreadCreationDetail, type PendingThreadCreation, } from "./pending-thread-creation"; import { @@ -145,24 +146,12 @@ function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefin : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom); - const pendingCreation = ((): PendingThreadCreation | null => { - if (selectedThreadKey === null) { - return null; - } - const queued = queuedMessagesByThreadKey[selectedThreadKey]?.find( - (message) => message.creation !== undefined, - ); - const outcome = creationOutcome[selectedThreadKey] ?? null; - const message = queued ?? outcome?.message ?? null; - return message === null ? null : { message, outcome }; - })(); - // The shell can arrive before creation is acknowledged. Wait for delivery - // before subscribing: an early not-found is cached as a deleted thread. - // Every detail reader must use this ref, not the route id. - const selectedThreadDetailRef = - pendingCreation === null || pendingCreation.outcome?.kind === "delivered" - ? selectedThreadRef - : null; + const { pendingCreation, detailRef: selectedThreadDetailRef } = selectThreadCreationDetail({ + threadRef: selectedThreadRef, + threadKey: selectedThreadKey, + queuedMessagesByThreadKey, + outcomes: creationOutcome, + }); const [previousCreation, setPreviousCreation] = useState(null); const needsDetail = selectedThreadShell === null || pendingCreation !== null || previousCreation !== null; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4d3f276a7e978..53a0f37d5fa17 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -14528,6 +14528,55 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ); + + it.effect("keeps the CLI's goal confirmation out of the conversation", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const condition = "all tests pass"; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: { + ...harness.providerThread, + goal: { objective: condition, status: "active", checks: 1 }, + }, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("goal-clear-attempt"), + text: "/goal clear", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + syntheticFrame("goal-clear", `Goal cleared: ${condition}`), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "goal-clear-result", + result: `Goal cleared: ${condition}\n`, + numTurns: 0, + }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.isTrue( + harness.events.some( + (event) => + event.type === "provider_thread.updated" && event.providerThread.goal === null, + ), + ); + assert.isFalse( + harness.events.some( + (event) => event.type === "message.updated" && event.message.role === "assistant", + ), + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7defea9ac33c9..0060ee805c8cd 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1725,6 +1725,19 @@ function nextClaudeGoal( }; } +/** The CLI confirms a goal change as a synthetic reply; the goal row shows that change instead. */ +function isClaudeGoalAcknowledgement(message: SDKMessage): boolean { + if ( + message.type !== "assistant" || + message.parent_tool_use_id !== null || + message.message.model !== "" + ) { + return false; + } + const text = textFromClaudeContent(message.message.content).trim(); + return text.startsWith(CLAUDE_GOAL_SET_PREFIX) || text.startsWith("Goal cleared: "); +} + const providerGoalsEqual = Schema.toEquivalence(Schema.NullOr(OrchestrationV2ProviderGoal)); function normalizeClaudeResultText(text: string): string { @@ -8912,6 +8925,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); return; } + if (isClaudeGoalAcknowledgement(message)) { + // The CLI repeats a local command's output as the turn's result text. + if (assistantText !== null) { + context.assistant.noticeTexts.add(normalizeClaudeResultText(assistantText.text)); + } + return; + } // Claude Code echoes /compact's stdout ("Not enough messages to compact.") as a // synthetic assistant message. The compaction item reports that outcome; it is not a // reply. @@ -8984,7 +9004,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context.assistant.fallbackText.length === 0 && resultText !== null && resultText.text.length > 0 && - !context.assistant.noticeTexts.has(resultText.text) + !context.assistant.noticeTexts.has(resultText.text) && + !context.assistant.noticeTexts.has(normalizeClaudeResultText(resultText.text)) ) { context.assistant.fallbackText = resultText.text; context.assistant.fallbackNativeItemId = resultText.nativeItemId; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 58ee26d03e9b1..4d6c486f9d317 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5971,6 +5971,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { nativeThreadId, nativeTurnId: "goal-turn-1", prompt: objective, + sentPrompt: `Start working toward this goal: ${objective}`, }).slice(5), 5, ), @@ -6035,6 +6036,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { [providerTurnId("goal-turn-2"), RunAttemptId.make("goal-attempt"), 2], ], ); + // The goal is set paused until its first turn starts; only the result shows. + assert.notInclude(providerGoals(harness.events), "paused"); assert.equal(providerGoals(harness.events).at(-1), "complete"); }).pipe( Effect.scoped, @@ -6217,6 +6220,233 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect("pauses a goal Codex does not continue before its run settles", () => + Effect.gen(function* () { + const prompt = "Keep going"; + const transcript = makeCodexReplayTranscript({ + scenario: "goal-continuation-expired", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), + notification("goal active", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: "goal-turn", + goal: codexGoal("active", 10), + }), + notification("turn done", "turn/completed", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), + }), + notification("goal accounted", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: null, + goal: codexGoal("active", 11), + }), + ...request( + 4, + "thread/goal/set", + { threadId: nativeThreadId, status: "paused" }, + { goal: codexGoal("paused", 11) }, + ), + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("goal-expired-attempt"), + text: prompt, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.tokensUsed === 11, + ), + ); + assert.lengthOf(harness.terminalEvents(), 0); + yield* TestClock.adjust("5 seconds"); + yield* harness.firstTerminal; + + assert.deepEqual(providerGoals(harness.events), ["active", "active", "paused"]); + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["completed"], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("stops a turn Codex starts while the expiry pause is in flight", () => + Effect.gen(function* () { + const prompt = "Keep going"; + const transcript = makeCodexReplayTranscript({ + scenario: "goal-continuation-during-expiry", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), + notification("goal active", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: "goal-turn", + goal: codexGoal("active", 10), + }), + notification("turn done", "turn/completed", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), + }), + notification("goal accounted", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: null, + goal: codexGoal("active", 11), + }), + { + type: "expect_outbound", + label: "pause", + frame: { + id: 4, + method: "thread/goal/set", + params: { threadId: nativeThreadId, status: "paused" }, + }, + }, + notification("goal paused", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: null, + goal: codexGoal("paused", 11), + }), + notification("late continuation", "turn/started", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "inProgress" }), + }), + ...request( + 5, + "thread/goal/set", + { threadId: nativeThreadId, status: "paused" }, + { goal: codexGoal("paused", 11) }, + ), + ...request( + 6, + "turn/interrupt", + { threadId: nativeThreadId, turnId: "goal-turn-2" }, + {}, + ), + { + type: "emit_inbound", + label: "paused", + frame: { id: 4, result: { goal: codexGoal("paused", 11) } }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("goal-race-attempt"), + text: prompt, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.tokensUsed === 11, + ), + ); + yield* TestClock.adjust("5 seconds"); + yield* harness.firstTerminal; + + assert.deepEqual( + harness.terminalEvents().map((event) => [event.providerTurnId, event.status]), + [ + [ + (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeTurnId: "goal-turn", + }), + "completed", + ], + ], + ); + assert.equal(providerGoals(harness.events).at(-1), "paused"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("settles the run when Codex never answers the expiry pause", () => + Effect.gen(function* () { + const prompt = "Keep going"; + const transcript = makeCodexReplayTranscript({ + scenario: "goal-expiry-pause-unanswered", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), + notification("goal active", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: "goal-turn", + goal: codexGoal("active", 10), + }), + notification("turn done", "turn/completed", { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), + }), + notification("goal accounted", "thread/goal/updated", { + threadId: nativeThreadId, + turnId: null, + goal: codexGoal("active", 11), + }), + { + type: "expect_outbound", + label: "pause", + frame: { + id: 4, + method: "thread/goal/set", + params: { threadId: nativeThreadId, status: "paused" }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("goal-unanswered-attempt"), + text: prompt, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.tokensUsed === 11, + ), + ); + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("10 seconds"); + yield* harness.firstTerminal; + + assert.deepEqual( + harness.terminalEvents().map((event) => event.status), + ["completed"], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("pauses an active goal before Stop interrupts its turn", () => Effect.gen(function* () { const prompt = "Keep going"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 6a29e92258320..a65e8f4e0b976 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -31,7 +31,9 @@ import { CodexSettings, defaultInstanceIdForDriver, isOrchestrationV2WorkActive, + type OrchestrationV2GoalCommand, OrchestrationV2ProviderGoal, + parseGoalCommand, ProviderDriverKind, type ProviderSetupError, } from "@t3tools/contracts"; @@ -883,32 +885,6 @@ const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean => turn.status === "failed" || turn.status === "cancelled"); -export type CodexGoalCommand = - | { readonly type: "show" } - | { readonly type: "clear" } - | { readonly type: "pause" } - | { readonly type: "resume" } - | { readonly type: "set"; readonly objective: string }; - -function parseCodexGoalCommand(text: string): CodexGoalCommand | null { - const match = /^\/goal(?:\s+([\s\S]*))?$/u.exec(text.trim()); - if (match === null) return null; - const argument = (match[1] ?? "").trim(); - switch (argument.toLowerCase()) { - case "": - case "edit": - return { type: "show" }; - case "clear": - return { type: "clear" }; - case "pause": - return { type: "pause" }; - case "resume": - return { type: "resume" }; - default: - return { type: "set", objective: argument }; - } -} - type CodexThreadGoal = CodexSchema.V2ThreadGoalUpdatedNotification["goal"]; const CODEX_GOAL_STATUSES = { @@ -1857,9 +1833,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); + // Setting a goal passes through cleared and paused states; publish only the result. + const goalUpdatesHeld = new Set(); const emitGoalUpdate = Effect.fnUntraced(function* (nativeThreadId: string) { const providerThread = rootProviderThreads.get(nativeThreadId); - if (providerThread === undefined || !goalsByNativeThread.has(nativeThreadId)) return; + if ( + providerThread === undefined || + !goalsByNativeThread.has(nativeThreadId) || + goalUpdatesHeld.has(nativeThreadId) + ) { + return; + } const goal = goalsByNativeThread.get(nativeThreadId) ?? null; if (providerGoalsEqual(providerThread.goal ?? null, goal)) return; const updated = { ...providerThread, goal, updatedAt: yield* DateTime.now }; @@ -1880,6 +1864,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* emitGoalUpdate(nativeThreadId); }); + const releaseGoalUpdates = (nativeThreadId: string) => + Effect.suspend(() => { + goalUpdatesHeld.delete(nativeThreadId); + return emitGoalUpdate(nativeThreadId); + }); + // Only new model output proves recovery; local item/completed can arrive during upstream retries. const completeProviderRetry = Effect.fn("CodexAdapterV2.completeProviderRetry")(function* ( context: ActiveCodexTurnContext, @@ -5703,10 +5693,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( next: yield* Deferred.make(), }); yield* Effect.sleep(CODEX_GOAL_CONTINUATION_GRACE).pipe( + Effect.andThen(pauseUncontinuedGoal(nativeThreadId, event)), Effect.andThen( turnTerminalizationPermit.withPermits(1)(releaseGoalHold(nativeThreadId, event)), ), - Effect.forkIn(scope), + // The grace window starts when the turn completes, not when the fiber is scheduled. + Effect.forkIn(scope, { startImmediately: true }), ); return; } @@ -5714,6 +5706,51 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, ); + // A goal must not read as active once its run ends. Claiming the hold first + // makes a turn Codex starts during the pause an orphan, which is stopped. + const pauseUncontinuedGoal = Effect.fnUntraced(function* ( + nativeThreadId: string, + expected: CodexRootTerminalEvent, + ) { + const hold = goalHolds.get(nativeThreadId); + if ( + hold?.event !== expected || + goalStops.has(nativeThreadId) || + goalsByNativeThread.get(nativeThreadId)?.status !== "active" + ) { + return; + } + goalHolds.delete(nativeThreadId); + // Keeps a turn Codex starts before settlement an orphan once the goal reads paused. + goalStops.set(nativeThreadId, (goalStops.get(nativeThreadId) ?? 0) + 1); + yield* Effect.logWarning("orchestration-v2.codex-goal-continuation-expired", { + nativeThreadId, + }); + yield* client + .request("thread/goal/set", { threadId: nativeThreadId, status: "paused" }) + .pipe( + // The hold is already claimed, so an unanswered pause must not keep the run open. + Effect.timeout("10 seconds"), + Effect.flatMap(({ goal }) => { + goalsByNativeThread.set(nativeThreadId, providerGoalFromCodex(goal)); + return emitGoalUpdate(nativeThreadId); + }), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.codex-goal-pause-failed", { cause }), + ), + Effect.ensuring( + turnTerminalizationPermit.withPermits(1)(settleGoalHold(hold, "completed")), + ), + Effect.ensuring( + Effect.sync(() => { + const stops = (goalStops.get(nativeThreadId) ?? 1) - 1; + if (stops > 0) goalStops.set(nativeThreadId, stops); + else goalStops.delete(nativeThreadId); + }), + ), + ); + }); + /** Hold the terminalization permit and remove the goalHolds entry before calling. */ const settleGoalHold = Effect.fnUntraced(function* ( hold: CodexGoalHold, @@ -6092,9 +6129,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ); + /** Settles a /goal turn that never reaches Codex. State changes reply through the goal row. */ const completeGoalCommandTurn = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - reply: string, + reply: string | null, ) { const now = yield* DateTime.now; const nativeId = `goal-command:${turnInput.attemptId}`; @@ -6139,26 +6177,28 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( providerTurn, }); yield* rememberRootProviderThread(turnInput.providerThread); - const artifacts = yield* buildAgentMessageArtifacts( - context, - { id: nativeId, text: reply }, - true, - ); - yield* emitProviderEvent({ - type: "node.updated", - driver: CODEX_PROVIDER, - node: artifacts.node, - }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CODEX_PROVIDER, - message: artifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); + if (reply !== null) { + const artifacts = yield* buildAgentMessageArtifacts( + context, + { id: nativeId, text: reply }, + true, + ); + yield* emitProviderEvent({ + type: "node.updated", + driver: CODEX_PROVIDER, + node: artifacts.node, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CODEX_PROVIDER, + message: artifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); + } yield* emitProviderEvent({ type: "provider_turn.updated", driver: CODEX_PROVIDER, @@ -6316,7 +6356,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const runGoalCommand = Effect.fn("CodexAdapterV2.runGoalCommand")( function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - command: CodexGoalCommand, + command: OrchestrationV2GoalCommand, ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); rootProviderThreads.set(threadId, turnInput.providerThread); @@ -6338,10 +6378,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (command.type === "clear") { const { cleared } = yield* client.request("thread/goal/clear", { threadId }); goalsByNativeThread.set(threadId, null); - return yield* completeGoalCommandTurn( - turnInput, - cleared ? "Goal cleared." : "No goal to clear.", - ); + return yield* completeGoalCommandTurn(turnInput, cleared ? null : "No goal is set."); } if (current === null && command.type !== "set") { return yield* completeGoalCommandTurn(turnInput, "No goal is set."); @@ -6352,12 +6389,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( status: "paused", }); goalsByNativeThread.set(threadId, providerGoalFromCodex(goal)); - return yield* completeGoalCommandTurn( - turnInput, - "Goal paused. Send /goal resume to continue.", - ); + return yield* completeGoalCommandTurn(turnInput, null); } if (command.type === "set") { + goalUpdatesHeld.add(threadId); if (current !== null) yield* client.request("thread/goal/clear", { threadId }); // Keep paused until our turn starts so Codex cannot start one first. const { goal } = yield* client.request("thread/goal/set", { @@ -6371,12 +6406,16 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const activation = { stopped: false }; goalActivations.set(threadId, activation); yield* Effect.gen(function* () { + // A bare objective reads like a statement, so the model echoes it back. yield* startNativeTurn( turnInput, command.type === "set" ? yield* toCodexInput({ ...turnInput, - message: { ...turnInput.message, text: command.objective }, + message: { + ...turnInput.message, + text: `Start working toward this goal: ${command.objective}`, + }, }) : [], ); @@ -6410,6 +6449,14 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, (effect, turnInput) => effect.pipe( + Effect.ensuring( + Effect.suspend(() => { + const threadId = turnInput.providerThread.nativeThreadRef?.nativeId; + return threadId == null || !goalUpdatesHeld.has(threadId) + ? Effect.void + : releaseGoalUpdates(threadId); + }), + ), Effect.mapError( (cause) => new ProviderAdapter.ProviderAdapterTurnStartError({ @@ -6698,9 +6745,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), startTurn: (turnInput) => { const goalCommand = - turnInput.message.attachments.length === 0 && turnInput.restartContinuationOfRunId === undefined - ? parseCodexGoalCommand(turnInput.message.text) + ? parseGoalCommand(turnInput.message) : null; if (goalCommand !== null) return runGoalCommand(turnInput, goalCommand); return Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 6c07a410a1b36..8a8ef82554f63 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -4,6 +4,8 @@ import { ModelSelection, type OrchestrationV2Command, OrchestrationV2ProviderCapabilities, + type OrchestrationV2GoalCommand, + type OrchestrationV2ProviderGoalStatus, type OrchestrationV2Run, OrchestrationV2ThreadProjection, ProviderInstanceId, @@ -128,6 +130,26 @@ export function isNativeMaintenanceCommand(message: { ); } +/** + * A goal command sent into active work stops that work when it starts or + * replaces goal work, or ends a goal that is driving the run. + */ +export function goalCommandStopsActiveRun( + command: OrchestrationV2GoalCommand, + goalStatus: OrchestrationV2ProviderGoalStatus | null, +): boolean { + switch (command.type) { + case "set": + case "resume": + return true; + case "clear": + case "pause": + return goalStatus === "active"; + case "show": + return false; + } +} + export function resolveMessageDispatchIntent( projection: OrchestrationV2ThreadProjection, requestedMode: MessageDispatchMode, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1d8f1e7a03947..9c56bdf4a493f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -62,6 +62,7 @@ import { type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, latestProviderTurnForAttempt, + parseGoalCommand, orchestrationV2RunWorkStartedAt, ProviderInstanceId, ProviderInteractionMode, @@ -102,6 +103,7 @@ import { import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2, + goalCommandStopsActiveRun, isNativeMaintenanceCommand, resolveMessageDispatchIntent, } from "./CommandPolicy.ts"; @@ -479,13 +481,6 @@ function wakeWorkStartedAt( return previous === undefined ? {} : { workStartedAt: orchestrationV2RunWorkStartedAt(previous) }; } -function isGoalCommand(message: { - readonly text: string; - readonly attachments: ReadonlyArray; -}): boolean { - return message.attachments.length === 0 && /^\/goal(?:\s|$)/u.test(message.text.trim()); -} - const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { @@ -654,10 +649,12 @@ function isBlockingRun(run: OrchestrationV2Run): boolean { } /** Waiting runs only drain post-terminal output; their agent turn is over and still needs a wake. */ +function isLiveRun(run: OrchestrationV2Run): boolean { + return run.status === "preparing" || run.status === "starting" || run.status === "running"; +} + function hasLiveRun(projection: Pick): boolean { - return projection.runs.some( - (run) => run.status === "preparing" || run.status === "starting" || run.status === "running", - ); + return projection.runs.some(isLiveRun); } function withPullRequestWatch( @@ -1019,6 +1016,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const threadForkService = yield* ThreadForkServiceV2; const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; + /** The driver when this provider runs `/goal` natively, else undefined. */ + const goalDriverFor = Effect.fn("Orchestrator.goalDriverFor")(function* ( + command: OrchestrationV2ServerCommand, + providerInstanceId: ProviderInstanceId, + ) { + const { driver } = yield* providerAdapters + .get(providerInstanceId) + .pipe(mapDispatchError(command)); + return driver === "codex" || driver === "claudeAgent" ? driver : undefined; + }); + + /** Claude Code reads `/goal pause` and `/goal resume` as new goals named "pause" and "resume". */ + const ensureGoalCommandSupported = Effect.fn("Orchestrator.ensureGoalCommandSupported")( + function* ( + command: OrchestrationV2ServerCommand, + message: { readonly text: string; readonly attachments: ReadonlyArray }, + providerInstanceId: ProviderInstanceId, + ) { + const goalCommand = parseGoalCommand(message); + if (goalCommand?.type !== "pause" && goalCommand?.type !== "resume") return; + if ((yield* goalDriverFor(command, providerInstanceId)) !== "claudeAgent") return; + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Claude goals cannot be paused or resumed. Send /goal clear to remove the goal.", + }); + }, + ); + const mapDispatchError = (command: OrchestrationV2ServerCommand) => (effect: Effect.Effect): Effect.Effect => @@ -4332,7 +4358,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : "Signing out must run as a separate turn. Queue it or wait for the active turn to finish.", }); } - if (isGoalCommand(input)) { + if (parseGoalCommand(input) !== null) { return yield* new OrchestratorDispatchError({ commandId: input.command.commandId, commandType: input.command.type, @@ -5257,6 +5283,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) { dispatchMode = { type: "queue_after_active" }; } + // Codex and Claude own /goal; elsewhere it is ordinary text that cannot steer. + const parsedGoalCommand = parseGoalCommand(command); + const goalCommand = + parsedGoalCommand === null || + (yield* goalDriverFor(command, modelSelection.instanceId)) === undefined + ? null + : parsedGoalCommand; + let goalStopRunId: RunId | undefined; + if (goalCommand !== null) { + yield* ensureGoalCommandSupported(command, command, modelSelection.instanceId); + // A goal command never joins a running turn. One that starts or ends goal + // work stops the run and goes first in the queue; the rest wait. A send + // that asked to be queued (no delivery intent) only queues, and a run + // that is only finishing or running background work is never stopped. + const resolvedMode = dispatchMode; + const target = + resolvedMode.type === "steer_active" || resolvedMode.type === "restart_active" + ? projection.runs.find((run) => run.id === resolvedMode.targetRunId) + : resolvedMode.type === "queue_after_active" && command.deliveryIntent !== undefined + ? projection.runs.findLast(isLiveRun) + : undefined; + if (target !== undefined) { + const goal = projection.providerThreads.find( + (candidate) => candidate.id === target.providerThreadId, + )?.goal; + const targetMessage = projection.messages.find( + (message) => message.id === target.userMessageId, + ); + if ( + isLiveRun(target) && + goalCommandStopsActiveRun(goalCommand, goal?.status ?? null) && + (targetMessage === undefined || !isNativeMaintenanceCommand(targetMessage)) && + !(yield* stopReachedRun(command, command.threadId, target.id)) + ) { + goalStopRunId = target.id; + } + dispatchMode = { type: "queue_after_active" }; + } + } if ( command.notification !== undefined && (command.createdBy !== "agent" || @@ -5614,12 +5679,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? { queueHeld: true } : {}), queuePosition: - Math.max( - 0, - ...projection.runs - .filter((candidate) => candidate.status === "queued") - .map((candidate) => candidate.queuePosition ?? candidate.ordinal), - ) + 1, + goalStopRunId !== undefined + ? 1 + : Math.max( + 0, + ...projection.runs + .filter((candidate) => candidate.status === "queued") + .map((candidate) => candidate.queuePosition ?? candidate.ordinal), + ) + 1, requestedAt: now, startedAt: null, completedAt: null, @@ -5772,6 +5839,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } + if (goalStopRunId !== undefined) { + return yield* stopRunForGoalCommand({ + command, + events, + effects, + projection: yield* getProjectionWithPendingEvents(command.threadId, events), + queuedRunId: runId, + targetRunId: goalStopRunId, + now, + }); + } return; } const pendingForkTransfer = pendingForkTransferForThread(projection); @@ -8704,6 +8782,46 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const now = yield* DateTime.now; const emitEvent = emit(events, command); + const parsedGoalCommand = parseGoalCommand(queuedMessage); + const goalCommand = + parsedGoalCommand === null || + (yield* goalDriverFor(command, queuedRun.providerInstanceId)) === undefined + ? null + : parsedGoalCommand; + if (goalCommand !== null) { + yield* ensureGoalCommandSupported(command, queuedMessage, queuedRun.providerInstanceId); + const target = projection.runs.find((run) => run.id === command.targetRunId); + const goal = projection.providerThreads.find( + (candidate) => candidate.id === target?.providerThreadId, + )?.goal; + const targetMessage = projection.messages.find( + (message) => message.id === target?.userMessageId, + ); + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: queuedRun.id, + nodeId: queuedRootNode.id, + providerInstanceId: queuedRun.providerInstanceId, + occurredAt: now, + payload: { ...queuedRun, queuePosition: 1 }, + }); + const stops = + target !== undefined && + isLiveRun(target) && + goalCommandStopsActiveRun(goalCommand, goal?.status ?? null) && + (targetMessage === undefined || !isNativeMaintenanceCommand(targetMessage)) && + !(yield* stopReachedRun(command, command.threadId, target.id)); + return yield* stopRunForGoalCommand({ + command, + events, + effects, + projection, + queuedRunId: queuedRun.id, + targetRunId: stops ? target.id : undefined, + now, + }); + } yield* emitEvent({ type: "run.updated", threadId: command.threadId, @@ -9014,6 +9132,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "A guest's message cannot be edited. Cancel it instead.", }); } + yield* ensureGoalCommandSupported( + command, + { text: command.text, attachments: command.attachments ?? queuedMessage.attachments }, + queuedRun.providerInstanceId, + ); const queuedTurnItem = projection.turnItems.find( (candidate) => candidate.type === "user_message" && candidate.messageId === queuedMessage.id, ); @@ -10836,6 +10959,73 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); + /** Puts a queued goal command first and, given a target, stops the run in its way. */ + const stopRunForGoalCommand = Effect.fn("Orchestrator.stopRunForGoalCommand")(function* (input: { + readonly command: Extract< + OrchestrationV2Command, + { readonly type: "message.dispatch" | "queued-message.promote-to-steer" } + >; + readonly events: Ref.Ref>; + readonly effects: Ref.Ref>; + readonly projection: Pick; + readonly queuedRunId: RunId; + readonly targetRunId: RunId | undefined; + readonly now: DateTime.Utc; + }) { + const { command, events, effects, projection, now, targetRunId } = input; + const others = queuedRunsInDeliveryOrder(projection).filter( + (run) => run.id !== input.queuedRunId && !isAutomaticCompletionRun(projection, run), + ); + for (const [index, run] of others.entries()) { + const queuePosition = index + 2; + if (run.queuePosition === queuePosition) continue; + yield* emit( + events, + command, + )({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, queuePosition }, + }); + } + if (targetRunId === undefined) return undefined; + const interruptEvents = yield* Ref.make>([]); + const interruptEffects = yield* Ref.make>([]); + const interrupted = yield* Effect.exit( + dispatchRunInterrupt( + { + type: "run.interrupt", + commandId: command.commandId, + threadId: command.threadId, + runId: targetRunId, + reason: "Stopped to apply /goal", + }, + interruptEvents, + interruptEffects, + ), + ); + // A failed interrupt may have written part of its events, so they are kept apart. + // The goal command then waits for the turn to end. + if (Exit.isFailure(interrupted)) { + yield* Effect.logWarning("goal command could not stop the running turn", { + threadId: command.threadId, + runId: targetRunId, + cause: interrupted.cause, + }); + return undefined; + } + const written = yield* Ref.get(interruptEvents); + const enqueued = yield* Ref.get(interruptEffects); + yield* Ref.update(events, (existing) => [...existing, ...written]); + yield* Ref.update(effects, (existing) => [...existing, ...enqueued]); + // Like Stop: a run stopped before its provider started also abandons that start. + return interrupted.value; + }); + const dispatchThreadStop = Effect.fn("Orchestrator.dispatchThreadStop")(function* ( command: Extract, events: Ref.Ref>, @@ -12174,7 +12364,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio text: guestSend.text, context: { version: 1 as const, records: [], guest: guestSend.guest }, }; - yield* dispatchMessage(dispatched, events, effects); + cancelUnsettledEffects = yield* dispatchMessage(dispatched, events, effects); yield* recordSubagentFollowup(dispatched, events).pipe(mapDispatchError(command)); break; } @@ -12234,7 +12424,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cancelUnsettledEffects = yield* dispatchThreadStop(command, events, effects); break; case "queued-message.promote-to-steer": - yield* dispatchQueuedMessagePromoteToSteer(command, events, effects); + cancelUnsettledEffects = yield* dispatchQueuedMessagePromoteToSteer( + command, + events, + effects, + ); break; case "queued-run.start": case "queue.resume": { diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index c0c94a317cb2f..47ed556e6691a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -10,6 +10,8 @@ import { type OrchestrationV2Run, type OrchestrationV2RunAttempt, type OrchestrationV2TurnItem, + isCompactionCommandMessage, + parseGoalCommand, RunId, ThreadId, } from "@t3tools/contracts"; @@ -285,11 +287,12 @@ export const layer: Layer.Layer< source.status === "interrupted" || (source.status === "completed" && handoff.delivery === undefined && + // Compaction and /goal turns defer history to the next turn. projection.messages.some( (message) => message.id === source.userMessageId && - message.attachments.length === 0 && - message.text.trim().toLowerCase() === "/compact", + (isCompactionCommandMessage(message) || + parseGoalCommand(message) !== null), ))), ))), ); @@ -1048,6 +1051,9 @@ export const layer: Layer.Layer< text: message.text, records: message.context?.records ?? [], }); + // Codex and Claude parse /goal from the prompt, so it goes verbatim and history waits a turn. + const goalDriver = session.driver === "codex" || session.driver === "claudeAgent"; + const goalCommand = goalDriver && parseGoalCommand(message) !== null; // The provider turn marks cancellation work delivered; restart continuations use their own text or resume natively. const noteContinuation = isRestartNoteContinuation( run, @@ -1062,8 +1068,8 @@ export const layer: Layer.Layer< projection.messages .filter( (candidate) => - candidate.attachments.length === 0 && - candidate.text.trim().toLowerCase() === "/compact", + isCompactionCommandMessage(candidate) || + (goalDriver && parseGoalCommand(candidate) !== null), ) .map((candidate) => candidate.id), ), @@ -1211,7 +1217,7 @@ export const layer: Layer.Layer< ]; const delivery = yield* deliverContextHandoffs({ handoffs: [...effectiveHandoffs, ...retryHandoff], - deferInline: compact, + deferInline: compact || goalCommand, providerThread: runningProviderThread, budget: Effect.gen(function* () { return handoffBudget({ @@ -1265,7 +1271,10 @@ export const layer: Layer.Layer< ...(noteContinuation ? promptedInput : turnInput), message: { ...turnInput.message, - text: context === "" ? userText : `${context}\n\nUser message:\n${userText}`, + text: + context === "" || goalCommand + ? userText + : `${context}\n\nUser message:\n${userText}`, }, }).pipe( // Clear pending handoff markers for refused prompts so the next turn retains the native session. diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 1a5ee50872706..2cc6ee6f825b2 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -547,7 +547,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* yield* Fiber.join(running); return threadId; }); - return { events, started, steered, layer, startFirstTurn }; + return { cwd, events, started, steered, layer, startFirstTurn }; }); it.effect("sends a selected held message once and preserves the remaining queue order", () => @@ -1048,3 +1048,218 @@ it.effect("queues a plain send behind the stopped queue and releases its hold", }), ), ); + +const goalHarness = Effect.fn("goalHarness")(function* (name: string) { + const harness = yield* nextTurnSelectionHarness(name); + const send = (id: string, text: string, delivery: "queue" | "auto") => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId: ThreadId.make(`thread:${name}`), + messageId: MessageId.make(`message:${id}`), + text, + attachments: [], + modelSelection: runSelection, + ...(delivery === "queue" + ? { dispatchMode: { type: "queue_after_active" } } + : { dispatchMode: { type: "start_immediately" }, deliveryIntent: "auto" }), + createdBy: "user", + creationSource: "web", + }); + }); + const read = (threadId: ThreadId) => + Effect.gen(function* () { + const projection = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(threadId); + const textOf = (run: (typeof projection.runs)[number]) => + projection.messages.find((message) => message.id === run.userMessageId)?.text; + return { + queue: projection.runs + .filter((run) => run.status === "queued") + .toSorted((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0)) + .map(textOf), + started: harness.started.map((turn) => turn.message.text), + stopRequested: projection.turnItems.some( + (item) => + item.type === "run_interrupt_request" && item.runId === harness.started[0]?.runId, + ), + }; + }); + return { ...harness, send, read }; +}); + +it.effect("stops the running turn and starts a goal sent into it next", () => + Effect.scoped( + Effect.gen(function* () { + const { steered, layer, startFirstTurn, send, read } = yield* goalHarness("goal-steer"); + yield* Effect.gen(function* () { + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + yield* send("goal", "/goal all tests pass", "auto"); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: ["queued"], + started: ["first", "/goal all tests pass"], + stopRequested: true, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("queues a goal clear without stopping work when no goal drives it", () => + Effect.scoped( + Effect.gen(function* () { + const { steered, layer, startFirstTurn, send, read } = yield* goalHarness("goal-clear"); + yield* Effect.gen(function* () { + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + yield* send("clear", "/goal clear", "auto"); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: ["queued", "/goal clear"], + started: ["first"], + stopRequested: false, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("promotes a queued goal by stopping the running turn", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn, send, read } = + yield* goalHarness("goal-promote"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + yield* send("queued", "queued", "queue"); + yield* send("goal", "/goal ship it", "queue"); + const goalRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === MessageId.make("message:goal"), + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: goalRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: ["queued"], + started: ["first", "/goal ship it"], + stopRequested: true, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("stops a turn that is still starting when a goal is sent into it", () => + Effect.scoped( + Effect.gen(function* () { + const { cwd, layer, send, started } = yield* goalHarness("goal-during-start"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:goal-during-start"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:goal-during-start"), + title: "Goal during start", + modelSelection: runSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + // The stopped run's end starts the queue, which then needs one more drain. + const goalStarting = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "starting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + yield* send("first", "first", "auto"); + yield* send("goal", "/goal all tests pass", "auto"); + yield* worker.drain(); + yield* Fiber.join(goalStarting); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const statusOf = (text: string) => + projection.runs.find( + (run) => + projection.messages.find((message) => message.id === run.userMessageId)?.text === + text, + )?.status; + assert.equal(statusOf("first"), "interrupted"); + assert.deepEqual( + started.map((turn) => turn.message.text), + ["/goal all tests pass"], + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("stops the running turn when a goal clear ends the goal driving it", () => + Effect.scoped( + Effect.gen(function* () { + const { events, layer, startFirstTurn, send, read, steered } = + yield* goalHarness("goal-clear-active"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const providerThread = (yield* orchestrator.getThreadProjection(threadId)) + .providerThreads[0]!; + const goalSeen = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "provider-thread.updated" && event.payload.goal?.status === "active", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + yield* Queue.offer(events, { + type: "provider_thread.updated", + driver, + providerThread: { + ...providerThread, + goal: { objective: "all tests pass", status: "active" }, + }, + }); + yield* Fiber.join(goalSeen); + yield* send("clear", "/goal clear", "auto"); + yield* worker.drain(); + + assert.deepEqual(steered, []); + assert.deepEqual(yield* read(threadId), { + queue: [], + started: ["first", "/goal clear"], + stopRequested: true, + }); + }).pipe(Effect.provide(layer)); + }), + ), +); diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 1ed172b2da4ec..89e5d264545af 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -4,6 +4,7 @@ import { createBrowserHistory, createHashHistory } from "@tanstack/react-router" import { applyAppearanceContrast } from "./appearanceContrast"; import { applyAppearanceFontVariables, fontSettingOrThemeFont } from "./appearanceFonts"; +import { startElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention"; import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer"; import { startSshPasswordPromptQueue } from "./components/desktop/SshPasswordPromptDialog"; @@ -14,6 +15,7 @@ import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPrevie import { startMainAppLocationTracking } from "./components/sidebar/mainAppLocation"; import { startDesktopUpdateLaunchNotice } from "./components/sidebar/updateEverythingRun"; import { startThreadNotifications } from "./components/ThreadNotificationCoordinator"; +import { startGuideRunNotifications } from "./components/pullRequest/guide/PullRequestGuideRunCoordinator"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; import { resolveServerBackedAppDisplayName } from "./branding.logic"; import { isElectron } from "./env"; @@ -152,6 +154,7 @@ export function startAppServices(): AppRouter { startDefaultThemeAdoption(); startModelPreferencesMigration(); startAgentOpenedPreviews(); + if (isElectron) startElectronBrowserHost(); startThreadOverlayRetention(router); startMainAppLocationTracking(router); startBackgroundThreadAnnouncements(appAtomRegistry, router); @@ -162,6 +165,7 @@ export function startAppServices(): AppRouter { startLegacyThreadMigrationToast(); startPermissionUpdateNotices(router); startThreadNotifications(appAtomRegistry, router); + startGuideRunNotifications(appAtomRegistry, router); if (isElectron) startDesktopUpdateLaunchNotice(); return router; diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index 9a14ab8350c84..7525f66350ff9 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -21,13 +21,11 @@ export function BrowserSurfaceSlot(props: { className, fitSourceContent = false, } = props; - const elementRef = useRef(null); const presentationRef = useRef({ visible, cornerRadius, zIndex }); const updateRef = useRef<(() => void) | null>(null); - useLayoutEffect(() => { - const element = elementRef.current; - if (!element) return; + // The lease lives as long as this element for one tab, so the ref callback owns it. + const attachSurface = (element: HTMLDivElement) => { let lease = acquireBrowserSurface(tabId, fitSourceContent); const update = () => { const rect = element.getBoundingClientRect(); @@ -77,7 +75,7 @@ export function BrowserSurfaceSlot(props: { if (updateRef.current === update) updateRef.current = null; lease.release(); }; - }, [fitSourceContent, tabId]); + }; useLayoutEffect(() => { presentationRef.current = { visible, cornerRadius, zIndex }; @@ -90,5 +88,5 @@ export function BrowserSurfaceSlot(props: { zIndex, ]); - return
; + return
; } diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 1d35a6e933fcc..d71b7a2b6b2ba 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,21 +1,28 @@ import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, + type DesktopPreviewBridge, type DesktopPreviewOpenLinkEvent, + type EnvironmentId, FILL_PREVIEW_VIEWPORT, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { type ComponentProps, useEffect, useEffectEvent, useState } from "react"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { type ComponentProps, useState } from "react"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { isElectron } from "~/env"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { useDesktopHostedPreviewSessions } from "~/components/preview/usePreviewSession"; -import { useActivePreviewSessions } from "~/previewStateStore"; +import { + readActivePreviewSessions, + type ThreadPreviewState, + useActivePreviewSessions, +} from "~/previewStateStore"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; -import { useAtomCommand } from "~/state/use-atom-command"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserDefaults } from "./browserDefaults"; @@ -25,10 +32,11 @@ import { openUrlInPreview } from "./openFileInPreview"; import { rendersServerTabNatively } from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; -export function ElectronBrowserHost() { - const previewByThreadKey = useActivePreviewSessions(); - const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); - useDesktopHostedPreviewSessions(primaryEnvironmentId); +/** Every session this window renders as a hosted webview, in a fixed (non-display) order. */ +function hostedBrowserSessions( + previewByThreadKey: Record, + primaryEnvironmentId: EnvironmentId | null, +) { const activeSessions = Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { const threadRef = parseScopedThreadKey(threadKey); return threadRef @@ -50,68 +58,78 @@ export function ElectronBrowserHost() { }); // Electron destroys a moved webview's guest, so the render order must not follow the server's // updatedAt order. Any fixed order works; this one is not a display order. - const sessions = activeSessions.toSorted((left, right) => + return activeSessions.toSorted((left, right) => left.runtimeTabId < right.runtimeTabId ? -1 : left.runtimeTabId > right.runtimeTabId ? 1 : 0, ); +} - useEffect(() => { - const preview = window.desktopBridge?.preview; - if (!preview) return; - - let lastSerializedTheme = ""; - const syncTheme = () => { - const theme = readPreviewAnnotationTheme(); - const serializedTheme = JSON.stringify(theme); - if (serializedTheme === lastSerializedTheme) return; - lastSerializedTheme = serializedTheme; - void preview.setAnnotationTheme(theme).catch(() => { - lastSerializedTheme = ""; - }); - }; - const frameId = window.requestAnimationFrame(syncTheme); - const observer = new MutationObserver(syncTheme); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class", "style"], - }); - const headObserver = new MutationObserver(syncTheme); - headObserver.observe(document.head, { - childList: true, - subtree: true, - characterData: true, +function startAnnotationThemeSync(preview: DesktopPreviewBridge): () => void { + let lastSerializedTheme = ""; + const syncTheme = () => { + const theme = readPreviewAnnotationTheme(); + const serializedTheme = JSON.stringify(theme); + if (serializedTheme === lastSerializedTheme) return; + lastSerializedTheme = serializedTheme; + void preview.setAnnotationTheme(theme).catch(() => { + lastSerializedTheme = ""; }); - return () => { - window.cancelAnimationFrame(frameId); - observer.disconnect(); - headObserver.disconnect(); - }; - }, []); + }; + // App services start before the first paint, when the theme styles may not be applied yet. + const frameId = window.requestAnimationFrame(syncTheme); + const observer = new MutationObserver(syncTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + const headObserver = new MutationObserver(syncTheme); + headObserver.observe(document.head, { + childList: true, + subtree: true, + characterData: true, + }); + return () => { + window.cancelAnimationFrame(frameId); + observer.disconnect(); + headObserver.disconnect(); + }; +} - useEffect(() => { - const preview = window.desktopBridge?.preview; - if (!preview) return; - return preview.onPointerEvent((event) => { +function openLinkInPreview({ tabId, url, background }: DesktopPreviewOpenLinkEvent): void { + const source = hostedBrowserSessions( + readActivePreviewSessions(), + appAtomRegistry.get(primaryEnvironmentIdAtom), + ).find((session) => session.runtimeTabId === tabId); + if (!source) return; + void openUrlInPreview({ + threadRef: source.threadRef, + url, + openPreview: (input) => runAtomCommand(appAtomRegistry, previewEnvironment.open, input), + profileId: source.snapshot.profileId, + background, + }); +} + +/** App-lifetime listeners for the desktop preview bridge: annotation theme, agent pointer, links. */ +export function startElectronBrowserHost(): () => void { + const preview = window.desktopBridge?.preview; + if (!preview) return () => {}; + const disposers = [ + startAnnotationThemeSync(preview), + preview.onPointerEvent((event) => { useBrowserPointerStore.getState().apply(event); - }); - }, []); + }), + preview.onOpenLink(openLinkInPreview), + ]; + return () => { + for (const dispose of disposers) dispose(); + }; +} - const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: true }); - const onOpenLink = useEffectEvent(({ tabId, url, background }: DesktopPreviewOpenLinkEvent) => { - const source = sessions.find((session) => session.runtimeTabId === tabId); - if (!source) return; - void openUrlInPreview({ - threadRef: source.threadRef, - url, - openPreview, - profileId: source.snapshot.profileId, - background, - }); - }); - useEffect(() => { - const preview = window.desktopBridge?.preview; - if (!preview) return; - return preview.onOpenLink(onOpenLink); - }, []); +export function ElectronBrowserHost() { + const previewByThreadKey = useActivePreviewSessions(); + const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); + useDesktopHostedPreviewSessions(primaryEnvironmentId); + const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId); if (!isElectron) return null; return ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 89bf3a7641537..b98faab439110 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1328,7 +1328,6 @@ export default function ChatView(props: ChatViewProps) { } = usePendingThreadRequests({ environmentId, activeThreadId, - activeThread, serverProjection, supportsQuestionAttachments, composerDraftTarget, @@ -2079,9 +2078,7 @@ export default function ChatView(props: ChatViewProps) { const { showScrollToBottom, timelineLiveFollowEnabled, - cancelPositionRestoreRef, isTimelineAtLogicalEndStable, - cancelTimelineLiveFollowForUserNavigationStable, onComposerPageScrollKeyDownStable, onComposerPageScrollKeyUpStable, onComposerPageScrollReleaseStable, @@ -2091,14 +2088,14 @@ export default function ChatView(props: ChatViewProps) { onToolOutputCollapsedAtEndStable, onIsAtEndChangeStable, anchorNewTurn, + attachTimelineViewport, + attachComposerOverlay: attachFollowComposerOverlay, } = useTimelineFollow({ legendListRef, composerRef, - composerOverlayElement, composerTimelineInset, routeThreadKey, activeThreadKey, - activeThreadId: activeThread?.id, isServerThread, serverProjection, threadStatus, @@ -3449,8 +3446,7 @@ export default function ChatView(props: ChatViewProps) { onIsAtEndChange={onIsAtEndChangeStable} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable} - onManualNavigation={cancelTimelineLiveFollowForUserNavigationStable} - cancelPositionRestoreRef={cancelPositionRestoreRef} + onViewportAttach={attachTimelineViewport} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} emptyPlaceholderInset={composerReservedHeight} topFadeEnabled={!hasTimelineTopBanner} @@ -3471,7 +3467,10 @@ export default function ChatView(props: ChatViewProps) {
{ + setComposerOverlayElement(overlay); + attachFollowComposerOverlay(overlay); + }} inert={isRevertingCheckpoint} data-chat-composer-overlay="true" className={ diff --git a/apps/web/src/components/ForkShellTerminal.tsx b/apps/web/src/components/ForkShellTerminal.tsx index 7ed88d60b99dc..385e07a8ef661 100644 --- a/apps/web/src/components/ForkShellTerminal.tsx +++ b/apps/web/src/components/ForkShellTerminal.tsx @@ -16,7 +16,6 @@ import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts"; import { terminalThreadOpenInput } from "../forkTerminalThreadOpen"; import { useGrabTerminalFocus } from "../hooks/useForkTerminalFocus"; import { useLocalStorage } from "../hooks/useLocalStorage"; -import { observeResize } from "../lib/observeResize"; import { cn } from "../lib/utils"; import { providersIgnoringCheckedAtAtom } from "../state/server"; import { useEnvironmentScope } from "../state/session"; @@ -96,21 +95,6 @@ export function ForkShellTerminal(props: { }); }; - // The viewport refits on a change of these numbers, so a sidebar or window resize refits it. - const [layout, setLayout] = useState({ height: 0, epoch: 0 }); - useEffect(() => { - const container = containerRef.current; - if (!container) return; - return observeResize(container, (entries) => { - const entry = entries.at(-1); - if (!entry) return; - setLayout((current) => ({ - height: Math.round(entry.contentRect.height), - epoch: current.epoch + 1, - })); - }); - }, []); - return (
{readOnly ? ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index e4ff6e96e1994..4caa4bc3d4f72 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1003,10 +1003,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); }, [props.open]); - const updateTabScrollState = () => { - const viewport = tabScrollViewport(tabListRef.current); - if (!viewport) return; - + const updateTabScrollState = (viewport: HTMLDivElement) => { const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; const canScrollRight = @@ -1213,34 +1210,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) { setRenamingDevice(null); }); + // A newly active tab, or tabs that start overflowing, can sit outside the scrolled strip. useEffect(() => { if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [props.activeSurfaceId, tabScrollState.hasOverflow]); - useEffect(() => { - const viewport = tabScrollViewport(tabListRef.current); - if (!viewport) return; - - const content = viewport.firstElementChild; - const stopObserving = observeResize( - content ? [viewport, content] : viewport, - updateTabScrollState, - ); - viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); - updateTabScrollState(); - - return () => { - stopObserving(); - viewport.removeEventListener("scroll", updateTabScrollState); - }; - }, [updateTabScrollState]); - - useEffect(() => { - const viewport = tabScrollViewport(tabListRef.current); + const attachTabList = (root: HTMLDivElement | null) => { + tabListRef.current = root; + const viewport = tabScrollViewport(root); if (!viewport) return; + const update = () => updateTabScrollState(viewport); const handleWheel = (event: WheelEvent) => { if (event.ctrlKey) return; let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; @@ -1252,12 +1234,22 @@ export function RightPanelTabs(props: RightPanelTabsProps) { viewport.scrollLeft += delta; if (viewport.scrollLeft === previousScrollLeft) return; event.preventDefault(); - updateTabScrollState(); + update(); }; + const content = viewport.firstElementChild; + const stopObserving = observeResize(content ? [viewport, content] : viewport, update); + viewport.addEventListener("scroll", update, { passive: true }); viewport.addEventListener("wheel", handleWheel, { passive: false }); - return () => viewport.removeEventListener("wheel", handleWheel); - }, [updateTabScrollState]); + update(); + + return () => { + tabListRef.current = null; + stopObserving(); + viewport.removeEventListener("scroll", update); + viewport.removeEventListener("wheel", handleWheel); + }; + }; return ( { @@ -87,3 +88,16 @@ it("retains visible output and selection until a replacement subscription receiv synchronizeTerminalOutput(terminal, cleared, observedCursor); expect(terminal.resetAndWrite).toHaveBeenLastCalledWith(""); }); + +it("reports an attach failure that leaves the output unchanged", () => { + const terminal = { resetAndWrite: vi.fn(), write: vi.fn(), clearSelection: vi.fn() }; + const previous = { ...EMPTY_TERMINAL_BUFFER_STATE, error: null }; + const failed = { ...previous, error: "Terminal attach failed" }; + + expect( + synchronizeTerminalSession(terminal, previous, failed, INITIAL_TERMINAL_OUTPUT_CURSOR), + ).toBe(INITIAL_TERMINAL_OUTPUT_CURSOR); + expect(terminal.write).toHaveBeenCalledWith("\r\n[terminal] Terminal attach failed\r\n"); + expect(terminal.resetAndWrite).not.toHaveBeenCalled(); + expect(terminal.clearSelection).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index b63f48eeddedc..b286187ac5a94 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomMount, useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -34,7 +34,6 @@ import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, type ReactNode, - type SetStateAction, useEffect, useEffectEvent, useLayoutEffect, @@ -85,7 +84,8 @@ import { confirmTerminalClose } from "~/lib/terminalCloseConfirm"; import { terminalZoomShortcut, useTerminalZoom } from "../hooks/useForkTerminalZoom"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; -import { useAttachedTerminalSession } from "../state/terminalSessions"; +import { attachedTerminalSessionAtom } from "../state/terminalSessions"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; import { readEnvironmentScope } from "../state/session"; @@ -138,6 +138,8 @@ function resolveTerminalDrawerMaxHeight(input: DrawerLayout): number { ); } +const DRAG_HEIGHT_PROPERTY = "--terminal-drawer-drag-height"; + function clampDrawerHeight(height: number, maxHeight: number): number { const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT; return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); @@ -157,7 +159,10 @@ function readDrawerLayout(column: HTMLElement | null): Omit, + message: string, +): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } @@ -184,6 +189,24 @@ export function synchronizeTerminalOutput( return update.cursor; } +/** Writes what changed between two session notifications: new output, then a new error. */ +export function synchronizeTerminalSession( + terminal: Pick, + previous: Pick, + session: Pick, + cursor: TerminalOutputCursor, +): TerminalOutputCursor { + const nextCursor = + session.version === previous.version && session.output === previous.output + ? cursor + : synchronizeTerminalOutput(terminal, session, cursor); + // An attach failure can change only the error, keeping the last buffer. + if (session.error !== null && session.error !== previous.error) { + writeSystemMessage(terminal, session.error); + } + return nextCursor; +} + function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { if (typeof document === "undefined") return fallback; @@ -413,6 +436,19 @@ export function shouldClearTerminalSelectionAction(options: { return options.actionPending || options.openMenuRequestId === options.currentRequestId; } +/** + * A writable session's output generation. A new one is a fresh PTY (or a + * reconnect) that has not seen the surface's grid yet. + */ +function writableSessionGeneration( + session: TerminalSessionState, + canOperate: boolean, +): number | null { + return canOperate && session.version > 0 && session.status === "running" + ? session.output.generation + : null; +} + export function shouldHandleTerminalExit( current: TerminalSessionState["status"], synchronized: TerminalSessionState["status"], @@ -452,8 +488,6 @@ interface TerminalViewportProps { /** Focuses the viewport when it mounts; otherwise focus arrives through `focusTerminal`. */ autoFocus?: boolean; visible: boolean; - resizeEpoch: number; - drawerHeight: number; keybindings: ResolvedKeybindingsConfig; } @@ -472,6 +506,7 @@ export function TerminalViewport(props: TerminalViewportProps) { props.worktreePath ?? null, runtimeEnvSignature(props.runtimeEnv), props.shell ?? null, + props.draft === true, ]); return ; } @@ -495,8 +530,6 @@ function TerminalSessionViewport({ onAddTerminalContext, autoFocus = false, visible, - resizeEpoch, - drawerHeight, keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); @@ -572,9 +605,10 @@ function TerminalSessionViewport({ const { fontSize: terminalFontSize, zoom } = useTerminalZoom(baseTerminalFontSize); const zoomTerminal = useEffectEvent(zoom); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); - const terminalSession = useAttachedTerminalSession({ + // Keyed by the launch (see TerminalViewport), so the atom is fixed for this surface. + const terminalSessionAtom = attachedTerminalSessionAtom( environmentId, - terminal: { + { threadId, terminalId, cwd, @@ -584,10 +618,15 @@ function TerminalSessionViewport({ ...(shell ? { shell } : {}), }, draft, - }); - const canResizeTerminal = - canOperateTerminal && terminalSession.version > 0 && terminalSession.status === "running"; - const resizeSessionGeneration = canResizeTerminal ? terminalSession.output.generation : null; + ); + // Attaching starts with the viewport, while the WASM surface is still loading; + // the surface reads the session itself, so output chunks never re-render here. + useAtomMount(terminalSessionAtom); + const readTerminalSession = useEffectEvent(() => appAtomRegistry.get(terminalSessionAtom)); + const subscribeTerminalSession = useEffectEvent( + (listener: (session: TerminalSessionState) => void) => + appAtomRegistry.subscribe(terminalSessionAtom, listener), + ); const writeTerminal = useEffectEvent((data: string) => runTerminalWrite({ environmentId, @@ -595,15 +634,14 @@ function TerminalSessionViewport({ }), ); const resizeTerminal = useEffectEvent((cols: number, rows: number) => { - if (!canResizeTerminal || !hasTerminalWriteAccess()) return; + if (writableSessionGeneration(readTerminalSession(), hasTerminalWriteAccess()) === null) { + return; + } return runTerminalResize({ environmentId, input: { threadId, terminalId, cols, rows }, }); }); - const terminalOutput = terminalSession.output; - const terminalError = terminalSession.error; - const terminalStatus = terminalSession.status; const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const synchronizedStatusRef = useRef("closed"); const reportedStatusRef = useRef(null); @@ -635,19 +673,6 @@ function TerminalSessionViewport({ }, ); const reportInput = useEffectEvent((data: string) => onInput?.(data)); - const terminalVersion = terminalSession.version; - const previousSessionRef = useRef({ - output: terminalOutput, - error: terminalError, - status: terminalStatus, - version: terminalVersion, - }); - const readLatestSession = useEffectEvent(() => ({ - output: terminalOutput, - error: terminalError, - status: terminalStatus, - version: terminalVersion, - })); const openLinkInPreview = useEffectEvent( (url: string, forceBrowser: boolean, fallbackToBrowser: () => void) => openTerminalLinkInPreview({ url, threadRef, openPreview, fallbackToBrowser, forceBrowser }), @@ -667,13 +692,6 @@ function TerminalSessionViewport({ terminalRef.current?.refreshLinkActivation(); }, [canOpenHostEditor]); - useEffect(() => { - if (resizeSessionGeneration === null) return; - // The first fit can precede authorization/attach; replay the grid for each writable session, including - // reconnects. - terminalRef.current?.resendSize(); - }, [resizeSessionGeneration]); - useLayoutEffect(() => { visibleRef.current = visible; terminalRef.current?.setVisible(visible); @@ -732,8 +750,7 @@ function TerminalSessionViewport({ if (currentFont.family !== setupFont.family || currentFont.size !== setupFont.size) { void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size)); } - const latestSession = readLatestSession(); - previousSessionRef.current = latestSession; + const latestSession = readTerminalSession(); const initialOutput = readTerminalOutputUpdate( latestSession.output, INITIAL_TERMINAL_OUTPUT_CURSOR, @@ -745,6 +762,29 @@ function TerminalSessionViewport({ if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status, latestSession.version); + // Read and subscribe in one synchronous step so no output lands between them. + let previousSession = latestSession; + let resizeGeneration = writableSessionGeneration(latestSession, hasTerminalWriteAccess()); + setupCleanups.push( + subscribeTerminalSession((session) => { + const nextResizeGeneration = writableSessionGeneration(session, hasTerminalWriteAccess()); + if (nextResizeGeneration !== resizeGeneration) { + resizeGeneration = nextResizeGeneration; + // The first fit can precede authorization/attach; replay the grid for each writable session, including + // reconnects. + if (nextResizeGeneration !== null) terminal.resendSize(); + } + const previous = previousSession; + previousSession = session; + synchronizeTerminalStatus(terminal, session.status, session.version); + outputCursorRef.current = synchronizeTerminalSession( + terminal, + previous, + session, + outputCursorRef.current, + ); + }), + ); if ( hasTerminalWriteAccess() && visibleRef.current && @@ -1167,53 +1207,6 @@ function TerminalSessionViewport({ }; }, [cwd, environmentId, terminalId]); - useEffect(() => { - const terminal = terminalRef.current; - const current = { - output: terminalOutput, - error: terminalError, - status: terminalStatus, - version: terminalVersion, - }; - if (!terminal) { - previousSessionRef.current = current; - return; - } - - const previous = previousSessionRef.current; - synchronizeTerminalStatus(terminal, current.status, current.version); - if (current.version === previous.version && current.output === previous.output) { - return; - } - - outputCursorRef.current = synchronizeTerminalOutput(terminal, current, outputCursorRef.current); - - if (current.error !== null && current.error !== previous.error) { - writeSystemMessage(terminal, current.error); - } - - previousSessionRef.current = current; - }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); - - // A hidden surface skips fitting, and showing it again fits through setVisible. - const layoutRevision = visible ? `${drawerHeight}:${resizeEpoch}` : null; - useEffect(() => { - const terminal = terminalRef.current; - if (!terminal || layoutRevision === null) return; - const wasAtBottom = terminal.isAtBottom(); - // onResize alone sends PTY resize RPCs; fitting here only refreshes layout. - const frame = window.requestAnimationFrame(() => { - if (!visibleRef.current) return; - terminal.fit(); - if (wasAtBottom) { - terminal.scrollToBottom(); - } - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [layoutRevision]); - const attachContainer = (mount: HTMLDivElement) => { containerRef.current = mount; const focus = () => @@ -1349,8 +1342,6 @@ function TerminalSplitPanes({ threadId, onAddTerminalContext, visible, - resizeEpoch, - drawerHeight, keybindings, }: { terminalIds: readonly string[]; @@ -1365,8 +1356,6 @@ function TerminalSplitPanes({ threadId: ThreadId; onAddTerminalContext?: ((selection: TerminalContextSelection) => void) | undefined; visible: boolean; - resizeEpoch: number; - drawerHeight: number; keybindings: ResolvedKeybindingsConfig; }) { return ( @@ -1415,8 +1404,6 @@ function TerminalSplitPanes({ onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} visible={visible} - resizeEpoch={resizeEpoch} - drawerHeight={drawerHeight} keybindings={keybindings} />
@@ -1571,36 +1558,19 @@ export default function ThreadTerminalDrawer({ false, Schema.Boolean, ); - const controlledDrawerHeight = height; const drawerRef = useRef(null); const [drawerLayout, setDrawerLayout] = useState(() => readDrawerLayout(null)); const maxDrawerHeight = resolveTerminalDrawerMaxHeight({ ...drawerLayout, composerHeight }); - const [drawerHeightState, setDrawerHeightState] = useState(() => ({ - threadId, - height: controlledDrawerHeight, - })); - const preferredDrawerHeight = - drawerHeightState.threadId === threadId ? drawerHeightState.height : controlledDrawerHeight; - const drawerHeight = clampDrawerHeight(preferredDrawerHeight, maxDrawerHeight); - const setDrawerHeight = (update: SetStateAction) => { - setDrawerHeightState((current) => { - const currentHeight = current.threadId === threadId ? current.height : controlledDrawerHeight; - const nextHeight = typeof update === "function" ? update(currentHeight) : update; - return nextHeight === currentHeight && current.threadId === threadId - ? current - : { threadId, height: nextHeight }; - }); - }; - const [resizeEpoch, setResizeEpoch] = useState(0); - const drawerHeightRef = useRef(drawerHeight); - const lastSyncedHeightRef = useRef({ threadId, height: controlledDrawerHeight }); + const drawerHeight = clampDrawerHeight(height, maxDrawerHeight); + // A drag only writes the drawer's style; the store hears the height once, at + // pointerup. The custom property outranks the rendered height while it is set. const resizeStateRef = useRef<{ pointerId: number; startY: number; startHeight: number; + height: number; drawer: HTMLElement | null; } | null>(null); - const didResizeDuringDragRef = useRef(false); const normalizedTerminalIds = (() => { const normalizedIds: string[] = []; @@ -1770,35 +1740,15 @@ export default function ThreadTerminalDrawer({ }); }; - useLayoutEffect(() => { - const resizeState = resizeStateRef.current; - if (resizeState) { - resizeState.drawer?.style.setProperty("height", `${drawerHeightRef.current}px`); - return; - } - drawerHeightRef.current = drawerHeight; - }, [drawerHeight]); - - const syncHeight = (nextHeight: number) => { - const lastSynced = lastSyncedHeightRef.current; - if (lastSynced.threadId === threadId && lastSynced.height === nextHeight) return; - lastSyncedHeightRef.current = { threadId, height: nextHeight }; - onHeightChange(nextHeight); - }; - - useEffect(() => { - lastSyncedHeightRef.current = { threadId, height: controlledDrawerHeight }; - }, [controlledDrawerHeight, threadId]); - const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); - didResizeDuringDragRef.current = false; resizeStateRef.current = { pointerId: event.pointerId, startY: event.clientY, - startHeight: drawerHeightRef.current, + startHeight: drawerHeight, + height: drawerHeight, drawer: event.currentTarget.parentElement, }; }; @@ -1811,12 +1761,11 @@ export default function ThreadTerminalDrawer({ resizeState.startHeight + (resizeState.startY - event.clientY), maxDrawerHeight, ); - if (clampedHeight === drawerHeightRef.current) { + if (clampedHeight === resizeState.height) { return; } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - resizeState.drawer?.style.setProperty("height", `${clampedHeight}px`); + resizeState.height = clampedHeight; + resizeState.drawer?.style.setProperty(DRAG_HEIGHT_PROPERTY, `${clampedHeight}px`); }; const handleResizePointerEnd = (event: ReactPointerEvent) => { @@ -1826,19 +1775,11 @@ export default function ThreadTerminalDrawer({ if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } + resizeState.drawer?.style.removeProperty(DRAG_HEIGHT_PROPERTY); // A drag that ends where it started keeps the stored preference, which may // be taller than the capped height the drag started from. - if (!didResizeDuringDragRef.current || drawerHeightRef.current === resizeState.startHeight) { - // React skips the write when the rendered height did not change, so put - // back the preference as the current layout caps it. - const renderedHeight = clampDrawerHeight(preferredDrawerHeight, maxDrawerHeight); - drawerHeightRef.current = renderedHeight; - resizeState.drawer?.style.setProperty("height", `${renderedHeight}px`); - return; - } - setDrawerHeight(drawerHeightRef.current); - syncHeight(drawerHeightRef.current); - setResizeEpoch((value) => value + 1); + if (resizeState.height === resizeState.startHeight || resizeState.height === height) return; + onHeightChange(resizeState.height); }; const drawerLayoutRef = useRef(drawerLayout); @@ -1855,16 +1796,17 @@ export default function ThreadTerminalDrawer({ if (!heightBudgetChanged && source === "column") return; drawerLayoutRef.current = layout; const resizeState = resizeStateRef.current; - const liveHeight = clampDrawerHeight( - drawerHeightRef.current, - resolveTerminalDrawerMaxHeight({ ...layout, composerHeight }), - ); - if (resizeState && liveHeight !== drawerHeightRef.current) { - drawerHeightRef.current = liveHeight; - resizeState.drawer?.style.setProperty("height", `${liveHeight}px`); + if (resizeState) { + const liveHeight = clampDrawerHeight( + resizeState.height, + resolveTerminalDrawerMaxHeight({ ...layout, composerHeight }), + ); + if (liveHeight !== resizeState.height) { + resizeState.height = liveHeight; + resizeState.drawer?.style.setProperty(DRAG_HEIGHT_PROPERTY, `${liveHeight}px`); + } } if (heightBudgetChanged) setDrawerLayout(layout); - setResizeEpoch((value) => value + 1); }, ); useLayoutEffect(() => { @@ -1889,9 +1831,6 @@ export default function ThreadTerminalDrawer({ }; }, [isPanel, visible]); - const syncPreferredHeight = useEffectEvent(() => syncHeight(preferredDrawerHeight)); - useEffect(() => () => syncPreferredHeight(), []); - if (normalizedTerminalIds.length === 0) { return (