diff --git a/frontend/src/client/paliadin.ts b/frontend/src/client/paliadin.ts index d3c174e..a7abd73 100644 --- a/frontend/src/client/paliadin.ts +++ b/frontend/src/client/paliadin.ts @@ -164,6 +164,12 @@ async function sendTurn(text: string): Promise { es.addEventListener("content", (ev) => { const data = JSON.parse((ev as MessageEvent).data); const text = String(data.text || ""); + // Cache the full text on the bubble so finishBubble can render the + // complete response even when the typewriter is mid-flight when end + // arrives. textContent reflects only what's been typed so far and + // would otherwise truncate the rendered Markdown (m, 2026-05-08 — + // saw "## Proje" instead of the full 1408-byte body). + placeholder.dataset.fullText = text; typewriter(placeholder, text); }); @@ -282,8 +288,9 @@ function typewriter(bubble: HTMLElement, text: string): void { const speed = 6; const tick = () => { if (bubble.dataset.streaming !== "true") { - // Aborted — flush remaining text instantly. - node.textContent = text; + // Streaming finished — finishBubble has already rendered the full + // Markdown via dataset.fullText. Return without writing so we + // don't replace the rendered HTML with raw text on a delayed tick. return; } if (i >= text.length) return; @@ -307,7 +314,9 @@ function getBubbleText(bubble: HTMLElement): string { // "ran search_my_deadlines (3 results)". function finishBubble(bubble: HTMLElement, data: any): void { const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement; - const raw = textNode.textContent || ""; + // Prefer the full text cached on the bubble at content-event time; + // textContent may still reflect the typewriter's partial state. + const raw = bubble.dataset.fullText ?? textNode.textContent ?? ""; textNode.innerHTML = renderResponseHTML(raw); const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null; diff --git a/internal/services/paliadin.go b/internal/services/paliadin.go index 6669481..36f0d2f 100644 --- a/internal/services/paliadin.go +++ b/internal/services/paliadin.go @@ -750,6 +750,14 @@ func (s *paliadinDB) insertTurnRow(ctx context.Context, t *PaliadinTurn) error { func (s *paliadinDB) completeTurn(ctx context.Context, turnID uuid.UUID, finishedAt time.Time, durationMS int, response string, tokens int, meta trailerMeta, chipCount int) error { + // used_tools and rows_seen are NOT NULL in the schema (default '{}'). + // parseTrailer leaves them nil when Claude omits the trailer or the + // turn has no tool calls (casual chat). pq treats nil slices as NULL, + // so we must coerce to a non-nil empty array on every path. + usedTools := make(pq.StringArray, 0, len(meta.UsedTools)) + for _, t := range meta.UsedTools { + usedTools = append(usedTools, t) + } rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen)) for _, n := range meta.RowsSeen { rowsSeen = append(rowsSeen, int64(n)) @@ -768,7 +776,7 @@ func (s *paliadinDB) completeTurn(ctx context.Context, turnID uuid.UUID, ` _, err := s.db.ExecContext(ctx, q, turnID, finishedAt, durationMS, response, tokens, - pq.StringArray(meta.UsedTools), rowsSeen, chipCount, + usedTools, rowsSeen, chipCount, optionalString(meta.ClassifierTag)) return err }