Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions e2e/multi-canvas.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Multi-canvas regressions — several canvas tabs in the center dock zone.
//
// Bug 1 (remount): DockTabStack rendered the active tab's content without a
// React key, so switching between two canvas tabs REUSED the same mounted
// Canvas instance with a swapped panelId prop. Canvas wires its world-transform
// subscription, wheel handling, and observers in mount-only effects, so the
// visible canvas kept rendering/zooming through the PREVIOUS canvas's store:
// zoom appeared dead, panels created on the hidden store never appeared, and
// the world transform showed another canvas's viewport.
//
// Bug 2 (placement routing): an unpinned panel create (keyboard shortcut,
// programmatic create) routed to the workspace's PRIMARY canvas (first canvas
// tab) instead of the ACTIVE one, so with a secondary canvas tab active the new
// panel landed on a hidden canvas.
import { test, expect } from '@playwright/test'
import { launchApp, closeApp } from './fixtures/electron-app'
import type { ElectronApplication, Page } from 'playwright'

let app: ElectronApplication
let page: Page

const EXTRA_CANVASES = 6

test.beforeEach(async () => {
;({ electronApp: app, mainWindow: page } = await launchApp())
await page.evaluate(() => window.__cateE2E!.setActiveLeftSidebarView(null))
// Seed: 6 extra canvas tabs beside the default one. Each create activates
// the new tab, so the LAST canvas ends up active/mounted.
for (let i = 0; i < EXTRA_CANVASES; i++) {
await page.evaluate(() => void window.__cateE2E!.createCanvasPanel({ x: 100, y: 100 }))
}
await page.waitForTimeout(200)
})
test.afterEach(async () => closeApp(app))

/** The single mounted canvas: its panel id, its world div's CSS transform, and
* its store zoom (the harness resolves the store by the mounted DOM id). */
function mountedCanvas(p: Page) {
return p.evaluate(() => {
const el = document.querySelector('[data-canvas-panel-id]') as HTMLElement | null
const world = el?.querySelector('div[style*="transform-origin"]') as HTMLElement | null
return {
id: el?.getAttribute('data-canvas-panel-id') ?? null,
transform: world?.style.transform ?? null,
zoom: window.__cateE2E!.zoom(),
}
})
}

/** Ids of all canvas tabs in the center tab strip, in order. */
function canvasTabIds(p: Page) {
return p.evaluate(() =>
Array.from(document.querySelectorAll('.dock-tab-bar [data-tab-panel-id]')).map(
(el) => el.getAttribute('data-tab-panel-id')!,
),
)
}

test('exactly one canvas is mounted and it is the last-created tab', async () => {
const tabs = await canvasTabIds(page)
expect(tabs.length).toBe(EXTRA_CANVASES + 1)

const mountedIds = await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-canvas-panel-id]')).map((el) =>
el.getAttribute('data-canvas-panel-id'),
),
)
expect(mountedIds).toEqual([tabs[tabs.length - 1]])
})

test('unpinned create lands on the ACTIVE canvas, not the hidden primary one', async () => {
const result = await page.evaluate(async () => {
const mountedId = document
.querySelector('[data-canvas-panel-id]')!
.getAttribute('data-canvas-panel-id')!
const nodeId = window.__cateE2E!.createTerminal({ x: 300, y: 300 })
await new Promise((r) => setTimeout(r, 200))
return {
mountedId,
nodeId,
nodesOnMounted: window.__cateE2E!.nodes().length,
nodeInDom: !!document.querySelector(`[data-node-id="${nodeId}"]`),
}
})
// The node must exist on the canvas the user is looking at AND be rendered.
expect(result.nodesOnMounted).toBe(1)
expect(result.nodeInDom).toBe(true)
})

test('zoom drives the mounted canvas: store and world transform stay in lock-step', async () => {
// Store-level zoom (toolbar/shortcut path) must move THIS canvas's world div.
const probe = await page.evaluate(async () => {
window.__cateE2E!.setZoom(2)
await new Promise((r) => setTimeout(r, 200))
const world = document.querySelector(
'[data-canvas-panel-id] div[style*="transform-origin"]',
) as HTMLElement
return { zoom: window.__cateE2E!.zoom(), transform: world.style.transform }
})
expect(probe.zoom).toBe(2)
expect(probe.transform).toContain('scale(2)')
})

test('ctrl+wheel zooms the mounted canvas store (not a previous tab)', async () => {
const probe = await page.evaluate(async () => {
const el = document.querySelector('[data-canvas-panel-id]') as HTMLElement
const r = el.getBoundingClientRect()
const before = window.__cateE2E!.zoom()
el.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -120,
clientX: r.left + r.width / 2,
clientY: r.top + r.height / 2,
ctrlKey: true,
bubbles: true,
cancelable: true,
}),
)
// Smooth zoom is rAF-driven; give it time to settle.
await new Promise((res) => setTimeout(res, 800))
return { before, after: window.__cateE2E!.zoom() }
})
expect(probe.before).toBe(1)
expect(probe.after).toBeGreaterThan(1)
})

