fix(desktop): ship the runtime footer as metadata, not message text - #357
Conversation
The /footer runtime footer was appended to the message.complete TEXT. That broke the desktop two ways once #352's stamping landed: 1. DUPLICATED every message: the streamed bubble has no footer, the completed text does -> the streamed-vs-final dedupe in completeAssistantMessage no longer matches -> the completed text appends as a second bubble. 2. The footer vanished on session-sync re-hydration (DB rows carry no footer). Fix: the server ships the footer as payload.footer on message.complete (text stays pristine); the client carries it through completeAssistantMessage -> ChatMessage.footer -> toRuntimeMessage metadata.custom.footer and renders it as a muted monospace line under the bubble (RuntimeMetadataFooter), proper chrome instead of fake message text. Contract test pins 'footer never concatenated into text'. Desktop 1305 green, TSC clean, tui_gateway suites 91 green.
|
| Filename | Overview |
|---|---|
| tui_gateway/server.py | Footer is correctly split out of raw text into payload["footer"]; payload construction reordering is safe and logic is correct. |
| apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts | Correctly extracts payload.footer with a guard for empty/whitespace strings before forwarding to completeAssistantMessage. |
| apps/desktop/src/app/session/hooks/use-message-stream/index.ts | Footer is correctly spread into both the completeMessage (existing message path) and newAssistantFromCompletion (new message path), and is properly excluded on error completions. |
| apps/desktop/src/lib/chat-runtime.ts | Footer is surfaced as metadata.custom.footer in the runtime message, cleanly separating it from message text and making it accessible to the new RuntimeMetadataFooter component. |
| apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx | New RuntimeMetadataFooter reads from metadata.custom.footer via useAuiState with appropriate type guard; placed correctly inside MessagePrimitive.Root context and before the action bar. |
| apps/desktop/src/lib/chat-messages.ts | Adds footer?: string to both ChatMessage and GatewayEventPayload types; minimal, correct type additions. |
| tests/tui_gateway/test_desktop_runtime_footer.py | Uses server.__file__ for correct path resolution; positive assertion is a reliable guard, but the negative regex doesn't cover all concatenation forms (e.g., raw += ... or single-quote f-strings). |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant S as tui_gateway/server.py
participant GE as gateway-event.ts
participant MS as useMessageStream
participant RT as chat-runtime.ts
participant UI as RuntimeMetadataFooter
S->>S: "build payload = {text: raw, usage, status}"
S->>S: "if raw && status=="complete": payload["footer"] = footer"
S-->>GE: "emit message.complete {text, footer?, ...}"
GE->>GE: extract footer (trim, guard empty)
GE->>MS: completeAssistantMessage(sessionId, text, footer?)
MS->>MS: completeMessage: spread footer onto ChatMessage
MS->>RT: toRuntimeMessage(chatMessage)
RT->>RT: "metadata.custom = footer ? {footer} : {}"
RT-->>UI: ThreadMessage with metadata.custom.footer
UI->>UI: useAuiState → read metadata.custom.footer
UI-->>UI: render muted monospace line (or null)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant S as tui_gateway/server.py
participant GE as gateway-event.ts
participant MS as useMessageStream
participant RT as chat-runtime.ts
participant UI as RuntimeMetadataFooter
S->>S: "build payload = {text: raw, usage, status}"
S->>S: "if raw && status=="complete": payload["footer"] = footer"
S-->>GE: "emit message.complete {text, footer?, ...}"
GE->>GE: extract footer (trim, guard empty)
GE->>MS: completeAssistantMessage(sessionId, text, footer?)
MS->>MS: completeMessage: spread footer onto ChatMessage
MS->>RT: toRuntimeMessage(chatMessage)
RT->>RT: "metadata.custom = footer ? {footer} : {}"
RT-->>UI: ThreadMessage with metadata.custom.footer
UI->>UI: useAuiState → read metadata.custom.footer
UI-->>UI: render muted monospace line (or null)
Reviews (2): Last reviewed commit: "test: CWD-independent path + explicit fi..." | Re-trigger Greptile
…pts a committed twin (#362) The #361 zombie sweep drops an un-stamped optimistic row in favor of its polled committed twin — but the runtime footer travels ONLY on the message.complete frame and lives on that optimistic row (DB rows never carry it). So the sweep silently traded a footer-bearing zombie for a footer-less committed row: footer visible live, gone one poll later (regression of the #357 behavior, observed live 2026-07-16). dropZombieOptimisticRows now harvests the swept zombie's footer (keyed by the same media-normalized content key) and adoptZombieFooters grafts it onto the adopting committed row. Guardrails: assistant rows only, never overwrites an existing footer, one graft per harvested key. Tests: transplant case (RED without the graft: footer undefined after adopt) + no-overwrite case. Suite 1323/1323, tsc clean. Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Live incident (Ace, 2026-07-15): with
/footer on, every desktop message renders TWICE — once without the footer (streamed bubble), once with it (completed text).Root cause — a two-PR interaction: #333 appended the footer to the
message.completetext; #352 made the client's streamed-vs-final reconciliation text-sensitive. Streamed bubble text ≠ completed text (footer delta) → dedupe misses → the completed text lands as a duplicate bubble. Same text-baked design also made the footer vanish on session-sync re-hydration (DB rows carry no footer).Fix — footer is metadata, never text:
payload["footer"]onmessage.complete;textstays pristinecompleteAssistantMessage(sessionId, text, footer)→ChatMessage.footer→toRuntimeMessagemetadata.custom.footer→ newRuntimeMetadataFooterrenders it as a muted monospace line under the bubbleTests: new server contract test (
footer never concatenated into text); desktop 1305/1305, TSC clean, tui_gateway 91/91.