fix(canvas): follow a newly created canvas when another is open (#3218) - #3225
Conversation
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>
/review — automated pre-landing review (at
|
| 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.
fetchDetailand 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 theselectSeqguard 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 specscanvasUtils,canvasPanelSelectorGate,canvasPanelControlRowandsourceTextRatchetall pass (135/135). - Docs:
feature-flows/agent-canvas.mddocuments 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
…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>
|
Review fixes in
Tests: 🤖 Generated with Claude Code |
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 neverrows[0], so a pinned canvas sorting ahead doesn't take its place.CanvasPanelfollows that id throughselect(), which emitscanvas-selected(so the next turn carries it) and keeps theselectSeqstale-fetch guard.feature-flows/agent-canvas.md.Changes
src/frontend/src/components/canvas/canvasUtils.js:canvasesAppearedsrc/frontend/src/components/canvas/CanvasPanel.vue: known-id tracking, the deferred follow, andpick()for reader pickssrc/frontend/tests/unit/canvasFollowNew.spec.js: helper casessrc/frontend/tests/unit/canvasPanelFollowNew.mount.spec.js: mounts the real panel and swapscanvases, one test per acceptance criteriondocs/memory/feature-flows/agent-canvas.mdTest 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 in0064dddf0.npm run test:unit: 258 files, 4599 pass, including the raw-colour, loading-gate and source-text ratchets.Fixes #3218
🤖 Generated with Claude Code