Apps are easy-to-use tools with buttons, forms, and displays built just for your team.
You can open an app to work with your data, fill out forms, or trigger tasks - no technical knowledge needed!
'
+ },
+ element: '[data-value="app"]'
+ },
+ {
+ popover: {
+ title: 'Finally, the Menu section',
+ description:
+ 'Explore available tabs where you can access your history of runs, your scheduled scripts, and your workspaces.
💡 Want to see this again? Pick Take the tour from that same menu.
',
+ onNextClick: async () => {
+ // The step points into the menu, so it has to be open before the popover
+ // lands on it — and open is also where the entry to re-run the tour is.
+ const menuButton = document.querySelector('[role="menuitem"]') as HTMLElement | null
+ menuButton?.click()
+ await wait(MENU_OPEN_DELAY_MS)
+ driver.destroy()
+ }
+ },
+ element: '[role="menuitem"]'
+ }
+ ]
+
+ return steps
+ }}
+/>
diff --git a/frontend/src/lib/components/tutorials/Tutorial.svelte b/frontend/src/lib/components/tutorials/Tutorial.svelte
new file mode 100644
index 0000000000..d4c99482a2
--- /dev/null
+++ b/frontend/src/lib/components/tutorials/Tutorial.svelte
@@ -0,0 +1,103 @@
+
+
+{#if tutorial}
+
+{/if}
diff --git a/frontend/src/lib/components/tutorials/TutorialControls.svelte b/frontend/src/lib/components/tutorials/TutorialControls.svelte
new file mode 100644
index 0000000000..dd15cf4151
--- /dev/null
+++ b/frontend/src/lib/components/tutorials/TutorialControls.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {#if activeIndex === 0}
+
+
UI is not interactive during the tour, press next at every step
+
You can use the arrow keys to navigate
+
+ {/if}
+
+
+ Step {activeIndex + 1} of {totalSteps}
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/tutorials/TutorialInner.svelte b/frontend/src/lib/components/tutorials/TutorialInner.svelte
new file mode 100644
index 0000000000..ce0784ba8e
--- /dev/null
+++ b/frontend/src/lib/components/tutorials/TutorialInner.svelte
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/lib/components/tutorials/operatorTour.ts b/frontend/src/lib/components/tutorials/operatorTour.ts
new file mode 100644
index 0000000000..5c4269e84d
--- /dev/null
+++ b/frontend/src/lib/components/tutorials/operatorTour.ts
@@ -0,0 +1,47 @@
+import { UserService } from '$lib/gen'
+
+/**
+ * The tour's slot in the `tutorial_progress` bitmask. Slot 6 is reserved for it across
+ * versions: an operator who has already been through the tour must not meet it again, and
+ * a slot that another tutorial writes would read as finished on day one.
+ */
+const OPERATOR_TOUR_BIT = 6
+
+/** URL parameter the sidebar entry uses to ask the home page for a run. */
+export const TOUR_PARAM = 'tour'
+export const TOUR_PARAM_VALUE = 'operator'
+
+/** Long enough for the home page's tabs to exist before the first step points at one. */
+export const TOUR_START_DELAY_MS = 500
+/** Time for the sidebar to open before the last step points into it. */
+export const MENU_OPEN_DELAY_MS = 300
+
+export async function hasSeenOperatorTour(): Promise {
+ // A failure answers "seen": the tour interrupts the page, and interrupting someone who
+ // has already been through it is worse than never offering it, which the sidebar entry
+ // covers anyway.
+ try {
+ const progress = (await UserService.getTutorialProgress()).progress ?? 0
+ return (progress & (1 << OPERATOR_TOUR_BIT)) !== 0
+ } catch (error) {
+ console.error('Could not read tutorial progress:', error)
+ return true
+ }
+}
+
+export async function markOperatorTourSeen(): Promise {
+ try {
+ // Read-modify-write, because the row is shared: it carries every slot's state, and a
+ // write of this bit alone would clear the rest. `skipped_all` rides along for the same
+ // reason — and the handler rejects a body without it, whatever the generated type says.
+ const current = await UserService.getTutorialProgress()
+ await UserService.updateTutorialProgress({
+ requestBody: {
+ progress: (current.progress ?? 0) | (1 << OPERATOR_TOUR_BIT),
+ skipped_all: current.skipped_all ?? false
+ }
+ })
+ } catch (error) {
+ console.error('Could not record tutorial progress:', error)
+ }
+}
diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte
index b2035e2ed4..c1f4f4818d 100644
--- a/frontend/src/routes/(root)/(logged)/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/+page.svelte
@@ -28,6 +28,14 @@
import { z } from 'zod'
import HomeAIChat from '$lib/components/home/HomeAIChat.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
+ import { onMount, untrack } from 'svelte'
+ import OperatorTour from '$lib/components/tutorials/OperatorTour.svelte'
+ import {
+ hasSeenOperatorTour,
+ TOUR_PARAM,
+ TOUR_PARAM_VALUE,
+ TOUR_START_DELAY_MS
+ } from '$lib/components/tutorials/operatorTour'
type Tab = 'hub' | 'workspace'
@@ -83,6 +91,41 @@
}
let showCreateButtons = $state(false)
+
+ let operatorTour: OperatorTour | undefined = $state(undefined)
+
+ // Delayed so the tabs the first steps point at exist. `runTutorial` refuses while a tour is
+ // already running, which is the guard that matters — the tour ends by telling the operator
+ // to start it again from the menu, so a start has to be possible for the life of the page.
+ function startTour() {
+ setTimeout(() => operatorTour?.runTutorial(), TOUR_START_DELAY_MS)
+ }
+
+ // The sidebar entry asks by URL parameter so it works from any page an operator can be on.
+ // Read reactively rather than on mount: arriving from the menu while already on the home
+ // page is a parameter change, not a new page.
+ $effect(() => {
+ if (page.url.searchParams.get(TOUR_PARAM) !== TOUR_PARAM_VALUE) return
+ const user = $userStore
+ if (!user) return
+ untrack(() => {
+ const url = new URL(page.url)
+ url.searchParams.delete(TOUR_PARAM)
+ replaceState(url, page.state)
+ // Gated here too: the parameter is part of a URL anyone can type, and the tour
+ // describes a home page that only operators see.
+ if (user.operator) startTour()
+ })
+ })
+
+ onMount(async () => {
+ // Operators get the tour once, and only when they have not been through it: they cannot
+ // create anything, so the home page is the whole product to them and it is worth naming
+ // its three tabs. Anyone who can build gets nothing — they have the create button.
+ if (!$userStore?.operator || page.url.searchParams.has(TOUR_PARAM)) return
+ if (await hasSeenOperatorTour()) return
+ startTour()
+ })
@@ -324,6 +367,10 @@
{/if}