diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 16f697d11f..100ee00bec 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { resolvePairState } from "@t3tools/client-runtime/state/pair"; import { delegatedThreadRows } from "@t3tools/client-runtime/state/delegated-threads"; import { prepareRevertedMessageContext } from "../lib/composerRewindContext"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; @@ -3027,6 +3028,16 @@ export default function ChatView(props: ChatViewProps) { [agentSessionLive, threadActivities], ); const allThreadShells = useThreadShells(); + const activeThreadPaired = useMemo( + () => + activeThread + ? resolvePairState({ + threads: allThreadShells, + lead: { environmentId, threadId: activeThread.id, driverKind: null }, + }).kind === "on" + : false, + [activeThread, allThreadShells, environmentId], + ); const delegatedThreads = useMemo( () => (activeThreadRef ? delegatedThreadRows(allThreadShells, activeThreadRef) : []), [allThreadShells, activeThreadRef], @@ -7788,6 +7799,7 @@ export default function ChatView(props: ChatViewProps) { const followUp = resolvePlanFollowUpSubmission({ draftText: promptForSend, planMarkdown: activeProposedPlan.planMarkdown, + options: { paired: activeThreadPaired }, }); const outgoingFollowUpText = formatOutgoingPrompt({ provider: ctxSelectedProvider, @@ -10666,6 +10678,7 @@ export default function ChatView(props: ChatViewProps) { respondingRequestIds={respondingRequestIds} showPlanFollowUpPrompt={showPlanFollowUpPrompt} activeProposedPlan={activeProposedPlan} + pairedImplement={activeThreadPaired} activeTasksProgress={activeComposerTasksProgress} activeTaskSteps={activeComposerTaskSteps} activeDelegatedWork={activeComposerDelegatedWork} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c0411a3cfa..e52f269703 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1301,6 +1301,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + pairedImplement?: boolean; }) { return ( <> @@ -1336,6 +1337,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} + pairedImplement={props.pairedImplement ?? false} /> ); @@ -1475,6 +1477,7 @@ export interface ChatComposerProps { // Plan showPlanFollowUpPrompt: boolean; activeProposedPlan: Thread["proposedPlans"][number] | null; + pairedImplement?: boolean; activeTasksProgress: ComposerTasksProgress | null; activeTaskSteps: readonly ComposerTaskStep[] | null; activeDelegatedWork: ComposerDelegatedWorkSummary | null; @@ -7890,6 +7893,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + pairedImplement={props.pairedImplement ?? false} /> diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 37b2b408d5..2250ae7e23 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -159,6 +159,30 @@ function renderSendButton( ); } +function renderImplement(pairedImplement: boolean | undefined) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: false, + canQueueFollowUp: false, + onQueueFollowUp: () => {}, + showPlanFollowUpPrompt: true, + promptHasText: false, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + ...(pairedImplement === undefined ? {} : { pairedImplement }), + }), + ); +} + afterEach(() => { stageArtworkState.mode = "none"; stageArtworkState.variant = null; @@ -276,3 +300,12 @@ describe("ComposerPrimaryActions", () => { expect(markup).not.toContain('aria-label="Send message"'); }); }); + +describe("implementing a plan on a paired thread", () => { + it("says the work goes through the executor, and only then", () => { + expect(renderImplement(true)).toContain(">Implement with executor<"); + expect(renderImplement(false)).toContain(">Implement<"); + expect(renderImplement(undefined)).toContain(">Implement<"); + expect(renderImplement(false)).not.toContain("with executor"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index d316fd4305..2e2a2acf16 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -38,6 +38,8 @@ interface ComposerPrimaryActionsProps { onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + /** The thread has a pair executor, so implementing a plan goes through it. */ + pairedImplement?: boolean; } const formatPendingPrimaryActionLabel = (input: { @@ -82,6 +84,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, + pairedImplement, }: ComposerPrimaryActionsProps) { const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } @@ -195,7 +198,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {...pointerFocusProps} disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable} > - {isConnecting || isSendBusy ? "Sending..." : "Implement"} + {isConnecting || isSendBusy + ? "Sending..." + : pairedImplement + ? "Implement with executor" + : "Implement"} { expect(html).toContain("Working"); }); + it("offers to stop the executor only while it holds a turn", () => { + const stop = { onStopExecutor: () => undefined }; + for (const phase of ["running", "needs-approval", "needs-input"] as const) { + const html = renderToStaticMarkup( + , + ); + expect(html, phase).toContain("data-pair-stop"); + expect(html, phase).toContain("Stop executor"); + } + for (const phase of ["idle", "completed", "interrupted", "error"] as const) { + const html = renderToStaticMarkup( + , + ); + expect(html, phase).not.toContain("data-pair-stop"); + } + // Without a handler there is nothing to press. + expect( + renderToStaticMarkup(), + ).not.toContain("data-pair-stop"); + }); + it("shows what the executor is doing and links to it when the pair is on", () => { const html = renderToStaticMarkup( , diff --git a/apps/web/src/components/chat/PairControl.tsx b/apps/web/src/components/chat/PairControl.tsx index d2f4e8c70d..108db6dade 100644 --- a/apps/web/src/components/chat/PairControl.tsx +++ b/apps/web/src/components/chat/PairControl.tsx @@ -27,6 +27,8 @@ export interface PairControlProps { readonly lockedReason?: string | null; readonly onToggle: (on: boolean) => void; readonly onExecutorChange: (instanceId: ProviderInstanceId, model: string) => void; + /** Stops the executor's running turn. The button shows only while it holds one. */ + readonly onStopExecutor?: () => void; } /** @@ -187,6 +189,19 @@ export function PairControlPanel(props: PairControlProps) { {props.state.activity}

)} + {props.onStopExecutor !== undefined && + (props.state.phase === "running" || + props.state.phase === "needs-approval" || + props.state.phase === "needs-input") && ( + + )} )} diff --git a/apps/web/src/components/chat/usePairControl.ts b/apps/web/src/components/chat/usePairControl.ts index cd34f437b9..65e21b4b59 100644 --- a/apps/web/src/components/chat/usePairControl.ts +++ b/apps/web/src/components/chat/usePairControl.ts @@ -18,6 +18,7 @@ import { useEnvironmentSettings } from "~/hooks/useSettings"; import { useThreadShells } from "~/state/entities"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; +import { buildThreadTurnInterruptInput } from "../ChatView.logic"; import { stackedThreadToast, toastManager } from "../ui/toast"; import type { PairControlProps } from "./PairControl"; import { @@ -35,7 +36,12 @@ export function usePairControl(input: { readonly projectId: ProjectId | null; }): Pick< PairControlProps, - "state" | "executorSelection" | "lockedReason" | "onToggle" | "onExecutorChange" + | "state" + | "executorSelection" + | "lockedReason" + | "onToggle" + | "onExecutorChange" + | "onStopExecutor" > { const threads = useThreadShells(); const settings = useEnvironmentSettings(input.environmentId); @@ -77,6 +83,7 @@ export function usePairControl(input: { const archiveThread = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); const stopSession = useAtomCommand(threadEnvironment.stopSession, { reportFailure: false }); + const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false }); const onToggle = useCallback( async (on: boolean) => { @@ -175,6 +182,35 @@ export function usePairControl(input: { [leadId], ); + const onStopExecutor = useCallback(async () => { + if (state.kind !== "on") { + return; + } + const executorShell = threads.find( + (thread) => thread.environmentId === input.environmentId && thread.id === state.executorId, + ); + if (!executorShell) { + return; + } + const result = await interruptTurn({ + environmentId: input.environmentId, + input: buildThreadTurnInterruptInput(executorShell), + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not stop the executor", + description: + error instanceof Error + ? error.message + : "An error occurred while stopping the executor.", + }), + ); + } + }, [input.environmentId, interruptTurn, state, threads]); + const executorSelection = useMemo( () => resolveExecutorSelection({ state, picked, defaultSelection }), [defaultSelection, picked, state], @@ -188,7 +224,8 @@ export function usePairControl(input: { lockedReason, onToggle, onExecutorChange, + onStopExecutor, }), - [executorSelection, lockedReason, onExecutorChange, onToggle, state], + [executorSelection, lockedReason, onExecutorChange, onStopExecutor, onToggle, state], ); } diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index a8700ab924..929020d771 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -122,3 +122,15 @@ describe("buildProposedPlanMarkdownFilename", () => { expect(buildProposedPlanMarkdownFilename("- step 1")).toBe("plan.md"); }); }); + +describe("implementing a plan on a paired thread", () => { + it("reminds the lead to work through its executor, after the plan itself", () => { + const plain = buildPlanImplementationPrompt("# Plan\n\n1. Do it"); + const paired = buildPlanImplementationPrompt("# Plan\n\n1. Do it", { paired: true }); + expect(buildPlanImplementationPrompt("# Plan\n\n1. Do it", { paired: false })).toBe(plain); + expect(paired.startsWith(plain)).toBe(true); + expect(paired.slice(plain.length)).toBe( + "\n\nThis thread is paired. Write the contract and the failing tests yourself, then hand the plan to your executor one step at a time with pair_handoff, and verify each step before the next.", + ); + }); +}); diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 525be17a72..c8678e3186 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -73,11 +73,27 @@ function sanitizePlanFileSegment(input: string): string { /** Prefix of the message the app sends when the user approves a plan. */ export const PLAN_IMPLEMENTATION_PROMPT_PREFIX = "PLEASE IMPLEMENT THIS PLAN:\n"; -export function buildPlanImplementationPrompt(planMarkdown: string): string { - return `${PLAN_IMPLEMENTATION_PROMPT_PREFIX}${planMarkdown.trim()}`; +/** + * The message sent when the user approves a plan. On a paired thread it ends + * with a reminder of how a pair implements, at the moment the lead decides how + * to start; the prefix stays first because other code recognizes it. + */ +export function buildPlanImplementationPrompt( + planMarkdown: string, + options: { readonly paired?: boolean } = {}, +): string { + const base = `${PLAN_IMPLEMENTATION_PROMPT_PREFIX}${planMarkdown.trim()}`; + if (options.paired === true) { + return `${base}\n\nThis thread is paired. Write the contract and the failing tests yourself, then hand the plan to your executor one step at a time with pair_handoff, and verify each step before the next.`; + } + return base; } -export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): { +export function resolvePlanFollowUpSubmission(input: { + draftText: string; + planMarkdown: string; + options?: { readonly paired?: boolean }; +}): { text: string; interactionMode: "default" | "plan"; } { @@ -90,7 +106,7 @@ export function resolvePlanFollowUpSubmission(input: { draftText: string; planMa } return { - text: buildPlanImplementationPrompt(input.planMarkdown), + text: buildPlanImplementationPrompt(input.planMarkdown, input.options), interactionMode: "default", }; } diff --git a/docs/user/agent-delegation.md b/docs/user/agent-delegation.md index 3bd1780491..c13b529626 100644 --- a/docs/user/agent-delegation.md +++ b/docs/user/agent-delegation.md @@ -133,14 +133,17 @@ faster, cheaper model that stays linked for as long as the first thread lives. T the brief, and checks the result; the executor does the implementation in the same worktree, so there is nothing to merge back. -In the web composer, **Pair** sits beside the model picker. Open it, choose the executor's model, and -turn the switch on. The executor starts from your **Default delegation model** when you have one, and -follows **Child permissions**. While the pair is on, the control shows the executor's model and what -it is doing, and **Open executor** takes you to its thread. The switch waits while the lead is -mid-turn, because a change applies between turns. Turning the pair off deletes an executor that was -never briefed and archives one that has history; turning it on again brings an archived executor +In the web composer, **Pair** sits beside the model picker. Open it, choose the executor's model, +and turn the switch on. The executor starts from your **Default delegation model** when you have +one, and follows **Child permissions**. While the pair is on, the control shows the executor's model +and what it is doing, and **Open executor** takes you to its thread. The switch waits while the lead +is mid-turn, because a change applies between turns. Turning the pair off deletes an executor that +was never briefed and archives one that has history; turning it on again brings an archived executor back. To change the executor's model, turn the pair off first. With **Pylon delegation** on, you can -also ask for a pair in your message, for example “Pair with Antigravity for this.” +also ask for a pair in your message, for example “Pair with Antigravity for this.” While the +executor is working, the same panel has **Stop executor**. When you approve a plan on a paired +thread, the button reads **Implement with executor** and the lead is reminded to write the tests +itself and hand the plan over one step at a time. While a thread is paired, the lead's own subagents are paused for that thread only, so implementation goes to the executor. Pylon's own delegation is paused there too: a paired lead cannot