diff --git a/apps/agor-ui/src/components/SessionCanvas/canvas/BoardObjectNodes.test.tsx b/apps/agor-ui/src/components/SessionCanvas/canvas/BoardObjectNodes.test.tsx index d4919536a..f0acd5619 100644 --- a/apps/agor-ui/src/components/SessionCanvas/canvas/BoardObjectNodes.test.tsx +++ b/apps/agor-ui/src/components/SessionCanvas/canvas/BoardObjectNodes.test.tsx @@ -2,10 +2,19 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { App as AntApp } from 'antd'; import type { ReactNode } from 'react'; import { ReactFlowProvider } from 'reactflow'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConnectionProvider } from '../../../contexts/ConnectionContext'; import { ZoneNode } from './BoardObjectNodes'; +const zoneConfigModalRenderSpy = vi.hoisted(() => vi.fn()); + +vi.mock('./ZoneConfigModal', () => ({ + ZoneConfigModal: (props: { zoneName: string }) => { + zoneConfigModalRenderSpy(props); + return
Configure Zone: {props.zoneName}
; + }, +})); + const CONNECTED = { connected: true, connecting: false, @@ -106,3 +115,21 @@ describe('ZoneNode layer toolbar', () => { expect(onReorder).not.toHaveBeenCalled(); }); }); + +describe('ZoneNode config modal', () => { + beforeEach(() => { + zoneConfigModalRenderSpy.mockClear(); + }); + + it('mounts the configuration modal only when the user opens it', () => { + renderZone(vi.fn(), CONNECTED); + + expect(zoneConfigModalRenderSpy).not.toHaveBeenCalled(); + expect(screen.queryByTestId('zone-config-modal')).not.toBeInTheDocument(); + + fireEvent.pointerUp(screen.getByTitle('Configure zone')); + + expect(zoneConfigModalRenderSpy).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('zone-config-modal')).toHaveTextContent('Configure Zone: My Zone'); + }); +}); diff --git a/apps/agor-ui/src/components/SessionCanvas/canvas/BoardObjectNodes.tsx b/apps/agor-ui/src/components/SessionCanvas/canvas/BoardObjectNodes.tsx index 2063f9e17..2c267c2f5 100644 --- a/apps/agor-ui/src/components/SessionCanvas/canvas/BoardObjectNodes.tsx +++ b/apps/agor-ui/src/components/SessionCanvas/canvas/BoardObjectNodes.tsx @@ -17,7 +17,7 @@ import { import { ColorPicker, theme } from 'antd'; import type { Color } from 'antd/es/color-picker'; import { AggregationColor } from 'antd/es/color-picker/color'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { NodeResizer, useViewport } from 'reactflow'; import { useMutationGate } from '../../../contexts/ConnectionContext'; import { DeleteZoneModal } from './DeleteZoneModal'; @@ -49,27 +49,13 @@ const getColorPalette = (token: ReturnType['token']) => [ token.magenta6 || token.magenta, // magenta-6 ]; +type ZoneBoardObject = Extract; + /** * ZoneNode - Resizable rectangle for organizing sessions visually */ -interface ZoneNodeData { +interface ZoneNodeData extends Omit { objectId: string; - label: string; - width: number; - height: number; - borderColor?: string; - backgroundColor?: string; - /** @deprecated Use borderColor instead */ - color?: string; - status?: string; - locked?: boolean; - /** Label/status font size in px. Falls back to the theme default when unset. */ - fontSize?: number; - /** Effective base stacking order (persisted value or per-type default). */ - zIndex?: number; - x: number; - y: number; - trigger?: BoardObject extends { type: 'zone'; trigger?: infer T } ? T : never; pinnedItemCount?: number; onUpdate?: (objectId: string, objectData: BoardObject) => void; onDelete?: (objectId: string, deleteAssociatedSessions: boolean) => void; @@ -149,36 +135,43 @@ const ZoneNodeComponent = ({ data, selected }: { data: ZoneNodeData; selected?: } }, [isEditingLabel]); + const zoneData = useMemo( + () => ({ + type: 'zone', + x: data.x, + y: data.y, + width: data.width, + height: data.height, + label: data.label, + borderColor: data.borderColor, + backgroundColor: data.backgroundColor, + color: data.color, + status: data.status, + locked: data.locked, + fontSize: data.fontSize, + zIndex: data.zIndex, + trigger: data.trigger, + }), + [ + data.x, + data.y, + data.width, + data.height, + data.label, + data.borderColor, + data.backgroundColor, + data.color, + data.status, + data.locked, + data.fontSize, + data.zIndex, + data.trigger, + ] + ); + // Helper to create full object data with current values - const createObjectData = ( - overrides: Partial<{ - width: number; - height: number; - label: string; - borderColor?: string; - backgroundColor?: string; - color?: string; - status?: string; - locked?: boolean; - fontSize?: number; - zIndex?: number; - trigger?: BoardObject extends { type: 'zone'; trigger?: infer T } ? T : never; - }> - ): BoardObject => ({ - type: 'zone', - x: data.x, - y: data.y, - width: data.width, - height: data.height, - label: data.label, - borderColor: data.borderColor, - backgroundColor: data.backgroundColor, - color: data.color, - status: data.status, - locked: data.locked, - fontSize: data.fontSize, - zIndex: data.zIndex, - trigger: data.trigger, + const createObjectData = (overrides: Partial>): BoardObject => ({ + ...zoneData, ...overrides, }); @@ -905,26 +898,30 @@ const ZoneNodeComponent = ({ data, selected }: { data: ZoneNodeData; selected?: )} - setConfigModalOpen(false)} - zoneName={data.label} - objectId={data.objectId} - onUpdate={data.onUpdate || (() => {})} - zoneData={createObjectData({})} - /> - setDeleteModalOpen(false)} - onConfirm={(deleteAssociatedSessions) => { - setDeleteModalOpen(false); - if (data.onDelete) { - data.onDelete(data.objectId, deleteAssociatedSessions); - } - }} - zoneName={data.label} - pinnedItemCount={data.pinnedItemCount || 0} - /> + {configModalOpen && ( + setConfigModalOpen(false)} + zoneName={data.label} + objectId={data.objectId} + onUpdate={data.onUpdate || (() => {})} + zoneData={zoneData} + /> + )} + {deleteModalOpen && ( + setDeleteModalOpen(false)} + onConfirm={(deleteAssociatedSessions) => { + setDeleteModalOpen(false); + if (data.onDelete) { + data.onDelete(data.objectId, deleteAssociatedSessions); + } + }} + zoneName={data.label} + pinnedItemCount={data.pinnedItemCount || 0} + /> + )} ); }; diff --git a/apps/agor-ui/src/components/SessionCanvas/canvas/ZoneConfigModal.test.tsx b/apps/agor-ui/src/components/SessionCanvas/canvas/ZoneConfigModal.test.tsx new file mode 100644 index 000000000..d5237c805 --- /dev/null +++ b/apps/agor-ui/src/components/SessionCanvas/canvas/ZoneConfigModal.test.tsx @@ -0,0 +1,44 @@ +import type { BoardObject } from '@agor-live/client'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ZoneConfigModal } from './ZoneConfigModal'; + +const zoneData = (label: string): BoardObject => ({ + type: 'zone', + x: 0, + y: 0, + width: 400, + height: 600, + label, +}); + +describe('ZoneConfigModal', () => { + it('does not reset in-progress edits during parent rerenders while open', () => { + const { rerender } = render( + {}} + zoneName="Zone" + objectId="zone-1" + onUpdate={() => {}} + zoneData={zoneData('Zone')} + /> + ); + + const nameInput = screen.getByLabelText('Zone Name'); + fireEvent.change(nameInput, { target: { value: 'Draft name' } }); + + rerender( + {}} + zoneName="Zone" + objectId="zone-1" + onUpdate={() => {}} + zoneData={zoneData('Zone')} + /> + ); + + expect(nameInput).toHaveValue('Draft name'); + }); +}); diff --git a/apps/agor-ui/src/components/SessionCanvas/canvas/ZoneConfigModal.tsx b/apps/agor-ui/src/components/SessionCanvas/canvas/ZoneConfigModal.tsx index 61de53996..d5b18c9bd 100644 --- a/apps/agor-ui/src/components/SessionCanvas/canvas/ZoneConfigModal.tsx +++ b/apps/agor-ui/src/components/SessionCanvas/canvas/ZoneConfigModal.tsx @@ -47,17 +47,26 @@ export const ZoneConfigModal = ({ const triggerBehavior = Form.useWatch('triggerBehavior', form); - // Reset form when modal opens (prevent WebSocket updates from erasing user input) + const zoneTrigger = zoneData.type === 'zone' ? zoneData.trigger : undefined; + const hasZoneTrigger = Boolean(zoneTrigger); + const zoneTriggerBehavior = zoneTrigger?.behavior; + const zoneTriggerTemplate = zoneTrigger?.template; + const zoneTriggerAgent = zoneTrigger?.agent; + + // Reset form when modal opens (prevent WebSocket updates from erasing user input). + // Keep dependencies to stable primitive fields: ZoneNode re-renders often, and + // passing a freshly-created zone object through this effect used to make AntD's + // Form/Modal tree re-run initialization work unnecessarily while open. useEffect(() => { if (open && !isInitializingRef.current) { isInitializingRef.current = true; - if (zoneData.type === 'zone' && zoneData.trigger) { + if (hasZoneTrigger) { form.setFieldsValue({ name: zoneName, - triggerBehavior: zoneData.trigger.behavior, - triggerTemplate: zoneData.trigger.template, + triggerBehavior: zoneTriggerBehavior, + triggerTemplate: zoneTriggerTemplate, }); - setTriggerAgent(zoneData.trigger.agent || 'claude-code'); + setTriggerAgent(zoneTriggerAgent || 'claude-code'); } else { form.setFieldsValue({ name: zoneName, @@ -69,7 +78,15 @@ export const ZoneConfigModal = ({ } else if (!open) { isInitializingRef.current = false; } - }, [open, zoneName, zoneData, form]); + }, [ + open, + zoneName, + hasZoneTrigger, + zoneTriggerBehavior, + zoneTriggerTemplate, + zoneTriggerAgent, + form, + ]); const handleSave = async () => { if (!mutationGate.canMutate) return;