Skip to content

fix(mac): AppKit markdown emphasis/appearance and sidebar selection fixes - #441

Merged
SergeSerb2 merged 9 commits into
mainfrom
surgecode/appkit-markdown-sidebar-fixes
Aug 2, 2026
Merged

fix(mac): AppKit markdown emphasis/appearance and sidebar selection fixes#441
SergeSerb2 merged 9 commits into
mainfrom
surgecode/appkit-markdown-sidebar-fixes

Conversation

@SergeSerb2

Copy link
Copy Markdown
Owner

Summary

Post-Gate-F bug-fix batch for the AppKit renderer's Markdown and sidebar surfaces, from the 2026-08-02 bug hunt:

  • Bold/italic finally render: inline emphasis intents now resolve into real bold/italic font traits in the AppKit markdown path (they rendered as plain body text since the port).
  • Code-block and table chrome colors resolve against the view's own effective appearance and refresh on appearance change (were frozen CGColors resolved against the wrong appearance at class-load time).
  • Markdown tables measure from laid-out content instead of a hard-coded 30pt/row, so wrapped cells stop clipping.
  • Sidebar: selecting a delegated/automation child row no longer instantly deselects; structural removals no longer clear or hijack the user's session selection; row context menus anchor to the outline view so a targeted row reload can't dismiss them mid-interaction; the "Show N more" row activates on primary click; snoozed sessions inside a collapsed disclosure now carry their wake deadline so they actually wake on time.
  • Streaming markdown cache: exact prefix comparison (same-length in-place edits no longer show stale text); plain-stream tail count kept exact; link/file-target scans deferred until a message settles (they ran over the whole document on every streaming delta with no consumer).

Deliberately skipped (need product decisions or their own pass): transcript context-menu restoration, syntax-highlight restoration in code blocks, plain-stream fast-path predicate widening, project identity stabilization. Tracked in the session's finding inventory.

Area

  • apps/mac — native macOS app
  • apps/windows — Windows desktop app
  • apps/mobile — iPhone companion app
  • apps/server — backend server
  • Shared packages or relay
  • Build, CI, or release tooling
  • Docs

Release size

  • size:XS
  • size:S
  • size:M
  • size:L
  • size:XL

Verification

  • Focused suites during iteration (AppKitMarkdownLayoutTests, AppKitSidebarControllerTests, SidebarPresentationStoreTests, StreamingMarkdownTests + 4 more): all pass, with new tests per fix
  • pnpm run verify after the final edit (resolved to the Swift suite for this Swift-only diff): pass

🤖 Generated with Claude Code

SergeSerb2 and others added 9 commits August 2, 2026 13:42
Two defects in the AppKit Markdown renderer's styling layer.

Emphasis was never applied. MarkdownSemanticModel encodes bold/italic
purely as `inlinePresentationIntent`, which SwiftUI's `Text` honors but
TextKit ignores — `AppKitMarkdownStyle.attributed` only branched on
`.code` and `link`, then back-filled every unstyled run with the plain
body font. Every `**bold**` and `*italic*` span in the transcript, in
table cells, and in list/quote/heading bodies rendered as regular 14pt
text. Emphasis intents are now resolved into real symbolic traits and
composed with the code font, so `***both***` and ``**bold `code`**``
render correctly.

Code-block and table chrome froze dynamic system colors into CGColors in
`init`. `NSColor.cgColor` resolves against the current *drawing*
appearance, which outside a draw pass is the system appearance, not the
view's — so on a Light Mode Mac the dark-pinned transcript drew white
text on a white `textBackgroundColor` plate. Nothing implemented
`updateLayer`/`viewDidChangeEffectiveAppearance` either, so a runtime
theme switch never repaired the cached fragment views. Both views now
re-resolve their layer colors in `updateLayer` under the view's own
effective appearance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`AppKitMarkdownTableView.measuredHeight` ignored its width argument and
returned `rowCount * 30 + 12`, but the cells it builds are NSTextViews
with `widthTracksTextView` and only a minimum height constraint, so any
cell whose text wraps grows past that budget. The transcript row was
sized for the estimate and the lower table rows were clipped; because
the same value is what the height cache stores, the clipping was stable
rather than self-correcting.

The laid-out document height is now authoritative, with the old fixed
estimate kept as a floor so an empty or not-yet-laid-out table measures
exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two selection defects in the native outline.

Delegated automation children are projected as `.automation(scope)` and
never get a `.thread(scope)` node, but `synchronizeSelection` and the
keyed differ's selection plan both looked the scope up as `.thread`.
Clicking a nested "Agent: …" row opened the child thread and then
immediately ran `deselectAll`, leaving the sidebar with no selected row
and the differ unable to retain selection across moves. Scope-to-node
resolution now goes through one place, `SidebarOutlineSnapshot
.selectionNodeID(for:)`, which returns whichever node actually carries
that scope as its thread action target.

