From 8396adce765c40939a274868c1b4c487d96bcfa7 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Fri, 11 Sep 2026 13:34:04 +0200 Subject: [PATCH] fix(reader): keep reader mode snapshot stable Issue: Cmd+R could freeze Architect when Reader Mode opened on a live Codex session because terminal history was reparsed on every render epoch update. Solution: Load terminal history only when Reader Mode opens and keep that content stable while terminal output continues. Add regression coverage and document that closing and reopening captures newer output. --- README.md | 2 +- docs/ARCHITECTURE.md | 4 +- src/main.zig | 1 + src/ui/components/reader_overlay.zig | 66 ++++++++++++++++++++++------ 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8027561c..038993b8 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Architect solves this with a grid view that keeps all your agents visible, with - **Selection-to-agent context** — release after selecting terminal text to reveal a narrow robot button anchored just outside the selection; the button follows the selection while scrolling; choose Claude, Codex, or Gemini in a centered modal with highlighted dropdown navigation, review the fully wrapped context in a scrollable field, enter wrapped multiline instructions, cancel if needed, or launch a new agent in the selected terminal's working directory with the selection submitted as its initial prompt without embedding large contexts in the shell command line - **Story viewer** — run `architect story ` to open a scrollable overlay that renders PR story files with prose text and diff-colored code blocks - **MCP session spawning** — run `architect-mcp` from an MCP client to ask the running Architect app to create a terminal session in a requested working directory -- **Reader mode** (⌘R) — open a centered markdown reader for the selected terminal's history (works in full view and grid) with live updates, bottom pinning, incremental search (⌘F, Enter/Shift+Enter), markdown tables with inline cell styling (bold/italic/code/links/strikethrough), task checkboxes (emoji), clickable links, shared draggable scrollbar, and left-to-right gradient separators before command prompts (OSC 133 + fallback heuristics) +- **Reader mode** (⌘R) — open a centered markdown snapshot of the selected terminal's history (works in full view and grid); close and reopen it to capture newer output. Includes bottom pinning, incremental search (⌘F, Enter/Shift+Enter), markdown tables with inline cell styling (bold/italic/code/links/strikethrough), task checkboxes (emoji), clickable links, shared draggable scrollbar, and left-to-right gradient separators before command prompts (OSC 133 + fallback heuristics) ### Terminal Essentials - Smooth animated transitions for grid expansion, contraction, and reflow (cells and borders move/resize together) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b37785d3..cef62eb9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -263,7 +263,7 @@ ui/components/markdown_parser.DisplayBlock[] ui/components/markdown_renderer.RenderLine[] | render as SDL text runs in centered reader column v -Reader overlay (live updates + search) +Reader overlay (open-time snapshot + search) ``` ### Story Content Path @@ -510,7 +510,7 @@ Rotate: rename active file to architect-.log and continue in new | `ui/components/markdown_parser.zig` | Shared markdown parser for reader mode and story overlays. Parses headings, paragraphs, lists (including task checkboxes), blockquotes, markdown tables, fenced code, horizontal rules, inline styles (bold/italic/code/strikethrough/link), and prompt separator blocks. In story mode (`parseStory()`), additionally handles `story-diff` fenced blocks, code block metadata JSON, anchor extraction (`**[N]**` in prose, `` in code), and per-line paragraph emission. | `parse()`, `parseStory()`, `freeBlocks()`, `DisplayBlock`, `StyledSpan`, `CodeBlockMeta`, `CodeLineKind`, `ParseOptions` | std | | `ui/components/markdown_renderer.zig` | Line layout engine that wraps parsed markdown blocks into renderable lines and style runs, including prompt-separator and story-specific line kinds (diff headers, diff lines, code lines with anchor/kind metadata) | `buildLines()`, `freeLines()`, `RenderLine`, `RenderRun` | `ui/components/markdown_parser` | | `ui/components/search_utils.zig` | Shared search utilities for overlays: case-insensitive substring find, match rebuilding, search bar rendering, and text texture creation | `SearchMatch`, `TextTex`, `findCaseInsensitive()`, `rebuildMatches()`, `renderSearchBar()`, `makeTextTexture()` | `gfx/primitives`, `font_cache`, `dpi`, `geom`, `c` | -| `ui/components/reader_overlay.zig` | Fullscreen reader overlay for the selected terminal history (full view or grid selection) with live markdown updates, centered reading-width layout, bottom pinning, jump-to-bottom, incremental search, clickable links, shared scrollbar interactions, styled inline markdown in table cells, and left-to-right gradient prompt separators | `ReaderOverlayComponent`, `toggle()` | `ui/components/fullscreen_overlay`, `ui/components/scrollbar`, `ui/components/search_utils`, `app/terminal_history`, `ui/components/markdown_parser`, `ui/components/markdown_renderer`, `os/open`, `font_cache`, `geom`, `c` | +| `ui/components/reader_overlay.zig` | Fullscreen reader overlay for an open-time snapshot of the selected terminal history (full view or grid selection) with centered reading-width layout, bottom pinning, jump-to-bottom, incremental search, clickable links, shared scrollbar interactions, styled inline markdown in table cells, and left-to-right gradient prompt separators | `ReaderOverlayComponent`, `toggle()` | `ui/components/fullscreen_overlay`, `ui/components/scrollbar`, `ui/components/search_utils`, `app/terminal_history`, `ui/components/markdown_parser`, `ui/components/markdown_renderer`, `os/open`, `font_cache`, `geom`, `c` | | `ui/components/modal_frame.zig` | Shared chrome for centered modal dialogs: full-window darkening scrim + rounded filled/bordered panel, and the Escape/⌘W dismiss-key check. Used by `confirm_dialog.zig` and `selection_agent_overlay.zig` so their scrim/panel rendering and dismissal keys can't drift independently | `renderScrimAndPanel()`, `isDismissKey()` | `gfx/primitives`, `geom`, `c` | | `ui/components/dropdown_menu.zig` | Reusable vertical list menu: owns open/hover/keyboard-nav state and the committed `selected` index, renders its own cached item-label textures, and reports a `.selected`/`.closed` event on click or Enter/Escape so the owning component reacts (persist the pick, or act on it immediately) instead of tracking hit-testing and highlight rendering itself. Used by `selection_agent_overlay.zig`'s agent selector and `diff_overlay.zig`'s "Send to agent" menu | `DropdownMenu`, `openMenu()`, `close()`, `handleKey()`, `handleClick()`, `handleMotion()`, `itemAt()`, `itemRect()`, `render()` | `gfx/primitives`, `font_cache`, `ui/text_render`, `geom`, `c` | | `ui/components/selection_agent_overlay.zig` | Selection action form with highlighted agent selector, multiline prompt field, fully wrapped and scrollable selected-context preview, viewport-bounded context textures, cached UI text, and launch action containing the selected terminal context | `SelectionAgentOverlayComponent`, `open()`, `formatAgentPrompt()` | `ui/text_edit`, `ui/text_render`, `ui/first_frame_guard`, `ui/components/modal_frame`, `ui/components/dropdown_menu`, `ui/components/scrollbar`, `gfx/primitives`, `font_cache`, `geom`, `c` | diff --git a/src/main.zig b/src/main.zig index 12dd0518..096c5645 100644 --- a/src/main.zig +++ b/src/main.zig @@ -81,6 +81,7 @@ test { _ = @import("ui/components/pr_dropdown_model.zig"); _ = @import("ui/components/pr_dropdown_repo.zig"); _ = @import("ui/components/pr_dropdown_view.zig"); + _ = @import("ui/components/reader_overlay.zig"); _ = @import("ui/components/scrollbar.zig"); _ = @import("ui/components/search_utils.zig"); _ = @import("ui/components/selection_agent_overlay.zig"); diff --git a/src/ui/components/reader_overlay.zig b/src/ui/components/reader_overlay.zig index fff43a26..f8b45982 100644 --- a/src/ui/components/reader_overlay.zig +++ b/src/ui/components/reader_overlay.zig @@ -65,7 +65,6 @@ pub const ReaderOverlayComponent = struct { scrollbar_state: scrollbar.State = .{}, session_index: usize = 0, - last_render_epoch: u64 = 0, wrap_cols: usize = 90, layout_char_w_px: c_int = 0, pinned_to_bottom: bool = true, @@ -131,7 +130,7 @@ pub const ReaderOverlayComponent = struct { self.layout_char_w_px = 0; self.wrap_cols = self.computeWrapCols(host); self.overlay.show(now_ms); - self.refreshFromSession(host, true); + self.loadSnapshotFromSession(host, true); return .opened; } @@ -169,7 +168,6 @@ pub const ReaderOverlayComponent = struct { markdown_renderer.freeLines(self.allocator, &self.lines); self.blocks = .empty; self.lines = .empty; - self.last_render_epoch = 0; } fn clearLinkHits(self: *ReaderOverlayComponent) void { @@ -177,7 +175,7 @@ pub const ReaderOverlayComponent = struct { self.hovered_link = null; } - fn refreshFromSession(self: *ReaderOverlayComponent, host: *const types.UiHost, force_bottom: bool) void { + fn loadSnapshotFromSession(self: *ReaderOverlayComponent, host: *const types.UiHost, force_bottom: bool) void { if (self.session_index >= self.sessions.len) return; const session = self.sessions[self.session_index]; @@ -197,7 +195,6 @@ pub const ReaderOverlayComponent = struct { }; self.rebuildLines(host, force_bottom); - self.last_render_epoch = session.render_epoch; } fn rebuildLines(self: *ReaderOverlayComponent, host: *const types.UiHost, force_bottom: bool) void { @@ -875,14 +872,9 @@ pub const ReaderOverlayComponent = struct { self.rebuildLines(host, false); } - if (self.session_index < self.sessions.len) { - const session = self.sessions[self.session_index]; - if (session.render_epoch != self.last_render_epoch) { - self.refreshFromSession(host, false); - } else { - _ = self.syncScrollMetrics(host); - } - } + // Reader content is an open-time snapshot. Terminal output continues + // independently and is captured the next time the overlay opens. + _ = self.syncScrollMetrics(host); if (self.pinned_to_bottom) { self.overlay.scroll_offset = self.overlay.max_scroll; @@ -1695,3 +1687,51 @@ pub const ReaderOverlayComponent = struct { .wantsFrame = wantsFrameFn, }; }; + +test "reader content stays a snapshot while the terminal updates" { + const allocator = std.testing.allocator; + + var session: SessionState = undefined; + session.render_epoch = 2; + session.terminal = null; + var sessions = [_]*SessionState{&session}; + + var component = ReaderOverlayComponent{ + .allocator = allocator, + .opener = undefined, + .sessions = &sessions, + .overlay = .{ + .visible = true, + .animation_state = .open, + }, + .session_index = 0, + .raw_text = try allocator.dupe(u8, "captured before live output"), + }; + defer component.clearContent(); + + var host = types.UiHost{ + .now_ms = 1_000, + .window_w = 1_200, + .window_h = 800, + .window_focused = true, + .ui_scale = 1.0, + .grid_cols = 1, + .grid_rows = 1, + .cell_w = 10, + .cell_h = 20, + .term_cols = 120, + .term_rows = 40, + .view_mode = .Full, + .focused_session = 0, + .focused_cwd = null, + .focused_has_foreground_process = true, + .sessions = &[_]types.SessionUiInfo{}, + .theme = undefined, + }; + var actions = types.UiActionQueue.init(allocator); + defer actions.deinit(); + + ReaderOverlayComponent.updateFn(&component, &host, &actions); + + try std.testing.expectEqualStrings("captured before live output", component.raw_text.?); +}