Gateway v4 inserts varying whitespace (extra newlines) between repeated
copies of assistant text, so exact byte-level N-copy detection missed
them. Now handles whitespace-flexible boundaries when checking for
self-repeated text.
Gateway v4 writes assistant text duplicated 2-4x within a single JSONL
entry. Added deduplicateSelfRepeat() to extractAssistantText() that
detects exact N-copy repeats and reduces to a single copy.
Browser was dropping the SSE connection during long tool-call gaps
(no data flowing). Added 5-second keepalive SSE comments (: keepalive)
to keep the connection alive. SSE comments are ignored by the browser
event parser but prevent connection timeout.
Rebuilt frontend dist/ bundle to fix client-side rendering duplication.
The pre-built bundle was processing SSE events differently from the
source, causing visible text duplication despite correct server output.
Removed temporary SSE debug logging from sseEmitter.ts.
The poll endpoint inserted duplicate assistant rows because gateway v4
rewrites JSONL during multi-tool-call turns, changing the merged
externalId between polls. Now checks for recent assistant rows with
overlapping text content before inserting, and updates the existing
row instead of creating a duplicate.
Root cause: the chat handler saved assistant messages from JSONL after
the stream ended, while the poll endpoint also synced JSONL to DB
periodically. Gateway v4 rewrites JSONL entries during multi-tool-call
turns, changing the externalId for the same logical message between
reads. Each poll saw a 'new' externalId and inserted another copy.
Fix: remove assistant message persistence from the chat handler.
The poll endpoint already handles JSONL-to-DB sync with robust dedup.
The chat handler now only links the user message externalId.
Also: API_HOST env var support for binding to Tailscale-only.
Adds a small icon button anchored to the upper-right of every fenced
code block in the chat view. Clicking copies the code to the clipboard
and briefly swaps to a checkmark. Uses navigator.clipboard.writeText
when available and falls back to a hidden textarea + execCommand for
non-secure contexts (HTTP origins like Tailscale magic-DNS, where the
Clipboard API rejects). The streaming codepath (which bypasses
react-markdown for incremental safety) is unchanged; the button
appears once the turn finishes and the message rerenders through the
markdown path.
The reduce in parseMessagesFromJsonl coalesces consecutive assistant
entries to merge intra-turn fragments (text + tool calls + more text)
into one bubble. It relies on user 'message' entries between turns to
break the streak.
When message.content is a bare string (plain user prompts) rather than
a ContentPart[], the existing 'Array.isArray(...) ? ... : []' path made
rawText empty for that entry. The downstream filter then dropped the
user message, leaving consecutive assistant entries with no separator.
The reduce then glued every assistant turn since the last surviving user
entry into one cumulative message, producing the user-visible bug where
each new chat response appears appended to the prior one and grows
unbounded across turns.
Mirrors the same handling already in readFirstUserMessage at lines
178-180 of the same file.