diff --git a/apps/desktop/src/components/onboarding-chat/cards/build.tsx b/apps/desktop/src/components/onboarding-chat/cards/build.tsx index 1457200518..85862205f1 100644 --- a/apps/desktop/src/components/onboarding-chat/cards/build.tsx +++ b/apps/desktop/src/components/onboarding-chat/cards/build.tsx @@ -14,6 +14,7 @@ import { quarantineHandoffReceipt } from '@/app/contrib/handoff-receipt' import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils' import type { CardProps } from '@/components/onboarding-chat/cards/frame' import { Chip } from '@/components/onboarding-chat/chip' +import { readPersistedHandoff } from '@/components/onboarding-chat/persisted-handoff' import { $handoffError, $setupHandoff, @@ -123,6 +124,8 @@ export function HandoffCard({ attrs, locked }: CardProps) { const brief = (attrs.brief ?? '').trim().slice(0, 240) const plan = parseHandoffPlan(attrs.plan) const state = useStore($setupHandoff) + // `locked` follows this text part; a later part (a tool call, reasoning) settles it while the reply still runs. + const replyRunning = useAuiState(s => s.message.status?.type === 'running') const receipt = useMemo(() => { try { @@ -139,24 +142,33 @@ export function HandoffCard({ attrs, locked }: CardProps) { const completed = receipt.completed useEffect(() => { - if (!task || !brief || locked || !storedId || !runtimeId || $setupHandoff.get() || completed) { + if (!task || !brief || locked || replyRunning || !storedId || !runtimeId || $setupHandoff.get() || completed) { return } let cancelled = false void resolveSessionOwner(storedId) - .then(owner => { + .then(async owner => { assertSessionOwnerResolved(owner, { method: 'onboarding.handoff', sessionId: storedId }) + const connectionId = isSessionOwnerRoute(owner) ? owner.connectionId : null + + const profile = isSessionOwnerRoute(owner) + ? owner.profile + : owner || $setupSession.get()?.profile || $activeGatewayProfile.get() + + // An unreachable history keeps the rendered attrs: today's behaviour, never a stalled handoff. + const persisted = await readPersistedHandoff(connectionId, profile, runtimeId).catch(() => null) + const persistedTask = (persisted?.task ?? '').trim().slice(0, 60) + const persistedBrief = (persisted?.brief ?? '').trim().slice(0, 240) + if (!cancelled) { - requestSetupHandoff(task, brief, plan, { - storedId, - runtimeId, - connectionId: isSessionOwnerRoute(owner) ? owner.connectionId : null, - profile: isSessionOwnerRoute(owner) - ? owner.profile - : owner || $setupSession.get()?.profile || $activeGatewayProfile.get() - }) + requestSetupHandoff( + persistedTask || task, + persistedBrief || brief, + persisted ? parseHandoffPlan(persisted.plan) : plan, + { storedId, runtimeId, connectionId, profile } + ) } }) .catch(error => { @@ -169,7 +181,7 @@ export function HandoffCard({ attrs, locked }: CardProps) { return () => { cancelled = true } - }, [brief, locked, plan, task, storedId, runtimeId, completed]) + }, [brief, locked, plan, replyRunning, task, storedId, runtimeId, completed]) if (!task || !brief) { return null diff --git a/apps/desktop/src/components/onboarding-chat/persisted-handoff.ts b/apps/desktop/src/components/onboarding-chat/persisted-handoff.ts new file mode 100644 index 0000000000..98555b50a9 --- /dev/null +++ b/apps/desktop/src/components/onboarding-chat/persisted-handoff.ts @@ -0,0 +1,47 @@ +import type { SessionHistoryResult } from '@hermes/shared' + +import { segmentTranscriptDirectives } from '@/lib/transcript-directives' +import { requestGatewayForAgent } from '@/store/gateway' + +/** + * The handoff directive as the backend persisted it for the welcome chat. The build session is named and seeded + * from these attrs, so they come from the stored reply, not the renderer's streamed copy of it: on Windows the + * streamed copy once repeated its own chunks ("Set up mySet up my games…") while the stored reply was intact, and + * the handoff carried the garbled task and brief into the new session. Null when no persisted reply holds one. + */ +export async function readPersistedHandoff( + connectionId: null | string, + profile: string, + runtimeId: string +): Promise>> { + const history = await requestGatewayForAgent>( + connectionId, + profile, + 'session.history', + { + session_id: runtimeId + } + ) + + const messages = Array.isArray(history?.messages) ? history.messages : [] + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + + if (message?.role !== 'assistant' || typeof message.text !== 'string') { + continue + } + + for (const segment of segmentTranscriptDirectives(message.text) ?? []) { + if ( + segment.kind === 'directive' && + segment.directive.name === 'onboarding' && + segment.directive.attrs.step === 'handoff' + ) { + return segment.directive.attrs + } + } + } + + return null +}