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;