From 21f285b8e578eaa34b60144f8940095c69ad9e59 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Sun, 1 Nov 2026 18:07:58 +0530 Subject: [PATCH] wip(assistant): runs lane Claude-Session: https://claude.ai/code/session_017PUuNbanUSUUbZ5dcZBYmM --- .../approvals/AssistantApprovalCard.spec.ts | 91 ++- .../approvals/AssistantApprovalCard.vue | 95 ++- .../approvals/AssistantApprovalResult.spec.ts | 10 + .../approvals/AssistantApprovalResult.vue | 1 + .../ai-assistant/approvals/approvals.types.ts | 6 +- .../ai-assistant/helper/InlineQueryHelper.vue | 4 +- .../runs/AssistantDecisionNotice.spec.ts | 212 ++++++ .../runs/AssistantDecisionNotice.vue | 238 +++++++ .../runs/AssistantRunsButton.spec.ts | 176 +++++ .../ai-assistant/runs/AssistantRunsButton.vue | 171 +++++ .../runs/AssistantRunsMenu.spec.ts | 160 +++++ .../ai-assistant/runs/AssistantRunsMenu.vue | 292 +++++++++ .../ai-assistant/runs/runsTestKit.ts | 68 ++ .../ai-assistant/runs/runsText.spec.ts | 76 +++ .../components/ai-assistant/runs/runsText.ts | 80 +++ .../shell/AssistantHeaderButton.spec.ts | 50 +- .../shell/AssistantHeaderButton.vue | 50 +- .../ai-assistant/shell/AssistantShell.spec.ts | 61 +- .../ai-assistant/shell/AssistantShell.vue | 23 +- .../turn/AssistantTurnOutcome.spec.ts | 29 +- .../turn/AssistantTurnOutcome.vue | 9 +- .../assistant/approvalKeepAlive.spec.ts | 176 +++++ .../assistant/approvalKeepAlive.ts | 198 ++++++ .../assistant/assistantErrors.spec.ts | 30 + .../composables/assistant/assistantErrors.ts | 31 +- .../assistant/assistantStore.types.ts | 16 +- web/src/composables/assistant/hints.spec.ts | 9 + web/src/composables/assistant/hints.ts | 5 + .../composables/assistant/historyCrossTab.ts | 18 +- .../assistant/historyElsewhere.spec.ts | 58 ++ .../composables/assistant/historyElsewhere.ts | 89 ++- .../composables/assistant/historyRecords.ts | 3 + web/src/composables/assistant/index.ts | 5 +- .../composables/assistant/lifecycle.spec.ts | 65 ++ web/src/composables/assistant/lifecycle.ts | 89 ++- web/src/composables/assistant/pure.ts | 1 + web/src/composables/assistant/runs.spec.ts | 503 +++++++++++++++ web/src/composables/assistant/runs.ts | 608 ++++++++++++++++++ web/src/composables/assistant/runtime.spec.ts | 7 + web/src/composables/assistant/runtime.ts | 127 +++- .../composables/assistant/turnStateMachine.ts | 5 +- .../assistant/useAssistantApprovals.spec.ts | 40 +- .../assistant/useAssistantApprovals.ts | 90 ++- .../assistant/useAssistantCapability.spec.ts | 15 + .../assistant/useAssistantCapability.ts | 1 + .../assistant/useAssistantClient.spec.ts | 14 + .../assistant/useAssistantClient.ts | 10 +- .../assistant/useAssistantHistory.spec.ts | 2 + web/src/locales/languages/en-US.json | 71 +- web/src/services/assistant.queries.spec.ts | 13 +- web/src/services/assistant.queries.ts | 15 + web/src/services/assistant.querykeys.ts | 1 + web/src/services/assistant.spec.ts | 45 +- web/src/services/assistant.ts | 43 +- 54 files changed, 4221 insertions(+), 84 deletions(-) create mode 100644 web/src/components/ai-assistant/runs/AssistantDecisionNotice.spec.ts create mode 100644 web/src/components/ai-assistant/runs/AssistantDecisionNotice.vue create mode 100644 web/src/components/ai-assistant/runs/AssistantRunsButton.spec.ts create mode 100644 web/src/components/ai-assistant/runs/AssistantRunsButton.vue create mode 100644 web/src/components/ai-assistant/runs/AssistantRunsMenu.spec.ts create mode 100644 web/src/components/ai-assistant/runs/AssistantRunsMenu.vue create mode 100644 web/src/components/ai-assistant/runs/runsTestKit.ts create mode 100644 web/src/components/ai-assistant/runs/runsText.spec.ts create mode 100644 web/src/components/ai-assistant/runs/runsText.ts create mode 100644 web/src/composables/assistant/approvalKeepAlive.spec.ts create mode 100644 web/src/composables/assistant/approvalKeepAlive.ts create mode 100644 web/src/composables/assistant/runs.spec.ts create mode 100644 web/src/composables/assistant/runs.ts diff --git a/web/src/components/ai-assistant/approvals/AssistantApprovalCard.spec.ts b/web/src/components/ai-assistant/approvals/AssistantApprovalCard.spec.ts index dc2d8f1063..d8eaef0391 100644 --- a/web/src/components/ai-assistant/approvals/AssistantApprovalCard.spec.ts +++ b/web/src/components/ai-assistant/approvals/AssistantApprovalCard.spec.ts @@ -14,7 +14,7 @@ // along with this program. If not, see . import { flushPromises, mount } from "@vue/test-utils"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { computed, defineComponent, h } from "vue"; import { runEscapeLadder } from "@/composables/assistant"; @@ -157,7 +157,7 @@ describe("AssistantApprovalCard", () => { ); expect(wrapper.get('[data-test="assistant-approval-heading"]').text()).toBe("Delete 1 alert"); const countdown = wrapper.get('[data-test="assistant-approval-countdown"]'); - expect(countdown.text()).toBe("Expires in 0:25"); + expect(countdown.text()).toBe("Expires soon · 0:25"); expect(countdown.attributes("aria-hidden")).toBe("true"); expect(wrapper.get('[data-test="assistant-approval-expiry"]').attributes("aria-live")).toBe( "off", @@ -612,4 +612,91 @@ describe("AssistantApprovalCard", () => { const details = wrapper.get('[data-test="assistant-approval-details"]'); expect(details.text()).toContain("Details"); }); + + describe("timing (`30` §7.3)", () => { + const PERMISSION_FRAME = { kind: "permission", tool: "o2_search", args: {} }; + const inMs = (ms: number) => cardOf(PERMISSION_FRAME, { expiresAt: Date.now() + ms }); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("drops More time when the user's budget is spent, and says so", () => { + const spent = mountCard({ ...inMs(300_000), userExtendsLeft: 0 }, { extendable: true }); + expect(spent.find('[data-test="assistant-approval-extend"]').exists()).toBe(false); + expect(spent.get('[data-test="assistant-approval-last-extension"]').text()).toBe( + "No more time can be added.", + ); + const left = mountCard({ ...inMs(300_000), userExtendsLeft: 1 }, { extendable: true }); + expect(left.find('[data-test="assistant-approval-extend"]').exists()).toBe(true); + expect(left.find('[data-test="assistant-approval-last-extension"]').exists()).toBe(false); + }); + + it("warns at 60 s and 20 s, once each, and an extension starts over", async () => { + const wrapper = mountCard(inMs(90_000)); + await flushPromises(); + const warnings = () => + (wrapper.emitted("announce") ?? []) + .map(([text]) => String(text)) + .filter((text) => text.includes("Approval expires in")); + await vi.advanceTimersByTimeAsync(30_000); + expect(warnings()).toHaveLength(1); + expect(warnings()[0]).toContain("More time is on the card."); + await vi.advanceTimersByTimeAsync(40_000); + expect(warnings()).toHaveLength(2); + await vi.advanceTimersByTimeAsync(5_000); + expect(warnings()).toHaveLength(2); + await wrapper.setProps({ card: inMs(70_000) }); + await flushPromises(); + expect((wrapper.emitted("announce") ?? []).map(([text]) => String(text)).join()).toContain( + "More time added.", + ); + await vi.advanceTimersByTimeAsync(10_000); + expect(warnings()).toHaveLength(3); + }); + + it("gives no warning for a window it mounted inside: the mount line said the time", async () => { + const wrapper = mountCard(inMs(50_000)); + await flushPromises(); + await vi.advanceTimersByTimeAsync(5_000); + const spoken = (wrapper.emitted("announce") ?? []).map(([text]) => String(text)); + expect(spoken.filter((text) => text.includes("Approval expires in"))).toEqual([]); + }); + + it("reads Expires soon from the 60 s warning on", async () => { + const wrapper = mountCard(inMs(90_000)); + const countdown = () => wrapper.get('[data-test="assistant-approval-countdown"]').text(); + expect(countdown()).toBe("Expires in 1:30"); + await vi.advanceTimersByTimeAsync(31_000); + expect(countdown()).toBe("Expires soon · 0:59"); + }); + }); + + describe("gated, on a server without split budgets (PO A14, B96)", () => { + const frame = { kind: "permission", tool: "o2_search", args: {} }; + + it("keeps Deny and Reply, and puts the reason where Approve was", () => { + const wrapper = mountCard(cardOf(frame, { expiresAt: Date.now() + 300_000 }), { + gated: true, + extendable: true, + }); + expect(wrapper.find('[data-test="assistant-approval-deny"]').exists()).toBe(true); + expect(wrapper.find('[data-test="assistant-approval-reply"]').exists()).toBe(true); + expect(wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-approval-extend"]').exists()).toBe(false); + expect(wrapper.get('[data-test="assistant-approval-gated"]').text()).toContain( + "Approving needs a newer O2 Assistant agent.", + ); + }); + + it("decides the gate when the card mounts, so an upgrade never changes a card being read", async () => { + const wrapper = mountCard(cardOf(frame), { gated: true }); + await wrapper.setProps({ gated: false }); + expect(wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(false); + }); + }); }); diff --git a/web/src/components/ai-assistant/approvals/AssistantApprovalCard.vue b/web/src/components/ai-assistant/approvals/AssistantApprovalCard.vue index 6dd7f2f663..0e631a8699 100644 --- a/web/src/components/ai-assistant/approvals/AssistantApprovalCard.vue +++ b/web/src/components/ai-assistant/approvals/AssistantApprovalCard.vue @@ -57,7 +57,19 @@ along with this program. If not, see . class="tabular-nums" data-test="assistant-approval-countdown" > - {{ t("assistant.approval.expiresIn", { time: clockText }) }} + {{ + t(expiringSoon ? "assistant.approval.expiringSoon" : "assistant.approval.expiresIn", { + time: clockText, + }) + }} + + + {{ t("assistant.approval.lastExtension") }} @@ -184,9 +196,12 @@ along with this program. If not, see .
+ + {{ t("assistant.approval.gateTiming", { product }) }} +
. {{ t("assistant.approval.reply") }} . {{ t("assistant.approval.edit") }} . {{ t("assistant.approval.moreTime") }} .
-
+
(), - { org: "", sending: false, extendable: false, orgName: "", multiOrg: false }, + { org: "", sending: false, extendable: false, gated: false, orgName: "", multiOrg: false }, ); const emit = defineEmits<{ @@ -432,6 +452,7 @@ const emit = defineEmits<{ }>(); const { t } = useI18nTyped(); +const { product } = useAssistantName(); const router = useRouter(); const { mdUp } = useBreakpoint(); const idle = useAssistantIdleFocus(); @@ -456,9 +477,11 @@ const selected = ref(props.card.targets.map((target) => target.id ?? t const mountedAt = ref(Number.POSITIVE_INFINITY); let keydownAt = Number.NEGATIVE_INFINITY; let carriedKey = false; -let warned = false; +// A card that mounts inside a warning's window said its time in the mount announcement. +let warned = new Set(WARN_ATS_MS.filter((at) => initialLeft() <= at)); let unmounted = false; -const initialMs = typeof props.card.expiresAt === "number" ? props.card.expiresAt - Date.now() : 0; +// Decided when the card mounts, so a server upgrade never changes a card under the reader (`30` §7.3). +const gated = props.gated; let timer: ReturnType | null = null; const offs: (() => void)[] = []; @@ -477,9 +500,16 @@ const remainingMs = computed(() => ); const clockText = computed(() => raw(approvalClock(remainingMs.value ?? 0))); // From mount, so the reader never has to race the countdown for it (PO A11, A23). +const extendsSpent = computed(() => props.card.userExtendsLeft === 0); const moreTime = computed( - () => props.extendable && remainingMs.value !== null && remainingMs.value > 0, + () => + props.extendable && + !gated && + !extendsSpent.value && + remainingMs.value !== null && + remainingMs.value > 0, ); +const expiringSoon = computed(() => remainingMs.value !== null && remainingMs.value <= WARN_AT_MS); const targets = computed(() => props.card.targets.map((target) => ({ @@ -594,6 +624,10 @@ const describedBy = computed( const argsText = computed(() => JSON.stringify(props.card.args ?? {}, null, 2)); +function initialLeft(): number { + return typeof props.card.expiresAt === "number" ? props.card.expiresAt - Date.now() : Infinity; +} + function onKeydown(): void { keydownAt = Date.now(); } @@ -611,7 +645,7 @@ function deny(reason: "deny" | "escape"): void { } function approve(): void { - if (nothingSelected.value) return; + if (nothingSelected.value || gated) return; decide({ approved: true, scope: canAlwaysAllow(props.card) ? scope.value : "once", @@ -656,19 +690,23 @@ function announce(moved: boolean): void { } function duration(ms: number): I18nText { - const seconds = Math.ceil(ms / 1000); - if (seconds < 120) return t("assistant.approval.seconds", { count: seconds }, seconds); - const minutes = Math.round(seconds / 60); - return t("assistant.approval.minutes", { count: minutes }, minutes); + return approvalDuration(ms, t); } +// Each warning speaks once per window; an extension starts the window over. function tick(): void { now.value = Date.now(); const ms = remainingMs.value; - if (ms === null || warned || props.sending || initialMs <= LONG_CARD_MS || ms > WARN_AT_MS) - return; - warned = true; - emit("announce", t("assistant.approval.expiresSoon")); + if (ms === null || props.sending) return; + const due = WARN_ATS_MS.find((at) => ms <= at && !warned.has(at)); + if (due === undefined) return; + for (const at of WARN_ATS_MS) if (ms <= at) warned.add(at); + const key = extendsSpent.value + ? "assistant.announce.approvalExpiringFinal" + : "assistant.announce.approvalExpiring"; + const line = t(key, { title: heading.value, duration: duration(ms) }); + const focused = root.value?.contains(document.activeElement); + emit("announce", focused ? line : t(idle.jumpKey(), { line, keys: t(idle.spokenKeys()) })); } onMounted(async () => { @@ -699,6 +737,19 @@ onBeforeUnmount(() => { offs.forEach((off) => off()); }); +watch( + () => props.card.expiresAt, + (next, before) => { + if (typeof next !== "number" || typeof before !== "number" || next <= before) return; + warned = new Set(); + now.value = Date.now(); + emit( + "announce", + t("assistant.announce.approvalExtended", { duration: duration(next - now.value) }), + ); + }, +); + watch( () => props.card.id, () => (selected.value = props.card.targets.map((target) => target.id ?? target.name)), diff --git a/web/src/components/ai-assistant/approvals/AssistantApprovalResult.spec.ts b/web/src/components/ai-assistant/approvals/AssistantApprovalResult.spec.ts index 5fb2aa4e8e..a3747c7889 100644 --- a/web/src/components/ai-assistant/approvals/AssistantApprovalResult.spec.ts +++ b/web/src/components/ai-assistant/approvals/AssistantApprovalResult.spec.ts @@ -55,6 +55,7 @@ function mountRecord( readonly?: boolean; live?: boolean; held?: { value: boolean }; + gated?: boolean; } = {}, ) { const message: ChatMessage = { @@ -75,6 +76,7 @@ function mountRecord( askAgain: vi.fn(), stop: vi.fn(), undo: vi.fn(async () => ({ ok: true }) as const), + gated: () => over.gated === true, }; const wrapper = mount(AssistantApprovalResult, { props: { @@ -102,6 +104,14 @@ describe("AssistantApprovalResult", () => { expect(wrapper.find('[data-test="assistant-approval-result"]').exists()).toBe(false); }); + it("offers no Ask again on an expired card a gated server would only refuse (PO B96)", () => { + const expired = { outcome: { state: "expired" as const, at: Date.now() } }; + const open = mountRecord(expired); + expect(open.wrapper.text()).toContain("Ask again"); + const gated = mountRecord(expired, { gated: true }); + expect(gated.wrapper.text()).not.toContain("Ask again"); + }); + it("is never green before /confirm answers", () => { const { wrapper } = mountRecord({ outcome: { state: "sending", at: Date.now() } }); expect(line(wrapper)).toBe("Sending your decision…"); diff --git a/web/src/components/ai-assistant/approvals/AssistantApprovalResult.vue b/web/src/components/ai-assistant/approvals/AssistantApprovalResult.vue index 99406429d7..25c4a883af 100644 --- a/web/src/components/ai-assistant/approvals/AssistantApprovalResult.vue +++ b/web/src/components/ai-assistant/approvals/AssistantApprovalResult.vue @@ -342,6 +342,7 @@ const action = computed<{ label: t("assistant.approval.result.sendAgain"), run: () => host.sendAgain(current, id), }; + if (state === "expired" && host.gated?.()) return null; if (state === "expired" || (state === "unconfirmed" && !card.value.destructive)) return { id: "ask-again", diff --git a/web/src/components/ai-assistant/approvals/approvals.types.ts b/web/src/components/ai-assistant/approvals/approvals.types.ts index ce8fbc678e..475319318e 100644 --- a/web/src/components/ai-assistant/approvals/approvals.types.ts +++ b/web/src/components/ai-assistant/approvals/approvals.types.ts @@ -27,7 +27,9 @@ import type { /** The card as its component renders it: the transcript's block, plus the pending card's ids and expiry while it waits. */ export type ApprovalCardView = ApprovalBlockData & - Partial>; + Partial< + Pick + >; /** Every state a record renders; `waiting_elsewhere` is a card another tab of this browser holds. */ export type ApprovalResultState = ApprovalOutcome["state"] | "waiting_elsewhere"; @@ -55,6 +57,8 @@ export interface AssistantApprovalsHost { /** Stop on a turn another tab is waiting on. */ stop(sid: Sid): void; undo(sid: Sid, ledgerId: string): Promise; + /** No split budgets: cards are Deny and Reply only, so an expired one offers no Ask again (PO B96). */ + gated?(): boolean; } /** Provided by the shell beside the evidence host. */ diff --git a/web/src/components/ai-assistant/helper/InlineQueryHelper.vue b/web/src/components/ai-assistant/helper/InlineQueryHelper.vue index cd471e9ffb..dd85ea7a20 100644 --- a/web/src/components/ai-assistant/helper/InlineQueryHelper.vue +++ b/web/src/components/ai-assistant/helper/InlineQueryHelper.vue @@ -329,6 +329,8 @@ const FAILURE_LINES: Partial = new Set([ "offline", "rate_limited", + "run_limit_member", + "run_limit_org", "turn_limit", "unreachable", "agent_unavailable", @@ -911,7 +913,7 @@ function failureLine(state: Extract): Line { [isAdmin.value ? "manage-usage" : "ask-admin"], ); } - if (code === "rate_limited" || code === "turn_limit") + if (code === "rate_limited" || code === "turn_limit" || code.startsWith("run_limit_")) return failed( retryWait.value > 0 ? t("assistant.helper.rateLimited", { seconds: retryWait.value }) diff --git a/web/src/components/ai-assistant/runs/AssistantDecisionNotice.spec.ts b/web/src/components/ai-assistant/runs/AssistantDecisionNotice.spec.ts new file mode 100644 index 0000000000..ffcb2e3b24 --- /dev/null +++ b/web/src/components/ai-assistant/runs/AssistantDecisionNotice.spec.ts @@ -0,0 +1,212 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { flushPromises, mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nextTick } from "vue"; + +import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant"; +import AssistantDecisionNotice from "./AssistantDecisionNotice.vue"; +import { fakeRuns, runRow } from "./runsTestKit"; + +const { fake } = vi.hoisted(() => ({ fake: { current: null as any, runs: null as any } })); + +vi.mock("@/composables/assistant", async (importOriginal) => ({ + ...(await importOriginal()), + useAssistantStore: () => fake.current.store, + useAssistantRuns: () => fake.runs.runs, +})); + +const NOW = 5_000_000; + +const decision = (sid: string, over = {}) => + runRow(sid, { + group: "needs_you", + status: "needsApproval", + request: `card-${sid}`, + expiresAt: NOW + 300_000, + ...over, + }); + +function setup(decisions = [decision("a")]) { + fake.current = fakeAssistant(); + fake.runs = fakeRuns(); + fake.runs.decisions.value = decisions; + const wrapper = mount(AssistantDecisionNotice, { + attachTo: document.body, + global: { plugins: [fakeRouter(), fakeVuex()] }, + }); + return { wrapper, store: fake.current.store, runs: fake.runs }; +} + +const notice = () => document.querySelector('[data-test="assistant-decision-notice"]'); +const press = (id: string) => + document.querySelector(`[data-test="assistant-decision-notice-${id}"]`)?.click(); + +describe("AssistantDecisionNotice", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.useRealTimers(); + }); + + it("names the region, has no live role, and never takes focus", async () => { + setup(); + await nextTick(); + const region = notice(); + expect(region?.tagName).toBe("SECTION"); + expect(region?.getAttribute("aria-label")).toBe("O2 Assistant notice"); + expect(region?.getAttribute("aria-live")).toBeNull(); + expect(region?.textContent).toContain("O2 Assistant needs your decision"); + expect(region?.textContent).toContain("Chat a"); + expect(region?.textContent).toContain("Expires in 5:00"); + expect(document.activeElement).toBe(document.body); + }); + + it("stays away when there is no decision", async () => { + setup([]); + await nextTick(); + expect(notice()).toBeNull(); + }); + + it("gives way to the Runs button while a visible container shows another chat", async () => { + const { store } = setup(); + store.state.shell.placement = { kind: "sidebar" }; + store.state.shell.activeSid = "other"; + await nextTick(); + expect(notice()).toBeNull(); + }); + + it("replaces the line for a second request, and Review opens the list", async () => { + const { runs } = setup([decision("a"), decision("b")]); + await nextTick(); + expect(notice()?.textContent).toContain("2 chats need your decision"); + press("review"); + expect(runs.runs.requestOpen).toHaveBeenCalled(); + expect(runs.runs.open).not.toHaveBeenCalled(); + }); + + it("Review opens the one chat on its card", async () => { + const { runs } = setup(); + await nextTick(); + press("review"); + expect(runs.runs.open).toHaveBeenCalledWith(expect.objectContaining({ sid: "a" }), { + focus: "card", + }); + }); + + it("Hide notice leaves the request pending and hands focus back", async () => { + const { wrapper, runs } = setup(); + await nextTick(); + press("hide"); + await nextTick(); + expect(notice()).toBeNull(); + expect(wrapper.emitted("hidden")).toHaveLength(1); + expect(runs.runs.decisions.value).toHaveLength(1); + }); + + it("comes back at each warning even when hidden, and speaks each once", async () => { + const { wrapper } = setup([decision("a", { expiresAt: NOW + 90_000 })]); + await nextTick(); + press("hide"); + await nextTick(); + await vi.advanceTimersByTimeAsync(30_000); + await nextTick(); + expect(notice()?.textContent).toContain("Expires soon · 1:00"); + press("hide"); + await nextTick(); + expect(notice()).toBeNull(); + await vi.advanceTimersByTimeAsync(40_000); + await nextTick(); + expect(notice()?.textContent).toContain("Expires soon · 0:20"); + await vi.advanceTimersByTimeAsync(10_000); + const spoken = (wrapper.emitted("announce") ?? []).map(([text]) => String(text)); + expect(spoken.filter((text) => text.startsWith("Approval expires in"))).toEqual([ + "Approval expires in 60 seconds: Chat a. Review it from the O2 Assistant notice.", + "Approval expires in 20 seconds: Chat a. Review it from the O2 Assistant notice.", + ]); + }); + + it("says when no more time can be added", async () => { + const { wrapper } = setup([decision("a", { expiresAt: NOW + 50_000, extendsLeft: 0 })]); + await vi.advanceTimersByTimeAsync(3_000); + const spoken = (wrapper.emitted("announce") ?? []).map(([text]) => String(text)); + expect(spoken.join()).toContain("No more time can be added. Review it from"); + }); + + it("warns even while a visible container shows another chat", async () => { + const { store } = setup([decision("a", { expiresAt: NOW + 70_000 })]); + store.state.shell.placement = { kind: "sidebar" }; + store.state.shell.activeSid = "other"; + await nextTick(); + expect(notice()).toBeNull(); + await vi.advanceTimersByTimeAsync(10_000); + await nextTick(); + expect(notice()?.textContent).toContain("Expires soon"); + }); + + it("keeps background sentences 3 s apart and drops none", async () => { + const { wrapper } = setup([decision("a"), decision("b")]); + await nextTick(); + expect(wrapper.emitted("announce")).toHaveLength(1); + await vi.advanceTimersByTimeAsync(3_000); + expect(wrapper.emitted("announce")).toHaveLength(2); + }); + + it("leaves the first request's sentence to the header's own light", async () => { + fake.current = fakeAssistant(); + fake.runs = fakeRuns(); + const { store } = fake.current; + const sid = store.createSession({ + origin: { entryKind: "header_button", objectKind: "none", container: "sidebar" }, + thread: "new", + }); + store.state.sessions[sid].pendingApproval = { id: "c1" }; + fake.runs.decisions.value = [decision(sid)]; + const wrapper = mount(AssistantDecisionNotice, { + attachTo: document.body, + global: { plugins: [fakeRouter(), fakeVuex()] }, + }); + await flushPromises(); + expect(wrapper.emitted("announce")).toBeUndefined(); + }); + + it("waits for a modal dialog to close", async () => { + setup(); + const dialog = document.createElement("div"); + dialog.setAttribute("data-o2-dialog", ""); + dialog.setAttribute("data-state", "open"); + document.body.appendChild(dialog); + await vi.advanceTimersByTimeAsync(1_000); + await nextTick(); + expect(notice()).toBeNull(); + dialog.remove(); + await vi.advanceTimersByTimeAsync(1_000); + await nextTick(); + expect(notice()).not.toBeNull(); + }); + + it("leaves when the request does", async () => { + const { runs } = setup(); + await nextTick(); + runs.decisions.value = []; + await nextTick(); + expect(notice()).toBeNull(); + }); +}); diff --git a/web/src/components/ai-assistant/runs/AssistantDecisionNotice.vue b/web/src/components/ai-assistant/runs/AssistantDecisionNotice.vue new file mode 100644 index 0000000000..2de9e41509 --- /dev/null +++ b/web/src/components/ai-assistant/runs/AssistantDecisionNotice.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/web/src/components/ai-assistant/runs/AssistantRunsButton.spec.ts b/web/src/components/ai-assistant/runs/AssistantRunsButton.spec.ts new file mode 100644 index 0000000000..a0eb4df3e6 --- /dev/null +++ b/web/src/components/ai-assistant/runs/AssistantRunsButton.spec.ts @@ -0,0 +1,176 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { flushPromises, mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nextTick } from "vue"; + +import OPopover from "@/lib/overlay/Popover/OPopover.vue"; + +import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant"; +import AssistantRunsButton from "./AssistantRunsButton.vue"; +import { fakeRuns, runRow } from "./runsTestKit"; + +const { fake, md } = vi.hoisted(() => ({ + fake: { current: null as any, runs: null as any }, + md: { value: true }, +})); + +vi.mock("@/composables/useBreakpoint", async () => { + const { computed } = await import("vue"); + return { + default: () => ({ + lgUp: computed(() => md.value), + mdUp: computed(() => md.value), + isMobile: computed(() => !md.value), + isTablet: computed(() => false), + isDesktop: computed(() => md.value), + }), + }; +}); +vi.mock("@/composables/assistant", async (importOriginal) => ({ + ...(await importOriginal()), + useAssistantStore: () => fake.current.store, + useAssistantRuns: () => fake.runs.runs, +})); + +// An open popover closes the previous one, so each spec's wrapper is unmounted, never wiped from the DOM. +const mounted: { unmount(): void }[] = []; + +function mountButton() { + const wrapper = mount(AssistantRunsButton, { + attachTo: document.body, + global: { plugins: [fakeRouter(), fakeVuex()] }, + }); + mounted.push(wrapper); + return wrapper; +} + +function setup(rows = [runRow("a"), runRow("b", { group: "needs_you", status: "needsApproval" })]) { + fake.current = fakeAssistant(); + fake.runs = fakeRuns(rows); + const wrapper = mountButton(); + return { wrapper, store: fake.current.store, runs: fake.runs }; +} + +const button = (wrapper: ReturnType["wrapper"]) => + wrapper.find('[data-test="assistant-runs-button"]'); + +describe("AssistantRunsButton", () => { + beforeEach(() => { + md.value = true; + }); + + afterEach(() => { + mounted.splice(0).forEach((wrapper) => wrapper.unmount()); + }); + + it("stays away while no other chat is listed", () => { + const { wrapper } = setup([]); + expect(button(wrapper).exists()).toBe(false); + }); + + it("starts its name with the number it shows, then counts per signal (WCAG 2.5.3, PO B53)", () => { + const { wrapper } = setup(); + const target = button(wrapper); + expect(target.attributes("aria-label")).toBe("2 other chats: 1 answering · 1 needs you"); + expect(target.attributes("data-state")).toBe("attention"); + expect(target.text()).toContain("2"); + }); + + it("does not count the chat its own container shows", async () => { + const { wrapper, store } = setup(); + store.state.shell.activeSid = "a"; + await nextTick(); + expect(button(wrapper).attributes("aria-label")).toBe("1 other chat: 1 needs you"); + store.state.shell.activeSid = null; + await nextTick(); + expect(button(wrapper).attributes("aria-expanded")).toBe("false"); + }); + + it("opens the list and reports it expanded", async () => { + const { wrapper } = setup(); + await button(wrapper).trigger("click"); + await flushPromises(); + expect(button(wrapper).attributes("aria-expanded")).toBe("true"); + expect(document.querySelector('[data-test="assistant-runs-menu"]')).not.toBeNull(); + }); + + it("closes on a second press, though the popover already closed on the pointer going down", async () => { + const { wrapper } = setup(); + await button(wrapper).trigger("click"); + await flushPromises(); + await button(wrapper).trigger("pointerdown"); + wrapper.findComponent(OPopover).vm.$emit("update:open", false); + await flushPromises(); + await button(wrapper).trigger("click"); + await flushPromises(); + expect(button(wrapper).attributes("aria-expanded")).toBe("false"); + }); + + it("opens on a request from elsewhere, even with nothing else to show", async () => { + const { wrapper, runs } = setup([]); + await runs.runs.requestOpen(); + await flushPromises(); + expect(runs.runs.takeOpenRequest).toHaveBeenCalled(); + expect(button(wrapper).attributes("aria-expanded")).toBe("true"); + }); + + it("takes a request made before it mounted", async () => { + fake.current = fakeAssistant(); + fake.runs = fakeRuns(); + fake.runs.runs.openRequested.value = true; + const wrapper = mountButton(); + await flushPromises(); + expect(button(wrapper).attributes("aria-expanded")).toBe("true"); + }); + + it("uses a sheet below md", async () => { + md.value = false; + const { wrapper } = setup(); + await button(wrapper).trigger("click"); + await flushPromises(); + expect(document.querySelector('[data-test="assistant-runs-sheet-back"]')).not.toBeNull(); + }); + + it("says once that a slot freed while the refusal shows", async () => { + const { wrapper, store, runs } = setup(); + const sid = store.createSession({ + origin: { entryKind: "header_button", objectKind: "none", container: "sidebar" }, + thread: "new", + }); + store.state.shell.activeSid = sid; + store.state.sessions[sid].turn = { + state: "refused", + error: { code: "run_limit_member", limit: 3 }, + }; + store.state.sessions[sid].turnMeta.turnId = "t1"; + runs.active.value = 3; + await nextTick(); + runs.active.value = 2; + await nextTick(); + const region = wrapper.find('[data-test="assistant-runs-status-region"]'); + expect(region.text()).toBe("A chat finished. You can send now."); + }); + + it("stays quiet when a chat ends with no refusal showing", async () => { + const { wrapper, runs } = setup(); + runs.active.value = 3; + await nextTick(); + runs.active.value = 2; + await nextTick(); + expect(wrapper.find('[data-test="assistant-runs-status-region"]').text()).toBe(""); + }); +}); diff --git a/web/src/components/ai-assistant/runs/AssistantRunsButton.vue b/web/src/components/ai-assistant/runs/AssistantRunsButton.vue new file mode 100644 index 0000000000..8808bd3a6b --- /dev/null +++ b/web/src/components/ai-assistant/runs/AssistantRunsButton.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/web/src/components/ai-assistant/runs/AssistantRunsMenu.spec.ts b/web/src/components/ai-assistant/runs/AssistantRunsMenu.spec.ts new file mode 100644 index 0000000000..5b13f109f6 --- /dev/null +++ b/web/src/components/ai-assistant/runs/AssistantRunsMenu.spec.ts @@ -0,0 +1,160 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { flushPromises, mount } from "@vue/test-utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant"; +import AssistantRunsMenu from "./AssistantRunsMenu.vue"; +import { fakeRuns, runRow } from "./runsTestKit"; + +const { fake } = vi.hoisted(() => ({ fake: { current: null as any, runs: null as any } })); + +vi.mock("@/composables/assistant", async (importOriginal) => ({ + ...(await importOriginal()), + useAssistantStore: () => fake.current.store, + useAssistantRuns: () => fake.runs.runs, +})); + +const waiting = runRow("wait", { + group: "needs_you", + status: "needsApproval", + request: "c1", + expiresAt: Date.now() + 240_000, +}); +const answering = runRow("run"); +const done = runRow("done", { group: "ready", status: "ready" }); + +function setup(rows = [waiting, answering, done]) { + fake.current = fakeAssistant(); + fake.runs = fakeRuns(rows); + const wrapper = mount(AssistantRunsMenu, { + attachTo: document.body, + global: { + plugins: [ + fakeRouter([ + { path: "/assistant/:chatId?", name: "assistant", component: { template: "
" } }, + ]), + fakeVuex(), + ], + }, + }); + return { wrapper, store: fake.current.store, runs: fake.runs.runs, source: fake.runs }; +} + +const rowsOf = (wrapper: ReturnType["wrapper"]) => + wrapper.findAll('[data-test="assistant-runs-row-open"]'); + +describe("AssistantRunsMenu", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("states the cap and the summary, zero parts dropped", () => { + const { wrapper, source } = setup(); + source.active.value = 2; + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.get('[data-test="assistant-runs-capacity"]').text()).toBe( + "2 of 3 running chats", + ); + expect(wrapper.get('[data-test="assistant-runs-summary"]').text()).toBe( + "1 needs you · 1 answering", + ); + }); + }); + + it("names each row, with the time left on a pending card", () => { + const { wrapper } = setup(); + const [first, second] = rowsOf(wrapper); + expect(first.attributes("aria-label")).toBe( + "Chat wait, O2 Assistant, Needs your approval, 4 minutes left", + ); + expect(second.attributes("aria-label")).toContain("Chat run, O2 Assistant, Answering"); + }); + + it("offers Stop on an answering row and Review on a waiting one, with the title in their names", async () => { + const { wrapper, runs } = setup(); + const stop = wrapper.get('[data-test="assistant-runs-row-stop"]'); + expect(stop.attributes("aria-label")).toBe("Stop Chat run"); + await stop.trigger("click"); + expect(runs.stop).toHaveBeenCalledWith(answering); + const review = wrapper.get('[data-test="assistant-runs-row-review"]'); + expect(review.attributes("aria-label")).toBe("Review Chat wait"); + await review.trigger("click"); + expect(runs.open).toHaveBeenCalledWith(waiting, expect.objectContaining({ focus: "card" })); + expect(wrapper.emitted("close")).toHaveLength(1); + }); + + it("opens a row on click and marks the chat on screen", async () => { + const { wrapper, runs, store } = setup(); + store.state.shell.activeSid = "run"; + await wrapper.vm.$nextTick(); + expect(wrapper.find('[data-sid="run"]').attributes("data-current")).toBe(""); + expect(wrapper.find('[data-sid="run"]').text()).toContain("This chat"); + await rowsOf(wrapper)[0].trigger("click"); + expect(runs.open).toHaveBeenCalledWith(waiting, expect.objectContaining({ focus: "composer" })); + }); + + it("moves through the rows with the arrow keys, Home and End, with one Tab stop", async () => { + const { wrapper } = setup(); + const rows = rowsOf(wrapper); + expect(rows.map((row) => row.attributes("tabindex"))).toEqual(["0", "-1", "-1"]); + rows[0].element.focus(); + await rows[0].trigger("keydown", { key: "ArrowDown" }); + expect(document.activeElement).toBe(rows[1].element); + await rows[1].trigger("keydown", { key: "End" }); + expect(document.activeElement).toBe(rows[2].element); + await rows[2].trigger("keydown", { key: "Home" }); + expect(document.activeElement).toBe(rows[0].element); + await wrapper.vm.$nextTick(); + expect(rowsOf(wrapper).map((row) => row.attributes("tabindex"))).toEqual(["0", "-1", "-1"]); + }); + + it("clears only a finished row with Delete", async () => { + const { wrapper, runs } = setup(); + const rows = rowsOf(wrapper); + await rows[0].trigger("keydown", { key: "Delete" }); + expect(runs.clear).not.toHaveBeenCalled(); + await rows[2].trigger("keydown", { key: "Delete" }); + expect(runs.clear).toHaveBeenCalledWith("done"); + }); + + it("offers Clear finished only when a finished row is listed", async () => { + const { wrapper, runs } = setup(); + await wrapper.get('[data-test="assistant-runs-clear-finished"]').trigger("click"); + expect(runs.clearFinished).toHaveBeenCalled(); + expect( + setup([answering]).wrapper.find('[data-test="assistant-runs-clear-finished"]').exists(), + ).toBe(false); + }); + + it("shows another org's chat with no way to open it in place (PO B51)", () => { + const { wrapper } = setup([runRow("x", { otherOrg: true, org: "acme", title: null })]); + const row = wrapper.get('[data-test="assistant-runs-row-other-org"]'); + expect(row.text()).toContain("A chat in acme"); + expect(row.text()).toContain("In acme"); + expect(wrapper.find('[data-test="assistant-runs-row-open"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-runs-row-menu"]').exists()).toBe(false); + }); + + it("moves to the full view from the row menu", async () => { + const { wrapper, runs, store } = setup([answering]); + const move = vi.spyOn(store, "moveTo"); + await (wrapper.vm as any).$.setupState.openFull(answering); + await flushPromises(); + expect(runs.open).toHaveBeenCalledWith(answering); + expect(move).toHaveBeenCalledWith({ kind: "full" }); + }); +}); diff --git a/web/src/components/ai-assistant/runs/AssistantRunsMenu.vue b/web/src/components/ai-assistant/runs/AssistantRunsMenu.vue new file mode 100644 index 0000000000..eb0d672c36 --- /dev/null +++ b/web/src/components/ai-assistant/runs/AssistantRunsMenu.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/web/src/components/ai-assistant/runs/runsTestKit.ts b/web/src/components/ai-assistant/runs/runsTestKit.ts new file mode 100644 index 0000000000..68cce61c28 --- /dev/null +++ b/web/src/components/ai-assistant/runs/runsTestKit.ts @@ -0,0 +1,68 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { computed, ref } from "vue"; +import { vi } from "vitest"; + +import type { AssistantRuns, RunRow } from "@/composables/assistant"; + +/** A row as the list builds it; specs name only what they test. */ +export function runRow(sid: string, over: Partial = {}): RunRow { + return { + sid, + org: "default", + title: `Chat ${sid}` as RunRow["title"], + group: "answering", + status: "working", + agent: null, + at: 1_000, + startedAt: 1_000, + turnId: `t-${sid}`, + request: null, + expiresAt: null, + extendsLeft: null, + consent: false, + otherOrg: false, + stale: false, + ...over, + }; +} + +/** The runtime's list with writable rows, so a component spec drives it directly. */ +export function fakeRuns(initial: RunRow[] = []) { + const rows = ref(initial); + const decisions = ref([]); + const active = ref(0); + const requested = ref(false); + const runs = { + rows: computed(() => rows.value), + rail: computed(() => rows.value), + otherOrg: computed(() => rows.value.filter((row) => row.otherOrg)), + cap: computed(() => 3), + active: computed(() => active.value), + needsCount: computed(() => rows.value.filter((row) => row.group === "needs_you").length), + decisions: computed(() => decisions.value), + openRequested: requested, + requestOpen: vi.fn(async () => void (requested.value = true)), + takeOpenRequest: vi.fn(() => void (requested.value = false)), + clear: vi.fn(), + clearFinished: vi.fn(), + stop: vi.fn(async () => undefined), + open: vi.fn(async () => undefined), + light: () => null, + dispose: () => undefined, + } satisfies AssistantRuns; + return { runs, rows, decisions, active }; +} diff --git a/web/src/components/ai-assistant/runs/runsText.spec.ts b/web/src/components/ai-assistant/runs/runsText.spec.ts new file mode 100644 index 0000000000..3fb6073328 --- /dev/null +++ b/web/src/components/ai-assistant/runs/runsText.spec.ts @@ -0,0 +1,76 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; + +import { gt, raw } from "@/types/i18n"; + +import { rowSignal, runAgent, runSignal, runStatus, runsSummary, runTitle } from "./runsText"; +import { runRow } from "./runsTestKit"; + +const counts = (over = {}) => ({ needs: 0, answering: 0, failed: 0, ready: 0, ...over }); + +describe("runsText", () => { + it("picks one signal by priority: needs you, failed, ready, working", () => { + expect(runSignal(counts({ needs: 1, failed: 2, ready: 3, answering: 4 }))).toBe("attention"); + expect(runSignal(counts({ failed: 2, ready: 3 }))).toBe("failed"); + expect(runSignal(counts({ ready: 3, answering: 4 }))).toBe("unread"); + expect(runSignal(counts({ answering: 4 }))).toBe("working"); + }); + + it("gives a row the signal of its group", () => { + expect(rowSignal(runRow("a", { group: "needs_you" }))).toBe("attention"); + expect(rowSignal(runRow("a", { group: "failed" }))).toBe("failed"); + expect(rowSignal(runRow("a", { group: "ready" }))).toBe("unread"); + expect(rowSignal(runRow("a"))).toBe("working"); + }); + + it("words the summary in the order asked, dropping zero parts (PO B53)", () => { + const all = counts({ needs: 2, answering: 1 }); + expect(runsSummary(all, ["answering", "needs"], gt)).toBe("1 answering · 2 need you"); + expect(runsSummary(all, ["needs", "answering"], gt)).toBe("2 need you · 1 answering"); + expect(runsSummary(counts({ needs: 1 }), ["answering", "needs"], gt)).toBe("1 needs you"); + expect(runsSummary(counts(), ["answering", "needs"], gt)).toBe(""); + }); + + it("never leaves a row without a title", () => { + expect(runTitle(runRow("a"), gt)).toBe("Chat a"); + expect(runTitle(runRow("a", { title: null }), gt)).toBe("New chat"); + expect(runTitle(runRow("a", { title: null, otherOrg: true, org: "acme" }), gt)).toBe( + "A chat in acme", + ); + }); + + it("names the agent by its job, with the product for the default one", () => { + expect(runAgent(runRow("a", { agent: "sre-rca" }), "O2", gt)).toBe("Investigator"); + expect(runAgent(runRow("a"), "O2", gt)).toBe("O2"); + }); + + it("words each status with what belongs to it", () => { + expect(runStatus(runRow("a", { startedAt: 1_000 }), 13_000, gt)).toContain("Answering · "); + expect(runStatus(runRow("a", { startedAt: null }), 13_000, gt)).toBe("Answering"); + expect(runStatus(runRow("a", { status: "needsApproval", expiresAt: 76_000 }), 1_000, gt)).toBe( + raw("Needs your approval · 1:15"), + ); + expect(runStatus(runRow("a", { status: "needsApproval" }), 1_000, gt)).toBe( + "Needs your approval", + ); + expect(runStatus(runRow("a", { status: "elsewhere" }), 1_000, gt)).toBe( + "Answering in another tab", + ); + expect(runStatus(runRow("a", { status: "reconnecting" }), 1_000, gt)).toBe("Reconnecting"); + expect(runStatus(runRow("a", { status: "stopped" }), 1_000, gt)).toContain("Stopped · "); + }); +}); diff --git a/web/src/components/ai-assistant/runs/runsText.ts b/web/src/components/ai-assistant/runs/runsText.ts new file mode 100644 index 0000000000..2762eaaa96 --- /dev/null +++ b/web/src/components/ai-assistant/runs/runsText.ts @@ -0,0 +1,80 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { RunRow, RunsCounts } from "@/composables/assistant"; +import { approvalClock, elapsedText, runAgentKey } from "@/composables/assistant"; +import { raw, type I18nText, type TranslateFn } from "@/types/i18n"; +import { formatOrgTime } from "@/utils/formatters"; + +import type { AssistantSignal } from "../common/AssistantStatusDot.types"; + +/** The signal a row, or a button speaking for several, shows. */ +export function runSignal(counts: RunsCounts): AssistantSignal { + if (counts.needs) return "attention"; + if (counts.failed) return "failed"; + if (counts.ready) return "unread"; + return "working"; +} + +/** The one mark of a row; a stopped chat has no failure to show. */ +export function rowSignal(row: RunRow): AssistantSignal { + if (row.group === "needs_you") return "attention"; + if (row.group === "failed") return "failed"; + return row.group === "ready" ? "unread" : "working"; +} + +/** Non-zero parts in the order given, joined the way the list reads them. */ +export function runsSummary( + counts: RunsCounts, + order: (keyof RunsCounts)[], + t: TranslateFn, +): I18nText { + const parts = order + .filter((part) => counts[part] > 0) + .map((part) => t(`assistant.runs.summary.${part}`, { count: counts[part] }, counts[part])); + return raw(parts.join(" · ")); +} + +export function runTitle(row: RunRow, t: TranslateFn): I18nText { + if (row.title) return row.title; + return row.otherOrg + ? t("assistant.runs.chatInOrg", { org: raw(row.org) }) + : t("assistant.thread.newChatTitle"); +} + +export function runAgent(row: RunRow, product: string, t: TranslateFn): I18nText { + return t(`assistant.runs.agent.${runAgentKey(row.agent)}`, { product }); +} + +/** The run-status words of a row, with the elapsed time, countdown or finish time that belongs to them. */ +export function runStatus(row: RunRow, now: number, t: TranslateFn): I18nText { + const word = (status: I18nText, elapsed: string) => + t("assistant.turn.withElapsed", { status, elapsed: raw(elapsed) }); + switch (row.status) { + case "working": + return row.startedAt === null + ? t("assistant.runs.state.answering") + : t("assistant.runs.state.working", { elapsed: elapsedText(now - row.startedAt, t) }); + case "needsApproval": { + const status = t("assistant.runs.state.needsApproval"); + return row.expiresAt === null ? status : word(status, approvalClock(row.expiresAt - now)); + } + case "reconnecting": + case "elsewhere": + return t(`assistant.runs.state.${row.status}`); + default: + return word(t(`assistant.runs.state.${row.status}`), formatOrgTime(row.at)); + } +} diff --git a/web/src/components/ai-assistant/shell/AssistantHeaderButton.spec.ts b/web/src/components/ai-assistant/shell/AssistantHeaderButton.spec.ts index 3ce8cfefd3..272190e7ed 100644 --- a/web/src/components/ai-assistant/shell/AssistantHeaderButton.spec.ts +++ b/web/src/components/ai-assistant/shell/AssistantHeaderButton.spec.ts @@ -22,12 +22,14 @@ import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; import { raw } from "@/types/i18n"; import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant"; +import { fakeRuns, runRow } from "../runs/runsTestKit"; import AssistantHeaderButton from "./AssistantHeaderButton.vue"; import { closeLockedPanel, useLockedPanel } from "./lockedPanel"; -const { fake, openAssistant, track, toast, lg } = vi.hoisted(() => ({ +const { fake, openAssistant, track, toast, lg, otherRuns } = vi.hoisted(() => ({ lg: { value: true }, fake: { current: null as any }, + otherRuns: { current: null as any }, openAssistant: vi.fn(async () => ({ ok: true })), track: vi.fn(), toast: vi.fn(), @@ -50,6 +52,7 @@ vi.mock("@/composables/assistant", async (importOriginal) => ({ useAssistantStore: () => fake.current.store, useAssistantCapability: () => fake.current.handle, useAssistantHistory: () => fake.current.history, + useAssistantRuns: () => otherRuns.current, openAssistant, })); vi.mock("@/services/assistantAnalytics", async (importOriginal) => ({ @@ -61,6 +64,10 @@ vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => ({ toast, })); +beforeEach(() => { + otherRuns.current = fakeRuns().runs; +}); + function setup( gate: GateCode = "available", over: Partial = {}, @@ -373,6 +380,47 @@ describe("AssistantHeaderButton light", () => { }); }); +describe("AssistantHeaderButton other orgs (PO B51)", () => { + const other = (over = {}) => runRow("x", { otherOrg: true, org: "acme", ...over }); + + it("counts another org's chats in the light and lists them on click, never opening one", async () => { + otherRuns.current = fakeRuns([other()]).runs; + const { wrapper } = setup(); + expect(button(wrapper).attributes("data-state")).toBe("working"); + await button(wrapper).trigger("click"); + await flushPromises(); + expect(otherRuns.current.requestOpen).toHaveBeenCalled(); + expect(openAssistant).not.toHaveBeenCalled(); + }); + + it("shows a waiting decision of another org as needs you, with its count", () => { + const waiting = other({ group: "needs_you", status: "needsApproval", request: "x" }); + otherRuns.current = fakeRuns([waiting]).runs; + const { wrapper } = setup(); + expect(button(wrapper).attributes("data-state")).toBe("needs_you"); + expect(button(wrapper).attributes("aria-label")).toContain("1 waiting"); + }); + + it("leaves a light of this org's chats alone", async () => { + otherRuns.current = fakeRuns([other()]).runs; + const { wrapper } = setup("available", {}, { state: "working", sid: "here" }); + await button(wrapper).trigger("click"); + await flushPromises(); + expect(otherRuns.current.requestOpen).not.toHaveBeenCalled(); + expect(openAssistant).toHaveBeenCalled(); + }); + + it("counts one decision once, however many chats wait on it", async () => { + const waiting = { group: "needs_you", status: "needsApproval", request: "c" } as const; + otherRuns.current = fakeRuns([ + runRow("a", waiting), + runRow("b", { ...waiting, request: "d" }), + ]).runs; + const { wrapper } = setup("available", {}, { state: "needs_you", sid: "a" }); + expect(button(wrapper).attributes("aria-label")).toContain("2 waiting"); + }); +}); + describe("AssistantHeaderButton announcements and toasts", () => { const region = (wrapper: any) => wrapper.get('[data-test="assistant-header-status-region"]').text(); diff --git a/web/src/components/ai-assistant/shell/AssistantHeaderButton.vue b/web/src/components/ai-assistant/shell/AssistantHeaderButton.vue index b771b34de4..7eb88f1f5c 100644 --- a/web/src/components/ai-assistant/shell/AssistantHeaderButton.vue +++ b/web/src/components/ai-assistant/shell/AssistantHeaderButton.vue @@ -87,6 +87,11 @@ along with this program. If not, see . ref="region" data-test="assistant-header-status-region" /> + row.group === "needs_you").length; + if (waiting) + return { + ...plain( + "needs_you", + t("assistant.header.needsYouCountName", { ...p, count: waiting }), + t("assistant.header.stateNeedsApproval"), + ), + signal: "attention", + count: waiting, + otherOrg: true, + }; + const name = t("assistant.header.answeringName", p); + return { ...plain("working", name, name), signal: "working", otherOrg: true }; +} + function buttonEl(): HTMLElement | null { return (button.value?.$el as HTMLElement | undefined) ?? null; } @@ -423,6 +456,7 @@ function onClick(): void { } return; } + if (current.otherOrg) return void runs.requestOpen(buttonEl()); openOnLitSession(buttonEl()); } @@ -501,26 +535,20 @@ function toastHidden(): void { for (const session of mine.value) { const turnId = session.turnMeta.turnId; if (!turnId || isShown(session.sid)) continue; - const kind = needsYou(session) - ? "needs_you" - : session.unread && session.turn.state === "done" - ? "ready" - : null; + // A waiting chat speaks through the decision notice, so only a ready answer toasts. + const kind = session.unread && session.turn.state === "done" ? "ready" : null; if (!kind || toasted.has(`${turnId}:${kind}`)) continue; toasted.add(`${turnId}:${kind}`); showToast(session.sid, kind); } } -function showToast(sid: string, kind: "ready" | "needs_you"): void { +function showToast(sid: string, kind: "ready"): void { let opened = false; const title = { title: titleOf(sid) }; toast({ variant: "default", - message: - kind === "ready" - ? t("assistant.header.toastReady", title) - : t("assistant.header.toastNeedsYou", title), + message: t("assistant.header.toastReady", title), timeout: HEADER_TOAST_MS, mask: true, live: "off", diff --git a/web/src/components/ai-assistant/shell/AssistantShell.spec.ts b/web/src/components/ai-assistant/shell/AssistantShell.spec.ts index 9cc9763a2f..361ea3deda 100644 --- a/web/src/components/ai-assistant/shell/AssistantShell.spec.ts +++ b/web/src/components/ai-assistant/shell/AssistantShell.spec.ts @@ -23,6 +23,7 @@ import type { HistoryDeps } from "@/composables/assistant/useAssistantHistory"; import { raw } from "@/types/i18n"; import { fakeAssistant, fakeRouter, fakeVuex, hintBus } from "../__tests__/fakeAssistant"; +import { fakeRuns } from "../runs/runsTestKit"; import AssistantShell, { SHELL_ROUTE_CONTEXT, STEP_ANNOUNCE_MS, @@ -745,6 +746,35 @@ describe("WP-06 conversation wiring", () => { expect(label(again)).toMatch(/^Retry in 20 s/); }); + it("opens the running chats from a member's run-limit refusal", async () => { + const { runs } = fakeRuns(); + setRuntimeHandle({ + runs, + lifecycle: { + signedOutElsewhere: computed(() => false), + otherAccountSignedIn: computed(() => false), + turnLock: () => null, + }, + } as never); + const refused = live({ + state: "refused", + error: { + code: "run_limit_member", + phase: "pre_admission", + retryable: false, + action: "show_running", + limit: 3, + }, + }); + await flushPromises(); + const action = refused.wrapper.get('[data-test="assistant-turn-outcome-action"]'); + expect(action.text()).toBe("Show running chats"); + await action.trigger("click"); + await flushPromises(); + setRuntimeHandle(null); + expect(runs.requestOpen).toHaveBeenCalled(); + }); + it("keeps focus on Stop it when the cancel fails, and never takes focus the reader moved (V7)", async () => { const busy = { code: "turn_busy", @@ -967,10 +997,14 @@ describe("AssistantShell decision cards", () => { args: { items: [{ resource_type: "alert", name: "checkout-latency-old", id: "a1" }] }, }; - async function mountWithCard(card: "approval" | "consent" | "none" = "approval", open = true) { + async function mountWithCard( + card: "approval" | "consent" | "none" = "approval", + open = true, + capability: Partial = {}, + ) { localStorage.clear(); sessionStorage.clear(); - fake.current = fakeAssistant("available"); + fake.current = fakeAssistant("available", capability); const store = fake.current.store; const { approvalOf } = await import("@/composables/assistant/reducer"); const approval = { ...approvalOf(DELETE), id: "t1:1" }; @@ -1031,6 +1065,16 @@ describe("AssistantShell decision cards", () => { expect(hidden.wrapper.find('[data-test="assistant-approval-card"]').exists()).toBe(false); }); + it("gates the card on a server without split budgets, and lets it through with them", async () => { + const older = await mountWithCard(); + expect(older.wrapper.find('[data-test="assistant-approval-gated"]').exists()).toBe(true); + expect(older.wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(false); + older.wrapper.unmount(); + const newer = await mountWithCard("approval", true, { approvalExtendBudgets: true }); + expect(newer.wrapper.find('[data-test="assistant-approval-gated"]').exists()).toBe(false); + expect(newer.wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(true); + }); + it("posts the card's decision through the store, and Reply puts the composer in reply mode", async () => { const { wrapper, store, sid } = await mountWithCard(); const decide = vi.spyOn(store, "decideApproval").mockResolvedValue({} as never); @@ -1173,6 +1217,19 @@ describe("AssistantShell history states", () => { expect(wrapper.find('[data-test="assistant-shell-elsewhere-stop"]').exists()).toBe(true); }); + it("says Reconnecting, never another tab, until a live owner shows (PO B3)", async () => { + const { wrapper, store } = setup(); + const sid = open(store); + const elsewhere = vi.spyOn(fake.current.history, "elsewhere"); + elsewhere.mockReturnValue({ mode: "polling", evidence: false, approval: false, turnId: "t" }); + store.state.sessions[sid].turn = { state: "responding.writing" } as never; + store.state.sessions[sid].turnMeta.ownedByThisTab = false; + await flushPromises(); + expect(wrapper.get('[data-test="assistant-shell-elsewhere-status"]').text()).toBe( + "Reconnecting", + ); + }); + it("says history is unavailable with Retry while the cached transcript stays", async () => { const { wrapper, store } = setup(); const sid = open(store); diff --git a/web/src/components/ai-assistant/shell/AssistantShell.vue b/web/src/components/ai-assistant/shell/AssistantShell.vue index f26acb9a82..186cedfc95 100644 --- a/web/src/components/ai-assistant/shell/AssistantShell.vue +++ b/web/src/components/ai-assistant/shell/AssistantShell.vue @@ -46,6 +46,7 @@ along with this program. If not, see . :container="container" >