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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -10666,6 +10678,7 @@ export default function ChatView(props: ChatViewProps) {
respondingRequestIds={respondingRequestIds}
showPlanFollowUpPrompt={showPlanFollowUpPrompt}
activeProposedPlan={activeProposedPlan}
pairedImplement={activeThreadPaired}
activeTasksProgress={activeComposerTasksProgress}
activeTaskSteps={activeComposerTaskSteps}
activeDelegatedWork={activeComposerDelegatedWork}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
onImplementPlanInNewThread: () => void;
pairedImplement?: boolean;
}) {
return (
<>
Expand Down Expand Up @@ -1336,6 +1337,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
onPreviousPendingQuestion={props.onPreviousPendingQuestion}
onInterrupt={props.onInterrupt}
onImplementPlanInNewThread={props.onImplementPlanInNewThread}
pairedImplement={props.pairedImplement ?? false}
/>
</>
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -7890,6 +7893,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
pairedImplement={props.pairedImplement ?? false}
/>
</div>
</div>
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/components/chat/ComposerPrimaryActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
});
});
9 changes: 8 additions & 1 deletion apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -82,6 +84,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
onPreviousPendingQuestion,
onInterrupt,
onImplementPlanInNewThread,
pairedImplement,
}: ComposerPrimaryActionsProps) {
const pointerFocusProps = preserveComposerFocusOnPointerDown
? { onPointerDown: preventPointerFocus }
Expand Down Expand Up @@ -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"}
</Button>
<Menu>
<MenuTrigger
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/chat/PairControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,27 @@ describe("PairControlPanel", () => {
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(
<PairControlPanel {...props({ state: on(phase), ...stop })} />,
);
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(
<PairControlPanel {...props({ state: on(phase), ...stop })} />,
);
expect(html, phase).not.toContain("data-pair-stop");
}
// Without a handler there is nothing to press.
expect(
renderToStaticMarkup(<PairControlPanel {...props({ state: on("running") })} />),
).not.toContain("data-pair-stop");
});

it("shows what the executor is doing and links to it when the pair is on", () => {
const html = renderToStaticMarkup(
<PairControlPanel {...props({ state: on("error", "quota exceeded today") })} />,
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/chat/PairControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -187,6 +189,19 @@ export function PairControlPanel(props: PairControlProps) {
{props.state.activity}
</p>
)}
{props.onStopExecutor !== undefined &&
(props.state.phase === "running" ||
props.state.phase === "needs-approval" ||
props.state.phase === "needs-input") && (
<button
type="button"
data-pair-stop
onClick={props.onStopExecutor}
className="self-start font-medium text-destructive text-xs hover:underline"
>
Stop executor
</button>
)}
</div>
)}
</div>
Expand Down
41 changes: 39 additions & 2 deletions apps/web/src/components/chat/usePairControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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],
Expand All @@ -188,7 +224,8 @@ export function usePairControl(input: {
lockedReason,
onToggle,
onExecutorChange,
onStopExecutor,
}),
[executorSelection, lockedReason, onExecutorChange, onToggle, state],
[executorSelection, lockedReason, onExecutorChange, onStopExecutor, onToggle, state],
);
}
12 changes: 12 additions & 0 deletions apps/web/src/proposedPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
});
});
24 changes: 20 additions & 4 deletions apps/web/src/proposedPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
} {
Expand All @@ -90,7 +106,7 @@ export function resolvePlanFollowUpSubmission(input: { draftText: string; planMa
}

return {
text: buildPlanImplementationPrompt(input.planMarkdown),
text: buildPlanImplementationPrompt(input.planMarkdown, input.options),
interactionMode: "default",
};
}
Expand Down
17 changes: 10 additions & 7 deletions docs/user/agent-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading