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
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div data-testid="zone-config-modal">Configure Zone: {props.zoneName}</div>;
},
}));

const CONNECTED = {
connected: true,
connecting: false,
Expand Down Expand Up @@ -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');
});
});
131 changes: 64 additions & 67 deletions apps/agor-ui/src/components/SessionCanvas/canvas/BoardObjectNodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -49,27 +49,13 @@ const getColorPalette = (token: ReturnType<typeof theme.useToken>['token']) => [
token.magenta6 || token.magenta, // magenta-6
];

type ZoneBoardObject = Extract<BoardObject, { type: 'zone' }>;

/**
* ZoneNode - Resizable rectangle for organizing sessions visually
*/
interface ZoneNodeData {
interface ZoneNodeData extends Omit<ZoneBoardObject, 'type'> {
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;
Expand Down Expand Up @@ -149,36 +135,43 @@ const ZoneNodeComponent = ({ data, selected }: { data: ZoneNodeData; selected?:
}
}, [isEditingLabel]);

const zoneData = useMemo<ZoneBoardObject>(
() => ({
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<Omit<ZoneBoardObject, 'type'>>): BoardObject => ({
...zoneData,
...overrides,
});

Expand Down Expand Up @@ -905,26 +898,30 @@ const ZoneNodeComponent = ({ data, selected }: { data: ZoneNodeData; selected?:
</div>
)}
</div>
<ZoneConfigModal
open={configModalOpen}
onCancel={() => setConfigModalOpen(false)}
zoneName={data.label}
objectId={data.objectId}
onUpdate={data.onUpdate || (() => {})}
zoneData={createObjectData({})}
/>
<DeleteZoneModal
open={deleteModalOpen}
onCancel={() => setDeleteModalOpen(false)}
onConfirm={(deleteAssociatedSessions) => {
setDeleteModalOpen(false);
if (data.onDelete) {
data.onDelete(data.objectId, deleteAssociatedSessions);
}
}}
zoneName={data.label}
pinnedItemCount={data.pinnedItemCount || 0}
/>
{configModalOpen && (
<ZoneConfigModal
open={configModalOpen}
onCancel={() => setConfigModalOpen(false)}
zoneName={data.label}
objectId={data.objectId}
onUpdate={data.onUpdate || (() => {})}
zoneData={zoneData}
/>
)}
{deleteModalOpen && (
<DeleteZoneModal
open={deleteModalOpen}
onCancel={() => setDeleteModalOpen(false)}
onConfirm={(deleteAssociatedSessions) => {
setDeleteModalOpen(false);
if (data.onDelete) {
data.onDelete(data.objectId, deleteAssociatedSessions);
}
}}
zoneName={data.label}
pinnedItemCount={data.pinnedItemCount || 0}
/>
)}
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<ZoneConfigModal
open
onCancel={() => {}}
zoneName="Zone"
objectId="zone-1"
onUpdate={() => {}}
zoneData={zoneData('Zone')}
/>
);

const nameInput = screen.getByLabelText('Zone Name');
fireEvent.change(nameInput, { target: { value: 'Draft name' } });

rerender(
<ZoneConfigModal
open
onCancel={() => {}}
zoneName="Zone"
objectId="zone-1"
onUpdate={() => {}}
zoneData={zoneData('Zone')}
/>
);

expect(nameInput).toHaveValue('Draft name');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down