From 83b8954ae26f8d453d38e867e62366341cfe7729 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:24:35 +0200 Subject: [PATCH] fix: turn the default raw app into a feature tour (#11417) * feat: turn the default raw app into a feature tour Co-Authored-By: Claude Opus 5.5 (1M context) * fix: don't present ctx fields as unforgeable in the starter app Co-Authored-By: Claude Opus 5.5 (1M context) * chore: format raw app templates Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/copilot/chat/global/core.ts | 7 +- .../src/lib/components/raw_apps/templates.ts | 1279 +++++++++++++++-- .../src/lib/components/raw_apps/utils.test.ts | 17 + frontend/src/lib/components/raw_apps/utils.ts | 16 +- .../apps_raw/edit/[...path]/+page.svelte | 10 +- 5 files changed, 1223 insertions(+), 106 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 62eddd1a89..a69a2debca 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -53,8 +53,7 @@ import { evalValue } from '$lib/components/flows/utils.svelte' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { FRAMEWORK_TEMPLATES, - STARTER_RUNNABLE, - STARTER_RUNNABLE_KEY, + STARTER_RUNNABLES, type FrameworkKey } from '$lib/components/raw_apps/templates' import { @@ -6411,13 +6410,13 @@ async function initApp( const value: AppDraftValue = { summary, files: { ...template }, - runnables: { [STARTER_RUNNABLE_KEY]: { ...STARTER_RUNNABLE } } + runnables: structuredClone(STARTER_RUNNABLES) } await recomputeAppPolicy(value) const result = await saveAppDraft(workspace, path, value) return finishAppDraftWrite(result, ctx, () => ({ content: `Saved app "${path}" draft (${framework})`, - message: `Initialized a per-user draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (saved server-side, not a deployed workspace item). Use write_app_file / write_app_runnable to evolve it.` + message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.` })) } diff --git a/frontend/src/lib/components/raw_apps/templates.ts b/frontend/src/lib/components/raw_apps/templates.ts index ba630f0720..d6df109262 100644 --- a/frontend/src/lib/components/raw_apps/templates.ts +++ b/frontend/src/lib/components/raw_apps/templates.ts @@ -1,3 +1,7 @@ +// The starter app is a tour of what a raw app can do: each card calls one of the +// STARTER_RUNNABLES below through a different `./wmill` export. The three framework +// variants share `indexCss` and must stay in step with each other and with the runnables. + const reactIndex = ` import React from 'react' @@ -7,65 +11,500 @@ import App from './App' const root = createRoot(document.getElementById('root')!); root.render(); ` + +const STATS_SAMPLE_TEXT = + 'Windmill turns scripts into apps. Write the frontend in React, Svelte or Vue, and the backend in TypeScript, Python or any other supported language. Scripts run on workers, and the app calls them like functions.' + const appTsx = `import React, { useState } from 'react' -import { backend } from './wmill' +import { backend, backendAsync, getJob, streamJob } from './wmill' import './index.css' -const App = () => { - const [value, setValue] = useState(undefined as string | undefined) - const [loading, setLoading] = useState(false) +// Windmill injects the viewer (absent for anonymous visitors) and the workspace. +const ctx = (window as any).ctx ?? {} +const viewer: { name?: string; username?: string } | undefined = ctx.ctx +const firstName = (viewer?.name || viewer?.username || '').split(' ')[0] - async function runA() { - setLoading(true) - try { - setValue(await backend.a({ x: 42 })) - } catch (e) { - console.error() - } - setLoading(false) - } +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - return
-

hello world

+function errorMessage(e: any): string { + return e?.message ?? (typeof e === 'string' ? e : JSON.stringify(e)) +} - - -
- {loading ? 'Loading ...' : value ?? 'Click button to see value here'} +function Card(props: { + step: string + title: string + description: string + code: string + children: React.ReactNode +}) { + return ( +
+
+ {props.step} +
+

{props.title}

+

{props.description}

-
; -}; +
+
{props.children}
+
+        {props.code}
+      
+ + ) +} -export default App; +function GreetDemo() { + const [name, setName] = useState(firstName || 'world') + const [result, setResult] = useState<{ message: string; ran_at: string }>() + const [error, setError] = useState() + const [loading, setLoading] = useState(false) + + async function run() { + setLoading(true) + setError(undefined) + try { + setResult(await backend.greet({ name })) + } catch (e) { + setError(errorMessage(e)) + } + setLoading(false) + } + + return ( + +
{ + e.preventDefault() + run() + }} + > + setName(e.target.value)} placeholder="Your name" /> + +
+
+ {error ? ( + {error} + ) : result ? ( + <> + {result.message} + Ran at {new Date(result.ran_at).toLocaleTimeString()} + + ) : ( + The result shows up here. + )} +
+
+ ) +} + +type Stats = { words: number; characters: number; sentences: number; top_words: string[] } + +function StatsDemo() { + const [text, setText] = useState(${JSON.stringify(STATS_SAMPLE_TEXT)}) + const [stats, setStats] = useState() + const [error, setError] = useState() + const [loading, setLoading] = useState(false) + + async function run() { + setLoading(true) + setError(undefined) + try { + setStats(await backend.word_stats({ text })) + } catch (e) { + setError(errorMessage(e)) + } + setLoading(false) + } + + return ( + + +
+ +
+ {#if statsError} +
{statsError}
+ {/if} + {#if stats} +
+
{stats.words}words
+
{stats.sentences}sentences
+
{stats.characters}characters
+
+ {#each stats.top_words as word (word)} + {word} + {/each} +
+
+ {/if} +
+
const stats = await backend.word_stats({ text })
+ + +
+ {@render header('3', 'Run jobs in the background', 'Start long jobs without blocking the UI, then poll their status. Start several at once.')} +
+
+ +
+ {#if jobsError} +
{jobsError}
+ {/if} +
    + {#each jobs as job (job.id)} +
  • + {job.status} + {job.id.slice(-8)} + {job.seconds}s +
    +
    +
    +
  • + {:else} +
  • No jobs yet.
  • + {/each} +
+
+
const id = await backendAsync.slow_task({ seconds })
+const job = await getJob(id)
+
+ +
+ {@render header('4', 'Stream results', 'A runnable can yield its output bit by bit. Render it as it arrives, like an LLM answer.')} +
+
+ +
+
+ {#if streamError} + {streamError} + {:else if streamed || streaming} + {streamed}{#if streaming}{/if} + {:else} + Streamed text shows up here. + {/if} +
+
+
await streamJob(id, (u) => append(u.new_result_stream))
+
+ - -` +` const indexSvelte = ` import { mount } from 'svelte'; @@ -78,12 +517,221 @@ export default app; ` const appVue = ` ` const indexVue = `import { createApp } from 'vue' @@ -92,16 +740,386 @@ import "./index.css"; createApp(App).mount('#root')` -const indexCss = `.myclass { - border: 1px solid gray; - padding: 2px; -}` +const indexCss = `/* The app renders inside an iframe whose host page has its own theme the + iframe cannot read, so paint an opaque surface here: without one the host's + background shows through and dark text lands on it unreadable. */ +:root { + color-scheme: light; + --bg: #f4f5f8; + --card: #ffffff; + --sunken: #efeff4; + --border: #e2e3eb; + --fg: #3d4758; + --fg-strong: #1d2430; + --muted: #718096; + --accent: #5074f6; + --accent-hover: #2c5beb; + --accent-soft: #e8ebfb; + --success: #15803d; + --success-soft: #dcfce7; + --error: #b91c1c; + --error-soft: #fef2f2; + --warning: #a16207; + --warning-soft: #fef9c3; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --bg: #242832; + --card: #2e3441; + --sunken: #272c35; + --border: #3b4252; + --fg: #d4d7dd; + --fg-strong: #eeeff2; + --muted: #a9b0ba; + --accent: #7085db; + --accent-hover: #5670d5; + --accent-soft: #343d5c; + --success: #86efac; + --success-soft: #1f3a2b; + --error: #fca5a5; + --error-soft: #431a1a; + --warning: #fde68a; + --warning-soft: #3d3419; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--fg); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +h1, +h2 { + margin: 0; + color: var(--fg-strong); +} + +code, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.muted { + color: var(--muted); +} + +.error { + color: var(--error); +} + +.app { + max-width: 1040px; + margin: 0 auto; + padding: 40px 20px 56px; +} + +.hero { + margin-bottom: 28px; +} + +.hero h1 { + font-size: 1.75rem; + font-weight: 650; + letter-spacing: -0.01em; + margin: 8px 0 6px; +} + +.hero p { + max-width: 620px; + margin: 0; +} + +.eyebrow { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 12px; + font-weight: 600; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); + gap: 16px; +} + +.card { + display: flex; + flex-direction: column; + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 1px 2px rgb(16 24 40 / 0.04); + overflow: hidden; +} + +.card-header { + display: flex; + gap: 12px; + padding: 18px 18px 0; +} + +.card-header h2 { + font-size: 15px; + font-weight: 600; +} + +.card-header p { + margin: 2px 0 0; + font-size: 13px; +} + +.card-step { + flex: none; + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 7px; + background: var(--accent-soft); + color: var(--accent); + font-size: 13px; + font-weight: 700; +} + +.card-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px 18px; +} + +.snippet { + margin: 0; + padding: 10px 18px; + border-top: 1px solid var(--border); + background: var(--sunken); + color: var(--muted); + font-size: 12px; + white-space: pre-wrap; +} + +.row { + display: flex; + gap: 8px; +} + +input, +textarea { + flex: 1; + min-width: 0; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card); + color: var(--fg-strong); + font: inherit; + resize: vertical; +} + +input:focus, +textarea:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +button { + padding: 7px 14px; + border: none; + border-radius: 6px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +button:hover:not(:disabled) { + background: var(--accent-hover); +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +.output { + min-height: 44px; + padding: 11px 12px; + border-radius: 6px; + background: var(--sunken); + word-break: break-word; +} + +.output.error { + background: var(--error-soft); +} + +.output.stream { + min-height: 96px; + white-space: pre-wrap; +} + +.caret { + display: inline-block; + width: 7px; + height: 1em; + margin-left: 2px; + vertical-align: text-bottom; + background: var(--accent); + animation: blink 1s steps(1) infinite; +} + +@keyframes blink { + 50% { + opacity: 0; + } +} + +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.stats > div { + display: flex; + flex-direction: column; + padding: 10px 12px; + border-radius: 6px; + background: var(--sunken); +} + +.stats strong { + font-size: 20px; + color: var(--fg-strong); +} + +.stats span { + font-size: 12px; +} + +.stats .tags { + grid-column: 1 / -1; + flex-direction: row; + flex-wrap: wrap; + gap: 6px; + padding: 0; + background: none; +} + +.tag { + padding: 2px 8px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); +} + +.jobs { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.jobs li { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 6px; + background: var(--sunken); + font-size: 13px; +} + +.pill { + min-width: 64px; + padding: 1px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + text-align: center; +} + +.pill.queued { + background: var(--warning-soft); + color: var(--warning); +} + +.pill.running { + background: var(--accent-soft); + color: var(--accent); +} + +.pill.success { + background: var(--success-soft); + color: var(--success); +} + +.pill.failure { + background: var(--error-soft); + color: var(--error); +} + +.bar { + flex: 1; + height: 4px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} + +.bar-fill { + width: 0; + height: 100%; + border-radius: inherit; + background: var(--accent); +} + +.bar-fill.running { + animation: fill linear forwards; +} + +.bar-fill.success { + width: 100%; + background: var(--success); +} + +.bar-fill.failure { + width: 100%; + background: var(--error); +} + +@keyframes fill { + to { + width: 95%; + } +} +` export const react19Template = { - '/index.tsx': reactIndex, - '/App.tsx': appTsx, - '/index.css': indexCss, - '/package.json': `{ + '/index.tsx': reactIndex, + '/App.tsx': appTsx, + '/index.css': indexCss, + '/package.json': `{ "dependencies": { "react": "19.0.0", "react-dom": "19.0.0" @@ -114,10 +1132,10 @@ export const react19Template = { } export const react18Template = { - '/index.tsx': reactIndex, - '/App.tsx': appTsx, - '/index.css': indexCss, - '/package.json': `{ + '/index.tsx': reactIndex, + '/App.tsx': appTsx, + '/index.css': indexCss, + '/package.json': `{ "dependencies": { "react": "18.3.1", "react-dom": "18.3.1" @@ -130,10 +1148,10 @@ export const react18Template = { } export const svelte5Template = { - '/index.ts': indexSvelte, - '/App.svelte': appSvelte, - '/index.css': indexCss, - '/package.json': `{ + '/index.ts': indexSvelte, + '/App.svelte': appSvelte, + '/index.css': indexCss, + '/package.json': `{ "dependencies": { "svelte": "^5.56.8" } @@ -141,10 +1159,10 @@ export const svelte5Template = { } export const vueTemplate = { - '/index.ts': indexVue, - '/App.vue': appVue, - '/index.css': indexCss, - '/package.json': `{ + '/index.ts': indexVue, + '/App.vue': appVue, + '/index.css': indexCss, + '/package.json': `{ "dependencies": { "core-js": "3.26.1", "vue": "3.5.13" @@ -153,41 +1171,120 @@ export const vueTemplate = { } export const FRAMEWORK_TEMPLATES = { - react19: react19Template, - react18: react18Template, - svelte5: svelte5Template, - vue: vueTemplate + react19: react19Template, + react18: react18Template, + svelte5: svelte5Template, + vue: vueTemplate } export type FrameworkKey = keyof typeof FRAMEWORK_TEMPLATES -export const STARTER_RUNNABLE_KEY = 'a' +type StarterArg = { type: 'string' | 'number' } -// Matches the starter runnable that the apps_raw/add page seeds, so the -// React/Svelte demo button (`backend.a({ x: 42 })`) works on first render. -export const STARTER_RUNNABLE = { - name: 'a', - fields: {}, - type: 'inline', - inlineScript: { - content: - '// import * as wmill from "windmill-client"\n\nexport async function main(x: string) {\n return x\n}\n', - language: 'bun', - schema: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - properties: { - x: { - default: null, - description: '', - originalType: 'string', - type: 'string' - } - }, - required: ['x'], - type: 'object' - } +function inlineRunnable( + language: 'bun' | 'python3', + content: string, + args: Record, + fields: Record = {} +) { + return { + fields, + type: 'inline', + inlineScript: { + content, + language, + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: Object.fromEntries( + Object.entries(args).map(([k, { type }]) => [ + k, + { default: null, description: '', originalType: type, type } + ]) + ), + required: Object.keys(args), + type: 'object' + } + } + } +} + +// The runnables every framework template calls, seeded by the apps_raw/edit page and +// the AI chat's app creation so the starter works on first render. +export const STARTER_RUNNABLES = { + greet: { + name: 'greet', + ...inlineRunnable( + 'bun', + `// import * as wmill from "windmill-client" + +// \`username\` is a context field: the app fills it with the viewer's username. +// Direct API callers can pass any value, so don't use it for access control. +export async function main(name: string, username: string) { + return { + message: \`Hello \${name}! \${username ? \`You are signed in as \${username}.\` : 'You are not signed in.'}\`, + ran_at: new Date().toISOString() } } +`, + { name: { type: 'string' }, username: { type: 'string' } }, + { username: { type: 'ctx', ctx: 'username' } } + ) + }, + word_stats: { + name: 'word_stats', + ...inlineRunnable( + 'python3', + `import re +from collections import Counter + + +def main(text: str): + words = re.findall(r"[a-z']+", text.lower()) + top = Counter(w for w in words if len(w) > 4).most_common(5) + return { + "words": len(words), + "characters": len(text), + "sentences": len(re.findall(r"[.!?]+", text)), + "top_words": [word for word, _ in top], + } +`, + { text: { type: 'string' } } + ) + }, + slow_task: { + name: 'slow_task', + ...inlineRunnable( + 'bun', + `export async function main(seconds: number) { + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) + return \`Done after \${seconds}s\` +} +`, + { seconds: { type: 'number' } } + ) + }, + stream_text: { + name: 'stream_text', + ...inlineRunnable( + 'bun', + `// Returning an async generator streams each yielded chunk to the caller. +export async function main() { + const text = + 'Every word of this text is yielded by a Bun script as soon as it is ready, ' + + 'and the app renders it the moment it arrives. Use the same pattern to ' + + 'stream an LLM answer, a long report or live logs.' + return (async function* () { + for (const word of text.split(' ')) { + await new Promise((resolve) => setTimeout(resolve, 60)) + yield word + ' ' + } + })() +} +`, + {} + ) + } +} export const appVueRouter = `