test('switching canvas tabs remounts: each canvas shows its OWN viewport', async () => {
const tabs = await canvasTabIds(page)
const last = tabs[tabs.length - 1]
const secondToLast = tabs[tabs.length - 2]

// Zoom the active (last) canvas to 3.
await page.evaluate(() => window.__cateE2E!.setZoom(3))
await page.waitForTimeout(100)
expect((await mountedCanvas(page)).transform).toContain('scale(3)')

// Switch to the second-to-last canvas tab.
await page.click(`.dock-tab-bar [data-tab-panel-id="${secondToLast}"]`)
await page.waitForTimeout(200)

let mounted = await mountedCanvas(page)
expect(mounted.id).toBe(secondToLast)
// Its viewport is its own (zoom 1) — NOT the previous tab's scale(3).
expect(mounted.zoom).toBe(1)
expect(mounted.transform).toContain('scale(1)')

// Zooming THIS canvas updates THIS canvas's world div.
await page.evaluate(() => window.__cateE2E!.setZoom(2))
await page.waitForTimeout(100)
mounted = await mountedCanvas(page)
expect(mounted.zoom).toBe(2)
expect(mounted.transform).toContain('scale(2)')

// Switch back: the last canvas still has its own zoom 3.
await page.click(`.dock-tab-bar [data-tab-panel-id="${last}"]`)
await page.waitForTimeout(200)
mounted = await mountedCanvas(page)
expect(mounted.id).toBe(last)
expect(mounted.zoom).toBe(3)
expect(mounted.transform).toContain('scale(3)')
})

test('panels created across several canvas tabs land on their own canvas', async () => {
const tabs = await canvasTabIds(page)
// On each of the last three canvas tabs: activate, create, assert isolation.
for (const tabId of tabs.slice(-3)) {
await page.click(`.dock-tab-bar [data-tab-panel-id="${tabId}"]`)
await page.waitForTimeout(150)
const res = await page.evaluate(async (expected) => {
const mountedId = document
.querySelector('[data-canvas-panel-id]')!
.getAttribute('data-canvas-panel-id')!
const nodeId = window.__cateE2E!.createTerminal({ x: 200, y: 200 })
await new Promise((r) => setTimeout(r, 150))
return {
mountedOk: mountedId === expected,
nodes: window.__cateE2E!.nodes().length,
nodeInDom: !!document.querySelector(`[data-node-id="${nodeId}"]`),
}
}, tabId)
expect(res.mountedOk).toBe(true)
expect(res.nodes).toBe(1) // exactly its own node — no bleed from other tabs
expect(res.nodeInDom).toBe(true)
}
})
56 changes: 43 additions & 13 deletions src/agent/renderer/AgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
AuthProviderStatus,
} from '../../shared/types'
import type { AgentMessage as StoreMessage } from './agentStore'
import { loadDefaultModel } from './agentModelPrefs'
import { loadDefaultModel, clearModelPrefsForProvider } from './agentModelPrefs'

// -----------------------------------------------------------------------------
// Component
Expand Down Expand Up @@ -126,7 +126,7 @@
const retry = slice?.retry ?? { active: false }
const steeringQueue = slice?.steeringQueue ?? []
const followUpQueue = slice?.followUpQueue ?? []
const extensionStatuses = slice?.extensionStatuses ?? []

Check warning on line 129 in src/agent/renderer/AgentPanel.tsx

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

The 'extensionStatuses' logical expression could make the dependencies of useMemo Hook (at line 815) change on every render. To fix this, wrap the initialization of 'extensionStatuses' in its own useMemo() Hook
const extensionWidgets = slice?.extensionWidgets ?? []
// Composer draft lives in the active chat's slice so switching chats keeps
// each chat's own in-progress message + image attachments.
Expand All @@ -137,6 +137,9 @@
const currentUiRequest = uiRequests[0]

const [providerStatuses, setProviderStatuses] = useState<AuthProviderStatus[]>([])
/** False until the first authStatus() round-trip — gates the stale-model
* reset below so an empty initial status list never wipes a valid pick. */
const [authLoaded, setAuthLoaded] = useState(false)
const [availableModels, setAvailableModels] = useState<
Array<{ provider: string; model: string; label?: string }>
>([])
Expand Down Expand Up @@ -211,6 +214,7 @@
try {
const statuses = await window.electronAPI.authStatus()
setProviderStatuses(statuses)
setAuthLoaded(true)
} catch (err) {
log.warn('[AgentPanel] refreshAuth failed', err)
}
Expand Down Expand Up @@ -435,6 +439,7 @@
// Read the draft straight from the active slice so we never send a stale
// closure value mid-stream.
const cur = useAgentStore.getState().panels[activeAgentKey]
if (!cur?.model) return
const text = (cur?.draft ?? '').trim()
const images = (cur?.draftImages ?? []).slice()
if (!text && images.length === 0) return
Expand Down Expand Up @@ -563,6 +568,27 @@
return !!s?.connected
}, [selectedModel, providerStatuses])

