Skip to content

Commit fdd65fc

Browse files
authored
Improve chat handoff launch UI (#639)
1 parent 4b203aa commit fdd65fc

18 files changed

Lines changed: 541 additions & 26 deletions

apps/desktop/src/main/services/chat/agentChatService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19266,6 +19266,7 @@ export function createAgentChatService(args: {
1926619266
sessionId: created.id,
1926719267
text: buildHandoffPrompt(brief),
1926819268
displayText: "Chat handoff from previous session",
19269+
metadata: { kind: "handoff", hideFullPrompt: true },
1926919270
reasoningEffort: targetReasoningEffort,
1927019271
executionMode: createdManaged.session.executionMode ?? null,
1927119272
interactionMode: createdManaged.session.interactionMode ?? null,

apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,58 @@ describe("AgentChatMessageList transcript rendering", () => {
494494
expect(screen.getByText("Full handoff prompt with all implementation details.")).toBeTruthy();
495495
});
496496

497+
it("hides the full handoff prompt when handoff metadata marks it internal", async () => {
498+
renderMessageList([
499+
{
500+
sessionId: "session-1",
501+
timestamp: "2026-03-17T10:00:00.000Z",
502+
event: {
503+
type: "user_message",
504+
text: "This message was injected automatically by ADE during a chat handoff.\n\nSecret implementation brief.",
505+
displayText: "Chat handoff from previous session",
506+
metadata: { kind: "handoff", hideFullPrompt: true },
507+
},
508+
},
509+
]);
510+
511+
await waitFor(() => {
512+
expect(screen.getByText("Chat handoff from previous session")).toBeTruthy();
513+
});
514+
expect(screen.queryByText("Full prompt")).toBeNull();
515+
expect(screen.queryByText(/Secret implementation brief/)).toBeNull();
516+
});
517+
518+
it("does not fall back to hidden handoff prompt text when display text is missing", async () => {
519+
const writeText = vi.fn().mockResolvedValue(undefined);
520+
Object.defineProperty(navigator, "clipboard", {
521+
configurable: true,
522+
value: { writeText },
523+
});
524+
525+
renderMessageList([
526+
{
527+
sessionId: "session-1",
528+
timestamp: "2026-03-17T10:00:00.000Z",
529+
event: {
530+
type: "user_message",
531+
text: "Internal handoff prompt that should never be exposed.",
532+
metadata: { kind: "handoff", hideFullPrompt: true },
533+
},
534+
},
535+
]);
536+
537+
await waitFor(() => {
538+
expect(screen.queryByText(/Internal handoff prompt/)).toBeNull();
539+
});
540+
expect(screen.queryByText("Full prompt")).toBeNull();
541+
542+
fireEvent.click(screen.getByRole("button", { name: "Copy message" }));
543+
544+
await waitFor(() => {
545+
expect(writeText).toHaveBeenCalledWith("");
546+
});
547+
});
548+
497549
it("shows attachment and simulator send confirmations for delivered user messages with context", async () => {
498550
renderMessageList([
499551
{

apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2298,10 +2298,17 @@ function renderEvent(
22982298
<span aria-hidden></span>
22992299
</button>
23002300
) : null}
2301-
<MessageCopyButton value={event.text} />
2301+
<MessageCopyButton value={event.metadata?.hideFullPrompt === true ? (event.displayText?.trim() ?? "") : event.text} />
23022302
</div>
23032303
{(() => {
23042304
const displayText = event.displayText?.trim();
2305+
if (event.metadata?.hideFullPrompt === true) {
2306+
return displayText ? (
2307+
<div className="whitespace-pre-wrap break-words text-[length:var(--chat-font-size)] font-medium leading-[1.7] text-white">
2308+
{displayText}
2309+
</div>
2310+
) : null;
2311+
}
23052312
if (displayText && displayText !== event.text.trim()) {
23062313
return (
23072314
<div className="space-y-2 text-[length:var(--chat-font-size)] leading-[1.7] text-white">

apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,8 @@ function installAdeMocks(options?: {
453453
steerError?: Error;
454454
listError?: Error;
455455
createError?: Error;
456-
handoffResult?: { session: AgentChatSession; usedFallbackSummary: boolean };
456+
handoffResult?: { session: AgentChatSession; usedFallbackSummary: boolean } | Promise<{ session: AgentChatSession; usedFallbackSummary: boolean }>;
457+
handoffError?: Error;
457458
sessions?: AgentChatSessionSummary[];
458459
eventHistory?: AgentChatEventHistorySnapshot | ((args: { sessionId: string; maxEvents?: number }) => Promise<AgentChatEventHistorySnapshot> | AgentChatEventHistorySnapshot);
459460
includeClaudeModel?: boolean;
@@ -470,10 +471,13 @@ function installAdeMocks(options?: {
470471
const list = options?.listError
471472
? vi.fn().mockRejectedValue(options.listError)
472473
: vi.fn().mockResolvedValue(options?.sessions ?? [buildSession("session-1")]);
473-
const handoff = vi.fn().mockResolvedValue(options?.handoffResult ?? {
474+
const defaultHandoffResult = {
474475
session: buildCreatedSession("handoff-session-1"),
475476
usedFallbackSummary: false,
476-
});
477+
};
478+
const handoff = options?.handoffError
479+
? vi.fn().mockRejectedValue(options.handoffError)
480+
: vi.fn().mockImplementation(() => options?.handoffResult ?? Promise.resolve(defaultHandoffResult));
477481
const create = options?.createError
478482
? vi.fn().mockRejectedValue(options.createError)
479483
: vi.fn().mockImplementation(async (args: Record<string, unknown> = {}) => {
@@ -705,6 +709,7 @@ function resetChatTestStore() {
705709
workViewByProject: {},
706710
laneWorkViewByScope: {},
707711
draftLaunchJobsByScope: {},
712+
handoffLaunchJobsByScope: {},
708713
});
709714
}
710715

@@ -3070,7 +3075,48 @@ describe("AgentChatPane submit recovery", () => {
30703075
cursorModeId: "agent",
30713076
cursorConfigValues: {},
30723077
}));
3073-
expect(onSessionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "session-2" }));
3078+
expect(onSessionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "session-2" }), { source: "handoff" });
3079+
});
3080+
});
3081+
3082+
it("tracks an ephemeral sidebar handoff launch job while handoff is in flight", async () => {
3083+
const session = buildSession("session-1", { status: "idle" });
3084+
let resolveHandoff!: (result: { session: AgentChatSession; usedFallbackSummary: boolean }) => void;
3085+
const pendingHandoff = new Promise<{ session: AgentChatSession; usedFallbackSummary: boolean }>((resolve) => {
3086+
resolveHandoff = resolve;
3087+
});
3088+
installAdeMocks({
3089+
handoffResult: pendingHandoff,
3090+
});
3091+
3092+
renderPane(session);
3093+
3094+
fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" }));
3095+
fireEvent.click(await screen.findByRole("button", { name: "Handoff" }));
3096+
fireEvent.click(await screen.findByRole("button", { name: "Create handoff chat" }));
3097+
3098+
await waitFor(() => {
3099+
const jobs = Object.values(useAppStore.getState().handoffLaunchJobsByScope).flat();
3100+
expect(jobs).toHaveLength(1);
3101+
expect(jobs[0]).toEqual(expect.objectContaining({
3102+
sourceSessionId: session.sessionId,
3103+
laneId: session.laneId,
3104+
targetModelLabel: "GPT-5.4-Mini",
3105+
targetToolType: "codex-chat",
3106+
status: "preparing-summary",
3107+
}));
3108+
});
3109+
3110+
await act(async () => {
3111+
resolveHandoff({
3112+
session: buildCreatedSession("session-2"),
3113+
usedFallbackSummary: false,
3114+
});
3115+
await pendingHandoff;
3116+
});
3117+
3118+
await waitFor(() => {
3119+
expect(Object.values(useAppStore.getState().handoffLaunchJobsByScope).flat()).toHaveLength(0);
30743120
});
30753121
});
30763122

apps/desktop/src/renderer/components/chat/AgentChatPane.tsx

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ import {
171171
type NativeControlState,
172172
type PreparedDraftLaunch,
173173
} from "../../lib/draftLaunchJobs";
174+
import {
175+
buildHandoffLaunchJobsScopeKey,
176+
createHandoffLaunchJobId,
177+
type HandoffLaunchJob,
178+
type HandoffLaunchJobStatus,
179+
} from "../../lib/handoffLaunchJobs";
174180
import {
175181
createAppControlContextInstanceId,
176182
createBuiltInBrowserContextInstanceId,
@@ -2924,6 +2930,15 @@ export function AgentChatPane({
29242930
) => {
29252931
rootAppStoreApi.getState().setDraftLaunchJobs(draftLaunchJobsScopeKey, next);
29262932
}, [draftLaunchJobsScopeKey]);
2933+
const handoffLaunchJobsScopeKey = useMemo(
2934+
() => buildHandoffLaunchJobsScopeKey({ projectBinding, projectRoot }),
2935+
[projectBinding, projectRoot],
2936+
);
2937+
const setHandoffLaunchJobs = useCallback((
2938+
next: HandoffLaunchJob[] | ((prev: HandoffLaunchJob[]) => HandoffLaunchJob[]),
2939+
) => {
2940+
rootAppStoreApi.getState().setHandoffLaunchJobs(handoffLaunchJobsScopeKey, next);
2941+
}, [handoffLaunchJobsScopeKey]);
29272942
const hasActiveDraftLaunchJobs = useMemo(
29282943
() => draftLaunchJobs.some((job) => !isDraftLaunchJobTerminal(job.status)),
29292944
[draftLaunchJobs],
@@ -3033,6 +3048,7 @@ export function AgentChatPane({
30333048
const [loading, setLoading] = useState(false);
30343049
const [preferencesReady, setPreferencesReady] = useState(false);
30353050
const [error, setError] = useState<string | null>(null);
3051+
const handoffErrorClearTimerRef = useRef<number | null>(null);
30363052
const [deletingChatSessionId, setDeletingChatSessionId] = useState<string | null>(null);
30373053
const [computerUseSnapshot, setComputerUseSnapshot] = useState<ComputerUseOwnerSnapshot | null>(null);
30383054
const [chatActionsOpen, setChatActionsOpen] = useState(
@@ -3064,6 +3080,12 @@ export function AgentChatPane({
30643080
});
30653081
return () => { cancelled = true; unsubscribe(); };
30663082
}, [laneId]);
3083+
useEffect(() => () => {
3084+
if (handoffErrorClearTimerRef.current != null) {
3085+
window.clearTimeout(handoffErrorClearTimerRef.current);
3086+
handoffErrorClearTimerRef.current = null;
3087+
}
3088+
}, []);
30673089
const [chatActionsTab, setChatActionsTab] = useState<ChatActionsTab>(
30683090
() => readChatCompanionUiState(initialCompanionStateKey).chatActionsTab,
30693091
);
@@ -7465,8 +7487,36 @@ export function AgentChatPane({
74657487

74667488
const handoffSession = useCallback(async (mode: "brief" | "fork" = "brief") => {
74677489
if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked) return;
7490+
const sourceLaneId = selectedSession?.laneId ?? laneId;
7491+
if (!sourceLaneId) return;
7492+
const jobId = createHandoffLaunchJobId();
7493+
const stageTimerIds: number[] = [];
7494+
const patchHandoffJob = (status: HandoffLaunchJobStatus) => {
7495+
setHandoffLaunchJobs((current) => current.map((job) => (
7496+
job.id === jobId ? { ...job, status } : job
7497+
)));
7498+
};
7499+
const targetModelLabel = handoffTargetDescriptor?.displayName
7500+
?? formatLocalModelLabel(handoffModelId);
7501+
setHandoffLaunchJobs((current) => [
7502+
{
7503+
id: jobId,
7504+
sourceSessionId: selectedSessionId,
7505+
laneId: sourceLaneId,
7506+
laneName: laneDisplayLabel ?? sourceLaneId,
7507+
targetModelId: handoffModelId,
7508+
targetModelLabel,
7509+
targetToolType: handoffTargetProvider ? chatToolTypeForProvider(handoffTargetProvider) : "other",
7510+
status: "preparing-summary",
7511+
createdAtMs: Date.now(),
7512+
},
7513+
...current.filter((job) => job.sourceSessionId !== selectedSessionId),
7514+
]);
74687515
setError(null);
74697516
setHandoffBusy(true);
7517+
setChatActionsOpen(false);
7518+
stageTimerIds.push(window.setTimeout(() => patchHandoffJob("creating-chat"), 700));
7519+
stageTimerIds.push(window.setTimeout(() => patchHandoffJob("sending-handoff"), 1500));
74707520
try {
74717521
const resolvedHandoffPermissionMode = handoffNativePermissionMode ?? selectedSession?.permissionMode;
74727522
const result = await window.ade.agentChat.handoff({
@@ -7487,13 +7537,22 @@ export function AgentChatPane({
74877537
cursorModeId: handoffCursorModeId,
74887538
cursorConfigValues: handoffCursorConfigValues,
74897539
});
7490-
setChatActionsOpen(false);
7491-
notifySessionCreated(result.session);
7540+
notifySessionCreated(result.session, { source: "handoff" });
74927541
invalidateCurrentChatSessionList();
74937542
void refreshSessions({ force: true }).catch(() => {});
74947543
} catch (handoffError) {
7495-
setError(handoffError instanceof Error ? handoffError.message : String(handoffError));
7544+
const message = handoffError instanceof Error ? handoffError.message : String(handoffError);
7545+
setError(message);
7546+
if (handoffErrorClearTimerRef.current != null) {
7547+
window.clearTimeout(handoffErrorClearTimerRef.current);
7548+
}
7549+
handoffErrorClearTimerRef.current = window.setTimeout(() => {
7550+
handoffErrorClearTimerRef.current = null;
7551+
setError((current) => (current === message ? null : current));
7552+
}, 6000);
74967553
} finally {
7554+
stageTimerIds.forEach((timerId) => window.clearTimeout(timerId));
7555+
setHandoffLaunchJobs((current) => current.filter((job) => job.id !== jobId));
74977556
setHandoffBusy(false);
74987557
}
74997558
}, [
@@ -7502,6 +7561,7 @@ export function AgentChatPane({
75027561
handoffClaudePermissionMode,
75037562
handoffCodexApprovalPolicy,
75047563
handoffCodexConfigSource,
7564+
handoffTargetDescriptor,
75057565
handoffFastMode,
75067566
handoffCodexSandbox,
75077567
handoffCursorConfigValues,
@@ -7513,10 +7573,14 @@ export function AgentChatPane({
75137573
handoffReasoningEffort,
75147574
handoffTargetProvider,
75157575
invalidateCurrentChatSessionList,
7576+
laneDisplayLabel,
7577+
laneId,
75167578
notifySessionCreated,
75177579
refreshSessions,
75187580
selectedSession?.permissionMode,
7581+
selectedSession?.laneId,
75197582
selectedSessionId,
7583+
setHandoffLaunchJobs,
75207584
]);
75217585

75227586
const handleDeleteSelectedChat = useCallback(() => {

apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,38 @@ describe("chatUserMinimap.logic", () => {
6464
expect(entries.map((e) => e.fullUserOrdinal)).toEqual([0, 1]);
6565
});
6666

67+
it("uses display text for hidden handoff prompts and skips empty hidden prompts", () => {
68+
const rows: ChatTranscriptGroupedEnvelope[] = [
69+
{
70+
key: "handoff-display",
71+
timestamp: "2026-01-01T00:00:00.000Z",
72+
event: {
73+
type: "user_message",
74+
text: "Internal handoff prompt that should not appear in the minimap.",
75+
displayText: "Chat handoff from previous session",
76+
metadata: { kind: "handoff", hideFullPrompt: true },
77+
},
78+
},
79+
{
80+
key: "handoff-hidden",
81+
timestamp: "2026-01-01T00:00:01.000Z",
82+
event: {
83+
type: "user_message",
84+
text: "Internal handoff prompt without display text.",
85+
metadata: { kind: "handoff", hideFullPrompt: true },
86+
},
87+
},
88+
userRow("normal prompt", "normal"),
89+
];
90+
91+
const entries = collectUserMessageMinimapSourceEntries(rows);
92+
93+
expect(entries.map((entry) => entry.preview)).toEqual([
94+
"Chat handoff from previous session",
95+
"normal prompt",
96+
]);
97+
});
98+
6799
it("samples when above max markers", () => {
68100
const entries = Array.from({ length: 100 }, (_, i) => ({
69101
rowIndex: i,

apps/desktop/src/renderer/components/chat/chatUserMinimap.logic.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ export function collectUserMessageMinimapSourceEntries(
4646
const row = groupedRows[rowIndex]!;
4747
if (!isTimelineUserMessageRow(row)) continue;
4848
const event = row.event;
49-
const preview = summarizeInlineText(event.text ?? "", PREVIEW_MAX_CHARS);
49+
const hideFullPrompt = event.metadata?.hideFullPrompt === true;
50+
const previewSource = hideFullPrompt ? (event.displayText?.trim() ?? "") : (event.text ?? "");
51+
const preview = summarizeInlineText(previewSource, PREVIEW_MAX_CHARS);
52+
if (hideFullPrompt && preview.length === 0) continue;
5053
out.push({
5154
rowIndex,
5255
key: row.key,

apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,31 @@ describe("SessionListPane", () => {
110110
expect(screen.getByText("Mobile Tool Streaming UI")).toBeTruthy();
111111
});
112112

113+
it("renders in-flight handoff placeholders in the matching lane group", () => {
114+
renderPane({
115+
runningFiltered: [],
116+
sessionsGroupedByLane: new Map(),
117+
handoffJobs: [
118+
{
119+
id: "handoff-job-1",
120+
sourceSessionId: "source-session",
121+
laneId: "lane-known",
122+
laneName: "Known Lane",
123+
targetModelId: "openai/gpt-5.4-mini",
124+
targetModelLabel: "GPT-5.4-Mini",
125+
targetToolType: "codex-chat",
126+
status: "creating-chat",
127+
createdAtMs: Date.now(),
128+
},
129+
],
130+
});
131+
132+
expect(screen.getByTestId("handoff-launch-placeholder")).toBeTruthy();
133+
expect(screen.getByText("Handoff to GPT-5.4-Mini")).toBeTruthy();
134+
expect(screen.getByText("Creating chat...")).toBeTruthy();
135+
expect(screen.getByText("First message: Chat handoff from previous session")).toBeTruthy();
136+
});
137+
113138
it("lets the user set the group organization from the filter panel", () => {
114139
const setSessionListOrganization = vi.fn();
115140
const view = renderPane({ setSessionListOrganization });

0 commit comments

Comments
 (0)