From 0f655627d1e4e5dbea4f5cdc2549e9c8b3a21899 Mon Sep 17 00:00:00 2001
From: Xuan Gu <162244362+xuang7@users.noreply.github.com>
Date: Fri, 15 May 2026 00:41:22 -0700
Subject: [PATCH 1/2] wip1
---
frontend/angular.json | 1 +
frontend/package.json | 3 +
.../dataset-file-selector.component.html | 1 +
.../operator-menu.component.html | 6 +-
.../property-editor.component.html | 3 +-
.../result-panel/result-panel.component.html | 1 +
.../tutorial-chat.component.html | 106 ++++
.../tutorial-chat.component.scss | 232 ++++++++
.../tutorial-chat/tutorial-chat.component.ts | 181 ++++++
.../tutorial-panel.component.html | 44 ++
.../tutorial-panel.component.scss | 101 ++++
.../tutorial-panel.component.ts | 193 +++++++
.../component/workspace.component.html | 2 +
.../component/workspace.component.ts | 4 +
.../service/tutorial/tutorial.service.ts | 536 ++++++++++++++++++
frontend/src/styles.scss | 80 +++
frontend/yarn.lock | 24 +
17 files changed, 1515 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html
create mode 100644 frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss
create mode 100644 frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts
create mode 100644 frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html
create mode 100644 frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss
create mode 100644 frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts
create mode 100644 frontend/src/app/workspace/service/tutorial/tutorial.service.ts
diff --git a/frontend/angular.json b/frontend/angular.json
index b9e9961d027..8a7243ef354 100644
--- a/frontend/angular.json
+++ b/frontend/angular.json
@@ -29,6 +29,7 @@
"node_modules/jointjs/css/themes/default.css",
"node_modules/ng-zorro-antd/ng-zorro-antd.min.css",
"node_modules/ng-zorro-antd/resizable/style/index.min.css",
+ "node_modules/driver.js/dist/driver.css",
"src/styles.scss"
],
"scripts": ["./node_modules/marked/lib/marked.umd.js"],
diff --git a/frontend/package.json b/frontend/package.json
index 08b298260e3..58314ac54c7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -43,9 +43,11 @@
"@ngx-formly/ng-zorro-antd": "6.3.12",
"ai": "5.0.93",
"ajv": "8.10.0",
+ "canvas-confetti": "^1.9.4",
"concaveman": "2.0.0",
"d3-shape": "2.1.0",
"dagre": "0.8.5",
+ "driver.js": "^1.4.0",
"file-saver": "2.0.5",
"fuse.js": "6.5.3",
"html2canvas": "1.4.1",
@@ -101,6 +103,7 @@
"@nx/angular": "22.7.0",
"@schematics/angular": "21.2.8",
"@types/backbone": "1.4.15",
+ "@types/canvas-confetti": "^1.9.0",
"@types/concaveman": "1.1.6",
"@types/d3-shape": "2.1.2",
"@types/dagre": "0.7.47",
diff --git a/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html b/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html
index 9fbc1ddffdc..0a8a2c57f7a 100644
--- a/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html
+++ b/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html
@@ -26,6 +26,7 @@
*ngIf="isFileSelectionEnabled"
nz-button
nzSize="small"
+ data-tutorial="file-select-button"
(click)="isFileSelectionEnabled && onClickOpenFileSelectionModal()">
Select File
diff --git a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html
index ff8bb97a296..60e1500ffec 100644
--- a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html
+++ b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html
@@ -54,13 +54,15 @@
*ngFor="let groupname of groupNames"
[nzHeader]="groupname.groupName"
class="operator-group"
- [attr.data-depth]="depth">
+ [attr.data-depth]="depth"
+ [attr.data-group-name]="groupname.groupName">
+ class="operator-label"
+ [attr.data-operator-type]="operatorSchema.operatorType">
diff --git a/frontend/src/app/workspace/component/property-editor/property-editor.component.html b/frontend/src/app/workspace/component/property-editor/property-editor.component.html
index 7ca4a328a87..952f32962ee 100644
--- a/frontend/src/app/workspace/component/property-editor/property-editor.component.html
+++ b/frontend/src/app/workspace/component/property-editor/property-editor.component.html
@@ -34,7 +34,8 @@
nz-menu-item
(click)="openPanel()"
*ngIf="!width"
- nz-tooltip="Property Editor">
+ nz-tooltip="Property Editor"
+ data-tutorial="open-property-panel">
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel.component.html b/frontend/src/app/workspace/component/result-panel/result-panel.component.html
index d2c6a535493..03c98a499ec 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel.component.html
+++ b/frontend/src/app/workspace/component/result-panel/result-panel.component.html
@@ -36,6 +36,7 @@
id="divider">
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html
new file mode 100644
index 00000000000..fc83d7634f0
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
π€
+
{{ msg.content }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss
new file mode 100644
index 00000000000..278420f9ef2
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss
@@ -0,0 +1,232 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Floating "Ask AI" button
+.chat-toggle-btn {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ z-index: 10000;
+ border-radius: 24px;
+ height: 44px;
+ padding: 0 16px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ box-shadow: 0 4px 16px rgba(24, 144, 255, 0.35);
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(24, 144, 255, 0.45);
+ }
+}
+
+.chat-btn-label {
+ font-size: 13px;
+ font-weight: 600;
+}
+
+// Slide-in chat panel
+.chat-panel {
+ position: fixed;
+ bottom: 80px;
+ right: 24px;
+ z-index: 9999;
+ width: 340px;
+ height: 460px;
+ background: #fff;
+ border-radius: 16px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid #e8e8e8;
+
+ // Slide in from bottom-right
+ transform: translateY(30px) scale(0.95);
+ opacity: 0;
+ pointer-events: none;
+ transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
+
+ &.open {
+ transform: translateY(0) scale(1);
+ opacity: 1;
+ pointer-events: all;
+ }
+}
+
+// Header
+.chat-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
+ color: #fff;
+ flex-shrink: 0;
+}
+
+.chat-header-title {
+ font-weight: 600;
+ font-size: 14px;
+ flex: 1;
+}
+
+.chat-header-subtitle {
+ font-size: 11px;
+ opacity: 0.8;
+ display: none;
+}
+
+.chat-close-btn {
+ color: rgba(255, 255, 255, 0.8) !important;
+ margin-left: auto;
+
+ &:hover {
+ color: #fff !important;
+ }
+}
+
+// Message list
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ background: #f9fafb;
+}
+
+.chat-loading {
+ display: flex;
+ justify-content: center;
+ padding: 20px;
+}
+
+.chat-message {
+ display: flex;
+ max-width: 90%;
+
+ &.user-message {
+ align-self: flex-end;
+
+ .message-bubble {
+ background: #1890ff;
+ color: #fff;
+ border-radius: 16px 16px 4px 16px;
+ }
+ }
+
+ &.agent-message {
+ align-self: flex-start;
+
+ .message-bubble {
+ background: #fff;
+ color: #262626;
+ border: 1px solid #e8e8e8;
+ border-radius: 16px 16px 16px 4px;
+ }
+ }
+}
+
+.message-bubble {
+ padding: 8px 12px;
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
+}
+
+.message-icon {
+ font-size: 14px;
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+.message-content {
+ font-size: 13px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+// Typing indicator animation
+.typing-indicator {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 0;
+
+ span {
+ width: 6px;
+ height: 6px;
+ background: #bfbfbf;
+ border-radius: 50%;
+ animation: bounce 1.2s infinite;
+
+ &:nth-child(2) {
+ animation-delay: 0.2s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 0.4s;
+ }
+ }
+}
+
+@keyframes bounce {
+ 0%,
+ 60%,
+ 100% {
+ transform: translateY(0);
+ }
+ 30% {
+ transform: translateY(-5px);
+ }
+}
+
+// Input area
+.chat-input-area {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ border-top: 1px solid #f0f0f0;
+ background: #fff;
+ flex-shrink: 0;
+}
+
+.chat-input {
+ flex: 1;
+ border-radius: 20px;
+ font-size: 13px;
+}
+
+.chat-send-btn {
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts
new file mode 100644
index 00000000000..3515c27952f
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts
@@ -0,0 +1,181 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { AfterViewChecked, Component, ElementRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
+import { NgClass, NgFor, NgIf } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { HttpClient } from "@angular/common/http";
+import { Subject } from "rxjs";
+import { takeUntil } from "rxjs/operators";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzInputDirective } from "ng-zorro-antd/input";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
+import { Ι΅NzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { TutorialService } from "../../../service/tutorial/tutorial.service";
+
+interface ChatMessage {
+ role: "user" | "assistant" | "system";
+ content: string;
+ timestamp?: Date;
+}
+
+interface ChatCompletionResponse {
+ choices: { message: { role: string; content: string } }[];
+}
+
+const SYSTEM_PROMPT = `You are a friendly AI tutor for Texera, a visual workflow editor for big-data analysis.
+Workflows are built by dragging operators (CSVScan, Filter, KeywordSearch, ...) onto a canvas and connecting them via edges from output ports to input ports.
+Keep your answers SHORT (2-3 sentences max). If the user is stuck on a tutorial step, give a direct hint without being condescending. If asked about an operator, briefly explain what it does.`;
+
+@Component({
+ selector: "texera-tutorial-chat",
+ templateUrl: "tutorial-chat.component.html",
+ styleUrls: ["tutorial-chat.component.scss"],
+ imports: [
+ NgIf,
+ NgFor,
+ NgClass,
+ FormsModule,
+ NzButtonComponent,
+ NzWaveDirective,
+ Ι΅NzTransitionPatchDirective,
+ NzIconDirective,
+ NzInputDirective,
+ NzTooltipDirective,
+ ],
+})
+export class TutorialChatComponent implements OnInit, OnDestroy, AfterViewChecked {
+ @ViewChild("messageContainer") private messageContainer?: ElementRef;
+
+ private readonly destroy$ = new Subject();
+
+ public isOpen = false;
+ public isTutorialActive = false;
+ public visibleMessages: ChatMessage[] = []; // shown to user (no system msg)
+ public inputText = "";
+ public isSending = false;
+ public initialized = false;
+ private shouldScrollToBottom = false;
+
+ constructor(
+ private http: HttpClient,
+ private tutorialService: TutorialService
+ ) {}
+
+ ngOnInit(): void {
+ this.tutorialService.isActive$.pipe(takeUntil(this.destroy$)).subscribe(active => {
+ this.isTutorialActive = active;
+ if (!active) this.isOpen = false;
+ });
+ }
+
+ ngAfterViewChecked(): void {
+ if (this.shouldScrollToBottom) {
+ this.scrollToBottom();
+ this.shouldScrollToBottom = false;
+ }
+ }
+
+ togglePanel(): void {
+ this.isOpen = !this.isOpen;
+ if (this.isOpen && !this.initialized) {
+ this.visibleMessages.push({
+ role: "assistant",
+ content: "π Hey! I'm your Texera tutor. Stuck on a step? Curious what an operator does? Type a question below β I'll keep it short and useful.",
+ timestamp: new Date(),
+ });
+ this.shouldScrollToBottom = true;
+ this.initialized = true;
+ }
+ }
+
+ closePanel(): void {
+ this.isOpen = false;
+ }
+
+ sendMessage(): void {
+ const text = this.inputText.trim();
+ if (!text || this.isSending) return;
+
+ this.visibleMessages.push({ role: "user", content: text, timestamp: new Date() });
+ this.shouldScrollToBottom = true;
+ this.inputText = "";
+ this.isSending = true;
+
+ const step = this.tutorialService.currentStep;
+ const contextLine = step
+ ? `[Tutorial context: the user is on ${step.title}. Hint context: ${step.aiHint}]`
+ : `[Tutorial context: the user is exploring Texera.]`;
+
+ // Build chat history for the LLM (system + visible turns, with current context injected)
+ const messages: ChatMessage[] = [
+ { role: "system", content: `${SYSTEM_PROMPT}\n\n${contextLine}` },
+ ...this.visibleMessages
+ .filter(m => m.role === "user" || m.role === "assistant")
+ .map(m => ({ role: m.role, content: m.content })),
+ ];
+
+ this.http
+ .post("/api/chat/completion", {
+ model: "claude-haiku-4.5",
+ messages,
+ max_tokens: 400,
+ })
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: response => {
+ const reply = response.choices?.[0]?.message?.content ?? "(no response)";
+ this.visibleMessages.push({ role: "assistant", content: reply, timestamp: new Date() });
+ this.shouldScrollToBottom = true;
+ this.isSending = false;
+ },
+ error: err => {
+ const errMsg = err?.error?.error?.message || err?.message || "Failed to get response";
+ this.visibleMessages.push({
+ role: "assistant",
+ content: `β οΈ ${errMsg}`,
+ timestamp: new Date(),
+ });
+ this.shouldScrollToBottom = true;
+ this.isSending = false;
+ },
+ });
+ }
+
+ onKeyEnter(event: KeyboardEvent): void {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ this.sendMessage();
+ }
+ }
+
+ private scrollToBottom(): void {
+ if (this.messageContainer) {
+ const el = this.messageContainer.nativeElement;
+ el.scrollTop = el.scrollHeight;
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+}
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html
new file mode 100644
index 00000000000..486387b3ffd
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html
@@ -0,0 +1,44 @@
+
+
+
+
+
π
+
+ Awesome β you did it!
+ You just built your first Texera workflow. Click any operator to peek at its data.
+
+
+
+
+
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss
new file mode 100644
index 00000000000..b2935a7df1e
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss
@@ -0,0 +1,101 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Completion toast β pops in after the user finishes the tour, then drifts away
+.tutorial-completion {
+ position: fixed;
+ bottom: 110px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10001;
+ background: linear-gradient(135deg, #fff 0%, #f0f5ff 100%);
+ border: 2px solid #1890ff;
+ border-radius: 14px;
+ padding: 14px 22px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ box-shadow: 0 8px 28px rgba(24, 144, 255, 0.28);
+ font-size: 15px;
+ max-width: 460px;
+ animation: tutorial-pop-in 0.5s cubic-bezier(0.18, 1.25, 0.4, 1);
+}
+
+.tutorial-completion-emoji {
+ font-size: 34px;
+ line-height: 1;
+ animation: tutorial-bounce 1.6s ease-in-out infinite;
+}
+
+.tutorial-completion-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ color: #1f1f1f;
+
+ strong {
+ font-size: 15px;
+ color: #1890ff;
+ }
+}
+
+.tutorial-completion-sub {
+ font-size: 13px;
+ color: #555;
+}
+
+@keyframes tutorial-pop-in {
+ 0% { opacity: 0; transform: translateX(-50%) translateY(20px) scale(0.85); }
+ 60% { opacity: 1; transform: translateX(-50%) translateY(-4px) scale(1.04); }
+ 100% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
+}
+
+@keyframes tutorial-bounce {
+ 0%, 100% { transform: translateY(0) rotate(-4deg); }
+ 50% { transform: translateY(-6px) rotate(8deg); }
+}
+
+// Floating "Start Tutorial" launcher β sits at the top-right while the tour is off
+.tutorial-start-btn {
+ position: fixed;
+ top: 12px;
+ right: 16px;
+ z-index: 10000;
+ height: 36px;
+ padding: 0 16px;
+ border-radius: 20px;
+ font-weight: 600;
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ box-shadow: 0 4px 14px rgba(24, 144, 255, 0.4);
+ animation: tutorial-btn-pulse 2s ease-in-out infinite;
+ transition: transform 0.15s ease;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(24, 144, 255, 0.55);
+ }
+}
+
+@keyframes tutorial-btn-pulse {
+ 0%, 100% { box-shadow: 0 4px 14px rgba(24, 144, 255, 0.4); }
+ 50% { box-shadow: 0 4px 24px rgba(24, 144, 255, 0.7); }
+}
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts
new file mode 100644
index 00000000000..12eae71291f
--- /dev/null
+++ b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts
@@ -0,0 +1,193 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnDestroy, OnInit } from "@angular/core";
+import { NgIf } from "@angular/common";
+import { Subject } from "rxjs";
+import { takeUntil } from "rxjs/operators";
+import confetti from "canvas-confetti";
+import { driver, Driver } from "driver.js";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
+import { Ι΅NzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { TutorialService } from "../../../service/tutorial/tutorial.service";
+
+const TUTORIAL_SEEN_KEY = "texera-tutorial-seen";
+
+@Component({
+ selector: "texera-tutorial-panel",
+ templateUrl: "tutorial-panel.component.html",
+ styleUrls: ["tutorial-panel.component.scss"],
+ imports: [
+ NgIf,
+ NzButtonComponent,
+ NzWaveDirective,
+ Ι΅NzTransitionPatchDirective,
+ NzIconDirective,
+ NzTooltipDirective,
+ ],
+})
+export class TutorialPanelComponent implements OnInit, OnDestroy {
+ private readonly destroy$ = new Subject();
+ private welcomeDriver: Driver | null = null;
+
+ public isActive = false;
+ public showCompletion = false;
+
+ constructor(public tutorialService: TutorialService) {}
+
+ ngOnInit(): void {
+ this.tutorialService.isActive$.pipe(takeUntil(this.destroy$)).subscribe(active => {
+ this.isActive = active;
+ });
+
+ this.tutorialService.completed$.pipe(takeUntil(this.destroy$)).subscribe(() => {
+ try {
+ localStorage.setItem(TUTORIAL_SEEN_KEY, "1");
+ } catch {
+ /* localStorage may be unavailable */
+ }
+ this.showCompletion = true;
+ this.fireConfetti();
+ setTimeout(() => (this.showCompletion = false), 6000);
+ });
+
+ if (!this.hasSeenTutorial()) {
+ setTimeout(() => this.showWelcomePopover(), 800);
+ }
+ }
+
+ onRestart(): void {
+ this.tutorialService.restart();
+ }
+
+ /**
+ * Celebrate tour completion with a short confetti burst from both bottom
+ * corners. Two staggered cannons feel livelier than a single center blast
+ * and keep the spotlight on the result panel readable.
+ */
+ private fireConfetti(): void {
+ const duration = 1800;
+ const end = Date.now() + duration;
+ const colors = ["#1890ff", "#52c41a", "#faad14", "#eb2f96", "#722ed1"];
+
+ const frame = () => {
+ confetti({
+ particleCount: 4,
+ angle: 60,
+ spread: 60,
+ startVelocity: 55,
+ origin: { x: 0, y: 0.85 },
+ colors,
+ });
+ confetti({
+ particleCount: 4,
+ angle: 120,
+ spread: 60,
+ startVelocity: 55,
+ origin: { x: 1, y: 0.85 },
+ colors,
+ });
+ if (Date.now() < end) requestAnimationFrame(frame);
+ };
+ frame();
+ }
+
+ private hasSeenTutorial(): boolean {
+ try {
+ return localStorage.getItem(TUTORIAL_SEEN_KEY) === "1";
+ } catch {
+ return false;
+ }
+ }
+
+ private markSeen(): void {
+ try {
+ localStorage.setItem(TUTORIAL_SEEN_KEY, "1");
+ } catch {
+ /* localStorage may be unavailable */
+ }
+ }
+
+ /**
+ * Show the first-time welcome as a centered driver.js popover so it shares
+ * the spotlight aesthetic of the rest of the tour. The CTA acts as the
+ * single "Done" button (since there's no element to advance from) and
+ * starts the real 16-step tour; the X button is the polite opt-out.
+ */
+ private showWelcomePopover(): void {
+ let userStartedTour = false;
+ this.welcomeDriver = driver({
+ showProgress: false,
+ allowClose: true,
+ overlayColor: "#000",
+ overlayOpacity: 0.7,
+ stagePadding: 0,
+ popoverClass: "tutorial-welcome-popover",
+ showButtons: ["next", "close"],
+ doneBtnText: "Start building your first workflow β¨",
+ onCloseClick: () => {
+ this.markSeen();
+ this.welcomeDriver?.destroy();
+ },
+ onNextClick: () => {
+ userStartedTour = true;
+ this.markSeen();
+ this.welcomeDriver?.destroy();
+ },
+ onDestroyed: () => {
+ const wasStart = userStartedTour;
+ this.welcomeDriver = null;
+ if (wasStart) setTimeout(() => this.tutorialService.start(), 250);
+ },
+ steps: [
+ {
+ popover: {
+ title: "π Welcome to Texera!",
+ description: `
+
+
π§ͺ
+
+ Texera lets you build big-data workflows by
+ dragging blocks β no code required.
+
+
+ Take a friendly 2-minute tour and you'll have a
+ real workflow running by the end. We'll celebrate together π
+
+
+ You can relaunch the tour any time from the floating button.
+
+
+ `,
+ },
+ },
+ ],
+ });
+ this.welcomeDriver.drive();
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.welcomeDriver?.destroy();
+ }
+}
diff --git a/frontend/src/app/workspace/component/workspace.component.html b/frontend/src/app/workspace/component/workspace.component.html
index c54446fb318..8883bfb69f9 100644
--- a/frontend/src/app/workspace/component/workspace.component.html
+++ b/frontend/src/app/workspace/component/workspace.component.html
@@ -37,4 +37,6 @@
*ngIf="copilotEnabled"
[agentIdToActivate]="agentIdToActivate">
+
+
diff --git a/frontend/src/app/workspace/component/workspace.component.ts b/frontend/src/app/workspace/component/workspace.component.ts
index 9968c26f647..7c6b96101ad 100644
--- a/frontend/src/app/workspace/component/workspace.component.ts
+++ b/frontend/src/app/workspace/component/workspace.component.ts
@@ -61,6 +61,8 @@ import { LeftPanelComponent } from "./left-panel/left-panel.component";
import { AgentPanelComponent } from "./agent/agent-panel/agent-panel.component";
import { PropertyEditorComponent } from "./property-editor/property-editor.component";
import { FormlyRepeatDndComponent } from "../../common/formly/repeat-dnd/repeat-dnd.component";
+import { TutorialPanelComponent } from "./tutorial/tutorial-panel/tutorial-panel.component";
+import { TutorialChatComponent } from "./tutorial/tutorial-chat/tutorial-chat.component";
export const SAVE_DEBOUNCE_TIME_IN_MS = 5000;
@@ -84,6 +86,8 @@ export const SAVE_DEBOUNCE_TIME_IN_MS = 5000;
AgentPanelComponent,
PropertyEditorComponent,
FormlyRepeatDndComponent,
+ TutorialPanelComponent,
+ TutorialChatComponent,
],
})
export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy {
diff --git a/frontend/src/app/workspace/service/tutorial/tutorial.service.ts b/frontend/src/app/workspace/service/tutorial/tutorial.service.ts
new file mode 100644
index 00000000000..e142c924e22
--- /dev/null
+++ b/frontend/src/app/workspace/service/tutorial/tutorial.service.ts
@@ -0,0 +1,536 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Injectable, NgZone, OnDestroy } from "@angular/core";
+import { BehaviorSubject, Subject } from "rxjs";
+import { filter, takeUntil } from "rxjs/operators";
+import { driver, Driver, DriveStep } from "driver.js";
+import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
+import { ExecuteWorkflowService } from "../execute-workflow/execute-workflow.service";
+import { ExecutionState } from "../../types/execute-workflow.interface";
+import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
+import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface";
+
+type AutoAdvanceTrigger =
+ | "operatorAdded"
+ | "operatorSelected"
+ | "linkAdded"
+ | "executionStarted"
+ | "propertyChanged"
+ | "computingUnitConnected";
+
+export interface TutorialStep extends DriveStep {
+ /** Short label used by the AI chat for context */
+ title: string;
+ /** Prompt fragment given to the AI when the user asks a question on this step */
+ aiHint: string;
+ /** If set, advance automatically when this event fires on the workflow */
+ autoAdvanceOn?: AutoAdvanceTrigger;
+ /** If true, clicking the highlighted element advances to the next step */
+ advanceOnClick?: boolean;
+}
+
+export const TUTORIAL_STEPS: TutorialStep[] = [
+ {
+ element: "texera-operator-menu",
+ popover: {
+ title: "π Welcome to your toolbox",
+ description:
+ "This panel on the LEFT is your toolbox. Each tile here is an operator β a tiny building block that does one thing to your data. We're going to wire two of them together!",
+ side: "right",
+ align: "start",
+ },
+ title: "Step 1 β The Operator Panel",
+ aiHint: "The user just opened Texera and is being introduced to the operator panel on the left. Intro only β user clicks Next to advance.",
+ },
+ {
+ element: 'nz-collapse-panel[data-group-name="Data Input"]',
+ popover: {
+ title: "First, let's get some data π₯",
+ description:
+ 'Click "Data Input" to open the category. Sources are operators that pull data into your workflow β CSV files, databases, APIs.',
+ side: "right",
+ align: "start",
+ },
+ title: "Step 2 β Find a Source",
+ aiHint: "The user is locating the Data Input category to find a source operator.",
+ advanceOnClick: true,
+ },
+ {
+ element: '.operator-label[data-operator-type="CSVFileScan"]',
+ popover: {
+ title: "Drag it onto the canvas π―",
+ description:
+ "Grab CSV File Scan and drop it onto the empty canvas on the right. This little box's job: read rows from a CSV file.",
+ side: "right",
+ align: "start",
+ },
+ title: "Step 3 β Drag CSV File Scan",
+ aiHint: "The user is dragging the CSV File Scan source operator from the operator panel onto the canvas.",
+ autoAdvanceOn: "operatorAdded",
+ },
+ {
+ element: '[model-id^="CSVFileScan-operator-"]',
+ popover: {
+ title: "Nice! Now give it a click",
+ description:
+ "There's your CSV File Scan on the canvas. Click it once to select it β that's how we tell Texera \"I want to configure this one\".",
+ side: "bottom",
+ align: "center",
+ },
+ title: "Step 4 β Click the Operator",
+ aiHint: "The user needs to click the just-placed CSV File Scan operator on the canvas to open its property editor.",
+ autoAdvanceOn: "operatorSelected",
+ },
+ {
+ element: 'li[data-tutorial="open-property-panel"]',
+ popover: {
+ title: "Open its settings βοΈ",
+ description:
+ "See the little form icon at the top-right? Click it to slide open the settings panel on the right side. (Already open? We'll just skip ahead.)",
+ side: "left",
+ align: "center",
+ },
+ title: "Step 5 β Open the Property Panel",
+ aiHint: "The user needs to click the small form icon at the top-right of the canvas to expand the (currently collapsed) property panel. Auto-skipped when the panel is already open.",
+ advanceOnClick: true,
+ },
+ {
+ element: 'button[data-tutorial="file-select-button"]',
+ popover: {
+ title: "Pick a file π",
+ description: "Click Select File to open the dataset browser β that's where your CSVs live.",
+ side: "left",
+ align: "center",
+ },
+ title: "Step 6 β Open the File Picker",
+ aiHint: "The user is about to click the Select File button to open the dataset selection modal.",
+ advanceOnClick: true,
+ },
+ {
+ element: ".ant-modal-content",
+ popover: {
+ title: "Browse your data",
+ description:
+ "Pick a dataset on the left, expand a version, then double-click any .csv. The modal closes itself and the file name auto-fills β no typing required β¨.",
+ side: "top",
+ align: "end",
+ },
+ title: "Step 7 β Pick a CSV File",
+ aiHint: "The user is choosing a CSV file from the dataset selection modal. After they pick one, the modal closes and the fileName property updates.",
+ autoAdvanceOn: "propertyChanged",
+ },
+ {
+ element: 'nz-collapse-panel[data-group-name="Data Cleaning"]',
+ popover: {
+ title: "Time for a second operator π§°",
+ description:
+ 'Back to the left panel. Click "Data Cleaning" to open that category β we\'re about to chain a second operator after our source.',
+ side: "right",
+ align: "start",
+ },
+ title: "Step 8 β Find the Data Cleaning Category",
+ aiHint: "The user needs to expand the Data Cleaning category in the operator panel to find Limit.",
+ advanceOnClick: true,
+ },
+ {
+ element: '.operator-label[data-operator-type="Limit"]',
+ popover: {
+ title: "Drag Limit onto the canvas",
+ description:
+ "Grab Limit and drop it on the canvas next to CSV File Scan. Limit caps your output to the first N rows β perfect for a quick peek.",
+ side: "right",
+ align: "start",
+ },
+ title: "Step 9 β Drag Limit",
+ aiHint: "The user is dragging the Limit operator onto the canvas as a second operator.",
+ autoAdvanceOn: "operatorAdded",
+ },
+ {
+ element: "texera-workflow-editor",
+ popover: {
+ title: "Wire them up π",
+ description:
+ "Drag from the βΆ port on the right edge of CSV File Scan over to the port on the left edge of Limit. That edge tells Texera \"send rows this way\".",
+ side: "over",
+ align: "center",
+ },
+ title: "Step 10 β Connect Operators",
+ aiHint: "The user is dragging an edge from CSV File Scan's output port to Limit's input port.",
+ autoAdvanceOn: "linkAdded",
+ },
+ {
+ element: '[model-id^="Limit-operator-"]',
+ popover: {
+ title: "Click Limit to configure it",
+ description:
+ "Click the Limit box on the canvas. The property panel on the right will swap over to its settings β each operator has its own.",
+ side: "bottom",
+ align: "center",
+ },
+ title: "Step 11 β Click Limit",
+ aiHint: "The user needs to click the just-connected Limit operator on the canvas to switch the property editor to its fields.",
+ autoAdvanceOn: "operatorSelected",
+ },
+ {
+ element: 'li[data-tutorial="open-property-panel"]',
+ popover: {
+ title: "Open its settings",
+ description:
+ "If the panel collapsed again, click the form icon at the top-right to reopen it. (Already open? Skippingβ¦)",
+ side: "left",
+ align: "center",
+ },
+ title: "Step 12 β Open the Property Panel (Limit)",
+ aiHint: "Same as step 5: the user clicks the form icon to reopen the property panel for the Limit operator. Auto-skipped when the panel is already open.",
+ advanceOnClick: true,
+ },
+ {
+ element: ".property-editor-form",
+ popover: {
+ title: "How many rows? π’",
+ description:
+ "Type a number into the Limit field β that's the max rows you'll see. Try 10 for a snappy preview.",
+ side: "left",
+ align: "start",
+ },
+ title: "Step 13 β Configure Limit",
+ aiHint: "The user is configuring the Limit operator by typing a number in the limit field (max output rows).",
+ autoAdvanceOn: "propertyChanged",
+ },
+ {
+ element: "#texera-compute-unit-selection",
+ popover: {
+ title: "Almost there β pick a Computing Unit β‘",
+ description:
+ "Before running, click the Computing Unit selector at the top and pick (or start) one. That's the little worker that'll execute your workflow.",
+ side: "bottom",
+ align: "start",
+ },
+ title: "Step 14 β Connect a Computing Unit",
+ aiHint: "The user needs to select/connect a Computing Unit from the top menu before running the workflow.",
+ autoAdvanceOn: "computingUnitConnected",
+ },
+ {
+ element: "#run-button",
+ popover: {
+ title: "Hit Run! βΆοΈ",
+ description: "Once the unit shows Connected, smash that βΆ Run button and watch Texera get to work.",
+ side: "bottom",
+ align: "start",
+ },
+ title: "Step 15 β Run the Workflow",
+ aiHint: "The user is clicking the Run button at the top to execute the workflow.",
+ advanceOnClick: true,
+ },
+ {
+ element: 'li[data-tutorial="open-result-panel"]',
+ popover: {
+ title: "Open the result panel π",
+ description:
+ "Your workflow finished β the data is ready! Click this little square icon at the bottom-left to slide open the result panel and see your rows.",
+ side: "top",
+ align: "start",
+ },
+ title: "Step 16 β Open the Result Panel",
+ aiHint: "The user just ran the workflow. They need to click the 'Open Result Panel' icon (the square / border icon at the bottom-left of the screen) to expand the result panel and view their data.",
+ advanceOnClick: true,
+ },
+ {
+ popover: {
+ title: "π You did it!",
+ description:
+ "Your CSV rows are now waiting for you in the result panel. You just built and ran your very first Texera workflow β from empty canvas to real data in under two minutes. Welcome aboard! β¨",
+ },
+ title: "Step 17 β Celebration",
+ aiHint: "Final celebratory step. The user has opened the result panel and can now see their workflow's output. No element is highlighted β the popover is centered like the welcome screen for a clean closing moment.",
+ },
+];
+
+@Injectable({
+ providedIn: "root",
+})
+export class TutorialService implements OnDestroy {
+ private readonly destroy$ = new Subject();
+ private driverObj: Driver | null = null;
+ private lastActiveIndex = 0;
+
+ // One-shot DOM click listener for steps with `advanceOnClick: true`.
+ private clickAdvanceEl: Element | null = null;
+ private clickAdvanceHandler: ((ev: Event) => void) | null = null;
+
+ private readonly isActiveSubject = new BehaviorSubject(false);
+ public readonly isActive$ = this.isActiveSubject.asObservable();
+
+ private readonly currentStepSubject = new BehaviorSubject(0);
+ public readonly currentStep$ = this.currentStepSubject.asObservable();
+
+ private readonly completedSubject = new Subject();
+ public readonly completed$ = this.completedSubject.asObservable();
+
+ public readonly steps: TutorialStep[] = TUTORIAL_STEPS;
+
+ constructor(
+ private workflowActionService: WorkflowActionService,
+ private executeWorkflowService: ExecuteWorkflowService,
+ private computingUnitStatusService: ComputingUnitStatusService,
+ private ngZone: NgZone
+ ) {
+ this.wireEventListeners();
+ }
+
+ get isActive(): boolean {
+ return this.isActiveSubject.getValue();
+ }
+
+ get currentStepIndex(): number {
+ return this.currentStepSubject.getValue();
+ }
+
+ get currentStep(): TutorialStep | null {
+ return this.isActive ? (TUTORIAL_STEPS[this.currentStepIndex] ?? null) : null;
+ }
+
+ get stepCount(): number {
+ return TUTORIAL_STEPS.length;
+ }
+
+ start(): void {
+ if (this.driverObj) {
+ this.driverObj.destroy();
+ this.driverObj = null;
+ }
+ this.lastActiveIndex = 0;
+ this.currentStepSubject.next(0);
+ this.isActiveSubject.next(true);
+
+ const driverSteps: DriveStep[] = TUTORIAL_STEPS.map(step => ({
+ element: step.element,
+ popover: step.popover,
+ }));
+
+ // Log selector resolution so missing targets are obvious in the console.
+ // eslint-disable-next-line no-console
+ console.info(
+ "[tutorial] starting; selector-resolution report:",
+ driverSteps.map((s, i) => ({
+ idx: i,
+ element: s.element,
+ found: typeof s.element === "string" ? !!document.querySelector(s.element) : !!s.element,
+ }))
+ );
+
+ this.driverObj = driver({
+ showProgress: true,
+ // Keep the top-right X button so the user always has an explicit exit,
+ // but disarm overlay clicks so accidental clicks on the dim area don't
+ // abort the tour.
+ allowClose: true,
+ overlayClickBehavior: () => {
+ /* no-op: clicking the dim overlay does nothing */
+ },
+ animate: true,
+ smoothScroll: true,
+ // Generous padding so small spotlights (operator boxes on canvas,
+ // small buttons in panels) have visual breathing room and stay easy
+ // to click without nicking the dim overlay edge.
+ stagePadding: 16,
+ stageRadius: 8,
+ disableActiveInteraction: false,
+ overlayColor: "#000",
+ overlayOpacity: 0.65,
+ nextBtnText: "Next β",
+ prevBtnText: "β Back",
+ doneBtnText: "All done! π",
+ progressText: "Step {{current}} / {{total}}",
+ steps: driverSteps,
+ onHighlightStarted: (el, _step, opts) => {
+ const idx = opts?.state?.activeIndex ?? 0;
+ this.lastActiveIndex = idx;
+ this.ngZone.run(() => this.currentStepSubject.next(idx));
+ this.detachClickAdvance();
+ const step = TUTORIAL_STEPS[idx];
+
+ // Force the spotlight target into view. driver.js's `smoothScroll`
+ // sometimes misses nested scroll containers (e.g. the operator menu's
+ // inner list when a category is scrolled below the fold). After the
+ // browser settles the scroll, ask driver.js to re-measure with
+ // `refresh()` so the spotlight + popover land at the new position.
+ if (el && typeof (el as HTMLElement).scrollIntoView === "function") {
+ (el as HTMLElement).scrollIntoView({ block: "center", inline: "nearest" });
+ setTimeout(() => this.driverObj?.refresh(), 60);
+ }
+
+ // Auto-skip a step whose target doesn't exist (e.g. step 12's "open
+ // the property panel" form icon, which only renders when width=0).
+ // Re-query after a short delay to give Angular a tick to render, then
+ // moveNext if the element is still missing or zero-sized. Never skip
+ // the final step β moveNext() on it ends the tour, which makes the
+ // celebratory "you're done" popover flash away instantly.
+ const isLastStep = idx === TUTORIAL_STEPS.length - 1;
+ if (typeof step?.element === "string" && !isLastStep) {
+ const selector = step.element;
+ setTimeout(() => {
+ if (this.currentStepIndex !== idx || !this.driverObj) return;
+ const reEl = document.querySelector(selector) as HTMLElement | null;
+ const rect = reEl?.getBoundingClientRect();
+ const missing = !reEl || !rect || (rect.width === 0 && rect.height === 0);
+ if (missing) {
+ // eslint-disable-next-line no-console
+ console.info(
+ `[tutorial] step ${idx + 1} target "${selector}" not found / zero-size; auto-skipping`
+ );
+ this.ngZone.run(() => this.driverObj?.moveNext());
+ }
+ }, 250);
+ }
+
+ // Debug: log the bounding rect of the spotlight target so
+ // mis-positioned popovers can be diagnosed at a glance.
+ if (el) {
+ const rect = (el as HTMLElement).getBoundingClientRect?.();
+ // eslint-disable-next-line no-console
+ console.info(
+ `[tutorial] step ${idx + 1} highlight:`,
+ step?.title,
+ "β",
+ step?.element,
+ rect && {
+ x: Math.round(rect.x),
+ y: Math.round(rect.y),
+ w: Math.round(rect.width),
+ h: Math.round(rect.height),
+ }
+ );
+ } else if (step?.element) {
+ // Only warn when an element was configured but couldn't be found.
+ // Steps without `element` are intentionally centered popovers
+ // (e.g. the final celebration) β that's not a failure mode.
+ // eslint-disable-next-line no-console
+ console.warn(`[tutorial] step ${idx + 1} highlight: NO ELEMENT MATCHED for`, step?.element);
+ }
+ if (step?.advanceOnClick && el) this.attachClickAdvance(el);
+ },
+ onDestroyed: () => {
+ this.detachClickAdvance();
+ this.ngZone.run(() => {
+ const wasOnLast = this.lastActiveIndex === TUTORIAL_STEPS.length - 1;
+ this.driverObj = null;
+ this.isActiveSubject.next(false);
+ if (wasOnLast) this.completedSubject.next();
+ });
+ },
+ });
+ this.driverObj.drive();
+ }
+
+ next(): void {
+ this.driverObj?.moveNext();
+ }
+
+ previous(): void {
+ this.driverObj?.movePrevious();
+ }
+
+ skip(): void {
+ this.driverObj?.destroy();
+ }
+
+ restart(): void {
+ this.start();
+ }
+
+ private autoAdvanceIfMatches(trigger: AutoAdvanceTrigger): void {
+ if (!this.isActive || !this.driverObj) return;
+ const step = TUTORIAL_STEPS[this.currentStepIndex];
+ if (step?.autoAdvanceOn !== trigger) return;
+ setTimeout(() => this.ngZone.run(() => this.driverObj?.moveNext()), 700);
+ }
+
+ private attachClickAdvance(el: Element): void {
+ this.clickAdvanceEl = el;
+ this.clickAdvanceHandler = () => {
+ // Let the click's side-effects finish β collapse panel expand, ng-zorro
+ // modal mount animation, etc. β before driver.js measures the next
+ // target's bounding rect. 500ms covers ant-modal's ~300ms fade-in.
+ setTimeout(() => this.ngZone.run(() => this.driverObj?.moveNext()), 500);
+ };
+ el.addEventListener("click", this.clickAdvanceHandler, { once: true });
+ }
+
+ private detachClickAdvance(): void {
+ if (this.clickAdvanceEl && this.clickAdvanceHandler) {
+ this.clickAdvanceEl.removeEventListener("click", this.clickAdvanceHandler);
+ }
+ this.clickAdvanceEl = null;
+ this.clickAdvanceHandler = null;
+ }
+
+ private wireEventListeners(): void {
+ this.workflowActionService
+ .getTexeraGraph()
+ .getOperatorAddStream()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.autoAdvanceIfMatches("operatorAdded"));
+
+ this.workflowActionService
+ .getTexeraGraph()
+ .getLinkAddStream()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.autoAdvanceIfMatches("linkAdded"));
+
+ this.workflowActionService
+ .getTexeraGraph()
+ .getOperatorPropertyChangeStream()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.autoAdvanceIfMatches("propertyChanged"));
+
+ this.workflowActionService
+ .getJointGraphWrapper()
+ .getJointOperatorHighlightStream()
+ .pipe(
+ filter(ids => ids.length > 0),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => this.autoAdvanceIfMatches("operatorSelected"));
+
+ this.executeWorkflowService
+ .getExecutionStateStream()
+ .pipe(
+ filter(({ current }) => current.state === ExecutionState.Running || current.state === ExecutionState.Completed),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => this.autoAdvanceIfMatches("executionStarted"));
+
+ this.computingUnitStatusService
+ .getStatus()
+ .pipe(
+ filter(state => state === ComputingUnitState.Running),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => this.autoAdvanceIfMatches("computingUnitConnected"));
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.detachClickAdvance();
+ this.driverObj?.destroy();
+ }
+}
diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss
index 5f4a00952a9..db70acdda56 100644
--- a/frontend/src/styles.scss
+++ b/frontend/src/styles.scss
@@ -19,6 +19,86 @@
@import "@ali-hm/angular-tree-component/css/angular-tree-component.css";
+/* driver.js β purely cosmetic. DO NOT override z-index here; driver.js
+ uses z-index: 1000000000 by design so the spotlight always wins. */
+.driver-popover {
+ max-width: 360px;
+}
+.driver-popover-title {
+ font-weight: 600;
+}
+.driver-popover-description b {
+ color: #1890ff;
+}
+
+/* Welcome popover β a centered, fancier variant of the standard driver.js
+ popover used for the first-time greeting. Wider, with a soft gradient and
+ a primary-coloured CTA so "Start building your first workflow" reads as
+ the obvious next step. */
+.driver-popover.tutorial-welcome-popover {
+ max-width: 460px;
+ background: linear-gradient(135deg, #ffffff 0%, #f0f5ff 100%);
+ border: 2px solid #1890ff;
+ box-shadow: 0 16px 48px rgba(24, 144, 255, 0.32);
+ animation: tutorial-welcome-pop 0.5s cubic-bezier(0.18, 1.25, 0.4, 1);
+
+ .driver-popover-title {
+ font-size: 20px;
+ color: #1890ff;
+ text-align: center;
+ }
+
+ .driver-popover-footer button.driver-popover-next-btn {
+ background: #1890ff;
+ border-color: #1890ff;
+ color: #fff;
+ font-weight: 600;
+ padding: 6px 18px;
+ border-radius: 8px;
+ box-shadow: 0 4px 14px rgba(24, 144, 255, 0.45);
+ text-shadow: none;
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
+
+ &:hover {
+ background: #40a9ff;
+ border-color: #40a9ff;
+ transform: translateY(-1px);
+ box-shadow: 0 6px 18px rgba(24, 144, 255, 0.6);
+ }
+ }
+
+ .tutorial-welcome-emoji {
+ font-size: 54px;
+ margin-bottom: 8px;
+ display: inline-block;
+ animation: tutorial-welcome-wave 1.4s ease-in-out infinite;
+ transform-origin: 70% 80%;
+ }
+}
+
+@keyframes tutorial-welcome-pop {
+ 0% { opacity: 0; transform: scale(0.85); }
+ 60% { opacity: 1; transform: scale(1.04); }
+ 100% { opacity: 1; transform: scale(1); }
+}
+
+@keyframes tutorial-welcome-wave {
+ 0%, 100% { transform: rotate(-14deg); }
+ 50% { transform: rotate(14deg); }
+}
+
+/* driver.js by default sets pointer-events:none on every element outside the
+ highlighted one. That breaks drag-and-drop tutorial steps (user can grab the
+ operator label but the canvas refuses the drop). Re-enable interaction
+ everywhere while the tour is active β the dim overlay still gives the visual
+ cue, but the page stays fully usable. */
+body.driver-active *:not(.driver-overlay) {
+ pointer-events: auto !important;
+}
+body.driver-active .driver-overlay {
+ pointer-events: none !important;
+}
+
.ant-menu-item,
.ant-menu-submenu-title,
img {
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 6a4ae4330c4..699d39f1ad8 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -6002,6 +6002,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/canvas-confetti@npm:^1.9.0":
+ version: 1.9.0
+ resolution: "@types/canvas-confetti@npm:1.9.0"
+ checksum: 10c0/ffe2c674d466b8e13472c81ab2a97056f3433fd40a3513dbc1bb76764e4e7c3ff0a2a58d37b16ea6a245c831077c553db321b069dda6572eab59f2be61137b2e
+ languageName: node
+ linkType: hard
+
"@types/chai@npm:^5.2.2":
version: 5.2.3
resolution: "@types/chai@npm:5.2.3"
@@ -8224,6 +8231,13 @@ __metadata:
languageName: node
linkType: hard
+"canvas-confetti@npm:^1.9.4":
+ version: 1.9.4
+ resolution: "canvas-confetti@npm:1.9.4"
+ checksum: 10c0/08f11b5fa20b365922108828d7488f9b3eac2afdf39bc478339268c1250b5215df82682d7074520a3f0f72f4728f9703bbc937dcfe793f196b44e6e8ac0b1f7b
+ languageName: node
+ linkType: hard
+
"case-anything@npm:^2.1.13":
version: 2.1.13
resolution: "case-anything@npm:2.1.13"
@@ -9401,6 +9415,13 @@ __metadata:
languageName: node
linkType: hard
+"driver.js@npm:^1.4.0":
+ version: 1.4.0
+ resolution: "driver.js@npm:1.4.0"
+ checksum: 10c0/f868e85037d4180d55a883ec5bdad7a59e3b16e84438af4285ad605cab651b21fe565839a1607992362b0c028309d4ae6d052926d1c4ab67b2c62142a579b529
+ languageName: node
+ linkType: hard
+
"dunder-proto@npm:1.0.1, dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
@@ -11035,6 +11056,7 @@ __metadata:
"@nx/angular": "npm:22.7.0"
"@schematics/angular": "npm:21.2.8"
"@types/backbone": "npm:1.4.15"
+ "@types/canvas-confetti": "npm:^1.9.0"
"@types/concaveman": "npm:1.1.6"
"@types/d3-shape": "npm:2.1.2"
"@types/dagre": "npm:0.7.47"
@@ -11057,10 +11079,12 @@ __metadata:
"@vitest/coverage-v8": "npm:4.1.5"
ai: "npm:5.0.93"
ajv: "npm:8.10.0"
+ canvas-confetti: "npm:^1.9.4"
concaveman: "npm:2.0.0"
concurrently: "npm:7.4.0"
d3-shape: "npm:2.1.0"
dagre: "npm:0.8.5"
+ driver.js: "npm:^1.4.0"
eslint: "npm:8.57.0"
eslint-plugin-rxjs: "npm:5.0.3"
eslint-plugin-rxjs-angular: "npm:2.0.1"
From 086c2fa6c8592a8a99498cd5ad66444f15f6ce83 Mon Sep 17 00:00:00 2001
From: Xuan Gu <162244362+xuang7@users.noreply.github.com>
Date: Sat, 16 May 2026 08:45:22 -0700
Subject: [PATCH 2/2] feat(workspace): add tutorial mode with guided driver.js
flows
Gated behind gui.workflow.workspace.tutorial-enabled.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
common/config/src/main/resources/gui.conf | 6 +
.../org/apache/texera/config/GuiConfig.scala | 2 +
.../service/resource/ConfigResource.scala | 1 +
frontend/src/_tutorial-driver-overrides.scss | 470 +++++++++++
.../common/service/gui-config.service.mock.ts | 1 +
frontend/src/app/common/type/gui-config.ts | 1 +
.../component/menu/menu.component.html | 6 +-
.../tutorial/argus/argus.component.html | 152 ++++
.../tutorial/argus/argus.component.scss | 206 +++++
.../tutorial/argus/argus.component.ts | 40 +
.../badge-unlocked.component.html | 60 ++
.../badge-unlocked.component.scss | 135 +++
.../badge-unlocked.component.ts | 47 ++
.../trophy-shelf/trophy-shelf.component.html | 94 +++
.../trophy-shelf/trophy-shelf.component.scss | 267 ++++++
.../trophy-shelf/trophy-shelf.component.ts | 111 +++
.../tutorial-chat.component.html | 49 +-
.../tutorial-chat.component.scss | 171 +++-
.../tutorial-chat/tutorial-chat.component.ts | 273 +++++--
.../tutorial-panel.component.html | 59 +-
.../tutorial-panel.component.scss | 68 +-
.../tutorial-panel.component.ts | 374 +++++++--
.../component/workspace.component.html | 6 +-
.../component/workspace.component.spec.ts | 8 +
.../component/workspace.component.ts | 4 +
.../service/tutorial/flows/build-complex.ts | 215 +++++
.../service/tutorial/flows/build-simple.ts | 271 ++++++
.../tutorial/flows/flows-integrity.spec.ts | 154 ++++
.../workspace/service/tutorial/flows/index.ts | 145 ++++
.../tutorial/flows/overview-workspace.ts | 348 ++++++++
.../service/tutorial/flows/public-workflow.ts | 42 +
.../tutorial/tutorial-progress.spec.ts | 94 +++
.../service/tutorial/tutorial-progress.ts | 75 ++
.../service/tutorial/tutorial.service.ts | 768 +++++++++---------
.../tutorial-workflows/movies-filter.json | 102 +++
frontend/src/styles.scss | 85 +-
36 files changed, 4254 insertions(+), 656 deletions(-)
create mode 100644 frontend/src/_tutorial-driver-overrides.scss
create mode 100644 frontend/src/app/workspace/component/tutorial/argus/argus.component.html
create mode 100644 frontend/src/app/workspace/component/tutorial/argus/argus.component.scss
create mode 100644 frontend/src/app/workspace/component/tutorial/argus/argus.component.ts
create mode 100644 frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.html
create mode 100644 frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.scss
create mode 100644 frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.ts
create mode 100644 frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.html
create mode 100644 frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.scss
create mode 100644 frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.ts
create mode 100644 frontend/src/app/workspace/service/tutorial/flows/build-complex.ts
create mode 100644 frontend/src/app/workspace/service/tutorial/flows/build-simple.ts
create mode 100644 frontend/src/app/workspace/service/tutorial/flows/flows-integrity.spec.ts
create mode 100644 frontend/src/app/workspace/service/tutorial/flows/index.ts
create mode 100644 frontend/src/app/workspace/service/tutorial/flows/overview-workspace.ts
create mode 100644 frontend/src/app/workspace/service/tutorial/flows/public-workflow.ts
create mode 100644 frontend/src/app/workspace/service/tutorial/tutorial-progress.spec.ts
create mode 100644 frontend/src/app/workspace/service/tutorial/tutorial-progress.ts
create mode 100644 frontend/src/assets/tutorial-workflows/movies-filter.json
diff --git a/common/config/src/main/resources/gui.conf b/common/config/src/main/resources/gui.conf
index d58d94ac7b9..f0742d17968 100644
--- a/common/config/src/main/resources/gui.conf
+++ b/common/config/src/main/resources/gui.conf
@@ -112,6 +112,12 @@ gui {
copilot-enabled = false
copilot-enabled = ${?GUI_WORKFLOW_WORKSPACE_COPILOT_ENABLED}
+ # whether the guided tutorial / Argus chat is enabled. Disable to remove
+ # the trophy launcher button, the welcome popover, and the in-tour Argus
+ # chat from the workspace entirely.
+ tutorial-enabled = true
+ tutorial-enabled = ${?GUI_WORKFLOW_WORKSPACE_TUTORIAL_ENABLED}
+
# the limit of columns to be displayed in the result table
limit-columns = 15
limit-columns = ${?GUI_WORKFLOW_WORKSPACE_LIMIT_COLUMNS}
diff --git a/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala b/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala
index adc789c9843..d5725420366 100644
--- a/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala
+++ b/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala
@@ -71,6 +71,8 @@ object GuiConfig {
conf.getInt("gui.workflow-workspace.active-time-in-minutes")
val guiWorkflowWorkspaceCopilotEnabled: Boolean =
conf.getBoolean("gui.workflow-workspace.copilot-enabled")
+ val guiWorkflowWorkspaceTutorialEnabled: Boolean =
+ conf.getBoolean("gui.workflow-workspace.tutorial-enabled")
val guiWorkflowWorkspaceLimitColumns: Int =
conf.getInt("gui.workflow-workspace.limit-columns")
}
diff --git a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
index b7517d81eb7..55c6de5ed4b 100644
--- a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
+++ b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
@@ -57,6 +57,7 @@ class ConfigResource {
),
"activeTimeInMinutes" -> GuiConfig.guiWorkflowWorkspaceActiveTimeInMinutes,
"copilotEnabled" -> GuiConfig.guiWorkflowWorkspaceCopilotEnabled,
+ "tutorialEnabled" -> GuiConfig.guiWorkflowWorkspaceTutorialEnabled,
"limitColumns" -> GuiConfig.guiWorkflowWorkspaceLimitColumns,
// flags from the auth.conf if needed
"expirationTimeInMinutes" -> AuthConfig.jwtExpirationMinutes
diff --git a/frontend/src/_tutorial-driver-overrides.scss b/frontend/src/_tutorial-driver-overrides.scss
new file mode 100644
index 00000000000..26538e51caa
--- /dev/null
+++ b/frontend/src/_tutorial-driver-overrides.scss
@@ -0,0 +1,470 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+ * Global styles for the tutorial mode (driver.js popovers, body.driver-active
+ * overrides, sparkle-burst animation injected into , etc.).
+ *
+ * Every rule here MUST live at the global root rather than inside a component
+ * stylesheet because the elements they target sit outside the Angular
+ * component tree:
+ * - driver.js appends its `.driver-popover` and `.driver-overlay` to
+ * - ng-zorro / CDK portal nz-select dropdowns into `.cdk-overlay-container`,
+ * also at level
+ * - the sparkle burst is created via `document.body.appendChild(...)`
+ * in tutorial.service.ts
+ *
+ * Angular's emulated view encapsulation rewrites component-scoped selectors
+ * with `[_ngcontent-xyz]` attributes, which match nothing on those
+ * outside-the-tree elements β so component styles cannot reach them.
+ *
+ * All rules are gated by either `body.driver-active` (only active during a
+ * tour) or the `.tutorial-*` / `.driver-popover.tutorial-*` namespace.
+ * When the tutorial flag is off and no tour is running, none of these rules
+ * apply to anything.
+ */
+
+/* driver.js β purely cosmetic. DO NOT override z-index here; driver.js
+ uses z-index: 1000000000 by design so the spotlight always wins. */
+.driver-popover {
+ max-width: 360px;
+}
+.driver-popover-title {
+ font-weight: 600;
+}
+.driver-popover-description b {
+ color: #1890ff;
+}
+
+/* Welcome popover β a centered, fancier variant of the standard driver.js
+ popover used for the first-time greeting. Wider, with a soft gradient and
+ a primary-coloured CTA so "Start building your first workflow" reads as
+ the obvious next step. */
+.driver-popover.tutorial-welcome-popover {
+ max-width: 460px;
+ background: linear-gradient(135deg, #ffffff 0%, #f0f5ff 100%);
+ border: 2px solid #1890ff;
+ box-shadow: 0 16px 48px rgba(24, 144, 255, 0.32);
+ animation: tutorial-welcome-pop 0.5s cubic-bezier(0.18, 1.25, 0.4, 1);
+
+ .driver-popover-title {
+ font-size: 20px;
+ color: #1890ff;
+ text-align: center;
+ }
+
+ .driver-popover-footer button.driver-popover-next-btn {
+ background: #1890ff;
+ border-color: #1890ff;
+ color: #fff;
+ font-weight: 600;
+ padding: 6px 18px;
+ border-radius: 8px;
+ box-shadow: 0 4px 14px rgba(24, 144, 255, 0.45);
+ text-shadow: none;
+ transition:
+ transform 0.15s ease,
+ box-shadow 0.15s ease;
+
+ &:hover {
+ background: #40a9ff;
+ border-color: #40a9ff;
+ transform: translateY(-1px);
+ box-shadow: 0 6px 18px rgba(24, 144, 255, 0.6);
+ }
+ }
+
+ .tutorial-welcome-emoji {
+ font-size: 54px;
+ margin-bottom: 8px;
+ display: inline-block;
+ animation: tutorial-welcome-wave 1.4s ease-in-out infinite;
+ transform-origin: 70% 80%;
+ }
+}
+
+@keyframes tutorial-welcome-pop {
+ 0% {
+ opacity: 0;
+ transform: scale(0.85);
+ }
+ 60% {
+ opacity: 1;
+ transform: scale(1.04);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes tutorial-welcome-wave {
+ 0%,
+ 100% {
+ transform: rotate(-14deg);
+ }
+ 50% {
+ transform: rotate(14deg);
+ }
+}
+
+/* Floating sparkle burst (fired on each step advance). Anchored in the
+ top-right corner so the celebration never overlaps the spotlight content
+ the user is reading. Sparkles fly down-left into the workspace; the XP
+ chip rises a few pixels and fades. */
+.tutorial-sparkle-burst {
+ position: fixed;
+ top: 56px;
+ right: 56px;
+ width: 0;
+ height: 0;
+ z-index: 1000000001; /* above driver.js's overlay z-index */
+ pointer-events: none;
+
+ .sparkle-particle {
+ position: absolute;
+ left: 0;
+ top: 0;
+ font-size: 12px;
+ color: #f5c542;
+ text-shadow: 0 0 6px rgba(245, 197, 66, 0.9);
+ transform: translate(-50%, -50%);
+ animation: tutorial-sparkle-fly 0.9s ease-out forwards;
+ }
+
+ .xp-float {
+ position: absolute;
+ left: 0;
+ top: 0;
+ background: linear-gradient(135deg, #faad14, #f5c542);
+ color: #fff;
+ font-weight: 700;
+ font-size: 10px;
+ letter-spacing: 0.3px;
+ padding: 2px 7px;
+ border-radius: 10px;
+ box-shadow: 0 3px 8px rgba(250, 173, 20, 0.5);
+ transform: translate(-50%, -50%);
+ white-space: nowrap;
+ animation: tutorial-xp-rise 1.1s ease-out forwards;
+ }
+}
+
+@keyframes tutorial-sparkle-fly {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.4) rotate(0deg);
+ }
+ 30% {
+ opacity: 1;
+ transform: translate(calc(-50% + var(--dx, 0px)), calc(-50% + var(--dy, 0px))) scale(1.3) rotate(180deg);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(calc(-50% + var(--dx, 0px) * 1.6), calc(-50% + var(--dy, 0px) * 1.6)) scale(0.5) rotate(360deg);
+ }
+}
+
+@keyframes tutorial-xp-rise {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.7);
+ }
+ 20% {
+ opacity: 1;
+ transform: translate(-50%, calc(-50% - 12px)) scale(1.08);
+ }
+ 85% {
+ opacity: 1;
+ transform: translate(-50%, calc(-50% - 40px)) scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, calc(-50% - 56px)) scale(1);
+ }
+}
+
+/* Flow-picker popover β bigger than the standard welcome popover, hosts
+ selectable flow cards. */
+.driver-popover.tutorial-picker-popover {
+ max-width: 520px;
+
+ .tutorial-picker {
+ .picker-intro {
+ margin-bottom: 14px;
+ }
+ .picker-tagline {
+ margin: 0 0 10px;
+ color: #333;
+ font-size: 13px;
+ line-height: 1.55;
+ }
+ .picker-cta {
+ margin: 0 0 4px;
+ color: #1890ff;
+ font-size: 13px;
+ font-weight: 600;
+ }
+ .picker-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 12px;
+ }
+ .picker-flow-card {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 10px 14px;
+ border-radius: 10px;
+ border: 1.5px solid rgba(24, 144, 255, 0.2);
+ background: #fff;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover:not(.disabled):not(:disabled) {
+ transform: translateY(-1px);
+ border-color: #1890ff;
+ box-shadow: 0 6px 16px rgba(24, 144, 255, 0.25);
+ }
+ &.disabled,
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ background: repeating-linear-gradient(45deg, #fff, #fff 6px, #fafafa 6px, #fafafa 12px);
+ }
+ }
+ .picker-flow-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ font-size: 14px;
+ color: #1f1f1f;
+ margin-bottom: 4px;
+ }
+ .picker-flow-emoji {
+ font-size: 18px;
+ }
+ .picker-flow-name {
+ flex: 1;
+ }
+ .picker-flow-desc {
+ font-size: 12px;
+ color: #666;
+ line-height: 1.4;
+ }
+ .picker-flow-meta {
+ margin-top: 4px;
+ font-size: 11px;
+ color: #999;
+ }
+ .picker-ribbon {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 6px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ font-weight: 700;
+
+ &.done {
+ background: #d9f7be;
+ color: #389e0d;
+ }
+ &.soon {
+ background: #fff7e6;
+ color: #d4a017;
+ }
+ }
+ .picker-skip {
+ display: block;
+ margin: 6px auto 0;
+ background: transparent;
+ border: none;
+ color: #888;
+ font-size: 12px;
+ cursor: pointer;
+ text-decoration: underline;
+
+ &:hover {
+ color: #555;
+ }
+ }
+ }
+}
+
+/* Resume popover β narrower, friendly "welcome back" prompt. */
+.driver-popover.tutorial-resume-popover {
+ max-width: 420px;
+
+ .tutorial-resume p {
+ margin: 0 0 6px;
+ font-size: 14px;
+ }
+ .resume-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .resume-btn {
+ padding: 8px 14px;
+ border-radius: 8px;
+ border: 1.5px solid rgba(24, 144, 255, 0.25);
+ background: #fff;
+ color: #1f1f1f;
+ font-weight: 600;
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ border-color: #1890ff;
+ transform: translateY(-1px);
+ }
+ &.primary {
+ background: #1890ff;
+ border-color: #1890ff;
+ color: #fff;
+ box-shadow: 0 4px 12px rgba(24, 144, 255, 0.45);
+
+ &:hover {
+ background: #40a9ff;
+ }
+ }
+ &.ghost {
+ background: transparent;
+ border-color: transparent;
+ color: #888;
+ &:hover {
+ color: #555;
+ transform: none;
+ }
+ }
+ }
+}
+
+/* Hide the progress text on popovers tagged with this class. driver.js's
+ per-step `showProgress: false` is resolved via an OR-chain
+ (`a = w || s("showProgress") || false`) which means a per-step `false`
+ value gets overridden by a global `true` β leaving us no real per-step
+ off switch. This CSS hook is the workaround: detail steps that should
+ not be counted in the main spine declare popoverClass:
+ "tutorial-no-progress" and the counter element disappears. */
+.driver-popover.tutorial-no-progress .driver-popover-progress-text {
+ display: none !important;
+}
+
+/* Wide variant for popovers that hover above wider spotlight targets
+ (e.g. the middle toolbar group, which spans most of the viewport).
+ driver.js's default popover maxes at 300px which looks dwarfed next to a
+ wide stage; bump to 440px on this step so the popover reads naturally. */
+.driver-popover.tutorial-wide-popover {
+ max-width: 440px;
+}
+
+/* "Tell me more" opt-in deep-dive button injected into overview-step
+ popovers (workspace overview tour). Visually identical shape to the
+ default Next button, just gray-on-gray instead of accented β keeps Next
+ as the obvious primary action while the deeper path stays available. */
+.driver-popover .driver-popover-navigation-btns .tutorial-show-details-btn {
+ all: unset;
+ box-sizing: border-box;
+ display: inline-block;
+ padding: 3px 7px;
+ line-height: 1.3;
+ font: 12px / normal sans-serif;
+ color: #555;
+ background: #f5f5f5;
+ border: 1px solid #d9d9d9;
+ border-radius: 3px;
+ cursor: pointer;
+ margin-right: 4px;
+ transition:
+ background 0.15s ease,
+ border-color 0.15s ease;
+}
+.driver-popover .driver-popover-navigation-btns .tutorial-show-details-btn:hover {
+ background: #ebebeb;
+ border-color: #b3b3b3;
+}
+
+/* driver.js by default sets pointer-events:none on every element outside the
+ highlighted one. That breaks drag-and-drop tutorial steps (user can grab the
+ operator label but the canvas refuses the drop). Re-enable interaction
+ everywhere while the tour is active β the dim overlay still gives the visual
+ cue, but the page stays fully usable. */
+body.driver-active *:not(.driver-overlay) {
+ pointer-events: auto !important;
+}
+body.driver-active .driver-overlay {
+ pointer-events: none !important;
+}
+
+/* Suppress ng-zorro tooltips/popovers while a tour step is active. Their
+ CDK-overlay positioning re-measures whenever driver.js mutates the active
+ element's inline styles, producing visible tooltip jitter on every step.
+ The driver.js popover already fills the "tooltip" role during the tour. */
+body.driver-active .ant-tooltip,
+body.driver-active .ant-popover {
+ display: none !important;
+}
+
+/* driver.js's default CSS forces `overflow: hidden !important` on the active
+ element's direct parent (so the user can't scroll past the spotlight).
+ That breaks two things in the property editor:
+ 1. The form's nz-select dropdowns get clipped / unclickable.
+ 2. The ancestor scroll container (#content) can't scroll because the
+ forced-hidden node in between intercepts wheel events.
+ We restore the parent's overflow to visible β its original default β so
+ the form behaves normally and scroll events bubble up to #content. */
+body.driver-active :not(body):has(> .driver-active-element) {
+ overflow: visible !important;
+}
+
+/* ng-zorro renders nz-select / nz-autocomplete dropdowns via CDK Overlay
+ portaled to a -level container at default z-index 1000. driver.js's
+ dim overlay sits at z-index ~100000+, which means open dropdowns get
+ visually buried under the overlay β they "open" but the user can't see
+ them. Lift the CDK overlay container above driver.js's popover so
+ dropdowns inside the spotlighted form remain visible and clickable. */
+body.driver-active .cdk-overlay-container {
+ z-index: 1000000001 !important;
+}
+
+/* Freeze hover / focus / wave reactions on small clickable spotlights
+ (icons, menu items, buttons) so cursor wobble at the stage cutout edge
+ doesn't flicker them. Scoped to li / button / nz-icon hosts only β the
+ form-area spotlights (.property-editor-form, .driver-active-element with
+ nested ng-zorro components) MUST keep their transitions, otherwise
+ nz-select / formly-form get stuck in mid-transition state and the form
+ becomes unresponsive after the first selection. */
+body.driver-active li.driver-active-element,
+body.driver-active button.driver-active-element,
+body.driver-active .driver-active-element[nz-icon],
+body.driver-active li.driver-active-element *,
+body.driver-active button.driver-active-element * {
+ transition: none !important;
+ animation: none !important;
+}
+body.driver-active li.driver-active-element .ant-wave,
+body.driver-active button.driver-active-element .ant-wave,
+body.driver-active li.driver-active-element [ant-click-animating-without-extra-node],
+body.driver-active button.driver-active-element [ant-click-animating-without-extra-node] {
+ display: none !important;
+}
diff --git a/frontend/src/app/common/service/gui-config.service.mock.ts b/frontend/src/app/common/service/gui-config.service.mock.ts
index 179259c5a97..608766dd879 100644
--- a/frontend/src/app/common/service/gui-config.service.mock.ts
+++ b/frontend/src/app/common/service/gui-config.service.mock.ts
@@ -51,6 +51,7 @@ export class MockGuiConfigService {
expirationTimeInMinutes: 2880,
activeTimeInMinutes: 15,
copilotEnabled: false,
+ tutorialEnabled: false,
limitColumns: 15,
};
diff --git a/frontend/src/app/common/type/gui-config.ts b/frontend/src/app/common/type/gui-config.ts
index 3b7554e8d4c..f81ea781f95 100644
--- a/frontend/src/app/common/type/gui-config.ts
+++ b/frontend/src/app/common/type/gui-config.ts
@@ -42,6 +42,7 @@ export interface GuiConfig {
expirationTimeInMinutes: number;
activeTimeInMinutes: number;
copilotEnabled: boolean;
+ tutorialEnabled: boolean;
limitColumns: number;
}
diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html
index a21e4d56429..008a6d2ec13 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.html
+++ b/frontend/src/app/workspace/component/menu/menu.component.html
@@ -326,7 +326,8 @@