diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte index 97af435dda..37db9a90ba 100644 --- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte @@ -196,14 +196,15 @@ return { errorCount: 0, warningCount: 0, errors: [], warnings: [] } }, - setFlowJson: async ({ modules, schema, preprocessorModule, failureModule, groups }) => { + setFlowJson: async ({ modules, schema, preprocessorModule, failureModule, groups, notes }) => { try { if ( modules !== undefined || schema !== undefined || preprocessorModule !== undefined || failureModule !== undefined || - groups !== undefined + groups !== undefined || + notes !== undefined ) { // Take snapshot of current flowStore and set as beforeFlow if (!diffManager?.hasPendingChanges) { @@ -218,7 +219,8 @@ schema, preprocessorModule, failureModule, - groups + groups, + notes }) // Refresh the state store to update UI diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index 77fc955d56..af616cca84 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -32,7 +32,13 @@ import type { ContextElement } from '../context' import type { ExtendedOpenFlow } from '$lib/components/flows/types' import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree' import { createInlineScriptSession, type InlineScriptSession } from './inlineScriptsUtils' -import { validateFlowGroups, type FlowGroup, type FlowJsonUpdateResult } from './helperUtils' +import { + validateFlowGroups, + validateFlowNotes, + type FlowGroup, + type FlowNote, + type FlowJsonUpdateResult +} from './helperUtils' import { flowModuleSchema } from './openFlowZod.gen' import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree' import { @@ -50,6 +56,7 @@ type FlowJsonUpdate = { preprocessorModule?: FlowModule | null failureModule?: FlowModule | null groups?: FlowGroup[] | null + notes?: FlowNote[] | null } function formatEmptyInlineScriptWarning({ @@ -179,6 +186,13 @@ const setFlowJsonToolSchema = z.object({ .nullable() .describe( 'JSON string containing the optional array of semantic flow groups. Each group has summary, note, autocollapse, start_id, end_id, color. color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes or other strings. Pass null to clear groups.' + ), + notes: z + .string() + .optional() + .nullable() + .describe( + 'JSON string containing the optional array of free-floating sticky notes attached to the flow. Use notes to surface important flow-wide information (what the flow does, key assumptions, warnings, TODOs). Each note has id (unique string), text (markdown), color (one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes), and optional position {x,y} and size {width,height} — omit both and the editor places and sizes the note automatically. Always use type "free". The "group" note type is DEPRECATED — to segment a complex flow into labelled, colored sections use the `groups` field instead (each group carries its own note and color). Pass null to clear notes.' ) }) @@ -548,7 +562,8 @@ export const flowTools: Tool[] = [ schema: parsedFlow.schema, preprocessorModule: parsedFlow.preprocessor_module, failureModule: parsedFlow.failure_module, - groups: parsedFlow.groups + groups: parsedFlow.groups, + notes: parsedFlow.notes }) const warning = formatEmptyInlineScriptWarning(updateResult) @@ -651,13 +666,14 @@ export const flowTools: Tool[] = [ showDetails: true, showFade: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const { modules, schema, preprocessor_module, failure_module, groups } = args + const { modules, schema, preprocessor_module, failure_module, groups, notes } = args let parsedModules: FlowModule[] | null | undefined let parsedSchema: Record | null | undefined let parsedPreprocessorModule: FlowModule | null | undefined let parsedFailureModule: FlowModule | null | undefined let parsedGroups: FlowGroup[] | null | undefined + let parsedNotes: FlowNote[] | null | undefined // Parse JSON strings parsedModules = parseOptionalJsonArg(modules, 'modules') as FlowModule[] | null | undefined @@ -674,6 +690,7 @@ export const flowTools: Tool[] = [ | null | undefined parsedGroups = parseOptionalJsonArg(groups, 'groups') as FlowGroup[] | null | undefined + parsedNotes = parseOptionalJsonArg(notes, 'notes') as FlowNote[] | null | undefined if (parsedModules === null) { parsedModules = undefined } @@ -702,11 +719,16 @@ export const flowTools: Tool[] = [ ) parsedFailureModule = validateSpecialFlowModule(parsedFailureModule, 'failure_module') - if (parsedGroups !== undefined) { + if (parsedGroups !== undefined || parsedNotes !== undefined) { const effectiveModules = parsedModules ?? helpers.getFlowAndSelectedId().flow.value.modules ?? [] const moduleIdsForGroups = new Set(collectAllFlowModuleIdsFromModules(effectiveModules)) - parsedGroups = validateFlowGroups(parsedGroups, moduleIdsForGroups) + if (parsedGroups !== undefined) { + parsedGroups = validateFlowGroups(parsedGroups, moduleIdsForGroups) + } + if (parsedNotes !== undefined) { + parsedNotes = validateFlowNotes(parsedNotes, moduleIdsForGroups) + } } const ids = [ @@ -729,7 +751,8 @@ export const flowTools: Tool[] = [ ? { preprocessorModule: parsedPreprocessorModule } : {}), ...(parsedFailureModule !== undefined ? { failureModule: parsedFailureModule } : {}), - ...(parsedGroups !== undefined ? { groups: parsedGroups } : {}) + ...(parsedGroups !== undefined ? { groups: parsedGroups } : {}), + ...(parsedNotes !== undefined ? { notes: parsedNotes } : {}) }) const warning = formatEmptyInlineScriptWarning(updateResult) @@ -831,7 +854,7 @@ export function prepareFlowSystemMessage(customPrompt?: string): ChatCompletionS Use \`patch_flow_json\` for small, localized changes when you can target an exact snippet from the \`CURRENT FLOW JSON COMPACT\` block below. Always copy the exact search text from the \`CURRENT FLOW JSON COMPACT\` block below. -The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\` keys. +The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\` keys. **Parameters:** - \`old_string\`: Exact JSON text to find @@ -859,7 +882,16 @@ Use the \`set_flow_json\` tool to set the entire flow structure at once. Provide - \`schema\`: Flow input schema in JSON Schema format (optional) - \`preprocessor_module\`: Special module that runs before \`modules\` (optional, separate from \`modules\`) - \`failure_module\`: Special module that runs on failure (optional, separate from \`modules\`) -- \`groups\`: Array of semantic groups for organizing modules in the editor (optional). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). \`color\` MUST be one of these exact names: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — do NOT use hex codes, CSS colors, or any other strings. Omit \`color\` entirely if no preference and the editor will assign one automatically. Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups. +- \`groups\`: Array of semantic groups for organizing modules in the editor (optional, but **strongly recommended** — proactively segment any non-trivial flow into groups so it reads clearly; don't wait to be asked). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). \`color\` MUST be one of these exact names: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — do NOT use hex codes, CSS colors, or any other strings. Omit \`color\` entirely if no preference and the editor will assign one automatically. Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups. +- \`notes\`: Array of free-floating sticky notes shown in the editor (optional). Each note has \`id\` (unique string), \`text\` (markdown content), \`color\` (same palette as groups: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — never hex codes), and optional \`position\` {x, y} / \`size\` {width, height} (omit both — the editor auto-places and sizes the note). Always set \`type\` to \`free\`. The \`group\` note type is **deprecated** — do not create group notes; use the \`groups\` field to segment a flow instead. Notes are documentation only and do not affect execution. Pass \`null\` to clear existing notes. + +### When to use notes vs groups + +**Strongly prefer \`groups\` to organize flows.** Groups are the primary way to make a flow readable: whenever a flow has more than a couple of steps, or any time consecutive steps form a logical stage (e.g. "fetch", "transform", "notify"), segment them into \`groups\`. Each group spans a range of steps (\`start_id\`..\`end_id\`), carries its own \`summary\`, \`note\` (markdown under the group header), and \`color\`, and can be collapsed. Proactively add or update groups when building or restructuring a flow — do not wait to be asked. Aim for every meaningful step to belong to a semantic group. + +- **\`groups\` (default, use liberally):** segment a flow into labelled semantic sections. This is the main organizational tool — reach for it on essentially any non-trivial flow, not just "complex" ones. +- **\`notes\` (free sticky notes, use sparingly):** reserve for important flow-wide information that does not belong to a specific span of steps — overall purpose, key assumptions, warnings, or TODOs. Usually a single note is enough; do not use notes to label sequences of steps (that is what \`groups\` are for). +- Do **not** use \`group\`-type notes (deprecated) — \`groups\` is the supported way to group steps. **Example - Simple flow:** \`\`\`javascript diff --git a/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts b/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts index a4b0ea72d5..2f1cb5a699 100644 --- a/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts +++ b/frontend/src/lib/components/copilot/chat/flow/editableFlowJson.ts @@ -3,7 +3,7 @@ import type { FlowModule, FlowValue } from '$lib/gen' import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree' import { SPECIAL_MODULE_IDS } from '../shared' import type { InlineScriptSession } from './inlineScriptsUtils' -import { validateFlowGroups, type FlowGroup } from './helperUtils' +import { validateFlowGroups, validateFlowNotes, type FlowGroup, type FlowNote } from './helperUtils' import { flowModuleSchema, flowModulesSchema } from './openFlowZod.gen' /** @@ -19,6 +19,7 @@ export type EditableFlowJson = { preprocessor_module: FlowModule | null failure_module: FlowModule | null groups: FlowGroup[] | null + notes: FlowNote[] | null } /** Optional input to the rich-error path of `validateEditableFlowJson`. */ @@ -145,8 +146,10 @@ function formatJsonSchemaForError(jsonSchema: any): string { if (jsonSchema.enum) { return `one of: ${jsonSchema.enum.map((v: any) => JSON.stringify(v)).join(', ')}` } - if (jsonSchema.oneOf) return jsonSchema.oneOf.map((s: any) => formatJsonSchemaForError(s)).join(' | ') - if (jsonSchema.anyOf) return jsonSchema.anyOf.map((s: any) => formatJsonSchemaForError(s)).join(' | ') + if (jsonSchema.oneOf) + return jsonSchema.oneOf.map((s: any) => formatJsonSchemaForError(s)).join(' | ') + if (jsonSchema.anyOf) + return jsonSchema.anyOf.map((s: any) => formatJsonSchemaForError(s)).join(' | ') if (jsonSchema.description) return jsonSchema.description return jsonSchema.type || JSON.stringify(jsonSchema) } @@ -177,7 +180,10 @@ function getExpectedFormat(schema: z.ZodType): string | null { return null } -export function validateFlowModules(rawModules: unknown, ctx: SchemaErrorContext = {}): FlowModule[] { +export function validateFlowModules( + rawModules: unknown, + ctx: SchemaErrorContext = {} +): FlowModule[] { if (!Array.isArray(rawModules)) { throw new Error('Flow modules must be an array') } @@ -275,6 +281,7 @@ export function validateEditableFlowJson( const failureModule = validateOptionalFlowModule(flow.failure_module, 'failure_module') const groupModuleIds = new Set(collectAllFlowModuleIdsFromModules(modules)) const groups = validateFlowGroups(flow.groups, groupModuleIds) + const notes = validateFlowNotes(flow.notes, groupModuleIds) if (preprocessorModule) { if (preprocessorModule.id !== SPECIAL_MODULE_IDS.PREPROCESSOR) { @@ -316,7 +323,8 @@ export function validateEditableFlowJson( schema, preprocessor_module: preprocessorModule, failure_module: failureModule, - groups + groups, + notes } } @@ -371,7 +379,8 @@ export function buildEditableFlowJson( schema: flow.schema ?? null, preprocessor_module: preprocessorModule ?? null, failure_module: failureModule ?? null, - groups: flow.value.groups ?? null + groups: flow.value.groups ?? null, + notes: flow.value.notes ?? null } } @@ -407,6 +416,7 @@ export function applyEditableFlowJsonToFlow( preprocessor_module: restoreSpecialRawscriptModule(editable.preprocessor_module, session) ?? undefined, failure_module: restoreSpecialRawscriptModule(editable.failure_module, session) ?? undefined, - groups: editable.groups ?? undefined + groups: editable.groups ?? undefined, + notes: editable.notes ?? undefined } } diff --git a/frontend/src/lib/components/copilot/chat/flow/helperUtils.test.ts b/frontend/src/lib/components/copilot/chat/flow/helperUtils.test.ts index 75749013a8..6dd0e1f791 100644 --- a/frontend/src/lib/components/copilot/chat/flow/helperUtils.test.ts +++ b/frontend/src/lib/components/copilot/chat/flow/helperUtils.test.ts @@ -3,7 +3,8 @@ import type { FlowModule } from '$lib/gen' import { applyFlowJsonUpdate, updateRawScriptModuleContent, - validateFlowGroups + validateFlowGroups, + validateFlowNotes } from './helperUtils' import { createInlineScriptSession } from './inlineScriptsUtils' @@ -222,6 +223,70 @@ describe('applyFlowJsonUpdate', () => { expect((flow.value as any).groups).toEqual(existingGroups) }) + it('persists notes passed in the flow json update', () => { + const flow = { + value: { + modules: [makeRawScriptModule('fetch_data', 'existing code')] + } + } + const inlineScriptSession = createInlineScriptSession() + inlineScriptSession.set('fetch_data', 'existing code') + + applyFlowJsonUpdate(flow as any, inlineScriptSession, { + notes: [ + { + id: 'note_1', + text: 'Remember to set the API key', + color: 'yellow', + type: 'free' + } as any + ] + }) + + expect((flow.value as any).notes).toEqual([ + { + id: 'note_1', + text: 'Remember to set the API key', + color: 'yellow', + type: 'free' + } + ]) + }) + + it('clears notes when an empty array or null is passed', () => { + const flow = { + value: { + modules: [], + notes: [{ id: 'n', text: 't', color: 'yellow', type: 'free' }] + } + } + const inlineScriptSession = createInlineScriptSession() + + applyFlowJsonUpdate(flow as any, inlineScriptSession, { notes: [] }) + expect((flow.value as any).notes).toBeUndefined() + ;(flow.value as any).notes = [{ id: 'n', text: 't', color: 'yellow', type: 'free' }] + applyFlowJsonUpdate(flow as any, inlineScriptSession, { notes: null }) + expect((flow.value as any).notes).toBeUndefined() + }) + + it('leaves notes untouched when not provided in the update', () => { + const existingNotes = [{ id: 'n', text: 't', color: 'yellow', type: 'free' }] + const flow = { + value: { + modules: [makeRawScriptModule('a', 'existing code')], + notes: existingNotes + } + } + const inlineScriptSession = createInlineScriptSession() + inlineScriptSession.set('a', 'existing code') + + applyFlowJsonUpdate(flow as any, inlineScriptSession, { + modules: [makeRawScriptModule('a', 'inline_script.a')] + }) + + expect((flow.value as any).notes).toEqual(existingNotes) + }) + it('updates ai agent rawscript tools in place when changing module code', () => { const flow = { value: { @@ -319,3 +384,162 @@ describe('validateFlowGroups', () => { expect(result).toEqual([{ start_id: 'a', end_id: 'b' }]) }) }) + +describe('validateFlowNotes', () => { + it('returns null for null/undefined input', () => { + expect(validateFlowNotes(null)).toBeNull() + expect(validateFlowNotes(undefined)).toBeNull() + }) + + it('rejects non-array input', () => { + expect(() => validateFlowNotes({})).toThrow('Flow notes must be an array') + }) + + it('rejects a note that is not an object', () => { + expect(() => validateFlowNotes(['nope'])).toThrow('Invalid note at index 0: must be an object') + }) + + it('rejects a note with a missing or non-string id', () => { + expect(() => validateFlowNotes([{ text: 't' }])).toThrow( + 'Invalid note at index 0: id must be a non-empty string' + ) + expect(() => validateFlowNotes([{ id: '', text: 't' }])).toThrow( + 'Invalid note at index 0: id must be a non-empty string' + ) + }) + + it('rejects duplicate note ids', () => { + expect(() => + validateFlowNotes([ + { id: 'n', text: 'a' }, + { id: 'n', text: 'b' } + ]) + ).toThrow('Invalid note at index 1: duplicate note id "n"') + }) + + it('rejects a non-string text', () => { + expect(() => validateFlowNotes([{ id: 'n', text: 42 }])).toThrow( + 'Invalid note at index 0: text must be a string' + ) + }) + + it('rejects an invalid type', () => { + expect(() => validateFlowNotes([{ id: 'n', text: 't', type: 'sticky' }])).toThrow( + 'Invalid note at index 0: type must be "free" or "group"' + ) + }) + + it('rejects an unknown color name', () => { + expect(() => validateFlowNotes([{ id: 'n', text: 't', color: '#ffff00' }])).toThrow( + /color must be one of/ + ) + }) + + it('defaults type to free and color to the default note color', () => { + const [note] = validateFlowNotes([{ id: 'n', text: 't' }])! + expect(note).toMatchObject({ id: 'n', text: 't', type: 'free', color: 'green' }) + }) + + it('preserves a provided palette color rather than overriding it', () => { + const [note] = validateFlowNotes([{ id: 'n', text: 't', color: 'purple' }])! + expect(note).toMatchObject({ id: 'n', text: 't', type: 'free', color: 'purple' }) + }) + + it('gives a free note missing geometry a concrete position and size', () => { + const [note] = validateFlowNotes([{ id: 'n', text: 't' }])! + expect(note.position).toEqual({ x: expect.any(Number), y: expect.any(Number) }) + expect(note.size).toEqual({ width: expect.any(Number), height: expect.any(Number) }) + expect(note.size!.width).toBeGreaterThan(0) + expect(note.size!.height).toBeGreaterThan(0) + }) + + it('staggers the default y position of multiple geometry-less free notes', () => { + const notes = validateFlowNotes([ + { id: 'a', text: 't' }, + { id: 'b', text: 't' } + ])! + expect(notes[0].position!.y).not.toEqual(notes[1].position!.y) + }) + + it('does not override a free note that already has geometry', () => { + const [note] = validateFlowNotes([ + { id: 'n', text: 't', position: { x: 5, y: 6 }, size: { width: 400, height: 90 } } + ])! + expect(note.position).toEqual({ x: 5, y: 6 }) + expect(note.size).toEqual({ width: 400, height: 90 }) + }) + + it('does not add geometry to group notes', () => { + const [note] = validateFlowNotes([ + { id: 'n', text: 't', type: 'group', contained_node_ids: [] } + ])! + expect(note.position).toBeUndefined() + expect(note.size).toBeUndefined() + }) + + it('rejects a malformed position', () => { + expect(() => validateFlowNotes([{ id: 'n', text: 't', position: { x: 1 } }])).toThrow( + 'Invalid note at index 0: position must be an object with numeric x and y' + ) + expect(() => validateFlowNotes([{ id: 'n', text: 't', position: [1, 2] }])).toThrow( + 'Invalid note at index 0: position must be an object with numeric x and y' + ) + }) + + it('rejects a malformed size', () => { + expect(() => validateFlowNotes([{ id: 'n', text: 't', size: { width: '10' } }])).toThrow( + 'Invalid note at index 0: size must be an object with numeric width and height' + ) + }) + + it('accepts a free note with valid position and size', () => { + const result = validateFlowNotes([ + { + id: 'n', + text: 't', + color: 'blue', + position: { x: 10, y: 20 }, + size: { width: 300, height: 80 } + } + ]) + expect(result).toEqual([ + { + id: 'n', + text: 't', + type: 'free', + color: 'blue', + position: { x: 10, y: 20 }, + size: { width: 300, height: 80 } + } + ]) + }) + + it('rejects group note contained_node_ids that are not strings', () => { + expect(() => + validateFlowNotes([{ id: 'n', text: 't', type: 'group', contained_node_ids: [1] }]) + ).toThrow('Invalid note at index 0: contained_node_ids must be an array of strings') + }) + + it('rejects group note contained_node_ids that do not match a module', () => { + const moduleIds = new Set(['a', 'b']) + expect(() => + validateFlowNotes( + [{ id: 'n', text: 't', type: 'group', contained_node_ids: ['missing'] }], + moduleIds + ) + ).toThrow( + 'Invalid note at index 0: contained_node_ids "missing" does not match any flow module' + ) + }) + + it('accepts a valid group note whose contained ids are all modules', () => { + const moduleIds = new Set(['a', 'b']) + const result = validateFlowNotes( + [{ id: 'n', text: 't', color: 'blue', type: 'group', contained_node_ids: ['a', 'b'] }], + moduleIds + ) + expect(result).toEqual([ + { id: 'n', text: 't', color: 'blue', type: 'group', contained_node_ids: ['a', 'b'] } + ]) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts b/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts index f424490d24..35b6aae909 100644 --- a/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts +++ b/frontend/src/lib/components/copilot/chat/flow/helperUtils.ts @@ -1,19 +1,26 @@ -import type { FlowModule, FlowValue, OpenFlow, RawScript } from '$lib/gen' +import type { FlowModule, FlowNote, FlowValue, OpenFlow, RawScript } from '$lib/gen' import { forEachFlowModule } from '$lib/components/flows/dfs' import { findModuleInFlow } from '$lib/components/flows/flowTree' -import { NoteColor } from '$lib/components/graph/noteColors' +import { + DEFAULT_NOTE_COLOR, + MIN_NOTE_HEIGHT, + MIN_NOTE_WIDTH, + NoteColor +} from '$lib/components/graph/noteColors' import type { InlineScriptSession } from './inlineScriptsUtils' -/** Allowed group color names — matches the NoteColor palette the group - * editor uses. Other strings would render with default-blue styling at best - * and break the color picker UI at worst. */ -const ALLOWED_GROUP_COLORS = new Set(Object.values(NoteColor)) +/** Allowed note/group color names — matches the NoteColor palette the note and + * group editors use. The note renderer keys `NOTE_COLORS` by these exact names, + * so other strings (hex codes, CSS colors) render with no styling at best and + * break the color picker UI at worst. */ +const ALLOWED_NOTE_COLORS = new Set(Object.values(NoteColor)) type FlowLike = Pick & { schema?: Record } export type FlowGroup = NonNullable[number] +export type { FlowNote } export interface FlowJsonUpdate { modules?: FlowModule[] @@ -21,6 +28,7 @@ export interface FlowJsonUpdate { preprocessorModule?: FlowModule | null failureModule?: FlowModule | null groups?: FlowGroup[] | null + notes?: FlowNote[] | null } export interface FlowJsonUpdateResult { @@ -78,9 +86,9 @@ export function validateFlowGroups( } } if (g.color !== undefined && g.color !== null) { - if (typeof g.color !== 'string' || !ALLOWED_GROUP_COLORS.has(g.color)) { + if (typeof g.color !== 'string' || !ALLOWED_NOTE_COLORS.has(g.color)) { throw new Error( - `Invalid group at index ${index}: color must be one of ${[...ALLOWED_GROUP_COLORS].join(', ')}` + `Invalid group at index ${index}: color must be one of ${[...ALLOWED_NOTE_COLORS].join(', ')}` ) } } @@ -88,10 +96,138 @@ export function validateFlowGroups( }) } +/** + * Validate the optional array of sticky notes the agent attached to the flow. + * Notes are editor-only annotations and do not affect execution. + * + * `free` notes are the supported kind (standalone canvas annotations). The + * `group` note type is deprecated for creation — the chat prompt steers the + * agent toward `groups` instead — but it is still ACCEPTED here so flows that + * already contain group notes round-trip cleanly through `patch_flow_json` / + * `set_flow_json` rather than being rejected. When `moduleIds` is provided, + * every `contained_node_ids` entry of a `group` note must reference an existing + * module. + * + * A provided palette `color` is always preserved as-is; the default is only + * filled in when a note omits `color` entirely (FlowNote.color is required). + * + * Free notes are also given a concrete `position` and `size` when missing. A + * free note without geometry is not draggable/resizable in the editor (you'd + * have to resize it first to give it a size) — UI-created notes always set both, + * so agent-created notes must too. Provided geometry is preserved untouched. + */ +export function validateFlowNotes(rawNotes: unknown, moduleIds?: Set): FlowNote[] | null { + if (rawNotes == null) { + return null + } + + if (!Array.isArray(rawNotes)) { + throw new Error('Flow notes must be an array') + } + + const seenIds = new Set() + return rawNotes.map((note, index) => { + if (!note || typeof note !== 'object' || Array.isArray(note)) { + throw new Error(`Invalid note at index ${index}: must be an object`) + } + const n = note as Record + if (typeof n.id !== 'string' || !n.id) { + throw new Error(`Invalid note at index ${index}: id must be a non-empty string`) + } + if (seenIds.has(n.id)) { + throw new Error(`Invalid note at index ${index}: duplicate note id "${n.id}"`) + } + seenIds.add(n.id) + if (typeof n.text !== 'string') { + throw new Error(`Invalid note at index ${index}: text must be a string`) + } + const type = n.type ?? 'free' + if (type !== 'free' && type !== 'group') { + throw new Error(`Invalid note at index ${index}: type must be "free" or "group"`) + } + if (n.color !== undefined && n.color !== null) { + if (typeof n.color !== 'string' || !ALLOWED_NOTE_COLORS.has(n.color)) { + throw new Error( + `Invalid note at index ${index}: color must be one of ${[...ALLOWED_NOTE_COLORS].join(', ')}` + ) + } + } + if (n.position !== undefined && n.position !== null) { + const p = n.position as Record + if ( + typeof p !== 'object' || + Array.isArray(n.position) || + typeof p.x !== 'number' || + typeof p.y !== 'number' + ) { + throw new Error( + `Invalid note at index ${index}: position must be an object with numeric x and y` + ) + } + } + if (n.size !== undefined && n.size !== null) { + const s = n.size as Record + if ( + typeof s !== 'object' || + Array.isArray(n.size) || + typeof s.width !== 'number' || + typeof s.height !== 'number' + ) { + throw new Error( + `Invalid note at index ${index}: size must be an object with numeric width and height` + ) + } + } + if (type === 'group' && n.contained_node_ids !== undefined) { + if ( + !Array.isArray(n.contained_node_ids) || + n.contained_node_ids.some((id) => typeof id !== 'string') + ) { + throw new Error( + `Invalid note at index ${index}: contained_node_ids must be an array of strings` + ) + } + if (moduleIds) { + for (const id of n.contained_node_ids as string[]) { + if (!moduleIds.has(id)) { + throw new Error( + `Invalid note at index ${index}: contained_node_ids "${id}" does not match any flow module` + ) + } + } + } + } + const normalized = { + ...(n as FlowNote), + type, + // Preserve a provided color; only seed the default when omitted. + color: typeof n.color === 'string' ? n.color : DEFAULT_NOTE_COLOR + } as FlowNote + + // Free notes need explicit geometry to be draggable/resizable. Place + // missing ones to the left of the flow column, staggered by index so + // several new notes don't land exactly on top of each other. Group notes + // derive their layout from contained nodes, so they are left alone. + if (type === 'free') { + if (normalized.position == null) { + normalized.position = { + x: -(MIN_NOTE_WIDTH + 100), + y: index * (MIN_NOTE_HEIGHT + 24) + } + } + if (normalized.size == null) { + normalized.size = { width: MIN_NOTE_WIDTH, height: MIN_NOTE_HEIGHT } + } + } + + return normalized + }) +} + export function applyFlowJsonUpdate( flow: FlowLike, inlineScriptSession: InlineScriptSession, - { modules, schema, preprocessorModule, failureModule, groups }: FlowJsonUpdate + { modules, schema, preprocessorModule, failureModule, groups, notes }: FlowJsonUpdate ): FlowJsonUpdateResult { const emptyInlineScriptModuleIds = new Set() @@ -125,6 +261,10 @@ export function applyFlowJsonUpdate( flow.value.groups = groups == null || groups.length === 0 ? undefined : groups } + if (notes !== undefined) { + flow.value.notes = notes == null || notes.length === 0 ? undefined : notes + } + return { emptyInlineScriptModuleIds: Array.from(emptyInlineScriptModuleIds) }