import type { ChatTurn, ThreadMessage } from "./types"; /** * Flattens stored turns into the flat message list the transcript renders. * * Storage keeps a question and its answer together as a turn, because that is * what gets replayed to the model. The transcript needs a flat sequence, newest * message first. This is the one place that translation happens. */ export interface LiveState { isStreaming: boolean; /** Partial answer for the turn currently in flight. */ response: string; thinking: string; /** Activity line, e.g. "Searching the web". Only meaningful mid-turn. */ status?: string; } export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[] { const messages: ThreadMessage[] = []; // Strictly newest message first. The search bar sits at the top of the window, // so the most recent thing — an answer, once one has been written — should be // the first line under it. A consequence is that within an exchange the answer // comes before its question, which is the intended reading order here. for (let index = turns.length - 1; index >= 0; index--) { const turn = turns[index]; // Only the newest turn can be mid-flight. const isLive = index === turns.length - 1 && live.isStreaming; const text = isLive ? live.response : turn.response; messages.push({ id: `${turn.id}:assistant`, role: "assistant", text, createdAt: turn.createdAt, isStreaming: isLive, thinking: isLive ? live.thinking : turn.thinking, toolCalls: turn.toolCalls, error: isLive ? undefined : turn.error, // Only while there is nothing to read yet. Once tokens land, the // placeholder it feeds is gone anyway. status: isLive && text.length === 0 ? live.status : undefined, }); messages.push({ id: `${turn.id}:user`, role: "user", text: turn.prompt, createdAt: turn.createdAt, isStreaming: false, thinking: "", toolCalls: [], }); } return messages; }