fix(ui): require explicit handoff context selection (#3601)

This commit is contained in:
Carlos Florêncio
2026-09-11 14:46:04 +01:00
committed by GitHub
parent 21a639b726
commit 76ab362701
15 changed files with 760 additions and 52 deletions
@@ -1,19 +1,15 @@
import { describe, expect, it } from "vitest";
import { buildHandoffInitialState, summarizeContextValue } from "./handoff-types";
import { buildHandoffInitialState } from "./handoff-types";
describe("handoff-types", () => {
it("buildHandoffInitialState selects target profile and summarize context", () => {
it("buildHandoffInitialState selects target profile and blank context", () => {
const result = buildHandoffInitialState({
sourceSessionId: "session-a",
targetProfileId: "profile-b",
});
expect(result).toEqual({
selectedProfileId: "profile-b",
contextValue: "summarize:session-a",
contextValue: "blank",
});
});
it("summarizeContextValue prefixes session id", () => {
expect(summarizeContextValue("session-123")).toBe("summarize:session-123");
});
});
+1 -5
View File
@@ -9,10 +9,6 @@ export function buildHandoffInitialState(handoff: HandoffPreset): {
} {
return {
selectedProfileId: handoff.targetProfileId,
contextValue: summarizeContextValue(handoff.sourceSessionId),
contextValue: "blank",
};
}
export function summarizeContextValue(sessionId: string): string {
return `summarize:${sessionId}`;
}
@@ -14,7 +14,10 @@ const mockRecordRecentUse = vi.fn();
let mockAgentSelectorValue: string | undefined;
let mockAgentSelectorOnChange: ((value: string) => void) | undefined;
let mockExecutorProfile: ExecutorProfile | null = null;
let mockContextSelectValue: string | undefined;
const PLUGIN_COMPOSER_LABEL = "Plugin composer action";
const DESCRIPTION_INPUT_TEST_ID = "task-description-input";
const TYPED_HANDOFF_PROMPT = "typed handoff prompt";
const BASE_PROFILE: AgentProfileOption = {
id: "profile-1",
@@ -144,7 +147,7 @@ vi.mock("@/components/task-create-dialog-selectors", async () => {
React.Fragment,
null,
React.createElement("textarea", {
"data-testid": "task-description-input",
"data-testid": DESCRIPTION_INPUT_TEST_ID,
placeholder: "Describe what you want the agent to do... (@ to insert a saved prompt)",
value,
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
@@ -224,13 +227,29 @@ vi.mock("@/components/enhance-prompt-button", () => ({
vi.mock("./session-dialog-shared", () => ({
EnvironmentBadges: () => null,
AttachButton: () => null,
ContextSelect: ({ onValueChange }: { onValueChange: (value: string) => void }) => (
<div>
<button type="button" onClick={() => void onValueChange("copy_prompt")}>
Copy initial prompt
</button>
</div>
),
ContextSelect: ({
value,
onValueChange,
}: {
value: string;
onValueChange: (value: string) => void;
}) => {
mockContextSelectValue = value;
return (
<div>
<span data-testid="context-value">{value}</span>
<button type="button" onClick={() => void onValueChange("copy_prompt")}>
Copy initial prompt
</button>
<button type="button" onClick={() => void onValueChange("summarize:session-9")}>
Summarize source session
</button>
<button type="button" onClick={() => void onValueChange("summarize:session-10")}>
Summarize alternate session
</button>
</div>
);
},
useDialogAttachments: () => ({
attachments: [],
isDragging: false,
@@ -257,6 +276,7 @@ describe("NewSessionDialog", () => {
mockExecutorProfile = null;
mockAgentSelectorValue = undefined;
mockAgentSelectorOnChange = undefined;
mockContextSelectValue = undefined;
});
beforeEach(() => {
@@ -274,13 +294,13 @@ describe("NewSessionDialog", () => {
fireEvent.click(screen.getByRole("button", { name: "Copy initial prompt" }));
await waitFor(() =>
expect((screen.getByTestId("task-description-input") as HTMLTextAreaElement).value).toBe(
expect((screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement).value).toBe(
"seed prompt",
),
);
});
it("writes the handoff summary into the fresh-open dialog prompt", async () => {
it("opens handoff with blank context without summarizing", () => {
render(
<NewSessionDialog
open={true}
@@ -290,12 +310,123 @@ describe("NewSessionDialog", () => {
/>,
);
expect(mockContextSelectValue).toBe("blank");
expect((screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement).value).toBe("");
expect(
(screen.getByRole("button", { name: "Start Agent" }) as HTMLButtonElement).disabled,
).toBe(true);
expect(screen.getByText("Profile 1")).toBeTruthy();
expect(mockSummarize).not.toHaveBeenCalled();
});
it("summarizes only after an explicit session selection", async () => {
render(
<NewSessionDialog
open={true}
onOpenChange={vi.fn()}
taskId="task-1"
handoff={{ sourceSessionId: "session-9", targetProfileId: "profile-1" }}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Summarize source session" }));
await waitFor(() => expect(mockSummarize).toHaveBeenCalledWith("session-9"));
await waitFor(() =>
expect((screen.getByTestId("task-description-input") as HTMLTextAreaElement).value).toBe(
expect((screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement).value).toBe(
"summary text",
),
);
expect(mockContextSelectValue).toBe("summarize:session-9");
});
it("summarizes the explicitly chosen alternate session", async () => {
render(
<NewSessionDialog
open={true}
onOpenChange={vi.fn()}
taskId="task-1"
handoff={{ sourceSessionId: "session-9", targetProfileId: "profile-1" }}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Summarize alternate session" }));
await waitFor(() => expect(mockSummarize).toHaveBeenCalledWith("session-10"));
expect(mockSummarize).toHaveBeenCalledTimes(1);
expect(mockContextSelectValue).toBe("summarize:session-10");
});
it("resets handoff context to blank when reopened", async () => {
const props = {
onOpenChange: vi.fn(),
taskId: "task-1",
handoff: { sourceSessionId: "session-9", targetProfileId: "profile-1" },
} as const;
const { rerender } = render(<NewSessionDialog open={true} {...props} />);
fireEvent.click(screen.getByRole("button", { name: "Summarize source session" }));
await waitFor(() =>
expect((screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement).value).toBe(
"summary text",
),
);
rerender(<NewSessionDialog open={false} {...props} />);
rerender(<NewSessionDialog open={true} {...props} />);
expect(mockContextSelectValue).toBe("blank");
expect((screen.getByTestId(DESCRIPTION_INPUT_TEST_ID) as HTMLTextAreaElement).value).toBe("");
expect(mockSummarize).toHaveBeenCalledTimes(1);
});
it("preserves a typed handoff draft across ordinary rerenders", () => {
const { rerender } = render(
<NewSessionDialog
open={true}
onOpenChange={vi.fn()}
taskId="task-1"
handoff={{ sourceSessionId: "session-9", targetProfileId: "profile-1" }}
/>,
);
const prompt = screen.getByTestId(DESCRIPTION_INPUT_TEST_ID);
fireEvent.change(prompt, { target: { value: TYPED_HANDOFF_PROMPT } });
rerender(
<NewSessionDialog
open={true}
onOpenChange={vi.fn()}
taskId="task-1"
handoff={{ sourceSessionId: "session-9", targetProfileId: "profile-1" }}
/>,
);
expect((prompt as HTMLTextAreaElement).value).toBe(TYPED_HANDOFF_PROMPT);
expect(mockSummarize).not.toHaveBeenCalled();
});
it("launches a typed handoff prompt without summarizing", async () => {
render(
<NewSessionDialog
open={true}
onOpenChange={vi.fn()}
taskId="task-1"
handoff={{ sourceSessionId: "session-9", targetProfileId: "profile-1" }}
/>,
);
fireEvent.change(screen.getByTestId(DESCRIPTION_INPUT_TEST_ID), {
target: { value: TYPED_HANDOFF_PROMPT },
});
fireEvent.click(screen.getByRole("button", { name: "Start Agent" }));
await waitFor(() => expect(mockLaunchSession).toHaveBeenCalledTimes(1));
expect(mockBuildStartRequest).toHaveBeenCalledWith(
"task-1",
"profile-1",
expect.objectContaining({ prompt: TYPED_HANDOFF_PROMPT }),
);
expect(mockSummarize).not.toHaveBeenCalled();
});
it("submits text a plugin composer action inserted into a blank composer", async () => {
@@ -151,19 +151,6 @@ function isMissingCompatibleProfile(
return !hasCompatibleProfiles;
}
function useHandoffAutoSummarize(
handoff: HandoffPreset | undefined,
contextValue: string,
onContextChange: (value: string) => void,
) {
const started = useRef(false);
useEffect(() => {
if (!handoff || started.current) return;
started.current = true;
void onContextChange(contextValue);
}, [handoff, contextValue, onContextChange]);
}
export function useSessionPromptController(
promptRef: RefObject<TaskFormInputsHandle | null>,
taskId: string,
@@ -396,7 +383,6 @@ function NewSessionForm({
setContextValue,
setHasPrompt,
});
useHandoffAutoSummarize(handoff, handoffInitial?.contextValue ?? "blank", handleContextChange);
const handleSubmit = useSessionLaunchSubmit({
promptRef,
@@ -1,7 +1,26 @@
import { test, expect } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
import { SessionPage } from "../../pages/session-page";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
const HANDOFF_SUMMARY = "Mobile handoff summary: completed the prior session work.";
async function mockSummarizeUtility(testPage: import("@playwright/test").Page) {
let summarizeRequestCount = 0;
await testPage.route("**/api/v1/utility/execute", async (route) => {
const request = route.request().postDataJSON() as { utility_agent_id?: string };
if (request.utility_agent_id === "builtin-summarize-session") summarizeRequestCount += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
response: HANDOFF_SUMMARY,
}),
});
});
return () => summarizeRequestCount;
}
async function createProfiles(
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
@@ -27,6 +46,7 @@ test.describe("Session handoff on mobile", () => {
test.setTimeout(120_000);
const { profileA, profileB, agentName } = await createProfiles(apiClient);
const getSummarizeRequestCount = await mockSummarizeUtility(testPage);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
@@ -65,8 +85,75 @@ test.describe("Session handoff on mobile", () => {
await session.openMobileHandoffDialog(session1Id, profileB.id);
const handoffDialog = session.handoffDialog();
await expect(handoffDialog).toBeVisible({ timeout: 5_000 });
await expect(handoffDialog).toContainText("Hand off to");
await expect(handoffDialog).toContainText("Mobile Handoff B");
const prompt = session.newSessionPromptInput();
const contextTrigger = handoffDialog.locator("button").filter({ hasText: "Blank" });
await expect(contextTrigger).toBeVisible();
await expect(prompt).toHaveValue("");
await expect(session.newSessionStartButton()).toBeDisabled();
await expect
.poll(() =>
testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
)
.toBe(true);
await dwell(
testPage,
500,
"negative-assertion",
"prove opening a mobile handoff does not invoke the summary utility",
);
expect(getSummarizeRequestCount()).toBe(0);
await contextTrigger.tap();
const summaryOption = testPage.getByRole("option", { name: /Mobile Handoff A/ });
await expect(summaryOption).toBeVisible();
await summaryOption.tap();
await expect(prompt).toHaveValue(HANDOFF_SUMMARY, { timeout: 15_000 });
await expect.poll(getSummarizeRequestCount).toBe(1);
await handoffDialog.getByRole("button", { name: "Cancel" }).tap();
await expect(handoffDialog).not.toBeVisible({ timeout: 5_000 });
await testPage.keyboard.press("Escape");
await expect(testPage.getByRole("dialog", { name: "Sessions" })).not.toBeVisible();
await session.openMobileHandoffDialog(session1Id, profileB.id);
await expect(session.handoffDialog()).toBeVisible({ timeout: 5_000 });
await expect(session.handoffDialog()).toContainText("Hand off to");
await expect(session.handoffDialog()).toContainText("Mobile Handoff B");
await expect(session.newSessionPromptInput()).toHaveValue("");
await expect(
session.handoffDialog().locator("button").filter({ hasText: "Blank" }),
).toBeVisible();
expect(getSummarizeRequestCount()).toBe(1);
await session.newSessionPromptInput().fill("/e2e:simple-message");
await session.newSessionStartButton().tap();
await expect(session.handoffDialog()).not.toBeVisible({ timeout: 15_000 });
let handoffSessionId: string | undefined;
await expect
.poll(
async () => {
const { sessions: updated } = await apiClient.listTaskSessions(task.id);
const handoffSession = updated.find(({ id }) => id !== session1Id);
handoffSessionId = handoffSession?.id;
if (!handoffSession) return null;
const { messages } = await apiClient.listSessionMessages(handoffSession.id);
return messages.find((message) => message.author_type === "user")?.content ?? null;
},
{ timeout: 30_000, message: "Waiting for mobile handoff session to be created" },
)
.toBe("/e2e:simple-message");
const { sessions: updated } = await apiClient.listTaskSessions(task.id);
const handoffSession = updated.find(({ id }) => id !== session1Id);
expect(handoffSession?.id).toBe(handoffSessionId);
expect(handoffSession?.agent_profile_id).toBe(profileB.id);
expect(getSummarizeRequestCount()).toBe(1);
});
});
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
@@ -7,7 +8,10 @@ const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
const HANDOFF_SUMMARY = "Handoff summary: completed the prior session work.";
async function mockSummarizeUtility(testPage: import("@playwright/test").Page) {
let summarizeRequestCount = 0;
await testPage.route("**/api/v1/utility/execute", async (route) => {
const request = route.request().postDataJSON() as { utility_agent_id?: string };
if (request.utility_agent_id === "builtin-summarize-session") summarizeRequestCount += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -17,6 +21,7 @@ async function mockSummarizeUtility(testPage: import("@playwright/test").Page) {
}),
});
});
return () => summarizeRequestCount;
}
async function createProfiles(
@@ -43,7 +48,7 @@ test.describe("Session handoff", () => {
test.setTimeout(120_000);
const { profileA, profileB } = await createProfiles(apiClient);
await mockSummarizeUtility(testPage);
const getSummarizeRequestCount = await mockSummarizeUtility(testPage);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
@@ -83,25 +88,68 @@ test.describe("Session handoff", () => {
await session.openHandoffDialog(session1Id, profileB.id);
await expect(session.handoffDialog()).toBeVisible({ timeout: 5_000 });
await expect(session.handoffDialog()).toContainText("Hand off to");
await expect(session.handoffDialog()).toContainText("Handoff Profile B");
const handoffDialog = session.handoffDialog();
await expect(handoffDialog).toBeVisible({ timeout: 5_000 });
await expect(handoffDialog).toContainText("Hand off to");
await expect(handoffDialog).toContainText("Handoff Profile B");
const prompt = session.newSessionPromptInput();
await expect(prompt).toHaveValue(HANDOFF_SUMMARY, { timeout: 15_000 });
const contextTrigger = handoffDialog.locator("button").filter({ hasText: "Blank" });
await expect(contextTrigger).toBeVisible();
await expect(prompt).toHaveValue("");
await expect(session.newSessionStartButton()).toBeDisabled();
await dwell(
testPage,
500,
"negative-assertion",
"prove opening a handoff does not invoke the summary utility",
);
expect(getSummarizeRequestCount()).toBe(0);
await prompt.fill(`${HANDOFF_SUMMARY}\n/e2e:simple-message`);
await contextTrigger.click();
const summaryOption = testPage.getByRole("option", {
name: /Handoff Profile A/,
});
await expect(summaryOption).toBeVisible();
await summaryOption.click();
await expect(prompt).toHaveValue(HANDOFF_SUMMARY, { timeout: 15_000 });
await expect.poll(getSummarizeRequestCount).toBe(1);
await handoffDialog.getByRole("button", { name: "Cancel" }).click();
await expect(handoffDialog).not.toBeVisible({ timeout: 5_000 });
await session.openHandoffDialog(session1Id, profileB.id);
await expect(session.handoffDialog()).toBeVisible({ timeout: 5_000 });
await expect(session.newSessionPromptInput()).toHaveValue("");
await expect(
session.handoffDialog().locator("button").filter({ hasText: "Blank" }),
).toBeVisible();
expect(getSummarizeRequestCount()).toBe(1);
await prompt.fill("/e2e:simple-message");
await session.newSessionStartButton().click();
await expect(session.handoffDialog()).not.toBeVisible({ timeout: 15_000 });
let handoffSessionId: string | undefined;
await expect
.poll(
async () => {
const { sessions: updated } = await apiClient.listTaskSessions(task.id);
return updated.length;
const handoffSession = updated.find(({ id }) => id !== session1Id);
handoffSessionId = handoffSession?.id;
if (!handoffSession) return null;
const { messages } = await apiClient.listSessionMessages(handoffSession.id);
return messages.find((message) => message.author_type === "user")?.content ?? null;
},
{ timeout: 30_000, message: "Waiting for handoff session to be created" },
)
.toBe(2);
.toBe("/e2e:simple-message");
const { sessions: updated } = await apiClient.listTaskSessions(task.id);
const handoffSession = updated.find(({ id }) => id !== session1Id);
expect(handoffSession?.id).toBe(handoffSessionId);
expect(handoffSession?.agent_profile_id).toBe(profileB.id);
expect(getSummarizeRequestCount()).toBe(1);
});
});
@@ -6,6 +6,13 @@ status: complete
# Implementation Plan: Agent Launch Prompt Composer
## Later context-default correction
The [Blank handoff context package](../handoff-blank-context/plan.md) supersedes
this completed package's automatic-summary preservation expectation. Its new
regressions require explicit summary selection. The results below describe the
original delivery and are retained as historical evidence.
## Overview
The New Agent and handoff flows currently render a bespoke `SessionPromptField`, while task creation renders `TaskFormInputs`. The bespoke field independently implements attachments and enhancement but never installs `useTaskCreatePromptMention` or the voice control, so `@saved-prompt` remains literal text in the affected dialog. The repair replaces that duplicate field with `TaskFormInputs` in session mode and adapts the surrounding context, enhancement, and launch hooks to its existing `TaskFormInputsHandle` contract before adding desktop and mobile regressions.
@@ -10,6 +10,10 @@ spec: "../../specs/ui/requirements/agent-launch-prompt-composer.md"
# Task 01: Reuse shared launch composer
The automatic-summary preservation criterion below records the original completed
delivery. It is superseded by [Blank handoff context](../handoff-blank-context/plan.md).
The shared-composer acceptance and historical results remain applicable.
## Acceptance
- New Agent and handoff render `TaskFormInputs` in session mode, exposing saved-prompt autocomplete, attachments, enhancement recovery, and voice without the bespoke `SessionPromptField`.
@@ -10,6 +10,10 @@ spec: "../../specs/ui/requirements/agent-launch-prompt-composer.md"
# Task 02: Cover agent launch flows
The [Blank handoff context package](../handoff-blank-context/plan.md) adds explicit
handoff-context coverage. The completed saved-prompt scenarios and results below
remain historical evidence; they do not verify the new context default.
## Acceptance
- Desktop E2E proves saved-prompt keyboard selection inserts content without launching and explicit Start Agent creates and activates the second session.
+172
View File
@@ -0,0 +1,172 @@
---
created: 2026-09-11
status: complete
requirements:
- REQ-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001
system_design:
- ../../specs/ui/system-design/agent-launch-prompt-composer.md
legacy_specs: []
---
# Implementation Plan: Blank handoff context
## Overview
Start every handoff with Blank context and an empty prompt. Generate summaries
only after explicit context selection. One sequential work order owns the small
correction, regression coverage, and public instructions.
## Confirmed root cause and reproduction
On 2026-09-11, a read-only Node 24 invocation imported the real
`apps/web/components/task/handoff-types.ts` and called
`buildHandoffInitialState({sourceSessionId: "session-a", targetProfileId: "profile-b"})`.
It returned `{selectedProfileId: "profile-b", contextValue: "summarize:session-a"}`,
which conflicts with the requested Blank default.
Source trace: `NewSessionForm` initializes from that helper, then
`useHandoffAutoSummarize` calls `handleContextChange` in a mount effect.
`useSessionContextChange` calls `summarize` for the prefixed value. The summary
hook fetches the transcript and can invoke the summary utility immediately.
The unit test `buildHandoffInitialState selects target profile and summarize context`,
the component test `writes the handoff summary into the fresh-open dialog prompt`,
and desktop `session-handoff.spec.ts` all encode this automatic behavior.
Smallest UI reproduction: open a completed task session, choose Handoff and a
compatible target profile, then observe summary selection/loading without any
context action. A configured utility and nonempty transcript allow generation.
The supplied screenshot shows Blank at capture time; it does not establish the
preceding transition. The helper execution and source trace confirm the defect.
No browser or live-instance reproduction was run for this design-only package.
Classification: intended product behavior changes. The existing requirement's
handoff scenario expressly preserved automatic summaries. The user has now
replaced that expectation; AC-001.9 through AC-001.11 specify the correction.
## Scope
### In scope
- Blank initialization, no summary side effect on opening, and fresh-open reset.
- Explicit summary selection, compatible target preservation, and typed launch.
- Focused desktop/mobile regressions and updated public handoff instructions.
### Out of scope
- Backend APIs, utility configuration, summary content quality, and persistence.
- Profile precedence, environment reuse, subtask and Quick Chat behavior.
- Dialog redesign, new copy/translation keys, or asynchronous summary cancellation.
## Technical approach
In `handoff-types.ts`, return `blank` from `buildHandoffInitialState` while
retaining the target profile. Remove `summarizeContextValue` if the confirmed
reference search still shows only its helper/test consumers.
In `new-session-dialog.tsx`, delete `useHandoffAutoSummarize` and its call;
retain the keyed fresh-open reset and existing profile resolver. Keep
`useSessionContextChange` attached to user selection in `ContextSelect`.
Do not replace the effect with an effect that clears the prompt on mount.
Update both handoff descriptions in `docs/public/sessions-and-review.md`
(a how-to guide): opening selects Blank and the target profile; the user may
type or explicitly choose a session summary before Start Agent.
The completed [composer package](../agent-launch-prompt-composer/plan.md) and its
two done work orders were inventoried. Preserve their historical results;
their automatic-summary preservation scope is superseded by this package.
There is no pending companion work to merge. The ADR index contains no separate
handoff-context decision requiring amendment. This local default correction
does not require a new architectural boundary or ADR.
## ASCII UI preview
UI-01: Handoff context on fresh opening. Desktop enters from session-tab actions;
phone enters from the mobile sessions action menu. Both use the existing dialog.
```text
Before (source-confirmed): After (desktop and phone):
Hand off to <target> Hand off to <target>
Environment / Agent Profile Environment / Agent Profile
Context [Summarizing... v] Context [Blank v]
Prompt [generated summary] Prompt [empty ]
[Cancel] [Start Agent: disabled]
After explicit summary choice:
Context [Summarizing... v] -> [Summarize <chosen session> v]
Prompt [summary text, editable]
[Cancel] [Start Agent]
```
The Before prompt represents successful completion of the automatic request.
Blank, empty prompt, target preservation, and explicit selection are required
structure (AC-001.6, .9-.11); spacing is illustrative. No geometry changes are
planned. Phone retains the same vertical form and existing footer/scroll
behavior, with touch selection through its current session entry. The focused
phone test checks reachable actions, containment, and no horizontal overflow.
Manual summary failure retains existing Blank recovery and error feedback.
## Tests
All criteria below belong to REQ-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.
| Criteria | File under apps/web | Planned evidence |
| --- | --- | --- |
| .9, .11 | components/task/handoff-types.test.ts | `buildHandoffInitialState selects target profile and blank context` fails before correction |
| .6, .9-.11 | components/task/new-session-dialog.test.tsx | `opens handoff with Blank context without summarizing`; empty prompt, disabled launch, target retained; close/reopen and source/target changes reset; rerender/session hydration does not summarize or erase typed draft |
| .4, .10 | components/task/new-session-dialog.test.tsx | Explicit source and other-session summary choices invoke only the chosen session; copy and Blank remain manual |
| .4-.6 | components/task/new-session-form-actions.test.ts; components/task/session-context-summary.test.ts | Retain manual summary result/failure and current prompt submission coverage |
## E2E tests
Update existing `apps/web/e2e/tests/session/session-handoff.spec.ts` in `chromium`
and `mobile-handoff.spec.ts` in `mobile-chrome`. Arm summary utility request
counting before opening. Assert Blank, empty prompt, correct target, disabled
Start Agent, and no summary execution before any context selection. Type and
launch without a summary. Separately reopen after a summary choice and prove
Blank reset; explicitly select the source summary and prove one request plus
editable summary text. Mobile uses touch selection and proves launch outcome.
These scenarios cover AC-001.6, .7, and .9-.11.
Count only `builtin-summarize-session` requests, so unrelated utility traffic
does not fail the test. Use the existing causal-wait helpers for selected-summary
responses and documented negative assertions. Preserve existing unhealthy-profile
coverage, which remains a focused compatibility check.
## Work orders
- [done] [Task 01: Make handoff context explicit](task-01-explicit-context.md)
## Verification results
Implementation evidence is complete. The helper and mount-effect causes were
removed, explicit context actions remain manual, and the public handoff guide
now describes the delivered Blank default.
- TDD RED: the helper expectation failed against `summarize:session-a`; the
unchanged automatic-summary dialog test then failed after the helper was
corrected, confirming both causes before the final production change.
- `pnpm install --frozen-lockfile`: passed from `apps`.
- Targeted Vitest suite: 5 files, 38 tests passed for the implementation;
the post-review fixup suite passed with 5 files and 39 tests.
- `pnpm run typecheck`: passed.
- Targeted ESLint: the implementation passed with 0 errors and 2 duplicate-
string warnings in `new-session-dialog.test.tsx`; the final fixup passed
with 0 errors and 0 warnings after deduplicating those test values.
- `pnpm run i18n:ratchet`: passed; 0 new violations and guard allowlist intact.
- Desktop managed E2E: 2 tests passed, including handoff and unhealthy-profile
coverage.
- Mobile managed E2E: 1 test passed on `mobile-chrome`, including touch launch
and the no-horizontal-overflow assertion.
- Public-doc validation: 62 tests passed and 46 published pages validated.
- Specification tests: 36 passed; all specification files passed lint.
- `git diff --check`: passed.
- The managed E2E runners completed isolated cleanup without repository or
instance artifacts. The tracked work-order package is the only plan output.
- Owning UI index: 14,745 bytes, within the configured 16 KiB limit.
## Risks
- Changing only the displayed default leaves the mount action in place; remove both.
- Existing automatic-summary tests must become explicit-choice tests, not be dropped.
- Keep compatible target selection independent from Blank context initialization.
- Public docs must ship with implementation, not claim pending behavior is live.
@@ -0,0 +1,170 @@
---
id: "01-explicit-context"
title: "Make handoff context explicit"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001
acceptance_criteria:
- AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.4
- AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.6
- AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.7
- AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.9
- AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.10
- AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.11
system_design:
- ../../specs/ui/system-design/agent-launch-prompt-composer.md
---
# Task 01: Make handoff context explicit
## Summary
Initialize handoff context as Blank and remove automatic context-action execution.
Preserve explicit context actions and target-profile selection on both viewports.
## In scope
- Implement the helper and mount-effect correction described in the plan.
- Update the existing unit/component and desktop/mobile handoff regressions.
- Update both public handoff descriptions with the delivered behavior.
## Out of scope
Backend changes, stored preferences, profile precedence, dialog layout changes,
new translations, and summary cancellation or stale-result redesign.
## Acceptance
- Fresh opening, reopening, and new handoff identity show Blank with an empty
prompt, preserve compatible target choice, and perform no automatic summary.
Ordinary rerenders and data hydration preserve typed text without summary work.
- Explicit context selection retains copy, Blank, selected-session summary,
loading/failure behavior, and launch guards. Typed launch needs no summary.
- Desktop and phone E2E prove the outcomes in the plan's scenario matrix;
public instructions describe the delivered default and optional summary action.
## ASCII UI preview
UI-01 from the [full preview](plan.md#ascii-ui-preview), AC-001.6 and .9-.11.
Shared desktop/phone form; phone entry uses the existing mobile session actions.
```text
Hand off to <target>
Environment / Agent Profile
Context [Blank v]
Prompt [empty]
[Cancel] [Start Agent: disabled]
```
Only user selection changes context to a session summary. Retain the current
phone form geometry and touch treatment. Compare both rendered views with this
structure during the focused E2E runs.
## Verification
Start with TDD: change the helper expectation to Blank and replace the automatic
component test with `opens handoff with Blank context without summarizing`.
Confirm a behavioral RED (actual summarize value/call), then remove both causes.
Add explicit-summary, reopen, alternate-session, hydration, and typed-launch
coverage specified by the plan. Extend the existing context-selector mock to
expose selected value and explicit summary actions if needed.
Run from repository root. Install dependencies once before package commands.
Managed E2E rebuilds production assets and owns isolated instance cleanup.
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm exec vitest run components/task/handoff-types.test.ts components/task/new-session-dialog.test.tsx components/task/new-session-form-actions.test.ts components/task/session-context-summary.test.ts components/task/new-session-profile-selection.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/task/handoff-types.ts components/task/handoff-types.test.ts components/task/new-session-dialog.tsx components/task/new-session-dialog.test.tsx e2e/tests/session/session-handoff.spec.ts e2e/tests/session/mobile-handoff.spec.ts)
(cd apps/web && pnpm run i18n:ratchet)
(cd apps/web && pnpm e2e:run --project chromium tests/session/session-handoff.spec.ts tests/session/session-handoff-unhealthy-profile.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/session/mobile-handoff.spec.ts)
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
python3 scripts/lint-spec-files.test.py
python3 scripts/lint-spec-files.py --all
git diff --check
git status --short -- docs/plans/handoff-blank-context
```
If supporting test helpers change, include them in targeted lint and test checks.
Record RED and final results, discovered test counts, rendered comparison, and
cleanup. Do not mark done if a required command fails or no tests are discovered.
## Files likely touched
- `apps/web/components/task/handoff-types.ts`
- `apps/web/components/task/handoff-types.test.ts`
- `apps/web/components/task/new-session-dialog.tsx`
- `apps/web/components/task/new-session-dialog.test.tsx`
- `apps/web/e2e/tests/session/session-handoff.spec.ts`
- `apps/web/e2e/tests/session/mobile-handoff.spec.ts`
- `docs/public/sessions-and-review.md`
- This work order and `plan.md` for execution status and results.
## Dependencies
None. Start after the user's explicit implementation request.
## Risks
The old tests encode automatic summaries. Retain their summary-delivery coverage
by selecting context explicitly. Keep session/profile initialization and the
existing form key intact. Count only summary utility requests in browser checks.
## Parallelism
`sequential`
## Inputs
- [Requirement](../../specs/ui/requirements/agent-launch-prompt-composer.md), AC-001.4, .6-.7, .9-.11.
- [Design](../../specs/ui/system-design/agent-launch-prompt-composer.md), Context lifecycle and Responsive behavior.
- `new-session-form-actions.ts`, `session-dialog-shared.tsx`, and `hooks/use-summarize-session.ts` for explicit-action behavior.
- Existing handoff E2E files and `e2e/pages/session-page.ts` for desktop/mobile entry helpers.
- Use `/tdd`, `/e2e`, and `/docs-maintainer` during execution.
## Results
Implemented the correction in the shared handoff composer. `buildHandoffInitialState`
now preserves the target profile while selecting `blank`; the automatic mount
effect was removed, so only an explicit ContextSelect action can request a
summary. Desktop and phone regressions cover fresh open, explicit summary
selection, reopen reset, typed launch, target preservation, and phone overflow.
TDD evidence:
- RED: the updated helper test failed before the helper change because the
actual value was `summarize:session-a`.
- RED: after the helper change, the unchanged dialog test failed because the
old mount effect no longer received an automatic summary call.
- GREEN: the implementation targeted suite passed with 5 files and 38 tests;
the post-review fixup suite passed with 5 files and 39 tests.
Validation:
- `pnpm install --frozen-lockfile`: passed from `apps`.
- `pnpm exec vitest run ...`: 5 files, 38 tests passed for the implementation.
- Post-review fixup targeted suite: 5 files, 39 tests passed after adding the
alternate-session summary regression.
- `pnpm run typecheck`: passed.
- Targeted ESLint: the implementation passed with 0 errors and 2 duplicate-
string warnings in `new-session-dialog.test.tsx`; the final fixup passed
with 0 errors and 0 warnings after deduplicating those test values.
- `pnpm run i18n:ratchet`: passed with 0 new violations.
- The exact desktop command passed with 2 tests: handoff and
unhealthy-profile compatibility.
- The mobile command passed with 1 `mobile-chrome` test, including touch
selection, typed launch, and horizontal-overflow checks.
- `node --test scripts/validate-public-docs.test.mjs`: 62 tests passed.
- `node scripts/validate-public-docs.mjs`: 46 published pages validated.
- `python3 scripts/lint-spec-files.test.py`: 36 tests passed.
- `python3 scripts/lint-spec-files.py --all`: passed.
- `git diff --check`: passed.
The E2E runs used the managed isolated runner and left no instance artifacts.
The public documentation correction is included in this implementation package.
+2 -2
View File
@@ -40,7 +40,7 @@ The profile picker shows only profiles compatible with the task executor. If non
| **Copy initial prompt** | Copies the first user message from the currently active session into the editable prompt field | A parallel approach; it is not guaranteed to be the task's original description, so inspect and edit it before launch |
| **Summarize a session** | Inserts a utility-agent summary of the selected conversation into the editable prompt field | Continue or branch from work already discussed |
**Handoff** from an existing session opens the same dialog and selects a summary of that session. Summarization requires a working `summarize-session` utility agent. Review generated summaries: they can omit constraints or decisions.
**Handoff** from an existing session opens the same dialog with Blank context and an empty prompt. Select a session summary when you need earlier discussion. Summarization requires a working `summarize-session` utility agent. Review generated summaries: they can omit constraints or decisions.
Prompts support pasted, dropped, or selected attachments. A prompt can contain at most 10 files, with a limit of 10 MiB per file and 20 MiB in total. The prompt itself is required.
@@ -56,7 +56,7 @@ Right-click an agent tab on desktop to manage it. Available actions depend on it
| **Resume** | Attempts to continue a completed, failed, or cancelled session |
| **Delete** | Permanently removes the conversation; if it was primary, another session is promoted when possible. The task workspace and its files are kept; a later session reuses them |
| **Share** | Opens the publishing preview for an eligible session |
| **Handoff** | Starts another session with a generated summary of this conversation |
| **Handoff** | Opens the launch dialog with Blank context. Select a summary when you want to include this conversation |
| **Close Others** | Closes other visible agent panels without deleting their sessions |
Stopping is not deletion. Resume succeeds only while the executor still has the session record needed to continue. A removed worktree, expired remote environment, restarted executor, removed profile, or missing runtime record can force a fresh session instead. The failure banner offers **Start fresh** when continuation is unavailable.
+2
View File
@@ -154,6 +154,8 @@ runtime, and workspace lifecycle/state ownership.
- [WebSocket Connectivity Warning](requirements/ws-connectivity-warning.md)
### Design
- [Agent launch prompt composer](system-design/agent-launch-prompt-composer.md)
- [Control sizing](system-design/control-sizing.md)
- [Adaptive Kanban](system-design/adaptive-kanban.md)
- [Clarification submit feedback](system-design/clarification-submit-feedback.md)
@@ -2,7 +2,7 @@
status: active
system: ui
created: 2026-08-02
updated: 2026-08-23
updated: 2026-09-11
owners:
- kandev
---
@@ -28,6 +28,9 @@ Starting another agent inside an existing task is another prompt-authoring flow,
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.6:** The Start Agent action remains disabled while the prompt is empty, while context is being summarized, while launch is in progress, or when no compatible agent profile is available.
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.7:** Desktop and phone layouts expose the same capabilities and launch result. On phone, the existing mobile sessions entry point opens the dialog; the prompt menu follows the shared [composer suggestion overlay requirements](composer-suggestion-overlays.md), and the controls remain touch reachable without horizontal document overflow.
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.8:** Agent launch continues to use the existing session launch contract, task environment, context selection, profile compatibility rules, and guarded enhancement delivery.
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.9:** Each fresh opening of New Agent or handoff shall select Blank with an empty prompt. Reopening after a previous context choice shall also start Blank. Opening shall not generate a summary or launch an agent.
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.10:** A session summary shall start only when the user explicitly selects a session summary option. The selected session supplies the summary. Opening, profile selection, and session-list updates shall not select context or start summarization.
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.11:** On desktop and phone, handoff shall retain the selected compatible target profile while starting with Blank context. Users can type a prompt and launch without configuring or invoking summarization.
## Migrated source detail
@@ -62,10 +65,25 @@ Starting another agent inside an existing task is another prompt-authoring flow,
- **GIVEN** voice mode is configured, **WHEN** the user dictates in the launch composer, **THEN** the transcript is inserted at the caret and configured auto-send starts the agent only after non-empty text was inserted.
- **GIVEN** a prior task prompt or session summary, **WHEN** the user selects that context, **THEN** the resulting text appears in the shared composer and remains editable with saved-prompt, attachment, enhancement, and voice controls available.
- **GIVEN** a phone viewport, **WHEN** the user opens New Agent from the mobile session controls, inserts a saved prompt by touch, and starts the agent, **THEN** the dialog remains viewport-contained without document horizontal overflow and the new session becomes active.
- **GIVEN** a session handoff, **WHEN** the handoff dialog opens, **THEN** it exposes the same shared launch composer while preserving the existing automatic summary and target-profile behavior.
- **GIVEN** a session handoff, **WHEN** the handoff dialog opens, **THEN** it exposes an empty shared launch composer with Blank context and preserves compatible target-profile selection. Summary generation requires a subsequent explicit context selection.
## Compatibility
The 2026-09-11 correction replaces automatic handoff summaries with explicit
context selection, as requested by the user. Existing summary generation,
copy-prompt selection, profile compatibility, and launch contracts remain available.
## System design
- [Agent launch prompt composer](../system-design/agent-launch-prompt-composer.md)
## Implementation Plans
- [Shared composer delivery](../../../plans/agent-launch-prompt-composer/plan.md) records the completed composer integration.
- [Blank handoff context](../../../plans/handoff-blank-context/plan.md) delivers AC-001.9 through AC-001.11 and supersedes the earlier automatic-summary expectation.
## Out of scope
- Changing saved-prompt storage, mention search, attachment limits, utility-agent enhancement, voice settings, or the session launch API.
- Changing task creation, subtask creation, task chat, Quick Chat, context selection semantics, agent profile selection, or environment reuse.
- Changing task creation, subtask creation, task chat, Quick Chat, explicit context-action results, agent profile selection, or environment reuse.
- Adding rich inline chips or external `#` entity references to the plain launch prompt.
@@ -0,0 +1,87 @@
---
status: current
system: ui
created: 2026-09-11
updated: 2026-09-11
owners:
- kandev
requirements:
- REQ-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001
---
# Agent launch prompt composer
## Boundary
This design extends the existing UI-owned launch-composer interaction contract.
It owns prompt initialization and user context selection in New Agent and handoff.
Task environment reuse and agent profile eligibility remain owned by their
[task](../../tasks/system-design/additional-session-workspace-reuse.md) and
[agent](../../agents/system-design/profile-recent-use.md) contracts.
## Components
`NewSessionDialog` in `apps/web/components/task/new-session-dialog.tsx` renders
`NewSessionForm` keyed by task, open state, and handoff source/target identity.
The form renders shared `TaskFormInputs` in session mode and reads its current
prompt and attachments through `TaskFormInputsHandle`.
`buildHandoffInitialState` in `handoff-types.ts` returns the target profile and
`contextValue: "blank"`. The source session remains part of `HandoffPreset` and
the form identity; it does not authorize summary generation.
`useSessionProfileSelection` independently preserves the existing compatible
handoff target and fallback behavior.
## Context lifecycle
The form initializes Blank and an empty composer for every fresh opening.
Keep the keyed reset on closing/reopening or changing handoff identity.
Ordinary rerenders and session/profile data hydration preserve the current draft.
Remove `useHandoffAutoSummarize` and its invocation. Mounting the form must not
call `useSessionContextChange`'s returned action. Only `ContextSelect` user
selection invokes that action:
- `blank` clears the composer and prompt-presence state.
- `copy_prompt` inserts the existing initial prompt.
- `summarize:<sessionId>` invokes `useSummarizeSession` for that selected session.
The summary hook reads the selected transcript and calls the existing
`builtin-summarize-session` utility. Opening the form may load session options,
but must not request a transcript for automatic summary generation or execute
the summary utility. There is no stored context preference or migration.
## Composer, launch, and failure behavior
Retain `useSessionPromptController` enhancement delivery and recovery.
`useSessionLaunchSubmit` reads the current shared handle and uses the existing
launch service. Empty-prompt, busy, attachment-upload, and compatible-profile
guards remain in force. Manual summary loading and
`applySummarizeSessionResult` success/failure behavior remain in force, including
Blank recovery and the existing error toast on failure. Broader asynchronous
summary cancellation or stale-result handling is outside this change.
## Responsive behavior
Desktop session actions and phone session actions open the same `NewSessionDialog`.
The nearest shipped phone exemplar is `mobile-handoff.spec.ts`, using
`SessionPage.openMobileHandoffDialog` through the mobile sessions controls.
This correction changes shared initialization only. It retains the existing
dialog, control order, scroll behavior, and touch presentation. Explicit summary
selection and typed-prompt launch receive desktop and Pixel 5 coverage.
Reuse the existing localized Blank and summary labels.
## Requirement mapping and evidence
| Acceptance criteria | Design boundary | Evidence |
| --- | --- | --- |
| AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.1 through .5 | Shared composer and launch | Existing composer and action tests |
| AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.6 through .8 | Guards and responsive launch | Dialog tests and desktop/mobile session E2E |
| AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.9 | Fresh-open initialization | Helper and dialog regressions, reopen E2E |
| AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.10 | Explicit context action | Summary spy/request counting and manual selection |
| AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.11 | Independent profile selection | Target preservation and typed-prompt launch on both viewports |
## Implementation Plans
[Blank handoff context](../../../plans/handoff-blank-context/plan.md) records the
completed correction and its implementation evidence.