From dd76d37bc2775d5a4f13e5757229c0d102794c40 Mon Sep 17 00:00:00 2001 From: Jae Yun Kim Date: Thu, 14 May 2026 18:51:33 -0700 Subject: [PATCH 1/3] Initial commit --- frontend/src/app/app.component.ts | 1 + frontend/src/app/app.module.ts | 2 + .../floating-agent.component.html | 310 ++++++++ .../floating-agent.component.scss | 367 ++++++++++ .../floating-agent.component.ts | 690 ++++++++++++++++++ .../floating-agent/floating-agent.service.ts | 206 ++++++ 6 files changed, 1576 insertions(+) create mode 100644 frontend/src/app/common/component/floating-agent/floating-agent.component.html create mode 100644 frontend/src/app/common/component/floating-agent/floating-agent.component.scss create mode 100644 frontend/src/app/common/component/floating-agent/floating-agent.component.ts create mode 100644 frontend/src/app/common/component/floating-agent/floating-agent.service.ts diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 513b05b6985..e0da0314d10 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -34,6 +34,7 @@ import { UntilDestroy } from "@ngneat/until-destroy"; + `, standalone: false, }) diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 21928b77039..700f925fa00 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -190,6 +190,7 @@ import { NzCheckboxModule } from "ng-zorro-antd/checkbox"; import { RegistrationRequestModalComponent } from "./common/service/user/registration-request-modal/registration-request-modal.component"; import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component"; import { UserComputingUnitListItemComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component"; +import { FloatingAgentComponent } from "./common/component/floating-agent/floating-agent.component"; registerLocaleData(en); @@ -358,6 +359,7 @@ registerLocaleData(en); MarkdownDescriptionComponent, UserComputingUnitComponent, UserComputingUnitListItemComponent, + FloatingAgentComponent, ], providers: [ provideNzI18n(en_US), diff --git a/frontend/src/app/common/component/floating-agent/floating-agent.component.html b/frontend/src/app/common/component/floating-agent/floating-agent.component.html new file mode 100644 index 00000000000..3b3394d8f96 --- /dev/null +++ b/frontend/src/app/common/component/floating-agent/floating-agent.component.html @@ -0,0 +1,310 @@ + +
+
+
+ + + Texera Assistant + +
+ + +
+
+ + +
+
+ Notification Settings + Toggle notification types on/off +
+ +
+
Workflow Runs
+
+ Successful runs + +
+
+ Failed runs + +
+
+ Killed runs + +
+
+ +
+
Social
+
+ Workflow likes + +
+
+ Workflow clones + +
+
+ Dataset likes + +
+
+ + +
+
Admin
+
+ User approval requests + +
+
+
+
+
+ + + + + + + Notifications + + + + + + + + + + + + Workflows + + + + + + + + + + + Requests + + + + + + + + +
+ + +
+ + + +
+
+ +
+
+
+
+ +
+
{{ n.title }}
+
{{ n.message }}
+
+ Try: {{ n.hint }} +
+ +
{{ n.timestamp | date: "short" }}
+
+
+
+
+
+ + + +
+ + + +
+
+
+
+ +
+
{{ w.name }}
+
{{ w.timestamp | date: "short" }}
+
+ +
+
+
+
+
+
+ + + +
+
diff --git a/frontend/src/app/common/component/floating-agent/floating-agent.component.scss b/frontend/src/app/common/component/floating-agent/floating-agent.component.scss new file mode 100644 index 00000000000..be38fc31706 --- /dev/null +++ b/frontend/src/app/common/component/floating-agent/floating-agent.component.scss @@ -0,0 +1,367 @@ +:host { + --agent-accent: #1677ff; + --agent-error: #ff4d4f; + --agent-warning: #faad14; + --agent-success: #52c41a; +} + +.floating-agent { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 1100; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; + + &.cdk-drag-dragging { + cursor: grabbing; + + .agent-button, + .panel-header { + cursor: grabbing; + } + } +} + +.agent-button { + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + background: var(--agent-accent); + color: #fff; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + cursor: grab; + display: flex; + align-items: center; + justify-content: center; + transition: + transform 0.18s ease, + box-shadow 0.18s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22); + } + + .agent-icon { + font-size: 24px; + } + + // Pull the badge up onto the icon corner. + :host ::ng-deep .ant-badge-count { + box-shadow: 0 0 0 2px #fff; + } +} + +.agent-panel { + width: 360px; + height: min(520px, calc(100vh - 112px)); + background: #fff; + border-radius: 12px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + background: linear-gradient(135deg, var(--agent-accent), #4096ff); + color: #fff; + cursor: grab; + + .panel-title { + font-weight: 600; + font-size: 14px; + display: inline-flex; + align-items: center; + gap: 8px; + + i { + font-size: 18px; + } + } + + .panel-header-actions { + display: flex; + gap: 4px; + align-items: center; + } + + button { + color: #fff; + + &.active { + background: rgba(255, 255, 255, 0.2); + } + } +} + +.settings-panel { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.settings-header { + margin-bottom: 16px; +} + +.settings-title { + display: block; + font-weight: 600; + font-size: 14px; + color: rgba(0, 0, 0, 0.85); +} + +.settings-subtitle { + display: block; + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + margin-top: 2px; +} + +.settings-section { + margin-bottom: 8px; +} + +.settings-section-title { + font-size: 12px; + font-weight: 600; + color: rgba(0, 0, 0, 0.65); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.settings-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; +} + +.settings-label { + font-size: 13px; + color: rgba(0, 0, 0, 0.85); +} + +.settings-divider { + margin: 12px 0 !important; +} + +.panel-tabs { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +:host ::ng-deep .panel-tabs .ant-tabs-nav { + margin: 0 12px; + flex: 0 0 auto; +} + +:host ::ng-deep .panel-tabs .ant-tabs-content-holder, +:host ::ng-deep .panel-tabs .ant-tabs-content, +:host ::ng-deep .panel-tabs .ant-tabs-tabpane { + min-height: 0; +} + +:host ::ng-deep .panel-tabs .ant-tabs-content-holder { + flex: 1; + overflow: hidden; +} + +:host ::ng-deep .panel-tabs .ant-tabs-content { + height: 100%; +} + +:host ::ng-deep .panel-tabs .ant-tabs-tabpane-active { + height: 100%; + overflow: hidden; +} + +.list-toolbar { + display: flex; + justify-content: flex-end; + padding: 0 12px; +} + +.notification-list, +.workflows-list { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + padding-top: 4px; +} + +.notification-scroll, +.workflows-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding-bottom: 8px; +} + +.notification-item { + padding: 10px 14px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + transition: background 0.15s ease; + + &:hover { + background: rgba(22, 119, 255, 0.04); + } + + &.unread { + background: rgba(22, 119, 255, 0.06); + } + + &.actionable { + cursor: pointer; + } + + &[data-level="error"] .item-icon { + color: var(--agent-error); + } + &[data-level="warning"] .item-icon { + color: var(--agent-warning); + } + &[data-level="success"] .item-icon { + color: var(--agent-success); + } + &[data-level="info"] .item-icon { + color: var(--agent-accent); + } +} + +.item-row { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.item-icon { + font-size: 18px; + margin-top: 2px; +} + +.item-body { + flex: 1; + min-width: 0; +} + +.item-title { + font-weight: 600; + font-size: 13px; + color: rgba(0, 0, 0, 0.85); + margin-bottom: 2px; +} + +.item-message { + font-size: 12.5px; + color: rgba(0, 0, 0, 0.65); + line-height: 1.4; + word-break: break-word; +} + +.item-hint { + margin-top: 4px; + font-size: 12px; + color: rgba(0, 0, 0, 0.55); + font-style: italic; + line-height: 1.4; +} + +.item-action { + margin-top: 4px; + padding: 0; + height: auto; + line-height: 1.2; + font-size: 12px; + font-weight: 500; + + i { + margin-left: 4px; + font-size: 11px; + } +} + +.item-meta { + margin-top: 4px; + font-size: 11px; + color: rgba(0, 0, 0, 0.4); +} + +.workflow-item { + padding: 10px 14px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + transition: background 0.15s ease; + + &:hover { + background: rgba(22, 119, 255, 0.04); + } +} + +.workflow-row { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.workflow-icon { + font-size: 18px; + margin-top: 2px; + + &.spinning { + animation: spin 1s linear infinite; + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.workflow-body { + flex: 1; + min-width: 0; +} + +.workflow-name { + font-weight: 600; + font-size: 13px; + color: rgba(0, 0, 0, 0.85); + margin-bottom: 2px; + word-break: break-word; +} + +.workflow-meta { + font-size: 11px; + color: rgba(0, 0, 0, 0.4); + margin-bottom: 4px; +} + +.workflow-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.empty { + padding: 24px 8px; +} diff --git a/frontend/src/app/common/component/floating-agent/floating-agent.component.ts b/frontend/src/app/common/component/floating-agent/floating-agent.component.ts new file mode 100644 index 00000000000..115506a81d1 --- /dev/null +++ b/frontend/src/app/common/component/floating-agent/floating-agent.component.ts @@ -0,0 +1,690 @@ +/** + * 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 { CommonModule, DatePipe } from "@angular/common"; +import { Router } from "@angular/router"; +import { CdkDrag, CdkDragEnd, CdkDragHandle } from "@angular/cdk/drag-drop"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { Observable, Subscription, BehaviorSubject, combineLatest, of, timer } from "rxjs"; +import { catchError, map, switchMap, startWith } from "rxjs/operators"; +import { FormsModule } from "@angular/forms"; +import { NzBadgeModule } from "ng-zorro-antd/badge"; +import { NzIconModule } from "ng-zorro-antd/icon"; +import { NzTabsModule } from "ng-zorro-antd/tabs"; +import { NzButtonModule } from "ng-zorro-antd/button"; +import { NzEmptyModule } from "ng-zorro-antd/empty"; +import { NzTooltipModule } from "ng-zorro-antd/tooltip"; +import { NzSwitchModule } from "ng-zorro-antd/switch"; +import { NzDividerModule } from "ng-zorro-antd/divider"; + +import { UserService } from "../../service/user/user.service"; +import { WorkflowPersistService } from "../../service/workflow-persist/workflow-persist.service"; +import { Role, User } from "../../type/user"; +import { + ExecutionState, + ExecutionStateInfo, +} from "../../../workspace/types/execute-workflow.interface"; +import { ExecuteWorkflowService } from "../../../workspace/service/execute-workflow/execute-workflow.service"; +import { WorkflowActionService } from "../../../workspace/service/workflow-graph/model/workflow-action.service"; +import { + ActionType, + CountResponse, + EntityType, + HubService, +} from "../../../hub/service/hub.service"; +import { AdminUserService } from "../../../dashboard/service/admin/user/admin-user.service"; +import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service"; +import { DASHBOARD_USER_DATASET, DASHBOARD_USER_WORKSPACE } from "../../../app-routing.constant"; +import { + AgentNotification, + AgentNotificationAction, + AgentNotificationCategory, + AgentNotificationSettings, + FloatingAgentService, +} from "./floating-agent.service"; + +const SOCIAL_POLL_MS = 30_000; +const ADMIN_POLL_MS = 60_000; +const MAX_WORKFLOWS_TO_TRACK = 20; +const MAX_DATASETS_TO_TRACK = 20; +const MAX_SESSION_WORKFLOWS = 20; +const POSITION_STORAGE_KEY = "texera-floating-agent-position"; +const EXECUTION_SNAPSHOT_STORAGE_KEY = "texera-floating-agent-execution-snapshot"; + +interface SessionWorkflow { + wid?: number; + name: string; + state: ExecutionState; + timestamp: number; +} + +const RUN_ERROR_HINTS: Partial> = { + [ExecutionState.Failed]: + "Open the run's console panel to see the operator stack trace. Common causes: bad UDF code, missing input columns, or dataset path typo.", + [ExecutionState.Killed]: + "The execution was killed — check whether the computing unit ran out of memory or was stopped manually.", +}; + +@UntilDestroy() +@Component({ + selector: "texera-floating-agent", + standalone: true, + templateUrl: "./floating-agent.component.html", + styleUrls: ["./floating-agent.component.scss"], + imports: [ + CommonModule, + FormsModule, + CdkDrag, + CdkDragHandle, + NzBadgeModule, + NzIconModule, + NzTabsModule, + NzButtonModule, + NzEmptyModule, + NzTooltipModule, + NzSwitchModule, + NzDividerModule, + ], + providers: [DatePipe], +}) +export class FloatingAgentComponent implements OnInit, OnDestroy { + public isOpen = false; + public isAdmin = false; + public isLoggedIn = false; + public isSettingsOpen = false; + public dragPosition: { x: number; y: number } = this.loadPosition(); + /** Set in cdkDragEnded when a real drag (>4px) occurred; swallows the click the browser fires next. */ + private suppressNextClick = false; + + public readonly settings$ = this.agentService.settings$; + + public readonly unreadTotal$ = this.agentService.unreadCount$; + public readonly unreadRun$ = this.agentService.unreadCountByCategory$("run"); + public readonly unreadSocial$ = this.agentService.unreadCountByCategory$("social"); + public readonly unreadAdmin$ = this.agentService.unreadCountByCategory$("admin"); + + public readonly runs$ = this.agentService.notificationsByCategory$("run"); + public readonly social$ = this.agentService.notificationsByCategory$("social"); + public readonly admin$ = this.agentService.notificationsByCategory$("admin"); + + private readonly sessionWorkflowsSubject = new BehaviorSubject([]); + public readonly sessionWorkflows$ = this.sessionWorkflowsSubject.asObservable(); + + public readonly notifications$ = combineLatest([this.runs$, this.social$]).pipe( + map(([runs, social]) => [...runs, ...social]) + ); + + public readonly unreadNotifications$ = combineLatest([this.unreadRun$, this.unreadSocial$]).pipe( + map(([runCount, socialCount]) => runCount + socialCount) + ); + + /** Baseline counts captured after the first poll so we only notify on increases. */ + private socialBaseline: Map = new Map(); + private adminSeenInactive: Set = new Set(); + private socialPollSub?: Subscription; + private adminPollSub?: Subscription; + /** + * Workflow identity captured when execution starts. Used at terminal-state time so we + * report the right name/wid even if the user navigated away (which resets the live + * WorkflowActionService metadata to DEFAULT_WORKFLOW / "Untitled Workflow"). Persisted + * so a page reload mid-run still gives us the right name when the terminal state lands. + */ + private executionSnapshot?: { wid?: number; name?: string }; + /** Last seen user uid so we only clear notifications on a real identity change. */ + private lastUserUid?: number; + + constructor( + private agentService: FloatingAgentService, + private userService: UserService, + private executeWorkflowService: ExecuteWorkflowService, + private workflowActionService: WorkflowActionService, + private workflowPersistService: WorkflowPersistService, + private datasetService: DatasetService, + private hubService: HubService, + private adminUserService: AdminUserService, + private router: Router + ) {} + + ngOnInit(): void { + this.executionSnapshot = this.loadExecutionSnapshot(); + this.userService + .userChanged() + .pipe(untilDestroyed(this)) + .subscribe(user => this.onUserChanged(user)); + this.subscribeRunEvents(); + } + + ngOnDestroy(): void { + this.stopPolling(); + } + + // ---------- UI ---------- + + public togglePanel(): void { + if (this.suppressNextClick) { + this.suppressNextClick = false; + return; + } + this.isOpen = !this.isOpen; + if (this.isOpen) { + this.agentService.markAllRead(); + } + } + + public closePanel(): void { + this.isOpen = false; + this.isSettingsOpen = false; + } + + public toggleSettings(event?: Event): void { + event?.stopPropagation(); + this.isSettingsOpen = !this.isSettingsOpen; + } + + public updateSetting(key: keyof AgentNotificationSettings, value: boolean): void { + this.agentService.updateSettings({ [key]: value }); + } + + public clearCategory(category: AgentNotificationCategory, event?: Event): void { + event?.stopPropagation(); + this.agentService.clear(category); + } + + public clearAllNotifications(event?: Event): void { + event?.stopPropagation(); + this.agentService.clear("run"); + this.agentService.clear("social"); + } + + public triggerAction(n: AgentNotification, event?: Event): void { + event?.stopPropagation(); + if (!n.action) return; + + const route = n.action.route[0] as string; + + // Handle special internal actions + if (route === "__retry-workflow__") { + const wid = n.action.route[1]; + this.handleRetryWorkflow(wid as number); + return; + } + + // Normal navigation + this.router.navigate(n.action.route); + this.closePanel(); + } + + public onDragEnded(event: CdkDragEnd): void { + const { x, y } = event.source.getFreeDragPosition(); + this.dragPosition = { x, y }; + if (Math.hypot(event.distance.x, event.distance.y) > 4) { + this.suppressNextClick = true; + } + try { + localStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(this.dragPosition)); + } catch { + // Storage may be unavailable (private mode, quota); position will reset next reload. + } + } + + private loadPosition(): { x: number; y: number } { + try { + const raw = localStorage.getItem(POSITION_STORAGE_KEY); + if (!raw) return { x: 0, y: 0 }; + const parsed = JSON.parse(raw) as { x: unknown; y: unknown }; + if (typeof parsed?.x === "number" && typeof parsed?.y === "number") { + return { x: parsed.x, y: parsed.y }; + } + } catch { + // Ignore malformed stored value. + } + return { x: 0, y: 0 }; + } + + private loadExecutionSnapshot(): { wid?: number; name?: string } | undefined { + try { + const raw = localStorage.getItem(EXECUTION_SNAPSHOT_STORAGE_KEY); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as { wid?: unknown; name?: unknown }; + const wid = typeof parsed?.wid === "number" ? parsed.wid : undefined; + const name = typeof parsed?.name === "string" ? parsed.name : undefined; + if (wid === undefined && name === undefined) return undefined; + return { wid, name }; + } catch { + return undefined; + } + } + + private persistExecutionSnapshot(): void { + try { + if (this.executionSnapshot) { + localStorage.setItem(EXECUTION_SNAPSHOT_STORAGE_KEY, JSON.stringify(this.executionSnapshot)); + } else { + localStorage.removeItem(EXECUTION_SNAPSHOT_STORAGE_KEY); + } + } catch { + // Storage may be unavailable; ignore. + } + } + + public iconFor(n: AgentNotification): string { + switch (n.level) { + case "success": + return "check-circle"; + case "warning": + return "exclamation-circle"; + case "error": + return "close-circle"; + default: + return "bell"; + } + } + + public stateIconFor(state: ExecutionState): string { + switch (state) { + case ExecutionState.Completed: + return "check-circle"; + case ExecutionState.Failed: + return "close-circle"; + case ExecutionState.Killed: + return "stop"; + case ExecutionState.Running: + return "loading"; + case ExecutionState.Paused: + return "pause-circle"; + default: + return "clock-circle"; + } + } + + public stateColorFor(state: ExecutionState): string { + switch (state) { + case ExecutionState.Completed: + return "#52c41a"; + case ExecutionState.Failed: + return "#ff4d4f"; + case ExecutionState.Killed: + return "#faad14"; + case ExecutionState.Running: + return "#1677ff"; + default: + return "#bfbfbf"; + } + } + + // ---------- User session ---------- + + private onUserChanged(user: User | undefined): void { + const previousUid = this.lastUserUid; + this.lastUserUid = user?.uid; + this.isLoggedIn = !!user; + this.isAdmin = user?.role === Role.ADMIN; + this.stopPolling(); + // Only wipe persisted state on real identity transitions — not on the initial restore + // from localStorage (previousUid === undefined && user defined). + const identityChanged = previousUid !== undefined && previousUid !== user?.uid; + if (identityChanged) { + this.agentService.clear(); + this.socialBaseline.clear(); + this.adminSeenInactive.clear(); + this.executionSnapshot = undefined; + this.persistExecutionSnapshot(); + } + if (!user) { + this.isOpen = false; + return; + } + this.startSocialPolling(); + if (this.isAdmin) { + this.startAdminPolling(); + } + } + + private stopPolling(): void { + this.socialPollSub?.unsubscribe(); + this.socialPollSub = undefined; + this.adminPollSub?.unsubscribe(); + this.adminPollSub = undefined; + } + + // ---------- Feature 1: workflow run events ---------- + + private subscribeRunEvents(): void { + this.executeWorkflowService + .getExecutionStateStream() + .pipe(untilDestroyed(this)) + .subscribe(({ previous, current }) => this.handleExecutionStateChange(previous, current)); + } + + private handleExecutionStateChange(previous: ExecutionStateInfo, current: ExecutionStateInfo): void { + // On page reload, the websocket reconnects and the server replays the current state. + // This produces a synthetic Uninitialized → [terminal] transition that we must NOT + // treat as a real event, otherwise we'd push a duplicate notification every refresh. + const isTerminalState = + current.state === ExecutionState.Completed || + current.state === ExecutionState.Failed || + current.state === ExecutionState.Killed; + if (previous.state === ExecutionState.Uninitialized && isTerminalState) { + return; + } + + // Capture identity when execution starts — at this moment WorkflowActionService still + // holds the live workflow metadata. We need it later because clearWorkflow() (on route + // change) resets the name to "Untitled Workflow". + if (previous.state === ExecutionState.Uninitialized && current.state !== ExecutionState.Uninitialized) { + const metadata = this.workflowActionService.getWorkflowMetadata(); + this.executionSnapshot = { wid: metadata?.wid, name: metadata?.name }; + this.persistExecutionSnapshot(); + } + + const snapshot = this.executionSnapshot ?? { + wid: this.workflowActionService.getWorkflowMetadata()?.wid, + name: this.workflowActionService.getWorkflowMetadata()?.name, + }; + const workflowName = snapshot.name && snapshot.name.length > 0 ? snapshot.name : "Workflow"; + + // Track workflow in session + this.trackSessionWorkflow(snapshot.wid, workflowName, current.state); + + switch (current.state) { + case ExecutionState.Completed: + this.agentService.push({ + category: "run", + level: "success", + type: "runSuccess", + title: `${workflowName} finished`, + message: "The workflow run completed successfully.", + action: this.workflowAction(snapshot.wid, "Tap to see result"), + }); + this.executionSnapshot = undefined; + this.persistExecutionSnapshot(); + return; + case ExecutionState.Failed: + this.agentService.push({ + category: "run", + level: "error", + type: "runFailure", + title: `${workflowName} failed`, + message: this.summarizeFailure(current), + hint: RUN_ERROR_HINTS[ExecutionState.Failed], + action: { label: "Retry", route: ["__retry-workflow__", snapshot.wid] }, + meta: { action: "retry", wid: snapshot.wid }, + }); + this.executionSnapshot = undefined; + this.persistExecutionSnapshot(); + return; + case ExecutionState.Killed: + this.agentService.push({ + category: "run", + level: "warning", + type: "runKilled", + title: `${workflowName} was killed`, + message: "Execution stopped before finishing.", + hint: RUN_ERROR_HINTS[ExecutionState.Killed], + action: { label: "Retry", route: ["__retry-workflow__", snapshot.wid] }, + meta: { action: "retry", wid: snapshot.wid }, + }); + this.executionSnapshot = undefined; + this.persistExecutionSnapshot(); + return; + default: + return; + } + } + + private workflowAction(wid: number | undefined, label: string): AgentNotificationAction | undefined { + if (wid === undefined) { + return undefined; + } + return { label, route: [DASHBOARD_USER_WORKSPACE, wid] }; + } + + private summarizeFailure(state: ExecutionStateInfo): string { + if (state.state !== ExecutionState.Failed) { + return "The workflow run failed."; + } + const errors = state.errorMessages; + if (errors.length === 0) { + return "The workflow run failed."; + } + const first = errors[0]; + const head = first.operatorId ? `${first.operatorId}: ${first.message}` : first.message; + return errors.length === 1 ? head : `${head} (+${errors.length - 1} more)`; + } + + // ---------- Feature 3: hub social events ---------- + + private startSocialPolling(): void { + this.socialPollSub = timer(0, SOCIAL_POLL_MS) + .pipe( + switchMap(() => this.fetchHubCounts()), + untilDestroyed(this) + ) + .subscribe(snapshot => this.applySocialSnapshot(snapshot)); + } + + private fetchHubCounts(): Observable<{ + counts: CountResponse[]; + nameByEntity: Map; + }> { + const ownedWorkflows$ = this.workflowPersistService.retrieveWorkflowsBySessionUser().pipe( + map(list => + list + .filter(w => w.isOwner && w.workflow?.wid !== undefined) + .slice(0, MAX_WORKFLOWS_TO_TRACK) + .map(w => ({ + type: EntityType.Workflow, + id: w.workflow.wid as number, + name: w.workflow.name ?? `Workflow #${w.workflow.wid}`, + })) + ), + catchError(() => of([] as { type: EntityType; id: number; name: string }[])) + ); + const ownedDatasets$ = this.datasetService.retrieveAccessibleDatasets().pipe( + map(list => + list + .filter(d => d.isOwner && d.dataset?.did !== undefined) + .slice(0, MAX_DATASETS_TO_TRACK) + .map(d => ({ + type: EntityType.Dataset, + id: d.dataset.did as number, + name: d.dataset.name ?? `Dataset #${d.dataset.did}`, + })) + ), + catchError(() => of([] as { type: EntityType; id: number; name: string }[])) + ); + return combineLatest([ownedWorkflows$, ownedDatasets$]).pipe( + switchMap(([workflows, datasets]) => { + const entities = [...workflows, ...datasets]; + const nameByEntity = new Map(); + for (const e of entities) { + nameByEntity.set(this.entityKey(e.type, e.id), e.name); + } + if (entities.length === 0) { + return of({ counts: [] as CountResponse[], nameByEntity }); + } + const entityTypes = entities.map(e => e.type); + const entityIds = entities.map(e => e.id); + return this.hubService + .getCounts(entityTypes, entityIds, [ActionType.Like, ActionType.Clone]) + .pipe( + map(counts => ({ counts, nameByEntity })), + catchError(() => of({ counts: [] as CountResponse[], nameByEntity })) + ); + }), + catchError(() => + of({ counts: [] as CountResponse[], nameByEntity: new Map() }) + ) + ); + } + + private applySocialSnapshot({ + counts, + nameByEntity, + }: { + counts: CountResponse[]; + nameByEntity: Map; + }): void { + const isFirstPoll = this.socialBaseline.size === 0; + for (const row of counts) { + // Clone counts on datasets are not meaningful in Texera today — skip them. + const trackedActions = + row.entityType === EntityType.Dataset + ? [ActionType.Like] + : [ActionType.Like, ActionType.Clone]; + for (const action of trackedActions) { + const key = this.socialKey(row.entityType, row.entityId, action); + const current = row.counts?.[action] ?? 0; + const previous = this.socialBaseline.get(key) ?? 0; + if (!isFirstPoll && current > previous) { + const diff = current - previous; + const name = + nameByEntity.get(this.entityKey(row.entityType, row.entityId)) ?? + this.fallbackName(row.entityType, row.entityId); + this.agentService.push({ + category: "social", + level: action === ActionType.Like ? "info" : "success", + type: this.socialNotificationType(row.entityType, action), + title: action === ActionType.Like ? `New like on ${name}` : `${name} was cloned`, + message: + action === ActionType.Like + ? `+${diff} like${diff === 1 ? "" : "s"} (total ${current}).` + : `+${diff} clone${diff === 1 ? "" : "s"} (total ${current}).`, + action: this.socialAction(row.entityType, row.entityId), + meta: { entityType: row.entityType, entityId: row.entityId, action, delta: diff }, + }); + } + this.socialBaseline.set(key, current); + } + } + } + + private socialKey(type: EntityType, id: number, action: ActionType): string { + return `${type}:${id}:${action}`; + } + + private entityKey(type: EntityType, id: number): string { + return `${type}:${id}`; + } + + private fallbackName(type: EntityType, id: number): string { + return type === EntityType.Dataset ? `Dataset #${id}` : `Workflow #${id}`; + } + + private socialAction(type: EntityType, id: number): AgentNotificationAction | undefined { + if (type === EntityType.Workflow) { + return { label: "Tap to open workflow", route: [DASHBOARD_USER_WORKSPACE, id] }; + } + if (type === EntityType.Dataset) { + return { label: "Tap to open dataset", route: [DASHBOARD_USER_DATASET, id] }; + } + return undefined; + } + + private socialNotificationType( + type: EntityType, + action: ActionType + ): "workflowLikes" | "workflowClones" | "datasetLikes" | undefined { + if (type === EntityType.Workflow) { + return action === ActionType.Like ? "workflowLikes" : "workflowClones"; + } + if (type === EntityType.Dataset) { + return action === ActionType.Like ? "datasetLikes" : undefined; + } + return undefined; + } + + private trackSessionWorkflow(wid: number | undefined, name: string, state: ExecutionState): void { + const workflows = this.sessionWorkflowsSubject.value; + const existingIndex = workflows.findIndex(w => w.wid === wid && w.name === name); + if (existingIndex >= 0) { + workflows[existingIndex] = { ...workflows[existingIndex], state, timestamp: Date.now() }; + } else { + workflows.unshift({ wid, name, state, timestamp: Date.now() }); + } + const updated = workflows.slice(0, MAX_SESSION_WORKFLOWS); + this.sessionWorkflowsSubject.next(updated); + } + + public handleKillWorkflow(): void { + try { + this.executeWorkflowService.killWorkflow(); + } catch (error) { + console.error("Failed to kill workflow:", error); + } + } + + public handleRetryWorkflow(wid: number): void { + if (wid === undefined || wid < 0) { + return; + } + // Navigate to the workflow in the editor and let the user re-run it + this.router.navigate([DASHBOARD_USER_WORKSPACE, wid]); + this.closePanel(); + } + + // ---------- Feature 4: admin pending users ---------- + + private startAdminPolling(): void { + this.adminPollSub = timer(0, ADMIN_POLL_MS) + .pipe( + switchMap(() => + this.adminUserService.getUserList().pipe(catchError(() => of([] as ReadonlyArray))) + ), + untilDestroyed(this) + ) + .subscribe(users => this.applyAdminSnapshot(users)); + } + + private applyAdminSnapshot(users: ReadonlyArray): void { + const inactive = users.filter(u => u.role === Role.INACTIVE); + const isFirstPoll = this.adminSeenInactive.size === 0; + for (const user of inactive) { + if (!this.adminSeenInactive.has(user.uid)) { + if (!isFirstPoll) { + this.agentService.push({ + category: "admin", + level: "warning", + type: "adminRequests", + title: `Approval needed: ${user.name}`, + message: this.buildAdminMessage(user), + meta: { uid: user.uid, email: user.email }, + }); + } + this.adminSeenInactive.add(user.uid); + } + } + // Drop any users that have been approved/removed so future re-INACTIVE flips notify again. + const stillInactive = new Set(inactive.map(u => u.uid)); + for (const uid of [...this.adminSeenInactive]) { + if (!stillInactive.has(uid)) { + this.adminSeenInactive.delete(uid); + } + } + } + + private buildAdminMessage(user: User): string { + const parts = [user.email]; + if (user.joiningReason && user.joiningReason.trim().length > 0) { + parts.push(`Reason: ${user.joiningReason.trim()}`); + } + return parts.join(" — "); + } +} diff --git a/frontend/src/app/common/component/floating-agent/floating-agent.service.ts b/frontend/src/app/common/component/floating-agent/floating-agent.service.ts new file mode 100644 index 00000000000..9246b47698f --- /dev/null +++ b/frontend/src/app/common/component/floating-agent/floating-agent.service.ts @@ -0,0 +1,206 @@ +/** + * 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 } from "@angular/core"; +import { BehaviorSubject, Observable } from "rxjs"; +import { map } from "rxjs/operators"; + +export type AgentNotificationCategory = "run" | "social" | "admin"; +export type AgentNotificationLevel = "info" | "success" | "warning" | "error"; +export type AgentNotificationType = + | "runSuccess" + | "runFailure" + | "runKilled" + | "workflowLikes" + | "workflowClones" + | "datasetLikes" + | "adminRequests"; + +export interface AgentNotificationSettings { + runSuccess: boolean; + runFailure: boolean; + runKilled: boolean; + workflowLikes: boolean; + workflowClones: boolean; + datasetLikes: boolean; + adminRequests: boolean; +} + +export const DEFAULT_NOTIFICATION_SETTINGS: AgentNotificationSettings = { + runSuccess: true, + runFailure: true, + runKilled: true, + workflowLikes: true, + workflowClones: true, + datasetLikes: true, + adminRequests: true, +}; + +export interface AgentNotificationAction { + /** Call-to-action text displayed on the notification. */ + label: string; + /** Angular router commands; passed to Router.navigate(). */ + route: unknown[]; +} + +export interface AgentNotification { + id: string; + category: AgentNotificationCategory; + level: AgentNotificationLevel; + /** Specific notification type for filtering. */ + type?: AgentNotificationType; + title: string; + message: string; + /** Optional remediation hint surfaced for run errors. */ + hint?: string; + /** Optional clickable call-to-action. */ + action?: AgentNotificationAction; + /** Unix epoch ms. */ + timestamp: number; + read: boolean; + /** Optional metadata for downstream actions; not displayed directly. */ + meta?: Record; +} + +const MAX_NOTIFICATIONS = 100; +const STORAGE_KEY = "texera-floating-agent-notifications"; +const SETTINGS_STORAGE_KEY = "texera-floating-agent-settings"; + +@Injectable({ providedIn: "root" }) +export class FloatingAgentService { + private readonly notificationsSubject = new BehaviorSubject( + FloatingAgentService.loadFromStorage() + ); + private readonly settingsSubject = new BehaviorSubject( + FloatingAgentService.loadSettingsFromStorage() + ); + + public readonly notifications$: Observable = this.notificationsSubject.asObservable(); + public readonly settings$: Observable = this.settingsSubject.asObservable(); + + public readonly unreadCount$: Observable = this.notifications$.pipe( + map(list => list.filter(n => !n.read).length) + ); + + public unreadCountByCategory$(category: AgentNotificationCategory): Observable { + return this.notifications$.pipe(map(list => list.filter(n => !n.read && n.category === category).length)); + } + + public notificationsByCategory$(category: AgentNotificationCategory): Observable { + return this.notifications$.pipe(map(list => list.filter(n => n.category === category))); + } + + public getSettings(): AgentNotificationSettings { + return this.settingsSubject.value; + } + + public updateSettings(settings: Partial): void { + const next = { ...this.settingsSubject.value, ...settings }; + this.settingsSubject.next(next); + this.persistSettings(); + } + + public isTypeEnabled(type: AgentNotificationType | undefined): boolean { + if (!type) return true; + return this.settingsSubject.value[type] !== false; + } + + public push(notification: Omit): void { + // Filter out muted notification types + if (notification.type && !this.isTypeEnabled(notification.type)) { + return; + } + const entry: AgentNotification = { + ...notification, + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: Date.now(), + read: false, + }; + const next = [entry, ...this.notificationsSubject.value].slice(0, MAX_NOTIFICATIONS); + this.notificationsSubject.next(next); + this.persist(); + } + + public markAllRead(category?: AgentNotificationCategory): void { + const next = this.notificationsSubject.value.map(n => + !category || n.category === category ? { ...n, read: true } : n + ); + this.notificationsSubject.next(next); + this.persist(); + } + + public clear(category?: AgentNotificationCategory): void { + const next = category ? this.notificationsSubject.value.filter(n => n.category !== category) : []; + this.notificationsSubject.next(next); + this.persist(); + } + + private persist(): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.notificationsSubject.value)); + } catch { + // Storage may be unavailable (private mode, quota); ignore. + } + } + + private persistSettings(): void { + try { + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(this.settingsSubject.value)); + } catch { + // Storage may be unavailable (private mode, quota); ignore. + } + } + + private static loadSettingsFromStorage(): AgentNotificationSettings { + try { + const raw = localStorage.getItem(SETTINGS_STORAGE_KEY); + if (!raw) return { ...DEFAULT_NOTIFICATION_SETTINGS }; + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULT_NOTIFICATION_SETTINGS, ...parsed }; + } catch { + return { ...DEFAULT_NOTIFICATION_SETTINGS }; + } + } + + private static loadFromStorage(): AgentNotification[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter(FloatingAgentService.isValidNotification).slice(0, MAX_NOTIFICATIONS); + } catch { + return []; + } + } + + private static isValidNotification(value: unknown): value is AgentNotification { + if (typeof value !== "object" || value === null) return false; + const n = value as Record; + return ( + typeof n.id === "string" && + (n.category === "run" || n.category === "social" || n.category === "admin") && + (n.level === "info" || n.level === "success" || n.level === "warning" || n.level === "error") && + typeof n.title === "string" && + typeof n.message === "string" && + typeof n.timestamp === "number" && + typeof n.read === "boolean" + ); + } +} From 5aa301474f43dfdcbda1badb865323657633c23f Mon Sep 17 00:00:00 2001 From: Jae Yun Kim Date: Thu, 14 May 2026 21:00:30 -0700 Subject: [PATCH 2/3] Added more features --- .../texera/web/ServletAwareConfigurator.scala | 2 + .../texera/web/auth/GuestAuthFilter.scala | 2 +- .../admin/user/AdminUserResource.scala | 40 +- .../org/apache/texera/auth/JwtParser.scala | 1 + .../floating-agent.component.html | 387 ++++++++------- .../floating-agent.component.scss | 112 ++++- .../floating-agent.component.ts | 443 ++++++++++++++++-- .../floating-agent/floating-agent.service.ts | 105 +++++ frontend/src/app/common/type/user.ts | 2 + .../service/admin/user/admin-user.service.ts | 18 + .../agent-panel/agent-panel.component.html | 4 +- .../agent-panel/agent-panel.component.ts | 15 +- .../agent/agent-panel-control.service.ts | 41 ++ sql/texera_ddl.sql | 1 + sql/updates/23.sql | 36 ++ 15 files changed, 986 insertions(+), 223 deletions(-) create mode 100644 frontend/src/app/workspace/service/agent/agent-panel-control.service.ts create mode 100644 sql/updates/23.sql diff --git a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala index cb3628df5b3..dd5b8d89ef9 100644 --- a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala +++ b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala @@ -77,6 +77,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La null, null, null, + null, null ) ) @@ -108,6 +109,7 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La null, null, null, + null, null ) ) diff --git a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala index b7dda09489e..2529cb6b1fd 100644 --- a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala +++ b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala @@ -39,7 +39,7 @@ import javax.ws.rs.core.SecurityContext } val GUEST: User = - new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null, null, null, null) + new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null, null, null, null, null) } @PreMatching diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala index cd5ead915df..5d934be8004 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala @@ -47,9 +47,12 @@ case class UserInfo( lastLogin: java.time.OffsetDateTime, // will be null if never logged in accountCreation: java.time.OffsetDateTime, affiliation: String, - joiningReason: String + joiningReason: String, + requestViewed: Boolean ) +case class MarkRequestsViewedRequest(uids: util.List[Integer]) + object AdminUserResource { private def context = SqlServer @@ -83,7 +86,8 @@ class AdminUserResource { USER_LAST_ACTIVE_TIME.LAST_ACTIVE_TIME, USER.ACCOUNT_CREATION_TIME, USER.AFFILIATION, - USER.JOINING_REASON + USER.JOINING_REASON, + USER.REQUEST_VIEWED ) .from(USER) .leftJoin(USER_LAST_ACTIVE_TIME) @@ -91,6 +95,38 @@ class AdminUserResource { .fetchInto(classOf[UserInfo]) } + /** + * Mark the given pending user requests as viewed so the admin assistant + * stops re-surfacing them in the notification center. + */ + @POST + @Path("/mark-requests-viewed") + @Consumes(Array(MediaType.APPLICATION_JSON)) + def markRequestsViewed(request: MarkRequestsViewedRequest): Unit = { + if (request.uids == null || request.uids.isEmpty) return + AdminUserResource.context + .update(USER) + .set(USER.REQUEST_VIEWED, java.lang.Boolean.TRUE) + .where(USER.UID.in(request.uids)) + .execute() + } + + /** + * Mark every currently-pending (INACTIVE) account request as viewed. + * Triggered by the "Clear" action on the Requests tab so admins can wipe + * the queue in one shot even if new requests arrived between polls. + */ + @POST + @Path("/mark-all-requests-viewed") + def markAllRequestsViewed(): Unit = { + AdminUserResource.context + .update(USER) + .set(USER.REQUEST_VIEWED, java.lang.Boolean.TRUE) + .where(USER.ROLE.eq(UserRoleEnum.INACTIVE)) + .and(USER.REQUEST_VIEWED.eq(java.lang.Boolean.FALSE)) + .execute() + } + @PUT @Path("/update") def updateUser(user: User): Unit = { diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala index bb139e7093a..607188d71ba 100644 --- a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala +++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala @@ -74,6 +74,7 @@ object JwtParser extends LazyLogging { null, null, null, + null, null ) new SessionUser(user) diff --git a/frontend/src/app/common/component/floating-agent/floating-agent.component.html b/frontend/src/app/common/component/floating-agent/floating-agent.component.html index 3b3394d8f96..5272d21cf6e 100644 --- a/frontend/src/app/common/component/floating-agent/floating-agent.component.html +++ b/frontend/src/app/common/component/floating-agent/floating-agent.component.html @@ -2,220 +2,249 @@
-
- +
+ + + Texera Assistant + +
+ - -
+ nzType="setting"> + +
+
- -
-
- Notification Settings - Toggle notification types on/off + +
+
+ Notification Settings + Toggle notification types on/off +
+ +
+
Workflow Runs
+
+ Successful runs + +
+
+ Failed runs + +
+
+ Killed runs + +
- -
-
Workflow Runs
-
- Successful runs - -
-
- Failed runs - -
-
- Killed runs - -
+ +
+
Social
+
+ Workflow likes + +
+
+ Workflow clones +
+
+ Dataset likes + +
+
+
-
Social
+
Admin
- Workflow likes + User approval requests -
-
- Workflow clones - -
-
- Dataset likes -
- - -
-
Admin
-
- User approval requests - -
-
-
-
+
+
- - - - - - Notifications - - - - - - - + + + + + + Notifications + + + + + + + - - - - Workflows - - - - - + + + + Workflows + + + + + - - - - - Requests - - - - - - - - + + + + + Requests + + + + + + + + + + +
- + + +
-
+ let-list + let-clearKind="clearKind"> +
-
+
-
-
+
+
+ +
+
diff --git a/frontend/src/app/common/component/floating-agent/floating-agent.component.scss b/frontend/src/app/common/component/floating-agent/floating-agent.component.scss index be38fc31706..e750fbdc6ef 100644 --- a/frontend/src/app/common/component/floating-agent/floating-agent.component.scss +++ b/frontend/src/app/common/component/floating-agent/floating-agent.component.scss @@ -25,6 +25,13 @@ } } +.agent-buttons { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + .agent-button { width: 56px; height: 56px; @@ -56,15 +63,114 @@ } } +.agent-button-secondary { + width: 44px; + height: 44px; + background: linear-gradient(135deg, #722ed1, #9254de); + cursor: pointer; + + .agent-icon { + font-size: 20px; + } +} + .agent-panel { - width: 360px; - height: min(520px, calc(100vh - 112px)); + // Positioned relative to .floating-agent so the panel naturally moves with the + // dragged button. Direction is flipped via .panel-opens-down / .panel-opens-right. + position: absolute; background: #fff; border-radius: 12px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18); - overflow: hidden; display: flex; flex-direction: column; + + // Default anchor: above the button, right-aligned (panel extends left). + bottom: calc(100% + 12px); + right: 0; + + // Round inner corners so children don't poke through the rounded panel border. + > .panel-header { + border-top-left-radius: 12px; + border-top-right-radius: 12px; + } +} + +// Flip below the button when there's no room above. +.floating-agent.panel-opens-down .agent-panel { + bottom: auto; + top: calc(100% + 12px); +} + +// Flip to the right side of the button when there's no room to the left. +.floating-agent.panel-opens-right .agent-panel { + right: auto; + left: 0; +} + +// Custom resize handles — positioned absolutely along the panel's free edges. +// Each handle is an invisible overlay that captures pointerdown for resizing. +.resize-handle { + position: absolute; + z-index: 10; + user-select: none; + touch-action: none; +} + +.resize-handle-left, +.resize-handle-right { + top: 0; + bottom: 0; + width: 6px; + cursor: ew-resize; +} +.resize-handle-left { + left: -3px; +} +.resize-handle-right { + right: -3px; +} + +.resize-handle-top, +.resize-handle-bottom { + left: 0; + right: 0; + height: 6px; + cursor: ns-resize; +} +.resize-handle-top { + top: -3px; +} +.resize-handle-bottom { + bottom: -3px; +} + +.resize-handle-topLeft, +.resize-handle-topRight, +.resize-handle-bottomLeft, +.resize-handle-bottomRight { + width: 14px; + height: 14px; + z-index: 11; +} +.resize-handle-topLeft { + top: -3px; + left: -3px; + cursor: nwse-resize; +} +.resize-handle-topRight { + top: -3px; + right: -3px; + cursor: nesw-resize; +} +.resize-handle-bottomLeft { + bottom: -3px; + left: -3px; + cursor: nesw-resize; +} +.resize-handle-bottomRight { + bottom: -3px; + right: -3px; + cursor: nwse-resize; } .panel-header { diff --git a/frontend/src/app/common/component/floating-agent/floating-agent.component.ts b/frontend/src/app/common/component/floating-agent/floating-agent.component.ts index 115506a81d1..f026a4f1686 100644 --- a/frontend/src/app/common/component/floating-agent/floating-agent.component.ts +++ b/frontend/src/app/common/component/floating-agent/floating-agent.component.ts @@ -17,13 +17,13 @@ * under the License. */ -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Component, ElementRef, OnDestroy, OnInit } from "@angular/core"; import { CommonModule, DatePipe } from "@angular/common"; -import { Router } from "@angular/router"; +import { NavigationEnd, Router } from "@angular/router"; import { CdkDrag, CdkDragEnd, CdkDragHandle } from "@angular/cdk/drag-drop"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { Observable, Subscription, BehaviorSubject, combineLatest, of, timer } from "rxjs"; -import { catchError, map, switchMap, startWith } from "rxjs/operators"; +import { catchError, filter, map, switchMap, startWith } from "rxjs/operators"; import { FormsModule } from "@angular/forms"; import { NzBadgeModule } from "ng-zorro-antd/badge"; import { NzIconModule } from "ng-zorro-antd/icon"; @@ -51,7 +51,12 @@ import { } from "../../../hub/service/hub.service"; import { AdminUserService } from "../../../dashboard/service/admin/user/admin-user.service"; import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service"; -import { DASHBOARD_USER_DATASET, DASHBOARD_USER_WORKSPACE } from "../../../app-routing.constant"; +import { AgentPanelControlService } from "../../../workspace/service/agent/agent-panel-control.service"; +import { + DASHBOARD_ADMIN_USER, + DASHBOARD_USER_DATASET, + DASHBOARD_USER_WORKSPACE, +} from "../../../app-routing.constant"; import { AgentNotification, AgentNotificationAction, @@ -67,6 +72,15 @@ const MAX_DATASETS_TO_TRACK = 20; const MAX_SESSION_WORKFLOWS = 20; const POSITION_STORAGE_KEY = "texera-floating-agent-position"; const EXECUTION_SNAPSHOT_STORAGE_KEY = "texera-floating-agent-execution-snapshot"; +const TERMINAL_DEDUP_STORAGE_KEY = "texera-floating-agent-terminal-dedup"; +const TERMINAL_DEDUP_WINDOW_MS = 30_000; +const SESSION_WORKFLOWS_STORAGE_KEY = "texera-floating-agent-session-workflows"; +const PANEL_SIZE_STORAGE_KEY = "texera-floating-agent-panel-size"; + +const PANEL_DEFAULT_WIDTH = 360; +const PANEL_DEFAULT_HEIGHT = 520; +const PANEL_MIN_WIDTH = 320; +const PANEL_MIN_HEIGHT = 360; interface SessionWorkflow { wid?: number; @@ -109,7 +123,19 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { public isAdmin = false; public isLoggedIn = false; public isSettingsOpen = false; + public isOnWorkflowPage = false; + public isAgentPanelOpen = false; + /** Panel anchor flip flags — computed when the panel opens based on viewport space. */ + public panelOpensDown = false; + public panelOpensRight = false; public dragPosition: { x: number; y: number } = this.loadPosition(); + public panelWidth = this.loadPanelSize().width; + public panelHeight = this.loadPanelSize().height; + public readonly panelMinWidth = PANEL_MIN_WIDTH; + public readonly panelMinHeight = PANEL_MIN_HEIGHT; + /** Viewport-relative position of the panel (computed in computePanelAnchor). */ + public panelLeft = 0; + public panelTop = 0; /** Set in cdkDragEnded when a real drag (>4px) occurred; swallows the click the browser fires next. */ private suppressNextClick = false; @@ -124,7 +150,9 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { public readonly social$ = this.agentService.notificationsByCategory$("social"); public readonly admin$ = this.agentService.notificationsByCategory$("admin"); - private readonly sessionWorkflowsSubject = new BehaviorSubject([]); + private readonly sessionWorkflowsSubject = new BehaviorSubject( + FloatingAgentComponent.loadSessionWorkflows() + ); public readonly sessionWorkflows$ = this.sessionWorkflowsSubject.asObservable(); public readonly notifications$ = combineLatest([this.runs$, this.social$]).pipe( @@ -137,7 +165,9 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { /** Baseline counts captured after the first poll so we only notify on increases. */ private socialBaseline: Map = new Map(); - private adminSeenInactive: Set = new Set(); + /** Uids surfaced in this session's admin notifications — used to avoid duplicate pushes + * before the user clicks/marks-viewed and the next poll cycle returns. */ + private adminNotifiedThisSession: Set = new Set(); private socialPollSub?: Subscription; private adminPollSub?: Subscription; /** @@ -159,7 +189,9 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { private datasetService: DatasetService, private hubService: HubService, private adminUserService: AdminUserService, - private router: Router + private agentPanelControlService: AgentPanelControlService, + private router: Router, + private elementRef: ElementRef ) {} ngOnInit(): void { @@ -169,6 +201,39 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { .pipe(untilDestroyed(this)) .subscribe(user => this.onUserChanged(user)); this.subscribeRunEvents(); + this.subscribeRouteChanges(); + // Track the AI agent panel's open state so we can hide the flask button while it's open. + this.agentPanelControlService.openState$ + .pipe(untilDestroyed(this)) + .subscribe(isOpen => (this.isAgentPanelOpen = isOpen)); + } + + private subscribeRouteChanges(): void { + // Set initial value based on current URL + this.isOnWorkflowPage = this.urlMatchesWorkflowEditor(this.router.url); + // Then update on every navigation + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + untilDestroyed(this) + ) + .subscribe(event => { + this.isOnWorkflowPage = this.urlMatchesWorkflowEditor(event.urlAfterRedirects); + }); + } + + private urlMatchesWorkflowEditor(url: string): boolean { + // Workflow editor URL pattern: /dashboard/user/workflow/:wid + return /\/dashboard\/user\/workflow\/\d+/.test(url); + } + + public openAgentPanel(event?: Event): void { + event?.stopPropagation(); + if (this.suppressNextClick) { + this.suppressNextClick = false; + return; + } + this.agentPanelControlService.requestToggle(); } ngOnDestroy(): void { @@ -184,10 +249,49 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { } this.isOpen = !this.isOpen; if (this.isOpen) { + this.computePanelAnchor(); this.agentService.markAllRead(); } } + /** + * Decide which side of the floating button the panel should expand toward, + * then compute the panel's viewport-fixed position. Default is up-left; we + * flip to down/right whenever the button is too close to the top/left edges + * of the viewport to fit the panel in the default direction. + */ + private computePanelAnchor(): void { + const buttonEl = this.elementRef.nativeElement.querySelector( + ".agent-button:not(.agent-button-secondary)" + ) as HTMLElement | null; + if (!buttonEl) return; + + const rect = buttonEl.getBoundingClientRect(); + const GAP = 12; + + // Vertical: prefer opening upward; flip downward if not enough room above. + this.panelOpensDown = rect.top < this.panelHeight + GAP; + // Horizontal: panel default extends to the LEFT of the button; flip if no room. + this.panelOpensRight = rect.right < this.panelWidth + GAP; + + // Compute viewport-fixed position. The panel is `position: fixed`, so left/top + // are in viewport coordinates. + if (this.panelOpensRight) { + // Panel aligned to the LEFT edge of the button, extending right. + this.panelLeft = rect.left; + } else { + // Panel aligned to the RIGHT edge of the button, extending left. + this.panelLeft = rect.right - this.panelWidth; + } + if (this.panelOpensDown) { + // Panel below the button. + this.panelTop = rect.bottom + GAP; + } else { + // Panel above the button. + this.panelTop = rect.top - this.panelHeight - GAP; + } + } + public closePanel(): void { this.isOpen = false; this.isSettingsOpen = false; @@ -207,10 +311,31 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { this.agentService.clear(category); } - public clearAllNotifications(event?: Event): void { + public clearByKind(kind: "notifications" | "requests" | undefined, event?: Event): void { + event?.stopPropagation(); + if (kind === "requests") { + // Mark every currently-pending request as viewed in the DB (not just the ones + // currently shown in the panel). This handles the case where a new signup + // arrived between polls but isn't reflected in the notifications list yet. + this.adminUserService + .markAllRequestsViewed() + .pipe(untilDestroyed(this)) + .subscribe({ + error: err => console.error("Failed to mark all requests as viewed:", err), + }); + this.adminNotifiedThisSession.clear(); + this.agentService.clear("admin"); + } else { + // Default: combined notifications tab (runs + social) + this.agentService.clear("run"); + this.agentService.clear("social"); + } + } + + public clearSessionWorkflows(event?: Event): void { event?.stopPropagation(); - this.agentService.clear("run"); - this.agentService.clear("social"); + this.sessionWorkflowsSubject.next([]); + this.persistSessionWorkflows(); } public triggerAction(n: AgentNotification, event?: Event): void { @@ -226,6 +351,23 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { return; } + // Admin request notifications: clicking is an implicit acknowledgement. Mark the + // request as viewed in the DB and immediately remove this specific notification from + // the list (so it doesn't reappear from localStorage on refresh). + if (n.category === "admin") { + const uid = (n.meta as { uid?: number } | undefined)?.uid; + if (typeof uid === "number") { + this.adminUserService + .markRequestsViewed([uid]) + .pipe(untilDestroyed(this)) + .subscribe({ + error: err => console.error("Failed to mark request as viewed:", err), + }); + this.adminNotifiedThisSession.delete(uid); + } + this.agentService.removeWhere(other => other.id === n.id); + } + // Normal navigation this.router.navigate(n.action.route); this.closePanel(); @@ -258,6 +400,100 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { return { x: 0, y: 0 }; } + private loadPanelSize(): { width: number; height: number } { + try { + const raw = localStorage.getItem(PANEL_SIZE_STORAGE_KEY); + if (!raw) return { width: PANEL_DEFAULT_WIDTH, height: PANEL_DEFAULT_HEIGHT }; + const parsed = JSON.parse(raw) as { width: unknown; height: unknown }; + const width = + typeof parsed?.width === "number" && parsed.width >= PANEL_MIN_WIDTH + ? parsed.width + : PANEL_DEFAULT_WIDTH; + const height = + typeof parsed?.height === "number" && parsed.height >= PANEL_MIN_HEIGHT + ? parsed.height + : PANEL_DEFAULT_HEIGHT; + return { width, height }; + } catch { + return { width: PANEL_DEFAULT_WIDTH, height: PANEL_DEFAULT_HEIGHT }; + } + } + + /** + * Resize handles to expose based on which edges of the panel are free + * (i.e., not anchored to the floating button). The opposite anchor is fixed, + * so resizing those edges would feel broken. + */ + public get panelResizeHandles(): string[] { + if (this.panelOpensDown && this.panelOpensRight) { + return ["right", "bottom", "bottomRight"]; + } + if (this.panelOpensDown) { + return ["left", "bottom", "bottomLeft"]; + } + if (this.panelOpensRight) { + return ["right", "top", "topRight"]; + } + return ["left", "top", "topLeft"]; + } + + /** + * Start a resize gesture from the given handle direction. The panel is anchored + * by CSS (right:0 / left:0 / top:* / bottom:*) so we only need to change width and + * height — the opposite edge stays fixed automatically. + */ + public startResize(direction: string, event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + + const startX = event.clientX; + const startY = event.clientY; + const startWidth = this.panelWidth; + const startHeight = this.panelHeight; + const maxSize = 900; + + const movesLeft = direction.toLowerCase().includes("left"); + const movesRight = direction.toLowerCase().includes("right"); + const movesTop = direction.toLowerCase().startsWith("top"); + const movesBottom = direction.toLowerCase().startsWith("bottom"); + + const onMouseMove = (e: MouseEvent) => { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + // For LEFT handle: panel's right edge is anchored, so dragging left (negative dx) + // should grow width. For RIGHT handle: panel's left edge is anchored, dragging + // right (positive dx) grows width. + if (movesRight) { + this.panelWidth = Math.min(maxSize, Math.max(PANEL_MIN_WIDTH, startWidth + dx)); + } else if (movesLeft) { + this.panelWidth = Math.min(maxSize, Math.max(PANEL_MIN_WIDTH, startWidth - dx)); + } + + if (movesBottom) { + this.panelHeight = Math.min(maxSize, Math.max(PANEL_MIN_HEIGHT, startHeight + dy)); + } else if (movesTop) { + this.panelHeight = Math.min(maxSize, Math.max(PANEL_MIN_HEIGHT, startHeight - dy)); + } + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + try { + localStorage.setItem( + PANEL_SIZE_STORAGE_KEY, + JSON.stringify({ width: this.panelWidth, height: this.panelHeight }) + ); + } catch { + // Storage may be unavailable; ignore. + } + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + } + private loadExecutionSnapshot(): { wid?: number; name?: string } | undefined { try { const raw = localStorage.getItem(EXECUTION_SNAPSHOT_STORAGE_KEY); @@ -343,9 +579,11 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { if (identityChanged) { this.agentService.clear(); this.socialBaseline.clear(); - this.adminSeenInactive.clear(); + this.adminNotifiedThisSession.clear(); this.executionSnapshot = undefined; this.persistExecutionSnapshot(); + this.sessionWorkflowsSubject.next([]); + this.persistSessionWorkflows(); } if (!user) { this.isOpen = false; @@ -374,7 +612,7 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { } private handleExecutionStateChange(previous: ExecutionStateInfo, current: ExecutionStateInfo): void { - // On page reload, the websocket reconnects and the server replays the current state. + // On page reload/HMR, the websocket reconnects and the server replays the current state. // This produces a synthetic Uninitialized → [terminal] transition that we must NOT // treat as a real event, otherwise we'd push a duplicate notification every refresh. const isTerminalState = @@ -392,17 +630,43 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { const metadata = this.workflowActionService.getWorkflowMetadata(); this.executionSnapshot = { wid: metadata?.wid, name: metadata?.name }; this.persistExecutionSnapshot(); + // A fresh user-initiated run — clear any prior dismissal for this workflow's + // terminal-state signatures so the next terminal event notifies normally. + if (metadata?.wid !== undefined) { + this.agentService.undismiss(`run:${metadata.wid}:runSuccess`); + this.agentService.undismiss(`run:${metadata.wid}:runFailure`); + this.agentService.undismiss(`run:${metadata.wid}:runKilled`); + } } - const snapshot = this.executionSnapshot ?? { - wid: this.workflowActionService.getWorkflowMetadata()?.wid, - name: this.workflowActionService.getWorkflowMetadata()?.name, - }; + // Prefer live metadata over the captured snapshot when both reference the same workflow. + // This way, if the user renames the workflow mid-run, the notification reflects the new name. + // Fall back to snapshot only when the editor has been unloaded (user navigated away). + const liveMetadata = this.workflowActionService.getWorkflowMetadata(); + const snapshotWid = this.executionSnapshot?.wid; + const useLive = liveMetadata?.wid !== undefined && liveMetadata.wid === snapshotWid; + const snapshot = useLive + ? { wid: liveMetadata!.wid, name: liveMetadata!.name } + : (this.executionSnapshot ?? { + wid: liveMetadata?.wid, + name: liveMetadata?.name, + }); const workflowName = snapshot.name && snapshot.name.length > 0 ? snapshot.name : "Workflow"; // Track workflow in session this.trackSessionWorkflow(snapshot.wid, workflowName, current.state); + // Multi-step replay guard: when the websocket reconnects and replays through + // Uninitialized → Initializing → Running → [terminal], the first guard above + // doesn't catch the terminal hop because `previous` is no longer Uninitialized. + // Dedup against (wid, state) within a short window — real reruns take longer + // than this window, so legitimate notifications still come through. + if (isTerminalState && this.wasRecentlyNotified(snapshot.wid, current.state)) { + this.executionSnapshot = undefined; + this.persistExecutionSnapshot(); + return; + } + switch (current.state) { case ExecutionState.Completed: this.agentService.push({ @@ -412,7 +676,9 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { title: `${workflowName} finished`, message: "The workflow run completed successfully.", action: this.workflowAction(snapshot.wid, "Tap to see result"), + meta: { wid: snapshot.wid }, }); + this.recordNotification(snapshot.wid, current.state); this.executionSnapshot = undefined; this.persistExecutionSnapshot(); return; @@ -427,6 +693,7 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { action: { label: "Retry", route: ["__retry-workflow__", snapshot.wid] }, meta: { action: "retry", wid: snapshot.wid }, }); + this.recordNotification(snapshot.wid, current.state); this.executionSnapshot = undefined; this.persistExecutionSnapshot(); return; @@ -441,6 +708,7 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { action: { label: "Retry", route: ["__retry-workflow__", snapshot.wid] }, meta: { action: "retry", wid: snapshot.wid }, }); + this.recordNotification(snapshot.wid, current.state); this.executionSnapshot = undefined; this.persistExecutionSnapshot(); return; @@ -449,6 +717,42 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { } } + private wasRecentlyNotified(wid: number | undefined, state: ExecutionState): boolean { + if (wid === undefined) return false; + const records = this.loadDedupRecords(); + const now = Date.now(); + return records.some( + r => r.wid === wid && r.state === state && now - r.time < TERMINAL_DEDUP_WINDOW_MS + ); + } + + private recordNotification(wid: number | undefined, state: ExecutionState): void { + if (wid === undefined) return; + const records = this.loadDedupRecords(); + const now = Date.now(); + const filtered = records.filter(r => now - r.time < TERMINAL_DEDUP_WINDOW_MS); + filtered.push({ wid, state, time: now }); + try { + localStorage.setItem(TERMINAL_DEDUP_STORAGE_KEY, JSON.stringify(filtered)); + } catch { + // Storage may be unavailable; ignore. + } + } + + private loadDedupRecords(): { wid: number; state: ExecutionState; time: number }[] { + try { + const raw = localStorage.getItem(TERMINAL_DEDUP_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + r => typeof r?.wid === "number" && typeof r?.state === "string" && typeof r?.time === "number" + ); + } catch { + return []; + } + } + private workflowAction(wid: number | undefined, label: string): AgentNotificationAction | undefined { if (wid === undefined) { return undefined; @@ -568,7 +872,15 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { ? `+${diff} like${diff === 1 ? "" : "s"} (total ${current}).` : `+${diff} clone${diff === 1 ? "" : "s"} (total ${current}).`, action: this.socialAction(row.entityType, row.entityId), - meta: { entityType: row.entityType, entityId: row.entityId, action, delta: diff }, + // Include `count` in meta so the dismissal signature changes when the + // count grows — letting a later increase fire a fresh notification. + meta: { + entityType: row.entityType, + entityId: row.entityId, + action, + delta: diff, + count: current, + }, }); } this.socialBaseline.set(key, current); @@ -612,15 +924,54 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { } private trackSessionWorkflow(wid: number | undefined, name: string, state: ExecutionState): void { - const workflows = this.sessionWorkflowsSubject.value; - const existingIndex = workflows.findIndex(w => w.wid === wid && w.name === name); + const workflows = [...this.sessionWorkflowsSubject.value]; + // Match by wid (the stable identifier) — name can change via rename and shouldn't + // create a duplicate session entry. Fall back to name-match only for unsaved workflows. + const existingIndex = + wid !== undefined + ? workflows.findIndex(w => w.wid === wid) + : workflows.findIndex(w => w.wid === undefined && w.name === name); if (existingIndex >= 0) { - workflows[existingIndex] = { ...workflows[existingIndex], state, timestamp: Date.now() }; + // Overwrite name too so renames are reflected in the Workflows tab. + workflows[existingIndex] = { wid, name, state, timestamp: Date.now() }; } else { workflows.unshift({ wid, name, state, timestamp: Date.now() }); } const updated = workflows.slice(0, MAX_SESSION_WORKFLOWS); this.sessionWorkflowsSubject.next(updated); + this.persistSessionWorkflows(); + } + + private persistSessionWorkflows(): void { + try { + localStorage.setItem( + SESSION_WORKFLOWS_STORAGE_KEY, + JSON.stringify(this.sessionWorkflowsSubject.value) + ); + } catch { + // Storage may be unavailable (private mode, quota); ignore. + } + } + + private static loadSessionWorkflows(): SessionWorkflow[] { + try { + const raw = localStorage.getItem(SESSION_WORKFLOWS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (w): w is SessionWorkflow => + typeof w === "object" && + w !== null && + typeof w.name === "string" && + typeof w.state === "string" && + typeof w.timestamp === "number" + ) + .slice(0, MAX_SESSION_WORKFLOWS); + } catch { + return []; + } } public handleKillWorkflow(): void { @@ -654,28 +1005,40 @@ export class FloatingAgentComponent implements OnInit, OnDestroy { } private applyAdminSnapshot(users: ReadonlyArray): void { - const inactive = users.filter(u => u.role === Role.INACTIVE); - const isFirstPoll = this.adminSeenInactive.size === 0; - for (const user of inactive) { - if (!this.adminSeenInactive.has(user.uid)) { - if (!isFirstPoll) { - this.agentService.push({ - category: "admin", - level: "warning", - type: "adminRequests", - title: `Approval needed: ${user.name}`, - message: this.buildAdminMessage(user), - meta: { uid: user.uid, email: user.email }, - }); - } - this.adminSeenInactive.add(user.uid); + // Only INACTIVE requests the admin hasn't already viewed (persisted in DB) count + // as fresh. This survives page reloads, browser switches, and offline periods. + const pendingUnseen = users.filter(u => u.role === Role.INACTIVE && !u.requestViewed); + const stillPending = new Set(pendingUnseen.map(u => u.uid)); + + // Auto-clean stale admin notifications: anyone in our notification list whose + // user is no longer pending+unseen (approved, deleted, or already viewed by + // another admin) should have their notification removed. + this.agentService.removeWhere(n => { + if (n.category !== "admin") return false; + const uid = (n.meta as { uid?: number } | undefined)?.uid; + return typeof uid === "number" && !stillPending.has(uid); + }); + + for (const user of pendingUnseen) { + if (!this.adminNotifiedThisSession.has(user.uid)) { + this.agentService.push({ + category: "admin", + level: "warning", + type: "adminRequests", + title: `Approval needed: ${user.name}`, + message: this.buildAdminMessage(user), + action: { label: "Review user", route: [DASHBOARD_ADMIN_USER] }, + meta: { uid: user.uid, email: user.email }, + }); + this.adminNotifiedThisSession.add(user.uid); } } - // Drop any users that have been approved/removed so future re-INACTIVE flips notify again. - const stillInactive = new Set(inactive.map(u => u.uid)); - for (const uid of [...this.adminSeenInactive]) { - if (!stillInactive.has(uid)) { - this.adminSeenInactive.delete(uid); + + // Drop in-session tracking for users no longer pending so a re-INACTIVE flip would + // notify again next poll. + for (const uid of [...this.adminNotifiedThisSession]) { + if (!stillPending.has(uid)) { + this.adminNotifiedThisSession.delete(uid); } } } diff --git a/frontend/src/app/common/component/floating-agent/floating-agent.service.ts b/frontend/src/app/common/component/floating-agent/floating-agent.service.ts index 9246b47698f..6d410517e2c 100644 --- a/frontend/src/app/common/component/floating-agent/floating-agent.service.ts +++ b/frontend/src/app/common/component/floating-agent/floating-agent.service.ts @@ -81,6 +81,8 @@ export interface AgentNotification { const MAX_NOTIFICATIONS = 100; const STORAGE_KEY = "texera-floating-agent-notifications"; const SETTINGS_STORAGE_KEY = "texera-floating-agent-settings"; +const DISMISSED_KEYS_STORAGE_KEY = "texera-floating-agent-dismissed-keys"; +const MAX_DISMISSED_KEYS = 500; @Injectable({ providedIn: "root" }) export class FloatingAgentService { @@ -106,6 +108,20 @@ export class FloatingAgentService { return this.notifications$.pipe(map(list => list.filter(n => n.category === category))); } + /** Synchronous snapshot — use for one-off lookups, not in templates. */ + public peekByCategory(category: AgentNotificationCategory): AgentNotification[] { + return this.notificationsSubject.value.filter(n => n.category === category); + } + + /** Remove notifications matching a predicate. Used to clean up stale entries when + * the underlying state changes (e.g., admin request marked viewed elsewhere). */ + public removeWhere(predicate: (n: AgentNotification) => boolean): void { + const filtered = this.notificationsSubject.value.filter(n => !predicate(n)); + if (filtered.length === this.notificationsSubject.value.length) return; + this.notificationsSubject.next(filtered); + this.persist(); + } + public getSettings(): AgentNotificationSettings { return this.settingsSubject.value; } @@ -126,6 +142,13 @@ export class FloatingAgentService { if (notification.type && !this.isTypeEnabled(notification.type)) { return; } + // Filter out notifications the user has previously dismissed via Clear. + // The signature is built from semantic identity (e.g., wid+state for runs, + // entity+action for social, uid for admin) — see signatureFor(). + const signature = FloatingAgentService.signatureFor(notification); + if (signature && this.isDismissed(signature)) { + return; + } const entry: AgentNotification = { ...notification, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -137,6 +160,78 @@ export class FloatingAgentService { this.persist(); } + /** + * Build a semantic signature for a notification so we can recognize the + * "same" event across reloads/HMR. Returns undefined when the notification + * has no stable identity (in which case dismissal can't apply). + */ + public static signatureFor( + n: Pick + ): string | undefined { + const meta = n.meta as Record | undefined; + if (n.category === "run") { + // Use the workflow id + the run's notification type (runSuccess/runFailure/runKilled). + const wid = meta?.["wid"] as number | undefined; + if (typeof wid === "number" && n.type) return `run:${wid}:${n.type}`; + } + if (n.category === "social") { + // Include the current total count so a later increase produces a new + // signature (and therefore a new notification even after dismissal). + const entityType = meta?.["entityType"]; + const entityId = meta?.["entityId"]; + const action = meta?.["action"]; + const count = meta?.["count"]; + if (entityType !== undefined && entityId !== undefined && action !== undefined) { + return `social:${entityType}:${entityId}:${action}:${count ?? "?"}`; + } + } + if (n.category === "admin") { + const uid = meta?.["uid"] as number | undefined; + if (typeof uid === "number") return `admin:${uid}`; + } + return undefined; + } + + private loadDismissedKeys(): Set { + try { + const raw = localStorage.getItem(DISMISSED_KEYS_STORAGE_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((s): s is string => typeof s === "string")); + } catch { + return new Set(); + } + } + + private saveDismissedKeys(keys: Set): void { + try { + // Bound the size so the dismissed set can't grow unbounded. + const arr = Array.from(keys).slice(-MAX_DISMISSED_KEYS); + localStorage.setItem(DISMISSED_KEYS_STORAGE_KEY, JSON.stringify(arr)); + } catch { + // Storage may be unavailable; ignore. + } + } + + public isDismissed(signature: string): boolean { + return this.loadDismissedKeys().has(signature); + } + + public dismiss(signatures: ReadonlyArray): void { + if (signatures.length === 0) return; + const keys = this.loadDismissedKeys(); + for (const s of signatures) keys.add(s); + this.saveDismissedKeys(keys); + } + + public undismiss(signature: string): void { + const keys = this.loadDismissedKeys(); + if (keys.delete(signature)) { + this.saveDismissedKeys(keys); + } + } + public markAllRead(category?: AgentNotificationCategory): void { const next = this.notificationsSubject.value.map(n => !category || n.category === category ? { ...n, read: true } : n @@ -146,6 +241,16 @@ export class FloatingAgentService { } public clear(category?: AgentNotificationCategory): void { + // Remember the signatures we're about to clear so polls / replay events + // don't immediately re-push them (e.g., after an HMR or page refresh). + const toClear = category + ? this.notificationsSubject.value.filter(n => n.category === category) + : this.notificationsSubject.value; + const signatures = toClear + .map(n => FloatingAgentService.signatureFor(n)) + .filter((s): s is string => typeof s === "string"); + if (signatures.length > 0) this.dismiss(signatures); + const next = category ? this.notificationsSubject.value.filter(n => n.category !== category) : []; this.notificationsSubject.next(next); this.persist(); diff --git a/frontend/src/app/common/type/user.ts b/frontend/src/app/common/type/user.ts index 58e34b68009..800286e8f2b 100644 --- a/frontend/src/app/common/type/user.ts +++ b/frontend/src/app/common/type/user.ts @@ -48,6 +48,8 @@ export interface User accountCreation?: Second; affiliation?: string; joiningReason: string; + /** True once an admin has acknowledged this user's join request via the assistant. */ + requestViewed?: boolean; }> {} export interface File diff --git a/frontend/src/app/dashboard/service/admin/user/admin-user.service.ts b/frontend/src/app/dashboard/service/admin/user/admin-user.service.ts index 481c5e5302e..596dbcd2f33 100644 --- a/frontend/src/app/dashboard/service/admin/user/admin-user.service.ts +++ b/frontend/src/app/dashboard/service/admin/user/admin-user.service.ts @@ -37,6 +37,8 @@ export const USER_ACCESS_WORKFLOWS = `${USER_BASE_URL}/access_workflows`; export const USER_ACCESS_FILES = `${USER_BASE_URL}/access_files`; export const USER_QUOTA_SIZE = `${USER_BASE_URL}/user_quota_size`; export const USER_DELETE_EXECUTION_COLLECTION = `${USER_BASE_URL}/deleteCollection`; +export const USER_MARK_REQUESTS_VIEWED_URL = `${USER_BASE_URL}/mark-requests-viewed`; +export const USER_MARK_ALL_REQUESTS_VIEWED_URL = `${USER_BASE_URL}/mark-all-requests-viewed`; @Injectable({ providedIn: "root", @@ -94,4 +96,20 @@ export class AdminUserService { public deleteExecutionCollection(eid: number): Observable { return this.http.delete(`${USER_DELETE_EXECUTION_COLLECTION}/${eid.toString()}`); } + + /** + * Mark the given pending user requests as viewed so the admin assistant + * stops re-surfacing them as new notifications. + */ + public markRequestsViewed(uids: ReadonlyArray): Observable { + return this.http.post(USER_MARK_REQUESTS_VIEWED_URL, { uids }); + } + + /** + * Mark every currently-pending (INACTIVE) user request as viewed in one DB + * transaction. Used by the assistant's "Clear all requests" action. + */ + public markAllRequestsViewed(): Observable { + return this.http.post(USER_MARK_ALL_REQUESTS_VIEWED_URL, {}); + } } diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.html b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.html index ee77072aa0c..a1047757e36 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.html +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.html @@ -17,7 +17,7 @@ under the License. --> - +
+ +
+
+ + +
+
+ + + +
+
+
+ +
+
+ {{ item.workflow?.workflow?.name || item.dataset?.dataset?.name || "(no name)" }} +
+
+ {{ item.resourceType }} + + + Modified {{ wf.lastModifiedTime | date: "medium" }} + + + Created {{ wf.creationTime | date: "medium" }} + + + + + Created {{ ds.creationTime | date: "medium" }} + + +
+
+
+ +
+
+ @@ -160,6 +262,81 @@ + + + + Operator + + +
+ +
+
{{ selectedOperatorType }}
+
{{ selectedOperatorId }}
+
+
+
+ {{ prop.key }} + {{ prop.value | json }} +
+
+ +
+
+ + Explanation + +
+
+ +
+
+ Asking your workflow's AI agent… +
+
+
+ + + +
+
+
+ Try: {{ n.hint }}
+
+
+ + AI suggestion + +
+
+ +
+
+ Asking your workflow's AI agent… +
+