Merge branch 'main' into datatable-roles-redesign

This commit is contained in:
Diego Imbert
2026-09-10 08:38:22 +02:00
committed by GitHub
13 changed files with 200 additions and 62 deletions
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS index_app_version_on_app_id;
DROP INDEX IF EXISTS index_app_script_on_app;
DROP INDEX IF EXISTS index_workspace_runnable_dependencies_on_app_path;
DROP INDEX IF EXISTS index_workspace_runnable_dependencies_on_flow_path;
@@ -0,0 +1,32 @@
-- The FK columns that cascade when a workspace's apps and flows are deleted. Unindexed,
-- Postgres seq-scans the whole child table once per deleted parent row, making a workspace
-- delete cost O(apps and flows deleted x rows in the instance). Fork deletion is where that
-- bites: a fork clones its parent's apps, flows and entire app version history.
--
-- The workspace_runnable_dependencies pair are partial because the table's check constraint
-- makes app_path and flow_path mutually exclusive, halving each index -- the cascade's
-- equality on the path proves the predicate. The table's existing path indexes are partial on
-- script_hash, which the cascade does not constrain, so they cannot serve it.
--
-- Dropped before built: a failed concurrent build leaves an invalid index that IF NOT EXISTS
-- would accept forever, unused by the planner yet still maintained on every write.
--
-- No statement separators outside the statements below, comments included: the CONCURRENTLY
-- rewrite in windmill-api/src/db.rs splits the file on them and would run comment text as SQL.
DROP INDEX IF EXISTS index_app_version_on_app_id;
CREATE INDEX index_app_version_on_app_id ON app_version (app_id);
DROP INDEX IF EXISTS index_app_script_on_app;
CREATE INDEX index_app_script_on_app ON app_script (app);
DROP INDEX IF EXISTS index_workspace_runnable_dependencies_on_app_path;
CREATE INDEX index_workspace_runnable_dependencies_on_app_path
ON workspace_runnable_dependencies (app_path, workspace_id) WHERE app_path IS NOT NULL;
DROP INDEX IF EXISTS index_workspace_runnable_dependencies_on_flow_path;
CREATE INDEX index_workspace_runnable_dependencies_on_flow_path
ON workspace_runnable_dependencies (flow_path, workspace_id) WHERE flow_path IS NOT NULL;
+3
View File
@@ -108,6 +108,9 @@ lazy_static::lazy_static! {
(20260826214706, include_str!(
"../../migrations/20260826214706_queue_suspended_drop_legacy_index.up.sql"
).replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
(20260909163047, include_str!(
"../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
].into_iter().collect();
}
@@ -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)
@@ -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<string>()
for (const n of finalNodes) {
if (n.type === 'collapsedGroup') {
@@ -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,
@@ -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)
@@ -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<string>
): 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<string>()
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
}
@@ -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<ExtendedOpenFlow> {
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<ExtendedOpenFlow>
}
function containedIds(flowStore: StateStore<ExtendedOpenFlow>): 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'])
})
})
@@ -264,7 +264,7 @@ export function computeNoteNodes(
if (editMode) {
if (noteEditorContext?.noteEditor?.isAvailable()) {
noteEditorContext.noteEditor.cleanupGroupNotes(nodes, collapsedModuleIds)
noteEditorContext.noteEditor.cleanupGroupNotes(nodes)
}
}
+7 -3
View File
@@ -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
@@ -614,6 +614,39 @@
</div>
</SettingCard>
{/if}
{#if promptScope === 'workspace'}
<!-- Recorded usage must be priced with the rates the chats actually ran under.
A workspace on instance defaults has no rates of its own, so the effective
ones come from copilotInfo rather than from this form's (empty) workspace
config. -->
<AiUsagePanel
workspace={effectiveWorkspace}
modelPricing={usesInstanceAiConfig ? ($copilotInfo.modelPricing ?? {}) : modelPricing}
/>
{/if}
<!-- Below the usage it explains: the rates are read as a correction to what the
table above already shows. Kept on its own `showWorkspaceOverrideEditor` gate so
the instance scope, which has no usage panel, still edits rates. -->
{#if showWorkspaceOverrideEditor}
<ModelPricing {aiProviders} bind:modelPricing />
{/if}
{#if promptScope === 'workspace'}
<SettingCard
label="Hide AI sessions"
description="Hides AI sessions and every other AI assistant button (chat, code generation and completion, AI fix) from all members of this workspace. AI agent steps and the AI sandbox in flows are not affected and keep using the providers configured above. This hides the assistant in the UI only; it does not restrict API access to the configured providers."
>
<Toggle
checked={copilotDisabled}
on:change={(e) => {
copilotDisabled = e.detail
}}
options={{ right: 'Hide AI sessions in this workspace' }}
/>
</SettingCard>
{/if}
</div>
<AIPromptsModal
@@ -624,39 +657,6 @@
scope={promptScope}
/>
{#if promptScope === 'workspace'}
<!-- Recorded usage must be priced with the rates the chats actually ran under.
A workspace on instance defaults has no rates of its own, so the effective
ones come from copilotInfo rather than from this form's (empty) workspace
config. -->
<AiUsagePanel
workspace={effectiveWorkspace}
modelPricing={usesInstanceAiConfig ? ($copilotInfo.modelPricing ?? {}) : modelPricing}
/>
{/if}
<!-- Below the usage it explains: the rates are read as a correction to what the
table above already shows. Kept on its own `showWorkspaceOverrideEditor` gate so
the instance scope, which has no usage panel, still edits rates. -->
{#if showWorkspaceOverrideEditor}
<ModelPricing {aiProviders} bind:modelPricing />
{/if}
{#if promptScope === 'workspace'}
<SettingCard
label="Hide AI sessions"
description="Hides AI sessions and every other AI assistant button (chat, code generation and completion, AI fix) from all members of this workspace. AI agent steps and the AI sandbox in flows are not affected and keep using the providers configured above. This hides the assistant in the UI only; it does not restrict API access to the configured providers."
>
<Toggle
checked={copilotDisabled}
on:change={(e) => {
copilotDisabled = e.detail
}}
options={{ right: 'Hide AI sessions in this workspace' }}
/>
</SettingCard>
{/if}
<!-- Not gated on `showWorkspaceOverrideEditor`: a workspace on instance defaults still has
the hide toggle above to save. -->
<SettingsFooter
+22 -2
View File
@@ -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.`
</div>
</TabContent>
<TabContent value="markdown" class="p-4">
<GfmMarkdown md={sampleMarkdown} />
<div class="text-xs text-tertiary mb-3">
The three <code>markdownProse</code> presets, same source. Each is sized for its own
surface: <code>xs</code> for group notes and chat reasoning, <code>sm</code> for chat
bubbles, sticky notes and markdown job results, <code>doc</code> for artifacts.
</div>
<div class="grid gap-4 md:grid-cols-3">
{#each ['xs', 'sm', 'doc'] as const as prose}
<div class="border border-border-light rounded-lg p-3 bg-surface min-w-0">
<div class="text-2xs text-tertiary font-mono mb-2">{prose}</div>
<GfmMarkdown md={sampleMarkdown} {prose} />
</div>
{/each}
</div>
</TabContent>
<TabContent value="chat" class="p-4">
<div class="text-xs text-tertiary mb-3">