Skip to content

fix(chat): scope a comparison pane's attachments to its own lane - #363

Merged
garfiec merged 1 commit into
developfrom
fix/comparison-pane-attachments
Aug 13, 2026
Merged

fix(chat): scope a comparison pane's attachments to its own lane#363
garfiec merged 1 commit into
developfrom
fix/comparison-pane-attachments

Conversation

@garfiec

@garfiec garfiec commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

A Compare Models turn rendered the message's attachment-driven surfaces twice, once per pane. Each pane filters content per lane but carried attachments through whole, so anything a bubble draws from the attachment list rather than from a pane-filtered part was emitted in both panes. Panes now see only the attachments their own lane produced.

Three duplications this removes:

  • An inline set_memory in one lane rendered its own card in that pane and — because its tool call is absent from the other pane's content — read as an orphan there and got a second card.
  • A background-memory-agent write matches no call in either pane, so both rendered it.
  • Office-doc previews render from message.attachments with no tool-call join at all, and duplicated unconditionally.

Closes #359.

Changes

  • attachmentsForPane (util/ComparisonDisplay.kt) — an attachment follows the lane whose parts contain the call that produced it; one attributable to no call in the turn renders once, in the primary pane.
  • Wired into buildComparisonDisplayMessages' parallel branch and into collapseParallelToPrimary. In the single list this is a removal, not only a de-duplication: the added agent's attachments are dropped alongside its content, so a secondary-lane office preview or memory write that previously rendered there now renders nowhere. That path also feeds in-conversation search and the iOS screen.
  • Where a pane ends up with no attributed parts — the Final→reload gap, or the in-branch empty-pane fallback — there is nothing to scope against, so the turn's attachments go to the primary pane, which is where they land once the attributed message arrives.
  • renderedToolCallIds gains a KDoc note on why it stops one level down and is not interchangeable with the recursive walk.
  • Module doc records the invariant.

Testing

  • ./gradlew test detektMetadataCommonMain detekt :app:lint :app:assembleDebug — green.
  • 7 new ComparisonDisplayTest cases. 6 of the 7 fail against the pre-change file; the seventh is the non-parallel regression guard and passes either way.
  • Device A/B on an emulator against a seeded comparison turn carrying three memory writes — one per lane plus one attributable to neither. Before: every write rendered twice. After: each renders once, in its own lane, with the unattributable one in the primary pane. The single-list path was checked by toggling Compare Models off. No crashes; one pre-existing empty-body decode error appears identically on both builds.

Notes

  • Upstream hands MemoryArtifacts every attachment once above both columns, with no toolCallId filter (ParallelContent.tsx:275, MemoryArtifacts.tsx). Placing an unattributable write in the primary pane matches that. Routing an attributable one to its own lane is a deliberate divergence, needed because mobile also renders an inline per-call card that upstream has no equivalent of.
  • Scoping lives in ComparisonDisplay rather than at the render site because the panes are derived in a pure function, so the fix is assertable in JVM tests — feature:chat has only an on-device Compose harness, the same reason sendButtonModeFor is a pure transform pulled out of its composable. A render-site fix would have to be repeated on every bubble surface that reads message.attachments.
  • Ownership uses outputToolCallIds (recursive) and not the similarly-named renderedToolCallIds, which stops one level down because that is where the subagent trace recursion stops — a call two levels down is drawn by nothing. outputToolCallIds now serves two purposes across three call sites: attachment hoisting (activity groups and subagent cards) and this lane attribution.
  • The background-agent case was exercised with a seeded attachment of that shape rather than a live background memory agent (memory.agent.enabled is not configured on the test server), so the rendering path is covered but not the server behaviour that produces it.
  • The office-preview duplicate follows from the same rule but was not observed on a device.

A pane filters `content` per lane but carried `attachments` through whole,
so every surface a bubble draws from the attachment list rather than from a
pane-filtered part rendered once per pane.
@github-actions

Copy link
Copy Markdown
Contributor

Android debug APK

Artifact: switchboard-android-debug-363
Download: switchboard-android-debug-363.zip
Retention: 90 days
Commit: b2136c2fc0d49abc309bc54c1d05a6502cf0a442

Download requires a GitHub login. Installs over previous debug builds without uninstalling (stable signing key).

@garfiec
garfiec merged commit d87bf1e into develop Aug 13, 2026
6 checks passed
@garfiec
garfiec deleted the fix/comparison-pane-attachments branch August 13, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Comparison panes duplicate attachment-driven surfaces (memory writes, office previews)

1 participant