mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
feat(assistant): conversation redesign
Claude-Session: https://claude.ai/code/session_017PUuNbanUSUUbZ5dcZBYmM
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user