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/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..fc1e940c095 --- /dev/null +++ b/frontend/src/app/common/component/floating-agent/floating-agent.component.html @@ -0,0 +1,553 @@ + +
+
+
+ + + 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 + +
+
+
+
+
+ + +
+
+ + +
+
+ + + +
+
+
+ +
+
+ {{ 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" }} + + +
+
+
+ +
+
+ + + + + + + Notifications + + + + + + + + + + + + Workflows + + + + + + + + + + Operator + + +
+ +
+
{{ selectedOperatorType }}
+
{{ selectedOperatorId }}
+
+
+
+ {{ prop.key }} + {{ prop.value | json }} +
+
+ +
+
+ + Explanation + +
+
+ +
+
+ Asking your workflow's AI agent… +
+
+
+ + + +
+
+
+ + + + + + Requests + + + + + + + +
+ + +
+
+ +
+ + +
+
+ + + +
+
+ +
+
+
+
+ +
+
{{ n.title }}
+
{{ n.message }}
+
+ Try: {{ n.hint }} +
+
+
+ + AI suggestion + +
+
+ +
+
+ Asking your workflow's AI agent… +
+
+ +
{{ 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..06f1b7431ca --- /dev/null +++ b/frontend/src/app/common/component/floating-agent/floating-agent.component.scss @@ -0,0 +1,777 @@ +: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-buttons { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.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-button-secondary { + width: 44px; + height: 44px; + background: linear-gradient(135deg, #722ed1, #9254de); + cursor: pointer; + + .agent-icon { + font-size: 20px; + } +} + +.agent-panel { + // 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); + 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 { + 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-ai-suggestion { + margin-top: 6px; + padding: 8px 10px; + border-radius: 6px; + background: rgba(114, 46, 209, 0.08); + border-left: 3px solid #722ed1; +} + +.item-ai-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + color: #722ed1; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.item-ai-spinner { + margin-left: 4px; +} + +.item-ai-body { + margin-top: 4px; + font-size: 12.5px; + color: rgba(0, 0, 0, 0.78); + line-height: 1.5; + + // Tighten markdown defaults so the suggestion reads compactly in the panel. + :host ::ng-deep & markdown { + display: block; + + p { + margin: 0 0 6px; + } + + p:last-child { + margin-bottom: 0; + } + + strong { + color: rgba(0, 0, 0, 0.88); + font-weight: 600; + } + + em { + color: rgba(0, 0, 0, 0.7); + } + + ul, + ol { + margin: 4px 0 6px; + padding-left: 20px; + } + + li { + margin-bottom: 2px; + } + + li::marker { + color: #722ed1; + } + + code { + background: rgba(114, 46, 209, 0.12); + color: #531dab; + padding: 1px 5px; + border-radius: 3px; + font-size: 11.5px; + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + monospace; + word-break: break-all; + } + + pre { + background: rgba(0, 0, 0, 0.05); + padding: 8px 10px; + border-radius: 4px; + margin: 4px 0 6px; + overflow-x: auto; + } + + pre > code { + background: transparent; + color: rgba(0, 0, 0, 0.85); + padding: 0; + } + + h1, + h2, + h3, + h4 { + font-size: 12.5px; + font-weight: 600; + color: rgba(0, 0, 0, 0.88); + margin: 6px 0 4px; + } + + a { + color: #722ed1; + text-decoration: underline; + } + } +} + +.item-ai-placeholder { + font-style: italic; + color: rgba(0, 0, 0, 0.5); +} + +.item-ai-retry { + margin-top: 4px; + padding: 0; + height: auto; + line-height: 1.2; + font-size: 12px; + font-weight: 500; + color: #722ed1 !important; + + i { + margin-left: 4px; + font-size: 11px; + } +} + +.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; +} + +.search-panel { + flex: 1; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.search-input-row { + display: flex; + gap: 6px; +} + +.search-input { + flex: 1; + padding: 5px 8px; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + font-size: 13px; + outline: none; + + &:focus { + border-color: var(--agent-accent); + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15); + } +} + +.search-type-row { + display: flex; + gap: 12px; + font-size: 12px; + color: rgba(0, 0, 0, 0.65); +} + +.search-type-option { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + + input[type="radio"] { + cursor: pointer; + } +} + +.search-results { + display: flex; + flex-direction: column; + gap: 2px; +} + +.search-result-item { + display: flex; + gap: 10px; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + transition: background 0.12s ease; + + &:hover { + background: rgba(22, 119, 255, 0.06); + } +} + +.search-result-icon { + font-size: 18px; + color: var(--agent-accent); + margin-top: 2px; +} + +.search-result-body { + flex: 1; + min-width: 0; +} + +.search-result-title { + font-weight: 500; + font-size: 13px; + color: rgba(0, 0, 0, 0.85); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.search-result-meta { + font-size: 11px; + color: rgba(0, 0, 0, 0.5); + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + margin-top: 2px; +} + +.search-result-type { + text-transform: capitalize; + font-weight: 500; + color: rgba(0, 0, 0, 0.55); +} + +.search-result-date { + color: rgba(0, 0, 0, 0.45); +} + +.operator-pane { + height: 100%; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.operator-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + padding-bottom: 8px; +} + +.operator-type { + font-weight: 600; + font-size: 14px; + color: rgba(0, 0, 0, 0.85); +} + +.operator-id { + font-size: 11px; + color: rgba(0, 0, 0, 0.45); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + margin-top: 2px; + word-break: break-all; +} + +.operator-props { + font-size: 12px; + color: rgba(0, 0, 0, 0.65); + display: flex; + flex-direction: column; + gap: 4px; +} + +.operator-prop-row { + display: flex; + gap: 8px; +} + +.operator-prop-key { + font-weight: 500; + color: rgba(0, 0, 0, 0.7); + flex-shrink: 0; + min-width: 80px; +} + +.operator-prop-value { + color: rgba(0, 0, 0, 0.55); + word-break: break-all; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11.5px; +} + +.operator-explain-btn { + align-self: flex-start; + background: #722ed1 !important; + border-color: #722ed1 !important; + + i { + margin-right: 4px; + } +} + +.operator-explanation { + margin-top: 0; +} + +.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..d8a5db1b91a --- /dev/null +++ b/frontend/src/app/common/component/floating-agent/floating-agent.component.ts @@ -0,0 +1,1421 @@ +/** + * 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, ElementRef, OnDestroy, OnInit } from "@angular/core"; +import { CommonModule, DatePipe } from "@angular/common"; +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, 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"; +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 { MarkdownComponent } from "ngx-markdown"; + +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 { SearchService } from "../../../dashboard/service/user/search.service"; +import { SearchResultItem } from "../../../dashboard/type/search-result"; +import { SortMethod } from "../../../dashboard/type/sort-method"; +import { AgentPanelControlService } from "../../../workspace/service/agent/agent-panel-control.service"; +import { AgentService } from "../../../workspace/service/agent/agent.service"; +import { + DASHBOARD_ADMIN_USER, + 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"; +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 SESSION_WORKFLOWS_DISMISSED_STORAGE_KEY = "texera-floating-agent-session-workflows-dismissed"; +/** Must match the key used by AgentPanelComponent (per-workflow agent binding). */ +const AGENT_BY_WORKFLOW_STORAGE_KEY = "agent-panel-active-agent-by-workflow"; +/** Stop waiting for the agent's reply after this long. */ +const AI_SUGGESTION_TIMEOUT_MS = 60_000; +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; + 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, + MarkdownComponent, + ], + providers: [DatePipe], +}) +export class FloatingAgentComponent implements OnInit, OnDestroy { + public isOpen = false; + public isAdmin = false; + public isLoggedIn = false; + public isSettingsOpen = false; + public isSearchOpen = false; + public isOnWorkflowPage = false; + public isAgentPanelOpen = false; + public searchQuery = ""; + public searchType: "all" | "workflow" | "dataset" = "all"; + public searchResults: SearchResultItem[] = []; + public searchLoading = false; + private searchSub?: Subscription; + /** ID of the operator displayed in the Operator tab. Sticky — does NOT clear on + * canvas deselection; only changes when the user selects a different operator. */ + public selectedOperatorId?: string; + public selectedOperatorType?: string; + public selectedOperatorProperties?: Record; + public operatorExplanation?: string; + public operatorExplanationLoading = false; + /** Cache of explanations keyed by operator id so switching between operators + * doesn't lose the prior AI response. */ + private operatorExplanationCache: Map = new Map(); + /** 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; + + 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( + FloatingAgentComponent.loadSessionWorkflows() + ); + 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(); + /** 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; + /** + * 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 agentPanelControlService: AgentPanelControlService, + private workspaceAgentService: AgentService, + private searchService: SearchService, + private router: Router, + private elementRef: ElementRef + ) {} + + ngOnInit(): void { + this.executionSnapshot = this.loadExecutionSnapshot(); + this.userService + .userChanged() + .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)); + // Track operator selection so the Operator tab can show what's selected and + // offer to explain it via the bound AI agent. + this.subscribeOperatorHighlight(); + } + + private subscribeOperatorHighlight(): void { + const jointWrapper = this.workflowActionService.getJointGraphWrapper(); + const sync = (): void => { + const ids = jointWrapper.getCurrentHighlightedOperatorIDs(); + const newId = ids.length === 1 ? ids[0] : undefined; + // Sticky: ignore deselections so the user can keep reading the explanation + // after they click off the operator. Only react to actual new selections. + if (!newId) return; + if (newId === this.selectedOperatorId) return; + this.selectedOperatorId = newId; + this.operatorExplanationLoading = false; + // Load cached explanation for this operator (undefined if never asked). + this.operatorExplanation = this.operatorExplanationCache.get(newId); + try { + const op = this.workflowActionService.getTexeraGraph().getOperator(newId); + this.selectedOperatorType = op.operatorType; + this.selectedOperatorProperties = op.operatorProperties as Record; + } catch { + this.selectedOperatorType = undefined; + this.selectedOperatorProperties = undefined; + } + }; + jointWrapper.getJointOperatorHighlightStream().pipe(untilDestroyed(this)).subscribe(() => sync()); + sync(); + } + + 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 { + this.stopPolling(); + } + + // ---------- UI ---------- + + public togglePanel(): void { + if (this.suppressNextClick) { + this.suppressNextClick = false; + return; + } + 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; + this.isSearchOpen = false; + } + + public toggleSettings(event?: Event): void { + event?.stopPropagation(); + this.isSettingsOpen = !this.isSettingsOpen; + if (this.isSettingsOpen) this.isSearchOpen = false; + } + + public toggleSearch(event?: Event): void { + event?.stopPropagation(); + this.isSearchOpen = !this.isSearchOpen; + if (this.isSearchOpen) this.isSettingsOpen = false; + } + + public runSearch(): void { + const keyword = this.searchQuery.trim(); + if (!keyword) { + this.searchResults = []; + return; + } + this.searchSub?.unsubscribe(); + this.searchLoading = true; + const emptyFilters = { + createDateStart: null, + createDateEnd: null, + modifiedDateStart: null, + modifiedDateEnd: null, + owners: [], + ids: [], + operators: [], + projectIds: [], + }; + const type: "workflow" | "dataset" | null = this.searchType === "all" ? null : this.searchType; + this.searchSub = this.searchService + .search([keyword], emptyFilters, 0, 20, type, SortMethod.EditTimeDesc, this.isLoggedIn, false) + .pipe(untilDestroyed(this)) + .subscribe({ + next: result => { + this.searchResults = result.results.filter( + r => r.resourceType === "workflow" || r.resourceType === "dataset" + ); + this.searchLoading = false; + }, + error: err => { + console.error("[FloatingAgent] search failed:", err); + this.searchResults = []; + this.searchLoading = false; + }, + }); + } + + public openSearchResult(item: SearchResultItem): void { + let route: unknown[] | undefined; + if (item.resourceType === "workflow" && item.workflow?.workflow?.wid !== undefined) { + route = [DASHBOARD_USER_WORKSPACE, item.workflow.workflow.wid]; + } else if (item.resourceType === "dataset" && item.dataset?.dataset?.did !== undefined) { + route = [DASHBOARD_USER_DATASET, item.dataset.dataset.did]; + } + if (route) { + this.router.navigate(route); + this.closePanel(); + } + } + + 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 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(); + // Remember which workflow ids the user just dismissed so passive state events + // (websocket replays on reload, state polling, etc.) don't re-populate them. + // The dismissal is lifted automatically when the workflow next enters the + // Running state — that's a clear user-initiated re-run. + const currentWids = this.sessionWorkflowsSubject.value + .map(w => w.wid) + .filter((w): w is number => typeof w === "number"); + if (currentWids.length > 0) { + const dismissed = this.loadDismissedSessionWorkflows(); + currentWids.forEach(wid => dismissed.add(wid)); + this.saveDismissedSessionWorkflows(dismissed); + } + this.sessionWorkflowsSubject.next([]); + this.persistSessionWorkflows(); + } + + private loadDismissedSessionWorkflows(): Set { + try { + const raw = localStorage.getItem(SESSION_WORKFLOWS_DISMISSED_STORAGE_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((n): n is number => typeof n === "number")); + } catch { + return new Set(); + } + } + + private saveDismissedSessionWorkflows(set: Set): void { + try { + localStorage.setItem(SESSION_WORKFLOWS_DISMISSED_STORAGE_KEY, JSON.stringify([...set])); + } catch { + // Storage may be unavailable; ignore. + } + } + + 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; + } + + // 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(); + } + + 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 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); + 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.adminNotifiedThisSession.clear(); + this.executionSnapshot = undefined; + this.persistExecutionSnapshot(); + this.sessionWorkflowsSubject.next([]); + this.persistSessionWorkflows(); + } + 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/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 = + 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(); + } + + // 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({ + category: "run", + level: "success", + type: "runSuccess", + 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; + case ExecutionState.Failed: { + const notificationId = 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 }, + }); + // If this workflow has a bound AI agent (per the workflow-agent map), ask it + // for a remediation suggestion and stream the reply back into the notification. + if (notificationId && snapshot.wid !== undefined) { + this.askAgentAboutFailure(notificationId, workflowName, snapshot.wid, current); + } + this.recordNotification(snapshot.wid, current.state); + 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.recordNotification(snapshot.wid, current.state); + this.executionSnapshot = undefined; + this.persistExecutionSnapshot(); + return; + default: + return; + } + } + + 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 []; + } + } + + /** + * Look up the AI agent bound to the failed workflow (via the workflow→agent map saved + * by AgentPanelComponent). If one exists and is actively connected, send a debug + * prompt and stream the reply back into the notification's `aiSuggestion` field. + * If the workflow has no bound agent, silently skip — only static hint is shown. + */ + private askAgentAboutFailure( + notificationId: string, + workflowName: string, + wid: number, + state: ExecutionStateInfo + ): void { + if (state.state !== ExecutionState.Failed) return; + const agentId = this.lookupAgentForWorkflow(wid); + console.log(`[FloatingAgent] askAgent: wid=${wid}, boundAgentId=${agentId}`); + if (!agentId) return; // No agent bound to this workflow — skip silently. + const activeIds = new Set(this.workspaceAgentService.getActivelyConnectedAgentIds()); + if (!activeIds.has(agentId)) { + console.log( + `[FloatingAgent] askAgent: bound agent ${agentId} is not actively connected; skipping` + ); + return; + } + + const errorDetails = state.errorMessages + .map(e => (e.operatorId ? `Operator ${e.operatorId}: ${e.message}` : e.message)) + .join("\n"); + const prompt = + `My workflow "${workflowName}" just failed with the following error(s):\n\n${errorDetails}\n\n` + + `Provide a concise diagnostic (under 120 words) with:\n` + + `1. The most likely cause.\n` + + `2. 1-3 concrete steps to debug or fix it.\n\n` + + `Do NOT ask me any follow-up questions. Do NOT offer to perform actions or wait ` + + `for my reply. End your response after the steps.`; + + this.agentService.updateById(notificationId, { aiSuggestionLoading: true }); + const promptSentAt = Date.now(); + let collected = ""; + let completed = false; + + const sub = this.workspaceAgentService + .getReActStepsObservable(agentId) + .pipe(untilDestroyed(this)) + .subscribe(steps => { + if (completed) return; + // Only consider agent-role steps that arrived after we sent the prompt. + const after = steps.filter( + s => s.role === "agent" && new Date(s.timestamp).getTime() >= promptSentAt + ); + if (after.length === 0) return; + collected = after.map(s => s.content).filter(Boolean).join("\n").trim(); + const cleaned = this.cleanAiSuggestion(collected); + if (cleaned) { + this.agentService.updateById(notificationId, { aiSuggestion: cleaned }); + } + const last = after[after.length - 1]; + if (last.isEnd) { + completed = true; + this.agentService.updateById(notificationId, { aiSuggestionLoading: false }); + sub.unsubscribe(); + } + }); + + // Failsafe: stop showing the spinner after a timeout even if isEnd never arrives. + setTimeout(() => { + if (!completed) { + completed = true; + this.agentService.updateById(notificationId, { aiSuggestionLoading: false }); + sub.unsubscribe(); + } + }, AI_SUGGESTION_TIMEOUT_MS); + + try { + this.workspaceAgentService.sendMessage(agentId, prompt, "chat"); + } catch (err) { + console.error("[FloatingAgent] sendMessage failed:", err); + this.agentService.updateById(notificationId, { aiSuggestionLoading: false }); + sub.unsubscribe(); + } + } + + /** + * Strip conversational follow-ups (questions to the user, offers to perform + * actions, etc.) from the trailing portion of the agent's response so the + * notification stays focused on causes and remediation steps. The agent's full + * reply remains visible in the AI panel chat. + */ + private cleanAiSuggestion(text: string): string { + let cleaned = text.trim(); + // Patterns that suggest a trailing paragraph is conversational and should be dropped. + const followUpPatterns: RegExp[] = [ + /\?\s*$/, // ends with a question mark + /^(could|can|would|will|should|do|does)\s+(you|i)\b/i, + /^(let me know|once you|i'll|i will|i can|i would|please (share|provide|send|tell))\b/i, + /^(if you (can|could|want))\b/i, + ]; + // Drop trailing paragraphs/sentences that match a follow-up pattern. Stops as soon + // as the new last block doesn't match, so legitimate steps with questions inline + // (e.g., "Is the path correct?" as part of step 2) stay intact. + for (let i = 0; i < 5; i++) { + // Try paragraph split first; if there's only one paragraph, fall back to sentences. + const paraSplit = cleaned.split(/\n\s*\n/); + const lastPara = paraSplit[paraSplit.length - 1].trim(); + if (lastPara && followUpPatterns.some(p => p.test(lastPara))) { + cleaned = paraSplit.slice(0, -1).join("\n\n").trim(); + continue; + } + // Sentence-level cleanup: strip trailing sentences that match a follow-up. + const sentences = cleaned.split(/(?<=[.!?])\s+(?=[A-Z])/); + const lastSent = sentences[sentences.length - 1].trim(); + if (lastSent && followUpPatterns.some(p => p.test(lastSent))) { + cleaned = sentences.slice(0, -1).join(" ").trim(); + continue; + } + break; + } + return cleaned; + } + + /** + * Ask the workflow's bound AI agent to explain the currently selected operator. + * Streams the reply into `operatorExplanation` for the Operator tab to render. + */ + public explainSelectedOperator(): void { + if (!this.selectedOperatorId || !this.selectedOperatorType) return; + const wid = this.workflowActionService.getWorkflowMetadata()?.wid; + const agentId = wid !== undefined ? this.lookupAgentForWorkflow(wid) : undefined; + if (!agentId) { + this.operatorExplanation = + "Bind an AI agent to this workflow (purple flask button) to get operator explanations."; + return; + } + const activeIds = new Set(this.workspaceAgentService.getActivelyConnectedAgentIds()); + if (!activeIds.has(agentId)) { + this.operatorExplanation = "The bound AI agent isn't currently connected. Open it from the flask button."; + return; + } + + const propsJson = JSON.stringify(this.selectedOperatorProperties ?? {}, null, 2); + const prompt = + `Briefly explain this Texera operator and its current parameters (under 100 words).\n\n` + + `Operator type: ${this.selectedOperatorType}\n` + + `Operator id: ${this.selectedOperatorId}\n` + + `Current parameters:\n\`\`\`json\n${propsJson}\n\`\`\`\n\n` + + `Cover: (1) what the operator does, (2) what each non-default parameter is doing here. ` + + `Do NOT ask follow-up questions or offer to take actions — just explain.`; + + this.operatorExplanation = undefined; + this.operatorExplanationLoading = true; + const promptSentAt = Date.now(); + let collected = ""; + let completed = false; + + // Capture the operator id at request time so the cache write later targets + // the right operator even if the user has clicked something else by then. + const requestedOperatorId = this.selectedOperatorId; + const sub = this.workspaceAgentService + .getReActStepsObservable(agentId) + .pipe(untilDestroyed(this)) + .subscribe(steps => { + if (completed) return; + const after = steps.filter( + s => s.role === "agent" && new Date(s.timestamp).getTime() >= promptSentAt + ); + if (after.length === 0) return; + collected = after.map(s => s.content).filter(Boolean).join("\n").trim(); + const cleaned = this.cleanAiSuggestion(collected); + if (cleaned) { + if (requestedOperatorId) this.operatorExplanationCache.set(requestedOperatorId, cleaned); + // Only update the visible explanation if we're still on the same operator. + if (requestedOperatorId === this.selectedOperatorId) this.operatorExplanation = cleaned; + } + const last = after[after.length - 1]; + if (last.isEnd) { + completed = true; + this.operatorExplanationLoading = false; + sub.unsubscribe(); + } + }); + + setTimeout(() => { + if (!completed) { + completed = true; + this.operatorExplanationLoading = false; + sub.unsubscribe(); + } + }, AI_SUGGESTION_TIMEOUT_MS); + + try { + this.workspaceAgentService.sendMessage(agentId, prompt, "chat"); + } catch (err) { + console.error("[FloatingAgent] sendMessage (explain) failed:", err); + this.operatorExplanationLoading = false; + sub.unsubscribe(); + } + } + + /** Read the workflow→agent map written by AgentPanelComponent. */ + private lookupAgentForWorkflow(wid: number): string | undefined { + try { + const raw = localStorage.getItem(AGENT_BY_WORKFLOW_STORAGE_KEY); + if (!raw) return undefined; + const map = JSON.parse(raw) as Record; + const value = map[String(wid)]; + return typeof value === "string" ? value : undefined; + } catch { + return undefined; + } + } + + 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), + // 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); + } + } + } + + 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 { + if (wid !== undefined) { + const dismissed = this.loadDismissedSessionWorkflows(); + if (dismissed.has(wid)) { + // Workflow was explicitly cleared by the user. Only re-admit when the user + // visibly starts a new run (state hits Running) — otherwise stay hidden. + if (state !== ExecutionState.Running) return; + dismissed.delete(wid); + this.saveDismissedSessionWorkflows(dismissed); + } + } + + 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) { + // 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 { + 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 { + // 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 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); + } + } + } + + 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..45d0d4cdc9b --- /dev/null +++ b/frontend/src/app/common/component/floating-agent/floating-agent.service.ts @@ -0,0 +1,311 @@ +/** + * 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; + /** LLM-generated remediation suggestion, streamed in after the notification is pushed. */ + aiSuggestion?: string; + /** Whether an AI suggestion request is in flight. */ + aiSuggestionLoading?: boolean; +} + +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 { + 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))); + } + + /** 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; + } + + 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): string | undefined { + // Filter out muted notification types + if (notification.type && !this.isTypeEnabled(notification.type)) { + return undefined; + } + 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(); + return entry.id; + } + + /** Update fields on an existing notification (used to stream in AI suggestions). */ + public updateById(id: string, partial: Partial): void { + const list = this.notificationsSubject.value; + const idx = list.findIndex(n => n.id === id); + if (idx < 0) return; + const next = [...list]; + next[idx] = { ...list[idx], ...partial }; + this.notificationsSubject.next(next); + 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 + ); + 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" + ); + } +} 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. --> - +