feat: let flow AI chat create and edit sticky notes (#9412)

* feat: let flow AI chat create and edit sticky notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: strengthen flow AI guidance to prefer groups for organizing flows

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: harden flow note validation (validate position/size, document color default and group acceptance)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: make AI-created free notes draggable by seeding default position and size

Free notes need explicit geometry to be draggable/resizable in the editor; UI-created notes always set position+size but agent-created notes omitted both, so they couldn't be moved until resized. Seed defaults in validateFlowNotes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-06-03 19:27:01 +02:00
committed by GitHub
parent d537c82c4f
commit e4e0984e55
5 changed files with 436 additions and 28 deletions
@@ -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
@@ -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<FlowAIChatHelpers>[] = [
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<FlowAIChatHelpers>[] = [
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<string, any> | 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<FlowAIChatHelpers>[] = [
| 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<FlowAIChatHelpers>[] = [
)
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<FlowAIChatHelpers>[] = [
? { 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
@@ -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
}
}
@@ -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'] }
])
})
})
@@ -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<string>(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<string>(Object.values(NoteColor))
type FlowLike = Pick<OpenFlow, 'value'> & {
schema?: Record<string, any>
}
export type FlowGroup = NonNullable<FlowValue['groups']>[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<string>): FlowNote[] | null {
if (rawNotes == null) {
return null
}
if (!Array.isArray(rawNotes)) {
throw new Error('Flow notes must be an array')
}
const seenIds = new Set<string>()
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string>()
@@ -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)
}