diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index 7e70b0c215..22b74230b4 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -806,6 +806,19 @@ } } } + // Group notes reference their members by module id. A stale id here is not + // merely cosmetic: cleanupGroupNotes drops ids it cannot resolve and deletes + // the note once none are left. + const notes = flowStore.val.value.notes + if (notes) { + for (const note of notes) { + if (note.contained_node_ids) { + note.contained_node_ids = note.contained_node_ids.map((nid) => + nid === id ? newId : nid + ) + } + } + } flowStateStore.val[newId] = flowStateStore.val[id] delete flowStateStore.val[id] refreshStateStore(flowStore) diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index f83b67267a..74430cffde 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -855,7 +855,8 @@ ...aiToolNodesResult.toolNodes ] - // Collect module IDs hidden inside collapsed groups so note cleanup preserves them + // Module IDs hidden inside collapsed groups: a note whose members are all in here has + // nothing on screen to wrap, so it is skipped. const collapsedModuleIds = new Set() for (const n of finalNodes) { if (n.type === 'collapsedGroup') { diff --git a/frontend/src/lib/components/graph/NoteTool.svelte b/frontend/src/lib/components/graph/NoteTool.svelte index ad41bd974a..d6f05244d1 100644 --- a/frontend/src/lib/components/graph/NoteTool.svelte +++ b/frontend/src/lib/components/graph/NoteTool.svelte @@ -86,7 +86,7 @@ // Create the actual note using NoteEditor context if (noteEditorContext?.noteEditor) { noteEditorContext.noteEditor.addNote({ - text: '### Free note\nDouble click to edit me', + text: '## Note\nDouble click to edit me', position, size, color: DEFAULT_NOTE_COLOR, @@ -107,7 +107,7 @@ if (!noteEditorContext?.noteEditor || !contextMenuPosition) return noteEditorContext.noteEditor.addNote({ - text: '### Free note\nDouble click to edit me', + text: '## Note\nDouble click to edit me', position: contextMenuPosition, size: { width: 300, height: 200 }, color: DEFAULT_NOTE_COLOR, diff --git a/frontend/src/lib/components/graph/PaneContextMenu.svelte b/frontend/src/lib/components/graph/PaneContextMenu.svelte index 37be64bffc..7f3409812b 100644 --- a/frontend/src/lib/components/graph/PaneContextMenu.svelte +++ b/frontend/src/lib/components/graph/PaneContextMenu.svelte @@ -68,7 +68,7 @@ function handleAddStickyNote() { if (noteEditorContext?.noteEditor && pendingFlowPosition) { noteEditorContext.noteEditor.addNote({ - text: '### Free note\nDouble click to edit me', + text: '## Note\nDouble click to edit me', position: { x: pendingFlowPosition.x, y: pendingFlowPosition.y - (graphContext?.yOffset || 0) diff --git a/frontend/src/lib/components/graph/noteEditor.svelte.ts b/frontend/src/lib/components/graph/noteEditor.svelte.ts index 50c2df7bfa..e3f17f576c 100644 --- a/frontend/src/lib/components/graph/noteEditor.svelte.ts +++ b/frontend/src/lib/components/graph/noteEditor.svelte.ts @@ -6,6 +6,7 @@ import { DEFAULT_GROUP_NOTE_COLOR, getNextAvailableColor } from './noteColors' import { generateId } from './util' import { getContext, setContext } from 'svelte' import { completeAndSplitGroup } from './groupDetectionUtils' +import { forEachFlowModule } from '../flows/dfs' /** * Utility class for editing flow notes via direct flowStore mutations @@ -158,10 +159,7 @@ export class NoteEditor { /** * Create a group note containing the specified node IDs */ - createGroupNote( - nodeIds: string[], - text: string = '### Group note\nDouble click to edit me' - ): string { + createGroupNote(nodeIds: string[], text: string = '## Note\nDouble click to edit me'): string { // Filter ids in case they contain subflow nodes let filteredNodeIds: string[] = nodeIds let subflowIds: string[] = [] @@ -219,10 +217,7 @@ export class NoteEditor { /** * Clean up group notes using DAG path completion */ - cleanupGroupNotes( - flowNodes: { id: string; parentIds?: string[] }[], - collapsedModuleIds?: Set - ): void { + cleanupGroupNotes(flowNodes: { id: string; parentIds?: string[] }[]): void { if (!this.isAvailable()) { return } @@ -232,19 +227,23 @@ export class NoteEditor { if (groupNotes.length === 0) return let hasChanges = false - const nodeSet = new Set(flowNodes.map((n) => n.id)) + const renderedIds = new Set(flowNodes.map((n) => n.id)) - // Include collapsed module IDs as valid — they are hidden but still exist - if (collapsedModuleIds) { - for (const id of collapsedModuleIds) { - nodeSet.add(id) - } - } + // A note's members are module ids, and the flow's own modules are what say whether one + // still exists. The rendered nodes are a view of them: a live module is absent from it + // while its group is collapsed, and again in the pass after its id changed, so pruning + // against the render alone deletes steps out of notes that are perfectly valid. + const moduleIds = new Set() + forEachFlowModule(this.flowStore.val.value?.modules ?? [], (mod) => { + moduleIds.add(mod.id) + }) // Step 1: Clean invalid nodes from existing group notes for (const note of groupNotes) { const originalIds = note.contained_node_ids || [] - const validIds = originalIds.filter((id) => nodeSet.has(id)) + // Path completion below can add ids that exist only in the graph (group + // boundaries), so a rendered node counts as valid alongside a live module. + const validIds = originalIds.filter((id) => moduleIds.has(id) || renderedIds.has(id)) if (validIds.length !== originalIds.length) { note.contained_node_ids = validIds @@ -259,9 +258,9 @@ export class NoteEditor { const originalNodes = note.contained_node_ids || [] if (originalNodes.length === 0) continue - // Skip path completion for notes that reference collapsed modules, - // since the DAG is incomplete when groups are collapsed - if (collapsedModuleIds && originalNodes.some((id) => collapsedModuleIds.has(id))) { + // Path completion walks the rendered edges, so it can only place members it can + // see; one it cannot would come back as unreachable and be dropped from the note. + if (originalNodes.some((id) => !renderedIds.has(id))) { continue } diff --git a/frontend/src/lib/components/graph/noteEditor.test.ts b/frontend/src/lib/components/graph/noteEditor.test.ts new file mode 100644 index 0000000000..5ec38601ef --- /dev/null +++ b/frontend/src/lib/components/graph/noteEditor.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock modules that transitively import CSS/Monaco +vi.mock('monaco-editor', () => ({})) +vi.mock('@xyflow/svelte', () => ({})) + +import type { FlowModule, OpenFlow } from '$lib/gen' +import type { StateStore } from '$lib/utils' +import type { ExtendedOpenFlow } from '../flows/types' +import { NoteEditor } from './noteEditor.svelte' + +function makeFlowStore( + moduleIds: string[], + containedNodeIds: string[] +): StateStore { + const modules: FlowModule[] = moduleIds.map((id) => ({ + id, + value: { type: 'rawscript', content: '', language: 'bun' } as any + })) + const flow: OpenFlow = { + summary: '', + value: { + modules, + notes: [ + { + id: 'note', + text: 'note', + color: 'yellow', + type: 'group', + contained_node_ids: containedNodeIds + } + ] + }, + schema: {} + } + return { val: flow as ExtendedOpenFlow } as StateStore +} + +function containedIds(flowStore: StateStore): string[] | undefined { + return flowStore.val.value.notes?.[0]?.contained_node_ids +} + +describe('cleanupGroupNotes', () => { + it('keeps a module the graph has not rendered', () => { + // Both the pass after a module id changed and a collapsed group leave a live module + // out of the rendered nodes. + const flowStore = makeFlowStore(['renamed', 'b'], ['renamed', 'b']) + new NoteEditor(flowStore).cleanupGroupNotes([{ id: 'b' }]) + + expect(containedIds(flowStore)).toEqual(['renamed', 'b']) + }) + + it('drops a module that no longer exists in the flow', () => { + const flowStore = makeFlowStore(['b'], ['deleted', 'b']) + new NoteEditor(flowStore).cleanupGroupNotes([{ id: 'b' }]) + + expect(containedIds(flowStore)).toEqual(['b']) + }) +}) diff --git a/frontend/src/lib/components/graph/noteUtils.svelte.ts b/frontend/src/lib/components/graph/noteUtils.svelte.ts index 577b421a9e..7cb6e17ece 100644 --- a/frontend/src/lib/components/graph/noteUtils.svelte.ts +++ b/frontend/src/lib/components/graph/noteUtils.svelte.ts @@ -264,7 +264,7 @@ export function computeNoteNodes( if (editMode) { if (noteEditorContext?.noteEditor?.isAvailable()) { - noteEditorContext.noteEditor.cleanupGroupNotes(nodes, collapsedModuleIds) + noteEditorContext.noteEditor.cleanupGroupNotes(nodes) } } diff --git a/frontend/src/lib/components/markdownProse.ts b/frontend/src/lib/components/markdownProse.ts index aedd2f45b1..3f79ab79a5 100644 --- a/frontend/src/lib/components/markdownProse.ts +++ b/frontend/src/lib/components/markdownProse.ts @@ -4,10 +4,14 @@ * call site with layout-only classes (padding, width, bg); anything typographic * belongs here. * - * - 'xs': micro scale for dense secondary panes (chat reasoning blocks) + * - 'xs': micro scale for dense secondary panes (chat reasoning blocks, group notes) * - 'sm': compact chat-bubble scale (assistant messages, flow/app chat, settings) * - 'doc': same rhythm and body size as 'sm', with a taller heading ramp * (lg/base/sm) and semibold headings for document-like surfaces (artifacts) + * + * h1 and h2 step above the body size in every preset, so `#` and `##` read as + * headings rather than bold body text. Below that the tight 'xs' and 'sm' scales + * run out of room, and h3 down differentiates by weight and colour alone. */ // Kept as literal template parts: Tailwind's scanner reads class names verbatim @@ -28,8 +32,8 @@ const bodyXs = 'text-primary prose-p:text-primary prose-li:text-primary prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs prose-table:text-xs' export const markdownProse = { - xs: `${base} prose-sm leading-snug prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`, - sm: `${base} ${rhythm} ${bodyXs} prose-headings:mt-3 prose-headings:mb-1 prose-headings:font-medium prose-headings:text-emphasis prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs`, + xs: `${base} prose-sm leading-snug prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-sm prose-h2:text-xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`, + sm: `${base} ${rhythm} ${bodyXs} prose-headings:mt-3 prose-headings:mb-1 prose-headings:font-medium prose-headings:text-emphasis prose-h1:text-base prose-h2:text-sm prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs`, doc: `${base} ${rhythm} ${bodyXs} prose-headings:mt-8 prose-headings:mb-2 prose-headings:font-semibold prose-headings:text-emphasis prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs prose-pre:bg-transparent prose-pre:p-0` } as const diff --git a/frontend/src/routes/kitchen_sink/+page.svelte b/frontend/src/routes/kitchen_sink/+page.svelte index a001da222f..b3ae4ef196 100644 --- a/frontend/src/routes/kitchen_sink/+page.svelte +++ b/frontend/src/routes/kitchen_sink/+page.svelte @@ -24,6 +24,8 @@ ## Heading 2 +### Heading 3 + Body text with **bold**, *italic*, a [link](https://windmill.dev), and \`inline code\` that must stay readable in both themes. > A block quote should be legible too. @@ -68,7 +70,13 @@ Raw sanitized HTML (via rehypeRaw) must keep its content, not render empty: // AssistantMessage, and fenced code blocks go through CodeDisplay → // HighlightCode. Exercise several languages + prose so the code-block styling // can be tuned against the real render path, not an approximation. - const chatSampleContent = `Here's how you'd wire up the trigger. First, some prose with \`inline code\`, a [link](https://windmill.dev), and **bold** text so we can see how code sits next to surrounding content. + const chatSampleContent = `# Wiring up the trigger + +Here's how you'd wire up the trigger. First, some prose with \`inline code\`, a [link](https://windmill.dev), and **bold** text so we can see how code sits next to surrounding content. + +## The script + +### A subsection \`\`\`python def main(name: str = "world"): @@ -188,7 +196,19 @@ That's the full round-trip.` - +
+ The three markdownProse presets, same source. Each is sized for its own + surface: xs for group notes and chat reasoning, sm for chat + bubbles, sticky notes and markdown job results, doc for artifacts. +
+
+ {#each ['xs', 'sm', 'doc'] as const as prose} +
+
{prose}
+ +
+ {/each} +