feat(assistant): conversation redesign

Claude-Session: https://claude.ai/code/session_017PUuNbanUSUUbZ5dcZBYmM
This commit is contained in:
Harsh Mahajan
2026-11-01 18:59:48 +05:30
parent 6b2d1ab53b
commit 4e8dd5c536
55 changed files with 2703 additions and 3186 deletions
@@ -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<void> {
await flushPromises();
}
async function openDetails(wrapper: ReturnType<typeof mountCard>): Promise<void> {
await wrapper.get('[data-test="assistant-approval-details"] button').trigger("click");
await flushPromises();
}
const buttons = (wrapper: ReturnType<typeof mountCard>) =>
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<ApprovalCardView> = {}) =>
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);
});
});
});
@@ -27,50 +27,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<section
role="group"
:aria-labelledby="headingId"
:aria-describedby="describedBy"
:aria-label="groupName"
:aria-describedby="metaLine ? metaId : undefined"
:aria-busy="sending ? 'true' : undefined"
class="rounded-surface bg-surface-base border-border-default flex min-w-0 flex-col gap-2 border p-3"
class="border-status-warning-text flex min-w-0 flex-col gap-1.5 border-s-2 py-0.5 ps-2.5"
:data-kind="card.kind"
data-test="assistant-approval-card"
>
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<OTag
variant="default-soft"
size="xs"
:label="t('assistant.approval.needsOk')"
data-test="assistant-approval-needs-ok"
/>
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2">
<h3
:id="headingId"
class="text-text-heading min-w-0 text-sm leading-5 font-semibold break-words"
ref="headingEl"
tabindex="-1"
class="text-text-heading focus-visible:ring-assistant-focus-ring rounded-default min-w-0 flex-1 text-sm font-medium break-words outline-none focus-visible:ring-2"
data-test="assistant-approval-heading"
>
{{ heading }}
</h3>
<!-- Kept out of the heading's accessible name, which a screen reader would re-read every second -->
<OText
v-if="remainingMs !== null && !sending"
variant="meta"
as="span"
aria-hidden="true"
class="tabular-nums"
<span
v-if="remainingMs !== null"
role="timer"
aria-live="off"
:aria-label="t('assistant.approval.expiresIn', { time: clockText })"
:class="[
'text-status-warning-text text-xs whitespace-nowrap tabular-nums',
expiringSoon && 'font-medium',
]"
data-test="assistant-approval-countdown"
>
{{
t(expiringSoon ? "assistant.approval.expiringSoon" : "assistant.approval.expiresIn", {
time: clockText,
})
}}
</OText>
<OText
v-if="remainingMs !== null && !sending && extendsSpent"
variant="meta"
as="span"
data-test="assistant-approval-last-extension"
>
{{ t("assistant.approval.lastExtension") }}
</OText>
{{ clockText }}
</span>
</div>
<OText v-if="card.askedIn === 'other_tab'" variant="meta" as="p">
{{ t("assistant.approval.askedElsewhere") }}
@@ -85,140 +70,78 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
{{ metaLine }}
</OText>
<p
v-if="card.question"
:id="questionId"
v-if="card.question && !question && !isApprovalChange(card)"
class="text-text-body text-sm break-words whitespace-pre-wrap"
data-test="assistant-approval-question"
>
{{ card.question }}
</p>
<div v-if="card.command" class="flex min-w-0 flex-col gap-1">
<OText variant="label" as="p">{{ t("assistant.approval.command") }}</OText>
<code
:id="commandId"
class="rounded-default bg-surface-subtle text-text-body block p-2 font-mono text-xs break-all whitespace-pre-wrap"
data-test="assistant-approval-command"
>{{ card.command }}</code
>
</div>
<div v-if="targets.length && !planned" :id="targetsId" data-test="assistant-approval-targets">
<ul class="flex min-w-0 flex-col gap-0.5">
<li
v-for="target in firstTargets"
:key="target.key"
class="flex min-w-0 items-center gap-2"
>
<OText as="span" class="min-w-0 flex-1 break-all">{{ target.label }}</OText>
<OButton
v-if="target.href"
variant="ghost-primary"
size="xs"
as="a"
:href="target.href"
target="_blank"
rel="noopener noreferrer"
:icon-right="assistantGlyph.newTab"
:aria-label="t('assistant.approval.result.open', { name: target.label })"
:class="['shrink-0', TOUCH_TARGET_CLASS]"
:data-test="`assistant-approval-target-${target.key}`"
>
{{ t("assistant.approval.open") }}
</OButton>
</li>
</ul>
<OCollapsible
v-if="moreTargets.length"
:label="t('assistant.approval.moreTargets', { count: moreTargets.length })"
:trigger-class="TOUCH_TARGET_CLASS"
data-test="assistant-approval-more-targets"
>
<ul class="flex min-w-0 flex-col gap-0.5">
<li v-for="target in moreTargets" :key="target.key" class="min-w-0 break-all">
<OText as="span">{{ target.label }}</OText>
</li>
</ul>
</OCollapsible>
</div>
<div
v-if="diff"
:id="diffId"
class="flex min-w-0 flex-col gap-1"
data-test="assistant-approval-diff"
<code
v-if="card.command"
class="rounded-default bg-surface-subtle text-text-body block p-2 font-mono text-xs break-all whitespace-pre-wrap"
data-test="assistant-approval-command"
>{{ card.command }}</code
>
<OText variant="label" as="p">
{{
card.kind === "create"
? t("assistant.approval.newValues")
: t("assistant.approval.change")
}}
</OText>
<dl class="grid min-w-0 grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5">
<template v-for="row in diff" :key="row.field">
<dt class="text-text-secondary text-xs break-all">{{ row.field }}</dt>
<dd class="text-text-body text-xs break-all">{{ row.value }}</dd>
</template>
</dl>
</div>
<div v-if="impact.length" :id="impactId" class="flex min-w-0 flex-col gap-1">
<OText variant="label" as="p">{{ t("assistant.approval.effect") }}</OText>
<ul
class="text-text-body flex min-w-0 list-disc flex-col gap-0.5 ps-5 text-sm"
data-test="assistant-approval-impact"
>
<li v-for="(line, index) in impact" :key="index" class="break-words">{{ line }}</li>
</ul>
</div>
<p
v-if="remainingMs !== null && !sending"
:id="expiryId"
aria-live="off"
class="sr-only"
data-test="assistant-approval-expiry"
<OText
v-if="gateLine"
variant="body"
as="p"
class="text-xs"
data-test="assistant-approval-gated"
>
{{ t("assistant.approval.expiresIn", { time: clockText }) }}
</p>
<OSelect
v-if="scopeOptions.length"
v-model="scope"
:options="scopeOptions"
:label="t('assistant.approval.scope.label')"
:disabled="sending"
size="sm"
data-test="assistant-approval-scope"
/>
{{ gateLine }}
</OText>
<!-- The buttons stay while the decision is on its way, so focus stays where it was -->
<div class="flex min-w-0 flex-col gap-2">
<OText v-if="gated" variant="meta" as="p" data-test="assistant-approval-gated">
{{ t("assistant.approval.gateTiming", { product }) }}
</OText>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<div class="-ms-2.5 flex min-w-0 flex-wrap items-center gap-1">
<template v-if="question && !replying">
<OButton
v-for="option in card.options ?? []"
:key="option.id"
variant="ghost"
size="xs"
:disabled="sending"
focusable-unavailable
:class="TOUCH_TARGET_CLASS"
:data-test="`assistant-question-option-${option.id}`"
@click="(event: MouseEvent) => act(event, () => choose(option))"
>
{{ raw(option.label) }}
</OButton>
<OButton
v-if="card.allowOther || !card.options?.length"
variant="ghost"
size="xs"
:disabled="sending"
focusable-unavailable
:class="TOUCH_TARGET_CLASS"
data-test="assistant-question-other"
@click="(event: MouseEvent) => act(event, () => emit('reply'))"
>
{{ t("assistant.question.otherAnswer") }}
</OButton>
</template>
<template v-else-if="!replying">
<OButton
v-if="mdUp || gated"
ref="denyButton"
variant="secondary"
size="sm"
variant="ghost"
size="xs"
:disabled="sending"
focusable-unavailable
:class="TOUCH_TARGET_CLASS"
data-test="assistant-approval-deny"
@click="(event: MouseEvent) => act(event, () => deny('deny'))"
@click="
(event: MouseEvent) => act(event, () => decide({ approved: false, reason: 'deny' }))
"
>
{{ t("assistant.approval.deny") }}
</OButton>
<OButton
variant="ghost"
size="sm"
size="xs"
:disabled="sending"
focusable-unavailable
:aria-describedby="replyId"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-approval-reply"
@click="(event: MouseEvent) => act(event, () => emit('reply'))"
@@ -228,7 +151,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
v-if="planned && card.editable && !gated"
variant="ghost"
size="sm"
size="xs"
:disabled="sending"
focusable-unavailable
:class="TOUCH_TARGET_CLASS"
@@ -237,120 +160,126 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
{{ t("assistant.approval.edit") }}
</OButton>
<OButton
v-if="editor && !gated"
variant="ghost"
size="sm"
:disabled="sending"
focusable-unavailable
:class="TOUCH_TARGET_CLASS"
data-test="assistant-approval-edit-in-editor"
@click="(event: MouseEvent) => act(event, editInEditor)"
>
{{ t("assistant.approval.editInEditor", { editor: editorLabel }) }}
</OButton>
<OButton
v-if="moreTime"
variant="ghost"
size="sm"
:disabled="sending"
focusable-unavailable
:class="TOUCH_TARGET_CLASS"
data-test="assistant-approval-extend"
@click="(event: MouseEvent) => act(event, () => emit('extend'))"
>
{{ t("assistant.approval.moreTime") }}
</OButton>
<OButton
v-if="mdUp && !gated"
:variant="card.destructive ? 'destructive' : 'primary'"
size="sm"
:disabled="sending || nothingSelected"
focusable-unavailable
:class="TOUCH_TARGET_CLASS"
data-test="assistant-approval-approve"
@click="(event: MouseEvent) => act(event, approve)"
>
{{ approveLabel }}
<template v-if="nothingSelected && !sending" #unavailable-reason>
{{ t("assistant.approval.nothingSelected") }}
</template>
</OButton>
</div>
<div v-if="!mdUp && !gated" class="flex min-w-0 gap-2">
<OButton
ref="denyButton"
variant="secondary"
size="sm"
:disabled="sending"
focusable-unavailable
class="min-h-11 flex-1"
data-test="assistant-approval-deny"
@click="(event: MouseEvent) => act(event, () => deny('deny'))"
>
{{ t("assistant.approval.deny") }}
</OButton>
<OButton
:variant="card.destructive ? 'destructive' : 'primary'"
size="sm"
:disabled="sending || nothingSelected"
focusable-unavailable
class="min-h-11 flex-1"
data-test="assistant-approval-approve"
@click="(event: MouseEvent) => act(event, approve)"
>
{{ approveLabel }}
<template v-if="nothingSelected && !sending" #unavailable-reason>
{{ t("assistant.approval.nothingSelected") }}
</template>
</OButton>
</div>
<OText :id="replyId" variant="meta" as="p" data-test="assistant-approval-reply-consequence">
{{ t("assistant.approval.replyConsequence") }}
</OText>
<div
v-if="sending"
class="text-text-secondary flex items-center gap-2 text-sm"
data-test="assistant-approval-sending"
>
<OSpinner size="xs" />
<span>{{ t("assistant.approval.result.sending") }}</span>
</div>
<template v-else>
<OText
v-if="nothingSelected"
variant="meta"
as="p"
data-test="assistant-approval-nothing"
>
{{ t("assistant.approval.nothingSelected") }}
</OText>
<OText v-if="narrowedNote" variant="meta" as="p" data-test="assistant-approval-narrowed">
{{ narrowedNote }}
</OText>
<OText v-if="mdUp" variant="meta" as="p">{{ t("assistant.approval.escapeHint") }}</OText>
</template>
<OButton
v-if="moreTime"
variant="ghost"
size="xs"
:disabled="sending"
focusable-unavailable
:class="TOUCH_TARGET_CLASS"
data-test="assistant-approval-extend"
@click="(event: MouseEvent) => act(event, () => emit('extend'))"
>
{{ t("assistant.approval.moreTime") }}
</OButton>
<OButton
v-if="!question && !replying && !gated"
:variant="card.destructive ? 'destructive' : 'primary'"
size="xs"
:disabled="sending || nothingSelected"
focusable-unavailable
:class="['ms-auto', TOUCH_TARGET_CLASS]"
data-test="assistant-approval-approve"
@click="(event: MouseEvent) => act(event, approve)"
>
{{ approveLabel }}
<template v-if="nothingSelected && !sending" #unavailable-reason>
{{ t("assistant.approval.nothingSelected") }}
</template>
</OButton>
</div>
<OText v-if="narrowedNote" variant="meta" as="p" data-test="assistant-approval-narrowed">
{{ narrowedNote }}
</OText>
<OCollapsible
v-if="card.args"
:label="t('assistant.approval.details')"
:trigger-class="TOUCH_TARGET_CLASS"
v-if="hasDetails && !replying"
:default-open="gated"
:trigger-class="DETAILS_TRIGGER"
data-test="assistant-approval-details"
>
<div class="flex min-w-0 flex-col gap-2">
<OText variant="label" as="p">
{{ t("assistant.approval.arguments") }} · {{ card.tool }}
</OText>
<template #trigger="{ open }">
<span class="text-text-secondary flex items-center gap-1 text-xs">
<OIcon
:name="open ? assistantGlyph.collapse : assistantGlyph.expand"
size="xs"
aria-hidden="true"
/>
{{ t("assistant.approval.details") }}
</span>
</template>
<div class="flex min-w-0 flex-col items-start gap-1.5 pt-1">
<ul
v-if="targets.length && !planned && targets.length + moreTargets > 1"
class="flex min-w-0 flex-col gap-0.5"
data-test="assistant-approval-targets"
>
<li v-for="target in targets" :key="target.key" class="min-w-0 break-all">
<OText as="span" class="text-xs">{{ target.label }}</OText>
</li>
<li v-if="moreTargets > 0">
<OText variant="meta" as="span">
{{ t("assistant.approval.moreTargets", { count: moreTargets }) }}
</OText>
</li>
</ul>
<dl
v-if="diff.length"
class="grid min-w-0 grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs"
data-test="assistant-approval-diff"
>
<template v-for="row in diff" :key="row.field">
<dt class="text-text-secondary break-all">{{ row.field }}</dt>
<dd class="text-text-body font-mono break-all">
<template v-if="row.before">
<s class="text-text-secondary">{{ row.before }}</s>
{{ raw("→") }}
</template>
{{ row.value }}
</dd>
</template>
</dl>
<ul
v-if="impact.length"
class="text-text-body flex min-w-0 list-disc flex-col gap-0.5 ps-4 text-xs"
data-test="assistant-approval-impact"
>
<li v-for="(line, index) in impact" :key="index" class="break-words">{{ line }}</li>
</ul>
<OSelect
v-if="scopeOptions.length"
v-model="scope"
:options="scopeOptions"
:label="t('assistant.approval.scope.label')"
:disabled="sending"
size="sm"
data-test="assistant-approval-scope"
/>
<OCodeBlock
v-if="card.args && !diff.length"
:code="argsText"
lang="json"
contrast="aa"
:scroll-label="t('assistant.approval.arguments')"
:copy-message="t('assistant.evidence.copied')"
:copy-button-class="TOUCH_TARGET_CLASS"
class="w-full"
data-test="assistant-approval-args"
/>
<OButton
v-if="editor"
variant="ghost"
size="xs"
:icon-left="assistantGlyph.newTab"
:disabled="sending"
focusable-unavailable
:class="['-ms-2.5', TOUCH_TARGET_CLASS]"
data-test="assistant-approval-finish-in"
@click="(event: MouseEvent) => act(event, editInEditor)"
>
{{ t("assistant.approval.finishIn", { editor: editorLabel }) }}
</OButton>
</div>
</OCollapsible>
</section>
@@ -388,33 +317,38 @@ import {
approvalHeading,
approvalName,
approvalNoun,
approvalNotifies,
approvalObjectId,
approvalScopeName,
approvalTargetHref,
approvalUntil,
approvalVerb,
approvalVerbText,
canAlwaysAllow,
isReplyingTo,
isApprovalChange,
type QuestionOption,
} from "@/composables/assistant/pure";
import useBreakpoint from "@/composables/useBreakpoint";
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 OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { formatOrgTime } from "@/utils/formatters";
import AssistantPlanBlock from "./AssistantPlanBlock.vue";
import { lowerFirst } from "./approvalText";
import type { ApprovalCardDecision, ApprovalCardView } from "./approvals.types";
/** The first targets listed; the rest sit behind "and N more". */
/** The first targets listed; the rest are counted. */
const FIRST_TARGETS = 5;
/** An id the name could not be resolved for is shown this short (D3-n). */
const SHORT_ID_CHARS = 8;
/** From here until a decision the tag line reads "Expires soon" (`30` §7.3). */
/** The last minute weighs the timer and speaks once (`30` §7.3). */
const WARN_AT_MS = 60_000;
/** Past this many items the impact says it is a large batch, without blocking it. */
@@ -426,11 +360,13 @@ const CARRIED_KEY_MS = 1_000;
/** The two warnings of every window, longest first (`30` §7.3). */
const WARN_ATS_MS = [60_000, 20_000];
const DETAILS_TRIGGER = "min-h-6 w-fit px-0 py-0 max-md:min-h-11 pointer-coarse:min-h-11";
const props = withDefaults(
defineProps<{
card: ApprovalCardView;
org?: string;
/** The decision is on its way: the buttons stay, unavailable, beside "Sending your decision…". */
/** The decision is on its way: the buttons stay, unavailable, under `aria-busy`. */
sending?: boolean;
/** The server lets the card ask for more time. */
extendable?: boolean;
@@ -454,21 +390,11 @@ const emit = defineEmits<{
const { t } = useI18nTyped();
const { product } = useAssistantName();
const router = useRouter();
const { mdUp } = useBreakpoint();
const idle = useAssistantIdleFocus();
const uid = useId();
const headingId = `assistant-approval-heading-${uid}`;
const questionId = `assistant-approval-question-${uid}`;
const commandId = `assistant-approval-command-${uid}`;
const targetsId = `assistant-approval-targets-${uid}`;
const impactId = `assistant-approval-impact-${uid}`;
const expiryId = `assistant-approval-expiry-${uid}`;
const metaId = `assistant-approval-meta-${uid}`;
const diffId = `assistant-approval-diff-${uid}`;
const replyId = `assistant-approval-reply-consequence-${uid}`;
const metaId = `assistant-approval-meta-${useId()}`;
const root = ref<HTMLElement | null>(null);
const denyButton = ref<{ $el: HTMLElement } | null>(null);
const headingEl = ref<HTMLElement | null>(null);
const plan = ref<{ $el: HTMLElement } | null>(null);
const now = ref(Date.now());
const scope = ref<ApprovalScope>("once");
@@ -485,6 +411,7 @@ const gated = props.gated;
let timer: ReturnType<typeof setInterval> | null = null;
const offs: (() => void)[] = [];
const question = computed(() => props.card.kind === "question");
const total = computed(() => props.card.total ?? props.card.targets.length);
const planned = computed(
() => props.card.kind === "delete" && total.value > 1 && props.card.targets.length > 0,
@@ -493,8 +420,16 @@ const count = computed(() =>
planned.value && props.card.editable ? selected.value.length : total.value,
);
const heading = computed(() => approvalHeading(t, props.card, count.value));
const groupName = computed(() =>
question.value
? t("assistant.question.needsAnswer", { question: heading.value })
: t("assistant.approval.needsOkName", { name: raw(lowerFirst(heading.value)) }),
);
const nothingSelected = computed(() => planned.value && count.value === 0);
// The composer takes over while the reader writes a reply or their own answer.
const replying = computed(() => isReplyingTo(props.card.sessionId, props.card.id));
const remainingMs = computed(() =>
typeof props.card.expiresAt === "number" ? Math.max(0, props.card.expiresAt - now.value) : null,
);
@@ -511,15 +446,15 @@ const moreTime = computed(
);
const expiringSoon = computed(() => remainingMs.value !== null && remainingMs.value <= WARN_AT_MS);
const targets = computed(() =>
const allTargets = computed(() =>
props.card.targets.map((target) => ({
key: target.id ?? target.name,
label: raw(target.name),
href: target.id ? approvalTargetHref(router, props.org, target) : null,
})),
);
const firstTargets = computed(() => targets.value.slice(0, FIRST_TARGETS));
const moreTargets = computed(() => targets.value.slice(FIRST_TARGETS));
const targets = computed(() => allTargets.value.slice(0, FIRST_TARGETS));
const moreTargets = computed(() => Math.max(0, allTargets.value.length - FIRST_TARGETS));
const entity = computed(() => approvalEntity(props.card));
@@ -539,14 +474,17 @@ const impact = computed(() => {
const diff = computed(() => {
const changed = approvalDiff(props.card);
if (!changed) return null;
const unknown = t("assistant.approval.currentUnknown");
return changed.rows.map((row) => {
const after = valueText(row.after);
if (props.card.kind === "create") return { field: raw(row.field), value: raw(after) };
const before = "before" in row ? valueText(row.before) : changed.oneSided ? unknown : "—";
return { field: raw(row.field), value: raw(`${before} → ${after}`) };
const rows = (changed?.rows ?? []).map((row) => {
const value = raw(valueText(row.after));
if (props.card.kind === "create") return { field: raw(row.field), value, before: null };
const before = "before" in row ? valueText(row.before) : changed?.oneSided ? unknown : null;
return { field: raw(row.field), value, before: before === null ? null : raw(before) };
});
const notifies = approvalNotifies(props.card);
return notifies
? [...rows, { field: t("assistant.approval.notifies"), value: raw(notifies), before: null }]
: rows;
});
// The name could not be resolved, so the id is said once here and never in the heading (D3-n).
@@ -561,14 +499,22 @@ const nameUnknown = computed<I18nText | null>(() => {
});
});
const endAndScope = computed<I18nText | null>(() => {
const until = approvalUntil(props.card);
const name = approvalScopeName(props.card);
const time = until === null ? null : raw(formatOrgTime(until));
if (time && name) return t("assistant.approval.meta", { time, scope: raw(name) });
if (time) return t("assistant.approval.metaUntil", { time });
return name ? raw(name) : null;
});
const metaLine = computed<I18nText | null>(() => {
const inOrg =
props.multiOrg && props.orgName
? t("assistant.approval.inOrg", { org: raw(props.orgName) })
: null;
if (nameUnknown.value && inOrg)
return t("assistant.turn.withElapsed", { status: nameUnknown.value, elapsed: inOrg });
return nameUnknown.value ?? inOrg;
const parts = [endAndScope.value, nameUnknown.value, inOrg].filter(Boolean);
return parts.length ? raw(parts.join(" · ")) : null;
});
const editor = computed(() => (planned.value ? null : approvalEditor(props.card, props.org)));
@@ -576,9 +522,18 @@ const editorLabel = computed(() =>
editor.value ? t(`assistant.approval.editor.${editor.value.type}`) : raw(""),
);
const gateLine = computed<I18nText | null>(() => {
if (!gated || question.value) return null;
return editor.value
? t("assistant.approval.gateTiming", { editor: editorLabel.value })
: t("assistant.approval.gateNoEditor", { product });
});
// A single change has nothing to scope: "always" would grant a write the reader saw once (D3-p).
const scopeOptions = computed(() =>
canAlwaysAllow(props.card) && (!isApprovalChange(props.card) || props.card.targets.length > 1)
!gated &&
canAlwaysAllow(props.card) &&
(!isApprovalChange(props.card) || props.card.targets.length > 1)
? [
{ label: t("assistant.approval.scope.once"), value: "once" },
{ label: t("assistant.approval.scope.chat"), value: "chat" },
@@ -590,6 +545,17 @@ const scopeOptions = computed(() =>
: [],
);
const hasDetails = computed(
() =>
!question.value &&
(diff.value.length > 0 ||
impact.value.length > 0 ||
scopeOptions.value.length > 0 ||
!!editor.value ||
!!props.card.args ||
(!planned.value && allTargets.value.length > 1)),
);
const approveLabel = computed(() => {
const verb = approvalVerb(props.card);
if (!props.card.destructive)
@@ -607,21 +573,6 @@ const narrowedNote = computed(() =>
: null,
);
const describedBy = computed(
() =>
[
props.card.question ? questionId : null,
metaLine.value ? metaId : null,
props.card.command ? commandId : null,
targets.value.length && !planned.value ? targetsId : null,
diff.value ? diffId : null,
impact.value.length ? impactId : null,
remainingMs.value !== null && !props.sending ? expiryId : null,
]
.filter(Boolean)
.join(" ") || undefined,
);
const argsText = computed(() => JSON.stringify(props.card.args ?? {}, null, 2));
function initialLeft(): number {
@@ -640,10 +591,6 @@ function act(event: MouseEvent, run: () => void): void {
run();
}
function deny(reason: "deny" | "escape"): void {
decide({ approved: false, reason });
}
function approve(): void {
if (nothingSelected.value || gated) return;
decide({
@@ -653,6 +600,10 @@ function approve(): void {
});
}
function choose(option: QuestionOption): void {
decide({ approved: false, reason: "answer", answer: option.id, answerLabel: option.label });
}
function edit(): void {
plan.value?.$el.querySelector<HTMLElement>("button, input")?.focus();
emit("announce", t("assistant.plan.deselectHint"));
@@ -671,7 +622,7 @@ function decide(decision: ApprovalDecision): void {
}
function focusDefault(): boolean {
const target = denyButton.value?.$el;
const target = headingEl.value;
if (!target) return false;
target.focus();
return true;
@@ -716,12 +667,10 @@ onMounted(async () => {
if (remainingMs.value !== null) timer = setInterval(tick, 1_000);
offs.push(
registerAssistantFocusTarget("card", focusDefault),
// While the decision is on its way Escape does nothing here, so it never reaches the panel's close.
// Escape never decides, and it stops here so it cannot close the panel on a waiting card either.
registerEscapeRung("b", (event) => {
const target = event.target instanceof Node ? event.target : document.activeElement;
if (!target || !root.value?.contains(target)) return false;
if (!props.sending) deny("escape");
return true;
return !!target && !!root.value?.contains(target);
}),
);
await nextTick();
@@ -151,11 +151,11 @@ describe("AssistantApprovalResult", () => {
});
it.each([
["declined", "Declined · nothing was changed"],
["declined", "Denied: delete 2 alerts"],
["new_chat", "Declined because you started a new chat"],
["stopped", "Declined · you stopped the answer"],
["withdrawn", "This request is no longer valid. The conversation moved on."],
["expired", "No answer in time, so the assistant didn't run this action."],
["expired", "Request expired. Nothing was changed."],
] as const)("reads %s as its own record", (state, text) => {
const { wrapper } = mountRecord({ outcome: { state, at: 1 } });
expect(line(wrapper)).toBe(text);
@@ -169,7 +169,7 @@ describe("AssistantApprovalResult", () => {
decision: { approved: false, reason: "reply", reply: "keep it" },
},
});
expect(line(wrapper)).toBe("Declined · replied: “keep it”");
expect(line(wrapper)).toBe("Declined with a reply");
});
it("says the change moved to its editor when Edit in {editor} resolved the card (PO A9)", () => {
@@ -183,18 +183,14 @@ describe("AssistantApprovalResult", () => {
{ outcome: { state: "editing", at: 1, decision: { approved: false, reason: "edited" } } },
{ blocks: [{ type: "approval", approval: pause }] },
);
expect(line(wrapper)).toBe(
"You're editing this in the alert editor. Nothing was changed here.",
);
expect(line(wrapper)).toBe("Moved to Alerts");
const batch = mountRecord({ outcome: { state: "editing", at: 1 } });
expect(line(batch.wrapper)).toBe("Declined · nothing was changed");
expect(line(batch.wrapper)).toBe("Denied: delete 2 alerts");
});
it("offers Send again after a lost decision, and Ask again after an expiry", async () => {
const lost = mountRecord({ outcome: { state: "delivery_failed", at: 1 } });
expect(line(lost.wrapper)).toBe(
"Your decision didn't reach the assistant. Nothing was changed.",
);
expect(line(lost.wrapper)).toBe("Couldn't send your decision. The alert hasn't changed.");
await lost.wrapper.get('[data-test="assistant-approval-result-send-again"]').trigger("click");
expect(lost.host.sendAgain).toHaveBeenCalledWith(lost.evidence.sid, "c9");
const expired = mountRecord({ outcome: { state: "expired", at: 1 } });
@@ -235,7 +231,7 @@ describe("AssistantApprovalResult", () => {
expect(check.attributes("aria-label")).toBe("Check the list (opens in a new tab)");
});
it("names every object it changed, and links the ones that still exist", () => {
it("lets a single done item be the line, and lists what failed", () => {
const single = mountRecord({
outcome: { state: "done", at: 1 },
result: {
@@ -245,9 +241,7 @@ describe("AssistantApprovalResult", () => {
resolvedAt: 1,
},
});
const row = single.wrapper.get('[data-test="assistant-approval-result-item-a1"]');
expect(row.text()).toBe("checkout-latency-old");
expect(single.wrapper.find('[data-test="assistant-approval-result-open-a1"]').exists()).toBe(
expect(single.wrapper.find('[data-test="assistant-approval-result-item-a1"]').exists()).toBe(
false,
);
const failed = mountRecord({
@@ -259,13 +253,13 @@ describe("AssistantApprovalResult", () => {
resolvedAt: 1,
},
});
expect(
failed.wrapper.get('[data-test="assistant-approval-result-open-a1"]').attributes("href"),
).toContain("/alerts/a1");
expect(failed.wrapper.find('[data-test="assistant-approval-result-item-a1"]').exists()).toBe(
true,
);
});
it("keeps Undo in place while it runs, and says so inline when it failed", async () => {
const { wrapper, evidence } = mountRecord({
it("draws no Undo on a record", () => {
const { wrapper } = mountRecord({
outcome: { state: "done", at: 1 },
result: {
status: "done",
@@ -283,28 +277,48 @@ describe("AssistantApprovalResult", () => {
resolvedAt: 1,
},
});
const session = evidence.store.state.sessions[evidence.sid];
const entry = {
id: "led-1",
approvalId: "c9",
turnId: "t1",
tool: "o2_delete",
op: "delete" as const,
resource: { type: "alert", name: "checkout-latency-old" },
status: "done" as const,
undoable: true,
at: 1,
};
session.ledger = [{ ...entry, undoing: true }];
await nextTick();
const undo = wrapper.get('[data-test="assistant-undo-led-1"]');
expect(undo.text()).toBe("Undoing…");
expect(undo.attributes("aria-disabled")).toBe("true");
session.ledger = [{ ...entry, undoFailed: true }];
await nextTick();
expect(wrapper.get('[data-test="assistant-approval-result-item-a1"]').text()).toContain(
"Undo didn't work.",
expect(wrapper.find('[data-test^="assistant-undo"]').exists()).toBe(false);
});
it("records an answered question by what was chosen, and an expired one in its own words", () => {
const question = approvalOf({
type: "confirmation_required",
id: "c9",
tool: "ask_user",
kind: "question",
message: "Which folder?",
});
const blocks: ContentBlock[] = [{ type: "approval", approval: question }];
const answered = mountRecord(
{
outcome: {
state: "answered",
at: 1,
decision: { approved: false, reason: "answer", answer: "p", answerLabel: "Payments" },
},
},
{ blocks },
);
expect(line(answered.wrapper)).toBe("Answered: Payments");
const expired = mountRecord({ outcome: { state: "expired", at: 1 } }, { blocks });
expect(line(expired.wrapper)).toBe("Question expired. Nothing was changed.");
});
it("reads the record of Finish in Alerts with Open, when the card has an object to open", () => {
const pause = approvalOf({
type: "confirmation_required",
id: "c9",
tool: "EnableAlert",
targets: [{ type: "alert", id: "a1", name: "cart" }],
args: { value: false },
});
const { wrapper } = mountRecord(
{ outcome: { state: "editing", at: 1 } },
{ blocks: [{ type: "approval", approval: pause }] },
);
expect(
wrapper.get('[data-test="assistant-approval-result-open"]').attributes("href"),
).toContain("/alerts/a1");
});
it("stays quiet while its turn still runs elsewhere, and when the turn's own expired outcome says it", () => {
@@ -340,7 +354,7 @@ describe("AssistantApprovalResult", () => {
],
}).wrapper,
);
expect(ended("expired")).toBe("No answer in time, so the assistant didn't run this action.");
expect(ended("expired")).toBe("Request expired. Nothing was changed.");
expect(ended("withdrawn")).toBe("This request is no longer valid. The conversation moved on.");
});
@@ -350,10 +364,8 @@ describe("AssistantApprovalResult", () => {
return mountRecord(null, { readonly: true, blocks: [{ type: "approval", approval }] });
};
expect(line(recorded("approved", 1_760_000_000_000).wrapper)).toMatch(/^Approved · /);
expect(line(recorded("denied").wrapper)).toBe("Declined · nothing was changed");
expect(line(recorded("expired").wrapper)).toBe(
"No answer in time, so the assistant didn't run this action.",
);
expect(line(recorded("denied").wrapper)).toBe("Denied: delete 2 alerts");
expect(line(recorded("expired").wrapper)).toBe("Request expired. Nothing was changed.");
expect(recorded("denied").wrapper.find("button, a").exists()).toBe(false);
expect(line(recorded("withdrawn").wrapper)).toBe(
"This request is no longer valid. The conversation moved on.",
@@ -418,7 +430,7 @@ describe("AssistantApprovalResult", () => {
expect(first.evidence.host.announce).toHaveBeenCalledTimes(1);
expect(first.evidence.host.announce).toHaveBeenCalledWith(
first.evidence.sid,
"Declined · nothing was changed",
"Denied: delete 2 alerts",
);
first.wrapper.unmount();
});
@@ -427,7 +439,7 @@ describe("AssistantApprovalResult", () => {
const fresh = mountRecord({ outcome: { state: "declined", at: Date.now() } });
expect(fresh.evidence.host.announce).toHaveBeenCalledWith(
fresh.evidence.sid,
"Declined · nothing was changed",
"Denied: delete 2 alerts",
);
const old = mountRecord({ outcome: { state: "declined", at: 1 } });
expect(old.evidence.host.announce).not.toHaveBeenCalled();
@@ -17,91 +17,132 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
v-if="view"
class="rounded-surface border-border-default flex min-w-0 flex-col gap-1.5 border px-3 py-2"
class="flex min-w-0 flex-col gap-1"
:data-state="view.state"
:data-approval-id="card?.id"
data-test="assistant-approval-result"
>
<div class="flex min-w-0 items-start gap-2">
<OSpinner v-if="view.state === 'sending'" size="xs" class="mt-0.5 shrink-0" />
<OIcon
v-else
:name="glyph"
size="sm"
class="text-text-secondary mt-0.5 shrink-0"
aria-hidden="true"
/>
<p
class="text-text-body min-w-0 flex-1 text-sm break-words"
data-test="assistant-approval-result-line"
>
{{ line }}
</p>
</div>
<ul
v-if="rows.length"
class="flex min-w-0 flex-col gap-0.5 ps-6"
:aria-label="t('assistant.approval.result.itemsName')"
<OBanner
v-if="view.state === 'delivery_failed'"
variant="error-soft"
dense
role="presentation"
:icon="assistantGlyph.failed"
>
<li
v-for="row in rows"
:key="row.key"
:class="[
'flex min-w-0 flex-wrap items-center gap-x-2 text-xs',
row.failed ? 'text-status-error-text' : 'text-text-secondary',
]"
:data-test="`assistant-approval-result-item-${row.key}`"
>
<span class="min-w-0 break-all">{{ row.text }}</span>
<div class="flex min-w-0 items-center gap-2">
<p class="min-w-0 flex-1 text-xs break-words" data-test="assistant-approval-result-line">
{{ line }}
</p>
<OButton
v-if="row.href && live"
variant="ghost-primary"
v-if="action && live"
variant="ghost"
size="xs"
:class="['shrink-0', TOUCH_TARGET_CLASS]"
:data-test="`assistant-approval-result-${action.id}`"
@click="action.run?.()"
>
{{ action.label }}
</OButton>
</div>
</OBanner>
<template v-else>
<div
ref="lineEl"
tabindex="-1"
class="focus-visible:ring-assistant-focus-ring rounded-default flex min-w-0 items-start gap-1.5 outline-none focus-visible:ring-2"
>
<OSpinner v-if="view.state === 'sending'" size="xs" class="mt-0.5 shrink-0" />
<OIcon
v-else
:name="glyph"
size="sm"
class="text-text-secondary mt-0.5 shrink-0"
aria-hidden="true"
/>
<p
:class="[
'min-w-0 flex-1 text-sm break-words',
view.state === 'expired' ? 'text-text-secondary' : 'text-text-body',
]"
data-test="assistant-approval-result-line"
>
{{ line }}
</p>
<OText
v-if="meta"
variant="meta"
as="span"
class="shrink-0 pt-0.5 whitespace-nowrap"
data-test="assistant-approval-result-meta"
>
{{ meta }}
</OText>
</div>
<p
v-if="replyText"
class="text-text-secondary text-sm break-words"
data-test="assistant-approval-result-reply"
>
{{ replyText }}
</p>
<ul
v-if="rows.length"
class="flex min-w-0 flex-col gap-0.5 ps-6"
:aria-label="t('assistant.approval.result.itemsName')"
>
<li
v-for="row in rows"
:key="row.key"
:class="[
'flex min-w-0 flex-wrap items-center gap-x-2 text-xs',
row.failed ? 'text-status-error-text' : 'text-text-secondary',
]"
:data-test="`assistant-approval-result-item-${row.key}`"
>
<span class="min-w-0 break-all">{{ row.text }}</span>
</li>
</ul>
<div v-if="action && live" class="flex flex-wrap gap-1">
<OButton
v-if="action.href"
variant="ghost-primary"
size="chip"
as="a"
:href="row.href"
:href="action.href"
target="_blank"
rel="noopener noreferrer"
:icon-right="assistantGlyph.newTab"
:aria-label="t('assistant.approval.result.open', { name: row.name })"
:aria-label="action.name"
:class="TOUCH_TARGET_CLASS"
:data-test="`assistant-approval-result-open-${row.key}`"
:data-test="`assistant-approval-result-${action.id}`"
>
{{ t("assistant.approval.open") }}
{{ action.label }}
</OButton>
<AssistantUndoControl
v-if="row.entry && live && sid"
:entry="row.entry"
:sid="sid"
test-id="assistant-undo"
/>
</li>
</ul>
<div v-if="action && live" class="flex flex-wrap gap-2 ps-6">
<OButton
v-else
variant="ghost-primary"
size="chip"
:class="TOUCH_TARGET_CLASS"
:data-test="`assistant-approval-result-${action.id}`"
@click="action.run?.()"
>
{{ action.label }}
</OButton>
</div>
</template>
<div v-if="back" class="flex min-w-0 flex-col gap-0.5">
<OButton
v-if="action.href"
variant="outline"
size="sm"
as="a"
:href="action.href"
target="_blank"
rel="noopener noreferrer"
:icon-right="assistantGlyph.newTab"
:aria-label="action.name"
:class="TOUCH_TARGET_CLASS"
:data-test="`assistant-approval-result-${action.id}`"
variant="ghost"
size="xs"
:icon-left="assistantGlyph.back"
:class="['self-start', TOUCH_TARGET_CLASS]"
data-test="assistant-approval-result-back"
@click="goBack"
>
{{ action.label }}
</OButton>
<OButton
v-else
variant="outline"
size="sm"
:class="TOUCH_TARGET_CLASS"
:data-test="`assistant-approval-result-${action.id}`"
@click="action.run?.()"
>
{{ action.label }}
{{ t("assistant.approval.backTo", { title: raw(back.title) }) }}
</OButton>
<OText v-if="back.org" variant="meta" as="p" class="ps-6">
{{ t("assistant.approval.backToOrg", { org: raw(back.org) }) }}
</OText>
</div>
</div>
</template>
@@ -114,7 +155,7 @@ const MAX_SPOKEN = 200;
</script>
<script setup lang="ts">
import { computed, inject, watch } from "vue";
import { computed, inject, nextTick, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
@@ -123,7 +164,14 @@ import {
approvalEditor,
approvalEntity,
approvalOutcomeOf,
approvalHeading,
approvalName,
approvalNoun,
approvalObjectId,
approvalTargetHref,
approvalToolEntity,
approvalUntil,
approvalVerb,
assistantEvidenceHostKey,
type ApprovalBlockData,
type ApprovalResultItem,
@@ -133,13 +181,15 @@ import {
} from "@/composables/assistant/pure";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.types";
import type { ChatMessage, ContentBlock } from "@/ts/interfaces/chat";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { formatOrgTime } from "@/utils/formatters";
import AssistantUndoControl from "./AssistantUndoControl.vue";
import { lowerFirst } from "./approvalText";
import {
assistantApprovalsHostKey,
type ApprovalCardView,
@@ -150,6 +200,19 @@ import {
/** A record that changed this recently is a decision just made, so it is spoken; an older one is history. */
const FRESH_MS = 3_000;
const TIMED_STATES: ReadonlySet<ApprovalResultState> = new Set([
"approved",
"done",
"partial",
"editing",
"declined",
"replied",
"answered",
"expired",
]);
const BY_YOU_STATES: ReadonlySet<ApprovalResultState> = new Set(["approved", "done", "partial"]);
const RECORDED_STATE = {
approved: "approved",
denied: "declined",
@@ -188,6 +251,7 @@ const emit = defineEmits<{ (e: "stop"): void }>();
const { t } = useI18nTyped();
const router = useRouter();
const lineEl = ref<HTMLElement | null>(null);
const host = props.readonly ? null : inject(assistantApprovalsHostKey, null);
const evidence = props.readonly ? null : inject(assistantEvidenceHostKey, null);
@@ -244,15 +308,17 @@ const line = computed<I18nText>(() => {
case "approved":
// A record recovered without its decision time says only that it was approved.
if (!current.outcome?.at) return t("assistant.approval.result.approvedNoTime");
return t("assistant.approval.result.approved", { time });
return approvedLine() ?? t("assistant.approval.result.approved", { time });
case "done":
return doneLine();
case "answered":
return t("assistant.question.record.answered", { choice: raw(answerOf()) });
case "partial":
return partialLine();
case "failed":
return t("assistant.approval.result.failed", { reason: failedReason() });
case "declined":
return t("assistant.approval.result.declined");
return t("assistant.approval.record.denied", { name: raw(lowerFirst(headingText())) });
case "editing":
return editingLine();
case "new_chat":
@@ -260,7 +326,7 @@ const line = computed<I18nText>(() => {
case "stopped":
return t("assistant.approval.result.stopped");
case "replied":
return t("assistant.approval.result.replied", { reply: raw(replyOf()) });
return t("assistant.approval.record.replied");
case "narrowed":
return t("assistant.approval.result.narrowed", {
count: current.outcome?.decision?.approved
@@ -268,11 +334,13 @@ const line = computed<I18nText>(() => {
: 0,
});
case "expired":
return t("assistant.approval.result.expired");
return card.value?.kind === "question"
? t("assistant.question.record.expired")
: t("assistant.approval.record.expired");
case "withdrawn":
return t("assistant.approval.result.withdrawn");
case "delivery_failed":
return t("assistant.approval.result.deliveryFailed");
return t("assistant.approval.deliveryFailed", { entity: entityNoun(1) });
case "unconfirmed":
if (current.outcome) return t("assistant.approval.result.unconfirmed");
return card.value?.noDecision
@@ -287,9 +355,30 @@ const line = computed<I18nText>(() => {
}
});
// When and who, at the end of the line; a line that carries its own time keeps it there.
const meta = computed<I18nText | null>(() => {
const current = view.value;
const at = current?.outcome?.at;
if (!current || !at || !TIMED_STATES.has(current.state)) return null;
const time = raw(formatOrgTime(at));
return BY_YOU_STATES.has(current.state) ? t("assistant.approval.record.youAt", { time }) : time;
});
const replyText = computed(() =>
view.value?.state === "replied" && replyOf() ? raw(replyOf()) : null,
);
const back = computed(() => (sid.value ? (host?.backTarget?.(sid.value) ?? null) : null));
const glyph = computed<IconName>(() => {
const state = view.value?.state;
if (state === "done" || state === "approved" || state === "approved_elsewhere")
if (state === "editing") return assistantGlyph.newTab;
if (
state === "done" ||
state === "approved" ||
state === "approved_elsewhere" ||
state === "answered"
)
return assistantGlyph.done;
if (state === "partial" || state === "failed" || state === "delivery_failed")
return assistantGlyph.failed;
@@ -298,19 +387,14 @@ const glyph = computed<IconName>(() => {
return assistantGlyph.interrupted;
});
// One item is the line itself; a batch lists its rows.
const rows = computed(() => {
const ledger = session.value?.ledger ?? [];
return (view.value?.result?.items ?? []).map((item, index) => {
const exists = item.status !== "done" || card.value?.kind !== "delete";
const entry = item.ledgerId
? ledger.find((candidate) => candidate.id === item.ledgerId)
: undefined;
const items = view.value?.result?.items ?? [];
if (items.length < 2 && items[0]?.status === "done") return [];
return items.map((item, index) => {
return {
key: item.id ?? `${index}`,
name: raw(item.name),
failed: item.status !== "done",
href: exists && item.id ? approvalTargetHref(router, org.value, item) : null,
entry: entry?.undoable ? entry : null,
text:
item.status === "done"
? raw(item.name)
@@ -339,9 +423,20 @@ const action = computed<{
if (state === "delivery_failed")
return {
id: "send-again",
label: t("assistant.approval.result.sendAgain"),
label: t("assistant.approval.retry"),
run: () => host.sendAgain(current, id),
};
if (state === "approved" || state === "done" || state === "editing") {
const href = targetHref();
return href
? {
id: "open",
label: t("assistant.approval.open"),
name: t("assistant.approval.result.open", { name: raw(targetName()) }),
href,
}
: null;
}
if (state === "expired" && host.gated?.()) return null;
if (state === "expired" || (state === "unconfirmed" && !card.value.destructive))
return {
@@ -361,6 +456,33 @@ const action = computed<{
: null;
});
function cardFacts() {
const current = card.value;
return current ? { ...current, targets: current.targets ?? [] } : null;
}
// A toggle card names its object in its arguments, not in `targets`.
function targetHref(): string | null {
const facts = cardFacts();
if (!facts || facts.kind === "delete") return null;
const id = approvalObjectId(facts);
const type = facts.targets[0]?.type ?? approvalToolEntity(facts);
if (!id || !type) return null;
return approvalTargetHref(router, org.value, { type, id, name: targetName() });
}
function targetName(): string {
const facts = cardFacts();
return facts ? (approvalName(facts) ?? "") : "";
}
function headingText(): I18nText {
const current = card.value;
return current
? approvalHeading(t, { ...current, targets: current.targets ?? [] }, total.value)
: raw("");
}
function listHref(): string | null {
const type = approvalEntity(card.value ?? { targets: [] });
return type === "alert" || type === "dashboard"
@@ -377,9 +499,10 @@ function approvalOf(id: string | undefined): ApprovalBlockData | null {
function resolvedState(
block: ResolvedBlockData,
): "expired" | "withdrawn" | "approved_elsewhere" | "declined_elsewhere" {
): "expired" | "withdrawn" | "approved_elsewhere" | "declined_elsewhere" | "answered" {
if (block.expired) return "expired";
if (block.withdrawn) return "withdrawn";
if (block.answer !== undefined) return "answered";
return block.approved ? "approved_elsewhere" : "declined_elsewhere";
}
@@ -407,9 +530,26 @@ function entityNoun(n: number): I18nText {
return t(`assistant.entity.${approvalEntity(card.value ?? { targets: [] })}`, n);
}
// "Paused alert x until 15:37": the effect the card asked for, in the past tense.
function approvedLine(): I18nText | null {
const facts = cardFacts();
if (!facts) return null;
const verb = approvalVerb(facts);
if (verb !== "pause" && verb !== "resume") return null;
const noun = t(`assistant.entity.${approvalNoun(facts)}`, 1);
const params = { entity: noun, name: raw(targetName()) };
const until = approvalUntil(facts);
if (verb === "resume") return t("assistant.approval.record.resumed", params);
return until === null
? t("assistant.approval.record.paused", params)
: t("assistant.approval.record.pausedUntil", { ...params, time: raw(formatOrgTime(until)) });
}
function doneLine(): I18nText {
const current = card.value;
if (!current) return raw("");
const effect = approvedLine();
if (effect) return effect;
const done =
view.value?.result?.items.filter((item) => item.status === "done").length ?? total.value;
if (current.kind === "delete")
@@ -462,12 +602,19 @@ function itemReason(item: ApprovalResultItem): I18nText {
// The editor is named from the card, so a reload says the same thing.
function editingLine(): I18nText {
const editor = card.value ? approvalEditor(card.value, org.value) : null;
if (!editor) return t("assistant.approval.result.declined");
return t("assistant.approval.result.editing", {
if (!editor)
return t("assistant.approval.record.denied", { name: raw(lowerFirst(headingText())) });
return t("assistant.approval.record.moved", {
editor: t(`assistant.approval.editor.${editor.type}`),
});
}
function answerOf(): string {
const decision = view.value?.outcome?.decision;
if (decision && !decision.approved) return decision.answerLabel ?? decision.answer ?? "";
return resolved.value?.answer ?? "";
}
function replyOf(): string {
const decision = view.value?.outcome?.decision;
return decision && !decision.approved ? (decision.reply ?? "") : "";
@@ -483,7 +630,18 @@ watch(
if (spoken.size >= MAX_SPOKEN) spoken.clear();
spoken.add(key);
evidence.announce(sid.value, line.value);
// The card just left under the pressed button; the reader lands on what it became.
void nextTick(() => {
const active = document.activeElement;
const line = lineEl.value;
if (active && active !== document.body && !line?.parentElement?.contains(active)) return;
line?.focus({ preventScroll: true });
});
},
{ immediate: true },
);
function goBack(): void {
if (sid.value) host?.goBack?.(sid.value);
}
</script>
@@ -0,0 +1,19 @@
// 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 <http://www.gnu.org/licenses/>.
/** A heading as the tail of a sentence: "Pause alert x" reads "Denied: pause alert x". */
export function lowerFirst(text: string): string {
return text.charAt(0).toLocaleLowerCase() + text.slice(1);
}
@@ -59,6 +59,9 @@ export interface AssistantApprovalsHost {
undo(sid: Sid, ledgerId: string): Promise<ActionResult>;
/** No split budgets: cards are Deny and Reply only, so an expired one offers no Ask again (PO B96). */
gated?(): boolean;
/** The chat the reader came from when they reached this card from another chat. */
backTarget?(sid: Sid): { title: string; org?: string } | null;
goBack?(sid: Sid): void;
}
/** Provided by the shell beside the evidence host. */
@@ -0,0 +1,127 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div
v-if="confidenceLabel || stale"
class="text-text-secondary flex min-w-0 flex-wrap items-center gap-x-1 text-xs leading-5"
data-test="assistant-answer-meta"
>
<span v-if="confidenceLabel" data-test="assistant-answer-confidence">
{{ confidenceLabel }}
</span>
<span v-if="stale" class="inline-flex items-center gap-x-1 whitespace-nowrap">
<span v-if="confidenceLabel" aria-hidden="true">·</span>
<span class="text-status-warning-text" data-test="assistant-answer-age">{{ ageLine }}</span>
<template v-if="rerunShown">
<span aria-hidden="true">·</span>
<OButton
variant="ghost-primary"
size="chip"
:disabled="!!blocked"
:focusable-unavailable="!!blocked"
:aria-label="rerunName"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-rerun"
@click="rerun"
>
{{ t("assistant.turn.rerun") }}
<template v-if="blocked" #unavailable-reason>{{ blocked }}</template>
<OTooltip v-if="blocked" :content="blocked" />
</OButton>
</template>
</span>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from "vue";
import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import {
assistantEvidenceHostKey,
isStaleAnswer,
isTerminal,
promptRange,
stepsOf,
turnLockKey,
useTurnLock,
type AssistantTurnView,
} from "@/composables/assistant/pure";
import OButton from "@/lib/core/Button/OButton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { ChatMessage } from "@/ts/interfaces/chat";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { dataAgeText, rangeText } from "./evidenceText";
const props = withDefaults(
defineProps<{
message: ChatMessage;
turn?: AssistantTurnView | null;
readonly?: boolean;
/** Findings only: "Medium confidence", or "Inconclusive". */
confidence?: "high" | "medium" | "low" | "inconclusive" | null;
}>(),
{ turn: null, readonly: false, confidence: null },
);
const { t } = useI18nTyped();
const evidence = props.readonly ? null : inject(assistantEvidenceHostKey, null);
const steps = computed(() => stepsOf(props.message, props.turn?.org ?? ""));
const stale = computed(() => isStaleAnswer(steps.value, Date.now()));
const ageLine = computed(() => dataAgeText(steps.value, Date.now(), t));
const confidenceLabel = computed<I18nText | null>(() => {
if (!props.confidence) return null;
return t(`assistant.turn.confidence.${props.confidence}`);
});
// A re-run starts a turn, so the cross-tab lock holds it like every other turn start.
const lock = useTurnLock(() => props.turn?.sid ?? null);
const blocked = computed<I18nText | null>(() => {
if (lock.value) return t(turnLockKey(lock.value));
return evidence?.capability().creditMode === "exhausted"
? t("assistant.message.creditsUsedUp")
: null;
});
// A send while another turn runs would join that turn, so Re-run waits for it.
const idle = computed(() => {
const turn = props.turn?.sid && evidence ? evidence.session(props.turn.sid)?.turn : null;
return !turn || turn.state === "idle" || isTerminal(turn);
});
const rerunShown = computed(
() =>
!props.readonly &&
!!evidence &&
!!props.turn?.sid &&
!!props.turn.prompt &&
props.turn.isLast &&
idle.value,
);
const rerunName = computed(() => {
const window = promptRange(props.turn?.prompt, props.turn?.askedWindow);
return window
? t("assistant.footer.rerunFor", { range: rangeText(window) })
: t("assistant.turn.rerun");
});
function rerun(): void {
const sid = props.turn?.sid;
if (!sid || !evidence || blocked.value || !props.turn) return;
void evidence.dispatch(sid, { kind: "rerun_now", turnId: props.turn.turnId });
}
</script>
@@ -12,106 +12,46 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, describe, expect, it } from "vitest";
import type { StepFacts } from "@/composables/assistant/pure";
import { raw } from "@/types/i18n";
import { fakeRouter } from "../__tests__/fakeAssistant";
import { fakeEvidence } from "../__tests__/fakeEvidence";
import AssistantClaimChip from "./AssistantClaimChip.vue";
describe("AssistantClaimChip", () => {
afterEach(() => (document.body.innerHTML = ""));
it("is a button named by its number and step, which opens, focuses and rings the step", async () => {
const { provide, sid, host } = fakeEvidence();
it("is a button named by its citation, which focuses and rings the row and leaves it closed", async () => {
const article = document.createElement("article");
article.innerHTML = '<button data-step-header="call_3">step</button><div></div>';
article.innerHTML = '<button><span data-step-header="call_3">row</span></button><div></div>';
document.body.appendChild(article);
const opened: string[] = [];
article.addEventListener("o2-assistant-open-step", (event) =>
opened.push((event as CustomEvent<{ callId: string }>).detail.callId),
);
const wrapper = mount(AssistantClaimChip, {
props: { n: 1, callId: "call_3", available: true, label: raw("Queried default"), sid },
props: { n: 1, callId: "call_3", available: true, label: raw("Citation 1, query 1, 3 rows") },
attachTo: article.querySelector("div")!,
global: { provide },
});
const chip = wrapper.get('[data-test="assistant-claim-1"]');
expect(chip.element.tagName).toBe("BUTTON");
expect(chip.attributes("aria-label")).toBe("Evidence 1, Queried default");
expect(chip.attributes("aria-label")).toBe("Citation 1, query 1, 3 rows");
await chip.trigger("click");
await flushPromises();
expect(host.dispatch).toHaveBeenCalledWith(sid, { kind: "open_step", callId: "call_3" });
const header = article.querySelector<HTMLElement>('[data-step-header="call_3"]')!;
expect(document.activeElement).toBe(header);
expect(header.classList.contains("ring-2")).toBe(true);
expect(opened).toEqual(["call_3"]);
const row = article.querySelector("button")!;
expect(document.activeElement).toBe(row);
expect(row.classList.contains("ring-2")).toBe(true);
});
it("opens an evidence peek in place with the step, and Show in steps jumps to the trail (D3-j)", async () => {
const { provide, sid, host } = fakeEvidence();
const article = document.createElement("article");
article.innerHTML = '<button data-step-header="call_3">step</button><div></div>';
document.body.appendChild(article);
const step: StepFacts = {
callId: "call_3",
n: 3,
tool: "SearchSQL",
kind: "query",
state: "done",
target: "default",
query: { language: "sql", text: "SELECT 1", stream: "default", streamType: "logs" },
range: null,
rows: 4,
count: null,
tookMs: 380,
error: null,
suggestion: null,
result: null,
command: null,
};
it("mutes an unavailable citation: no button and no Tab stop", () => {
const wrapper = mount(AssistantClaimChip, {
props: {
n: 1,
callId: "call_3",
available: true,
label: raw("Queried default"),
sid,
step,
total: 5,
},
attachTo: article.querySelector("div")!,
global: { provide, plugins: [fakeRouter()] },
});
await wrapper.get('[data-test="assistant-claim-1"]').trigger("click");
await flushPromises();
expect(host.dispatch).not.toHaveBeenCalled();
// The peek is portalled out of the transcript's masked ancestor, so it masks itself.
expect(
document.body
.querySelector('[data-test="assistant-claim-peek-title"]')
?.closest("[data-o-popover-content]")
?.getAttribute("data-dd-privacy"),
).toBe("mask");
expect(
document.body.querySelector('[data-test="assistant-claim-peek-title"]')?.textContent,
).toBe("Evidence 1 · step 3 of 5");
const show = document.body.querySelector<HTMLElement>(
'[data-test="assistant-step-evidence-show"]',
)!;
show.click();
await flushPromises();
expect(host.dispatch).toHaveBeenCalledWith(sid, { kind: "open_step", callId: "call_3" });
expect(document.activeElement).toBe(article.querySelector('[data-step-header="call_3"]'));
});
it("is muted with the not-available reason and no action when the step is not in view", () => {
const wrapper = mount(AssistantClaimChip, {
props: { n: 2, callId: "x", available: false, label: raw("x") },
attachTo: document.body,
props: { n: 2, callId: "gone", available: false, label: raw("Not available") },
});
const chip = wrapper.get('[data-test="assistant-claim-2"]');
expect(chip.element.tagName).toBe("SPAN");
expect(chip.attributes("aria-label")).toBe("Evidence 2, Evidence not available in this view");
expect(chip.find("button").exists()).toBe(false);
expect(chip.element.tagName).not.toBe("BUTTON");
expect(chip.attributes("tabindex")).toBeUndefined();
expect(chip.attributes("aria-hidden")).toBe("true");
});
});
@@ -13,145 +13,56 @@ 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 <http://www.gnu.org/licenses/>.
-->
<template>
<span ref="root" class="ms-1 inline-flex items-center align-middle">
<OPopover
v-if="available && step"
v-model:open="peeking"
:auto-focus="refocus"
privacy="mask"
side="bottom"
align="start"
:aria-label="peekTitle"
content-class="flex w-[min(25rem,calc(100vw-1.5rem))] flex-col gap-2 p-3"
>
<template #trigger>
<OTag
type="assistantClaim"
value="available"
:label="number"
clickable
:aria-label="name"
:class="[HIT_AREA, peeking && 'ring-border-strong rounded-default ring-2']"
:data-test="`assistant-claim-${n}`"
:data-call-id="callId"
/>
</template>
<OText variant="label" as="p" data-test="assistant-claim-peek-title">{{ peekTitle }}</OText>
<AssistantStepEvidence
:step="step"
surface="claim"
:sid="sid"
:readonly="readonly"
:openable="openable"
@show-in-steps="showInSteps"
/>
</OPopover>
<OTag
v-else-if="available"
type="assistantClaim"
value="available"
:label="number"
clickable
:aria-label="name"
:class="HIT_AREA"
:data-test="`assistant-claim-${n}`"
:data-call-id="callId"
@click="open"
/>
<span
v-else
tabindex="0"
role="note"
:aria-label="mutedName"
:class="[
'rounded-default focus-visible:ring-border-strong inline-flex items-center outline-none focus-visible:ring-2',
HIT_AREA,
]"
:data-test="`assistant-claim-${n}`"
data-muted=""
>
<OTag type="assistantClaim" value="muted" :label="number" />
<OTooltip :content="t('assistant.claim.notAvailable')" />
</span>
</span>
<OTag
v-if="available"
type="assistantClaim"
value="available"
:label="number"
clickable
:aria-label="label"
:class="['ms-1 align-middle', HIT_AREA, PILL]"
:data-test="`assistant-claim-${n}`"
:data-call-id="callId"
@click="open"
/>
<OTag
v-else
type="assistantClaim"
value="muted"
:label="number"
aria-hidden="true"
class="ms-1 align-middle"
:data-test="`assistant-claim-${n}`"
data-muted=""
/>
</template>
<script setup lang="ts">
import { computed, inject, ref, watch } from "vue";
import { computed } from "vue";
import { assistantEvidenceHostKey, type Sid, type StepFacts } from "@/composables/assistant/pure";
import OTag from "@/lib/core/Badge/OTag.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { raw, type I18nText } from "@/types/i18n";
import AssistantStepEvidence from "./AssistantStepEvidence.vue";
import { revealStep } from "./revealStep";
import { useStepActions } from "./useStepActions";
const HIT_AREA =
"relative max-md:before:absolute max-md:before:top-1/2 max-md:before:left-1/2 max-md:before:size-11 max-md:before:-translate-x-1/2 max-md:before:-translate-y-1/2 max-md:before:content-[''] pointer-coarse:before:absolute pointer-coarse:before:top-1/2 pointer-coarse:before:left-1/2 pointer-coarse:before:size-11 pointer-coarse:before:-translate-x-1/2 pointer-coarse:before:-translate-y-1/2 pointer-coarse:before:content-['']";
const props = withDefaults(
defineProps<{
n: number;
callId: string;
available: boolean;
/** "Queried default · 13:43–13:58 IST": what the step did. */
label: I18nText;
/** The cited step; with it the chip opens an evidence peek in place instead of jumping (D3-j). */
step?: StepFacts | null;
/** Steps in the answer's trail, for "step {i} of {total}". */
total?: number;
openable?: boolean;
sid?: Sid | null;
readonly?: boolean;
}>(),
{ step: null, total: 0, openable: true, sid: null, readonly: false },
);
const PILL = "bg-text-link/12 text-assistant-link border-transparent font-medium";
const { t } = useI18nTyped();
const host = props.readonly ? null : inject(assistantEvidenceHostKey, null);
const root = ref<HTMLElement | null>(null);
const peeking = ref(false);
// Show in steps moves focus to the trail, so closing the peek must not pull it back to the chip.
const refocus = ref(true);
const actions = useStepActions(
() => props.sid,
() => props.readonly,
"claim",
);
const props = defineProps<{
n: number;
callId: string;
available: boolean;
/** "Citation 1, query 1, 3 rows". */
label: I18nText;
}>();
const number = computed(() => raw(String(props.n)));
const name = computed(() => t("assistant.claim.name", { n: props.n, step: props.label }));
const peekTitle = computed(() => {
const title = t("assistant.claim.peekTitle", { n: props.n });
if (!props.step || !props.total) return title;
return t("assistant.turn.withElapsed", {
status: title,
elapsed: t("assistant.claim.stepOf", { i: props.step.n, total: props.total }),
});
});
const mutedName = computed(() =>
t("assistant.claim.name", { n: props.n, step: t("assistant.claim.notAvailable") }),
);
async function showInSteps(): Promise<void> {
refocus.value = false;
peeking.value = false;
if (props.step) await actions.showInSteps(props.step, root.value);
}
watch(peeking, (open) => {
if (open) refocus.value = true;
});
async function open(): Promise<void> {
if (props.sid && host)
await host.dispatch(props.sid, { kind: "open_step", callId: props.callId });
await revealStep(root.value, props.callId);
function open(event: MouseEvent | KeyboardEvent): void {
const from = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
void revealStep(from, props.callId);
}
</script>
@@ -61,122 +61,78 @@ const message = (block: ContentBlock): ChatMessage => ({
describe("AssistantFindings", () => {
afterEach(() => {
document.body.innerHTML = "";
track.mockReset();
track.mockClear();
});
const mountCard = (block: ContentBlock, turn: Record<string, unknown> = {}, readonly = false) => {
const mountFindings = (block: ContentBlock, extra: ContentBlock[] = []) => {
const evidence = fakeEvidence();
const base = message(block);
const wrapper = mount(AssistantFindings, {
props: {
block,
blockId: `b-${Math.random()}`,
message: message(block),
readonly,
turn: evidence.turn(turn),
blockId: "t1:0",
message: { ...base, contentBlocks: [...base.contentBlocks!, ...extra] },
turn: evidence.turn(),
},
attachTo: document.body,
global: { provide: readonly ? {} : evidence.provide },
global: { provide: evidence.provide },
});
return { wrapper, ...evidence };
return wrapper;
};
it("is a section named Findings with its sections in order and empty ones omitted", () => {
const { wrapper } = mountCard(findings);
const section = wrapper.get('[data-test="assistant-findings"]');
expect(section.element.tagName).toBe("SECTION");
expect(section.find(`#${section.attributes("aria-labelledby")}`).text()).toBe("Findings");
it("reads as prose: the conclusion, then evidence, Inferred, Ruled out and Next, no card", () => {
const wrapper = mountFindings(findings);
expect(wrapper.find('[data-test="o-card"]').exists()).toBe(false);
expect(wrapper.get('[data-test="assistant-findings-cause"]').text()).toBe(
"payments pool exhausted",
);
expect(wrapper.text()).toContain("Confidence: medium");
expect(wrapper.find('[data-test="assistant-findings-unknown"]').exists()).toBe(false);
expect(wrapper.find('[data-test="assistant-findings-ruled-out"]').exists()).toBe(true);
});
it("keeps every sub-section in one body, so stacked sections never squeeze each other", () => {
const { wrapper } = mountCard(findings);
const card = wrapper.get('[data-test="assistant-findings"]').element;
const bodies = Array.from(card.querySelectorAll("h4"), (heading) =>
heading.closest(".min-h-0"),
expect(wrapper.get('[data-test="assistant-answer-confidence"]').text()).toBe(
"Medium confidence",
);
expect(wrapper.get('[data-test="assistant-findings-evidence"]').text()).toContain(
"92% of errors are resets",
);
expect(wrapper.get('[data-test="assistant-findings-inferred"]').text()).toBe(
"Inferred: matches the pool ceiling",
);
expect(wrapper.get('[data-test="assistant-findings-ruled-out"]').text()).toBe(
"Ruled out: recent deploy",
);
expect(wrapper.get('[data-test="assistant-findings-next-steps"]').text()).toBe(
"Next: check max-connections",
);
expect(bodies.length).toBeGreaterThan(2);
expect(bodies[0]).not.toBeNull();
expect(new Set(bodies).size).toBe(1);
});
it("announces a failed outcome, clears the pick and retries it from Try again", async () => {
const { wrapper, host, sid } = mountCard(findings);
host.dispatch.mockResolvedValueOnce({ ok: false, reason: "failed" } as never);
await wrapper.get('[data-test="assistant-findings-outcome-yes"]').trigger("click");
await flushPromises();
expect(host.announce).toHaveBeenCalledWith(sid, "Couldn't send that. Try again.");
expect(
wrapper.get('[data-test="assistant-findings-outcome-yes"]').attributes("data-state"),
).not.toBe("on");
await wrapper.get('[data-test="assistant-findings-retry"]').trigger("click");
await flushPromises();
expect(host.dispatch).toHaveBeenLastCalledWith(sid, {
kind: "feedback",
messageId: "t1",
vote: "up",
reason: "cause:yes",
it("drops Next when the turn has follow-ups, which are the next step", () => {
const wrapper = mountFindings(findings, [{ type: "follow_ups", followUps: ["Show more"] }]);
expect(wrapper.find('[data-test="assistant-findings-next-steps"]').exists()).toBe(false);
});
it("says Not established for what the findings could not settle", () => {
const wrapper = mountFindings({
type: "findings",
findings: { ...findings.findings!, unknowns: ["what triggered it"] },
});
expect(host.announce).toHaveBeenLastCalledWith(
sid,
"Thanks, that helps calibrate the assistant",
expect(wrapper.get('[data-test="assistant-findings-unknown"]').text()).toBe(
"Not established: what triggered it",
);
});
it("tells verified from inferred in words, and only verified evidence gets a chip", () => {
const { wrapper } = mountCard(findings);
const items = wrapper.findAll('[data-test="assistant-findings-evidence"] li');
expect(items[0].text()).toContain("Verified by query");
expect(items[0].find('[data-test="assistant-claim-1"]').exists()).toBe(true);
expect(items[1].text()).toContain("Inferred");
expect(items[1].find('[data-test^="assistant-claim-"]').exists()).toBe(false);
it("leads the line with Inconclusive and keeps the plain text", () => {
const wrapper = mountFindings({
type: "findings",
findings: { ...findings.findings!, confidence: "inconclusive" },
});
expect(wrapper.get('[data-test="assistant-answer-confidence"]').text()).toBe("Inconclusive");
});
it("states an inconclusive result and asserts no cause", () => {
const inconclusive = {
...findings,
findings: { ...findings.findings!, confidence: "inconclusive" as const },
};
const { wrapper } = mountCard(inconclusive);
expect(wrapper.get('[data-test="assistant-findings-inconclusive"]').text()).toBe(
"Inconclusive: the assistant couldn't confirm a cause",
it("says it couldn't show findings, keeps them behind a closed disclosure, and counts it once", async () => {
const wrapper = mountFindings({ type: "findings", findings: null });
await flushPromises();
expect(wrapper.get('[data-test="assistant-findings-invalid"]').text()).toContain(
"Couldn't show findings.",
);
expect(wrapper.find('[data-test="assistant-findings-cause"]').exists()).toBe(false);
});
it("falls back to a notice and counts an invalid payload once, with no content", () => {
mountCard({ type: "findings", findings: null });
expect(document.body.textContent).toContain("Findings could not be shown as a card");
expect(wrapper.text()).toContain("Show raw findings");
expect(track).toHaveBeenCalledWith("ai_block_invalid", { block_kind: "findings" });
});
it("asks Was this the cause? on done only, and No opens an optional comment before sending", async () => {
const { wrapper, host } = mountCard(findings);
await wrapper.get('[data-test="assistant-findings-outcome-no"]').trigger("click");
expect(host.dispatch).not.toHaveBeenCalled();
await wrapper.get('[data-test="assistant-findings-comment"] textarea').setValue("dns");
await wrapper.get('[data-test="assistant-findings-send"]').trigger("click");
await flushPromises();
expect(host.dispatch).toHaveBeenCalledWith(expect.any(String), {
kind: "feedback",
messageId: "t1",
vote: "down",
reason: "cause:no",
comment: "dns",
});
expect(
mountCard(findings, { outcome: "stopped" })
.wrapper.find('[data-test="assistant-findings-outcome"]')
.exists(),
).toBe(false);
expect(
mountCard(findings, {}, true)
.wrapper.find('[data-test="assistant-findings-outcome"]')
.exists(),
).toBe(false);
});
});
@@ -13,210 +13,83 @@ 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 <http://www.gnu.org/licenses/>.
-->
<template>
<OText
v-if="!findings"
variant="meta"
as="p"
class="leading-5"
data-test="assistant-findings-invalid"
>
{{ t("assistant.findings.invalid") }}
</OText>
<div v-if="!findings" class="flex min-w-0 flex-col gap-1" data-test="assistant-findings-invalid">
<OText variant="meta" as="p">{{ t("assistant.findings.unreadable") }}</OText>
<OCollapsible
:label="t('assistant.findings.showRaw')"
:trigger-class="TOUCH_TARGET_CLASS"
data-test="assistant-findings-raw"
>
<OCodeBlock
:code="rawText"
lang="json"
contrast="aa"
:scroll-label="t('assistant.findings.showRaw')"
:copy-message="t('assistant.evidence.copied')"
/>
</OCollapsible>
</div>
<section
v-else
:aria-labelledby="titleId"
class="flex min-w-0 flex-col gap-2"
data-test="assistant-findings"
:data-confidence="findings.confidence"
>
<OCard variant="outlined" class="rounded-surface flex min-w-0 flex-col">
<OCardSection :role="SECTION_HEADER" dense>
<OText :id="titleId" variant="body-strong" as="h3">
{{ t("assistant.findings.title") }}
</OText>
</OCardSection>
<OCardSection :role="SECTION_BODY" dense class="flex flex-col gap-3">
<OText
v-if="inconclusive"
variant="body"
as="p"
class="break-words"
data-test="assistant-findings-inconclusive"
>
{{ t("assistant.findings.inconclusive") }}
</OText>
<div v-else class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<OText variant="label" as="h4">{{ t("assistant.findings.likelyCause") }}</OText>
<OTag
type="assistantConfidence"
:value="findings.confidence"
aria-hidden="true"
data-test="assistant-findings-confidence"
/>
<span class="sr-only">{{ confidenceName }}</span>
</div>
<OText variant="body" as="p" class="break-words" data-test="assistant-findings-cause">
{{ raw(findings.likelyCause) }}
</OText>
</div>
<div v-if="findings.evidence.length" class="flex min-w-0 flex-col gap-1">
<OText variant="label" as="h4">{{ t("assistant.findings.evidence") }}</OText>
<ul class="flex min-w-0 flex-col gap-1.5" data-test="assistant-findings-evidence">
<li
v-for="(item, index) in findings.evidence"
:key="index"
class="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1 text-sm"
:data-kind="item.kind"
>
<OIcon
:name="item.kind === 'verified' ? assistantGlyph.verified : assistantGlyph.inferred"
size="sm"
class="text-text-secondary mt-0.5 shrink-0"
aria-hidden="true"
/>
<OText variant="meta" class="shrink-0">
{{
item.kind === "verified"
? t("assistant.findings.verified")
: t("assistant.findings.inferred")
}}
</OText>
<span class="text-text-body min-w-0 flex-1 basis-40 break-words"
>{{ raw(item.text)
}}<AssistantClaimChip
v-if="item.kind === 'verified' && item.callId && numbers.get(item.callId)"
:n="numbers.get(item.callId)!"
:call-id="item.callId"
:available="!hidden && steps.some((step) => step.callId === item.callId)"
:label="citation(item.callId)"
:sid="turn?.sid ?? null"
:readonly="readonly"
/></span>
</li>
</ul>
</div>
<div v-for="list in lists" :key="list.key" class="flex min-w-0 flex-col gap-1">
<OText variant="label" as="h4">{{ list.title }}</OText>
<ul
class="text-text-body flex min-w-0 list-disc flex-col gap-1 ps-5 text-sm"
:data-test="`assistant-findings-${list.key}`"
>
<li v-for="(entry, index) in list.items" :key="index" class="break-words">
{{ raw(entry) }}
</li>
</ul>
</div>
</OCardSection>
</OCard>
<div
v-if="canAnswer"
class="flex min-w-0 flex-col gap-2"
data-test="assistant-findings-outcome"
<p
class="text-text-heading text-sm font-medium break-words"
data-test="assistant-findings-cause"
>
<OToggleGroup
:model-value="choice ?? undefined"
type="single"
:label="t('assistant.findings.wasThisTheCause')"
label-position="top"
class="max-md:w-full"
@update:model-value="(value) => choose(value as Cause)"
>
<OToggleGroupItem
v-for="option in OPTIONS"
:key="option.value"
:value="option.value"
:disabled="sending"
size="sm"
class="max-md:min-h-11 max-md:flex-1 pointer-coarse:min-h-11"
:data-test="`assistant-findings-outcome-${option.slug}`"
>
{{ t(option.label) }}
</OToggleGroupItem>
</OToggleGroup>
<div v-if="choice === 'cause:no' && !sent" class="flex min-w-0 flex-col gap-2">
<OTextarea
v-model="comment"
:label="t('assistant.findings.comment')"
:rows="1"
width="full"
data-test="assistant-findings-comment"
{{ raw(findings.likelyCause) }}
</p>
<AssistantAnswerMeta
:message="message"
:turn="turn"
:readonly="readonly"
:confidence="findings.confidence"
/>
<ul
v-if="verified.length"
class="text-text-body marker:text-text-secondary flex min-w-0 list-disc flex-col gap-0.5 ps-3 text-sm"
data-test="assistant-findings-evidence"
>
<li v-for="(item, index) in verified" :key="index" class="break-words" :data-kind="item.kind">
{{ raw(item.text)
}}<AssistantClaimChip
v-if="item.callId && numbers.get(item.callId)"
:n="numbers.get(item.callId)!"
:call-id="item.callId"
:available="!hidden && steps.some((step) => step.callId === item.callId)"
:label="citation(item.callId)"
/>
<OButton
variant="secondary"
size="sm"
:loading="sending"
:class="['self-start max-md:w-full', TOUCH_TARGET_CLASS]"
data-test="assistant-findings-send"
@click="send('cause:no')"
>
{{ t("assistant.findings.send") }}
</OButton>
</div>
<OText v-if="sent" variant="meta" as="p" data-test="assistant-findings-thanks">
{{ t("assistant.findings.thanks") }}
</OText>
<div v-else-if="failed" class="flex min-w-0 flex-wrap items-center gap-2">
<OText variant="meta" as="p" data-test="assistant-findings-failed">
{{ t("assistant.findings.failed") }}
</OText>
<OButton
variant="secondary"
size="sm"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-findings-retry"
@click="retry"
>
{{ t("assistant.findings.retry") }}
</OButton>
</div>
</div>
</li>
</ul>
<p
v-for="line in lines"
:key="line.key"
class="text-text-secondary text-sm break-words"
:data-test="`assistant-findings-${line.key}`"
>
{{ line.text }}
</p>
</section>
</template>
<script setup lang="ts">
import { computed, inject, onMounted, ref, useId } from "vue";
import { computed, inject, onMounted } from "vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import {
assistantEvidenceHostKey,
claimNumbers,
stepsOf,
turnAnnotationKey,
type AssistantTurnView,
} from "@/composables/assistant/pure";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OCard from "@/lib/core/Card/OCard.vue";
import OCardSection from "@/lib/core/Card/OCardSection.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import { claimNumbers, stepsOf, type AssistantTurnView } from "@/composables/assistant/pure";
import OCodeBlock from "@/lib/core/Code/OCodeBlock.vue";
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
import { trackAssistant } from "@/services/assistantAnalytics";
import type { ChatMessage, ContentBlock } from "@/ts/interfaces/chat";
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import AssistantAnswerMeta from "./AssistantAnswerMeta.vue";
import AssistantClaimChip from "./AssistantClaimChip.vue";
import { claimNumbersKey, stepCitation } from "./evidenceText";
type Cause = "cause:yes" | "cause:no" | "cause:unsure";
const OPTIONS: { value: Cause; slug: string; label: I18nKey }[] = [
{ value: "cause:yes", slug: "yes", label: "assistant.findings.yes" },
{ value: "cause:no", slug: "no", label: "assistant.findings.no" },
{ value: "cause:unsure", slug: "unsure", label: "assistant.findings.notSure" },
];
// OCardSection's own zones; bound, as its `role` prop is not an ARIA role.
const SECTION_HEADER = "header" as const;
const SECTION_BODY = "body" as const;
import { citationName, claimNumbersKey } from "./evidenceText";
// A block counts once per page load, so a re-render never repeats the event.
const reported = new Set<string>();
@@ -234,17 +107,9 @@ const props = withDefaults(
);
const { t } = useI18nTyped();
const evidence = props.readonly ? null : inject(assistantEvidenceHostKey, null);
const injectedNumbers = inject(claimNumbersKey, null);
const titleId = useId();
const comment = ref("");
const sending = ref(false);
const failed = ref<Cause | null>(null);
const picked = ref<Cause | null>(null);
const findings = computed(() => props.block?.findings ?? null);
const inconclusive = computed(() => findings.value?.confidence === "inconclusive");
const hidden = computed(() => props.turn?.hiddenByOwner === true);
const steps = computed(() => stepsOf(props.message, props.turn?.org ?? ""));
const numbers = computed(
@@ -254,77 +119,44 @@ const numbers = computed(
(findings.value?.evidence ?? []).flatMap((item) => (item.callId ? [item.callId] : [])),
),
);
const verified = computed(() =>
(findings.value?.evidence ?? []).filter((item) => item.kind === "verified"),
);
const rawText = computed(() => JSON.stringify(props.block ?? {}, null, 2));
const confidenceName = computed(() =>
t("assistant.findings.confidence", {
level: t(`assistant.findings.level.${findings.value?.confidence ?? "low"}`),
}),
// Follow-ups are the next step, so the Next line would say it twice.
const followUpsSent = computed(
() => !!props.message.contentBlocks?.some((block) => block.type === "follow_ups"),
);
const lists = computed(() => {
const lines = computed<{ key: string; text: I18nText }[]>(() => {
const current = findings.value;
if (!current) return [];
const inferred = current.evidence.filter((item) => item.kind === "inferred");
const next = followUpsSent.value ? [] : current.nextSteps.slice(0, 1);
const list = (items: string[]) => ({ text: raw(items.join("; ")) });
return [
{ key: "ruled-out", title: t("assistant.findings.ruledOut"), items: current.ruledOut },
{ key: "unknown", title: t("assistant.findings.unknown"), items: current.unknowns },
{ key: "next-steps", title: t("assistant.findings.nextSteps"), items: current.nextSteps },
].filter((list) => list.items.length > 0);
inferred.length && {
key: "inferred",
text: t("assistant.findings.inferredLine", list(inferred.map((item) => item.text))),
},
current.ruledOut.length && {
key: "ruled-out",
text: t("assistant.findings.ruledOutLine", list(current.ruledOut)),
},
current.unknowns.length && {
key: "unknown",
text: t("assistant.findings.notEstablished", list(current.unknowns)),
},
next.length && { key: "next-steps", text: t("assistant.findings.next", list(next)) },
].filter((line) => !!line);
});
const sid = computed(() => props.turn?.sid ?? null);
const annotationKey = computed(() => turnAnnotationKey(props.turn?.turnId ?? "", "findings"));
const stored = computed(() => {
const session = sid.value && evidence ? evidence.session(sid.value) : null;
return session?.annotations[annotationKey.value]?.feedback ?? null;
});
const sent = computed(() => !!stored.value);
const choice = computed<Cause | null>(
() => OPTIONS.find((option) => option.value === stored.value?.reason)?.value ?? picked.value,
);
// Outcome feedback is only for a finished answer in the reader's own chat.
const canAnswer = computed(
() =>
!props.readonly &&
!!evidence &&
!!sid.value &&
props.turn?.outcome === "done" &&
!!findings.value,
);
function citation(callId: string): I18nText {
const step = steps.value.find((candidate) => candidate.callId === callId);
return step ? stepCitation(step, t) : t("assistant.claim.notAvailable");
}
function choose(value: Cause | undefined): void {
if (!value || sending.value || sent.value) return;
picked.value = value;
if (value !== "cause:no") void send(value);
}
function retry(): void {
if (failed.value) void send(failed.value);
}
async function send(value: Cause): Promise<void> {
if (!sid.value || !evidence || !props.turn || sending.value) return;
sending.value = true;
failed.value = null;
const result = await evidence.dispatch(sid.value, {
kind: "feedback",
messageId: props.turn.turnId,
vote: value === "cause:yes" ? "up" : "down",
reason: value,
...(value === "cause:no" && comment.value.trim() && { comment: comment.value.trim() }),
});
sending.value = false;
// A failed pick is cleared so the toggle can be chosen again.
if (!result.ok) picked.value = null;
failed.value = result.ok ? null : value;
evidence.announce(
sid.value,
result.ok ? t("assistant.findings.thanks") : t("assistant.findings.failed"),
);
return step
? citationName(numbers.value.get(callId) ?? step.n, step, t)
: t("assistant.claim.notAvailable");
}
onMounted(() => {
@@ -22,11 +22,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:call-id="target.callId"
:available="available(target.callId)"
:label="label(target.callId)"
:step="stepOf ? stepOf(target.callId) : null"
:total="total"
:openable="openable"
:sid="sid"
:readonly="readonly"
/>
</Teleport>
</template>
@@ -34,7 +29,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
import type { Sid, StepFacts } from "@/composables/assistant/pure";
import type { I18nText } from "@/types/i18n";
import AssistantClaimChip from "./AssistantClaimChip.vue";
@@ -45,23 +39,13 @@ interface ClaimSlot {
callId: string;
}
const props = withDefaults(
defineProps<{
/** The block's cited call ids; a slot's `data-claim` is an index into it. */
claims: string[];
numbers: Map<string, number>;
available: (callId: string) => boolean;
label: (callId: string) => I18nText;
/** The cited step, for the chip's evidence peek; without it the chip jumps to the trail. */
stepOf?: ((callId: string) => StepFacts | null) | null;
/** Steps in the answer's trail, for "step {i} of {total}". */
total?: number;
openable?: boolean;
sid?: Sid | null;
readonly?: boolean;
}>(),
{ stepOf: null, total: 0, openable: true, sid: null, readonly: false },
);
const props = defineProps<{
/** The block's cited call ids; a slot's `data-claim` is an index into it. */
claims: string[];
numbers: Map<string, number>;
available: (callId: string) => boolean;
label: (callId: string) => I18nText;
}>();
const anchor = ref<HTMLElement | null>(null);
const targets = shallowRef<ClaimSlot[]>([]);
@@ -16,6 +16,8 @@
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { turnAnnotationKey } from "@/composables/assistant/pure";
import type { ChatMessage, ContentBlock } from "@/ts/interfaces/chat";
import { raw } from "@/types/i18n";
@@ -37,7 +39,7 @@ const query = (callId: string, stream: string, stepState = "done"): ContentBlock
stepState: stepState as ContentBlock["stepState"],
callArgs: {
stream_name: stream,
sql: `SELECT * FROM "${stream}"`,
sql: `SELECT count(*) FROM "${stream}" WHERE service='cart' AND status>=500 GROUP BY ${stream}`,
start_time: FROM * 1000,
end_time: TO * 1000,
},
@@ -62,79 +64,98 @@ const message = (blocks: ContentBlock[]): ChatMessage => ({
describe("AssistantQueriesGroup", () => {
afterEach(() => (document.body.innerHTML = ""));
const mountGroup = (blocks: ContentBlock[], numbers = new Map<string, number>()) => {
const { provide, turn, host, sid } = fakeEvidence();
const mountGroup = (
blocks: ContentBlock[],
props: Record<string, unknown> = {},
annotate?: (evidence: ReturnType<typeof fakeEvidence>) => void,
turn: Record<string, unknown> = {},
) => {
const evidence = fakeEvidence();
annotate?.(evidence);
const wrapper = mount(AssistantQueriesGroup, {
props: { message: message(blocks), numbers, turn: turn() },
props: { message: message(blocks), turn: evidence.turn(turn), ...props },
attachTo: document.body,
global: { plugins: [fakeRouter()], provide },
global: {
plugins: [fakeRouter([{ path: "/logs", name: "logs", component: { render: () => null } }])],
provide: evidence.provide,
},
});
return { wrapper, host, sid };
return { wrapper, ...evidence };
};
const rows = (wrapper: ReturnType<typeof mountGroup>["wrapper"]) =>
wrapper
.findAll('[data-test^="assistant-queries-row-"]')
.map((row) => row.attributes("data-test")!.replace("assistant-queries-row-", ""));
it("renders nothing when the turn ran no query", () => {
const { wrapper } = mountGroup([schema]);
expect(wrapper.find('[data-test="assistant-queries-group"]').exists()).toBe(false);
it("shows one closed line naming the queries and their window", () => {
const { wrapper } = mountGroup([query("a", "default"), query("b", "default")]);
const line = wrapper.get('[data-test="assistant-queries-line"]');
expect(line.text()).toMatch(/^2 queries, \d\d:\d\d–\d\d:\d\d$/);
expect(line.attributes("aria-expanded")).toBe("false");
expect(wrapper.find('[data-test="assistant-queries-rows"]').exists()).toBe(false);
});
it("lists cited queries first by claim number, the first open, and the rest behind Show more (D3-h)", async () => {
const numbers = new Map([
["q3", 1],
["q1", 2],
it("opens to numbered rows with the SQL fragment and the row count, the table only when it differs", async () => {
const { wrapper } = mountGroup([
query("a", "default"),
query("b", "default"),
query("c", "other"),
]);
const { wrapper } = mountGroup([query("q1", "a"), query("q2", "b"), query("q3", "c")], numbers);
await flushPromises();
expect(wrapper.get('[data-test="assistant-queries-cited"]').text()).toContain("2 cited");
expect(rows(wrapper)).toEqual(["q3", "q1"]);
expect(wrapper.find('[data-test="assistant-step-evidence-q3"]').exists()).toBe(true);
expect(wrapper.find('[data-test="assistant-step-evidence-q1"]').exists()).toBe(false);
const more = wrapper.get('[data-test="assistant-queries-more"]');
expect(more.text()).toBe("Show 1 more");
await more.trigger("click");
expect(rows(wrapper)).toEqual(["q3", "q1", "q2"]);
await wrapper.get('[data-test="assistant-queries-line"]').trigger("click");
const row = wrapper.get('[data-test="assistant-queries-row-a"]');
expect(row.text()).toContain("status>=500 GROUP BY default");
expect(row.text()).not.toContain("service='cart'");
expect(row.get('[data-test="assistant-queries-count-a"]').text()).toBe("4 rows");
expect(row.text()).not.toContain("default ");
expect(wrapper.get('[data-test="assistant-queries-row-c"]').text()).toContain("other");
});
it("with no citation shows the first query open and never lists a failed one", async () => {
const { wrapper } = mountGroup([query("q1", "a"), query("q2", "b", "failed")]);
await flushPromises();
expect(rows(wrapper)).toEqual(["q1"]);
expect(wrapper.find('[data-test="assistant-step-evidence-q1"]').exists()).toBe(true);
expect(wrapper.find('[data-test="assistant-queries-more"]').exists()).toBe(false);
it("keeps a step that ran no query as its own row, so nothing the agent did is lost", async () => {
const { wrapper } = mountGroup([schema, query("a", "default")]);
await wrapper.get('[data-test="assistant-queries-line"]').trigger("click");
expect(wrapper.find('[data-test="assistant-queries-row-s1"]').exists()).toBe(true);
expect(wrapper.get('[data-test="assistant-queries-line"]').text()).toMatch(/^1 query, /);
});
it("says what the assistant got, and Run now opens the query in its explorer without running it", async () => {
const { wrapper, host, sid } = mountGroup([query("q1", "a")]);
it("opens a citation's line when the answer asks for that step, and leaves the row closed", async () => {
const article = document.createElement("article");
document.body.appendChild(article);
const evidence = fakeEvidence();
const wrapper = mount(AssistantQueriesGroup, {
props: { message: message([query("a", "default")]), turn: evidence.turn() },
attachTo: article,
global: {
plugins: [fakeRouter([{ path: "/logs", name: "logs", component: { render: () => null } }])],
provide: evidence.provide,
},
});
await flushPromises();
const evidence = wrapper.get('[data-test="assistant-step-evidence-q1"]');
expect(evidence.get('[data-test="assistant-step-evidence-line"]').text()).toContain(
"The assistant got 4 rows in",
);
const run = evidence.get('[data-test="assistant-step-evidence-run"]');
expect(run.text()).toContain("Run now");
await run.trigger("click");
article.dispatchEvent(new CustomEvent("o2-assistant-open-step", { detail: { callId: "a" } }));
await flushPromises();
expect(host.dispatch).toHaveBeenCalledWith(
sid,
expect.objectContaining({ kind: "open_in_explorer", explorer: "logs" }),
);
expect(host.dispatch).not.toHaveBeenCalledWith(
sid,
expect.objectContaining({ kind: "run_query" }),
);
expect(wrapper.find('[data-test="assistant-queries-rows"]').exists()).toBe(true);
expect(wrapper.find('[data-test="assistant-queries-body-a"]').exists()).toBe(false);
});
it("toggles a row open and shut on its header", async () => {
const { wrapper } = mountGroup([query("q1", "a")]);
await flushPromises();
const row = wrapper.get('[data-test="assistant-queries-row-q1"]');
expect(row.attributes("aria-expanded")).toBe("true");
await row.trigger("click");
expect(row.attributes("aria-expanded")).toBe("false");
expect(wrapper.find('[data-test="assistant-step-evidence-q1"]').exists()).toBe(false);
it("offers the wider window on the line after an empty default window, and only the click sends", async () => {
const empty: ContentBlock = {
...query("a", "default"),
summary: { count: 0, took: 1 } as never,
result: { hits: [], total: 0 },
};
const { wrapper, host, sid } = mountGroup([empty], {}, (evidence) =>
evidence.store.annotate(evidence.sid, turnAnnotationKey("t1", "window"), {
homeDefaultWindow: true,
}),
);
const offer = wrapper.get('[data-test="assistant-rerun-wider"]');
expect(host.dispatch).not.toHaveBeenCalled();
await offer.trigger("click");
expect(host.dispatch).toHaveBeenCalledWith(sid, { kind: "rerun_wider", turnId: "t1" });
});
it("explains unavailable citations under the line, and shows nothing but a count when the owner hid them", () => {
const { wrapper } = mountGroup([query("a", "app_logs")], { unavailable: 2, readonly: true });
expect(wrapper.get('[data-test="assistant-queries-no-access"]').text()).toContain(
"2 citations unavailable: no access to app_logs",
);
const hidden = mountGroup([query("a", "default")], {}, undefined, { hiddenByOwner: true });
expect(hidden.wrapper.find('[data-test="assistant-trail-hidden"]').exists()).toBe(true);
expect(hidden.wrapper.find('[data-test="assistant-queries-line"]').exists()).toBe(false);
});
});
@@ -13,156 +13,344 @@ 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 <http://www.gnu.org/licenses/>.
-->
<template>
<section
v-if="queries.length"
v-if="steps.length"
ref="root"
role="group"
:aria-labelledby="headingId"
:aria-label="lineText"
class="flex min-w-0 flex-col gap-1"
data-test="assistant-queries-group"
>
<div class="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
<OText :id="headingId" variant="label" as="h3" class="leading-5">
{{ t("assistant.evidence.queriesHeading") }}
<div class="text-text-secondary flex min-w-0 flex-wrap items-center gap-x-1 text-xs leading-5">
<OText v-if="hidden" variant="meta" as="p" data-test="assistant-trail-hidden">
{{ lineText }}
</OText>
<OText v-if="cited.length" variant="meta" data-test="assistant-queries-cited">
· {{ t("assistant.evidence.queriesCited", { count: cited.length }, cited.length) }}
</OText>
</div>
<ul class="flex min-w-0 flex-col gap-0.5">
<li v-for="step in shown" :key="step.callId" class="flex min-w-0 flex-col">
<OButton
v-else
variant="ghost"
size="chip"
:aria-expanded="open ? 'true' : 'false'"
:class="['text-text-secondary -ms-1', TOUCH_TARGET_CLASS]"
data-test="assistant-queries-line"
@click="open = !open"
>
{{ lineText }}
<OIcon
:name="open ? assistantGlyph.collapse : assistantGlyph.expand"
size="xs"
class="text-text-secondary"
aria-hidden="true"
/>
</OButton>
<template v-if="widerOffer">
<span aria-hidden="true">·</span>
<OButton
variant="ghost"
size="sm"
:aria-expanded="opened.includes(step.callId) ? 'true' : 'false'"
:class="['w-full min-w-0 justify-start', TOUCH_TARGET_CLASS]"
:data-test="`assistant-queries-row-${step.callId}`"
@click="toggle(step.callId)"
variant="ghost-primary"
size="chip"
:disabled="!!blocked"
:focusable-unavailable="!!blocked"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-rerun-wider"
@click="rerunWider"
>
<OIcon
:name="opened.includes(step.callId) ? assistantGlyph.collapse : assistantGlyph.expand"
size="sm"
aria-hidden="true"
/>
<OTag
v-if="numbers.get(step.callId)"
type="assistantClaim"
value="available"
:label="raw(String(numbers.get(step.callId)))"
:aria-label="t('assistant.claim.peekTitle', { n: numbers.get(step.callId)! })"
/>
<OTruncatedText class="text-text-body min-w-0 flex-1 text-start text-xs">{{
stepLine(step, t)
}}</OTruncatedText>
{{ t("assistant.footer.tryWider", { duration: widerDuration }) }}
<template v-if="blocked" #unavailable-reason>{{ blocked }}</template>
</OButton>
<div v-if="opened.includes(step.callId)" class="min-w-0 ps-7 pe-1 pt-1 pb-2">
<AssistantStepEvidence
:step="step"
surface="query"
:sid="turn?.sid ?? null"
:readonly="readonly"
:openable="turn?.openable ?? true"
@show-in-steps="actions.showInSteps(step, root)"
/>
</div>
</template>
</div>
<div
v-if="unavailable > 0"
class="text-text-secondary flex min-w-0 flex-wrap items-center gap-x-1 text-xs leading-5"
data-test="assistant-queries-no-access"
>
<span>{{ noAccessLine }}</span>
<template v-if="!readonly">
<span aria-hidden="true">·</span>
<AssistantAskAdminPopover kind="access" :org="turn?.org ?? ''">
<template #trigger>
<OButton
variant="ghost-primary"
size="chip"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-queries-ask-admin"
>
{{ t("assistant.evidence.askAdmin") }}
</OButton>
</template>
</AssistantAskAdminPopover>
</template>
</div>
<ul v-if="open && !hidden" class="flex min-w-0 flex-col" data-test="assistant-queries-rows">
<li v-for="row in rows" :key="row.step.callId" class="min-w-0">
<OCollapsible
:model-value="expanded.includes(row.step.callId)"
:trigger-class="ROW_CLASS"
:data-test="`assistant-queries-row-${row.step.callId}`"
@update:model-value="(value: boolean) => toggle(row.step.callId, value)"
>
<template #trigger="{ open: rowOpen }">
<span
class="flex min-w-0 flex-1 items-center gap-2 text-xs"
:data-step-header="row.step.callId"
>
<span class="text-text-link w-3 shrink-0 text-start font-medium">
{{ row.step.n }}
</span>
<OTruncatedText
:class="[
'min-w-0 text-start',
row.mono ? 'font-mono' : '',
row.step.state === 'failed' ? 'text-status-error-text' : 'text-text-body',
]"
>{{ row.text }}</OTruncatedText
>
<OCode v-if="row.table" class="shrink-0">{{ row.table }}</OCode>
<span
v-if="row.count"
class="text-text-secondary shrink-0"
:data-test="`assistant-queries-count-${row.step.callId}`"
>
{{ row.count }}
</span>
<span class="flex-1" />
<OIcon
:name="rowOpen ? assistantGlyph.collapse : assistantGlyph.expand"
size="xs"
class="text-text-secondary shrink-0"
aria-hidden="true"
/>
</span>
</template>
<div
class="flex min-w-0 flex-col gap-1 ps-5 pt-1 pb-2"
:data-test="`assistant-queries-body-${row.step.callId}`"
>
<OText v-if="row.step.error" variant="meta" as="p" class="break-words">
{{ raw(row.step.error) }}
</OText>
<OText v-if="row.step.suggestion" variant="meta" as="p" class="break-words">
{{ raw(row.step.suggestion) }}
</OText>
<AssistantQueryText
v-if="row.step.query"
:text="row.step.query.text"
:language="row.step.query.language"
:label="row.text"
:data-test="`assistant-queries-sql-${row.step.callId}`"
/>
<div
v-if="row.step.query"
class="text-text-secondary flex min-w-0 flex-wrap items-center gap-x-2 text-xs"
>
<OButton
v-if="canOpen(row.step)"
variant="ghost-primary"
size="chip"
as="a"
:href="hrefOf(row.step)"
target="_blank"
rel="noopener noreferrer"
:icon-left="assistantGlyph.newTab"
:class="TOUCH_TARGET_CLASS"
:data-test="`assistant-queries-open-${row.step.callId}`"
@click="trackOpen"
>
{{ t("assistant.query.open", { explorer: explorerName(row.step) }) }}
</OButton>
<span v-if="row.step.range">{{ rangeText(row.step.range) }}</span>
</div>
</div>
</OCollapsible>
</li>
</ul>
<OButton
v-if="hidden.length && !showMore"
variant="ghost"
size="sm"
:class="['self-start', TOUCH_TARGET_CLASS]"
data-test="assistant-queries-more"
@click="showMore = true"
>
{{ t("assistant.evidence.showMore", { count: hidden.length }, hidden.length) }}
</OButton>
</section>
</template>
<script lang="ts">
import type { StepFacts } from "@/composables/assistant/pure";
/** The answer's executed queries in reading order: cited ones first by claim number, then the rest as they ran (D3-h). */
export function orderedQueries(
steps: StepFacts[],
numbers: Map<string, number>,
): { cited: StepFacts[]; rest: StepFacts[] } {
const ran = steps.filter((step) => !!step.query && step.state === "done");
const cited = ran
.filter((step) => numbers.has(step.callId))
.sort((a, b) => numbers.get(a.callId)! - numbers.get(b.callId)!);
return { cited, rest: ran.filter((step) => !numbers.has(step.callId)) };
}
</script>
<script setup lang="ts">
import { computed, ref, useId, watch } from "vue";
import { computed, defineAsyncComponent, inject, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import { stepsOf, type AssistantTurnView } from "@/composables/assistant/pure";
import OTag from "@/lib/core/Badge/OTag.vue";
import { widerPeriodText } from "@/composables/assistant";
import {
answerQueriesAllEmpty,
assistantEvidenceHostKey,
isTerminal,
queryCount,
stepsOf,
turnAnnotationKey,
turnLockKey,
useTurnLock,
windowsOf,
type AssistantTurnView,
type StepFacts,
} from "@/composables/assistant/pure";
import OButton from "@/lib/core/Button/OButton.vue";
import OCode from "@/lib/core/Code/OCode.vue";
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { trackAssistant } from "@/services/assistantAnalytics";
import type { ChatMessage } from "@/ts/interfaces/chat";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { explorerOf, explorerTarget } from "@/utils/assistant/explorerRoutes";
import { formatCount } from "@/utils/formatters";
import AssistantStepEvidence from "./AssistantStepEvidence.vue";
import { stepLine } from "./evidenceText";
import { useStepActions } from "./useStepActions";
import AssistantQueryText from "./AssistantQueryText.vue";
import { clockRangeText, explorerLabel, queryFragments, rangeText, stepLine } from "./evidenceText";
import { OPEN_STEP_EVENT } from "./revealStep";
const ROW_CLASS = "min-h-7 w-full px-0 py-0 hover:bg-surface-subtle max-md:min-h-11";
// The request flow reads the org's members, so it loads only when the line offers Ask an admin.
const AssistantAskAdminPopover = defineAsyncComponent(
() => import("@/components/ai-assistant/gate/AssistantAskAdminPopover.vue"),
);
const props = withDefaults(
defineProps<{
message: ChatMessage;
/** Claim numbers by call id, shared with the prose chips. */
numbers: Map<string, number>;
readonly?: boolean;
turn?: AssistantTurnView | null;
/** Citations the reader may not open; the line says why. */
unavailable?: number;
}>(),
{ readonly: false, turn: null },
{ readonly: false, turn: null, unavailable: 0 },
);
const { t } = useI18nTyped();
const headingId = `assistant-queries-${useId()}`;
const router = useRouter();
const evidence = props.readonly ? null : inject(assistantEvidenceHostKey, null);
const root = ref<HTMLElement | null>(null);
const showMore = ref(false);
const actions = useStepActions(
() => props.turn?.sid ?? null,
() => props.readonly,
"query",
const open = ref(false);
const expanded = ref<string[]>([]);
const hidden = computed(() => props.turn?.hiddenByOwner === true);
const steps = computed(() => stepsOf(props.message, props.turn?.org ?? ""));
const queries = computed(() => queryCount(steps.value));
const lineText = computed<I18nText>(() => {
const count = steps.value.length;
if (hidden.value) return t("assistant.steps.hiddenByOwner", { count }, count);
if (!queries.value) return t("assistant.steps.summary", { count }, count);
const windows = windowsOf(steps.value);
if (!windows.length)
return t("assistant.footer.queries", { count: queries.value }, queries.value);
const range = clockRangeText({
from: Math.min(...windows.map((window) => window.from)),
to: Math.max(...windows.map((window) => window.to)),
});
return t("assistant.evidence.line", { count: queries.value, range }, queries.value);
});
const chatStream = computed(() => {
const seen = new Map<string, number>();
for (const step of steps.value)
if (step.query?.stream) seen.set(step.query.stream, (seen.get(step.query.stream) ?? 0) + 1);
return [...seen].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
});
const fragments = computed(() => {
const sql = steps.value.filter((step) => step.query?.language === "sql");
const parts = queryFragments(sql.map((step) => step.query!.text));
return new Map(sql.map((step, index) => [step.callId, parts[index]]));
});
const rows = computed(() =>
steps.value.map((step) => {
const stream = step.query?.stream;
return {
step,
mono: !!step.query,
text: step.query
? raw(fragments.value.get(step.callId) ?? step.query.text)
: stepLine(step, t),
table: stream && stream !== chatStream.value ? raw(stream) : null,
count:
step.query && step.rows !== null
? t("assistant.steps.rows", { count: formatCount(step.rows) }, step.rows)
: null,
};
}),
);
const steps = computed(() =>
props.turn?.hiddenByOwner ? [] : stepsOf(props.message, props.turn?.org ?? ""),
);
const ordered = computed(() => orderedQueries(steps.value, props.numbers));
const cited = computed(() => ordered.value.cited);
const queries = computed(() => [...ordered.value.cited, ...ordered.value.rest]);
// With no citation the first query still shows, so the group never opens on a lone "Show more".
const lead = computed(() => (cited.value.length ? cited.value : ordered.value.rest.slice(0, 1)));
const hidden = computed(() => queries.value.slice(lead.value.length));
const shown = computed(() => (showMore.value ? queries.value : lead.value));
const lock = useTurnLock(() => props.turn?.sid ?? null);
const blocked = computed<I18nText | null>(() => {
if (lock.value) return t(turnLockKey(lock.value));
return evidence?.capability().creditMode === "exhausted"
? t("assistant.message.creditsUsedUp")
: null;
});
const opened = ref<string[]>([]);
// Only the click sends; the default range is never widened by itself (PO A26).
const widerOffer = computed(() => {
const turn = props.turn;
if (!evidence || props.readonly || !turn?.sid || !turn.isLast || turn.outcome !== "done")
return false;
const session = evidence.session(turn.sid);
const idle = !session?.turn || session.turn.state === "idle" || isTerminal(session.turn);
const sent = session?.annotations[turnAnnotationKey(turn.turnId, "window")];
return !!sent?.homeDefaultWindow && idle && answerQueriesAllEmpty(props.message, turn.org);
});
const widerDuration = computed(() => widerPeriodText());
function toggle(callId: string): void {
opened.value = opened.value.includes(callId)
? opened.value.filter((id) => id !== callId)
: [...opened.value, callId];
const noAccessLine = computed<I18nText>(() => {
const n = props.unavailable;
const stream = chatStream.value;
return stream
? t("assistant.evidence.noAccess", { n, stream: raw(stream) }, n)
: t("assistant.evidence.noAccessPlain", { n }, n);
});
function toggle(callId: string, value: boolean): void {
expanded.value = value
? [...expanded.value, callId]
: expanded.value.filter((id) => id !== callId);
}
// The lead opens once, when cited or alone; the reader's own toggles are kept after that.
watch(
() => lead.value[0]?.callId,
(first, before) => {
if (first && !before && (cited.value.length > 0 || queries.value.length === 1))
opened.value = [first];
},
{ immediate: true },
);
function canOpen(step: StepFacts): boolean {
const language = step.query?.language;
const own = evidence?.currentOrg();
return (
props.turn?.openable !== false &&
(language === "sql" || language === "promql") &&
(!own || !step.query?.org || own === step.query.org)
);
}
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;
}
function explorerName(step: StepFacts): I18nText {
return explorerLabel(step.query ? explorerOf(step.query) : "logs", t);
}
function trackOpen(): void {
trackAssistant("ai_evidence_action", { action: "open_explorer", block_kind: "query" });
}
function rerunWider(): void {
const sid = props.turn?.sid;
if (!sid || !evidence || blocked.value || !props.turn) return;
void evidence.dispatch(sid, { kind: "rerun_wider", turnId: props.turn.turnId });
}
// A citation opens the line so its row exists; the row itself stays closed until the reader opens it.
function onOpenStep(event: Event): void {
const callId = (event as CustomEvent<{ callId?: string }>).detail?.callId;
if (callId && steps.value.some((step) => step.callId === callId)) open.value = true;
}
watch(root, (el, _old, onCleanup) => {
const article = el?.closest("article");
if (!article) return;
article.addEventListener(OPEN_STEP_EVENT, onOpenStep);
onCleanup(() => article.removeEventListener(OPEN_STEP_EVENT, onOpenStep));
});
</script>
@@ -1,132 +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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="flex min-w-0 flex-col gap-2" :data-test="`assistant-step-evidence-${step.callId}`">
<OText
variant="meta"
as="p"
class="leading-5 break-words"
data-test="assistant-step-evidence-line"
>
{{ summary }}
</OText>
<AssistantQueryText
v-if="step.query"
:text="step.query.text"
:language="step.query.language"
:label="t('assistant.evidence.resultsOf', { name: stepLine(step, t) })"
:data-test="`assistant-step-evidence-query-${step.callId}`"
/>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<OButton
v-if="runnable"
variant="secondary"
size="sm"
as="a"
:href="actions.hrefOf(step)"
:target="actions.opensElsewhere.value ? '_blank' : undefined"
:rel="actions.opensElsewhere.value ? 'noopener noreferrer' : undefined"
:icon-left="assistantGlyph.run"
:icon-right="actions.opensElsewhere.value ? assistantGlyph.newTab : undefined"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-step-evidence-run"
@click="actions.open(step, $event)"
>
{{ t("assistant.evidence.runNow") }}
<OTooltip :content="runHint" />
</OButton>
<AssistantIconButton
v-if="step.query"
:label="t('assistant.query.copy')"
:icon="assistantGlyph.copy"
data-test="assistant-step-evidence-copy"
@click="actions.copy(step)"
/>
<OButton
variant="ghost-primary"
size="sm"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-step-evidence-show"
@click="emit('show-in-steps')"
>
{{ t("assistant.claim.showInSteps") }}
</OButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import AssistantIconButton from "@/components/ai-assistant/common/AssistantIconButton.vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import type { Sid, StepFacts } from "@/composables/assistant/pure";
import OButton from "@/lib/core/Button/OButton.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { explorerOf } from "@/utils/assistant/explorerRoutes";
import { formatCount } from "@/utils/formatters";
import AssistantQueryText from "./AssistantQueryText.vue";
import { explorerLabel, rangeText, stepLine } from "./evidenceText";
import { useStepActions, type StepActionSurface } from "./useStepActions";
const props = withDefaults(
defineProps<{
step: StepFacts;
surface: StepActionSurface;
sid?: Sid | null;
readonly?: boolean;
/** False on a readonly page with no explorer behind it. */
openable?: boolean;
}>(),
{ sid: null, readonly: false, openable: true },
);
const emit = defineEmits<{ (e: "show-in-steps"): void }>();
const { t } = useI18nTyped();
const actions = useStepActions(
() => props.sid,
() => props.readonly,
props.surface,
);
// Run now only loads the query into its explorer; a VRL or regex step has none.
const runnable = computed(
() =>
props.openable && !!props.step.query && ["sql", "promql"].includes(props.step.query.language),
);
const runHint = computed<I18nText>(() =>
t("assistant.evidence.runNowHint", {
explorer: explorerLabel(props.step.query ? explorerOf(props.step.query) : "logs", t),
}),
);
// What the assistant itself got, never a fresh result: the step's own count and window.
const summary = computed<I18nText>(() => {
const step = props.step;
if (!step.query || step.rows === null) return stepLine(step, t);
const count = formatCount(step.rows);
return step.range
? t("assistant.evidence.sawRowsIn", { count, range: rangeText(step.range) }, step.rows)
: t("assistant.evidence.sawRows", { count }, step.rows);
});
</script>
@@ -13,12 +13,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it } from "vitest";
import { computed } from "vue";
import { setRuntimeHandle } from "@/composables/assistant/runtimeHandle";
import { turnAnnotationKey } from "@/composables/assistant/pure";
import type { ChatMessage, ContentBlock } from "@/ts/interfaces/chat";
import { raw } from "@/types/i18n";
@@ -26,51 +22,14 @@ import { raw } from "@/types/i18n";
import { fakeEvidence } from "../__tests__/fakeEvidence";
import AssistantTurnFooter from "./AssistantTurnFooter.vue";
const FROM = Date.UTC(2026, 9, 14, 8, 13);
const TO = FROM + 15 * 60_000;
const query = (id: string, from = FROM, to = TO): ContentBlock => ({
type: "tool_call",
tool: "SearchSQL",
call_id: id,
stepState: "done",
callArgs: {
stream_name: "default",
sql: "SELECT 1",
start_time: from * 1000,
end_time: to * 1000,
},
});
const answer = (blocks: ContentBlock[], footer: ContentBlock["footer"] = {}): ChatMessage => ({
const answer = (footer: ContentBlock["footer"] = {}): ChatMessage => ({
role: "assistant",
content: raw(""),
contentBlocks: [...blocks, { type: "text", text: "x" }, { type: "footer", footer }],
});
const prompt: ChatMessage = {
role: "user",
content: raw("why"),
chips: [
{
id: "page:timeRange",
kind: "time",
label: raw("Time · 13:43–13:58"),
ref: { type: "time", org: "default", from: FROM, to: TO },
sizeBytes: 0,
provenance: "page",
sent: "whitelist",
},
{
id: "page:stream",
kind: "stream",
label: raw("Stream · default"),
sizeBytes: 0,
provenance: "page",
sent: "whitelist",
},
contentBlocks: [
{ type: "text", text: "x" },
{ type: "footer", footer },
],
};
});
describe("AssistantTurnFooter", () => {
afterEach(() => (document.body.innerHTML = ""));
@@ -78,229 +37,40 @@ describe("AssistantTurnFooter", () => {
const mountFooter = (
message: ChatMessage,
turn: Record<string, unknown> = {},
evidenceOver: Parameters<typeof fakeEvidence>[0] = {},
readonly = false,
creditMode?: "free" | "exhausted" | "pay_as_you_go",
) => {
const evidence = fakeEvidence(evidenceOver);
const wrapper = mount(AssistantTurnFooter, {
props: { message, readonly, turn: evidence.turn({ prompt, ...turn }) },
attachTo: document.body,
global: { provide: readonly ? {} : evidence.provide },
const evidence = fakeEvidence(creditMode ? { creditMode } : {});
return mount(AssistantTurnFooter, {
props: { message, turn: evidence.turn(turn) },
global: { provide: evidence.provide },
});
return { wrapper, ...evidence };
};
it("states the evaluated window, the query count, the flat credit on Cloud and Re-run", async () => {
const { wrapper, host } = mountFooter(
answer([query("c3")]),
{},
{ creditMode: "pay_as_you_go" },
);
const line = wrapper.get('[data-test="assistant-turn-footer"]');
expect(line.text()).toMatch(/^Evaluated .+–.+ UTC·1 query·1 credit\s?Re-run for now$/);
await wrapper.get('[data-test="assistant-rerun"]').trigger("click");
expect(host.dispatch).toHaveBeenCalledWith(expect.any(String), {
kind: "rerun_now",
turnId: "t1",
});
it("draws nothing on an ordinary finished answer: the answer's own lines say it all", () => {
expect(mountFooter(answer()).find('[data-test="assistant-turn-footer"]').exists()).toBe(false);
});
it("holds Re-run with the reason while another tab locks the chat", async () => {
setRuntimeHandle({
lifecycle: {
signedOutElsewhere: computed(() => false),
turnLock: () => "approval_elsewhere",
},
} as never);
const { wrapper, host } = mountFooter(answer([query("c3")]));
const rerun = wrapper.get('[data-test="assistant-rerun"]');
expect(rerun.attributes("aria-disabled")).toBe("true");
expect(wrapper.text()).toContain("Another tab is waiting for your approval on this chat.");
await rerun.trigger("click");
expect(host.dispatch).not.toHaveBeenCalled();
setRuntimeHandle(null);
it("states the credit on Cloud", () => {
const wrapper = mountFooter(answer(), {}, "pay_as_you_go");
expect(wrapper.get('[data-test="assistant-turn-footer-credits"]').text()).toBe("1 credit");
});
it("hides Re-run while another turn of the chat runs", async () => {
const { wrapper, store, sid } = mountFooter(answer([query("c3")]));
expect(wrapper.find('[data-test="assistant-rerun"]').exists()).toBe(true);
store.state.sessions[sid].turn = { state: "connecting" } as never;
await flushPromises();
expect(wrapper.find('[data-test="assistant-rerun"]').exists()).toBe(false);
});
it("opens the credits note as a popover a touch can reach", async () => {
const { wrapper } = mountFooter(answer([query("c3")]), {}, { creditMode: "pay_as_you_go" });
const credits = wrapper.get('[data-test="assistant-turn-footer-credits"]');
expect(credits.element.tagName).toBe("BUTTON");
await credits.trigger("click");
await flushPromises();
expect(document.body.textContent).toContain("Credits are counted when a request is accepted.");
// Portalled out of the transcript's masked ancestor, so it masks itself.
expect(
document.body.querySelector("[data-o-popover-content]")?.getAttribute("data-dd-privacy"),
).toBe("mask");
});
it("masks the windows list and the not-saved note, which are portalled out of the transcript", async () => {
const windows = mountFooter(
answer([query("c2"), query("c3", FROM - 3_600_000, TO)], { persisted: false }),
);
for (const id of ["assistant-turn-footer-windows", "assistant-turn-footer-not-saved"]) {
await windows.wrapper.get(`[data-test="${id}"]`).trigger("click");
await flushPromises();
const opened = [...document.body.querySelectorAll("[data-o-popover-content]")];
expect(opened.length).toBeGreaterThan(0);
expect(opened.every((el) => el.getAttribute("data-dd-privacy") === "mask")).toBe(true);
await windows.wrapper.get(`[data-test="${id}"]`).trigger("click");
await flushPromises();
}
});
describe("empty default window (PO A26)", () => {
const empty = (id: string): ContentBlock => ({ ...query(id), summary: { count: 0 } as any });
const flagged = (
message: ChatMessage,
turn: Record<string, unknown> = {},
evidenceOver: Parameters<typeof fakeEvidence>[0] = {},
) => {
const mounted = mountFooter(message, turn, evidenceOver);
mounted.store.annotate(mounted.sid, turnAnnotationKey("t1", "window"), {
homeDefaultWindow: true,
});
return mounted;
};
it("offers the last 1 h under a Home ask whose queries all came back empty, and sends it on click", async () => {
const { wrapper, host, sid } = flagged(answer([empty("c3")]));
await flushPromises();
const offer = wrapper.get('[data-test="assistant-turn-empty-window"]');
expect(offer.text()).toContain("Nothing in the default range, last 15 min.");
const button = wrapper.get('[data-test="assistant-rerun-wider"]');
expect(button.text()).toBe("Try the last 1 h");
await button.trigger("click");
await flushPromises();
expect(host.dispatch).toHaveBeenCalledTimes(1);
expect(host.dispatch).toHaveBeenCalledWith(sid, { kind: "rerun_wider", turnId: "t1" });
});
it("is absent when a query found rows or was denied, the window was not the default, or the turn is not the last", async () => {
const found = flagged(answer([empty("c2"), query("c3")]));
const unflagged = mountFooter(answer([empty("c3")]));
const earlier = flagged(answer([empty("c3")]), { isLast: false });
const denied = flagged(
answer([empty("c2"), { ...query("c3"), errorType: "permission_denied_tool" }]),
);
await flushPromises();
for (const { wrapper } of [found, unflagged, earlier, denied])
expect(wrapper.find('[data-test="assistant-turn-empty-window"]').exists()).toBe(false);
});
it("holds the offer with the credits reason when credits are used up", async () => {
const { wrapper, host } = flagged(answer([empty("c3")]), {}, { creditMode: "exhausted" });
await flushPromises();
const button = wrapper.get('[data-test="assistant-rerun-wider"]');
expect(button.attributes("aria-disabled")).toBe("true");
await button.trigger("click");
expect(host.dispatch).not.toHaveBeenCalled();
});
});
it("never shows credits off Cloud and says included credits on the free mode", () => {
expect(
mountFooter(answer([query("c3")]))
.wrapper.find('[data-test="assistant-turn-footer-credits"]')
.exists(),
).toBe(false);
const free = mountFooter(
answer([query("c3")], { usage: { totalTokens: 18954, llmCalls: 3 } }),
{},
{ creditMode: "free" },
);
expect(free.wrapper.get('[data-test="assistant-turn-footer-credits"]').text()).toBe(
"1 included credit",
);
});
it("charges a failed turn that ran no query, and says refunded only on a server count of 0", () => {
const failed = mountFooter(answer([]), { outcome: "failed" }, { creditMode: "free" });
expect(failed.wrapper.get('[data-test="assistant-turn-footer-credits"]').text()).toBe(
"1 included credit",
);
expect(failed.wrapper.find('[data-test="assistant-turn-footer-no-query"]').exists()).toBe(
false,
);
const refunded = mountFooter(
answer([], { usage: { credits: 0 } }),
{ outcome: "failed" },
{ creditMode: "pay_as_you_go" },
);
expect(refunded.wrapper.get('[data-test="assistant-turn-footer-credits"]').text()).toBe(
"0 credits · refunded",
);
});
it("names several windows as one sentence that opens the list", () => {
const { wrapper } = mountFooter(answer([query("c2"), query("c3", FROM - 3_600_000, TO)]));
expect(wrapper.get('[data-test="assistant-turn-footer-windows"]').text()).toMatch(
/and 1 other window$/,
);
});
it("says it answered without a query and offers no Re-run", () => {
const { wrapper } = mountFooter(answer([]));
expect(wrapper.get('[data-test="assistant-turn-footer-no-query"]').text()).toBe(
"Answered without running a query",
);
expect(wrapper.find('[data-test="assistant-rerun"]').exists()).toBe(false);
});
it("adds stopped to a stopped turn and not saved with its reason", () => {
const stopped = mountFooter(
{ ...answer([query("c3")]), contentBlocks: [query("c3")] },
it("says a refund only on a stopped turn the server counted as 0", () => {
const wrapper = mountFooter(
answer({ usage: { credits: 0 } }),
{ outcome: "stopped" },
"pay_as_you_go",
);
expect(stopped.wrapper.get('[data-test="assistant-turn-footer-stopped"]').text()).toBe(
"stopped",
);
const notSaved = mountFooter(answer([query("c3")], { persisted: false }));
expect(notSaved.wrapper.get('[data-test="assistant-turn-footer-not-saved"]').text()).toBe(
"not saved",
);
expect(wrapper.text()).toContain("0 credits · refunded");
});
it("disables Re-run with the credits reason when credits are used up", () => {
const { wrapper } = mountFooter(answer([query("c3")]), {}, { creditMode: "exhausted" });
expect(wrapper.get('[data-test="assistant-rerun"]').attributes("aria-disabled")).toBe("true");
it("says an answer was not saved", () => {
const wrapper = mountFooter(answer(), { outcome: "done_not_saved" });
expect(wrapper.get('[data-test="assistant-turn-footer-not-saved"]').text()).toBe("not saved");
});
it("lists what was sent without a used list, and what was used with one", () => {
const sent = mountFooter(answer([query("c3")]));
const line = sent.wrapper.get('[data-test="assistant-turn-context-used"]');
expect(line.attributes("aria-label")).toBe("Context used");
expect(line.findAll(".rounded-default, [data-test]").length).toBeGreaterThanOrEqual(0);
expect(line.text()).toBe("Sent:Time · 13:43–13:58Stream · default");
const used = mountFooter(answer([query("c3")], { contextUsed: ["page:stream"] }));
expect(used.wrapper.get('[data-test="assistant-turn-context-used"]').text()).toBe(
"Used:Stream · default",
);
const none = mountFooter(answer([query("c3")], { contextUsed: [] }));
expect(none.wrapper.get('[data-test="assistant-turn-context-used"]').text()).toBe(
"Used:No page context was used",
);
});
it("is absent while the turn runs and on a shared page has no Re-run or credits", () => {
expect(mountFooter(answer([query("c3")]), { outcome: "live" }).wrapper.html()).toBe(
"<!--v-if-->",
);
const shared = mountFooter(
answer([query("c3")]),
{ sid: null },
{ creditMode: "pay_as_you_go" },
true,
);
expect(shared.wrapper.find('[data-test="assistant-rerun"]').exists()).toBe(false);
expect(shared.wrapper.find('[data-test="assistant-turn-footer-credits"]').exists()).toBe(false);
it("is absent while the turn runs", () => {
const wrapper = mountFooter(answer(), { outcome: "live" }, "pay_as_you_go");
expect(wrapper.find('[data-test="assistant-turn-footer"]').exists()).toBe(false);
});
});
@@ -15,215 +15,76 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div v-if="visible" ref="root" class="flex min-w-0 flex-col gap-1">
<div
v-if="hasLine"
class="text-text-secondary flex min-w-0 flex-wrap items-center gap-x-1 gap-y-1 text-xs leading-5"
data-test="assistant-turn-footer"
:data-variant="variant"
<div
v-if="visible"
class="text-text-secondary flex min-w-0 flex-wrap items-center gap-x-1 gap-y-1 text-xs leading-5"
data-test="assistant-turn-footer"
:data-variant="notSaved ? 'not_saved' : 'done'"
>
<OPopover
v-if="credits"
privacy="mask"
side="top"
align="start"
:aria-label="credits"
content-class="p-2 text-xs"
>
<template v-if="windows.length">
<span v-if="windows.length === 1" data-test="assistant-turn-footer-evaluated">
{{ evaluatedHead }}
</span>
<OPopover
v-if="windows.length > 1"
privacy="mask"
side="top"
align="start"
:aria-label="t('assistant.footer.windowsName')"
content-class="flex w-[min(20rem,calc(100vw-1.5rem))] flex-col gap-1 p-2"
<template #trigger>
<OButton
variant="ghost"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-turn-footer-credits"
>
<template #trigger>
<OButton
variant="ghost-primary"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-turn-footer-windows"
>
{{ evaluatedHead }}
</OButton>
</template>
{{ credits }}
</OButton>
</template>
<OText v-if="tokensLine" variant="meta" as="p" data-test="assistant-turn-footer-tokens">{{
tokensLine
}}</OText>
<OText v-else variant="meta" as="p">{{ t("assistant.footer.creditsCountedHint") }}</OText>
</OPopover>
<template v-if="notSaved">
<span v-if="credits" aria-hidden="true">·</span>
<OPopover
privacy="mask"
side="top"
align="start"
:aria-label="t('assistant.footer.notSaved')"
content-class="p-2 text-xs"
>
<template #trigger>
<OButton
v-for="window in windows"
:key="window.step.callId"
variant="ghost"
size="sm"
:class="['justify-start', TOUCH_TARGET_CLASS]"
:data-test="`assistant-turn-footer-window-${window.step.n}`"
@click="openWindow(window.step.callId)"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-turn-footer-not-saved"
>
{{ t("assistant.footer.windowStep", { n: window.step.n, range: rangeText(window) }) }}
{{ t("assistant.footer.notSaved") }}
</OButton>
</OPopover>
<span aria-hidden="true">·</span>
<span>{{ t("assistant.footer.queries", { count: queries }, queries) }}</span>
</template>
<span v-else-if="!stopped" data-test="assistant-turn-footer-no-query">{{
t("assistant.footer.noQuery")
}}</span>
<template v-if="credits">
<span v-if="windows.length || !stopped" aria-hidden="true">·</span>
<OPopover
v-if="tokensLine"
privacy="mask"
side="top"
align="start"
:aria-label="credits"
content-class="p-2 text-xs"
>
<template #trigger>
<OButton
variant="ghost"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-turn-footer-credits"
>
{{ credits }}
</OButton>
</template>
<OText variant="meta" as="p" data-test="assistant-turn-footer-tokens">{{
tokensLine
}}</OText>
</OPopover>
<OPopover
v-else
privacy="mask"
side="top"
align="start"
:aria-label="credits"
content-class="p-2 text-xs"
>
<template #trigger>
<OButton
variant="ghost"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-turn-footer-credits"
>
{{ credits }}
</OButton>
</template>
<OText variant="meta" as="p">{{ t("assistant.footer.creditsCountedHint") }}</OText>
</OPopover>
</template>
<template v-if="stopped">
<span aria-hidden="true">·</span>
<span data-test="assistant-turn-footer-stopped">{{ t("assistant.footer.stopped") }}</span>
</template>
<template v-if="notSaved">
<span aria-hidden="true">·</span>
<OPopover
privacy="mask"
side="top"
align="start"
:aria-label="t('assistant.footer.notSaved')"
content-class="p-2 text-xs"
>
<template #trigger>
<OButton
variant="ghost"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-turn-footer-not-saved"
>
{{ t("assistant.footer.notSaved") }}
</OButton>
</template>
<OText variant="meta" as="p">{{ t("assistant.footer.notSavedHint") }}</OText>
</OPopover>
</template>
<OButton
v-if="canRerun"
:variant="isMobile ? 'secondary' : 'ghost'"
size="sm"
:disabled="!!rerunBlocked"
:focusable-unavailable="!!rerunBlocked"
:class="['max-md:w-full', TOUCH_TARGET_CLASS]"
data-test="assistant-rerun"
@click="rerun"
>
{{ t("assistant.footer.rerun") }}
<template v-if="rerunBlocked" #unavailable-reason>{{ rerunBlocked }}</template>
<OTooltip v-if="rerunBlocked" :content="rerunBlocked" />
</OButton>
</div>
<div
v-if="context"
role="group"
:aria-label="t('assistant.context.usedName')"
class="flex min-w-0 flex-wrap items-center gap-1.5 text-xs"
:data-kind="context.kind"
data-test="assistant-turn-context-used"
>
<OText variant="meta">{{
context.kind === "used" ? t("assistant.context.used") : t("assistant.context.sentLine")
}}</OText>
<OText v-if="!context.labels.length" variant="meta">{{
t("assistant.context.usedNone")
}}</OText>
<OTag
v-for="(label, index) in context.labels"
:key="index"
type="assistantContext"
:value="context.kind === 'used' ? 'used' : 'sent'"
:label="label"
class="max-w-full"
/>
</div>
<div
v-if="widerOffer"
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
data-test="assistant-turn-empty-window"
>
<OText variant="meta">{{ t("assistant.footer.emptyWindow", { range: defaultRange }) }}</OText>
<OButton
variant="outline"
size="sm"
:disabled="!!rerunBlocked"
:focusable-unavailable="!!rerunBlocked"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-rerun-wider"
@click="rerunWider"
>
{{ t("assistant.footer.tryWider", { duration: widerDuration }) }}
<template v-if="rerunBlocked" #unavailable-reason>{{ rerunBlocked }}</template>
</OButton>
</div>
</template>
<OText variant="meta" as="p">{{ t("assistant.footer.notSavedHint") }}</OText>
</OPopover>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref } from "vue";
import { computed, inject } from "vue";
import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import {
answerQueriesAllEmpty,
assistantEvidenceHostKey,
footerOf,
isTerminal,
queryCount,
stepsOf,
turnAnnotationKey,
turnLockKey,
useTurnLock,
windowsOf,
type AssistantTurnView,
} from "@/composables/assistant/pure";
import { timeRangeText, timeWindowOf, widerPeriodText } from "@/composables/assistant";
import useBreakpoint from "@/composables/useBreakpoint";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { ChatMessage, ContentBlock } from "@/ts/interfaces/chat";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { formatCount } from "@/utils/formatters";
import { rangeText } from "./evidenceText";
import { revealStep } from "./revealStep";
const props = withDefaults(
defineProps<{
block?: ContentBlock;
@@ -238,71 +99,15 @@ const props = withDefaults(
const { t } = useI18nTyped();
const evidence = props.readonly ? null : inject(assistantEvidenceHostKey, null);
const { isMobile } = useBreakpoint();
const root = ref<HTMLElement | null>(null);
const outcome = computed(() => props.turn?.outcome ?? "done");
// A refused or still-running turn evaluated nothing yet, so it has no footer.
const visible = computed(() => outcome.value !== "live" && (hasLine.value || !!context.value));
const steps = computed(() => stepsOf(props.message, props.turn?.org ?? ""));
const windows = computed(() => windowsOf(steps.value));
const queries = computed(() => queryCount(steps.value));
const footer = computed(() => footerOf(props.message));
const stopped = computed(() => outcome.value === "stopped" || outcome.value === "failed");
const notSaved = computed(
() => outcome.value === "done_not_saved" || footer.value?.persisted === false,
);
// An ended turn says nothing beyond its outcome block, except what it evaluated and the credit it was charged.
const hasLine = computed(() => !stopped.value || windows.value.length > 0 || !!credits.value);
const capability = computed(() => evidence?.capability() ?? null);
const exhausted = computed(() => capability.value?.creditMode === "exhausted");
// A re-run starts a turn, so the cross-tab lock holds it like every other turn start.
const turnLock = useTurnLock(() => props.turn?.sid ?? null);
const rerunBlocked = computed<I18nText | null>(() => {
if (turnLock.value) return t(turnLockKey(turnLock.value));
return exhausted.value ? t("assistant.message.creditsUsedUp") : null;
});
const live = computed(() => !props.readonly && !!evidence && !!props.turn?.sid);
// A send while another turn runs would join that turn, so Re-run waits for it.
const sessionIdle = computed(() => {
const turn = props.turn?.sid && evidence ? evidence.session(props.turn.sid)?.turn : null;
return !turn || turn.state === "idle" || isTerminal(turn);
});
const canRerun = computed(
() => live.value && sessionIdle.value && windows.value.length > 0 && !!props.turn?.prompt,
);
// Only the click sends; the default range is never widened by itself (PO A26).
const widerOffer = computed(() => {
const turn = props.turn;
if (!live.value || !turn?.sid || !turn.isLast || outcome.value !== "done") return false;
const session = evidence?.session(turn.sid);
const sent = session?.annotations[turnAnnotationKey(turn.turnId, "window")];
return (
!!sent?.homeDefaultWindow && sessionIdle.value && answerQueriesAllEmpty(props.message, turn.org)
);
});
const defaultRange = computed(() => timeRangeText(timeWindowOf(null, null), t));
const widerDuration = computed(() => widerPeriodText());
const variant = computed(() =>
!windows.value.length
? "no_query"
: stopped.value
? "stopped"
: notSaved.value
? "not_saved"
: "done",
);
// Several windows read as one sentence, which is the popover's trigger.
const evaluatedHead = computed<I18nText>(() => {
const first = windows.value[0];
if (!first) return raw("");
const range = rangeText(first);
const others = windows.value.length - 1;
return others > 0
? t("assistant.footer.evaluatedMore", { range, count: others }, others)
: t("assistant.footer.evaluated", { range });
});
const visible = computed(() => outcome.value !== "live" && (!!credits.value || notSaved.value));
// Cloud only, never on a shared page; the flat 1 until the server counts credits.
const credits = computed<I18nText | null>(() => {
@@ -326,35 +131,4 @@ const tokensLine = computed<I18nText | null>(() => {
calls,
);
});
const context = computed<{ kind: "used" | "sent"; labels: I18nText[] } | null>(() => {
const used = footer.value?.contextUsed;
const chips = props.turn?.prompt?.chips ?? [];
if (used) {
const labels = used.map(
(key) => chips.find((chip) => chip.id === key || chip.ref?.type === key)?.label ?? raw(key),
);
return { kind: "used", labels };
}
if (!chips.length) return null;
return { kind: "sent", labels: chips.map((chip) => chip.label) };
});
function rerun(): void {
const sid = props.turn?.sid;
if (!sid || !evidence || rerunBlocked.value || !props.turn) return;
void evidence.dispatch(sid, { kind: "rerun_now", turnId: props.turn.turnId });
}
function rerunWider(): void {
const sid = props.turn?.sid;
if (!sid || !evidence || rerunBlocked.value || !props.turn) return;
void evidence.dispatch(sid, { kind: "rerun_wider", turnId: props.turn.turnId });
}
async function openWindow(callId: string): Promise<void> {
const sid = props.turn?.sid;
if (sid && evidence) await evidence.dispatch(sid, { kind: "open_step", callId });
await revealStep(root.value, callId);
}
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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])",
]);
});
});
@@ -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<QueryRef["language"], string> = {
const QUERY_KINDS: ReadonlySet<StepFacts["kind"]> = 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<ComputedRef<Map<string, number>>> =
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<string, number>();
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(" · "));
@@ -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<HTMLElement, ReturnType<typeof setTimeout>>();
/** 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<boolean> {
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<HTMLElement>("[data-step-header]") ?? []).find(
const found = Array.from(scope?.querySelectorAll<HTMLElement>("[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();
@@ -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 <http://www.gnu.org/licenses/>.
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<boolean>;
hrefOf(step: StepFacts): string | undefined;
open(step: StepFacts, event: MouseEvent): Promise<void>;
copy(step: StepFacts): Promise<void>;
showInSteps(step: StepFacts, from: HTMLElement | null): Promise<void>;
}
/** 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<void> {
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<void> {
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<void> {
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 };
}
@@ -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" }),
);
});
});
@@ -17,14 +17,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
v-if="visible"
class="flex min-w-0 flex-col gap-1"
class="group/feedback flex min-w-0 flex-col gap-1"
role="group"
:aria-label="t('assistant.feedback.groupName')"
:aria-label="
cause ? t('assistant.findings.wasThisTheCause') : t('assistant.feedback.groupName')
"
data-test="assistant-feedback"
>
<div class="flex items-center gap-1">
<span
v-if="cause"
aria-hidden="true"
class="text-text-secondary hidden text-xs group-focus-within/feedback:inline group-hover/feedback:inline"
data-test="assistant-feedback-cause-word"
>
{{ t("assistant.findings.wasThisTheCause") }}
</span>
<AssistantIconButton
:label="t('assistant.feedback.helpful')"
:label="upLabel"
:icon="assistantGlyph.helpful"
:pressed="vote === 'up'"
data-test="assistant-feedback-up"
@@ -32,7 +42,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<span v-if="isMobile" :ref="bindSheetTrigger" class="contents">
<AssistantIconButton
:label="t('assistant.feedback.notHelpful')"
:label="downLabel"
:icon="assistantGlyph.notHelpful"
:pressed="vote === 'down'"
aria-haspopup="dialog"
@@ -45,14 +55,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
variant="ghost"
size="icon-sm"
:aria-label="t('assistant.feedback.notHelpful')"
:aria-label="downLabel"
:aria-pressed="vote === 'down'"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-feedback-down"
>
<OIcon :name="assistantGlyph.notHelpful" size="sm" />
<!-- The tooltip would sit over the dialog its button opened -->
<OTooltip :content="t('assistant.feedback.notHelpful')" :disabled="reasonOpen" />
<OTooltip :content="downLabel" :disabled="reasonOpen" />
</OButton>
</template>
<div
@@ -94,39 +104,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
{{ t("assistant.feedback.disclosure") }}
</OText>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex gap-2 self-end max-md:w-full">
<OButton
variant="ghost"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-copy-chat-id"
@click="copyChatId"
variant="outline"
size="sm"
:class="['max-md:flex-1', TOUCH_TARGET_CLASS]"
data-test="assistant-feedback-cancel"
@click="closeReason"
>
{{ t("assistant.feedback.copyChatId") }}
<span class="text-text-secondary font-mono">{{ shortId }}</span>
<OTooltip :content="fullId" />
{{ t("assistant.feedback.cancel") }}
</OButton>
<OButton
variant="primary"
size="sm"
:loading="sending"
:class="['max-md:flex-1', TOUCH_TARGET_CLASS]"
data-test="assistant-feedback-send"
@click="sendReason"
>
{{ t("assistant.feedback.send") }}
</OButton>
<div class="flex gap-2 max-md:w-full">
<OButton
variant="outline"
size="sm"
:class="['max-md:flex-1', TOUCH_TARGET_CLASS]"
data-test="assistant-feedback-cancel"
@click="closeReason"
>
{{ t("assistant.feedback.cancel") }}
</OButton>
<OButton
variant="primary"
size="sm"
:loading="sending"
:class="['max-md:flex-1', TOUCH_TARGET_CLASS]"
data-test="assistant-feedback-send"
@click="sendReason"
>
{{ t("assistant.feedback.send") }}
</OButton>
</div>
</div>
<OText v-if="localOnly" variant="meta" as="p" data-test="assistant-feedback-local-only">
{{ 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<void> {
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<void> {
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;
@@ -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 <http://www.gnu.org/licenses/>.
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<typeof import("@/composables/assistant")>()),
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();
});
});
@@ -0,0 +1,54 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="-ms-1.5 flex min-w-0 flex-col" data-test="assistant-follow-ups">
<OButton
v-for="item in items"
:key="item"
variant="ghost-primary"
size="sm"
:class="ROW_CLASS"
data-test="assistant-follow-up"
@click="stage(item)"
>
<OTruncatedText class="min-w-0 text-start">{{ raw(item) }}</OTruncatedText>
</OButton>
</div>
</template>
<script setup lang="ts">
import { inject } from "vue";
import { useAssistantStore } from "@/composables/assistant";
import { assistantTranscriptHostKey } from "@/composables/assistant/pure";
import OButton from "@/lib/core/Button/OButton.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { raw } from "@/types/i18n";
const ROW_CLASS =
"text-text-link hover:text-assistant-link min-h-7 w-full justify-start font-normal max-md:min-h-11 pointer-coarse:min-h-11";
const props = defineProps<{ sid: string; items: string[] }>();
const store = useAssistantStore();
const host = inject(assistantTranscriptHostKey, null);
// A follow-up stages, so the reader can edit it before Send (A28).
function stage(text: string): void {
store.stage(props.sid, { text, mode: "replace" });
host?.focusComposer();
}
</script>
@@ -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** <img src=x onerror=alert(1)>");
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<HTMLElement>('[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: '<p data-test="status">live</p>' },
});
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);
});
});
});
@@ -17,46 +17,45 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
v-if="message.role === 'user'"
class="group/message bg-assistant-user-bg rounded-surface flex min-w-0 flex-col gap-1.5 px-3 py-2"
class="group/message @container flex min-w-0 flex-col gap-1"
data-test="assistant-message-user"
data-scroll-anchor
>
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<!-- Metadata on the tinted bubble takes the body colour: the meta grey measures 4.3:1 there -->
<span
class="text-text-body min-w-0 flex-1 text-xs leading-5"
<div class="flex min-w-0 items-baseline gap-2">
<div class="min-w-0 flex-1">
<slot name="body">
<p
v-if="userText"
class="text-text-secondary min-w-0 text-sm break-words whitespace-pre-wrap"
data-test="assistant-message-text"
>
{{ userText }}
</p>
</slot>
</div>
<slot name="actions" />
<OText
variant="meta"
as="span"
class="shrink-0 whitespace-nowrap"
data-test="assistant-message-meta"
>
<span v-if="rangeLabel" class="@max-[22rem]:hidden" data-test="assistant-message-range">
{{ rangeLabel }} ·
</span>
{{ userMeta }}
</span>
<slot name="actions" />
</OText>
</div>
<slot name="body">
<p
v-if="userText"
class="text-text-body min-w-0 text-sm break-words whitespace-pre-wrap"
data-test="assistant-message-text"
>
{{ userText }}
</p>
</slot>
<ul
v-if="chips.length"
class="flex min-w-0 flex-wrap gap-1.5"
:aria-label="t('assistant.message.chipsName')"
<OText
v-if="sentChips.length"
variant="meta"
as="p"
class="flex min-w-0 items-center gap-1 break-words"
data-test="assistant-message-chips"
>
<li v-for="chip in chips" :key="chip.id" class="max-w-full min-w-0">
<OTag
type="assistantContext"
value="sent"
:label="chip.label"
:icon="chipGlyph[chip.kind]"
class="max-w-full"
:data-test="`assistant-message-chip-${chip.kind}`"
/>
</li>
</ul>
<OIcon :name="chipGlyph[sentChips[0].kind]" size="xs" aria-hidden="true" />
<span class="min-w-0">{{ sentContext }}</span>
</OText>
<div v-if="attachments.length" class="flex min-w-0 flex-col gap-1.5">
<AssistantAttachmentBlock
v-for="(block, index) in attachments"
@@ -95,14 +94,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</ul>
<p
v-if="message.rerunFor"
class="text-text-body text-xs leading-5"
class="text-text-secondary text-xs leading-5"
data-test="assistant-message-rerun"
>
{{ t("assistant.footer.rerunFor", { range: rerunRange }) }}
</p>
<p
v-if="message.editedFrom"
class="text-text-body text-xs leading-5"
class="text-text-secondary text-xs leading-5"
data-test="assistant-message-edited"
>
{{ t("assistant.message.editedFrom", { n: message.editedFrom }) }}
@@ -116,11 +115,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:aria-busy="live ? 'true' : undefined"
data-scroll-anchor
>
<div v-if="hasContent || live" class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<AssistantAgentIdentity size="meta" :agent="agent" :time="answerTime" />
<div
v-if="agent !== DEFAULT_AGENT || $slots.versions"
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
>
<AssistantAgentIdentity v-if="agent !== DEFAULT_AGENT" size="meta" :agent="agent" />
<slot name="versions" />
<span class="flex-1" />
</div>
<slot name="status" />
<template v-for="item in rendered" :key="item.key">
<!-- Inline-size containment keeps a 20k-character line from setting the transcript's width; layout and paint containment keep a filling part's repaint inside its block (Q1) -->
<div
@@ -134,8 +136,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<AssistantMarkdownPart
v-if="part.kind !== 'code'"
:part="part"
:html-class="PROSE"
:text-class="PLAIN"
:html-class="item.key === leadKey ? `${PROSE} ${LEAD_HTML}` : PROSE"
:text-class="item.key === leadKey ? `${PLAIN} ${LEAD_TEXT}` : PLAIN"
:live="item.live"
/>
<AssistantMarkdownCode
@@ -184,11 +186,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:numbers="claimMap"
:available="claimAvailable"
:label="claimLabel"
:step-of="claimStep"
:total="steps.length"
:openable="turn?.openable ?? true"
:sid="turn?.sid ?? null"
:readonly="readonly"
/>
</div>
<AssistantPolicyNotice
@@ -246,15 +243,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:turn="turn"
/>
</template>
<AssistantAnswerMeta
v-if="!live && message.role === 'assistant' && !hasFindings"
:message="message"
:turn="turn"
:readonly="readonly"
/>
<slot name="after" />
<AssistantQueriesGroup
v-if="!live && message.role === 'assistant'"
:message="message"
:numbers="claimMap"
:readonly="readonly"
:turn="turn"
:unavailable="unavailableClaims"
/>
<slot name="status" />
<slot name="after" />
<component
:is="footerRenderer"
v-if="footerRenderer && turn && !live"
@@ -270,8 +272,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
>
<AssistantFeedback v-if="feedbackShown" :message="message" :turn="turn" />
<span class="flex-1" />
<slot name="actions" />
</div>
<slot name="follow-ups" />
</div>
</template>
@@ -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<string> = new Set<BlockKind>([
"link",
"policy",
"attachment",
"trail",
]);
const LANGUAGE_NAMES: Record<string, string> = {
@@ -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<AssistantMessageProps>(), {
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<ImageAttachment[]>(() => props.message.images ?? []);
const baseUserMeta = computed<I18nText>(() => {
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<I18nText>(() => {
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<Rendered[]>(() => {
const out: Rendered[] = [];
const links = new Set<string>();
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<Rendered[]>(() => {
function renderBlock(block: ContentBlock, index: number, links: Set<string>): 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<string>): 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;
@@ -45,6 +45,15 @@ vi.mock("@/services/assistantAnalytics", async (importOriginal) => ({
}));
vi.mock("@/composables/useBreakpoint", () => ({ default: () => ({ mdUp: ref(mdUp.value) }) }));
const DROPDOWN = { template: "<div><slot name='trigger' /><slot /></div>" };
const DROPDOWN_ITEM = {
emits: ["select"],
props: ["disabled", "description"],
template:
"<button :aria-disabled=\"disabled ? 'true' : undefined\" @click=\"$emit('select')\"><slot />{{ description }}</button>",
};
const prompt: ChatMessage = {
role: "user",
content: raw("why 5xx?"),
@@ -80,6 +89,7 @@ function mountActions(props: Record<string, unknown>) {
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 () => {
@@ -67,6 +67,57 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<div
v-else-if="!isUser"
role="group"
:aria-label="t('assistant.message.actionsName')"
:class="[
'flex shrink-0 items-center gap-1',
isLast
? ''
: 'opacity-0 group-focus-within/message:opacity-100 group-hover/message:opacity-100 pointer-coarse:opacity-100',
]"
data-test="assistant-msg-actions"
>
<AssistantIconButton
:label="copyAnswer.label"
:icon="copyAnswer.icon"
:data-test="copyAnswer.dataTest"
@click="copyAnswer.run"
/>
<ODropdown align="end" privacy="mask">
<template #trigger>
<AssistantIconButton
:label="t('assistant.message.more')"
:icon="assistantGlyph.more"
data-test="assistant-msg-more"
/>
</template>
<ODropdownItem
v-for="item in moreItems"
:key="item.id"
:disabled="item.disabled"
:focusable-unavailable="item.disabled"
:description="item.disabled ? item.reason : null"
:icon-left="item.icon"
class="max-md:min-h-11 pointer-coarse:min-h-11"
:data-test="item.dataTest"
@select="item.run"
>
{{ item.label }}
</ODropdownItem>
<ODropdownSeparator />
<ODropdownItem
icon-left="content-copy"
class="max-md:min-h-11 pointer-coarse:min-h-11"
data-test="assistant-copy-chat-id"
@select="copyChatId"
>
{{ t("assistant.message.copyChatId") }}
</ODropdownItem>
</ODropdown>
</div>
<ODropdown v-else-if="!mdUp" align="end" privacy="mask">
<template #trigger>
<AssistantIconButton
@@ -301,6 +352,7 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { trackAssistant } from "@/services/assistantAnalytics";
import { useI18nTyped, type I18nText } from "@/types/i18n";
@@ -359,26 +411,30 @@ const running = computed(() => {
return !!session && isRunning(session.turn);
});
const items = computed<ActionItem[]>(() => {
if (isUser.value)
return [
{
id: "edit",
label: t("assistant.message.edit"),
icon: assistantGlyph.edit,
dataTest: "assistant-msg-edit",
disabled: turnLocked.value,
reason: turnLockedReason.value,
run: () => emit("edit"),
},
copyItem("prompt", t("assistant.message.copyPrompt"), "assistant-msg-copy-prompt", () =>
String(props.message.content ?? ""),
),
];
const answer: ActionItem[] = [];
const items = computed<ActionItem[]>(() => [
{
id: "edit",
label: t("assistant.message.edit"),
icon: assistantGlyph.edit,
dataTest: "assistant-msg-edit",
disabled: turnLocked.value,
reason: turnLockedReason.value,
run: () => emit("edit"),
},
copyItem("prompt", t("assistant.message.copyPrompt"), "assistant-msg-copy-prompt", () =>
String(props.message.content ?? ""),
),
]);
const copyAnswer = computed(() =>
copyItem("answer", t("assistant.message.copyAnswer"), "assistant-msg-copy-answer", answerText),
);
const moreItems = computed<ActionItem[]>(() => {
const more: ActionItem[] = [];
// Without B-12 a version replaces the latest answer only; an older turn would fork the chat.
if (props.isLast && props.prompt && props.canRegenerate)
answer.push({
more.push({
id: "regenerate",
label: t("assistant.message.regenerate"),
icon: assistantGlyph.regenerate,
@@ -387,11 +443,10 @@ const items = computed<ActionItem[]>(() => {
reason: turnLockedReason.value,
run: startRegenerate,
});
answer.push(
copyItem("answer", t("assistant.message.copyAnswer"), "assistant-msg-copy-answer", answerText),
more.push(
copyItem("markdown", t("assistant.message.copyMarkdown"), "assistant-msg-copy-md", answerMd),
);
return answer;
return more;
});
function copyItem(
@@ -484,6 +539,18 @@ async function copy(id: string, content: string | Promise<string>): Promise<void
copiedTimer = setTimeout(() => (copied.value = null), COPIED_MS);
}
async function copyChatId(): Promise<void> {
const result = await store.dispatch(props.sid, {
kind: "copy",
text: props.sid,
what: "chat_id",
});
announceComposer(
props.sid,
result.ok ? t("assistant.feedback.chatIdCopied") : t("assistant.client.copyManually"),
);
}
function startRegenerate(): void {
trackAssistant("ai_thread_action", { action: "regenerate" });
void store.regenerate(props.sid);
@@ -1,303 +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 <http://www.gnu.org/licenses/>.
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChatMessage, ContentBlock } from "@/ts/interfaces/chat";
import { raw } from "@/types/i18n";
import { fakeEvidence } from "../__tests__/fakeEvidence";
import { fakeRouter } from "../__tests__/fakeAssistant";
import { OPEN_STEP_EVENT } from "../evidence/revealStep";
import AssistantStepTrail from "./AssistantStepTrail.vue";
vi.mock("@/components/QueryEditor.vue", () => ({
default: { props: ["query"], template: "<pre>{{ query }}</pre>" },
}));
const FROM = Date.UTC(2026, 9, 14, 8, 13);
const TO = FROM + 15 * 60_000;
const sql = 'SELECT * FROM "default"';
const blocks: ContentBlock[] = [
{
type: "tool_call",
tool: "StreamList",
call_id: "call_1",
stepState: "done",
summary: { count: 3, took: 120 } as any,
},
{
type: "tool_call",
tool: "StreamSchema",
call_id: "call_2",
stepState: "done",
context: { stream_name: "default" },
summary: { count: 9, took: 90 } as any,
},
{
type: "tool_call",
tool: "SearchSQL",
call_id: "call_3",
stepState: "done",
callArgs: { stream_name: "default", sql, start_time: FROM * 1000, end_time: TO * 1000 },
summary: { count: 4, took: 380 } as any,
result: { hits: [{ a: "1" }], total: 2341 },
},
{ type: "text", text: "answer" },
{ type: "footer", footer: { durationMs: 12_000 } },
];
const message = (list: ContentBlock[] = blocks): ChatMessage => ({
role: "assistant",
content: raw(""),
contentBlocks: list,
});
describe("AssistantStepTrail", () => {
afterEach(() => (document.body.innerHTML = ""));
const mountTrail = (props: Record<string, unknown>, provide: Record<symbol, unknown> = {}) =>
mount(AssistantStepTrail, {
props,
attachTo: document.body,
global: { plugins: [fakeRouter()], provide },
});
it("collapses a finished trail to one line with its count and duration", () => {
const { provide, turn } = fakeEvidence();
const wrapper = mountTrail({ message: message(), turn: turn() }, provide);
const toggle = wrapper.get('[data-test="assistant-trail-toggle"]');
expect(toggle.text()).toBe("Worked through 3 steps · 12 s");
expect(toggle.attributes("aria-expanded")).toBe("false");
expect(wrapper.find('[data-test="assistant-step-call_1"]').exists()).toBe(false);
});
it("lists rows from structured fields when expanded, with no uppercase", async () => {
const { provide, turn } = fakeEvidence();
const wrapper = mountTrail({ message: message(), turn: turn() }, provide);
await wrapper.get('[data-test="assistant-trail-toggle"]').trigger("click");
const rows = ["call_1", "call_2", "call_3"].map((id) =>
wrapper.get(`[data-test="assistant-step-${id}"]`).text(),
);
expect(rows[0]).toBe("Listed streams · 3 streams · 120 ms");
expect(rows[1]).toBe("Read schema for default · 9 fields · 90 ms");
expect(rows[2]).toMatch(/^Queried default · .+–.+ · 4 rows · 380 ms$/);
});
it("shows a failed row under the collapsed header, in words and not green", () => {
const { provide, turn } = fakeEvidence();
const failed: ContentBlock = {
type: "tool_call",
tool: "SearchSQL",
call_id: "e1",
stepState: "failed",
success: false,
resultMessage: "Search field not found: latency",
};
const wrapper = mountTrail(
{ message: message([failed]), turn: turn({ outcome: "failed" }) },
provide,
);
expect(wrapper.get('[data-test="assistant-trail-toggle"]').text()).toBe(
"Worked through 1 step · 1 failed",
);
const row = wrapper.get('[data-test="assistant-step-e1"]');
expect(row.text()).toBe("Query failed · Search field not found: latency");
expect(row.attributes("aria-label")).toBe(
"Query failed · Search field not found: latency, Failed",
);
expect(wrapper.get('[data-test="assistant-step-state-e1"]').attributes("data-state")).toBe(
"failed",
);
});
it("marks a stopped step interrupted with a neutral glyph, never a check", () => {
const { provide, turn } = fakeEvidence();
const stopped: ContentBlock = {
type: "tool_call",
tool: "SearchSQL",
call_id: "c9",
stepState: "interrupted",
context: { stream_name: "default" },
};
const wrapper = mountTrail(
{ message: message([stopped]), turn: turn({ outcome: "stopped" }) },
provide,
);
expect(wrapper.get('[data-test="assistant-step-c9"]').text()).toBe(
"Queried default · stopped before it finished",
);
const state = wrapper.get('[data-test="assistant-step-state-c9"]');
expect(state.attributes("data-state")).toBe("interrupted");
expect(state.classes()).not.toContain("text-status-success-text");
});
it("stays open while running with the running row last", () => {
const { provide, turn } = fakeEvidence();
const running: ContentBlock = {
type: "tool_call",
tool: "SearchSQL",
call_id: "c1",
stepState: "running",
context: { stream_name: "default" },
};
const wrapper = mountTrail(
{ message: message([blocks[0], running]), turn: turn({ outcome: "live" }) },
provide,
);
expect(wrapper.get('[data-test="assistant-trail-toggle"]').text()).toBe("2 steps so far");
expect(wrapper.get('[data-test="assistant-step-state-c1"]').attributes("data-state")).toBe(
"running",
);
});
it("shows the newest 3 rows of a running trail and folds the rest behind Show earlier (D3-f)", async () => {
const { provide, turn } = fakeEvidence();
const done = (n: number): ContentBlock => ({
type: "tool_call",
tool: "StreamSchema",
call_id: `d${n}`,
stepState: "done",
context: { stream_name: `s${n}` },
});
const running: ContentBlock = {
type: "tool_call",
tool: "SearchSQL",
call_id: "c9",
stepState: "running",
context: { stream_name: "default" },
};
const wrapper = mountTrail(
{
message: message([done(1), done(2), done(3), done(4), running]),
turn: turn({ outcome: "live" }),
},
provide,
);
const rows = () =>
wrapper.findAll("[data-step-header]").map((el) => el.attributes("data-step-header"));
expect(rows()).toEqual(["d3", "d4", "c9"]);
const earlier = wrapper.get('[data-test="assistant-trail-earlier"]');
expect(earlier.text()).toBe("Show 2 earlier steps");
await earlier.trigger("click");
expect(rows()).toEqual(["d1", "d2", "d3", "d4", "c9"]);
expect(wrapper.find('[data-test="assistant-trail-earlier"]').exists()).toBe(false);
});
it("lets the reader close a running trail, and keeps that choice in the session", async () => {
const { provide, turn, store, sid } = fakeEvidence();
const running: ContentBlock = {
type: "tool_call",
tool: "SearchSQL",
call_id: "c1",
stepState: "running",
context: { stream_name: "default" },
};
const wrapper = mountTrail(
{ message: message([blocks[0], running]), turn: turn({ outcome: "live" }) },
provide,
);
const toggle = wrapper.get('[data-test="assistant-trail-toggle"]');
expect(toggle.attributes("aria-expanded")).toBe("true");
await toggle.trigger("click");
expect(toggle.attributes("aria-expanded")).toBe("false");
expect(store.state.sessions[sid].ui.expandedSteps).toContain("trail:t1:closed");
await toggle.trigger("click");
expect(toggle.attributes("aria-expanded")).toBe("true");
});
it("keeps expansion in the session by call id, so another chat expands nothing", async () => {
const { provide, turn, store, sid } = fakeEvidence();
const wrapper = mountTrail({ message: message(), turn: turn() }, provide);
await wrapper.get('[data-test="assistant-trail-toggle"]').trigger("click");
await wrapper.get('[data-test="assistant-step-call_3"]').trigger("click");
expect(store.state.sessions[sid].ui.expandedSteps).toEqual(["trail:t1", "call_3"]);
const other = fakeEvidence();
const second = mountTrail({ message: message(), turn: other.turn() }, other.provide);
expect(second.get('[data-test="assistant-trail-toggle"]').attributes("aria-expanded")).toBe(
"false",
);
});
it("expands a query step to its query, window, first rows and actions", async () => {
const { provide, turn, host } = fakeEvidence();
const wrapper = mountTrail({ message: message(), turn: turn() }, provide);
await wrapper.get('[data-test="assistant-trail-toggle"]').trigger("click");
await wrapper.get('[data-test="assistant-step-call_3"]').trigger("click");
await flushPromises();
const body = wrapper.get('[data-test="assistant-step-body-call_3"]');
expect(body.text()).toContain("Showing 1 of 2,341 rows");
const open = wrapper.get('[data-test="assistant-step-open-call_3"]');
expect(open.find("svg").exists()).toBe(false);
await open.trigger("click");
expect(host.dispatch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ kind: "open_in_explorer", explorer: "logs" }),
);
});
it("says the owner hid the steps and expands nothing on such a share", () => {
const wrapper = mountTrail({
message: message(),
readonly: true,
turn: { ...fakeEvidence().turn(), sid: null, hiddenByOwner: true },
});
expect(wrapper.get('[data-test="assistant-trail-hidden"]').text()).toBe(
"3 steps hidden by the owner",
);
expect(wrapper.find('[data-test="assistant-trail-toggle"]').exists()).toBe(false);
});
it("expands locally on a shared page when a claim opens a step", async () => {
const article = document.createElement("article");
document.body.appendChild(article);
const wrapper = mount(AssistantStepTrail, {
props: { message: message(), readonly: true, turn: { ...fakeEvidence().turn(), sid: null } },
attachTo: article,
global: { plugins: [fakeRouter()] },
});
await flushPromises();
article.dispatchEvent(new CustomEvent(OPEN_STEP_EVENT, { detail: { callId: "call_2" } }));
await flushPromises();
expect(wrapper.find('[data-test="assistant-step-body-call_2"]').exists()).toBe(true);
});
it("opens a step from a shared page as a plain new-tab link, and offers none when not openable", async () => {
const shared = (openable: boolean) =>
mount(AssistantStepTrail, {
props: {
message: message(),
readonly: true,
turn: { ...fakeEvidence().turn(), sid: null, openable },
},
attachTo: document.body,
global: { plugins: [fakeRouter()] },
});
const wrapper = shared(true);
await wrapper.get('[data-test="assistant-trail-toggle"]').trigger("click");
await wrapper.get('[data-test="assistant-step-call_3"]').trigger("click");
const open = wrapper.get('[data-test="assistant-step-open-call_3"]');
expect(open.element.tagName).toBe("A");
expect(open.attributes("target")).toBe("_blank");
expect(open.attributes("rel")).toContain("noopener");
const closed = shared(false);
await closed.get('[data-test="assistant-trail-toggle"]').trigger("click");
await closed.get('[data-test="assistant-step-call_3"]').trigger("click");
expect(closed.find('[data-test="assistant-step-open-call_3"]').exists()).toBe(false);
});
});
@@ -1,363 +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 <http://www.gnu.org/licenses/>.
-->
<template>
<div
v-if="steps.length"
ref="root"
class="flex min-w-0 flex-col gap-0.5"
data-test="assistant-steps"
>
<OText v-if="hidden" variant="meta" as="p" class="leading-5" data-test="assistant-trail-hidden">
{{ header }}
</OText>
<OButton
v-else
variant="ghost"
size="sm"
:aria-expanded="open ? 'true' : 'false'"
:aria-controls="shown.length ? listId : undefined"
:class="['max-w-full justify-start self-start', TOUCH_TARGET_CLASS]"
data-test="assistant-trail-toggle"
@click="toggleTrail"
>
<OIcon
:name="open ? assistantGlyph.collapse : assistantGlyph.expand"
size="sm"
aria-hidden="true"
/>
<OTruncatedText class="text-text-secondary min-w-0 text-xs">{{ header }}</OTruncatedText>
</OButton>
<OButton
v-if="!hidden && folded > 0"
variant="ghost"
size="sm"
:class="['ms-2 self-start', TOUCH_TARGET_CLASS]"
data-test="assistant-trail-earlier"
@click="showAll = true"
>
<OText variant="meta" as="span">
{{ t("assistant.steps.showEarlier", { count: folded }, folded) }}
</OText>
</OButton>
<ul v-if="!hidden && shown.length" :id="listId" class="flex min-w-0 flex-col gap-0.5 ps-2">
<li v-for="step in shown" :key="step.callId" class="flex min-w-0 flex-col">
<OButton
variant="ghost"
size="sm"
:aria-expanded="isExpanded(step) ? 'true' : 'false'"
:aria-label="stepName(step, t)"
:class="['rounded-default w-full min-w-0 justify-start', TOUCH_TARGET_CLASS]"
:data-test="`assistant-step-${step.callId}`"
:data-step-header="step.callId"
@click="toggleStep(step)"
>
<span
:class="['flex shrink-0 items-center', glyphTone(step)]"
:data-state="step.state"
:data-test="`assistant-step-state-${step.callId}`"
aria-hidden="true"
>
<template v-if="step.state === 'running'">
<OSpinner variant="ring" size="xs" class="motion-reduce:hidden" />
<OIcon :name="assistantGlyph.working" size="sm" class="motion-safe:hidden" />
</template>
<OIcon v-else :name="glyphOf(step)" size="sm" />
</span>
<OTruncatedText
:class="[
'min-w-0 flex-1 text-start text-xs',
step.state === 'failed' ? 'text-status-error-text' : 'text-text-body',
]"
>{{ stepLine(step, t) }}</OTruncatedText
>
</OButton>
<div
v-if="isExpanded(step)"
class="flex min-w-0 flex-col gap-2 ps-7 pe-1 pt-1 pb-2"
:data-test="`assistant-step-body-${step.callId}`"
>
<template v-if="step.state === 'failed'">
<OText v-if="step.error" variant="body" as="p" class="text-xs break-words">
{{ raw(step.error) }}
</OText>
<OText v-if="step.suggestion" variant="meta" as="p" class="break-words">
{{ raw(step.suggestion) }}
</OText>
</template>
<template v-if="step.query">
<AssistantQueryText
:text="step.query.text"
:language="step.query.language"
:label="stepLine(step, t)"
:data-test="`assistant-step-query-${step.callId}`"
/>
<OText variant="meta" as="p" class="leading-5">
{{ windowLine(step) }}
</OText>
</template>
<AssistantResultTable
v-if="step.result?.hits?.length"
:rows="step.result.hits.slice(0, MAX_HIT_ROWS)"
:label="t('assistant.evidence.resultsOf', { name: stepLine(step, t) })"
:data-test="`assistant-step-hits-${step.callId}`"
/>
<div
v-if="step.kind === 'cli' && step.result?.stdout"
class="flex min-w-0 flex-col gap-1"
>
<OCodeBlock
:code="step.result.stdout"
lang="plaintext"
chrome="terminal"
:highlight="false"
:copyable="false"
:scroll-label="stepLine(step, t)"
:data-test="`assistant-step-output-${step.callId}`"
/>
<OTag
v-if="step.result.truncated"
variant="default-soft"
size="sm"
:label="t('assistant.steps.outputTruncated')"
class="self-start"
/>
</div>
<div v-if="step.query" class="flex flex-wrap items-center gap-2">
<OButton
v-if="openable"
variant="secondary"
size="sm"
as="a"
:href="actions.hrefOf(step)"
:target="actions.opensElsewhere.value ? '_blank' : undefined"
:rel="actions.opensElsewhere.value ? 'noopener noreferrer' : undefined"
:icon-right="actions.opensElsewhere.value ? assistantGlyph.newTab : undefined"
:class="TOUCH_TARGET_CLASS"
:data-test="`assistant-step-open-${step.callId}`"
@click="actions.open(step, $event)"
>
{{
t("assistant.query.open", { explorer: explorerLabel(explorerOf(step.query), t) })
}}
</OButton>
<OButton
variant="ghost"
size="sm"
:icon-left="assistantGlyph.copy"
:class="TOUCH_TARGET_CLASS"
:data-test="`assistant-step-copy-${step.callId}`"
@click="actions.copy(step)"
>
{{ t("assistant.query.copy") }}
</OButton>
</div>
</div>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, watch } from "vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import AssistantQueryText from "@/components/ai-assistant/evidence/AssistantQueryText.vue";
import AssistantResultTable from "@/components/ai-assistant/evidence/AssistantResultTable.vue";
import { explorerLabel, stepLine, stepName } from "@/components/ai-assistant/evidence/evidenceText";
import { OPEN_STEP_EVENT } from "@/components/ai-assistant/evidence/revealStep";
import { useStepActions } from "@/components/ai-assistant/evidence/useStepActions";
import {
MAX_HIT_ROWS,
assistantEvidenceHostKey,
footerOf,
stepsOf,
type AssistantTurnView,
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 OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import { trackAssistant } from "@/services/assistantAnalytics";
import type { ChatMessage, ContentBlock } from "@/ts/interfaces/chat";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { explorerOf } from "@/utils/assistant/explorerRoutes";
import { formatCount, formatElapsed, formatOrgTime } from "@/utils/formatters";
/** While a turn runs the trail lists only its newest rows, so the live line stays in view (D3-f). */
const LIVE_ROWS = 3;
const props = withDefaults(
defineProps<{
block?: ContentBlock;
blockId?: string;
message: ChatMessage;
readonly?: boolean;
density?: "compact" | "roomy";
turn?: AssistantTurnView | null;
}>(),
{ block: undefined, blockId: "", readonly: false, density: "compact", turn: null },
);
const { t } = useI18nTyped();
const host = props.readonly ? null : inject(assistantEvidenceHostKey, null);
const actions = useStepActions(
() => props.turn?.sid ?? null,
() => props.readonly,
"trail",
);
// Without a live session (a shared page) the expansion is the reader's own and never stored.
const localExpanded = ref<string[]>([]);
const root = ref<HTMLElement | null>(null);
const showAll = ref(false);
const sid = computed(() => props.turn?.sid ?? null);
const live = computed(() => props.turn?.outcome === "live");
const hidden = computed(() => props.turn?.hiddenByOwner === true);
const steps = computed(() => stepsOf(props.message, props.turn?.org ?? ""));
const trailKey = computed(() => `trail:${props.turn?.turnId ?? props.blockId}`);
const closedKey = computed(() => `${trailKey.value}:closed`);
const listId = computed(
() => `assistant-trail-${(props.turn?.turnId ?? props.blockId).replace(/\W/g, "")}`,
);
const expanded = computed<string[]>(() => {
const session = sid.value && host ? host.session(sid.value) : null;
return session ? session.ui.expandedSteps : localExpanded.value;
});
// A running trail is open until the reader closes it; a finished one opens on its toggle or a claim.
const open = computed(() =>
live.value
? !expanded.value.includes(closedKey.value)
: expanded.value.includes(trailKey.value) ||
steps.value.some((step) => expanded.value.includes(step.callId)),
);
const exceptions = computed(() =>
steps.value.filter((step) => step.state === "failed" || step.state === "interrupted"),
);
// The exceptions stay listed when collapsed, so colour is spent on the problem only.
const listed = computed(() => (open.value ? steps.value : exceptions.value));
// A failed row and a row the reader opened are never folded away.
const shown = computed(() => {
if (!live.value || !open.value || showAll.value) return listed.value;
const newest = steps.value.length - LIVE_ROWS;
return listed.value.filter(
(step, index) =>
index >= newest || exceptions.value.includes(step) || expanded.value.includes(step.callId),
);
});
const folded = computed(() => listed.value.length - shown.value.length);
const openable = computed(() => props.turn?.openable !== false);
const duration = computed(() => {
const footer = footerOf(props.message);
if (footer?.durationMs !== undefined) return footer.durationMs;
if (live.value && props.turn?.startedAt) return Date.now() - props.turn.startedAt;
return null;
});
const header = computed<I18nText>(() => {
const count = steps.value.length;
if (hidden.value) return t("assistant.steps.hiddenByOwner", { count }, count);
const failed = steps.value.filter((step) => step.state === "failed").length;
if (live.value) return t("assistant.steps.soFar", { count }, count);
const detail = failed
? t("assistant.steps.failedCount", { count: failed }, failed)
: duration.value !== null
? raw(formatElapsed(duration.value))
: null;
return detail
? t("assistant.steps.summaryWith", { count, detail }, count)
: t("assistant.steps.summary", { count }, count);
});
function isExpanded(step: StepFacts): boolean {
return !hidden.value && expanded.value.includes(step.callId);
}
function toggle(key: string): void {
if (sid.value && host) return host.toggleStep(sid.value, key);
localExpanded.value = localExpanded.value.includes(key)
? localExpanded.value.filter((id) => id !== key)
: [...localExpanded.value, key];
}
function toggleTrail(): void {
if (live.value) return toggle(closedKey.value);
if (!open.value) return toggle(trailKey.value);
// Collapsing closes the trail and every step a claim opened in it.
for (const key of [trailKey.value, ...steps.value.map((step) => step.callId)])
if (expanded.value.includes(key)) toggle(key);
}
function toggleStep(step: StepFacts): void {
const opening = !isExpanded(step);
toggle(step.callId);
if (opening) trackAssistant("ai_evidence_action", { action: "expand_step", block_kind: "trail" });
}
function windowLine(step: StepFacts): I18nText {
if (!step.range) return t("assistant.evidence.rangeUnknown");
const from = raw(formatOrgTime(step.range.from, { showZone: true }));
const to = raw(formatOrgTime(step.range.to, { showZone: true }));
const total = step.result?.total ?? step.rows;
const shownRows = Math.min(step.result?.hits?.length ?? 0, MAX_HIT_ROWS);
if (!total || !shownRows) return t("assistant.steps.window", { from, to });
return t("assistant.steps.windowShowing", {
from,
to,
shown: formatCount(shownRows),
total: formatCount(total),
});
}
function onOpenStep(event: Event): void {
const callId = (event as CustomEvent<{ callId?: string }>).detail?.callId;
// A live trail is expanded by the store's `open_step`; only a shared page expands here.
if (!callId || (sid.value && host) || localExpanded.value.includes(callId)) return;
if (steps.value.some((step) => step.callId === callId)) toggle(callId);
}
function glyphOf(step: StepFacts) {
if (step.state === "failed") return assistantGlyph.failed;
if (step.state === "interrupted") return assistantGlyph.interrupted;
return assistantGlyph.done;
}
function glyphTone(step: StepFacts): string {
if (step.state === "failed") return "text-status-error-text";
return "text-text-secondary";
}
watch(root, (el, _old, onCleanup) => {
const article = el?.closest("article");
if (!article) return;
article.addEventListener(OPEN_STEP_EVENT, onOpenStep);
onCleanup(() => article.removeEventListener(OPEN_STEP_EVENT, onOpenStep));
});
</script>
@@ -300,7 +300,7 @@ describe("AssistantTranscript", () => {
const { wrapper, host } = mountLive(session);
const outcome = wrapper.get('[data-test="assistant-turn-outcome"]');
expect(outcome.attributes("data-code")).toBe("stream_dropped");
expect(outcome.text()).toContain("Connection lost after 40 s.");
expect(outcome.text()).toContain("Connection lost. The answer above is incomplete.");
await wrapper.get('[data-test="assistant-turn-outcome-action"]').trigger("click");
expect(host.outcomeAction).toHaveBeenCalledWith(
"s1",
@@ -330,7 +330,9 @@ describe("AssistantTranscript", () => {
],
};
const { wrapper } = mountLive(liveSession([prompt("q", "t1"), steps]));
expect(wrapper.get('[data-test="assistant-turn-outcome-title"]').text()).toBe("Stopped");
expect(wrapper.get('[data-test="assistant-turn-outcome-title"]').text()).toBe(
"Stopped after 2 steps, incomplete",
);
});
it("says when the running turn gains an outcome, never for a chat that opens with one (U12)", async () => {
@@ -436,7 +438,7 @@ describe("AssistantTranscript: agents (MULTI-AGENT-03, 04, 05)", () => {
]);
const { wrapper } = mountLive(session);
const metas = wrapper.findAll('[data-test="assistant-agent-identity"]');
expect(metas.map((meta) => meta.attributes("data-agent"))).toEqual(["o2-ai", "sre"]);
expect(metas.map((meta) => meta.attributes("data-agent"))).toEqual(["sre"]);
expect(wrapper.get('[data-test="assistant-answer-t2"]').attributes("aria-label")).toBe(
"Investigator, answer to look deeper",
);
@@ -57,7 +57,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data-test="`assistant-turn-${turn.turnId}`"
:data-turn-id="turn.turnId"
:aria-label="turnName(turn)"
class="flex min-w-0 flex-col gap-2"
class="[&+article]:border-border-subtle flex min-w-0 flex-col gap-2 [&+article]:border-t [&+article]:pt-4"
>
<div
v-if="compactionOf(turn.user)"
@@ -96,6 +96,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:turn-id="turn.turnId"
:readonly="readonly"
:density="density"
:range-label="rangeLabelOf(turn)"
>
<template v-if="actionsFor(turn)" #actions>
<AssistantMessageActions
@@ -191,11 +192,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:prompt="turn.user"
:turn-id="turn.turnId"
:is-last="turn.isLast"
:can-regenerate="!turn.outcomes.some((o) => o.record.kind === 'failed')"
:can-regenerate="canRegenerate(turn)"
:prompt-number="turn.userNumber"
:density="density"
/>
</template>
<template v-if="followUpsOf(turn).length" #follow-ups>
<AssistantFollowUps :sid="sid!" :items="followUpsOf(turn)" />
</template>
</AssistantMessage>
</article>
<AssistantHandoffSuggestion
@@ -338,6 +342,7 @@ import {
} from "vue";
import { announceComposer } from "@/components/ai-assistant/composer/composerUi";
import { lastRangeText } from "@/components/ai-assistant/evidence/evidenceText";
import AssistantTurnOutcome, {
type OutcomeAction,
} from "@/components/ai-assistant/turn/AssistantTurnOutcome.vue";
@@ -348,11 +353,13 @@ import {
agentStore,
currentAgent,
assistantEvidenceHostKey,
isStaleAnswer,
assistantSessionSourceKey,
assistantTranscriptHostKey,
manualRouteOf,
outcomeError,
promptRange,
stepsOf,
turnAnnotationKey,
useAssistantName,
} from "@/composables/assistant/pure";
@@ -372,6 +379,8 @@ import { useTranscriptScroll } from "./useTranscriptScroll";
// The bar and the inline editor act on the store, so a readonly page never loads them (constraint 29).
const AssistantMessageActions = defineAsyncComponent(() => import("./AssistantMessageActions.vue"));
const AssistantFollowUps = defineAsyncComponent(() => import("./AssistantFollowUps.vue"));
const props = withDefaults(defineProps<AssistantTranscriptProps>(), {
sid: undefined,
messages: undefined,
@@ -492,8 +501,9 @@ function compactionOf(message: ChatMessage | null | undefined): string | null {
return block?.message ? String(block.message) : null;
}
// While a card waits the card itself is the turn's status.
function statusShown(turn: TranscriptTurn): boolean {
return turn.live && !props.readonly && !!session.value;
return turn.live && !props.readonly && !!session.value && !session.value.pendingApproval;
}
function turnName(turn: TranscriptTurn): I18nText {
@@ -520,6 +530,29 @@ function answerActionsFor(turn: TranscriptTurn): boolean {
return !!props.sid && !!live && !props.readonly && !running.value && !!text && !turn.live;
}
// Older turns keep the hour alone, so the last turn's range reads as the chat's current one.
function rangeLabelOf(turn: TranscriptTurn): I18nText | null {
if (!turn.isLast) return null;
const window = promptRange(turn.user, askedWindowOf(turn.turnId));
return window ? lastRangeText(window, turn.user?.sentAt, t) : null;
}
// A failed turn acts through its outcome block, and a stale one has Re-run as its one path to new data.
function canRegenerate(turn: TranscriptTurn): boolean {
if (turn.outcomes.some((outcome) => outcome.record.kind === "failed")) return false;
const steps = stepsOf(turn.answer, session.value?.org ?? "");
return !(turn.isLast && !!turn.user && isStaleAnswer(steps, Date.now()));
}
// At most three, and two under findings, which already fill the turn (the design's budget).
function followUpsOf(turn: TranscriptTurn): string[] {
if (!turn.isLast || turn.live || props.readonly || !props.sid || !live) return [];
if (turn.outcomes.length) return [];
const blocks = turn.answer?.contentBlocks ?? [];
const items = blocks.find((block) => block.type === "follow_ups")?.followUps ?? [];
return items.slice(0, blocks.some((block) => block.type === "findings") ? 2 : 3);
}
// The picker's name is not re-read while focus stays inside it, so the switch is spoken (EB-09 AC 12).
function selectVersion(turnId: string, n: number, count: number): void {
if (!props.sid || !live) return;
@@ -259,7 +259,12 @@ describe("AssistantTurnOutcome", () => {
expect(failed.classes().join(" ")).toContain("bg-banner-error-soft-bg");
const refused = mountOutcome({ kind: "refused", error: error("offline") });
expect(refused.classes().join(" ")).toContain("bg-banner-info-bg");
expect(mountOutcome({ kind: "stopped" }).classes().join(" ")).toContain("bg-banner-default-bg");
});
it("states a stop as a line of the answer, with Continue on it", () => {
const stopped = mountOutcome({ kind: "stopped", steps: 3 });
expect(stopped.classes().join(" ")).not.toContain("bg-banner");
expect(stopped.text()).toContain("Stopped after 3 steps, incomplete");
});
it("names the action by the title and says the findings line only when findings exist", () => {
@@ -270,11 +275,13 @@ describe("AssistantTurnOutcome", () => {
expect(buttons(bare)[0].attributes("aria-describedby")).toBe(titleId);
const partial = mountOutcome({ error: error("provider_error"), partial: true });
expect(bodies(partial)).toEqual(["Nothing was changed.", "Findings so far are kept above."]);
});
it("reads a dropped stream with text above as the one connection-lost row with Retry", () => {
const dropped = mountOutcome({ error: error("stream_dropped"), partial: true, elapsedMs: 1 });
expect(bodies(dropped)).toEqual([
"Findings so far are kept above.",
"The text above is incomplete.",
]);
expect(title(dropped)).toBe("Connection lost. The answer above is incomplete.");
expect(bodies(dropped)).toEqual([]);
expect(label(dropped)).toBe("Retry");
});
it("reads Ask again with the undo line after a mutating tool, Edit and resend for an unrecoverable failure", () => {
@@ -355,11 +362,10 @@ describe("AssistantTurnOutcome", () => {
it("emits its one action, Continue on a stop and Retry save only in server mode", async () => {
const stopped = mountOutcome({ kind: "stopped", steps: 3, partial: true });
expect(title(stopped)).toBe("Stopped after 3 steps · findings kept");
expect(title(mountOutcome({ kind: "stopped", steps: 3 }))).toBe("Stopped");
expect(title(stopped)).toBe("Stopped after 3 steps, incomplete");
await buttons(stopped)[0].trigger("click");
expect(stopped.emitted("action")).toEqual([["continue"]]);
expect(title(mountOutcome({ kind: "stopped", steps: 0 }))).toBe("Stopped");
expect(title(mountOutcome({ kind: "stopped", steps: 0 }))).toBe("Stopped, incomplete");
expect(buttons(mountOutcome({ kind: "doneNotSaved" }))).toHaveLength(0);
const saved = mountOutcome({ kind: "doneNotSaved", persistence: true });
expect(title(saved)).toBe("Answered, but not saved to history.");
@@ -598,14 +604,14 @@ describe("AssistantTurnOutcome: agent refusals (MULTI-AGENT-12)", () => {
expect(member.find('[data-test="ask-admin-stub"]').exists()).toBe(true);
});
it("offers Try again beside the fallback when the agent is down", () => {
it("has no Retry for a send that never left: the text is back and Send is the retry", () => {
const w = mountOutcome({
kind: "refused",
sid: "s1",
error: error("agent_unreachable", { agent: "generate-query" }),
});
expect(title(w)).toBe("Query builder isn't reachable right now. Your message wasn't sent.");
expect(label(w)).toBe("Try again");
expect(title(w)).toBe("Isn't reachable. Your message wasn't sent.");
expect(w.find('[data-test="assistant-turn-outcome-action"]').exists()).toBe(false);
expect(w.find(fallback).exists()).toBe(true);
});
@@ -26,6 +26,41 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
{{ readonlyLine }}
</OText>
<div
v-else-if="plainLine"
class="text-text-secondary flex min-w-0 flex-wrap items-center gap-x-1 text-xs leading-5"
data-test="assistant-turn-outcome"
:data-kind="kind"
>
<span :id="titleId" class="min-w-0 break-words" data-test="assistant-turn-outcome-title">
{{ title }}
</span>
<template v-if="action">
<span aria-hidden="true">·</span>
<OButton
variant="ghost-primary"
size="chip"
:disabled="unavailable"
:focusable-unavailable="unavailable"
:class="ACTION_CLASS"
:aria-describedby="disabledReason ? undefined : titleId"
data-test="assistant-turn-outcome-action"
@click="emit('action', action)"
>
{{ actionLabel }}
<template v-if="disabledReason" #unavailable-reason>{{ disabledReason }}</template>
<OTooltip v-if="disabledReason" :content="disabledReason" />
</OButton>
</template>
<span
v-for="line in lines"
:key="line"
class="w-full min-w-0 break-words"
data-test="assistant-turn-outcome-body"
>
{{ line }}
</span>
</div>
<OBanner
v-else
:variant="look.variant"
@@ -339,6 +374,8 @@ export function outcomeActionOf(
if (kind === "doneNotSaved") return opts.persistence ? "retry_save" : null;
if (kind === "expired") return opts.gated ? null : "ask_again";
if (kind === "restored" || kind === "cancelledElsewhere" || !error) return null;
// The unsent text is back in the composer, so Send is the retry.
if (kind === "refused" && error.code === "agent_unreachable") return null;
if (error.code === "session_expired" && opts.signedIn) return "retry";
if (error.action === "none" || error.action === "send_again") return null;
if (ROLE_SPLIT_KINDS[error.code] && opts.role === "member") return "ask_admin";
@@ -425,6 +462,7 @@ const creditParams = computed(() => {
const manualShown = computed(
() =>
!props.readonly &&
!connectionLost.value &&
!!props.manualRoute &&
!!props.manualRoute.text &&
MANUAL_ROUTE_CODES.has(code.value ?? ""),
@@ -435,6 +473,11 @@ const manualLabel = computed(() => {
return t("assistant.outcome.manualRoute", { explorer: explorerLabel(explorer, t) });
});
const look = computed(() => LOOKS[props.kind]);
// Stopped and expired are facts about the turn, not faults, so they read as a line of the answer.
const plainLine = computed(() => props.kind === "stopped" || props.kind === "expired");
const connectionLost = computed(
() => props.kind === "failed" && code.value === "stream_dropped" && props.partial,
);
const refusedAgentName = computed(() =>
agentName(agentIdOf(props.error?.agent) ?? DEFAULT_AGENT, t),
);
@@ -455,9 +498,9 @@ const askKind = computed<AskAdminKind>(() => ROLE_SPLIT_KINDS[code.value ?? ""]
const title = computed<I18nText>(() => {
switch (props.kind) {
case "stopped":
return props.steps > 0 && props.partial
? t("assistant.turn.stoppedWithSteps", { n: props.steps }, props.steps)
: t("assistant.turn.stopped");
return props.steps > 0
? t("assistant.turn.stoppedAfter", { n: props.steps }, props.steps)
: t("assistant.turn.stoppedIncomplete");
case "doneNotSaved":
return t("assistant.turn.notSaved");
case "restored":
@@ -465,9 +508,12 @@ const title = computed<I18nText>(() => {
case "cancelledElsewhere":
return t("assistant.error.cancelled_elsewhere.title");
case "expired":
return t("assistant.error.approval_expired.title");
return t("assistant.approval.record.expired");
default: {
if (!props.error) return t("assistant.error.unknown.title");
if (connectionLost.value) return t("assistant.turn.connectionLost");
if (props.kind === "refused" && code.value === "agent_unreachable")
return t("assistant.send.unreachable");
if (creditCopy.value) return t(creditCopy.value.titleKey, creditParams.value);
const copy = { t, org: props.org, product, elapsedMs: props.elapsedMs ?? 0 };
const { key, params } = errorTitle(props.error, copy);
@@ -482,6 +528,7 @@ const lines = computed<I18nText[]>(() => {
if (props.kind === "stopped" && props.cancelUnconfirmed)
out.push(t("assistant.turn.cancelUnconfirmed"));
if (!error || (props.kind !== "failed" && props.kind !== "refused")) return out;
if (connectionLost.value) return out;
if (creditCopy.value) return [...out, t(creditCopy.value.bodyKey, creditParams.value)];
const bodyKey = `assistant.error.${error.code}.body`;
const claimsFindings = FINDINGS_BODIES.has(error.code);
@@ -569,6 +616,7 @@ const unavailable = computed(() => !!disabledReason.value || countingDown.value)
const detailsShown = computed(
() =>
!!props.error &&
!connectionLost.value &&
(props.kind === "failed" || props.kind === "refused" || props.cancelUnconfirmed),
);
@@ -186,14 +186,10 @@ describe("AssistantTurnStatus", () => {
expect(wrapper.find('[data-test="assistant-turn-status-stall"]').exists()).toBe(false);
});
it("shows a static glyph instead of the dots under reduced motion (HT-01 AC 11)", () => {
const match = vi.spyOn(window, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList);
const wrapper = mountStatus({ state: "responding.thinking" });
it("draws the still retry glyph while reconnecting, never the pulse", () => {
const wrapper = mountStatus({ state: "reconnecting", attempt: 1 });
expect(wrapper.find('[data-test="assistant-turn-status-spinner"]').exists()).toBe(false);
expect(wrapper.find('[data-test="assistant-turn-status-glyph"]').exists()).toBe(true);
match.mockRestore();
});
it("says another tab answers when this tab does not own the turn", () => {
@@ -32,7 +32,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex size-4 shrink-0 items-center justify-center" aria-hidden="true">
<OIcon v-if="glyph" :name="glyph" size="xs" data-test="assistant-turn-status-glyph" />
<OSpinner v-else variant="dots" size="xs" data-test="assistant-turn-status-spinner" />
<AssistantStatusDot v-else state="working" data-test="assistant-turn-status-spinner" />
</span>
<span class="flex min-w-0 flex-1 flex-col">
<OTruncatedText class="min-w-0" data-test="assistant-turn-status-text">{{
@@ -100,6 +100,7 @@ const OWN_WORDS: ReadonlySet<string> = new Set([
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import AssistantStatusDot from "@/components/ai-assistant/common/AssistantStatusDot.vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import {
elapsedMs,
@@ -112,7 +113,6 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OCard from "@/lib/core/Card/OCard.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { formatElapsed } from "@/utils/formatters";
@@ -128,10 +128,6 @@ const emit = defineEmits<AssistantTurnStatusEmits>();
const { t } = useI18nTyped();
const tick = ref(0);
const reducedMotion =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)")?.matches === true;
const live = computed(() => props.turn.state !== "idle" && !isTerminal(props.turn));
@@ -173,13 +169,13 @@ function hostLine(now: number, shared: I18nText): I18nText | null {
: t("assistant.rca.status.analysing", { elapsed });
}
// No motion while the turn waits on the user, and a static glyph under reduced motion (HT-01 AC 4, AC 11).
// No motion while the turn waits on the user or the line is down (HT-01 AC 4, AC 11).
const glyph = computed(() => {
if (!props.ownedByThisTab) return assistantGlyph.elsewhere;
const state = props.turn.state;
if (state === "awaiting_approval" || state === "awaiting_consent") return assistantGlyph.waiting;
if (status.value?.line === t("assistant.turn.caughtUp")) return assistantGlyph.done;
return reducedMotion ? assistantGlyph.working : null;
return state === "reconnecting" ? assistantGlyph.regenerate : null;
});
let timer: ReturnType<typeof setInterval> | null = null;
@@ -56,9 +56,9 @@ const DENIED_STATE: Record<
ApprovalOutcome["state"]
> = {
deny: "declined",
escape: "declined",
new_chat: "new_chat",
reply: "replied",
answer: "answered",
expired: "expired",
withdrawn: "withdrawn",
edited: "editing",
@@ -70,6 +70,7 @@ const OP_OF_KIND: Record<PendingApproval["kind"], ActionLedgerEntry["op"]> = {
create: "create",
run_command: "run_command",
permission: "run_command",
question: "run_command",
};
/** What a decision came to: the record's state and, for an approve answered item by item, its rows. */
@@ -109,8 +110,11 @@ export function confirmBodyOf(
card: Pick<
ApprovalBlockData,
"typed" | "grantable" | "destructive" | "kind" | "tool" | "operation"
>,
> &
Partial<Pick<ApprovalBlockData, "id">>,
): ConfirmBody {
if (!decision.approved && decision.reason === "answer")
return { id: card.id, answer: decision.answer };
if (!decision.approved)
return decision.reason === "reply" && decision.reply && card.typed
? { approved: false, reply: decision.reply }
@@ -60,6 +60,12 @@ const PLACEMENT_FIELDS: ReadonlySet<string> = new Set([
const ENABLED_FIELDS = ["value", "enabled", "enable"] as const;
const UNTIL_FIELDS = ["paused_until", "until", "end_time", "expires_at"] as const;
const SCOPE_FIELDS = ["folder_name", "folder", "team", "scope"] as const;
const NOTIFY_FIELDS = ["notifies", "notify", "destinations", "channels"] as const;
// Entity types with an in-app editor that opens one object by id.
const EDITOR_ROUTES: Readonly<
Record<string, (id: string, name: string, org: string) => RouteLocationRaw>
@@ -148,6 +154,42 @@ export function approvalEditor(
return { type, route: open(id, approvalName(card) ?? "", org) };
}
/** When the change ends, epoch ms, from the argument that says so; `null` for a change with no end. */
export function approvalUntil(card: Pick<Card, "args">): number | null {
const sources = [card.args, recordOf(card.args?.request_body)];
for (const source of sources)
for (const key of UNTIL_FIELDS) {
const value = source?.[key];
if (typeof value === "number" && value > 0) return epochMsOfWire(value);
const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN;
if (!Number.isNaN(parsed)) return parsed;
}
return null;
}
/** The folder or team the change sits in, for the card's meta; `null` when the card names none. */
export function approvalScopeName(card: Pick<Card, "args">): string | null {
const sources = [card.args, recordOf(card.args?.request_body)];
for (const source of sources)
for (const key of SCOPE_FIELDS) {
const value = source?.[key];
if (typeof value === "string" && value.trim()) return value;
}
return null;
}
/** Who the change notifies, shown in Details; `null` when the card names nobody. */
export function approvalNotifies(card: Pick<Card, "args">): string | null {
const sources = [card.args, recordOf(card.args?.request_body)];
for (const source of sources)
for (const key of NOTIFY_FIELDS) {
const value = source?.[key];
if (typeof value === "string" && value.trim()) return value;
if (Array.isArray(value) && value.length) return value.map(String).join(", ");
}
return null;
}
/** Whether a card changes something, so the scope select only shows on a batch of them. */
export function isApprovalChange(card: Card): boolean {
return approvalVerb(card) !== null;
@@ -167,6 +209,12 @@ function toggleOf(card: Pick<ApprovalBlockData, "tool" | "args">): { enabled: bo
return null;
}
// Search times are microseconds on the wire, seconds in some tools.
function epochMsOfWire(value: number): number {
if (value > 1e14) return Math.round(value / 1000);
return value < 1e12 ? value * 1000 : value;
}
function normalTool(tool: string): string {
return tool.toLowerCase().replace(/[^a-z]/g, "");
}
@@ -317,7 +317,14 @@ export type OpenAssistantResult =
reason: GateCode | "turn_running_no_autosend" | "approval_pending" | "org_changed";
};
export type ApprovalKind = "delete" | "update" | "create" | "run_command" | "permission";
export type ApprovalKind =
"delete" | "update" | "create" | "run_command" | "permission" | "question";
/** One choice of a question card. */
export interface QuestionOption {
id: string;
label: string;
}
/** `tool` = always for this tool; never offered for destructive operations. */
export type ApprovalScope = "once" | "chat" | "tool";
@@ -352,6 +359,10 @@ export interface PendingApproval {
/** From the last extend answer; absent until the server has said. */
userExtendsLeft?: number;
askedIn: "here" | "other_tab";
/** A question's choices; empty on an approval. */
options?: QuestionOption[];
/** A question also takes the user's own words. */
allowOther?: boolean;
/** False until B-05. */
grantable: boolean;
editable: boolean;
@@ -363,8 +374,12 @@ export type ApprovalDecision =
| { approved: true; scope: ApprovalScope; items?: string[] }
| {
approved: false;
reason: "deny" | "escape" | "new_chat" | "reply" | "expired" | "withdrawn" | "edited";
reason: "deny" | "new_chat" | "reply" | "answer" | "expired" | "withdrawn" | "edited";
reply?: string;
/** What `/confirm` carries for a question: a choice's id or the user's own text. */
answer?: string;
/** The words the record shows; a choice's label, or the text itself. */
answerLabel?: string;
};
export interface ApprovalResultItem {
@@ -400,6 +415,7 @@ export interface ApprovalOutcome {
| "new_chat"
| "stopped"
| "replied"
| "answered"
| "editing"
| "narrowed"
| "expired"
+15
View File
@@ -355,6 +355,21 @@ export function answerQueriesAllEmpty(
return queries.length > 0 && queries.every((step) => step.state === "done" && step.rows === 0);
}
/** Newest data the answer's queries read, epoch ms; `null` when no query ran. */
export function answerDataAt(steps: StepFacts[]): number | null {
const ends = steps.flatMap((step) => (step.range ? [step.range.to] : []));
return ends.length ? Math.max(...ends) : null;
}
/** Past this the answer says how old its data is, and the last turn offers Re-run. */
export const STALE_AFTER_MS = 60 * 60_000;
/** True when the data behind an answer is old enough to say so. */
export function isStaleAnswer(steps: StepFacts[], now: number): boolean {
const at = answerDataAt(steps);
return at !== null && now - at >= STALE_AFTER_MS;
}
/** Claim numbers by distinct `call_id` in order of first citation. */
export function claimNumbers(claims: string[]): Map<string, number> {
const numbers = new Map<string, number>();
@@ -190,4 +190,23 @@ describe("frame guards", () => {
expect(isDroppableFrame(parseFrame('{"type":"status"}'))).toBe(false);
expect(isUnknownFrame(parseFrame('{"type":"sync"}'))).toBe(false);
});
it("parses an ask_user frame, its options optional, and drops a malformed one to unknown", () => {
expect(
parseFrame(
JSON.stringify({
type: "ask_user",
request_id: "r1",
question: "Which folder?",
options: [{ id: "p", label: "Payments" }],
allow_other: true,
expires_at: 1_800_000_000,
}),
).type,
).toBe("ask_user");
expect(
parseFrame(JSON.stringify({ type: "ask_user", request_id: "r1", question: "q" })).type,
).toBe("ask_user");
expect(parseFrame(JSON.stringify({ type: "ask_user", question: "q" })).type).toBe("unknown");
});
});
+20
View File
@@ -247,6 +247,22 @@ const findingsFrameSchema = z.looseObject({
next_steps: z.array(z.string()),
});
const askUserOptionSchema = z.looseObject({ id: z.string(), label: z.string() });
const askUserFrameSchema = z.looseObject({
type: z.literal("ask_user"),
request_id: z.string(),
question: z.string(),
options: z.array(askUserOptionSchema).optional(),
allow_other: z.boolean().optional(),
expires_at: z.number().optional(),
});
const followUpsFrameSchema = z.looseObject({
type: z.literal("follow_ups"),
items: z.array(z.string()),
});
const syncFrameSchema = z.looseObject({ type: z.literal("sync") });
const syncStateFrameSchema = z.looseObject({ type: z.literal("sync_state") });
@@ -277,6 +293,8 @@ export const assistantFrameSchema = z.discriminatedUnion("type", [
queryFrameSchema,
resourceChangedFrameSchema,
findingsFrameSchema,
askUserFrameSchema,
followUpsFrameSchema,
syncFrameSchema,
syncStateFrameSchema,
]);
@@ -309,6 +327,8 @@ export type ConfirmationResolvedFrame = FrameOf<"confirmation_resolved">;
export type QueryFrame = FrameOf<"query">;
export type ResourceChangedFrame = FrameOf<"resource_changed">;
export type FindingsFrame = FrameOf<"findings">;
export type AskUserFrame = FrameOf<"ask_user">;
export type FollowUpsFrame = FrameOf<"follow_ups">;
/** A line that is not JSON, not an object, or not a known frame; the parser counts it and moves on. */
export interface UnknownFrame {
+7
View File
@@ -115,7 +115,9 @@ export type {
} from "./useEditorProposal";
export {
MAX_HIT_ROWS,
answerDataAt,
answerQueriesAllEmpty,
isStaleAnswer,
chipOpenQuery,
claimNumbers,
fenceQuery,
@@ -151,6 +153,10 @@ export { canAlwaysAllow } from "./actions/appliers/approval";
export {
approvalDiff,
approvalEditor,
approvalToolEntity,
approvalNotifies,
approvalScopeName,
approvalUntil,
approvalName,
approvalObjectId,
approvalVerb,
@@ -162,6 +168,7 @@ export {
} from "./approvalAnatomy";
export {
approvalClock,
isReplyingTo,
approvalDuration,
approvalEntity,
approvalHeading,
+2
View File
@@ -112,6 +112,8 @@ export const KNOWN_FRAME_TYPES: ReadonlySet<string> = new Set([
"query",
"resource_changed",
"findings",
"ask_user",
"follow_ups",
"sync",
"sync_state",
]);
@@ -595,6 +595,53 @@ describe("reduce: tool_result", () => {
});
});
describe("reduce: ask_user and follow_ups", () => {
const lastBlocks = (state: StreamState) => state.messages.at(-1)?.contentBlocks ?? [];
it("holds a question as a card of kind question, and treats the options as optional", () => {
const state = makeState();
const effects = reduce(
state,
{
type: "ask_user",
request_id: "r1",
question: "Which folder?",
options: [{ id: "p", label: "Payments" }, { id: 3 }],
allow_other: true,
expires_at: 1_800_000_000,
},
makeCtx({ phase: "live" }),
);
const card = lastBlocks(state).find((block) => block.approval)?.approval;
expect(card).toMatchObject({
id: "r1",
kind: "question",
question: "Which folder?",
options: [{ id: "p", label: "Payments" }],
allowOther: true,
expiresAt: 1_800_000_000_000,
});
expect(effects.some((effect) => effect.kind === "approval")).toBe(true);
const bare = makeState();
reduce(
bare,
{ type: "ask_user", request_id: "r2", question: "Sure?" },
makeCtx({ phase: "live" }),
);
expect(lastBlocks(bare).find((block) => block.approval)?.approval?.options).toEqual([]);
});
it("keeps at most three follow-ups and drops blanks", () => {
const state = makeState();
reduce(
state,
{ type: "follow_ups", items: ["a", " ", "b", "c", "d"] },
makeCtx({ phase: "live" }),
);
expect(lastBlocks(state).find((block) => block.followUps)?.followUps).toEqual(["a", "b", "c"]);
});
});
describe("reduce: confirmation_required", () => {
it("adds an inline confirmation block and scrolls", () => {
const msg = assistant();
+55 -1
View File
@@ -32,7 +32,7 @@ import {
type StepResult,
} from "./evidence";
import { contextUsedKeys } from "./frames";
import type { ApprovalKind, ApprovalTarget } from "./assistantStore.types";
import type { ApprovalKind, ApprovalTarget, QuestionOption } from "./assistantStore.types";
import type { ConfirmationRequiredFrame, ConfirmationResolvedFrame, UsageFrame } from "./frames";
const STEP_STATES: ReadonlySet<string> = new Set(["running", "done", "failed", "interrupted"]);
@@ -48,12 +48,17 @@ const MAX_TOOL_CHARS = 100;
const EVIDENCE_TYPES: ReadonlySet<string> = new Set(["query", "link", "findings"]);
const MAX_QUESTION_OPTIONS = 8;
const MAX_FOLLOW_UPS = 3;
const APPROVAL_KINDS: ReadonlySet<string> = new Set<ApprovalKind>([
"delete",
"update",
"create",
"run_command",
"permission",
"question",
]);
// Model and tool output on a card is untrusted: every list and string is capped.
@@ -119,6 +124,10 @@ export interface ApprovalBlockData {
typed: boolean;
/** The server's own expiry, epoch ms. */
expiresAt?: number;
/** A question's choices; a question sends one at once. */
options?: QuestionOption[];
/** A question also takes the user's own words. */
allowOther?: boolean;
/** Replayed with no decision in the record, so it renders as a record and never as a live card. */
noDecision?: boolean;
/** Replayed without a resolution frame; the server's approval row says how it was answered. */
@@ -133,6 +142,8 @@ export interface ResolvedBlockData {
at?: number;
/** Nobody answered in time; `approved` is false. */
expired?: boolean;
/** What a question was answered with elsewhere. */
answer?: string;
/** The server dropped the card because the chat moved on; `approved` is false. */
withdrawn?: boolean;
}
@@ -725,6 +736,11 @@ function reduceTyped(state: StreamState, data: any, ctx: ReducerCtx): StreamEffe
return typedPermissionDenied(state, data, extras);
case "confirmation_required":
return typedApproval(state, data, live, ctx.phase);
case "ask_user":
return typedApproval(state, askAsConfirmation(data), live, ctx.phase);
case "follow_ups":
typedFollowUps(state, data);
return [];
case "confirmation_resolved":
return typedResolved(state, data, ctx.phase);
case "resource_changed":
@@ -1006,6 +1022,28 @@ function typedApproval(
return effects;
}
// A question is held, extended, expired and answered as a card, so everything that watches cards counts it.
function askAsConfirmation(data: any) {
return {
type: "confirmation_required",
id: data.request_id,
tool: "ask_user",
kind: "question",
message: data.question,
options: data.options,
allow_other: data.allow_other,
expires_at: data.expires_at,
};
}
function typedFollowUps(state: StreamState, data: any): void {
const followUps = (Array.isArray(data.items) ? data.items : [])
.filter((item: unknown): item is string => typeof item === "string" && !!item.trim())
.slice(0, MAX_FOLLOW_UPS)
.map((item: string) => item.slice(0, MAX_LINE_CHARS));
if (followUps.length) appendBlock(state, { type: "follow_ups", followUps });
}
function typedResolved(state: StreamState, data: any, phase: StreamPhase): StreamEffect[] {
resolveConfirmation(state, data);
const id = boundedId(data.id);
@@ -1017,6 +1055,7 @@ function typedResolved(state: StreamState, data: any, phase: StreamPhase): Strea
...(typeof data.at === "number" && { at: data.at }),
...(data.expired === true && { expired: true }),
...(data.withdrawn === true && { withdrawn: true }),
...(typeof data.answer === "string" && { answer: data.answer.slice(0, MAX_LINE_CHARS) }),
};
appendBlock(state, { type: "approval_result", resolved });
return phase === "live" ? [{ kind: "approval_resolved", frame: data }] : [];
@@ -1078,9 +1117,24 @@ export function approvalOf(data: any): ApprovalBlockData {
...(Object.keys(args).length > 0 && { args: boundedJson(args) as Record<string, unknown> }),
typed,
...(expiresAt !== null && { expiresAt }),
...(kind === "question" && {
options: questionOptions(data?.options),
allowOther: data?.allow_other === true,
}),
};
}
function questionOptions(raw: unknown): QuestionOption[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((option) => typeof option?.id === "string" && typeof option?.label === "string")
.slice(0, MAX_QUESTION_OPTIONS)
.map((option) => ({
id: option.id.slice(0, MAX_TOOL_CHARS),
label: option.label.slice(0, MAX_LINE_CHARS),
}));
}
function approvalKindOf(data: any, tool: string, args: Record<string, unknown>): ApprovalKind {
if (APPROVAL_KINDS.has(data?.kind)) return data.kind as ApprovalKind;
if (tool === "o2_delete") return "delete";
@@ -1404,7 +1404,7 @@ describe("turnEngine: outcomes", () => {
await h.store.stop(h.current.sid);
await run;
h.store.continueTurn(h.current.sid);
expect(h.session().composer.text).toBe("Continue");
expect(h.session().composer.text).toBe("Continue the answer above");
expect(h.session().composer.chips.map((c) => c.id)).toEqual(["s"]);
expect(h.sends).toHaveLength(1);
});
+2 -4
View File
@@ -385,7 +385,7 @@ export function createTurnEngine(deps: TurnEngineDeps): TurnEngine {
noteHandoffFailed(session, rec);
out.turn.push(...next.effects);
if (session.turn.state !== "awaiting_approval") clearApproval(rec);
else if (frame.type === "confirmation_required")
else if (frame.type === "confirmation_required" || frame.type === "ask_user")
watchApproval(rec, session.pendingApproval?.expiresAt ?? frame.expires_at);
return out;
};
@@ -942,9 +942,7 @@ export function createTurnEngine(deps: TurnEngineDeps): TurnEngine {
},
continueTurn(sid) {
const chips = last.get(sid)?.snapshot.chips ?? [];
deps
.store()
.stage(sid, { text: t("assistant.turn.continuePrefill"), chips, mode: "replace" });
deps.store().stage(sid, { text: t("assistant.turn.continueDraft"), chips, mode: "replace" });
},
async retrySave(sid, turnId) {
const session = sessionOf(sid);
@@ -104,6 +104,7 @@ const QUIET_FRAMES: ReadonlySet<string> = new Set([
"compacting",
"query",
"findings",
"follow_ups",
"resource_changed",
"confirmation_resolved",
"navigation_action",
@@ -570,6 +571,8 @@ function onFrame(turn: TurnState, frame: AssistantFrame, ctx: TurnReducerCtx): S
: same(turn);
case "confirmation_required":
return step({ state: "awaiting_approval", callId: frame.call_id ?? frame.id ?? frame.tool });
case "ask_user":
return step({ state: "awaiting_approval", callId: frame.request_id });
case "error":
return onErrorFrame(turn, frame, ctx);
case "complete":
@@ -437,14 +437,55 @@ describe("useAssistantApprovals", () => {
it("tracks the decision with its latency and input, never content", async () => {
const { sid, approvals } = setup();
now += 4_000;
await approvals.decide(sid, { approved: false, reason: "escape" });
await approvals.decide(sid, { approved: false, reason: "deny" });
expect(track).toHaveBeenCalledWith(
"ai_approval_decided",
{ decision: "escape", items_count: 0, latency_ms: 4_000, input: "click" },
{ decision: "deny", items_count: 0, latency_ms: 4_000, input: "click" },
expect.anything(),
);
});
describe("question", () => {
const QUESTION = {
type: "confirmation_required",
id: "q1",
tool: "ask_user",
kind: "question",
message: "Which folder?",
options: [{ id: "payments", label: "Payments" }],
allow_other: true,
} as ConfirmationRequiredFrame;
it("posts a choice with { answer } on the request id, and records its label", async () => {
const { sid, env, approvals, session } = setup(QUESTION);
await approvals.decide(
sid,
{ approved: false, reason: "answer", answer: "payments", answerLabel: "Payments" },
"q1",
);
expect(env.confirm).toHaveBeenCalledWith(
expect.anything(),
sid,
{ id: "q1", answer: "payments" },
expect.anything(),
);
expect(session.pendingApproval).toBeNull();
expect(approvalOutcomeOf(session, "q1").outcome?.state).toBe("answered");
});
it("takes the composer's text as the answer, and sends no follow-up turn", async () => {
const { sid, env, approvals, session } = setup(QUESTION);
await approvals.decide(sid, { approved: false, reason: "reply", reply: "Platform" }, "q1");
expect(env.confirm).toHaveBeenCalledWith(
expect.anything(),
sid,
{ id: "q1", answer: "Platform" },
expect.anything(),
);
expect(approvalOutcomeOf(session, "q1").outcome?.state).toBe("answered");
});
});
it("never lets Send again answer a card that arrived after the failed one", async () => {
const { sid, env, approvals, session } = setup(PERMISSION);
env.confirm.mockRejectedValueOnce({ response: { status: 502 } });
@@ -169,6 +169,12 @@ export function useAssistantApprovals(sid: Sid): {
};
}
/** True while the composer writes the reply or the typed answer to this card. */
export function isReplyingTo(sid: Sid | undefined, approvalId: string): boolean {
const composer = sid ? runtimeHandle().value?.store.state.sessions[sid]?.composer : null;
return composer?.mode === "reply" && composer.replyApprovalId === approvalId;
}
/** The built runtime's approvals, for the cards and records that act on it; `null` before it exists. */
export function assistantApprovals(): ApprovalsRuntime | null {
return runtimeHandle().value?.approvals ?? null;
@@ -395,7 +401,21 @@ export function createApprovalsRuntime(deps: ApprovalsDeps): ApprovalsRuntime {
// A click answers the card it was made on, and an approve always names it; a card that changed since is never decided for it.
const named = approvalId === undefined ? !decision.approved : pending?.id === approvalId;
if (!pending || !named) return Promise.resolve(emptyResult("failed", "", deps.now()));
return run(sid, pending.id, decision);
// The composer's reply mode is how a question takes the user's own words.
const typedAnswer =
pending.kind === "question" && !decision.approved && decision.reason === "reply";
return run(
sid,
pending.id,
typedAnswer
? {
approved: false,
reason: "answer",
answer: decision.reply,
answerLabel: decision.reply,
}
: decision,
);
},
async withdraw(sid, reason) {
const pending = sessionOf(sid)?.pendingApproval;
@@ -524,9 +544,11 @@ export function entityKeyOf(type: string | undefined): string {
/** The card's heading: verb, entity and name in one sentence; the raw id is never part of it (D3-n). */
export function approvalHeading(
t: TranslateFn,
card: Pick<ApprovalBlockData, "kind" | "targets" | "tool" | "args">,
card: Pick<ApprovalBlockData, "kind" | "targets" | "tool" | "args"> &
Partial<Pick<ApprovalBlockData, "question">>,
count: number,
): I18nText {
if (card.kind === "question") return raw(card.question ?? "");
const verb = approvalVerb(card);
const toolset = typeof card.args?.toolset === "string" ? card.args.toolset : card.tool;
if (verb === "delete") {
@@ -699,6 +721,8 @@ function pendingOf(
grantable: block.grantable,
editable: block.editable,
question: block.question,
...(block.options && { options: block.options }),
...(block.allowOther !== undefined && { allowOther: block.allowOther }),
};
}
@@ -766,8 +790,7 @@ function decisionKind(
decision: ApprovalDecision,
): AiEventProps["ai_approval_decided"]["decision"] | null {
if (decision.approved) return "approve";
if (decision.reason === "deny" || decision.reason === "escape" || decision.reason === "reply")
return decision.reason;
if (decision.reason === "deny" || decision.reason === "reply") return decision.reason;
return decision.reason === "edited" ? "edit_then_approve" : null;
}
+71 -54
View File
@@ -9294,21 +9294,16 @@
"askAgainPrompt": "Ask me again: {heading}",
"askedElsewhere": "Asked in another tab",
"cannotUndo": "Cannot be undone",
"change": "Change",
"command": "Command",
"currentUnknown": "Current value unknown",
"deny": "Deny",
"details": "Details",
"edit": "Edit then approve",
"editInEditor": "Edit in {editor}",
"editor": {
"alert": "alert editor"
"alert": "Alerts"
},
"effect": "Effect",
"escapeHint": "Esc declines",
"expiresIn": "Expires in {time}",
"expiringSoon": "Expires soon · {time}",
"gateTiming": "Approving needs a newer {product} agent. Nothing changes unless you approve; ask an admin to update the agent.",
"gateTiming": "Approvals aren't available on this server. Finish this in {editor}.",
"heading": {
"delete": "Delete {count} {entity}",
"permission": "Allow {tool}",
@@ -9316,13 +9311,11 @@
},
"inOrg": "in {org}",
"minutes": "{count} minute | {count} minutes",
"lastExtension": "No more time can be added.",
"moreTargets": "and {count} more",
"moreTime": "More time",
"named": "{verb} {name}",
"nameUnknown": "Couldn't load the name of {entity} {shortId}; you may not have access to it.",
"needsOk": "Needs your OK",
"newValues": "New values",
"noDecision": "No answer was recorded for this request",
"nothingSelected": "Nothing selected",
"open": "Open",
@@ -9333,7 +9326,6 @@
"unknown": "error"
},
"reply": "Reply",
"replyConsequence": "Reply declines this request; the assistant can answer or ask again.",
"result": {
"approved": "Approved · {time}",
"approvedNoTime": "Approved",
@@ -9344,8 +9336,6 @@
"declined": "Declined · nothing was changed",
"declinedElsewhere": "Denied in another tab · {time}",
"declinedNewChat": "Declined because you started a new chat",
"editing": "You're editing this in the {editor}. Nothing was changed here.",
"deliveryFailed": "Your decision didn't reach the assistant. Nothing was changed.",
"done": {
"create": "Created {entity}",
"delete": "Deleted {count} {entity}",
@@ -9353,7 +9343,6 @@
"runCommand": "Ran the command on {toolset}",
"update": "Updated {entity} {name}"
},
"expired": "No answer in time, so the assistant didn't run this action.",
"failed": "Nothing was changed. {reason}",
"failedConsent": "Paid usage isn't enabled for this organization.",
"failedCredits": "AI credits are used up for this organization.",
@@ -9363,8 +9352,6 @@
"narrowed": "The assistant will propose these {count} again for your approval",
"open": "Open {name} (opens in a new tab)",
"partial": "Deleted {done} of {total} {entity} · {failed} failed",
"replied": "Declined · replied: “{reply}”",
"sendAgain": "Send again",
"sending": "Sending your decision…",
"stop": "Stop",
"stopped": "Declined · you stopped the answer",
@@ -9385,6 +9372,26 @@
"pause": "Pause {entity}",
"resume": "Resume {entity}",
"update": "Update {entity}"
},
"backTo": "Back to {title}",
"backToOrg": "Switches back to {org}",
"deliveryFailed": "Couldn't send your decision. The {entity} hasn't changed.",
"finishIn": "Finish in {editor}",
"gateNoEditor": "Approvals aren't available on this server.",
"meta": "Until {time} · {scope}",
"metaUntil": "Until {time}",
"needsOkName": "Needs your OK: {name}",
"notifies": "notifies",
"retry": "Retry",
"record": {
"denied": "Denied: {name}",
"expired": "Request expired. Nothing was changed.",
"moved": "Moved to {editor}",
"paused": "Paused {entity} {name}",
"pausedUntil": "Paused {entity} {name} until {time}",
"replied": "Declined with a reply",
"resumed": "Resumed {entity} {name}",
"youAt": "you · {time}"
}
},
"askAdmin": {
@@ -9476,9 +9483,7 @@
"claim": {
"name": "Evidence {n}, {step}",
"notAvailable": "Evidence not available in this view",
"peekTitle": "Evidence {n}",
"showInSteps": "Show in steps",
"stepOf": "step {i} of {total}"
"citation": "Citation {n}, query {n}, {rows}"
},
"client": {
"copyManually": "Couldn't copy. Select the text and copy it.",
@@ -10118,8 +10123,6 @@
"openHint": "Opens with the query loaded. Nothing runs until you choose Run query.",
"opensNewTab": "{label} (opens in a new tab)",
"otherOrg": "This query belongs to {org}. Switch organization to run it.",
"queriesCited": "{count} cited",
"queriesHeading": "Queries",
"queryFailed": "Query failed: {reason}",
"queryFailedPlain": "Query failed",
"rangeFallback": "range: unknown · runs on the last 15 min",
@@ -10132,9 +10135,12 @@
"runNowHint": "Opens {explorer} with this query loaded; you run it there.",
"sawRows": "The assistant got {count} row | The assistant got {count} rows",
"sawRowsIn": "The assistant got {count} row in {range} | The assistant got {count} rows in {range}",
"showMore": "Show {count} more",
"stopped": "Stopped",
"windowCapped": "{window} (cut to the last 7 days)"
"windowCapped": "{window} (cut to the last 7 days)",
"askAdmin": "Ask an admin",
"line": "{count} query, {range} | {count} queries, {range}",
"noAccess": "{n} citation unavailable: no access to {stream} | {n} citations unavailable: no access to {stream}",
"noAccessPlain": "{n} citation unavailable | {n} citations unavailable"
},
"export": {
"approved": "Approved by you at {time}",
@@ -10157,7 +10163,6 @@
"cancel": "Cancel",
"chatIdCopied": "Chat id copied",
"chatIdShort": "Chat {id}",
"copyChatId": "Copy chat id",
"disclosure": "Sends your rating, the reason and this chat's id. It never sends the chat text.",
"failed": "Couldn't send feedback. Try again.",
"groupName": "Rate this answer",
@@ -10177,52 +10182,39 @@
"whatWentWrong": "What went wrong?"
},
"findings": {
"comment": "What was the cause? (optional)",
"confidence": "Confidence: {level}",
"evidence": "Evidence",
"failed": "Couldn't send that. Try again.",
"inconclusive": "Inconclusive: the assistant couldn't confirm a cause",
"inferred": "Inferred",
"invalid": "Findings could not be shown as a card",
"level": {
"high": "high",
"inconclusive": "inconclusive",
"low": "low",
"medium": "medium"
},
"likelyCause": "Likely cause",
"nextSteps": "Next steps",
"no": "No",
"notSure": "Not sure",
"retry": "Try again",
"ruledOut": "Ruled out",
"send": "Send",
"thanks": "Thanks, that helps calibrate the assistant",
"title": "Findings",
"unknown": "Unknown",
"verified": "Verified by query",
"wasThisTheCause": "Was this the cause?",
"yes": "Yes"
"yes": "Yes",
"inferredLine": "Inferred: {text}",
"next": "Next: {text}",
"notEstablished": "Not established: {text}",
"ruledOutLine": "Ruled out: {text}",
"showRaw": "Show raw findings",
"unreadable": "Couldn't show findings."
},
"footer": {
"credits": "{count} credit | {count} credits",
"creditsCountedHint": "Credits are counted when a request is accepted.",
"emptyWindow": "Nothing in the default range, {range}.",
"evaluated": "Evaluated {range}",
"evaluatedMore": "Evaluated {range} and {count} other window | Evaluated {range} and {count} other windows",
"includedCredits": "{count} included credit | {count} included credits",
"noQuery": "Answered without running a query",
"notSaved": "not saved",
"notSavedHint": "This answer was given but could not be saved to history",
"queries": "{count} query | {count} queries",
"refunded": "0 credits · refunded",
"rerun": "Re-run for now",
"rerunFor": "Re-run for {range}",
"stopped": "stopped",
"tokens": "{tokens} tokens · {count} model call | {tokens} tokens · {count} model calls",
"tryWider": "Try the last {duration}",
"windowsName": "Evaluated windows",
"windowStep": "Step {n} · {range}"
"tryWider": "Try the last {duration}"
},
"fork": {
"contextNote": "Your first message uses the shared chat as context.",
@@ -10660,7 +10652,8 @@
"versionName": "Version {n} of {count}",
"versionOf": "{n} of {count}",
"you": "You",
"metaHandoff": "{meta} · to {agent}"
"metaHandoff": "{meta} · to {agent}",
"copyChatId": "Copy chat id"
},
"name": "O2 Assistant",
"limit": {
@@ -11229,8 +11222,6 @@
"outputTruncated": "Output truncated",
"rowName": "{step}, {state}",
"rows": "{count} row | {count} rows",
"showEarlier": "Show {count} earlier step | Show {count} earlier steps",
"soFar": "{count} step so far | {count} steps so far",
"step": {
"created": "Created {resource}",
"deleted": "Deleted {resource}",
@@ -11248,7 +11239,6 @@
"stoppedBeforeFinish": "stopped before it finished",
"streams": "{count} stream | {count} streams",
"summary": "Worked through {count} step | Worked through {count} steps",
"summaryWith": "Worked through {count} step · {detail} | Worked through {count} steps · {detail}",
"window": "{from} → {to}",
"windowShowing": "{from} → {to} · Showing {shown} of {total} rows"
},
@@ -11435,7 +11425,23 @@
"waitingApproval": "Waiting for your approval",
"waitingConsent": "Waiting for your decision on paid usage",
"withElapsed": "{status} · {elapsed}",
"writing": "Writing the answer"
"writing": "Writing the answer",
"ageDays": "{n} d",
"ageHours": "{n} h",
"connectionLost": "Connection lost. The answer above is incomplete.",
"confidence": {
"high": "High confidence",
"inconclusive": "Inconclusive",
"low": "Low confidence",
"medium": "Medium confidence"
},
"continueDraft": "Continue the answer above",
"dataAge": "Data from {age} ago",
"lastHours": "Last {n} h",
"lastMinutes": "Last {n} min",
"rerun": "Re-run",
"stoppedAfter": "Stopped after {n} step, incomplete | Stopped after {n} steps, incomplete",
"stoppedIncomplete": "Stopped, incomplete"
},
"voice": {
"allow": {
@@ -11540,8 +11546,8 @@
},
"dashboard": {
"1": "What changed on \"{dashboard}\" in the last {range}?",
"1Generic": "What changed on this dashboard recently?",
"2": "Explain the panel with the biggest change"
"2": "Explain the panel with the biggest change",
"1Generic": "What changed on this dashboard recently?"
},
"function": {
"1": "Write VRL to parse JSON from the `message` field",
@@ -11565,14 +11571,14 @@
"learn": "What can you do here?",
"logs": {
"1": "Why did errors spike in `{stream}` in the last {range}?",
"1Generic": "Why did errors spike in my logs recently?",
"2": "Summarise what's in `{stream}` right now",
"1Generic": "Why did errors spike in my logs recently?",
"2Generic": "Summarise what's in my logs right now"
},
"metrics": {
"1": "Write PromQL for p95 latency of `{metric}`",
"1Generic": "Write PromQL for p95 latency",
"2": "Which metrics stopped reporting?"
"2": "Which metrics stopped reporting?",
"1Generic": "Write PromQL for p95 latency"
},
"recent": "Continue \"{title}\"",
"regex": {
@@ -11667,6 +11673,17 @@
"prompt": "Continue this investigation",
"promptReason": "Continue this investigation: {reason}"
}
},
"question": {
"needsAnswer": "Needs your answer: {question}",
"otherAnswer": "Other answer",
"record": {
"answered": "Answered: {choice}",
"expired": "Question expired. Nothing was changed."
}
},
"send": {
"unreachable": "Isn't reachable. Your message wasn't sent."
}
},
"llmObservability": {
+2
View File
@@ -83,6 +83,8 @@ export interface ConfirmBody {
scope?: string;
items?: string[];
reply?: string;
/** Answers the question card `id`, with a choice's id or the user's own text. */
answer?: string;
/** Asks for more time on the card `id` instead of deciding. */
id?: string;
extend?: boolean;
+3
View File
@@ -46,6 +46,7 @@ export interface ContentBlock {
| "query"
| "link"
| "findings"
| "follow_ups"
| "footer"
| "approval"
| "approval_result"
@@ -57,6 +58,8 @@ export interface ContentBlock {
message?: I18nText;
context?: Record<string, any>;
call_id?: string; // Unique ID for matching tool_result to tool_call
/** The next questions the server offers, at most three. */
followUps?: string[];
// For text type:
text?: string;
// Confirmation state (from confirmation_required events):