diff --git a/web/src/components/ai-assistant/approvals/AssistantApprovalCard.spec.ts b/web/src/components/ai-assistant/approvals/AssistantApprovalCard.spec.ts index d8eaef0391..b7ccd267f8 100644 --- a/web/src/components/ai-assistant/approvals/AssistantApprovalCard.spec.ts +++ b/web/src/components/ai-assistant/approvals/AssistantApprovalCard.spec.ts @@ -15,15 +15,11 @@ import { flushPromises, mount } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { computed, defineComponent, h } from "vue"; import { runEscapeLadder } from "@/composables/assistant"; import { approvalOf } from "@/composables/assistant/reducer"; -import { - resetAssistantShortcutsForTests, - useAssistantShortcuts, -} from "@/composables/assistant/shortcuts"; -import { resetManager } from "@/lib/vue-shortcut-manager/manager"; +import { resetAssistantShortcutsForTests } from "@/composables/assistant/shortcuts"; +import { setRuntimeHandle } from "@/composables/assistant/runtimeHandle"; import { noteAssistantKeydown, resetIdleFocusForTests, @@ -86,6 +82,11 @@ async function keyClick(target: { element: Element }): Promise { await flushPromises(); } +async function openDetails(wrapper: ReturnType): Promise { + await wrapper.get('[data-test="assistant-approval-details"] button').trigger("click"); + await flushPromises(); +} + const buttons = (wrapper: ReturnType) => wrapper.findAll("button, a").map((el) => el.attributes("data-test")); @@ -96,72 +97,44 @@ describe("AssistantApprovalCard", () => { }); afterEach(() => (document.body.innerHTML = "")); - it("is a named group with the tag, the heading, a linked target and Deny · Reply · Approve", async () => { - const wrapper = mountCard(cardOf(DELETE_ONE)); + it("is a named group: the effect, the timer, Deny · Reply · the primary, and nothing else at rest", async () => { + const wrapper = mountCard(cardOf(DELETE_ONE, { expiresAt: Date.now() + 222_000 })); await flushPromises(); const group = wrapper.get('[data-test="assistant-approval-card"]'); expect(group.attributes("role")).toBe("group"); - const heading = wrapper.get('[data-test="assistant-approval-heading"]'); - expect(group.attributes("aria-labelledby")).toBe(heading.attributes("id")); - expect(heading.text()).toBe("Delete 1 alert"); - expect(wrapper.get('[data-test="assistant-approval-needs-ok"]').text()).toBe("Needs your OK"); - expect(wrapper.get('[data-test="assistant-approval-targets"]').text()).toContain( - "checkout-latency-old", - ); - const target = wrapper.get('[data-test="assistant-approval-target-2Yx9alert"]'); - expect(target.text()).toBe("Open"); - expect(target.attributes("aria-label")).toBe("Open checkout-latency-old (opens in a new tab)"); - expect(target.attributes("href")).toContain("/alerts/2Yx9alert"); - expect(target.attributes("target")).toBe("_blank"); - expect(target.attributes("rel")).toContain("noopener"); - const actions = buttons(wrapper).filter((id) => - [ - "assistant-approval-deny", - "assistant-approval-reply", - "assistant-approval-edit", - "assistant-approval-approve", - ].includes(id ?? ""), - ); - expect(actions).toEqual([ + expect(group.attributes("aria-label")).toBe("Needs your OK: delete 1 alert"); + expect(wrapper.get('[data-test="assistant-approval-heading"]').text()).toBe("Delete 1 alert"); + const timer = wrapper.get('[data-test="assistant-approval-countdown"]'); + expect(timer.text()).toBe("3:42"); + expect(timer.attributes("aria-label")).toBe("Expires in 3:42"); + expect(buttons(wrapper)).toEqual([ "assistant-approval-deny", "assistant-approval-reply", "assistant-approval-approve", + undefined, ]); - expect(wrapper.get('[data-test="assistant-approval-approve"]').text()).toBe("Delete 1 alert"); - expect(wrapper.find('[data-test="assistant-approval-scope"]').exists()).toBe(false); - expect(wrapper.get('[data-test="assistant-approval-impact"]').text()).toBe("Cannot be undone"); + expect(wrapper.text()).not.toContain("Reply declines"); }); - it("keeps a neutral border and tag, and takes its primary colour from the assistant roles", async () => { - const wrapper = mountCard(cardOf(PAUSE)); - await flushPromises(); - const classes = wrapper.get('[data-test="assistant-approval-card"]').classes(); - expect(classes).toContain("border-border-default"); - expect(classes.some((c) => c.includes("warning"))).toBe(false); - const tag = wrapper.get('[data-test="assistant-approval-needs-ok"]').classes().join(" "); - expect(tag).toContain("bg-badge-default-soft-bg"); - // `[data-assistant-surface]` remaps the button-primary tokens to the assistant primary role. - const approve = wrapper.get('[data-test="assistant-approval-approve"]').classes(); - expect(approve).toContain("bg-button-primary"); - expect(approve.some((c) => /(^|:)(bg|text|border)-primary-\d/.test(c))).toBe(false); + it("draws the amber edge and no box, and the destructive primary for an irreversible kind", () => { + const wrapper = mountCard(cardOf(DELETE_ONE)); + const group = wrapper.get('[data-test="assistant-approval-card"]'); + expect(group.classes()).toEqual( + expect.arrayContaining(["border-s-2", "border-status-warning-text"]), + ); + expect(group.classes()).not.toContain("rounded-surface"); + expect(wrapper.get('[data-test="assistant-approval-approve"]').classes().join(" ")).toContain( + "destructive", + ); }); - it("takes focus to Deny within one frame from the page, and says it is waiting", async () => { - const wrapper = mountCard(cardOf(DELETE_ONE, { expiresAt: Date.now() + 25_000 })); + it("takes focus to the heading within one frame from the page, and says it is waiting", async () => { + const wrapper = mountCard(cardOf(DELETE_ONE)); await flushPromises(); expect(document.activeElement).toBe( - wrapper.get('[data-test="assistant-approval-deny"]').element, - ); - expect(wrapper.emitted("announce")?.[0]?.[0]).toBe( - "Waiting for your approval: Delete 1 alert. Expires in 25 seconds.", - ); - 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 soon · 0:25"); - expect(countdown.attributes("aria-hidden")).toBe("true"); - expect(wrapper.get('[data-test="assistant-approval-expiry"]').attributes("aria-live")).toBe( - "off", + wrapper.get('[data-test="assistant-approval-heading"]').element, ); + expect(wrapper.emitted("announce")?.[0][0]).toContain("Delete 1 alert"); }); it("never takes focus from the composer textbox; the line says how to reach the card", async () => { @@ -201,65 +174,24 @@ describe("AssistantApprovalCard", () => { expect(wrapper.emitted("decide")).toHaveLength(1); }); - it("denies on Escape inside the card, and leaves an IME candidate alone", async () => { + it("never decides on Escape, and keeps it from closing the panel", async () => { const wrapper = mountCard(cardOf(DELETE_ONE)); await flushPromises(); - const deny = wrapper.get('[data-test="assistant-approval-deny"]').element; - const composing = new KeyboardEvent("keydown", { key: "Escape", isComposing: true }); - Object.defineProperty(composing, "target", { value: deny }); - expect(runEscapeLadder(composing)).toBe(false); - const escape = new KeyboardEvent("keydown", { key: "Escape" }); - Object.defineProperty(escape, "target", { value: deny }); - expect(runEscapeLadder(escape)).toBe(true); - expect(wrapper.emitted("decide")?.[0]?.[0]).toMatchObject({ - decision: { approved: false, reason: "escape" }, - }); - const outside = new KeyboardEvent("keydown", { key: "Escape" }); - Object.defineProperty(outside, "target", { value: document.body }); - expect(runEscapeLadder(outside)).toBe(false); + const deny = wrapper.get('[data-test="assistant-approval-deny"]'); + (deny.element as HTMLElement).focus(); + const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true }); + deny.element.dispatchEvent(event); + expect(runEscapeLadder(event)).toBe(true); + expect(wrapper.emitted("decide")).toBeUndefined(); }); - it("lets Escape close an open scope select first, keeps the card, and declines only on the next Escape", async () => { - resetManager(); - const ladder = mount( - defineComponent({ - setup() { - useAssistantShortcuts({ - gate: computed(() => "available" as const), - assistantCanChat: computed(() => true), - }); - return () => h("div"); - }, - }), - { attachTo: document.body }, - ); - const wrapper = mountCard( - cardOf({ type: "confirmation_required", tool: "bash", kind: "permission", grantable: true }), - ); + it("offers no decision by Escape anywhere, with or without the scope select", async () => { + const wrapper = mountCard(cardOf(PAUSE, { grantable: true, targets: [] })); await flushPromises(); - const trigger = wrapper.get('[data-test="assistant-approval-scope-trigger"]'); - await trigger.trigger("click"); - await flushPromises(); - expect(trigger.attributes("aria-expanded")).toBe("true"); - const escapeOn = () => { - const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true }); - Object.defineProperty(event, "target", { value: trigger.element }); - return event; - }; - expect(runEscapeLadder(escapeOn())).toBe(true); + const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true }); + wrapper.get('[data-test="assistant-approval-card"]').element.dispatchEvent(event); + runEscapeLadder(event); expect(wrapper.emitted("decide")).toBeUndefined(); - // The select closes itself on the same key, as Reka does in the browser. - window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - await flushPromises(); - expect(trigger.attributes("aria-expanded")).toBe("false"); - expect(wrapper.find('[data-test="assistant-approval-card"]').exists()).toBe(true); - expect(wrapper.emitted("decide")).toBeUndefined(); - expect(runEscapeLadder(escapeOn())).toBe(true); - expect(wrapper.emitted("decide")?.[0]?.[0]).toMatchObject({ - decision: { approved: false, reason: "escape" }, - }); - ladder.unmount(); - resetManager(); }); it.each([ @@ -303,58 +235,49 @@ describe("AssistantApprovalCard", () => { expect(wrapper.get('[data-test="assistant-approval-heading"]').text()).toBe(heading); }); - it("shows the changed fields of an update and reads Approve, primary", async () => { + it("keeps the diff in Details and reads Approve, primary", async () => { const wrapper = mountCard( cardOf({ type: "confirmation_required", - tool: "o2_UpdateAlert", + tool: "UpdateDashboard", kind: "update", - targets: [{ type: "alert", name: "checkout-latency" }], - before: { threshold: "1s", name: "same" }, - after: { threshold: "2s", name: "same" }, + targets: [{ type: "dashboard", id: "d1", name: "Checkout" }], + before: { title: "Old" }, + after: { title: "New" }, }), ); await flushPromises(); + expect(wrapper.find('[data-test="assistant-approval-diff"]').exists()).toBe(false); + await openDetails(wrapper); const diff = wrapper.get('[data-test="assistant-approval-diff"]'); - expect(diff.text()).toContain("Change"); - expect(diff.text()).toContain("threshold"); - expect(diff.text()).toContain("1s → 2s"); - expect(diff.text()).not.toContain("same"); - const approve = wrapper.get('[data-test="assistant-approval-approve"]'); - expect(approve.text()).toBe("Update alert"); - expect(approve.classes().join(" ")).not.toContain("destructive"); - const noBefore = mountCard( - cardOf({ - type: "confirmation_required", - tool: "o2_UpdateAlert", - kind: "update", - targets: [{ type: "alert", name: "a" }], - after: { threshold: "2s", org_id: "default", folder: "f1" }, - }), - ); - await flushPromises(); - const oneSided = noBefore.get('[data-test="assistant-approval-diff"]').text(); - expect(oneSided).toContain("Current value unknown → 2s"); - expect(oneSided).not.toContain("org_id"); - expect(oneSided).not.toContain("folder"); + expect(diff.text()).toContain("title"); + expect(diff.get("s").text()).toBe("Old"); + expect(wrapper.get('[data-test="assistant-approval-approve"]').text()).toBe("Update dashboard"); }); - it("reads a toggle as Pause, names the object by its body and shows its one changed field (D3-n)", async () => { - const named = mountCard( - cardOf({ ...PAUSE, args: { ...PAUSE.args, request_body: { name: "cart-5xx-spike" } } }), + it("reads a toggle as Pause, names the object, and states when it ends and who is notified", async () => { + const wrapper = mountCard( + cardOf({ + ...PAUSE, + args: { + ...PAUSE.args, + folder_name: "Payments", + paused_until: 1_800_000_000_000, + destinations: ["#cart-oncall"], + request_body: { name: "cart-5xx-spike" }, + }, + }), ); await flushPromises(); - expect(named.get('[data-test="assistant-approval-heading"]').text()).toBe( + expect(wrapper.get('[data-test="assistant-approval-heading"]').text()).toBe( "Pause alert cart-5xx-spike", ); - expect(named.get('[data-test="assistant-approval-approve"]').text()).toBe("Pause alert"); - expect(named.get('[data-test="assistant-approval-diff"]').text()).toContain( - "enabledCurrent value unknown → false", - ); - expect(named.find('[data-test="assistant-approval-meta"]').exists()).toBe(false); - const resume = mountCard(cardOf({ ...PAUSE, args: { ...PAUSE.args, value: "true" } })); - await flushPromises(); - expect(resume.get('[data-test="assistant-approval-heading"]').text()).toBe("Resume alert"); + const meta = wrapper.get('[data-test="assistant-approval-meta"]').text(); + expect(meta).toMatch(/^Until .* · Payments$/); + expect(wrapper.text()).not.toContain("#cart-oncall"); + await openDetails(wrapper); + expect(wrapper.get('[data-test="assistant-approval-diff"]').text()).toContain("#cart-oncall"); + expect(wrapper.get('[data-test="assistant-approval-approve"]').text()).toBe("Pause alert"); }); it("never puts a raw id in the heading: an unresolved name is said once, shortened, in the meta line", async () => { @@ -382,85 +305,59 @@ describe("AssistantApprovalCard", () => { expect(one.find('[data-test="assistant-approval-meta"]').exists()).toBe(false); }); - it("hands a single alert change to the alert editor, resolving the card first (PO A9)", async () => { + it("offers Finish in Alerts inside Details, resolving the card first (PO A9)", async () => { const router = fakeRouter(routes); + const push = vi.spyOn(router, "push"); const wrapper = mount(AssistantApprovalCard, { props: { card: cardOf(PAUSE), org: "default" }, attachTo: document.body, global: { plugins: [router] }, }); await flushPromises(); - const edit = wrapper.get('[data-test="assistant-approval-edit-in-editor"]'); - expect(edit.text()).toBe("Edit in alert editor"); - expect(buttons(wrapper).indexOf("assistant-approval-edit-in-editor")).toBeLessThan( - buttons(wrapper).indexOf("assistant-approval-approve"), - ); - await press(edit); - expect(wrapper.emitted("decide")?.[0]?.[0]).toEqual({ + expect(wrapper.find('[data-test="assistant-approval-finish-in"]').exists()).toBe(false); + await openDetails(wrapper); + const finish = wrapper.get('[data-test="assistant-approval-finish-in"]'); + expect(finish.text()).toBe("Finish in Alerts"); + await press(finish); + expect(wrapper.emitted("decide")?.[0][0]).toEqual({ decision: { approved: false, reason: "edited" }, approvalId: "t1:1", }); - expect(router.currentRoute.value.name).toBe("alertList"); - expect(router.currentRoute.value.query).toMatchObject({ - action: "update", - alert_id: "2Yx9alertcart", - org_identifier: "default", + expect(push).toHaveBeenCalled(); + }); + + it("keeps only More time while the composer writes the reply (A18 overruled)", async () => { + setRuntimeHandle({ + store: { + state: { sessions: { s1: { composer: { mode: "reply", replyApprovalId: "t1:1" } } } }, + }, + } as never); + const wrapper = mountCard(cardOf(PAUSE, { expiresAt: Date.now() + 60_000 }), { + extendable: true, }); - const batch = mountCard(cardOf(DELETE_ONE)); await flushPromises(); - expect(batch.find('[data-test="assistant-approval-edit-in-editor"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-approval-deny"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-approval-extend"]').exists()).toBe(true); + expect(wrapper.find('[data-test="assistant-approval-countdown"]').exists()).toBe(true); + setRuntimeHandle(null); }); - it("states Reply's consequence under the actions and describes Reply with it (PO A18)", async () => { - const wrapper = mountCard(cardOf(DELETE_ONE)); - await flushPromises(); - const consequence = wrapper.get('[data-test="assistant-approval-reply-consequence"]'); - expect(consequence.text()).toBe( - "Reply declines this request; the assistant can answer or ask again.", - ); - expect( - wrapper.get('[data-test="assistant-approval-reply"]').attributes("aria-describedby"), - ).toBe(consequence.attributes("id")); - expect(wrapper.get('[data-test="assistant-approval-reply"]').text()).toBe("Reply"); - }); - - it("offers a scope only when the server says it may be granted, never on a destructive card", async () => { - const grantable = mountCard( - cardOf({ type: "confirmation_required", tool: "bash", kind: "permission", grantable: true }), - ); - await flushPromises(); - expect(grantable.find('[data-test="assistant-approval-scope"]').exists()).toBe(true); - const destructive = mountCard( - cardOf({ - ...DELETE_ONE, - kind: "delete", - grantable: true, - targets: [{ type: "alert", name: "a" }], - }), - ); - await flushPromises(); - expect(destructive.find('[data-test="assistant-approval-scope"]').exists()).toBe(false); - const update = { + it("offers a scope in Details only when the server says it may be granted", async () => { + const run = cardOf({ type: "confirmation_required", - tool: "o2_UpdateAlert", - kind: "update", + tool: "RunThing", grantable: true, - after: { threshold: "2s" }, - }; - const single = mountCard(cardOf({ ...update, targets: [{ type: "alert", name: "a" }] })); + args: { command: "ls" }, + }); + const wrapper = mountCard(run); await flushPromises(); - expect(single.find('[data-test="assistant-approval-scope"]').exists()).toBe(false); - const several = mountCard( - cardOf({ - ...update, - targets: [ - { type: "alert", name: "a" }, - { type: "alert", name: "b" }, - ], - }), - ); - await flushPromises(); - expect(several.find('[data-test="assistant-approval-scope"]').exists()).toBe(true); + expect(wrapper.find('[data-test="assistant-approval-scope"]').exists()).toBe(false); + await openDetails(wrapper); + expect(wrapper.find('[data-test="assistant-approval-scope"]').exists()).toBe(true); + const destructive = mountCard(cardOf(DELETE_ONE, { grantable: true })); + await openDetails(destructive); + expect(destructive.find('[data-test="assistant-approval-scope"]').exists()).toBe(false); }); it("plans a batch with tick boxes, and Approve follows the ticked items", async () => { @@ -494,26 +391,19 @@ describe("AssistantApprovalCard", () => { await press(wrapper.get('[data-test="assistant-plan-item-1"] button')); await press(wrapper.get('[data-test="assistant-plan-item-3"] button')); await flushPromises(); - expect(wrapper.get('[data-test="assistant-approval-nothing"]').text()).toBe("Nothing selected"); expect(approve().attributes("aria-disabled") ?? approve().attributes("disabled")).toBeDefined(); }); - it("keeps its buttons, unavailable but focusable, beside 'Sending your decision…'", async () => { + it("keeps its buttons, unavailable but focusable, under aria-busy while sending", async () => { const wrapper = mountCard(cardOf(DELETE_ONE), { sending: true }); await flushPromises(); - expect(wrapper.get('[data-test="assistant-approval-sending"]').text()).toBe( - "Sending your decision…", + expect(wrapper.get('[data-test="assistant-approval-card"]').attributes("aria-busy")).toBe( + "true", ); - for (const id of ["deny", "reply", "approve"]) { - const button = wrapper.get(`[data-test="assistant-approval-${id}"]`); - expect(button.attributes("aria-disabled")).toBe("true"); - expect(button.attributes("disabled")).toBeUndefined(); - } - const approve = wrapper.get('[data-test="assistant-approval-approve"]'); - (approve.element as HTMLElement).focus(); - await press(approve); + const deny = wrapper.get('[data-test="assistant-approval-deny"]'); + expect(deny.attributes("aria-disabled")).toBe("true"); + await press(deny); expect(wrapper.emitted("decide")).toBeUndefined(); - expect(document.activeElement).toBe(approve.element); }); it("swallows Escape while its decision is on its way, so the panel stays open", async () => { @@ -527,25 +417,18 @@ describe("AssistantApprovalCard", () => { expect(wrapper.emitted("decide")).toBeUndefined(); }); - it("shows what will run on its face: the question and the command, read with the card", async () => { + it("keeps the command on the face of a CLI card, and a permission's own question", async () => { const wrapper = mountCard( cardOf({ type: "confirmation_required", - tool: "acme-cli", - message: "Run this on prod?", - args: { command: "kubectl delete pod checkout-7f9 --namespace prod" }, + tool: "Shell", + message: "Run this?", + args: { command: "kubectl get pods" }, }), ); await flushPromises(); - const question = wrapper.get('[data-test="assistant-approval-question"]'); - const command = wrapper.get('[data-test="assistant-approval-command"]'); - expect(question.text()).toBe("Run this on prod?"); - expect(command.text()).toBe("kubectl delete pod checkout-7f9 --namespace prod"); - const described = wrapper - .get('[data-test="assistant-approval-card"]') - .attributes("aria-describedby"); - expect(described).toContain(question.attributes("id")); - expect(described).toContain(command.attributes("id")); + expect(wrapper.get('[data-test="assistant-approval-command"]').text()).toBe("kubectl get pods"); + expect(wrapper.get('[data-test="assistant-approval-question"]').text()).toBe("Run this?"); }); it("names the card it was made on in every decision", async () => { @@ -625,15 +508,14 @@ describe("AssistantApprovalCard", () => { 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.", + it("drops More time when the user's budget is spent", () => { + const wrapper = mountCard( + cardOf(PAUSE, { expiresAt: Date.now() + 60_000, userExtendsLeft: 0 }), + { + extendable: true, + }, ); - 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); + expect(wrapper.find('[data-test="assistant-approval-extend"]').exists()).toBe(false); }); it("warns at 60 s and 20 s, once each, and an extension starts over", async () => { @@ -667,30 +549,31 @@ describe("AssistantApprovalCard", () => { 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"); + it("weighs the bare timer in the last 60 s", async () => { + const wrapper = mountCard(cardOf(PAUSE, { expiresAt: Date.now() + 48_000 })); + await flushPromises(); + const timer = wrapper.get('[data-test="assistant-approval-countdown"]'); + expect(timer.text()).toBe("0:48"); + expect(timer.classes()).toContain("font-medium"); }); }); 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 }), { + it("keeps Deny and Reply and the timer, says where to finish, with Details open", async () => { + const wrapper = mountCard(cardOf(PAUSE, { expiresAt: Date.now() + 60_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); + await flushPromises(); 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.", + expect(wrapper.find('[data-test="assistant-approval-countdown"]').exists()).toBe(true); + expect(wrapper.get('[data-test="assistant-approval-gated"]').text()).toBe( + "Approvals aren't available on this server. Finish this in Alerts.", ); + expect(wrapper.find('[data-test="assistant-approval-finish-in"]').exists()).toBe(true); }); it("decides the gate when the card mounts, so an upgrade never changes a card being read", async () => { @@ -699,4 +582,57 @@ describe("AssistantApprovalCard", () => { expect(wrapper.find('[data-test="assistant-approval-approve"]').exists()).toBe(false); }); }); + + describe("question", () => { + const question = (over: Partial = {}) => + cardOf( + { + type: "confirmation_required", + id: "q1", + tool: "ask_user", + kind: "question", + message: "Which folder should the alert go in?", + options: [ + { id: "payments", label: "Payments" }, + { id: "platform", label: "Platform" }, + ], + allow_other: true, + }, + { id: "q1", expiresAt: Date.now() + 580_000, ...over }, + ); + + it("asks its question with the choices, Other answer and More time, and no Deny", async () => { + const wrapper = mountCard(question(), { extendable: true }); + await flushPromises(); + expect(wrapper.get('[data-test="assistant-approval-card"]').attributes("aria-label")).toBe( + "Needs your answer: Which folder should the alert go in?", + ); + expect(wrapper.get('[data-test="assistant-approval-heading"]').text()).toBe( + "Which folder should the alert go in?", + ); + expect(buttons(wrapper)).toEqual([ + "assistant-question-option-payments", + "assistant-question-option-platform", + "assistant-question-other", + "assistant-approval-extend", + ]); + }); + + it("sends a choice at once, and Other answer goes to the composer", async () => { + const wrapper = mountCard(question()); + await flushPromises(); + await press(wrapper.get('[data-test="assistant-question-option-payments"]')); + expect(wrapper.emitted("decide")?.[0][0]).toEqual({ + decision: { + approved: false, + reason: "answer", + answer: "payments", + answerLabel: "Payments", + }, + approvalId: "q1", + }); + await press(wrapper.get('[data-test="assistant-question-other"]')); + expect(wrapper.emitted("reply")).toHaveLength(1); + }); + }); }); diff --git a/web/src/components/ai-assistant/approvals/AssistantApprovalCard.vue b/web/src/components/ai-assistant/approvals/AssistantApprovalCard.vue index 0e631a8699..20b2631c9c 100644 --- a/web/src/components/ai-assistant/approvals/AssistantApprovalCard.vue +++ b/web/src/components/ai-assistant/approvals/AssistantApprovalCard.vue @@ -27,50 +27,35 @@ along with this program. If not, see . />
-
- +

{{ heading }}

- - - - {{ t("assistant.approval.lastExtension") }} - + {{ clockText }} +
{{ t("assistant.approval.askedElsewhere") }} @@ -85,140 +70,78 @@ along with this program. If not, see . > {{ metaLine }} -

{{ card.question }}

-
- {{ t("assistant.approval.command") }} - {{ card.command }} -
- -
-
    -
  • - {{ target.label }} - - {{ t("assistant.approval.open") }} - -
  • -
- -
    -
  • - {{ target.label }} -
  • -
-
-
- -
{{ card.command }} - - {{ - card.kind === "create" - ? t("assistant.approval.newValues") - : t("assistant.approval.change") - }} - -
- -
-
- -
- {{ t("assistant.approval.effect") }} -
    -
  • {{ line }}
  • -
-
- -

- {{ t("assistant.approval.expiresIn", { time: clockText }) }} -

- - + {{ gateLine }} + -
- - {{ t("assistant.approval.gateTiming", { product }) }} - -
+
+ + diff --git a/web/src/components/ai-assistant/evidence/evidenceText.spec.ts b/web/src/components/ai-assistant/evidence/evidenceText.spec.ts new file mode 100644 index 0000000000..2853773c81 --- /dev/null +++ b/web/src/components/ai-assistant/evidence/evidenceText.spec.ts @@ -0,0 +1,51 @@ +// 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 { queryFragments } from "./evidenceText"; + +describe("queryFragments", () => { + it("shows what tells each query from its siblings, verbatim", () => { + expect( + queryFragments([ + "SELECT status, count(*) FROM app_logs WHERE service='cart' AND status>=500 GROUP BY status", + "SELECT msg, count(*) FROM app_logs WHERE service='cart' AND msg LIKE '%redis%'", + "SELECT region, count(*) FROM app_logs WHERE service='cart' AND status>=500 GROUP BY region", + "SELECT count(*) FROM app_logs WHERE service='redis'", + "SELECT p99(latency) FROM app_logs WHERE service='cart'", + ]), + ).toEqual([ + "status>=500 GROUP BY status", + "msg LIKE '%redis%'", + "status>=500 GROUP BY region", + "service='redis'", + "p99(latency)", + ]); + }); + + it("keeps a lone query's whole filter, and an AND inside quotes or brackets", () => { + expect(queryFragments(["SELECT * FROM t WHERE a=1 AND b=2"])).toEqual(["a=1 AND b=2"]); + expect(queryFragments(["SELECT * FROM t WHERE (a AND b) AND msg='x AND y'"])).toEqual([ + "(a AND b) AND msg='x AND y'", + ]); + }); + + it("leaves text it cannot read as SQL alone", () => { + expect(queryFragments(["rate(http_requests_total[5m])"])).toEqual([ + "rate(http_requests_total[5m])", + ]); + }); +}); diff --git a/web/src/components/ai-assistant/evidence/evidenceText.ts b/web/src/components/ai-assistant/evidence/evidenceText.ts index 75b42b42a8..2e53b44976 100644 --- a/web/src/components/ai-assistant/evidence/evidenceText.ts +++ b/web/src/components/ai-assistant/evidence/evidenceText.ts @@ -15,7 +15,12 @@ import type { ComputedRef, InjectionKey } from "vue"; -import { stepLabelOf, type QueryRef, type StepFacts } from "@/composables/assistant/pure"; +import { + answerDataAt, + stepLabelOf, + type QueryRef, + type StepFacts, +} from "@/composables/assistant/pure"; import { raw, type I18nText, type TranslateFn } from "@/types/i18n"; import { formatCount, formatElapsed, formatOrgRange } from "@/utils/formatters"; @@ -28,6 +33,21 @@ const LANGUAGE_LABELS: Record = { const QUERY_KINDS: ReadonlySet = new Set(["query", "promql", "traces"]); +const MINUTE_MS = 60_000; + +const HOUR_MS = 3_600_000; + +// A window that ended this close to the send is a relative one. +const NOW_SLACK_MS = 2 * MINUTE_MS; + +// Up to two days an age reads in hours, then in days. +const HOURS_SHOWN = 48; + +const SQL_SHAPE = + /^SELECT\s+([\s\S]+?)\s+FROM\s+\S+(?:\s+WHERE\s+([\s\S]+?))?(\s+(?:GROUP|ORDER|LIMIT|HAVING)\b[\s\S]*)?;?$/i; + +const AND_AT = /^\sAND\s$/i; + /** One answer's claim numbers by `call_id`, so prose chips and findings chips agree. */ export const claimNumbersKey: InjectionKey>> = Symbol("claimNumbers"); @@ -49,6 +69,53 @@ export function explorerLabel(explorer: "logs" | "metrics" | "traces", t: Transl return t("common.logs"); } +/** The question line's range: "Last 1 h" when the window ended as it was asked, else its clock range. */ +export function lastRangeText( + window: { from: number; to: number }, + sentAt: number | undefined, + t: TranslateFn, +): I18nText { + const minutes = Math.round((window.to - window.from) / MINUTE_MS); + if (sentAt === undefined || Math.abs(sentAt - window.to) > NOW_SLACK_MS || minutes < 1) + return clockRangeText(window); + return minutes % 60 === 0 + ? t("assistant.turn.lastHours", { n: minutes / 60 }) + : t("assistant.turn.lastMinutes", { n: minutes }); +} + +/** The clock range of a window without its zone: "14:06–15:06". */ +export function clockRangeText(range: { from: number; to: number }): I18nText { + return raw(formatOrgRange(range.from, range.to).replace(/\s\S+$/, "")); +} + +/** "Data from 3 h ago": the age of the newest data the steps read. */ +export function dataAgeText(steps: StepFacts[], now: number, t: TranslateFn): I18nText { + const at = answerDataAt(steps); + const ms = at === null ? 0 : Math.max(0, now - at); + const hours = Math.floor(ms / HOUR_MS); + const age = + hours >= HOURS_SHOWN + ? t("assistant.turn.ageDays", { n: Math.floor(hours / 24) }) + : t("assistant.turn.ageHours", { n: Math.max(hours, 1) }); + return t("assistant.turn.dataAge", { age }); +} + +/** The part of each query that tells it from its siblings, verbatim from the SQL; other languages stay whole. */ +export function queryFragments(queries: string[]): string[] { + const parts = queries.map(sqlParts); + const seen = new Map(); + for (const part of parts) + for (const term of new Set(part?.terms ?? [])) seen.set(term, (seen.get(term) ?? 0) + 1); + const [shared, count] = [...seen].sort((a, b) => b[1] - a[1])[0] ?? ["", 0]; + // The filter most queries share is the chat's subject, not what this query asked. + const common = queries.length > 1 && count * 2 > queries.length ? shared : null; + return parts.map((part, index) => { + if (!part) return queries[index]; + const own = part.terms.filter((term) => term !== common).join(" AND "); + return [own, part.tail].filter(Boolean).join(" ") || part.select || queries[index]; + }); +} + /** A trail row: what the step did, then its facts, composed from structured fields. */ export function stepLine(step: StepFacts, t: TranslateFn): I18nText { if (step.state === "running") { @@ -108,7 +175,16 @@ export function stepName(step: StepFacts, t: TranslateFn): I18nText { return t("assistant.steps.rowName", { step: stepLine(step, t), state }); } -/** The verb and window of a step, the claim chip's and a footnote's label. */ +/** A citation's accessible name: "Citation 1, query 1, 3 rows". */ +export function citationName(n: number, step: StepFacts, t: TranslateFn): I18nText { + const rows = + step.rows !== null + ? t("assistant.steps.rows", { count: formatCount(step.rows) }, step.rows) + : stepVerb(step, t); + return t("assistant.claim.citation", { n, rows }); +} + +/** The verb and window of a step, a footnote's label. */ export function stepCitation(step: StepFacts, t: TranslateFn): I18nText { return joined([stepVerb(step, t), step.range ? rangeText(step.range) : null]); } @@ -140,6 +216,31 @@ function stepFactsText(step: StepFacts, t: TranslateFn): (I18nText | null)[] { return [took]; } +// Splits on the WHERE's top-level ANDs; a quoted or bracketed AND stays inside its term. +function sqlParts(sql: string): { select: string; terms: string[]; tail: string } | null { + const shape = SQL_SHAPE.exec(sql.trim()); + if (!shape) return null; + const [, select, where = "", tail = ""] = shape; + const terms: string[] = []; + let depth = 0; + let quote = ""; + let start = 0; + for (let i = 0; i < where.length; i++) { + const ch = where[i]; + if (quote) quote = ch === quote ? "" : quote; + else if (ch === "'" || ch === '"') quote = ch; + else if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + else if (depth === 0 && AND_AT.test(where.slice(i, i + 5))) { + terms.push(where.slice(start, i).trim()); + start = i + 5; + i += 4; + } + } + terms.push(where.slice(start).trim()); + return { select: select.trim(), terms: terms.filter(Boolean), tail: tail.trim() }; +} + // Facts are separate values of one row, never one sentence split across keys. function joined(parts: (I18nText | null | undefined)[]): I18nText { return raw(parts.filter((part): part is I18nText => !!part).join(" · ")); diff --git a/web/src/components/ai-assistant/evidence/revealStep.ts b/web/src/components/ai-assistant/evidence/revealStep.ts index e54c8175cd..595145466f 100644 --- a/web/src/components/ai-assistant/evidence/revealStep.ts +++ b/web/src/components/ai-assistant/evidence/revealStep.ts @@ -18,22 +18,23 @@ import { nextTick } from "vue"; /** How long an opened step keeps its focus ring. */ export const STEP_HIGHLIGHT_MS = 2_000; -/** Fired on the answer so a trail without a store (a shared page) expands the step itself. */ +/** Fired on the answer so its queries line opens before the row is looked for. */ export const OPEN_STEP_EVENT = "o2-assistant-open-step"; -const HIGHLIGHT = ["ring-2", "ring-border-strong"]; +const HIGHLIGHT = ["ring-2", "ring-assistant-focus-ring", "bg-surface-accent-hover"]; const timers = new WeakMap>(); -/** After the store expanded a step: scroll its header into view, focus it and ring it for two seconds. */ +/** Opens the answer's queries line, then scrolls the step's row into view, focuses it and rings it for two seconds. */ export async function revealStep(from: HTMLElement | null, callId: string): Promise { const scope = from?.closest("article") ?? from?.ownerDocument; scope?.dispatchEvent(new CustomEvent(OPEN_STEP_EVENT, { detail: { callId } })); await nextTick(); // Compared, not put in a selector, so no call id can break or widen the query. - const header = Array.from(scope?.querySelectorAll("[data-step-header]") ?? []).find( + const found = Array.from(scope?.querySelectorAll("[data-step-header]") ?? []).find( (candidate) => candidate.dataset.stepHeader === callId, ); + const header = found?.closest("button") ?? found; if (!header) return false; header.scrollIntoView?.({ block: "nearest" }); header.focus(); diff --git a/web/src/components/ai-assistant/evidence/useStepActions.ts b/web/src/components/ai-assistant/evidence/useStepActions.ts deleted file mode 100644 index 13615479b4..0000000000 --- a/web/src/components/ai-assistant/evidence/useStepActions.ts +++ /dev/null @@ -1,106 +0,0 @@ -// 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, inject, type ComputedRef } from "vue"; -import { useRouter } from "vue-router"; - -import { assistantEvidenceHostKey, type Sid, type StepFacts } from "@/composables/assistant/pure"; -import { trackAssistant } from "@/services/assistantAnalytics"; -import { raw, useI18nTyped } from "@/types/i18n"; -import { explorerOf, explorerTarget } from "@/utils/assistant/explorerRoutes"; -import { copyToClipboard } from "@/utils/clipboard"; - -import { revealStep } from "./revealStep"; - -/** Where a step action was taken, as `ai_evidence_action` names it. */ -export type StepActionSurface = "trail" | "query" | "claim"; - -export interface StepActions { - /** A shared page or a reader who prefers it follows the link in a new tab. */ - opensElsewhere: ComputedRef; - hrefOf(step: StepFacts): string | undefined; - open(step: StepFacts, event: MouseEvent): Promise; - copy(step: StepFacts): Promise; - showInSteps(step: StepFacts, from: HTMLElement | null): Promise; -} - -/** Open, copy and "Show in steps" for one step's query, shared by the trail, the Queries group and the claim peek. */ -export function useStepActions( - sid: () => Sid | null, - readonly: () => boolean, - surface: StepActionSurface, -): StepActions { - const { t } = useI18nTyped(); - const router = useRouter(); - const host = readonly() ? null : inject(assistantEvidenceHostKey, null); - const opensElsewhere = computed(() => !sid() || !host || host.prefersNewTab()); - - function hrefOf(step: StepFacts): string | undefined { - if (!step.query || !router) return undefined; - return router.resolve(explorerTarget(step.query, explorerOf(step.query), String(Date.now()))) - .href; - } - - // A shared page follows the link itself; a live chat opens through the applier, which loads the query and never runs it. - async function open(step: StepFacts, event: MouseEvent): Promise { - if (!step.query) return; - trackAssistant("ai_evidence_action", { - action: surface === "trail" ? "open_step_logs" : "open_explorer", - block_kind: surface, - }); - const current = sid(); - if (!current || !host) return; - if (event.ctrlKey || event.metaKey || event.shiftKey || event.button === 1) return; - event.preventDefault(); - const result = await host.dispatch(current, { - kind: "open_in_explorer", - query: step.query, - explorer: explorerOf(step.query), - }); - if (!result.ok && result.reason === "other_org") - host.announce(current, t("assistant.evidence.otherOrg", { org: raw(step.query.org) })); - } - - async function copy(step: StepFacts): Promise { - if (!step.query) return; - trackAssistant("ai_evidence_action", { - action: surface === "trail" ? "copy_step_query" : "copy_query", - block_kind: surface, - }); - const current = sid(); - if (!current || !host) { - await copyToClipboard(step.query.text, t, { successMessage: t("assistant.evidence.copied") }); - return; - } - const result = await host.dispatch(current, { - kind: "copy", - text: step.query.text, - what: "query", - }); - host.announce( - current, - result.ok ? t("assistant.evidence.copied") : t("assistant.client.copyManually"), - ); - } - - async function showInSteps(step: StepFacts, from: HTMLElement | null): Promise { - trackAssistant("ai_evidence_action", { action: "open_claim_step", block_kind: surface }); - const current = sid(); - if (current && host) await host.dispatch(current, { kind: "open_step", callId: step.callId }); - await revealStep(from, step.callId); - } - - return { opensElsewhere, hrefOf, open, copy, showInSteps }; -} diff --git a/web/src/components/ai-assistant/transcript/AssistantFeedback.spec.ts b/web/src/components/ai-assistant/transcript/AssistantFeedback.spec.ts index c9dc696559..1142842593 100644 --- a/web/src/components/ai-assistant/transcript/AssistantFeedback.spec.ts +++ b/web/src/components/ai-assistant/transcript/AssistantFeedback.spec.ts @@ -135,15 +135,37 @@ describe("AssistantFeedback", () => { }); }); - it("copies the chat id from the reason popover and shows its first eight characters", async () => { - const { wrapper, host, sid } = mountBar(); - await wrapper.get('[data-test="assistant-feedback-down"]').trigger("click"); + it("asks whether this was the cause on findings, and sends the answer as a cause rating", async () => { + const { wrapper, host, sid } = mountBar( + {}, + { + ...done, + contentBlocks: [ + ...(done.contentBlocks ?? []), + { + type: "findings", + findings: { + likelyCause: "Redis", + confidence: "medium", + evidence: [], + ruledOut: [], + unknowns: [], + nextSteps: [], + }, + }, + ], + }, + ); + const group = wrapper.get('[data-test="assistant-feedback"]'); + expect(group.attributes("aria-label")).toBe("Was this the cause?"); + expect(wrapper.get('[data-test="assistant-feedback-cause-word"]').classes()).toContain( + "hidden", + ); + await wrapper.get('[data-test="assistant-feedback-up"]').trigger("click"); await flushPromises(); - const copy = document.querySelector('[data-test="assistant-copy-chat-id"]') as HTMLElement; - expect(copy.textContent).toContain(sid.slice(0, 8)); - copy.click(); - await flushPromises(); - expect(host.dispatch).toHaveBeenCalledWith(sid, { kind: "copy", text: sid, what: "chat_id" }); - expect(host.announce).toHaveBeenCalledWith(sid, "Chat id copied"); + expect(host.dispatch).toHaveBeenCalledWith( + sid, + expect.objectContaining({ vote: "up", reason: "cause:yes" }), + ); }); }); diff --git a/web/src/components/ai-assistant/transcript/AssistantFeedback.vue b/web/src/components/ai-assistant/transcript/AssistantFeedback.vue index 6b96680116..a639979cd1 100644 --- a/web/src/components/ai-assistant/transcript/AssistantFeedback.vue +++ b/web/src/components/ai-assistant/transcript/AssistantFeedback.vue @@ -17,14 +17,24 @@ along with this program. If not, see .
. > {{ t("assistant.feedback.disclosure") }} -
+
- {{ t("assistant.feedback.copyChatId") }} - {{ shortId }} - + {{ t("assistant.feedback.cancel") }} + + + {{ t("assistant.feedback.send") }} -
- - {{ t("assistant.feedback.cancel") }} - - - {{ t("assistant.feedback.send") }} - -
{{ t("assistant.feedback.localOnlyId") }} @@ -179,14 +176,12 @@ import OPopover from "@/lib/overlay/Popover/OPopover.vue"; import OSheet from "@/lib/overlay/Sheet/OSheet.vue"; import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; import type { ChatMessage } from "@/ts/interfaces/chat"; -import { raw, useI18nTyped } from "@/types/i18n"; +import { useI18nTyped } from "@/types/i18n"; const REASONS = ["wrong_data", "wrong_query", "too_slow", "wrong_action", "other"] as const; const THANKS_MS = 3_000; -const SHORT_ID_CHARS = 8; - const props = withDefaults( defineProps<{ message: ChatMessage; @@ -220,14 +215,23 @@ const visible = computed( (props.turn?.outcome === "done" || props.turn?.outcome === "done_not_saved") && !!footerOf(props.message), ); +// A findings answer asks whether this was the cause; an inconclusive one claims no cause to rate. +const cause = computed(() => { + const findings = props.message.contentBlocks?.find((block) => block.findings)?.findings; + return !!findings && findings.confidence !== "inconclusive"; +}); +const upLabel = computed(() => + cause.value ? t("assistant.findings.yes") : t("assistant.feedback.helpful"), +); +const downLabel = computed(() => + cause.value ? t("assistant.findings.no") : t("assistant.feedback.notHelpful"), +); const vote = computed(() => { if (failedVote.value) return null; const session = sid.value && evidence ? evidence.session(sid.value) : null; - const key = turnAnnotationKey(props.turn?.turnId ?? "", "feedback"); + const key = turnAnnotationKey(props.turn?.turnId ?? "", cause.value ? "findings" : "feedback"); return session?.annotations[key]?.feedback?.rating ?? null; }); -const fullId = computed(() => raw(sid.value ?? "")); -const shortId = computed(() => raw((sid.value ?? "").slice(0, SHORT_ID_CHARS))); const localOnly = computed(() => evidence?.capability().persistence === false); // The sheet and the popover take different props; the one rendered gets its own set. @@ -282,15 +286,26 @@ async function send(next: { function sendVote(next: "up" | "down"): void { if (vote.value === next) return; - void send({ vote: next }); + void send({ vote: next, ...(cause.value && { reason: "cause:yes" }) }); } async function sendReason(): Promise { - const sentOk = await send({ - vote: "down", - ...(reason.value && { reason: reason.value }), - ...(detail.value.trim() && { comment: detail.value.trim() }), - }); + const note = detail.value.trim(); + const sentOk = await send( + cause.value + ? { + vote: "down", + reason: "cause:no", + ...((reason.value || note) && { + comment: [reason.value, note].filter(Boolean).join(": "), + }), + } + : { + vote: "down", + ...(reason.value && { reason: reason.value }), + ...(note && { comment: note }), + }, + ); if (sentOk) closeReason(); } @@ -308,19 +323,6 @@ function showThanks(): void { thanksTimer = setTimeout(() => (thanks.value = false), THANKS_MS); } -async function copyChatId(): Promise { - if (!sid.value || !evidence) return; - const result = await evidence.dispatch(sid.value, { - kind: "copy", - text: sid.value, - what: "chat_id", - }); - evidence.announce( - sid.value, - result.ok ? t("assistant.feedback.chatIdCopied") : t("assistant.client.copyManually"), - ); -} - // Escape closes the reason dialog before any outer Escape handler sees it. function onPanelKeydown(event: KeyboardEvent): void { if (event.isComposing || event.key !== "Escape") return; diff --git a/web/src/components/ai-assistant/transcript/AssistantFollowUps.spec.ts b/web/src/components/ai-assistant/transcript/AssistantFollowUps.spec.ts new file mode 100644 index 0000000000..18cdb6a41b --- /dev/null +++ b/web/src/components/ai-assistant/transcript/AssistantFollowUps.spec.ts @@ -0,0 +1,50 @@ +// 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 { mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { assistantTranscriptHostKey } from "@/composables/assistant/pure"; + +import AssistantFollowUps from "./AssistantFollowUps.vue"; + +const { stage } = vi.hoisted(() => ({ stage: vi.fn() })); +vi.mock("@/composables/assistant", async (importOriginal) => ({ + ...(await importOriginal()), + useAssistantStore: () => ({ stage }), +})); + +describe("AssistantFollowUps", () => { + beforeEach(() => stage.mockClear()); + + it("stages a follow-up and focuses the composer, never sends", async () => { + const focusComposer = vi.fn(); + const wrapper = mount(AssistantFollowUps, { + props: { sid: "s1", items: ["Show Redis errors by minute", "Which endpoints?"] }, + global: { provide: { [assistantTranscriptHostKey as symbol]: { focusComposer } } }, + }); + const rows = wrapper.findAll('[data-test="assistant-follow-up"]'); + expect(rows.map((row) => row.text())).toEqual([ + "Show Redis errors by minute", + "Which endpoints?", + ]); + await rows[0].trigger("click"); + expect(stage).toHaveBeenCalledWith("s1", { + text: "Show Redis errors by minute", + mode: "replace", + }); + expect(focusComposer).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/ai-assistant/transcript/AssistantFollowUps.vue b/web/src/components/ai-assistant/transcript/AssistantFollowUps.vue new file mode 100644 index 0000000000..3c5699529e --- /dev/null +++ b/web/src/components/ai-assistant/transcript/AssistantFollowUps.vue @@ -0,0 +1,54 @@ + + + + diff --git a/web/src/components/ai-assistant/transcript/AssistantMessage.spec.ts b/web/src/components/ai-assistant/transcript/AssistantMessage.spec.ts index 6f91647121..e161e6acbf 100644 --- a/web/src/components/ai-assistant/transcript/AssistantMessage.spec.ts +++ b/web/src/components/ai-assistant/transcript/AssistantMessage.spec.ts @@ -71,7 +71,7 @@ describe("AssistantMessage", () => { document.body.innerHTML = ""; }); - it("heads an answer with its turn's agent, and the product's own without one", () => { + it("heads an answer with its agent only when that is not the default one", () => { const message = { role: "assistant", content: raw("hi") } as const; const handed = mount(AssistantMessage, { props: { message, turnId: "t1", agent: "sre", live: true }, @@ -80,9 +80,7 @@ describe("AssistantMessage", () => { "sre", ); const plain = mount(AssistantMessage, { props: { message, turnId: "t1", live: true } }); - expect(plain.get('[data-test="assistant-agent-identity"]').attributes("data-agent")).toBe( - "o2-ai", - ); + expect(plain.find('[data-test="assistant-agent-identity"]').exists()).toBe(false); }); it("says to whom only on the prompt that handed over", () => { @@ -127,15 +125,26 @@ describe("AssistantMessage", () => { expect(wrapper.find("strong").exists()).toBe(false); }); - it("shows the user's text as plain text, never Markdown, under You and the time (`30` §2.1)", () => { + it("keeps the range on the last turn's question line only, and no bubble", () => { + const last = mountMessage(user()); + expect(last.find('[data-test="assistant-message-range"]').exists()).toBe(false); + expect(last.classes()).not.toContain("bg-assistant-user-bg"); + const wrapper = mount(AssistantMessage, { + props: { message: user(), turnId: "t1", rangeLabel: raw("Last 1 h") }, + }); + expect(wrapper.get('[data-test="assistant-message-range"]').text()).toContain("Last 1 h"); + expect(wrapper.get('[data-test="assistant-message-text"]').classes()).toContain( + "text-text-secondary", + ); + }); + + it("shows the user's text as plain text, never Markdown, beside the time (`30` §2.1)", () => { const wrapper = mountMessage(user()); const body = wrapper.get('[data-test="assistant-message-text"]'); expect(body.text()).toBe("**not bold** "); expect(body.element.querySelector("strong, img")).toBeNull(); expect(body.classes()).toContain("whitespace-pre-wrap"); - expect(wrapper.get('[data-test="assistant-message-meta"]').text()).toMatch( - /^You · .*\d{2}:\d{2}/, - ); + expect(wrapper.get('[data-test="assistant-message-meta"]').text()).toMatch(/\d{2}:\d{2}$/); expect(wrapper.attributes("data-test")).toBe("assistant-message-user"); }); @@ -158,8 +167,7 @@ describe("AssistantMessage", () => { expect(wrapper.get('[data-test="assistant-message-meta"]').text()).toMatch( /^ada@example.com · /, ); - const chip = wrapper.get('[data-test="assistant-message-chip-log_row"]'); - expect(chip.text()).toContain("Log row · cart"); + expect(wrapper.get('[data-test="assistant-message-chips"]').text()).toBe("Log row · cart"); expect(wrapper.find('[data-test="assistant-context-chip-remove"]').exists()).toBe(false); }); @@ -203,7 +211,6 @@ describe("AssistantMessage", () => { ]), ); expect(wrapper.attributes("data-test")).toBe("assistant-message-assistant"); - expect(wrapper.find('[data-test="assistant-mark"]').exists()).toBe(true); expect(wrapper.get("h2").text()).toBe("Checkout 5xx spike"); expect(wrapper.element.querySelector("img")).toBeNull(); // A closed SQL fence is a runnable query block, never a code block with a Retry. @@ -313,18 +320,18 @@ describe("AssistantMessage", () => { }); }; - it("puts the trail first, before the text it explains", () => { + it("runs no trail: the steps are rows of the queries line, after the text", () => { const wrapper = mountLive([ { type: "text", text: "before" }, ...steps, { type: "text", text: "after" }, ]); - const order = Array.from( - wrapper.element.querySelectorAll( - '[data-test="assistant-steps"], [data-test="assistant-block-text"]', - ), - ).map((el) => el.getAttribute("data-test")); - expect(order).toEqual(["assistant-steps", "assistant-block-text", "assistant-block-text"]); + expect(wrapper.find('[data-test="assistant-steps"]').exists()).toBe(false); + const group = wrapper.get('[data-test="assistant-queries-group"]').element; + const texts = wrapper.findAll('[data-test="assistant-block-text"]'); + expect( + texts.at(-1)!.element.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); }); it("renders claim markers as numbered chips inside the sentence, never the raw marker", async () => { @@ -339,13 +346,13 @@ describe("AssistantMessage", () => { root.querySelectorAll('[data-test^="assistant-claim-"]'), ); expect(chips.map((chip) => [chip.dataset.test, chip.dataset.callId])).toEqual([ - ["assistant-claim-1", "call_3"], - ["assistant-claim-1", "call_3"], - ["assistant-claim-2", "call_1"], + ["assistant-claim-2", "call_3"], + ["assistant-claim-2", "call_3"], + ["assistant-claim-1", "call_1"], ]); expect(chips.every((chip) => chip.closest("p span[data-claim]"))).toBe(true); expect(root.querySelector("p")?.textContent?.replace(/\s+/g, " ")).toBe( - "Reset by peer1 and1, see2.", + "Reset by peer2 and2, see1.", ); }); @@ -400,7 +407,49 @@ describe("AssistantMessage", () => { expect(wrapper.find('a[href*="logout"]').exists()).toBe(false); }); - it("adds the footer and feedback only once the turn is done", () => { + it("gives the first text the conclusion's weight, and leaves it to the findings when they exist", () => { + const plain = mountLive([{ type: "text", text: "Checkout returned 121 errors." }]); + expect(plain.get('[data-test="assistant-block-text"]').html()).toContain("text-text-heading"); + const withFindings = mountLive([ + { type: "text", text: "Some context." }, + { + type: "findings", + findings: { + likelyCause: "Redis pool", + confidence: "low", + evidence: [], + ruledOut: [], + unknowns: [], + nextSteps: [], + }, + }, + ]); + expect(withFindings.get('[data-test="assistant-block-text"]').html()).not.toContain( + "text-text-heading", + ); + const order = Array.from( + withFindings.element.querySelectorAll( + '[data-test="assistant-findings"], [data-test="assistant-block-text"]', + ), + ).map((el) => el.getAttribute("data-test")); + expect(order).toEqual(["assistant-findings", "assistant-block-text"]); + }); + + it("puts the live line above the text, under the question", () => { + const wrapper = mount(AssistantMessage, { + props: { + message: answer([{ type: "text", text: "partial" }]), + turnId: "t1", + live: true, + }, + slots: { status: '

live

' }, + }); + const status = wrapper.get('[data-test="status"]').element; + const text = wrapper.get('[data-test="assistant-block-text"]').element; + expect(status.compareDocumentPosition(text) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("adds the action row only once the turn is done", () => { const blocks = [ ...steps, { type: "text" as const, text: "x" }, @@ -408,10 +457,8 @@ describe("AssistantMessage", () => { ]; const done = mountLive(blocks); expect(done.find('[data-test="assistant-feedback"]').exists()).toBe(true); - expect(done.find('[data-test="assistant-turn-footer"]').exists()).toBe(true); const live = mountLive(blocks, { outcome: "live" }); expect(live.find('[data-test="assistant-feedback"]').exists()).toBe(false); - expect(live.find('[data-test="assistant-turn-footer"]').exists()).toBe(false); }); }); }); diff --git a/web/src/components/ai-assistant/transcript/AssistantMessage.vue b/web/src/components/ai-assistant/transcript/AssistantMessage.vue index 50bb4bc3c1..6641799230 100644 --- a/web/src/components/ai-assistant/transcript/AssistantMessage.vue +++ b/web/src/components/ai-assistant/transcript/AssistantMessage.vue @@ -17,46 +17,45 @@ along with this program. If not, see . @@ -288,8 +292,6 @@ import type { AgentId, AssistantTurnView, PolicyBlockData } from "@/composables/ import type { BlockKind } from "@/services/assistantAnalytics"; import type { ChatMessage, ContentBlock } from "@/ts/interfaces/chat"; -import AssistantStepTrail from "./AssistantStepTrail.vue"; - export interface AssistantMessageProps { message: ChatMessage; turnId: string; @@ -301,11 +303,12 @@ export interface AssistantMessageProps { turn?: AssistantTurnView | null; /** The agent that answered this turn; a chat or server without agents reads as the default one. */ agent?: AgentId; + /** The question line's "Last 1 h"; the last turn only, the older ones keep the hour. */ + rangeLabel?: I18nText | null; } // A decision's record replaces its card in place; the pending card itself renders in the container. const BUILT_IN_RENDERERS: [string, Component][] = [ - ["trail", AssistantStepTrail], ["findings", AssistantFindings], ["footer", AssistantTurnFooter], ["approval", AssistantApprovalResult], @@ -315,8 +318,6 @@ const BUILT_IN_RENDERERS: [string, Component][] = [ ["consent_note", AssistantConsentNote], ]; -const TRAIL_BLOCK: ContentBlock = { type: "trail" }; - const FOOTER_BLOCK: ContentBlock = { type: "footer" }; /** One renderer per block kind; the evidence kinds are built in and the approvals register their own. */ @@ -336,7 +337,6 @@ const COUNTED_KINDS: ReadonlySet = new Set([ "link", "policy", "attachment", - "trail", ]); const LANGUAGE_NAMES: Record = { @@ -367,6 +367,11 @@ const PROSE = [ const PLAIN = "text-text-body min-w-0 text-sm leading-5 break-words whitespace-pre-wrap"; +// The conclusion is the only heading-colour line, so a multi-turn chat scans by its answers. +const LEAD_HTML = "[&_p:first-child]:text-text-heading [&_p:first-child]:font-medium"; + +const LEAD_TEXT = "text-text-heading font-medium"; + /** Lets a later package render its block kind; replaces an earlier registration of the same kind. */ export function registerBlockRenderer( kind: ContentBlock["type"] | string, @@ -390,14 +395,16 @@ import { useRouter } from "vue-router"; import AssistantAgentIdentity from "@/components/ai-assistant/agents/AssistantAgentIdentity.vue"; import { chipGlyph } from "@/components/ai-assistant/common/glyphs"; import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget"; +import AssistantAnswerMeta from "@/components/ai-assistant/evidence/AssistantAnswerMeta.vue"; import AssistantInlineClaims from "@/components/ai-assistant/evidence/AssistantInlineClaims.vue"; import AssistantLinkCard from "@/components/ai-assistant/evidence/AssistantLinkCard.vue"; import AssistantQueriesGroup from "@/components/ai-assistant/evidence/AssistantQueriesGroup.vue"; import AssistantQueryBlock, { type QueryBlock, } from "@/components/ai-assistant/evidence/AssistantQueryBlock.vue"; -import { claimNumbersKey, stepCitation } from "@/components/ai-assistant/evidence/evidenceText"; +import { citationName, claimNumbersKey } from "@/components/ai-assistant/evidence/evidenceText"; import { + DEFAULT_AGENT, agentName, assistantEvidenceHostKey, claimNumbers, @@ -410,11 +417,10 @@ import { stepsOf, toolLinksOf, type LinkTarget, - type StepFacts, } from "@/composables/assistant/pure"; -import OTag from "@/lib/core/Badge/OTag.vue"; import OButton from "@/lib/core/Button/OButton.vue"; import OCodeBlock from "@/lib/core/Code/OCodeBlock.vue"; +import OIcon from "@/lib/core/Icon/OIcon.vue"; import OText from "@/lib/core/Typography/OText.vue"; import OPopover from "@/lib/overlay/Popover/OPopover.vue"; import { trackAssistant } from "@/services/assistantAnalytics"; @@ -452,6 +458,7 @@ const props = withDefaults(defineProps(), { density: "compact", turn: null, agent: "o2-ai", + rangeLabel: null, }); const { t } = useI18nTyped(); @@ -478,28 +485,22 @@ const attachments = computed<{ name: string; body: string; hidden?: boolean }[]> ...(legacyUser.value?.blocks ?? []).map((block) => ({ name: block.name, body: block.body })), ]); -const chips = computed(() => props.message.chips ?? []); +// The page's own context is the default, so only what the reader added is worth a line. +const sentChips = computed(() => + (props.message.chips ?? []).filter((chip) => chip.kind !== "time" && chip.provenance !== "page"), +); +const sentContext = computed(() => raw(sentChips.value.map((chip) => chip.label).join(" · "))); const images = computed(() => props.message.images ?? []); -const baseUserMeta = computed(() => { - const who = props.message.author ? raw(props.message.author) : t("assistant.message.you"); - const at = props.message.sentAt; - if (at === undefined) return who; - return t("assistant.message.meta", { who, time: formatOrgTime(at, { showZone: true }) }); -}); - +// The question line keeps the hour; another person's message also keeps who wrote it. const userMeta = computed(() => { - const { handoffFrom, agentId } = props.message; - if (!handoffFrom || !agentId) return baseUserMeta.value; - return t("assistant.message.metaHandoff", { - meta: baseUserMeta.value, - agent: agentName(agentId, t), - }); -}); - -const answerTime = computed(() => { const at = props.message.sentAt; - return at === undefined ? undefined : raw(formatOrgTime(at, { showZone: true })); + const time = at === undefined ? null : raw(formatOrgTime(at)); + const who = props.message.author ? raw(props.message.author) : null; + const meta = who && time ? t("assistant.message.meta", { who, time }) : (who ?? time ?? raw("")); + const { handoffFrom, agentId } = props.message; + if (!handoffFrom || !agentId) return meta; + return t("assistant.message.metaHandoff", { meta, agent: agentName(agentId, t) }); }); const rerunRange = computed(() => { @@ -527,15 +528,13 @@ const permissions = computed(() => { const rendered = computed(() => { const out: Rendered[] = []; const links = new Set(); - const trail = renderers.get("trail"); - if (trail && steps.value.length) - out.push({ - kind: "registered", - key: `${props.turnId}:trail`, - component: trail, - block: TRAIL_BLOCK, - }); - blocks.value.forEach((block, index) => out.push(...renderBlock(block, index, links))); + // The conclusion leads the answer wherever its frame landed among the text. + const findingsIndex = blocks.value.findIndex((block) => block.type === "findings"); + if (findingsIndex >= 0) + out.push(...renderBlock(blocks.value[findingsIndex], findingsIndex, links)); + blocks.value.forEach((block, index) => { + if (index !== findingsIndex) out.push(...renderBlock(block, index, links)); + }); toolLinksOf(props.message).forEach((action, index) => { const link = buildNavigationRoute(action, org.value); pushLink(out, links, { @@ -551,6 +550,7 @@ const rendered = computed(() => { function renderBlock(block: ContentBlock, index: number, links: Set): Rendered[] { const key = `${props.turnId}:${index}`; + if (block.type === "follow_ups") return []; if (block.type === "footer" || block.type === "tool_call") { return block.type === "tool_call" && POLICY_CODES.has(block.errorType ?? "") ? [{ kind: "policy", key, text: policyText(block), policy: deniedPolicy(block) }] @@ -594,8 +594,7 @@ function renderBlock(block: ContentBlock, index: number, links: Set): Re return out; } const custom = renderers.get(block.type); - if (custom && block.type !== "trail") - return [{ kind: "registered", key, component: custom, block }]; + if (custom) return [{ kind: "registered", key, component: custom, block }]; if (block.type === "text") { if (!block.text?.trim()) return []; const open = props.live && index === lastTextIndex.value; @@ -654,8 +653,19 @@ function markdownTarget(href: string): LinkTarget | null { } } +const hasFindings = computed(() => blocks.value.some((block) => block.type === "findings")); + +// With no findings the first text is the conclusion. +const leadKey = computed(() => { + if (hasFindings.value) return null; + const first = rendered.value[0]; + return first?.kind === "markdown" ? first.key : null; +}); + +// A citation's number is its row in the queries list; a claim on a step this view lacks comes after them. const claimMap = computed(() => claimNumbers([ + ...steps.value.map((step) => step.callId), ...rendered.value.flatMap((item) => (item.kind === "markdown" ? item.claims : [])), ...blocks.value.flatMap((block) => (block.findings?.evidence ?? []).flatMap((item) => (item.callId ? [item.callId] : [])), @@ -716,22 +726,27 @@ function claimAvailable(callId: string): boolean { return !props.turn?.hiddenByOwner && steps.value.some((step) => step.callId === callId); } -function claimStep(callId: string): StepFacts | null { - if (props.turn?.hiddenByOwner) return null; - return steps.value.find((step) => step.callId === callId) ?? null; -} - function claimLabel(callId: string): I18nText { const step = steps.value.find((candidate) => candidate.callId === callId); - return step ? stepCitation(step, t) : t("assistant.claim.notAvailable"); + return step + ? citationName(claimMap.value.get(callId) ?? step.n, step, t) + : t("assistant.claim.notAvailable"); } +const unavailableClaims = computed(() => { + const claims = [ + ...rendered.value.flatMap((item) => (item.kind === "markdown" ? item.claims : [])), + ...blocks.value.flatMap((block) => + (block.findings?.evidence ?? []).flatMap((item) => (item.callId ? [item.callId] : [])), + ), + ]; + return new Set(claims.filter((callId) => !claimAvailable(callId))).size; +}); + const footerRenderer = computed(() => props.message.role === "assistant" ? (renderers.get("footer") ?? null) : null, ); -const hasContent = computed(() => rendered.value.length > 0); - // A tool the policy blocked is a notice with the server's detail; a data-sharing block keeps its own line. function deniedPolicy(block: ContentBlock): PolicyBlockData | null { if (block.errorType !== "permission_denied_tool") return null; diff --git a/web/src/components/ai-assistant/transcript/AssistantMessageActions.spec.ts b/web/src/components/ai-assistant/transcript/AssistantMessageActions.spec.ts index 9f4a4aaa83..bca56ae061 100644 --- a/web/src/components/ai-assistant/transcript/AssistantMessageActions.spec.ts +++ b/web/src/components/ai-assistant/transcript/AssistantMessageActions.spec.ts @@ -45,6 +45,15 @@ vi.mock("@/services/assistantAnalytics", async (importOriginal) => ({ })); vi.mock("@/composables/useBreakpoint", () => ({ default: () => ({ mdUp: ref(mdUp.value) }) })); +const DROPDOWN = { template: "
" }; + +const DROPDOWN_ITEM = { + emits: ["select"], + props: ["disabled", "description"], + template: + "", +}; + const prompt: ChatMessage = { role: "user", content: raw("why 5xx?"), @@ -80,6 +89,7 @@ function mountActions(props: Record) { return mount(AssistantMessageActions, { props: { sid: fake.current.sid, turnId: "t1", isLast: true, promptNumber: 1, ...props }, attachTo: document.body, + global: { stubs: { ODropdown: DROPDOWN, ODropdownItem: DROPDOWN_ITEM } }, }); } @@ -170,11 +180,32 @@ describe("AssistantMessageActions", () => { ).not.toContain("opacity-0"); }); - it("is a kebab below md (EB-09 AC 10)", () => { + it("keeps Copy and More at every width on an answer, and a kebab on a prompt below md (EB-09 AC 10)", () => { mdUp.value = false; const wrapper = mountActions({ message: answer, prompt }); expect(wrapper.find('[data-test="assistant-msg-more"]').exists()).toBe(true); - expect(wrapper.find('[data-test="assistant-msg-actions"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-msg-copy-answer"]').exists()).toBe(true); + const onPrompt = mountActions({ message: prompt }); + expect(onPrompt.find('[data-test="assistant-msg-actions"]').exists()).toBe(false); + expect(onPrompt.find('[data-test="assistant-msg-more"]').exists()).toBe(true); + }); + + it("drops Regenerate from More while Re-run shows", () => { + const wrapper = mountActions({ message: answer, prompt, canRegenerate: false }); + expect(wrapper.find('[data-test="assistant-msg-regenerate"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-msg-copy-md"]').exists()).toBe(true); + }); + + it("copies the chat id from More", async () => { + const dispatch = vi.spyOn(fake.current.store, "dispatch").mockResolvedValue({ ok: true }); + const wrapper = mountActions({ message: answer, prompt }); + await wrapper.get('[data-test="assistant-copy-chat-id"]').trigger("click"); + await flushPromises(); + expect(dispatch).toHaveBeenCalledWith(fake.current.sid, { + kind: "copy", + text: fake.current.sid, + what: "chat_id", + }); }); it("edits inline: Escape cancels with nothing sent, Send starts a new turn with the chips (EB-09 AC 4)", async () => { diff --git a/web/src/components/ai-assistant/transcript/AssistantMessageActions.vue b/web/src/components/ai-assistant/transcript/AssistantMessageActions.vue index 623a42034e..60301a9b71 100644 --- a/web/src/components/ai-assistant/transcript/AssistantMessageActions.vue +++ b/web/src/components/ai-assistant/transcript/AssistantMessageActions.vue @@ -67,6 +67,57 @@ along with this program. If not, see .
+
+ + + + + {{ item.label }} + + + + {{ t("assistant.message.copyChatId") }} + + +
+