`outlineViewSelectionDidChange` only ignored notifications while
`isApplyingSelection` was set, and that flag was set exclusively inside
`synchronizeSelection`. AppKit moves or clears `selectedRow` while rows
are removed inside `beginUpdates`/`endUpdates` and while a collapsing
parent hides them, so a session that settled, snoozed, got archived, or
fell out of a search query would post a selection notification mid-batch
and the delegate would read it as user intent: it re-entrantly cleared
the presentation selection and dispatched `navigation.clearSelection()`,
emptying the transcript, or activated whatever neighbouring row AppKit
had landed on. Controller-driven structural updates are now guarded, and
`synchronizeSelection` runs after every apply as the sole authority. It
clears the highlighted row only when the session selection actually
became nil, so it never steals an outline selection it did not set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both menu paths anchored the transient NSPopover to the row's
`SidebarRowView` — `presentMenu(forRow:)` via `view(atColumn:row:)` and
the ellipsis button by passing the row view itself. A targeted
`.reload(id)` calls `reloadItem`, which re-invokes `viewFor:item:` and
swaps in a different `SidebarRowView`; a transient popover whose
positioning view leaves the window is dismissed. Opening Snooze/Settle/
Pin on a running session therefore closed the menu on the next provider
mutation for that thread, and even an idle row reconfigured within a
minute when the visible-row clock refreshed its relative time.

The controller now hands the presenter the outline view plus the row's
rect in its coordinate space. The outline view outlives every cell-view
replacement, so the menu stays open and keeps pointing at its row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The row is projected unselectable and unexpandable, so
`shouldSelectItem` rejects it, it can never become `selectedRow` for
`handleKeyboardCommand`, and its disclosure/menu buttons are hidden. The
only remaining route to `revealAll` was `SidebarRowView.mouseUp` gated
on a double click, which never arrives because NSTableView's own
tracking loop consumes the click sequence for a row the row view does
not handle. A project with more than five active sessions therefore hid
the rest behind a row that did nothing at all.

The outline view now routes a primary click on an unselectable action
row to the controller, which activates it. Selectable rows keep AppKit's
click-to-select behavior untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`SidebarOutlineSnapshot.nextUpdateAt` is the minimum over *materialized*
nodes, and the controller's shared clock is fed from the visible subset
of those. A collapsed Snoozed disclosure materializes no thread rows, so
the wake time of everything inside it was absent from the snapshot: the
visible-row clock was scheduled at nil, `refresh()` never ran, and a
session snoozed behind the default-collapsed disclosure never woke until
an unrelated topology change or an app restart forced a reprojection.
The same gap hid auto-settle deadlines for rows past the "Show more" cap
and for every row under a collapsed project.

The collapsed disclosure, show-more, and project rows now carry the
earliest classification deadline of the scopes they hide. Only
classification deadlines count — relative-time labels of rows nobody can
see must not wake the clock — so a collapsed row reconfigures once, when
its hidden content actually changes bucket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`StreamingMarkdownCache.document` short-circuited on byte count plus a
trailing 64-byte window. A same-length in-place edit that differed
anywhere before those last 64 bytes therefore returned the previous
document unchanged — the engine had already detected the source change,
so the stale document became the snapshot for a bumped revision and the
transcript kept rendering the old wording. The same window gated the
append-only decision, so a longer non-append replacement whose bytes at
[n-64, n) happened to match skipped `reset` and kept a permanently wrong
settled prefix.

The comparison is now exact. It is a linear byte scan of the accumulated
message, which is far below the tail re-parse this same call gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`PlainStreamingState.append` tracked `tailCharacterCount + delta.count`,
but grapheme counts are not additive across the join: a trailing `\r`
meeting a leading `\n`, or a base letter meeting a combining mark (both
admitted by the plain-stream predicate), collapse into one character.
The tracked count drifted above the real length and was carried forward,
while the split used `index(_:offsetBy:)` with no `limitedBy:` — enough
accumulated drift traps with "String index is out of bounds" mid-stream,
and below that threshold it mis-sizes the frozen fragment.

The running total is now treated as the upper bound it is: `limitedBy`
makes the split total, and every split resets the count to the truth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… settles

`markdownFileTargets` and `markdownExternalLinks` each walk every block
and every attributed run of the document and parse a `URL` per link run.
Because the real streaming path always has `plainStreamingState == nil`,
they ran on every provider delta over the entire accumulated message —
quadratic main-actor work over a stream whose results nothing reads: no
AppKit consumer touches `MarkdownDocumentSnapshot.fileTargets` or
`.externalLinks` while a message is live.

They are now resolved when the message settles, which `finish()` already
reprojects with `isStreaming == false`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SergeSerb2 SergeSerb2 added the size:M Normal feature or meaningful behavior change label Aug 2, 2026
@SergeSerb2
SergeSerb2 merged commit 626e319 into main Aug 2, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M Normal feature or meaningful behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant