Skip to content

fix(canvas): follow a newly created canvas when another is open (#3218) - #3225

Merged
vybe merged 2 commits into
devfrom
fix/3218-follow-new-canvas
Oct 5, 2026
Merged

vybe merged 2 commits into
devfrom
fix/3218-follow-new-canvas

Conversation

@dolho

@dolho dolho commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A canvas an agent created while another canvas was open was added to the selector but never selected, so it stayed hidden until the reader found it in the dropdown. CanvasPanel's list watcher only re-selected when the open canvas disappeared from a refresh.

  • canvasesAppeared (canvasUtils.js, pure) returns the id a refresh brought that the previous list did not have. It returns nothing on the first load, and nothing when the whole list was replaced: the three mounts don't key the panel per agent, so switching agents swaps the list under it. When several canvases arrive at once, the most recently updated wins. It is never rows[0], so a pinned canvas sorting ahead doesn't take its place.
  • CanvasPanel follows that id through select(), which emits canvas-selected (so the next turn carries it) and keeps the selectSeq stale-fetch guard.
  • Mid-interaction rule (decided: defer). While manage mode, a search query or the share dialog is active, nothing switches. The arrival is followed when the interaction ends. It's dropped if the reader picks a canvas in the meantime or the arrival is gone.
  • Scope decision: an agent rewriting a different, existing canvas does not pull focus.
  • Unchanged: first-load selection (including the first list after an empty mount, as on Agent Detail), rewrite-in-place (ent#475), and the fallback when the open canvas is deleted.
  • Docs: the selection rules are written into feature-flows/agent-canvas.md.

Changes

  • src/frontend/src/components/canvas/canvasUtils.js: canvasesAppeared
  • src/frontend/src/components/canvas/CanvasPanel.vue: known-id tracking, the deferred follow, and pick() for reader picks
  • src/frontend/tests/unit/canvasFollowNew.spec.js: helper cases
  • src/frontend/tests/unit/canvasPanelFollowNew.mount.spec.js: mounts the real panel and swaps canvases, one test per acceptance criterion
  • docs/memory/feature-flows/agent-canvas.md

Test Plan

  • npx vitest run tests/unit/canvasFollowNew.spec.js tests/unit/canvasPanelFollowNew.mount.spec.js: 23 pass. The 5 follow tests fail without the fix; the agent-switch, rewrite, delete and no-new-id cases pass on the base too and are regression guards. The empty-mount (review C1) and search-pick (review I2) cases failed before their fixes in 0064dddf0.
  • npm run test:unit: 258 files, 4599 pass, including the raw-colour, loading-gate and source-text ratchets.
  • Manual: a Vite dev server from this branch against a local backend, checked by the author.
  • Workspace rail and voice column: same component, worth a glance on review.

Fixes #3218

🤖 Generated with Claude Code

The panel's list watcher only re-selected when the open canvas vanished
from a refresh, so a canvas the agent created while another was open
landed in the selector and stayed hidden.

- canvasesAppeared (canvasUtils.js) names the id a refresh brought that
  the previous list lacked: nothing on the first load or when the whole
  list was replaced (another agent's list under the same panel); the
  most recently updated when several arrive; never rows[0], so a pinned
  canvas sorting ahead does not steal it.
- CanvasPanel follows it through select() (canvas-selected emitted, the
  selectSeq race guard kept). While manage mode, a search or the share
  dialog is active the switch is deferred and happens when it ends; a
  canvas the reader picks meanwhile cancels it. A rewrite of a different
  existing canvas does not pull focus. Rewrite-in-place and the
  deleted-canvas fallback are unchanged.
- Flow doc: the selection rules in agent-canvas.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho dolho added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 5, 2026
@dolho

dolho commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

/review — automated pre-landing review (at 723b8537d)

Branch: fix/3218-follow-new-canvas → dev
Files Changed: 5 (+306/-2): CanvasPanel.vue, canvasUtils.js, 2 new specs, feature-flows/agent-canvas.md
Scope: CLEAN. Frontend canvas selection plus its flow doc, nothing outside the issue.
Plan Completion (issue #3218 ACs): 7 done / 0 partial / 0 not done / 1 changed / 0 unverifiable. AC3 ("initial load unchanged") holds only for a panel that mounts already populated. It regresses on Agent Detail, see C1.

AC Status Evidence
New id on refresh → selected DONE CanvasPanel.vue list watcher: else select(appeared)
Emits canvas-selected DONE goes through select() (emits)
Initial load unchanged CHANGED / regressed on Agent Detail C1
Followed even when a pinned canvas sorts ahead DONE canvasesAppeared never uses rows[0]
Rewrite in place + delete fallback DONE mount spec, "re-reads it in place" and "deleting the open canvas"
No yank mid-interaction (decide + document) DONE interacting + pendingFollow. The flow doc states "deferred".
Several arrive → most recently updated DONE canvasesAppeared loop
Pure helper + mounted behaviour test DONE canvasFollowNew.spec.js, canvasPanelFollowNew.mount.spec.js

Execution coverage (Step 2.5)

changed symbol / test file executed by live consumer verdict
canvasesAppeared (canvasUtils.js) canvasFollowNew.spec.js (direct calls) and the mount spec (through the panel) CanvasPanel.vue:590 ✅ executed
CanvasPanel list watcher / pendingFollow / interacting watcher canvasPanelFollowNew.mount.spec.js (jsdom mount, setProps swaps the list) AgentCanvasTab.vue:15, PortalRailCanvas.vue:50, PortalVoiceCanvas.vue:46 ✅ executed
pick() mount spec, setValue on canvas-select CanvasPanel.vue:33 (@update:model-value="pick") ✅ executed

Source-text grep (readFileSync|toContain|toMatch) over both new specs: no hits. Both are behavioural.

Fix mutation: I restored the merge-base CanvasPanel.vue from a scratch copy. 5/13 mount tests go red: the new-canvas switch, pinned-ahead, several-arrive, manage-defer and share-defer tests. The agent-switch, rewrite, delete and no-new-id cases stay green. They guard against regression and are not reproductions. The PR body says "the agent-switch case fail[s] without the fix", but it passes on base. The new helper could have broken it, and this test is a valid guard against that. The claim itself is inaccurate.

Critical Findings (block merge)

[C1] Behavioural regression: Agent Detail's first load no longer selects the first row of the pinned-first order (AC3) (Confidence: 9/10)
File: src/frontend/src/components/canvas/CanvasPanel.vue (list watcher, empty branch) + src/frontend/src/components/canvas/canvasUtils.js (canvasesAppeared)
Evidence:

// CanvasPanel.vue, empty branch of the props.canvases watcher
knownIds = new Set()
...
// canvasUtils.js
if (knownIds.size && !rows.some((c) => c && knownIds.has(c.canvas_id))) return null
...
if (!id || knownIds.has(id)) continue   // with an empty set, EVERY row is "new"
// AgentCanvasTab.vue: the panel mounts BEFORE the list loads
const canvases = ref([])
...
canvases.value = Array.isArray(data) ? data : []

Issue: The immediate watcher fires on mount with [] and sets knownIds to an empty Set, not null. When the real list arrives, canvasesAppeared skips the "replaced list" check because knownIds.size is 0, so every row counts as an arrival and it returns the most recently updated one. selectedId is null, so the watcher's fallback runs select(appeared || rows[0].canvas_id), and appeared wins over rows[0]. Agent Detail opens on the most recently updated canvas instead of the pinned one. I reproduced this in a scratch jsdom spec: mount with [], then setProps to [pinned 'p' (older), 'x' (newer)]. On this branch the result is Expected: "p" / Received: "x". The same spec passes against the merge-base panel. The PR's mount spec does not catch this because mountPanel(list) always mounts with a populated list, which is the shape of the portal rail (v-if="rows(agent).length") and not of Agent Detail. Agent Detail is the only mount that renders the panel before data arrives.
Fix: In the empty branch, reset to "never loaded" (knownIds = null) instead of new Set(). The next non-empty list then counts as a first load and selects rows[0]. When an agent goes from zero canvases to one, rows[0] is that canvas, so nothing is lost. Add a mount test that starts from canvases: [] and then loads a list with a pinned row ahead of a newer one, asserting the pinned row opens. That test is red on this branch today. Also update the helper's hostile-input case or its doc, since an empty knownIds would then be unreachable from the panel.
Why: This silently breaks the stated "initial load is unchanged" AC on the primary operator surface. Pinning exists to choose what opens first, and that choice is now ignored there every time the tab mounts.

Informational Findings (review required)

[I1] Test gap: the mount fixture covers only the pre-populated mount shape (Confidence: 8/10)
File: src/frontend/tests/unit/canvasPanelFollowNew.mount.spec.js (mountPanel(list, ...))
Issue: All 13 cases mount with BASE already present, so the empty → loaded transition on Agent Detail is never executed. This is the root cause of C1 reaching a green suite.
Suggestion: Add the [] → list case described in C1, and an [] → list → list-plus-new case so the follow still works after an empty first mount.

[I2] Programmatic selects during an interaction leave a stale pendingFollow (Confidence: 6/10)
File: src/frontend/src/components/canvas/CanvasPanel.vue (the canvasAutoSelect watcher and the delete-fallback select(...))
Issue: Only pick() clears pendingFollow. Suppose a search narrows the matches, canvasAutoSelect runs select(next), and the reader then clears the query. The deferred follow fires and moves the reader off the match they just found by searching. The flow doc's rule ("dropped if the reader picks a canvas meanwhile") arguably covers a search-driven selection too.
Suggestion: Decide whether a search-driven selection counts as a reader pick. If it does, clear pendingFollow in the canvasAutoSelect watcher, and add one sentence to the flow doc either way.

[I3] Edge: the "replaced list" heuristic swallows a real arrival when an agent deletes all its old canvases and creates one in the same refresh (Confidence: 6/10)
File: src/frontend/src/components/canvas/canvasUtils.js (if (knownIds.size && !rows.some(...)) return null)
Issue: That refresh looks like an agent switch. The open canvas is gone, so the result is rows[0], which is correct when there is only one new canvas and may differ when there are several. This is low impact, but the doc presents the heuristic as exact.
Suggestion: Optionally key the panel per agent in AgentCanvasTab/the rail (:key="agentName"), which makes the heuristic unnecessary. Otherwise note the limitation in the helper's docstring.

[I4] PR body inaccuracy (Confidence: 9/10)
The Test Plan says "The 5 follow tests and the agent-switch case fail without the fix". With the merge-base panel restored, the agent-switch case passes. See the mutation note above.

Clean Categories

  • SQL/data safety, auth boundaries, credential exposure: none apply. The change is frontend-only and makes no new API calls. fetchDetail and the actions are still injected unchanged.
  • Enterprise disclosure: the flow-doc addition describes OSS canvas selection only.
  • Race/stale-fetch: follows go through select(), so the selectSeq guard still covers a follow followed by a click (if (seq !== selectSeq) return // superseded).
  • Frontend design-system: no template or class changes beyond @update:model-value="pick", so the raw-color and loading-gate ratchets are unaffected. Neighbour specs canvasUtils, canvasPanelSelectorGate, canvasPanelControlRow and sourceTextRatchet all pass (135/135).
  • Docs: feature-flows/agent-canvas.md documents the rules, the scope decision and the revision row.

Summary

  • Critical: 1. MUST FIX before merge (C1: Agent Detail first load regresses AC3; one-line fix plus one test).
  • Informational: 4. Review recommended.
  • Scope: clean.

Suggested learning / deferred debt: "A watch(..., { immediate: true }) over a prop that a parent initialises to [] sees the empty list as the first load. Any 'previous state' sentinel must treat empty-before-first-data as never loaded, and mount fixtures should cover the parent's real initial prop, not a pre-populated one."

🤖 Generated with Claude Code

@dolho dolho added the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 5, 2026
…ncels a waiting follow (#3218)

Review C1: Agent Detail mounts CanvasPanel with canvases=[] before its list
loads. The empty branch set knownIds to an empty Set, so every row of the
first real list counted as an arrival and the most recently updated canvas
opened instead of the pinned-first row. The empty branch now resets knownIds
to null, so the next list is a first load.

Review I2: a match canvasAutoSelect selects during a search now goes through
pick(), clearing a pending follow, so clearing the query does not move the
reader off the canvas they searched for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Review fixes in 0064dddf0:

  • C1 (fixed): the empty-list branch of the canvases watcher now resets knownIds = null instead of new Set(), so the first real list after Agent Detail's [] mount is a first load and opens the pinned-first row. New mount tests: [] → [pinned p (older), x (newer)] opens p (was Expected "p" / Received "x" before the fix), and [] → list → list + new still follows the new canvas. The canvasesAppeared docstring now says the panel never passes an empty Set.
  • I1 (fixed): covered by the two empty-mount tests above.
  • I2 (fixed): the canvasAutoSelect watcher now calls pick(), so a match the search selects cancels a waiting follow. New test: a canvas arrives during a search, the search narrows to c, the query is cleared, and c stays open (was Received "new" before the fix). The flow doc says a search-selected match counts as a pick.
  • I3 (documented): the delete-all-and-create-one-in-the-same-refresh case is now named as a known limit in the canvasesAppeared docstring. No behaviour change.
  • I4 (fixed): the PR body's Test Plan now says only the 5 follow tests fail on base and the agent-switch, rewrite, delete and no-new-id cases are regression guards.

Tests: npx vitest run tests/unit/canvasFollowNew.spec.js tests/unit/canvasPanelFollowNew.mount.spec.js gives 23/23. npm run test:unit gives 258 files and 4599/4599.

🤖 Generated with Claude Code

@dolho dolho removed the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 5, 2026

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

merge-train: batch validated on train/20261005-1214 (#3228, all gates green)

@vybe
vybe merged commit f9d7e8f into dev Oct 5, 2026
25 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants