fix: the first build is named and seeded from the welcome chat's stored reply, not its streamed copy

On Windows (run 3, 2026-09-23) the renderer's streamed copy of the setup
reply repeated its own chunks. The handoff card read its task and brief from
that copy, so the build session was titled "Set up mySet up my games an…" and
opened with "Set up Sid up Sid's PC's PC for gaming for gaming…", cut at 240
chars. The backend's stored reply held the clean directive.

The card now reads the handoff directive from session.history (the persisted
reply) and uses those values; an unreachable history falls back to the
rendered attrs. It also waits for the whole reply to stop running: `locked`
tracks only the text part, which a later part settles mid-reply.
This commit is contained in:
alt-glitch
2026-09-23 11:40:04 +05:30
committed by Siddharth Balyan
parent d275e422dc
commit e458619f23
2 changed files with 70 additions and 11 deletions

View File

@@ -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

View File

@@ -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<null | Readonly<Record<string, string>>> {
const history = await requestGatewayForAgent<Partial<SessionHistoryResult>>(
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
}