// A model remembered from a provider the user has since cleared (saved
// default, or a resumed session's lastModel) should reset, not prompt a
// reconnect. Once real auth state is in, drop the stale pick — the auto-pick
// effect above then selects from whatever providers remain, or the "no
// model" hint shows when none do.
useEffect(() => {
if (!authLoaded || !activeAgentKey || !selectedModel) return
if (selectedProviderConnected) return
useAgentStore.getState().setModel(activeAgentKey, null)
clearModelPrefsForProvider(selectedModel.provider)
}, [authLoaded, activeAgentKey, selectedModel, selectedProviderConnected])

// With no model the composer is disabled and the placeholder doubles as the
// hint explaining why. The disconnected-provider term only matters in the
// window before the first authStatus() resolves — once it does, the reset
// effect above nulls the model and the no-model state takes over.
const composerDisabled = !selectedModel || !selectedProviderConnected
const composerPlaceholder = !selectedModel
? 'No model selected or no provider is set up yet'
: undefined

const filteredChats = useMemo(() => {
if (!chatSearch.trim()) return chats
const q = chatSearch.trim().toLowerCase()
Expand Down Expand Up @@ -1011,19 +1037,26 @@
/>
) : (
<div className="relative flex-1 flex flex-col min-h-0">
{selectedModel && !selectedProviderConnected && (
{!selectedModel ? (
<div className="px-3 py-2 bg-agent/10 border-b border-agent/30 flex items-center gap-2 text-[12px] text-primary">
<span className="flex-1 truncate">
Connect <strong>{selectedModel.provider}</strong> to start.
No model selected or no provider is set up yet.
</span>
<button
onClick={() => openProviderSettings()}
onClick={() => {
if (availableModels.length > 0) {
void refreshModels()
setModelPickerOpen(true)
} else {
openProviderSettings()
}
}}
className="px-2 py-1 rounded-md bg-agent hover:bg-agent-light text-white text-[11px] font-medium shrink-0"
>
Connect
{availableModels.length > 0 ? 'Pick model' : 'Set up provider'}
</button>
</div>
)}
) : null}

{/* Retry status is now shown inline in the chat thread */}
<ExtensionWidget widgets={extensionWidgets} placement="aboveEditor" />
Expand All @@ -1044,7 +1077,7 @@
onChange={setDraft}
onSubmit={handleSend}
onStop={handleInterrupt}
disabled={!!selectedModel && !selectedProviderConnected}
disabled={composerDisabled}
running={running}
textareaRef={textareaRef}
commands={commands}
Expand All @@ -1063,11 +1096,7 @@
planModeActive={planModeActive}
onTogglePlanMode={handleTogglePlanMode}
onSlashOpen={handleSlashOpen}
placeholder={
!selectedModel ? 'Pick a model to start…'
: !selectedProviderConnected ? `Connect ${selectedModel.provider} to start…`
: 'Ask the agent anything about this workspace…'
}
placeholder={composerPlaceholder ?? 'Ask the agent anything about this workspace…'}
/>
</div>
</div>
Expand Down Expand Up @@ -1101,7 +1130,7 @@
onChange={setDraft}
onSubmit={handleSend}
onStop={handleInterrupt}
disabled={!!selectedModel && !selectedProviderConnected}
disabled={composerDisabled}
running={running}
textareaRef={textareaRef}
commands={commands}
Expand All @@ -1120,6 +1149,7 @@
planModeActive={planModeActive}
onTogglePlanMode={handleTogglePlanMode}
onSlashOpen={handleSlashOpen}
placeholder={composerPlaceholder}
/>
</>
)}
Expand Down
6 changes: 6 additions & 0 deletions src/agent/renderer/agentModelPrefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ export function loadDefaultModel(): AgentModelRef | null {
export function saveDefaultModel(model: AgentModelRef | null): void {
useSettingsStore.getState().setSetting('agentDefaultModel', model)
}

/** Drop every saved model preference that points at a provider the user just
* disconnected, so a stale pick doesn't resurface as a "reconnect" prompt. */
export function clearModelPrefsForProvider(providerId: string): void {
if (loadDefaultModel()?.provider === providerId) saveDefaultModel(null)
}
Loading
Loading