Skip to content

Discard empty text cards when editing is abandoned - #124

Merged
ojowwalker77 merged 11 commits into
t3code/fix-issue-95from
t3code/2034c93e
Aug 10, 2026
Merged

Discard empty text cards when editing is abandoned#124
ojowwalker77 merged 11 commits into
t3code/fix-issue-95from
t3code/2034c93e

Conversation

@ojowwalker77

@ojowwalker77 ojowwalker77 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Fixes #107.

Problem

Clicking an empty area creates a text card with the "Brain dump…" placeholder; abandoning it left a ghost card on the board. The discard logic existed only in endEditing (the editor's focus-loss callback), but two abandonment paths never reached it:

  • stopEditing() (click empty canvas, click another card, marquee select) relied on resignFocus() → focus-loss → endEditing, but resignFocus() no-ops when the editor never actually became first responder — exactly the case for a freshly placed card clicked away from during its 0.06s mount/focus delay.
  • Board switch / teardown (flushSave) persisted the in-flight empty card before anything ended the edit.

Fix

All in BoardViewModel.swift:

  • Extract the empty-text-card check into discardIfAbandoned(_:) and call it from both endEditing and stopEditing. Text kind only — empty shapes/stickies remain intentional placements. Deletion goes through the shared delete path, so it's undoable and bound arrows refresh.
  • flushSave() ends any in-flight edit first, so switching boards or quitting mid-edit discards the ghost instead of saving it.

Verification

swift build clean, all 216 tests pass.

Review in cubic

Summary

  • Discard abandoned empty text cards when editing ends.
  • Apply this logic from both endEditing and stopEditing.
  • End active editing before flushSave() creates a persistence snapshot.
  • Preserve text cards with content and empty non-text elements.
  • Use the shared delete path to retain undo support and refresh bound arrows.
  • swift build passes, with 216 tests passing.

Note

Discard empty text cards when editing is abandoned or board navigation occurs

  • Empty text cards are now deleted when editing ends (via endEditing, stopEditing, or flushSave(abandoningActiveEdit: true)) without creating a starter card, including cases where the editor never gained focus.
  • Board navigation calls flushSave(abandoningActiveEdit: true) via checkpointBeforeLeavingCurrentBoard, ensuring blank cards are pruned before the board is persisted.
  • beginEditing guards against resurrecting selection state for a card that no longer exists after a discard.
  • Blank equation cards are also pruned on stopEditing and no longer trigger starter-card creation when they empty the board.
  • Behavioral Change: discarding an empty text card on an otherwise empty board leaves the board with zero cards rather than auto-inserting a starter card.

Macroscope summarized 1fd1e2b.

@github-actions github-actions Bot added the size:S label Aug 8, 2026
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

BoardViewModel now caches board-wide text context, keeps live editor frames transient, bounds inactive-board history, and renders snapshots without mutating committed state. Abandoned empty text cards are discarded during edit termination and active-edit-aware save flows.

Changes

Board state management

Layer / File(s) Summary
Text context and history lifecycle
Sources/ComposerApp/Views/BoardViewModel.swift, Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift
Board text context is cached and selectively invalidated. Inactive-board history is restored and bounded by board and card limits.
Editing lifecycle and transient frames
Sources/ComposerApp/Views/BoardViewModel.swift, Sources/ComposerApp/Views/BoardCardView.swift, Sources/ComposerApp/Services/CanvasBridge.swift, Tests/ComposerAppTests/BoardPersistenceTests.swift, Tests/ComposerAppTests/TextHugTests.swift
Live text frames remain local during editing. Edit boundaries commit or discard them. Abandoned empty text cards are removed without recreating starter cards.
Snapshot geometry and export
Sources/ComposerApp/Views/BoardViewModel.swift, Sources/ComposerApp/Services/BoardExporter.swift, Tests/ComposerAppTests/ArrowBindingTests.swift
Rendering snapshots apply live frames and recompute bound-arrow geometry on copies. Export uses the rendered snapshot for bounds and layers.
Canvas wiring and documentation
Sources/ComposerApp/Views/ComposerCanvas.swift, Sources/ComposerApp/Views/EditingStage.swift, CHANGELOG.md
Canvas transitions flush active edits before board changes. Render layers and editors receive the shared text context. The changelog records the new behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BoardCardView
  participant BoardViewModel
  participant BoardCardLayer
  participant BoardExporter
  BoardCardView->>BoardViewModel: report live fitted frame
  BoardViewModel->>BoardCardLayer: provide rendering snapshot
  BoardCardLayer->>BoardViewModel: recalculate bound-arrow geometry
  BoardExporter->>BoardViewModel: request rendering snapshot
  BoardViewModel-->>BoardExporter: return snapshot cards
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also adds broad text-context caching, inactive-board history eviction, live-frame rendering, and related tests beyond issue #107. Split unrelated performance, history, and rendering changes into separate PRs, or link issues that explicitly require them.
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement issue #107 by discarding empty text cards at editing boundaries while preserving non-empty text cards and empty non-text elements.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: removing empty text cards when editing ends without meaningful content.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/2034c93e

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Aug 8, 2026
Comment thread Sources/ComposerApp/Views/BoardViewModel.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ComposerApp/Views/BoardViewModel.swift`:
- Around line 277-281: Update discardIfAbandoned(_:) to use a cleanup-specific
deletion path that can leave the board empty instead of calling delete(id),
which recreates CardState.firstCard(). Preserve delete(id)’s existing
starter-card recreation behavior for ordinary user deletions, and ensure
flushSave() does not persist a replacement card when an abandoned text card is
removed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ba4d42ca-fc36-4ddf-b14b-bdaba5ed550a

📥 Commits

Reviewing files that changed from the base of the PR and between 4af30a9 and d82c74c.

📒 Files selected for processing (1)
  • Sources/ComposerApp/Views/BoardViewModel.swift

Comment thread Sources/ComposerApp/Views/BoardViewModel.swift Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread Sources/ComposerApp/Views/BoardViewModel.swift Outdated
Comment thread Sources/ComposerApp/Views/BoardViewModel.swift
Clicking away from a freshly placed text card during its mount/focus
delay never fired the editor's focus-loss callback (resignFocus no-ops
when the editor never took first responder), so the empty card survived
as a ghost placeholder. Board switches and teardown likewise persisted
an in-flight empty card via flushSave.

Extract the empty-text-card discard into discardIfAbandoned(_:), call it
from both endEditing and stopEditing, and have flushSave end any
in-flight edit before snapshotting. Non-text elements are kept; deletion
stays undoable through the shared delete path.

Fixes #107

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
Sources/ComposerApp/Views/BoardViewModel.swift (1)

277-281: 🗄️ Data Integrity & Integration | 🟠 Major

Do not use delete(id) for abandonment cleanup.

When the abandoned card is the last card, delete(id) recreates CardState.firstCard() in Lines 1558-1564. flushSave() then snapshots and commits that replacement, so abandoning a blank text card still persists a blank text card.

Use a cleanup-specific removal path that does not recreate the starter card. Preserve undo registration, interaction cleanup, refreshBoundArrows(), and the existing starter-card behavior for ordinary user deletions. Add a regression test for a board containing only an abandoned text card.

Also applies to: 365-368

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ComposerApp/Views/BoardViewModel.swift` around lines 277 - 281, The
abandonment cleanup in discardIfAbandoned must not call delete(id), because
deleting the sole card recreates CardState.firstCard() and persists it. Add or
reuse a cleanup-specific removal path that removes the abandoned card without
creating a starter card while preserving undo registration, interaction cleanup,
and refreshBoundArrows(); keep delete(id)’s existing starter-card behavior for
ordinary deletions, and add a regression test for a board containing only an
abandoned text card.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 14-15: Insert a blank line between the “### Fixed” heading and its
first list item in the changelog.

---

Duplicate comments:
In `@Sources/ComposerApp/Views/BoardViewModel.swift`:
- Around line 277-281: The abandonment cleanup in discardIfAbandoned must not
call delete(id), because deleting the sole card recreates CardState.firstCard()
and persists it. Add or reuse a cleanup-specific removal path that removes the
abandoned card without creating a starter card while preserving undo
registration, interaction cleanup, and refreshBoundArrows(); keep delete(id)’s
existing starter-card behavior for ordinary deletions, and add a regression test
for a board containing only an abandoned text card.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 217e3bdf-9e6f-4e1d-b37a-9985972e4f44

📥 Commits

Reviewing files that changed from the base of the PR and between d82c74c and 4fb680f.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • Sources/ComposerApp/Views/BoardViewModel.swift

Comment thread CHANGELOG.md
@github-actions github-actions Bot added size:L and removed size:M labels Aug 10, 2026
@ojowwalker77
ojowwalker77 changed the base branch from main to t3code/fix-issue-95 August 10, 2026 12:50

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/ComposerApp/Views/BoardViewModel.swift (1)

2165-2180: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Build an id lookup once per refresh pass.

updateBoundArrowGeometry resolves each binding with cards.first { $0.id == id }. refreshBoundArrows(in:) calls it for every bound arrow, so the pass is O(arrows × cards).

renderingSnapshot now runs this same pass whenever a live text frame is applied. The save debounce fires during typing, so the scan repeats on the main actor for every mid-edit save on a large board.

Pass a precomputed [UUID: CGRect] frame map (or [UUID: Int] index map) into the geometry helper.

♻️ Proposed refactor sketch
 private static func refreshBoundArrows(in cards: inout [CardState]) {
-    let existing = Set(cards.map(\.id))
+    var framesByID: [UUID: CGRect] = [:]
+    framesByID.reserveCapacity(cards.count)
+    for card in cards { framesByID[card.id] = card.frame }
     for i in cards.indices where cards[i].elementKind == .arrow {
-      if let start = cards[i].startBindingID, !existing.contains(start) {
+      if let start = cards[i].startBindingID, framesByID[start] == nil {
         cards[i].startBindingID = nil
         cards[i].startBindingAnchor = nil
       }
-      if let end = cards[i].endBindingID, !existing.contains(end) {
+      if let end = cards[i].endBindingID, framesByID[end] == nil {
         cards[i].endBindingID = nil
         cards[i].endBindingAnchor = nil
       }
       if cards[i].startBindingID != nil || cards[i].endBindingID != nil {
-        updateBoundArrowGeometry(at: i, in: &cards)
+        updateBoundArrowGeometry(at: i, in: &cards, frames: framesByID)
       }
     }
   }

updateBoundArrowGeometry then reads frames[id] instead of scanning, and keeps a frames: default of nil for the single-arrow callers in bindArrowIfPossible and connectCards.

Also applies to: 2223-2242

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ComposerApp/Views/BoardViewModel.swift` around lines 2165 - 2180,
Update refreshBoundArrows and updateBoundArrowGeometry to build one UUID-to-card
frame or index lookup per refresh pass and reuse it for every bound arrow
instead of scanning cards with first. Preserve a default nil lookup for
single-arrow callers such as bindArrowIfPossible and connectCards, while
ensuring geometry resolution uses the precomputed map when provided.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ComposerApp/Views/BoardCardView.swift`:
- Line 78: Update the editing branch in BoardCardView to preserve the current
card.frame origin while applying only the size from liveTextFrame. Do not return
liveTextFrame directly; construct the effective frame with card.frame’s origin
and liveTextFrame’s live size so it matches renderingSnapshot when the card
moves during editing.

In `@Sources/ComposerApp/Views/ComposerCanvas.swift`:
- Around line 1570-1586: Update equation-card creation and the transition
handlers gotoOlder, gotoNewer, newBoard, and pickBoard so equation editing is
established synchronously before any navigation or board-selection flush can
occur. If delayed activation must remain, make the transition cleanup invoked by
board.flushSave remove the still-blank equation rather than relying solely on
EditingStage.

In `@Tests/ComposerAppTests/BoardPersistenceTests.swift`:
- Around line 7-18: Resolve the empty-board contract in DumpStore.cards(for:):
after flushSave() persists an empty snapshot, ensure reloading currentCards
remains empty rather than synthesizing CardState.firstCard(text: dump.text).
Keep the test’s empty-board assertions intact and update the cards(for:) logic
to distinguish genuinely empty boards from boards requiring the initial card.

In `@Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift`:
- Around line 26-30: Update the revision assertions in the test around
board.insertText and board.delete: capture board.boardTextContext.revision
immediately after the insertion assertion, then assert deletion advances beyond
that captured post-insert revision instead of comparing again with
afterDefinition.
- Around line 118-122: Update the undo assertion in the board render-budget test
to fetch the card again from board.cards after board.undo(), then pass that
refreshed card to plainText(for:). Preserve the existing expectation that the
restored text is not "active", while avoiding the stale activeCard snapshot
captured before setText.

---

Outside diff comments:
In `@Sources/ComposerApp/Views/BoardViewModel.swift`:
- Around line 2165-2180: Update refreshBoundArrows and updateBoundArrowGeometry
to build one UUID-to-card frame or index lookup per refresh pass and reuse it
for every bound arrow instead of scanning cards with first. Preserve a default
nil lookup for single-arrow callers such as bindArrowIfPossible and
connectCards, while ensuring geometry resolution uses the precomputed map when
provided.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2f769019-374a-4fd0-8be0-5a6bc3aeb908

📥 Commits

Reviewing files that changed from the base of the PR and between 4fb680f and c03b665.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • Sources/ComposerApp/Services/BoardExporter.swift
  • Sources/ComposerApp/Services/CanvasBridge.swift
  • Sources/ComposerApp/Views/BoardCardView.swift
  • Sources/ComposerApp/Views/BoardViewModel.swift
  • Sources/ComposerApp/Views/ComposerCanvas.swift
  • Sources/ComposerApp/Views/EditingStage.swift
  • Tests/ComposerAppTests/ArrowBindingTests.swift
  • Tests/ComposerAppTests/BoardPersistenceTests.swift
  • Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift
  • Tests/ComposerAppTests/TextHugTests.swift

Comment thread Sources/ComposerApp/Views/ComposerCanvas.swift Outdated
Comment thread Tests/ComposerAppTests/BoardPersistenceTests.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/ComposerApp/Views/BoardViewModel.swift (1)

2165-2180: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Build an id lookup once per refresh pass.

updateBoundArrowGeometry resolves each binding with cards.first { $0.id == id }. refreshBoundArrows(in:) calls it for every bound arrow, so the pass is O(arrows × cards).

renderingSnapshot now runs this same pass whenever a live text frame is applied. The save debounce fires during typing, so the scan repeats on the main actor for every mid-edit save on a large board.

Pass a precomputed [UUID: CGRect] frame map (or [UUID: Int] index map) into the geometry helper.

♻️ Proposed refactor sketch
 private static func refreshBoundArrows(in cards: inout [CardState]) {
-    let existing = Set(cards.map(\.id))
+    var framesByID: [UUID: CGRect] = [:]
+    framesByID.reserveCapacity(cards.count)
+    for card in cards { framesByID[card.id] = card.frame }
     for i in cards.indices where cards[i].elementKind == .arrow {
-      if let start = cards[i].startBindingID, !existing.contains(start) {
+      if let start = cards[i].startBindingID, framesByID[start] == nil {
         cards[i].startBindingID = nil
         cards[i].startBindingAnchor = nil
       }
-      if let end = cards[i].endBindingID, !existing.contains(end) {
+      if let end = cards[i].endBindingID, framesByID[end] == nil {
         cards[i].endBindingID = nil
         cards[i].endBindingAnchor = nil
       }
       if cards[i].startBindingID != nil || cards[i].endBindingID != nil {
-        updateBoundArrowGeometry(at: i, in: &cards)
+        updateBoundArrowGeometry(at: i, in: &cards, frames: framesByID)
       }
     }
   }

updateBoundArrowGeometry then reads frames[id] instead of scanning, and keeps a frames: default of nil for the single-arrow callers in bindArrowIfPossible and connectCards.

Also applies to: 2223-2242

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ComposerApp/Views/BoardViewModel.swift` around lines 2165 - 2180,
Update refreshBoundArrows and updateBoundArrowGeometry to build one UUID-to-card
frame or index lookup per refresh pass and reuse it for every bound arrow
instead of scanning cards with first. Preserve a default nil lookup for
single-arrow callers such as bindArrowIfPossible and connectCards, while
ensuring geometry resolution uses the precomputed map when provided.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ComposerApp/Views/BoardCardView.swift`:
- Line 78: Update the editing branch in BoardCardView to preserve the current
card.frame origin while applying only the size from liveTextFrame. Do not return
liveTextFrame directly; construct the effective frame with card.frame’s origin
and liveTextFrame’s live size so it matches renderingSnapshot when the card
moves during editing.

In `@Sources/ComposerApp/Views/ComposerCanvas.swift`:
- Around line 1570-1586: Update equation-card creation and the transition
handlers gotoOlder, gotoNewer, newBoard, and pickBoard so equation editing is
established synchronously before any navigation or board-selection flush can
occur. If delayed activation must remain, make the transition cleanup invoked by
board.flushSave remove the still-blank equation rather than relying solely on
EditingStage.

In `@Tests/ComposerAppTests/BoardPersistenceTests.swift`:
- Around line 7-18: Resolve the empty-board contract in DumpStore.cards(for:):
after flushSave() persists an empty snapshot, ensure reloading currentCards
remains empty rather than synthesizing CardState.firstCard(text: dump.text).
Keep the test’s empty-board assertions intact and update the cards(for:) logic
to distinguish genuinely empty boards from boards requiring the initial card.

In `@Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift`:
- Around line 26-30: Update the revision assertions in the test around
board.insertText and board.delete: capture board.boardTextContext.revision
immediately after the insertion assertion, then assert deletion advances beyond
that captured post-insert revision instead of comparing again with
afterDefinition.
- Around line 118-122: Update the undo assertion in the board render-budget test
to fetch the card again from board.cards after board.undo(), then pass that
refreshed card to plainText(for:). Preserve the existing expectation that the
restored text is not "active", while avoiding the stale activeCard snapshot
captured before setText.

---

Outside diff comments:
In `@Sources/ComposerApp/Views/BoardViewModel.swift`:
- Around line 2165-2180: Update refreshBoundArrows and updateBoundArrowGeometry
to build one UUID-to-card frame or index lookup per refresh pass and reuse it
for every bound arrow instead of scanning cards with first. Preserve a default
nil lookup for single-arrow callers such as bindArrowIfPossible and
connectCards, while ensuring geometry resolution uses the precomputed map when
provided.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2f769019-374a-4fd0-8be0-5a6bc3aeb908

📥 Commits

Reviewing files that changed from the base of the PR and between 4fb680f and c03b665.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • Sources/ComposerApp/Services/BoardExporter.swift
  • Sources/ComposerApp/Services/CanvasBridge.swift
  • Sources/ComposerApp/Views/BoardCardView.swift
  • Sources/ComposerApp/Views/BoardViewModel.swift
  • Sources/ComposerApp/Views/ComposerCanvas.swift
  • Sources/ComposerApp/Views/EditingStage.swift
  • Tests/ComposerAppTests/ArrowBindingTests.swift
  • Tests/ComposerAppTests/BoardPersistenceTests.swift
  • Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift
  • Tests/ComposerAppTests/TextHugTests.swift
🛑 Comments failed to post (3)
Sources/ComposerApp/Views/BoardCardView.swift (1)

78-78: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use only the live size, not the live origin.

liveTextFrame carries the origin captured when fitTextEditing last ran. renderingSnapshot deliberately keeps the committed origin and applies only the live size. The view takes the whole rect, so the two paths disagree.

If the card moves while the edit stays active, the view renders it at the stale origin until the next editor layout callback. Apply the live size over the current card.frame origin to match the model.

🐛 Proposed fix
-    if isEditing, let liveTextFrame { return liveTextFrame }
+    if isEditing, let liveTextFrame {
+      return CGRect(origin: card.frame.origin, size: liveTextFrame.size)
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    if isEditing, let liveTextFrame {
      return CGRect(origin: card.frame.origin, size: liveTextFrame.size)
    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ComposerApp/Views/BoardCardView.swift` at line 78, Update the editing
branch in BoardCardView to preserve the current card.frame origin while applying
only the size from liveTextFrame. Do not return liveTextFrame directly;
construct the effective frame with card.frame’s origin and liveTextFrame’s live
size so it matches renderingSnapshot when the card moves during editing.
Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift (2)

26-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the delete-invalidation assertion.

Line 28 already proved the revision exceeds afterDefinition. Line 30 repeats the same comparison, so it passes even if delete never invalidates the context. Capture the revision after the insert and compare against that value.

💚 Proposed fix
     let afterDefinition = board.boardTextContext.revision
     let inserted = board.insertText("$name", at: .zero)
     XCTAssertGreaterThan(board.boardTextContext.revision, afterDefinition)
+    let afterInsert = board.boardTextContext.revision
     board.delete(inserted)
-    XCTAssertGreaterThan(board.boardTextContext.revision, afterDefinition)
+    XCTAssertGreaterThan(board.boardTextContext.revision, afterInsert)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    let afterDefinition = board.boardTextContext.revision
    let inserted = board.insertText("$name", at: .zero)
    XCTAssertGreaterThan(board.boardTextContext.revision, afterDefinition)
    let afterInsert = board.boardTextContext.revision
    board.delete(inserted)
    XCTAssertGreaterThan(board.boardTextContext.revision, afterInsert)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift` around lines 26
- 30, Update the revision assertions in the test around board.insertText and
board.delete: capture board.boardTextContext.revision immediately after the
insertion assertion, then assert deletion advances beyond that captured
post-insert revision instead of comparing again with afterDefinition.

118-122: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The final assertion cannot fail.

activeCard is a CardState value captured before setText. plainText(for:) resolves a text card through interactions[card.id]?.plainText ?? card.text. restore clears interactions, so after undo() the lookup misses and the call returns activeCard.text, the stale pre-setText copy. That value is never "active", so the assertion passes even if the active board lost its undo stack.

Re-read the card from board.cards after the undo.

💚 Proposed fix
     // The current board owns its live stacks, not an evictable cache entry.
-    let activeCard = try XCTUnwrap(board.cards.first)
-    board.setText(activeCard.id, "active")
+    let activeID = try XCTUnwrap(board.cards.first?.id)
+    board.setText(activeID, "active")
+    XCTAssertEqual(board.plainText(for: try XCTUnwrap(board.cards.first { $0.id == activeID })), "active")
     board.undo()
-    XCTAssertNotEqual(board.plainText(for: activeCard), "active")
+    XCTAssertNotEqual(
+      board.plainText(for: try XCTUnwrap(board.cards.first { $0.id == activeID })), "active")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    // The current board owns its live stacks, not an evictable cache entry.
    let activeID = try XCTUnwrap(board.cards.first?.id)
    board.setText(activeID, "active")
    XCTAssertEqual(board.plainText(for: try XCTUnwrap(board.cards.first { $0.id == activeID })), "active")
    board.undo()
    XCTAssertNotEqual(
      board.plainText(for: try XCTUnwrap(board.cards.first { $0.id == activeID })), "active")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/ComposerAppTests/BoardViewModelRenderBudgetTests.swift` around lines
118 - 122, Update the undo assertion in the board render-budget test to fetch
the card again from board.cards after board.undo(), then pass that refreshed
card to plainText(for:). Preserve the existing expectation that the restored
text is not "active", while avoiding the stale activeCard snapshot captured
before setText.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread Tests/ComposerAppTests/BoardPersistenceTests.swift Outdated
Comment thread Sources/ComposerApp/Services/CanvasBridge.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread Sources/ComposerApp/Views/BoardViewModel.swift
@ojowwalker77
ojowwalker77 merged commit 153370b into main Aug 10, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discard empty text cards when editing is abandoned

1 participant