+
@@ -161,14 +83,7 @@ along with this program. If not, see .
-
-
- {{ t("assistant.agent.picker.note") }}
-
-
- {{ t("assistant.agent.picker.next", { agent: nextName }) }}
-
@@ -176,10 +91,14 @@ along with this program. If not, see
.
import { computed, nextTick, ref, watch } from "vue";
import { useStore } from "vuex";
-import AssistantMark from "@/components/ai-assistant/common/AssistantMark.vue";
import { agentGlyph, assistantGlyph } from "@/components/ai-assistant/common/glyphs";
+import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import AssistantAskAdminPopover from "@/components/ai-assistant/gate/AssistantAskAdminPopover.vue";
-import { announceComposer } from "@/components/ai-assistant/composer/composerUi";
+import {
+ announceComposer,
+ composerPopoverClass,
+ useComposerLayout,
+} from "@/components/ai-assistant/composer/composerUi";
import {
useAssistantCapability,
useAssistantPlacement,
@@ -190,30 +109,25 @@ import {
} from "@/composables/assistant";
import {
agentName,
- agentPolicy,
- agentPurpose,
agentReason,
currentAgent,
nextAgent,
rosterOffersChoice,
} from "@/composables/assistant/pure";
-import useBreakpoint from "@/composables/useBreakpoint";
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 ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
-import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
-import OSheet from "@/lib/overlay/Sheet/OSheet.vue";
-import type { SheetSnap } from "@/lib/overlay/Sheet/OSheet.types";
+import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
+import { agentClause } from "./agentClause";
+
interface Row {
id: AgentId;
name: I18nText;
- purpose: I18nText;
- policy: I18nText;
+ clause: I18nText;
reason: I18nText | null;
askAdmin: boolean;
}
@@ -225,11 +139,8 @@ const store = useAssistantStore();
const vuex = useStore();
const capability = useAssistantCapability();
const placement = useAssistantPlacement();
-const { mdUp } = useBreakpoint();
-const isMobile = computed(() => !mdUp.value);
+const layout = useComposerLayout();
const menuOpen = ref(false);
-const sheetOpen = ref(false);
-const sheetSnap = ref
("half");
const roster = computed(() => capability.value.agents);
const shown = computed(() => rosterOffersChoice(roster.value));
@@ -237,14 +148,22 @@ const session = computed(() => (props.sid ? (store.state.sessions[props.sid] ??
const org = computed(() => vuex.state.selectedOrganization?.identifier ?? "");
const shownAgent = computed(() => nextAgent(session.value));
const shownName = computed(() => agentName(shownAgent.value, t));
-const nextName = computed(() => {
+const pending = computed(() => {
const next = session.value?.nextAgentId;
- return next && next !== currentAgent(session.value) ? agentName(next, t) : null;
+ return !!next && next !== currentAgent(session.value);
});
-const triggerAttrs = computed(() => ({
- "aria-label": t("assistant.agent.picker.label", { agent: shownName.value }),
- "data-test": "assistant-agent-picker",
-}));
+const shownClause = computed(() =>
+ agentClause(
+ roster.value?.find((agent) => agent.id === shownAgent.value),
+ t,
+ ),
+);
+const name = computed(() =>
+ t("assistant.agent.picker.label", { agent: shownName.value, clause: shownClause.value }),
+);
+const tooltip = computed(() =>
+ t("assistant.agent.picker.tooltip", { agent: shownName.value, clause: shownClause.value }),
+);
const rows = computed(() =>
(roster.value ?? []).map((agent) => {
@@ -252,8 +171,7 @@ const rows = computed(() =>
return {
id: agent.id,
name: agentName(agent.id, t),
- purpose: agentPurpose(agent.id, t),
- policy: agentPolicy(agent, t),
+ clause: agentClause(agent, t),
reason: agentReason(agent, refusal, t),
askAdmin: refusal ? refusal === "agent_not_permitted" : isRoleLocked(agent),
};
@@ -283,7 +201,6 @@ function choose(row: Row): void {
if (!sid) return;
store.pickAgent(sid, row.id, "picker");
announceComposer(sid, t("assistant.agent.announceChosen", { agent: row.name }));
- sheetOpen.value = false;
}
function announce(line: I18nText): void {
diff --git a/web/src/components/ai-assistant/agents/agentClause.ts b/web/src/components/ai-assistant/agents/agentClause.ts
new file mode 100644
index 0000000000..0271636ae2
--- /dev/null
+++ b/web/src/components/ai-assistant/agents/agentClause.ts
@@ -0,0 +1,25 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { type CapabilityAgent } from "@/composables/assistant";
+import { agentPolicy, agentPurpose, DEFAULT_AGENT } from "@/composables/assistant/pure";
+import type { I18nText, TranslateFn } from "@/types/i18n";
+
+/** "Finds causes · Read-only"; the default agent has no purpose line of its own. */
+export function agentClause(agent: CapabilityAgent | undefined, t: TranslateFn): I18nText {
+ const policy = agentPolicy(agent, t);
+ if (!agent || agent.id === DEFAULT_AGENT) return policy;
+ return t("assistant.agent.clause", { purpose: agentPurpose(agent.id, t), policy });
+}
diff --git a/web/src/components/ai-assistant/composer/AssistantAddContextMenu.spec.ts b/web/src/components/ai-assistant/composer/AssistantAddContextMenu.spec.ts
index 3e882d4a36..66fd1c84a3 100644
--- a/web/src/components/ai-assistant/composer/AssistantAddContextMenu.spec.ts
+++ b/web/src/components/ai-assistant/composer/AssistantAddContextMenu.spec.ts
@@ -85,11 +85,4 @@ describe("AssistantAddContextMenu", () => {
expect(wrapper.emitted("image")).toHaveLength(1);
expect(wrapper.emitted("select")).toBeUndefined();
});
-
- it("says why there is no mic while dictation is off", async () => {
- await openMenu();
- expect(
- document.body.querySelector('[data-test="assistant-composer-hint-voice"]')?.textContent,
- ).toContain("Voice dictation is off in this browser.");
- });
});
diff --git a/web/src/components/ai-assistant/composer/AssistantAddContextMenu.vue b/web/src/components/ai-assistant/composer/AssistantAddContextMenu.vue
index 84c6b75558..8c42051ffd 100644
--- a/web/src/components/ai-assistant/composer/AssistantAddContextMenu.vue
+++ b/web/src/components/ai-assistant/composer/AssistantAddContextMenu.vue
@@ -20,7 +20,7 @@ along with this program. If not, see .
side="top"
align="start"
:aria-label="t('assistant.composer.addContext')"
- content-class="w-[min(22rem,calc(100vw-1.5rem))] p-2"
+ :content-class="`${layout.tight ? 'w-[calc(20rem-0.375rem)]' : 'w-[min(22rem,calc(100vw-1.5rem))]'} p-2`"
privacy="mask"
@update:open="onOpen"
>
@@ -29,8 +29,11 @@ along with this program. If not, see .
:label="t('assistant.composer.addContext')"
:icon="assistantGlyph.addContext"
:no-tooltip="open"
+ class="h-7! w-7!"
data-test="assistant-add-context"
- />
+ >
+ {{ t("assistant.composer.addContextTip") }}
+
.
with-image
@select="pick"
/>
-
- -
- {{ row.label }}
-
- {{ row.keys }}
-
-
@@ -91,13 +79,12 @@ import { computed, nextTick, ref, useId } from "vue";
import AssistantIconButton from "@/components/ai-assistant/common/AssistantIconButton.vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import AssistantMentionMenu from "@/components/ai-assistant/context/AssistantMentionMenu.vue";
-import OShortcut from "@/lib/core/Shortcut/OShortcut.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import { trackAssistant } from "@/services/assistantAnalytics";
-import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
+import { useI18nTyped } from "@/types/i18n";
-import { useVoiceAvailability } from "./useAssistantVoice";
+import { useComposerLayout } from "./composerUi";
defineProps();
@@ -109,52 +96,7 @@ const query = ref("");
const search = ref | null>(null);
const menu = ref | null>(null);
const listboxId = `assistant-add-context-${useId()}`;
-const voice = useVoiceAvailability();
-
-interface KeyRow {
- id: string;
- label: I18nText;
- shortcut?: boolean;
- keys?: I18nText;
-}
-
-const voiceRow = computed((): KeyRow => {
- if (voice.status.value === "ready")
- return {
- id: "assistantComposerDictate",
- label: t("shortcuts.actions.assistantComposerDictate"),
- shortcut: true,
- };
- return {
- id: "voice",
- label: t(
- voice.status.value === "off"
- ? "assistant.voice.error.voice_off"
- : "assistant.voice.error.unsupported",
- ),
- };
-});
-
-// The keys the retired hints popover listed, plus the `@` that opens this same menu from the textbox.
-const keyRows = computed((): KeyRow[] => [
- { id: "mention", label: t("assistant.composer.mention.title"), keys: raw("@") },
- {
- id: "assistantComposerRecall",
- label: t("shortcuts.actions.assistantComposerRecall"),
- shortcut: true,
- },
- {
- id: "assistantComposerNewline",
- label: t("shortcuts.actions.assistantComposerNewline"),
- shortcut: true,
- },
- {
- id: "assistantComposerSend",
- label: t("shortcuts.actions.assistantComposerSend"),
- shortcut: true,
- },
- voiceRow.value,
-]);
+const layout = useComposerLayout();
const searchAttrs = computed(() => ({
"aria-label": t("assistant.composer.addMenuSearch"),
diff --git a/web/src/components/ai-assistant/composer/AssistantComposer.spec.ts b/web/src/components/ai-assistant/composer/AssistantComposer.spec.ts
index dbfadbdd5f..1bdb576fb4 100644
--- a/web/src/components/ai-assistant/composer/AssistantComposer.spec.ts
+++ b/web/src/components/ai-assistant/composer/AssistantComposer.spec.ts
@@ -13,11 +13,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import { flushPromises, mount } from "@vue/test-utils";
+import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ContextChip, PendingApproval } from "@/composables/assistant";
-import { runEscapeLadder } from "@/composables/assistant/shortcuts";
+import { registerAssistantFocusTarget, runEscapeLadder } from "@/composables/assistant/shortcuts";
import { trackAssistant } from "@/services/assistantAnalytics";
import { raw } from "@/types/i18n";
@@ -36,7 +36,7 @@ import { composerUi, noteDraftCleared, resetComposerUiForTests } from "./compose
import { HOLD_MS, SILENCE_MS, resetVoiceForTests, setVoiceOptIn } from "./useAssistantVoice";
const { fake, breakpoint } = vi.hoisted(() => ({
- fake: { current: null as any },
+ fake: { current: null as any, member: false },
breakpoint: { md: true },
}));
@@ -65,8 +65,12 @@ const ROW: ContextChip = {
sent: "serialized",
};
+const mounted: VueWrapper[] = [];
+
function setup(props: Partial = {}, withSession = true) {
fake.current = fakeAssistant();
+ if (fake.member)
+ fake.current.capability.value = { ...fake.current.capability.value, isAdmin: false };
const store = fake.current.store;
const sid = withSession
? store.createSession({
@@ -84,6 +88,7 @@ function setup(props: Partial = {}, withSession = true)
global: { plugins: [fakeRouter(), fakeVuex()] },
attachTo: host.lastElementChild as HTMLElement,
});
+ mounted.push(wrapper);
const textarea = () => wrapper.get('[data-test="assistant-composer-field"]');
const status = () => wrapper.get('[data-test="assistant-composer-status"]').text();
return { wrapper, store, sid: sid!, send, textarea, status, host };
@@ -112,6 +117,7 @@ async function type(textarea: ReturnType["textarea"]>,
beforeEach(() => {
breakpoint.md = true;
+ fake.member = false;
sessionStorage.clear();
localStorage.clear();
resetComposerUiForTests();
@@ -119,6 +125,8 @@ beforeEach(() => {
});
afterEach(() => {
+ // A live composer over a wiped body would throw inside the next flush and fail an unrelated test.
+ for (const wrapper of mounted.splice(0)) if (wrapper.exists()) wrapper.unmount();
document.body.innerHTML = "";
});
@@ -143,16 +151,109 @@ describe("names and regions (CT-08)", () => {
);
});
- it("uses the static name as the placeholder while the welcome offers a ghost (IW-06 AC 1)", () => {
- const { textarea } = setup({
- ghost: { label: raw("Why errors?"), text: "Why errors?", chips: [] },
- });
- expect(textarea().attributes("placeholder")).toBe("Message O2 Assistant");
+ it("asks about your data in an empty chat, with no count (§1)", () => {
+ const { textarea } = setup();
+ expect(textarea().attributes("placeholder")).toBe("Ask about your data");
});
- it("teaches scope with the default range when the page has no stream (CT-01 AC 10)", () => {
- const { textarea } = setup();
- expect(textarea().attributes("placeholder")).toBe("Ask a question or add context with @");
+ it("asks for a follow-up once the chat has a turn", async () => {
+ const { textarea, store, sid } = setup();
+ await answered(store, sid);
+ expect(textarea().attributes("placeholder")).toBe("Ask a follow-up");
+ });
+
+ it("names the chosen agent while it is not the default, and says the switch once", async () => {
+ const { wrapper, textarea, store, sid } = setup();
+ store.state.sessions[sid].nextAgentId = "sre";
+ await flushPromises();
+ expect(textarea().attributes("placeholder")).toBe("Ask Investigator");
+ expect(wrapper.get('[data-test="assistant-composer-helper"]').text()).toBe(
+ "Investigator answers from now on. Earlier answers stay.",
+ );
+ });
+});
+
+describe("rows (§1)", () => {
+ it("keeps one row while nothing beyond time is attached, and two once a chip is", async () => {
+ const { wrapper, store, sid } = setup();
+ await flushPromises();
+ const frame = () => wrapper.get('[data-test="assistant-composer"]');
+ expect(frame().attributes("data-rows")).toBe("1");
+ store.addChip(sid, ROW);
+ await flushPromises();
+ expect(frame().attributes("data-rows")).toBe("2");
+ });
+
+ it("takes two rows for a second line of text", async () => {
+ const { wrapper, textarea } = setup();
+ await type(textarea(), "one\ntwo");
+ expect(wrapper.get('[data-test="assistant-composer"]').attributes("data-rows")).toBe("2");
+ });
+
+ it("keeps the same DOM order at every width: text, tray, +, i, picker, mic, Send", () => {
+ const wrapper = mount(AssistantComposer, {
+ props: { sid: null },
+ slots: { chips: 'tray
' },
+ global: { plugins: [fakeRouter(), fakeVuex()] },
+ });
+ mounted.push(wrapper);
+ const order = (a: string, b: string) =>
+ wrapper
+ .get(`[data-test="${a}"]`)
+ .element.compareDocumentPosition(wrapper.get(`[data-test="${b}"]`).element) &
+ Node.DOCUMENT_POSITION_FOLLOWING;
+ expect(order("assistant-composer-field", "tray-stub")).toBeTruthy();
+ expect(order("tray-stub", "assistant-add-context")).toBeTruthy();
+ expect(order("assistant-add-context", "assistant-context-sees")).toBeTruthy();
+ expect(order("assistant-context-sees", "assistant-send")).toBeTruthy();
+ });
+});
+
+describe("refused sends (§1)", () => {
+ const refuse = (store: ReturnType["store"], sid: string, error: object) => {
+ const session = store.state.sessions[sid];
+ session.turn = { state: "refused", error } as never;
+ session.turnMeta.endedAt = Date.now();
+ };
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("counts a busy refusal down, keeps the draft, and turns Send on at 0", async () => {
+ vi.useFakeTimers();
+ const { wrapper, store, sid } = setup();
+ store.setDraft(sid, { text: "why?" });
+ refuse(store, sid, { code: "turn_limit", retryAfterMs: 2_000 });
+ await vi.advanceTimersByTimeAsync(0);
+ const row = () => wrapper.find('[data-test="assistant-composer-error-row"]');
+ expect(row().text()).toBe("O2 Assistant is busy. Send again in 2 s.");
+ expect(wrapper.get('[data-test="assistant-send"]').attributes("aria-disabled")).toBe("true");
+ await vi.advanceTimersByTimeAsync(2_000);
+ expect(row().exists()).toBe(false);
+ expect(wrapper.get('[data-test="assistant-send"]').attributes("aria-disabled")).toBeUndefined();
+ });
+
+ it("says the message wasn't sent when the service isn't reachable; Send is the retry", async () => {
+ const { wrapper, store, sid } = setup();
+ store.setDraft(sid, { text: "why?" });
+ refuse(store, sid, { code: "unreachable" });
+ await flushPromises();
+ expect(wrapper.get('[data-test="assistant-composer-error-row"]').text()).toBe(
+ "Isn't reachable. Your message wasn't sent.",
+ );
+ expect(wrapper.get('[data-test="assistant-send"]').attributes("aria-disabled")).toBeUndefined();
+ });
+
+ it("states the first attachment once; OK remembers it", async () => {
+ const { wrapper, store, sid } = setup();
+ store.addChip(sid, ROW);
+ await flushPromises();
+ const notice = '[data-test="assistant-composer-attach-notice"]';
+ expect(wrapper.get(notice).text()).toContain("Attachments are saved with this chat.");
+ await wrapper.get('[data-test="assistant-composer-attach-ok"]').trigger("click");
+ expect(wrapper.find(notice).exists()).toBe(false);
+ expect(JSON.parse(localStorage.getItem("o2.assistant.hints:k1")!).attachSaved).toBe(true);
});
});
@@ -226,28 +327,61 @@ describe("states (`40` §1.3)", () => {
expect(send).toHaveBeenCalledTimes(2);
});
- it("is inert under an approval card: readonly, aria-disabled, the reason as helper (CT-07)", async () => {
+ it("is inert under an approval card: no controls, a muted kept draft, one stop out (CT-07)", async () => {
const { wrapper, store, sid, textarea } = setup();
+ store.setDraft(sid, { text: "kept" });
store.state.sessions[sid].pendingApproval = { id: "c1" } as PendingApproval;
await flushPromises();
expect(wrapper.get('[data-test="assistant-composer"]').attributes("data-state")).toBe("inert");
expect(textarea().attributes("readonly")).toBeDefined();
expect(textarea().attributes("aria-disabled")).toBe("true");
- const helper = wrapper.get('[data-test="assistant-composer-helper"]');
- expect(helper.text()).toBe("Answer the request above to continue.");
- expect(textarea().attributes("aria-describedby")).toContain(helper.attributes("id"));
+ expect(textarea().attributes("tabindex")).toBe("-1");
+ expect(textarea().attributes("placeholder")).toBe("Decide above to continue");
+ expect(textarea().element.value).toBe("kept");
+ expect(wrapper.find('[data-test="assistant-composer-controls"]').exists()).toBe(false);
+ expect(wrapper.find('[data-test="assistant-composer-helper"]').exists()).toBe(false);
+ expect(wrapper.find('[data-test="assistant-composer-go-to-request"]').exists()).toBe(false);
});
- it("switches to the reply placeholder and helper (CT-07 AC 4)", async () => {
+ it("offers Go to request only while the card is out of view", async () => {
+ let report: (entries: { isIntersecting: boolean }[]) => void = () => undefined;
+ vi.stubGlobal(
+ "IntersectionObserver",
+ class {
+ constructor(callback: typeof report) {
+ report = callback;
+ }
+ observe = vi.fn();
+ disconnect = vi.fn();
+ },
+ );
+ const { wrapper, store, sid, host } = setup();
+ host.insertAdjacentHTML("afterbegin", '');
+ store.state.sessions[sid].pendingApproval = { id: "c1" } as PendingApproval;
+ await flushPromises();
+ const link = '[data-test="assistant-composer-go-to-request"]';
+ report([{ isIntersecting: false }]);
+ await flushPromises();
+ expect(wrapper.get(link).text()).toBe("Go to request");
+ report([{ isIntersecting: true }]);
+ await flushPromises();
+ expect(wrapper.find(link).exists()).toBe(false);
+ vi.unstubAllGlobals();
+ });
+
+ it("states the decline above the frame in reply mode, with no context controls (CT-07 AC 4)", async () => {
const { wrapper, store, sid, textarea } = setup();
store.state.sessions[sid].pendingApproval = { id: "c1" } as PendingApproval;
store.startReply(sid, "c1");
await flushPromises();
expect(textarea().attributes("placeholder")).toBe("Reply to the assistant");
expect(textarea().attributes("readonly")).toBeUndefined();
- expect(wrapper.get('[data-test="assistant-composer-helper"]').text()).toContain(
- "Your reply answers",
+ expect(wrapper.get('[data-test="assistant-composer-reply"]').text()).toContain(
+ "Reply declines the request",
);
+ expect(wrapper.find('[data-test="assistant-add-context"]').exists()).toBe(false);
+ expect(wrapper.find('[data-test="assistant-context-sees"]').exists()).toBe(false);
+ expect(wrapper.get('[data-test="assistant-send"]').attributes("aria-label")).toBe("Send reply");
});
it("answers the card with Enter in reply mode, the turn still running, chips left staged", async () => {
@@ -552,8 +686,8 @@ describe("images (CT-10)", () => {
const png = (name: string, size = 10) =>
new File([new Uint8Array(size)], name, { type: "image/png" });
- it("names pasted shots uniquely and removes the one asked for (AC 1–2)", async () => {
- const { wrapper, textarea, store, sid, status } = setup();
+ it("names pasted shots uniquely (AC 1)", async () => {
+ const { textarea, store, sid } = setup();
for (let i = 0; i < 2; i++) {
await textarea().trigger("paste", {
clipboardData: { files: [png("image.png")], getData: () => "" },
@@ -561,14 +695,10 @@ describe("images (CT-10)", () => {
await new Promise((resolve) => setTimeout(resolve, 20));
}
await flushPromises();
- const names = () =>
- store.state.sessions[sid].composer.images.map((i: { filename: string }) => i.filename);
- expect(names()).toEqual(["pasted-image-1.png", "pasted-image-2.png"]);
- const removes = wrapper.findAll('[data-test="assistant-image-remove"]');
- expect(removes[1].attributes("aria-label")).toBe("Remove pasted-image-2.png");
- await removes[1].trigger("click");
- expect(names()).toEqual(["pasted-image-1.png"]);
- expect(status()).toBe("pasted-image-2.png removed from context");
+ const names = store.state.sessions[sid].composer.images.map(
+ (i: { filename: string }) => i.filename,
+ );
+ expect(names).toEqual(["pasted-image-1.png", "pasted-image-2.png"]);
});
it("states the type cap where the control is, never as a toast (AC 5)", async () => {
@@ -586,7 +716,7 @@ describe("one frame, the + menu and long pastes (D4 §3.1, §3.6, §3.9)", () =>
const paste = (textarea: ReturnType["textarea"]>, text: string) =>
textarea.trigger("paste", { clipboardData: { files: [], getData: () => text } });
- it("holds the chips slot, the text and the controls inside the one bordered frame", () => {
+ it("holds the text and the controls inside the one bordered frame", () => {
const { wrapper, textarea } = setup();
const frame = textarea().element.parentElement!;
expect(frame.className).toContain("border");
@@ -595,19 +725,9 @@ describe("one frame, the + menu and long pastes (D4 §3.1, §3.6, §3.9)", () =>
expect(frame.querySelector('[data-test="assistant-send"]')).not.toBeNull();
expect(wrapper.find('[data-test="assistant-composer-hints"]').exists()).toBe(false);
expect(wrapper.find('[data-test="assistant-attach-image"]').exists()).toBe(false);
- const withChips = mount(AssistantComposer, {
- props: { sid: null },
- slots: { chips: 'tray
' },
- global: { plugins: [fakeRouter(), fakeVuex()] },
- });
- const field = withChips.get('[data-test="assistant-composer-field"]').element;
- const head = withChips.get('[data-test="assistant-composer-chips"]').element;
- expect(head.parentElement).toBe(field.parentElement);
- expect(head.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- expect(head.querySelector('[data-test="tray-stub"]')).not.toBeNull();
});
- it("opens the + menu with a search, the context groups, Image and the keys footer", async () => {
+ it("opens the + menu with a search, the context groups and Image, and no voice row", async () => {
seedMentionGroups();
const { wrapper } = setup();
await wrapper.get('[data-test="assistant-add-context"]').trigger("click");
@@ -618,11 +738,7 @@ describe("one frame, the + menu and long pastes (D4 §3.1, §3.6, §3.9)", () =>
expect(menu.querySelector('[data-test="assistant-mention-image"]')?.textContent).toContain(
"Image…",
);
- const keys = menu.querySelector('[data-test="assistant-add-context-keys"]')!;
- expect(keys.querySelectorAll("li")).toHaveLength(5);
- expect(
- keys.querySelector('[data-test="assistant-composer-hint-voice"]')?.textContent,
- ).toContain("Voice dictation is off in this browser.");
+ expect(menu.textContent).not.toContain("Voice dictation");
});
it("adds a + pick as a chip without touching the typed text", async () => {
@@ -816,7 +932,9 @@ describe("round 2 (U7–U9, U12, U16, U18–U19, F5, F8)", () => {
it("returns focus to Recent prompts when its sheet is dismissed (U20)", async () => {
breakpoint.md = false;
+ vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("coarse") }));
const { wrapper } = setup();
+ vi.unstubAllGlobals();
const state = (wrapper.vm as unknown as { $: { setupState: Record } }).$
.setupState;
state.recallEntries = [{ text: "earlier question", chips: [], at: 1 }];
@@ -1059,10 +1177,10 @@ describe("dictation (WP-48)", () => {
afterEach(() => {
resetVoiceForTests();
vi.useRealTimers();
- document.body.innerHTML = "";
});
- it("renders no mic on self-hosted until the browser opt-in (A5, A30)", async () => {
+ it("renders no mic for a member while dictation is off in this browser (A5, A30)", async () => {
+ fake.member = true;
const { wrapper } = setup();
expect(wrapper.find('[data-test="assistant-dictate"]').exists()).toBe(false);
setVoiceOptIn("k1", true);
@@ -1071,6 +1189,18 @@ describe("dictation (WP-48)", () => {
expect(mic(wrapper).attributes("aria-pressed")).toBe("false");
});
+ it("offers an admin Turn on in place, which makes the mic live", async () => {
+ const { wrapper } = setup();
+ expect(mic(wrapper).attributes("aria-haspopup")).toBe("dialog");
+ await mic(wrapper).trigger("click");
+ await flushPromises();
+ const turnOn = '[data-test="assistant-dictate-turn-on"]';
+ document.body.querySelector(turnOn)!.click();
+ await flushPromises();
+ expect(mic(wrapper).attributes("aria-haspopup")).toBeUndefined();
+ expect(document.body.querySelector(turnOn)).toBeNull();
+ });
+
it("renders the mic on Cloud by default", async () => {
const { wrapper } = setup();
fake.current.capability.value.edition = "cloud";
@@ -1126,10 +1256,10 @@ describe("dictation (WP-48)", () => {
await mic(ctx.wrapper).trigger("click");
await flushPromises();
expect(ctx.wrapper.get('[data-test="assistant-dictate-waiting"]').text()).toBe(
- "Waiting for microphone permission",
+ "Waiting for permission",
);
expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("true");
- expect(ctx.status()).toBe("Waiting for microphone permission");
+ expect(ctx.status()).toBe("Waiting for permission");
await mic(ctx.wrapper).trigger("click");
await flushPromises();
@@ -1137,15 +1267,13 @@ describe("dictation (WP-48)", () => {
expect(ctx.status()).toBe("Dictation cancelled.");
});
- it("shows listening, names the engine (B14) and says the start once", async () => {
+ it("shows listening with nothing under the frame, and says the start once", async () => {
const ctx = voiceSetup();
await listen(ctx);
expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("true");
expect(mic(ctx.wrapper).attributes("aria-label")).toBe("Dictate");
expect(ctx.wrapper.get('[data-test="assistant-dictate-status"]').text()).toContain("Listening");
- expect(helper(ctx.wrapper).text()).toBe(
- "Chrome · OpenObserve doesn't store the audio · Enter or Esc to finish, not send",
- );
+ expect(helper(ctx.wrapper).exists()).toBe(false);
expect(ctx.status()).toBe("Listening. Press Enter, Escape or the mic to stop.");
expect(ctx.textarea().attributes("data-test")).toBe("assistant-composer-field");
expect(ctx.wrapper.get('[data-test="assistant-composer"]').attributes("data-state")).toBe(
@@ -1241,7 +1369,7 @@ describe("dictation (WP-48)", () => {
expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("false");
});
- it("stops when a card arrives, and shows the disabled mic's reason", async () => {
+ it("stops when a card arrives; the inert frame has no mic", async () => {
const ctx = voiceSetup();
await listen(ctx);
ctx.store.state.sessions[ctx.sid].pendingConsent = { id: "c1" } as never;
@@ -1249,8 +1377,8 @@ describe("dictation (WP-48)", () => {
expect(ctx.wrapper.get('[data-test="assistant-composer"]').attributes("data-state")).toBe(
"inert",
);
- expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("false");
- expect(mic(ctx.wrapper).attributes("aria-disabled")).toBe("true");
+ expect(ctx.wrapper.find('[data-test="assistant-dictate"]').exists()).toBe(false);
+ expect(engine.abort).toHaveBeenCalled();
});
it("stops when the composer is hidden, and on unmount", async () => {
@@ -1287,12 +1415,13 @@ describe("dictation (WP-48)", () => {
expect(helper(ctx.wrapper).text()).toBe("Microphone access is blocked for this site.");
expect(ctx.status()).toBe("Microphone access is blocked for this site.");
- expect(mic(ctx.wrapper).attributes("aria-disabled")).toBeUndefined();
+ expect(mic(ctx.wrapper).attributes("aria-disabled")).toBe("true");
+ expect(mic(ctx.wrapper).attributes("aria-haspopup")).toBe("dialog");
expect(ctx.wrapper.find('[data-test="assistant-dictate-how"]').exists()).toBe(true);
expect(ctx.store.state.sessions[ctx.sid].composer.text).toBe("typed");
});
- it("offers Try again for an unreachable service, naming the vendor", async () => {
+ it("offers Retry for an unreachable service, naming the vendor", async () => {
const ctx = voiceSetup();
await listen(ctx);
engine.handlers!.onError("network");
@@ -1313,7 +1442,7 @@ describe("dictation (WP-48)", () => {
engine.handlers!.onStart();
engine.meter.db = -30;
await vi.advanceTimersByTimeAsync(SILENCE_MS);
- const line = "Your browser didn't turn speech into text. Type instead, or try another browser.";
+ const line = "Your browser didn't turn speech into text. Reload to try again.";
expect(helper(ctx.wrapper).text()).toBe(line);
expect(mic(ctx.wrapper).attributes("aria-disabled")).toBe("true");
});
@@ -1325,9 +1454,7 @@ describe("dictation (WP-48)", () => {
window.dispatchEvent(new Event("offline"));
await flushPromises();
expect(mic(ctx.wrapper).attributes("aria-disabled")).toBe("true");
- expect(helper(ctx.wrapper).text()).toBe(
- "You're offline. Your draft is kept; dictation needs a connection.",
- );
+ expect(helper(ctx.wrapper).text()).toBe("You're offline. Draft kept.");
online.mockRestore();
window.dispatchEvent(new Event("online"));
});
@@ -1342,13 +1469,26 @@ describe("dictation (WP-48)", () => {
engine.handlers!.onStart();
await vi.advanceTimersByTimeAsync(0);
expect(ctx.wrapper.get('[data-test="assistant-dictate-status"]').text()).toContain(
- "release Ctrl I to stop",
+ "Release Ctrl I to stop",
);
document.dispatchEvent(new KeyboardEvent("keyup", { key: "i" }));
await vi.advanceTimersByTimeAsync(0);
expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("false");
});
+ it("takes a tap of the chord to the waiting request and starts nothing", async () => {
+ const ctx = voiceSetup();
+ ctx.store.state.sessions[ctx.sid].pendingApproval = { id: "c1" } as PendingApproval;
+ await flushPromises();
+ const focusCard = vi.fn(() => true);
+ const off = registerAssistantFocusTarget("card", focusCard);
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "i", ctrlKey: true }));
+ document.dispatchEvent(new KeyboardEvent("keyup", { key: "i" }));
+ off();
+ expect(focusCard).toHaveBeenCalled();
+ expect(engine.handlers).toBeNull();
+ });
+
it("does not dictate on a hold while a card holds the composer", async () => {
const ctx = voiceSetup();
ctx.store.state.sessions[ctx.sid].pendingConsent = { id: "c1" } as never;
diff --git a/web/src/components/ai-assistant/composer/AssistantComposer.vue b/web/src/components/ai-assistant/composer/AssistantComposer.vue
index 9d44d03f63..3af81823c4 100644
--- a/web/src/components/ai-assistant/composer/AssistantComposer.vue
+++ b/web/src/components/ai-assistant/composer/AssistantComposer.vue
@@ -83,6 +83,58 @@ along with this program. If not, see .
+
+
+
+
+ {{ t("assistant.attach.ok") }}
+
+
+
+
+
+
+ {{ t("assistant.composer.replyHelper") }}
+
+
+ {{ t("assistant.composer.replyCancel") }}
+
+
+
.
@select="pickMention"
@close="closeMention"
/>
-
+
.
:readonly="inertKind !== null"
:placeholder="placeholder"
:input-attrs="textboxAttrs"
+ :tabindex="inertKind ? -1 : undefined"
+ :class="frameClass"
placeholder-tone="strong"
data-test="assistant-composer"
:data-state="state"
+ :data-rows="twoRows ? 2 : 1"
@update:model-value="onInput"
@keydown="onKeydown"
@paste="onPaste"
@focus="refreshPageContext"
@blur="onBlur"
>
-
-
+
-
-
- -
- (openImage = open ? (image.id ?? null) : null)"
- @remove="removeImage(image)"
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {{ t("assistant.composer.goToRequest") }}
+
-
+
+
+
+
+
+ {{
+ width.narrow.value
+ ? t("assistant.voice.waitingShort")
+ : t("assistant.voice.waitingPermission")
+ }}
+
+
.
@select="addOption"
@image="openPicker"
/>
+
+
-
-
.
:label="t('assistant.turn.stopLabel')"
:icon="assistantGlyph.stop"
:loading="state === 'stopping'"
- class="hover:text-status-warning-text focus-visible:text-status-warning-text"
+ class="h-7! w-7!"
data-test="assistant-stop"
@click="stop"
/>
-
- {{ t("assistant.voice.waitingPermission") }}
-
.
:id="helperId"
variant="meta"
as="p"
- :class="['min-w-0 flex-1', helperEmphasis ? 'text-text-body font-medium' : '']"
+ :class="[
+ 'min-w-0',
+ helperEmphasis ? 'text-text-body font-medium' : '',
+ helperFailed ? 'text-status-error-text' : '',
+ ]"
data-test="assistant-composer-helper"
>
{{ helper }}
-
- {{ t("assistant.composer.replyCancel") }}
-
.
.
+
+ {{ t("assistant.context.startNew") }}
+
.
@type-error="() => refuse('type', t('assistant.context.imageType'))"
/>
= new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
+
+const THREAD_WINDOW_MS = 30 * 60_000;
+
+// A refused send whose text came back: these say so above the frame; every other refusal is the transcript's.
+const BUSY_CODES: ReadonlySet = new Set(["turn_limit", "rate_limited"]);
+
+const UNSENT_CODES: ReadonlySet = new Set(["unreachable", "offline"]);
+
+// The frame lives inside OTextarea, whose wrapper is only reachable from the root until the lib grows `#trailing`.
+const FRAME_CLASS =
+ "[&>div]:focus-within:shadow-[0_0_0_0.125rem_color-mix(in_srgb,var(--color-assistant-focus-ring)_25%,transparent)]";
+
+const INERT_FRAME_CLASS = "[&>div]:border-border-default [&>div]:bg-surface-subtle";
diff --git a/web/src/components/ai-assistant/composer/AssistantDictationMeter.vue b/web/src/components/ai-assistant/composer/AssistantDictationMeter.vue
index 543d10dae5..16d2338e2f 100644
--- a/web/src/components/ai-assistant/composer/AssistantDictationMeter.vue
+++ b/web/src/components/ai-assistant/composer/AssistantDictationMeter.vue
@@ -32,10 +32,14 @@ along with this program. If not, see .
:style="{ transform: `scaleY(${barScale(weight)})` }"
/>
- {{ label }}
- {{
- elapsed
- }}
+ {{ label }}
+ {{ elapsed }}
@@ -47,6 +51,10 @@ export interface AssistantDictationMeterProps {
/** 0–1. */
level: number;
elapsedMs: number;
+ /** Below 24 rem the word yields first. */
+ narrow?: boolean;
+ /** At 20 rem the elapsed time goes too. */
+ tight?: boolean;
}
const BAR_WEIGHTS = [0.6, 1, 0.8, 0.5] as const;
@@ -58,7 +66,10 @@ const MIN_BAR_SCALE = 0.2;
-
diff --git a/web/src/components/ai-assistant/composer/composerRows.spec.ts b/web/src/components/ai-assistant/composer/composerRows.spec.ts
new file mode 100644
index 0000000000..cccff0a52d
--- /dev/null
+++ b/web/src/components/ai-assistant/composer/composerRows.spec.ts
@@ -0,0 +1,55 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, expect, it } from "vitest";
+
+import { composerFitsOneRow, composerPopoverClass, type ComposerRowInput } from "./composerUi";
+
+const ROW: ComposerRowInput = {
+ narrow: false,
+ extras: false,
+ text: "",
+ textWidth: 0,
+ frameWidth: 406,
+ controlsWidth: 216,
+};
+
+describe("composerFitsOneRow", () => {
+ it("holds one row while the draft fits beside the controls", () => {
+ expect(composerFitsOneRow(ROW)).toBe(true);
+ expect(composerFitsOneRow({ ...ROW, text: "Find the cause", textWidth: 96 })).toBe(true);
+ });
+
+ it("moves the text above the controls once it no longer fits", () => {
+ expect(composerFitsOneRow({ ...ROW, text: "long", textWidth: 180 })).toBe(false);
+ });
+
+ it("always takes two rows when narrow, with extras, or on a second line", () => {
+ expect(composerFitsOneRow({ ...ROW, narrow: true })).toBe(false);
+ expect(composerFitsOneRow({ ...ROW, extras: true })).toBe(false);
+ expect(composerFitsOneRow({ ...ROW, text: "a\nb" })).toBe(false);
+ });
+
+ it("stays on one row until the frame has been measured", () => {
+ expect(composerFitsOneRow({ ...ROW, text: "x".repeat(200), frameWidth: 0 })).toBe(true);
+ });
+});
+
+describe("composerPopoverClass", () => {
+ it("takes 3 px a side off a 20 rem window", () => {
+ expect(composerPopoverClass(true)).toContain("calc(20rem-0.375rem)");
+ expect(composerPopoverClass(false)).toContain("min(20rem");
+ });
+});
diff --git a/web/src/components/ai-assistant/composer/composerUi.ts b/web/src/components/ai-assistant/composer/composerUi.ts
index 2c54a143b1..c13c27b018 100644
--- a/web/src/components/ai-assistant/composer/composerUi.ts
+++ b/web/src/components/ai-assistant/composer/composerUi.ts
@@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import { reactive, toRaw } from "vue";
+import { computed, inject, reactive, toRaw, type ComputedRef, type InjectionKey } from "vue";
import type { useAssistantStore } from "@/composables/assistant";
import type { I18nText } from "@/types/i18n";
@@ -21,6 +21,15 @@ import type { I18nText } from "@/types/i18n";
/** How long a cap sentence stays under the tray (`40` §2.6). */
export const CAP_NOTICE_MS = 6_000;
+/** At and below this width the composer always takes two rows and chips sit behind `+N`. */
+export const COMPOSER_NARROW_REM = 24;
+
+/** The narrowest window; popovers take its width less 3 px a side. */
+export const COMPOSER_TIGHT_REM = 20;
+
+/** Chips shown before `+N` by width: none when narrow, one at 27 rem, three in a full view. */
+export const COMPOSER_WIDE_REM = 40;
+
/** A cleared draft's notice; `token` is `null` across orgs, where no Undo is offered (`40` §9). */
export interface ClearedNotice {
token: string | null;
@@ -40,10 +49,57 @@ export interface ComposerUi {
cleared: ClearedNotice | null;
}
+/** What a control inside the composer needs to know about the composer's own width. */
+export interface ComposerLayout {
+ narrow: boolean;
+ tight: boolean;
+ wide: boolean;
+ /** Dictation at 20 rem takes the room of `+N` and the chips. */
+ yieldChips: boolean;
+}
+
+/** The inputs of the one-row decision; widths in px, 0 when not measured yet. */
+export interface ComposerRowInput {
+ narrow: boolean;
+ /** Chips, images, a meter or anything else the control row must show beyond time. */
+ extras: boolean;
+ text: string;
+ textWidth: number;
+ frameWidth: number;
+ controlsWidth: number;
+}
+
+export const composerLayoutKey: InjectionKey> =
+ Symbol("assistantComposerLayout");
+
const states = reactive(new Map());
let seq = 0;
+// The frame's 12 px text inset and the 8 px the text keeps from the first control.
+const TEXT_INSET_PX = 20;
+
+/** One row holds while the draft fits a line beside the controls and nothing else needs showing. */
+export function composerFitsOneRow(input: ComposerRowInput): boolean {
+ if (input.narrow || input.extras || input.text.includes("\n")) return false;
+ if (!input.text || input.frameWidth === 0) return true;
+ return input.textWidth + TEXT_INSET_PX + input.controlsWidth <= input.frameWidth;
+}
+
+/** The composer's layout for a control inside it; outside a composer nothing is narrow. */
+export function useComposerLayout(): ComputedRef {
+ return inject(
+ composerLayoutKey,
+ computed(() => ({ narrow: false, tight: false, wide: false, yieldChips: false })),
+ false,
+ );
+}
+
+/** One popover width on every board; 3 px a side short of a 20 rem window. */
+export function composerPopoverClass(tight: boolean): string {
+ return tight ? "w-[calc(20rem-0.375rem)]" : "w-[min(20rem,calc(100vw-0.375rem))]";
+}
+
/** The per-session view state the composer, the tray and the shell share. */
export function composerUi(sid: string): ComposerUi {
let state = states.get(sid);
diff --git a/web/src/components/ai-assistant/composer/useAssistantVoice.ts b/web/src/components/ai-assistant/composer/useAssistantVoice.ts
index 655f5b9830..5951d33c1e 100644
--- a/web/src/components/ai-assistant/composer/useAssistantVoice.ts
+++ b/web/src/components/ai-assistant/composer/useAssistantVoice.ts
@@ -88,18 +88,6 @@ const SENTENCE_KEYS: Record, I18nKey> = {
safari: "assistant.voice.engine.apple",
};
-const ALLOW_KEYS: Record, I18nKey> = {
- chrome: "assistant.voice.allow.chrome",
- edge: "assistant.voice.allow.edge",
- safari: "assistant.voice.allow.safari",
-};
-
-const BRAND_SHORT: Record, string> = {
- chrome: "Chrome",
- edge: "Edge",
- safari: "Safari",
-};
-
// One engine and one owner per tab: a second composer starting takes the microphone from the first.
let shared: VoiceController | null = null;
let engineOverride: VoiceEngine | null = null;
@@ -113,6 +101,9 @@ export type VoiceMode = "off" | "browser";
/** What a text entry may offer: `silent` keeps a disabled mic rather than removing it mid-use (`40` §1.8.4). */
export type VoiceStatus = "off" | "unsupported" | "silent" | "ready";
+/** `none` renders no mic; `crossed` is a reason a new press cannot help; `off` is an admin's Turn on. */
+export type VoiceMic = "none" | "live" | "crossed" | "off";
+
export type VoicePhase = "idle" | "waiting" | "listening";
export type VoiceVia = "mic" | "hold";
@@ -144,6 +135,8 @@ export interface VoiceTarget {
/** True while this entry is the one a hold should dictate into. */
holdReady(): boolean;
hold(): void;
+ /** A press shorter than a hold. */
+ tap(): void;
}
export interface VoiceState {
@@ -161,6 +154,8 @@ export interface VoiceState {
denied: boolean;
/** Tab memory: this browser exposes a recogniser that returns nothing (B58). */
silent: boolean;
+ /** Tab memory: this browser has no speech service for the UI language. */
+ languageRefused: boolean;
online: boolean;
/** Runs on the device, so no vendor hears it and being offline is fine. */
onDevice: boolean;
@@ -200,6 +195,8 @@ export interface VoiceComposerOptions {
writeDraft(sid: Sid, text: string): void;
/** Hold is refused here: inert, not visible. */
blocked(): boolean;
+ /** A short press: takes focus to a pending request, if one waits. */
+ tap(): void;
/** True on a touch device below `md`, which uses the keyboard's own dictation key. */
touch(): boolean;
say(line: I18nText): void;
@@ -210,6 +207,7 @@ export interface VoiceComposer {
status: ComputedRef;
/** The mic renders. */
show: ComputedRef;
+ mic: ComputedRef;
/** Mic off for now, but still there. */
offline: ComputedRef;
mine: ComputedRef;
@@ -217,6 +215,8 @@ export interface VoiceComposer {
waiting: ComputedRef;
denied: ComputedRef;
helperLine: ComputedRef;
+ /** The helper line reports a failure, not a state. */
+ failure: ComputedRef;
action: ComputedRef<"retry" | "how" | null>;
elapsedMs: ComputedRef;
level: ComputedRef;
@@ -227,7 +227,8 @@ export interface VoiceComposer {
shown(text: string): string;
primerOpen: Ref;
sentence: ComputedRef;
- allowSteps: ComputedRef;
+ /** Saves the opt-in, which makes the mic live in place. */
+ turnOn(): void;
press(via: VoiceVia): void;
acceptPrimer(): void;
declinePrimer(): void;
@@ -278,6 +279,7 @@ export function createVoiceController(engine: VoiceEngine): VoiceController {
level: 0,
denied: false,
silent: false,
+ languageRefused: false,
online: typeof navigator === "undefined" || navigator.onLine !== false,
onDevice: false,
});
@@ -339,6 +341,7 @@ export function createVoiceController(engine: VoiceEngine): VoiceController {
function fail(current: VoiceRun, code: VoiceErrorCode): void {
if (code === "mic_denied") state.denied = true;
+ if (code === "language_unsupported") state.languageRefused = true;
finish(current, { kind: "error", code });
}
@@ -481,9 +484,11 @@ export function createVoiceController(engine: VoiceEngine): VoiceController {
}
function releaseHold(): void {
+ const tapped = holdTimer !== null;
clearHold();
if (holding && state.via === "hold") stop("release");
holding = false;
+ if (tapped) for (const target of targets) target.tap();
}
function onKeydown(event: KeyboardEvent): void {
@@ -614,6 +619,7 @@ export function holdKeys(): string {
export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer {
const { t } = useI18nTyped();
const store = useAssistantStore();
+ const capability = useAssistantCapability();
const availability = useVoiceAvailability();
const controller = voiceController();
const state = controller.state;
@@ -630,11 +636,21 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer
const listening = computed(() => mine.value && state.phase === "listening");
const waiting = computed(() => mine.value && state.phase === "waiting");
const status = availability.status;
- const usable = computed(() => status.value === "ready" || status.value === "silent");
- const show = computed(() => usable.value && !options.touch());
const offline = computed(() => !state.online && !state.onDevice);
+ const mic = computed(() => {
+ if (options.touch() || status.value === "unsupported") return "none";
+ if (status.value === "off") return capability.value.isAdmin ? "off" : "none";
+ const unavailable =
+ status.value === "silent" || state.denied || state.languageRefused || offline.value;
+ return unavailable && !mine.value ? "crossed" : "live";
+ });
+ const show = computed(() => mic.value !== "none");
const brand = () => controller.engine.brand();
+ const languageLine = computed(() =>
+ t("assistant.voice.language", { language: raw(languageEndonym(getIntlLocale())) }),
+ );
+
const errorLine = computed(() => {
const code = lastError.value;
if (!code) return null;
@@ -642,43 +658,42 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer
return t("assistant.voice.error.service_unreachable", {
vendor: raw(serviceVendor(brand() ?? "chrome")),
});
- if (code === "language_unsupported")
- return t("assistant.voice.error.language_unsupported", {
- language: raw(languageEndonym(getIntlLocale())),
- });
+ if (code === "language_unsupported") return languageLine.value;
+ if (code === "browser_silent") return t("assistant.voice.silent");
return t(`assistant.voice.error.${code}`);
});
- const listeningLine = computed(() => {
- const short = state.onDevice
- ? t("assistant.voice.engineShort.device")
- : brandShort(brand(), t("assistant.voice.engineShort.browser"));
- const line = t("assistant.voice.helperListening", { engineShort: short });
- return raw(`${line} · ${t("assistant.voice.helperFinish")}`);
+ // A reason a new press cannot help outlives the edit that clears a one-off error.
+ const stuckLine = computed(() => {
+ if (status.value === "silent") return t("assistant.voice.silent");
+ if (state.denied) return t("assistant.voice.error.mic_denied");
+ return state.languageRefused ? languageLine.value : null;
});
const helperLine = computed(() => {
- if (listening.value) return listeningLine.value;
if (enterHint.value) return t("assistant.voice.enterToSend");
- return errorLine.value;
+ return stuckLine.value ?? errorLine.value;
});
+ const failure = computed(
+ () =>
+ helperLine.value !== null &&
+ !enterHint.value &&
+ (state.denied || !!lastError.value?.startsWith("service_unreachable")),
+ );
+
const action = computed(() => {
- if (lastError.value === "mic_denied") return "how";
+ if (enterHint.value) return null;
+ if (state.denied) return "how";
return lastError.value?.startsWith("service_unreachable") ? "retry" : null;
});
const rowLabel = computed(() =>
state.via === "hold"
- ? t("assistant.voice.releaseToStop", { keys: raw(holdKeys()) })
+ ? t("assistant.voice.holdLine", { keys: raw(holdKeys()) })
: t("assistant.voice.listening"),
);
- const allowSteps = computed(() => {
- const current = brand();
- return t(current ? ALLOW_KEYS[current] : "assistant.voice.allow.other");
- });
-
function clearHint(): void {
enterHint.value = false;
if (enterTimer) clearTimeout(enterTimer);
@@ -711,13 +726,12 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer
},
ended,
holdReady: () =>
- status.value === "ready" &&
- !options.touch() &&
- !offline.value &&
+ mic.value === "live" &&
!options.blocked() &&
!composingHere &&
document.activeElement === options.field(),
hold: () => press("hold"),
+ tap: options.tap,
};
function begin(via: VoiceVia): void {
@@ -730,7 +744,7 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer
function press(via: VoiceVia): void {
if (mine.value) return controller.stop("mic");
- if (status.value !== "ready" || offline.value || composingHere) return;
+ if (mic.value !== "live" || composingHere) return;
const key = store.state.userOrgKey;
if (key && readHints(key).dictate) return begin(via);
pendingVia = via;
@@ -749,6 +763,11 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer
options.focusField();
}
+ function turnOn(): void {
+ const key = store.state.userOrgKey;
+ if (key) setVoiceOptIn(key, true);
+ }
+
function stopMine(reason: VoiceStopReason): void {
if (mine.value) controller.stop(reason);
}
@@ -780,12 +799,14 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer
return {
status,
show,
+ mic,
offline,
mine,
listening,
waiting,
denied: computed(() => state.denied),
helperLine,
+ failure,
action,
elapsedMs: computed(() => state.elapsedMs),
level: computed(() => state.level),
@@ -799,7 +820,7 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer
},
primerOpen,
sentence: availability.sentence,
- allowSteps,
+ turnOn,
press,
acceptPrimer,
declinePrimer,
@@ -812,10 +833,6 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer
};
}
-function brandShort(brand: VoiceBrand, fallback: I18nText): I18nText {
- return brand ? raw(BRAND_SHORT[brand]) : fallback;
-}
-
function unreachableCode(engine: VoiceEngine): VoiceErrorCode {
return engine.brand() ? "service_unreachable" : "service_unreachable_generic";
}
diff --git a/web/src/components/ai-assistant/composer/useComposerWidth.ts b/web/src/components/ai-assistant/composer/useComposerWidth.ts
new file mode 100644
index 0000000000..0231c30db3
--- /dev/null
+++ b/web/src/components/ai-assistant/composer/useComposerWidth.ts
@@ -0,0 +1,71 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { computed, onBeforeUnmount, onMounted, ref, type ComputedRef, type Ref } from "vue";
+
+import { COMPOSER_NARROW_REM, COMPOSER_TIGHT_REM, COMPOSER_WIDE_REM } from "./composerUi";
+
+export interface ComposerWidth {
+ /** The composer's own width in px; 0 until measured. */
+ width: Ref;
+ narrow: ComputedRef;
+ tight: ComputedRef;
+ wide: ComputedRef;
+}
+
+/** Follows the composer's own width, since a window or a sidebar is narrow on any screen. */
+export function useComposerWidth(root: Ref): ComposerWidth {
+ const width = ref(0);
+ const rem = ref(16);
+ let observer: ResizeObserver | null = null;
+
+ const atMost = (limit: number) => width.value > 0 && width.value <= limit * rem.value;
+
+ onMounted(() => {
+ const target = root.value;
+ if (!target || typeof ResizeObserver === "undefined") return;
+ rem.value = parseFloat(getComputedStyle(document.documentElement).fontSize) || rem.value;
+ observer = new ResizeObserver(() => (width.value = target.getBoundingClientRect().width));
+ observer.observe(target);
+ });
+ onBeforeUnmount(() => observer?.disconnect());
+
+ return {
+ width,
+ narrow: computed(() => atMost(COMPOSER_NARROW_REM)),
+ tight: computed(() => atMost(COMPOSER_TIGHT_REM)),
+ wide: computed(() => width.value >= COMPOSER_WIDE_REM * rem.value),
+ };
+}
+
+/** A control row's own width in a frame that may stretch it: its children and their gaps. */
+export function controlsWidthOf(row: HTMLElement | null): number {
+ if (!row) return 0;
+ const parts = Array.from(row.children).filter(
+ (child): child is HTMLElement => child instanceof HTMLElement && !("spacer" in child.dataset),
+ );
+ const gap = parseFloat(getComputedStyle(row).columnGap) || 0;
+ return parts.reduce((sum, part) => sum + part.offsetWidth, gap * (parts.length - 1));
+}
+
+/** The draft's width in the textbox's own font; 0 where the browser has no canvas. */
+export function textWidthOf(field: HTMLTextAreaElement | null, text: string): number {
+ if (!field || !text) return 0;
+ const context = document.createElement("canvas").getContext("2d");
+ if (!context) return 0;
+ const style = getComputedStyle(field);
+ context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
+ return context.measureText(text).width;
+}
diff --git a/web/src/components/ai-assistant/context/AssistantChipPopover.spec.ts b/web/src/components/ai-assistant/context/AssistantChipPopover.spec.ts
index cdf2c1b509..1664518806 100644
--- a/web/src/components/ai-assistant/context/AssistantChipPopover.spec.ts
+++ b/web/src/components/ai-assistant/context/AssistantChipPopover.spec.ts
@@ -140,47 +140,6 @@ describe("AssistantChipPopover", () => {
expect(img.className).toContain("max-h-80");
});
- it("offers Use page time only once the time is custom, and Apply only after a pick (CT-01 AC 4–5)", async () => {
- const time: ContextChip = {
- ...ROW,
- kind: "time",
- label: raw("Time · last 15 min"),
- data: undefined,
- provenance: "page",
- };
- const { wrapper } = await open({
- chip: time,
- window: { from: 0, to: 900_000, period: "15m", source: "page" },
- });
- const picker = () =>
- document.body.querySelector('[data-test="assistant-context-time-picker"]')!;
- expect(
- picker().querySelector('[data-test="assistant-context-time-page"]')!
- .disabled,
- ).toBe(true);
- expect(
- picker().querySelector('[data-test="assistant-context-time-apply"]')!
- .disabled,
- ).toBe(true);
- await wrapper.setProps({ window: { from: 0, to: 3_600_000, period: "1h", source: "custom" } });
- picker().querySelector('[data-test="assistant-context-time-page"]')!.click();
- expect(wrapper.emitted("apply-time")).toEqual([[null]]);
- });
-
- it("says why the default applies on a page without a range (CT-01 AC 2)", async () => {
- const time: ContextChip = {
- ...ROW,
- kind: "time",
- label: raw("Time · last 15 min · default"),
- data: undefined,
- provenance: "page",
- };
- await open({ chip: time, window: { from: 0, to: 900_000, period: "15m", source: "default" } });
- expect(
- document.body.querySelector('[data-test="assistant-context-time-default-note"]')?.textContent,
- ).toContain("this page has no time range");
- });
-
it("opens on its first action, and Tab at either edge hands focus back to the tray (CT-03 AC 5, U3)", async () => {
const big = { ...ROW, data: { message: "z".repeat(2_000), _o2_id: "1" } };
const { wrapper } = await open({ chip: big });
diff --git a/web/src/components/ai-assistant/context/AssistantChipPopover.vue b/web/src/components/ai-assistant/context/AssistantChipPopover.vue
index fba0821584..ed70f1146c 100644
--- a/web/src/components/ai-assistant/context/AssistantChipPopover.vue
+++ b/web/src/components/ai-assistant/context/AssistantChipPopover.vue
@@ -20,7 +20,7 @@ along with this program. If not, see .
privacy="mask"
side="top"
align="start"
- :aria-label="chip.kind === 'time' ? t('assistant.context.time.swap') : chip.label"
+ :aria-label="chip.label"
content-class="flex w-[min(28rem,calc(100vw-1.5rem))] max-h-[min(32rem,70vh)] flex-col gap-3 overflow-y-auto p-3"
@update:open="(value) => emit('update:open', value)"
>
@@ -30,7 +30,7 @@ along with this program. If not, see .
@@ -42,48 +42,7 @@ along with this program. If not, see .
-
-
- {{ t("assistant.context.time.defaultNote") }}
-
-
-
-
- {{ t("assistant.context.time.usePage") }}
-
-
- {{ t("assistant.context.time.apply") }}
-
-
-
-
-
+
.
@@ -154,19 +171,17 @@ const STREAM_TYPES = ["logs", "metrics", "traces"] as const;
import { useQuery } from "@tanstack/vue-query";
import { computed, reactive, ref, watch } from "vue";
+import { agentGlyph, assistantGlyph, chipGlyph } from "@/components/ai-assistant/common/glyphs";
+import { agentClause } from "@/components/ai-assistant/agents/agentClause";
import { announceComposer } from "@/components/ai-assistant/composer/composerUi";
-import {
- shortLabelFor,
- timeRangeText,
- useAssistantCapability,
- useAssistantStore,
-} from "@/composables/assistant";
+import { shortLabelFor, useAssistantCapability, useAssistantStore } from "@/composables/assistant";
import {
agentMatches,
agentName,
agentReason,
rosterOffersChoice,
} from "@/composables/assistant/pure";
+import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import { alertsListQuery } from "@/services/alerts.queries";
@@ -183,9 +198,11 @@ interface Group {
title: I18nText;
kind: I18nText;
state: GroupState;
- /** The options, then the Show more, Retry or no-access row, all in the ↑/↓ cycle. */
+ /** The options, then the Show all, Retry or no-access row, all in the ↑/↓ cycle. */
items: MentionOption[];
retry?: () => void;
+ /** How many the group holds; the header's right-hand count. */
+ total?: number;
}
const props = withDefaults(defineProps(), {
@@ -240,21 +257,6 @@ const matches = (option: MentionOption) => {
return terms.value.every((term) => hay.includes(term));
};
-const timeOptions = computed(() => [
- ...TIME_PERIODS.map((period) => ({
- id: `time:${period}`,
- kind: "time" as const,
- label: timeRangeText({ from: 0, to: 0, period, source: "custom" }, t),
- period,
- })),
- {
- id: "time:page",
- kind: "time" as const,
- label: t("assistant.composer.mention.pageTime"),
- period: null,
- },
-]);
-
const streamOptions = computed(() =>
STREAM_TYPES.flatMap((type, index) =>
namesOf(streams[index].data.value)
@@ -263,7 +265,7 @@ const streamOptions = computed(() =>
option(
"stream",
`${type}:${name}`,
- t("assistant.context.chip.stream", { name: raw(name) }),
+ raw(name),
raw(type),
{
id: name,
@@ -281,16 +283,7 @@ const dashboardOptions = computed(() =>
const id = stringOf(row.dashboard_id ?? row.dashboardId);
const title = stringOf(row.title) ?? id;
return id && title
- ? [
- option(
- "panel",
- id,
- t("assistant.context.chip.dashboard", { name: raw(title) }),
- undefined,
- { id, type: "dashboard" },
- stringOf(row.title),
- ),
- ]
+ ? [option("panel", id, raw(title), undefined, { id, type: "dashboard" }, stringOf(row.title))]
: [];
}),
);
@@ -300,16 +293,7 @@ const alertOptions = computed(() =>
const id = stringOf(row.alert_id ?? row.id);
const name = stringOf(row.name) ?? id;
return id && name
- ? [
- option(
- "alert",
- id,
- t("assistant.context.chip.alert", { name: raw(name) }),
- undefined,
- { id },
- stringOf(row.name),
- ),
- ]
+ ? [option("alert", id, raw(name), undefined, { id }, stringOf(row.name))]
: [];
}),
);
@@ -319,16 +303,7 @@ const pipelineOptions = computed(() =>
const id = stringOf(row.pipeline_id ?? row.id);
const name = stringOf(row.name) ?? id;
return id && name
- ? [
- option(
- "pipeline_node",
- id,
- t("assistant.context.chip.pipeline", { name: raw(name) }),
- undefined,
- { id },
- stringOf(row.name),
- ),
- ]
+ ? [option("pipeline_node", id, raw(name), undefined, { id }, stringOf(row.name))]
: [];
}),
);
@@ -348,18 +323,19 @@ const agentGroup = computed(() => {
label,
action: "agent",
agent: agent.id,
- ...(reason && { secondary: reason, disabled: true }),
+ glyph: reason ? assistantGlyph.locked : agentGlyph[agent.id],
+ secondary: reason ?? agentClause(agent, t),
+ ...(reason && { disabled: true }),
},
];
});
const title = t("assistant.composer.mention.group.agents");
- return [{ id: "agents", title, kind: title, state: "ready", items }];
+ return [{ id: "agents", title, kind: title, state: "ready", items, total: items.length }];
});
const groups = computed(() =>
[
...agentGroup.value,
- group("time", t("assistant.composer.mention.group.time"), raw(""), "ready", timeOptions.value),
group(
"streams",
t("assistant.composer.mention.group.streams"),
@@ -393,7 +369,10 @@ const groups = computed(() =>
() => void pipelines.refetch(),
),
...(props.withImage ? [imageGroup()] : []),
- ].filter((g) => g.state !== "ready" || g.items.length > 0),
+ ].filter(
+ (g) =>
+ (g.state !== "ready" || g.items.length > 0) && (g.id !== "pipelines" || !!terms.value.length),
+ ),
);
// A disabled agent stays in the ↑/↓ cycle, so Enter on it can read its reason (MULTI-AGENT-02 AC 6).
@@ -409,7 +388,13 @@ const empty = computed(() => groups.value.every((g) => g.state === "ready") && r
function imageGroup(): Group {
const label = t("assistant.composer.addImage");
- const item: MentionOption = { id: "action:image", kind: "image", label, action: "image" };
+ const item: MentionOption = {
+ id: "action:image",
+ kind: "image",
+ label,
+ action: "image",
+ glyph: chipGlyph.image,
+ };
return {
id: "attach",
title: t("assistant.composer.addGroupAttach"),
@@ -419,7 +404,7 @@ function imageGroup(): Group {
};
}
-// Show more and Retry act in place; an option or the Image row is handed to the composer.
+// Show all and Retry act in place; an option or the Image row is handed to the composer.
function choose(item: MentionOption): void {
if (item.action === "agent") return chooseAgent(item);
if (item.disabled) return;
@@ -458,7 +443,8 @@ function group(
retry?: () => void,
): Group {
const found = all.filter(matches);
- const shown = expanded.has(id) ? found : found.slice(0, MENTION_GROUP_LIMIT);
+ const limit = terms.value.length > 0 ? MENTION_SEARCH_LIMIT : MENTION_GROUP_LIMIT;
+ const shown = expanded.has(id) ? found : found.slice(0, limit);
const extra = (action: "more" | "retry", label: I18nText): MentionOption => ({
id: `${action}:${id}`,
kind: "field",
@@ -471,7 +457,7 @@ function group(
? [
...shown,
...(found.length > shown.length
- ? [extra("more", t("assistant.composer.mention.showMoreIn", { group: title }))]
+ ? [extra("more", t("assistant.composer.mention.showAll"))]
: []),
]
: state === "error"
@@ -486,7 +472,7 @@ function group(
},
]
: [];
- return { id, title, kind, state, items, retry };
+ return { id, title, kind, state, items, retry, total: state === "ready" ? found.length : 0 };
}
function option(
@@ -509,7 +495,14 @@ function option(
provenance: "mention",
sent: "whitelist",
};
- return { id: chip.id, kind, label, ...(secondary && { secondary }), chip };
+ return {
+ id: chip.id,
+ kind,
+ label,
+ ...(secondary && { secondary }),
+ chip,
+ code: kind === "stream" || kind === "alert",
+ };
}
function stateOf(
@@ -551,7 +544,7 @@ defineExpose({
},
/** The option Enter picks; `null` when nothing matches, so Enter inserts nothing (CT-05 AC 5). */
current: () => flat.value[active.value] ?? null,
- /** Runs Enter on the highlighted row: an option is selected, Show more and Retry act in place. */
+ /** Runs Enter on the highlighted row: an option is selected, Show all and Retry act in place. */
choose,
activeId: computed(() => {
const current = flat.value[active.value];
diff --git a/web/src/components/ai-assistant/context/AssistantMenuRow.vue b/web/src/components/ai-assistant/context/AssistantMenuRow.vue
new file mode 100644
index 0000000000..28ef3a4da6
--- /dev/null
+++ b/web/src/components/ai-assistant/context/AssistantMenuRow.vue
@@ -0,0 +1,45 @@
+
+
+
+
+ {{ label }}
+ {{ meta }}
+
+
+
+
+
diff --git a/web/src/components/ai-assistant/context/AssistantTimePrefix.spec.ts b/web/src/components/ai-assistant/context/AssistantTimePrefix.spec.ts
new file mode 100644
index 0000000000..59def75d7f
--- /dev/null
+++ b/web/src/components/ai-assistant/context/AssistantTimePrefix.spec.ts
@@ -0,0 +1,70 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
+import { afterEach, describe, expect, it } from "vitest";
+
+import type { TimeWindow } from "@/composables/assistant";
+
+import AssistantTimePrefix from "./AssistantTimePrefix.vue";
+
+const DEFAULT: TimeWindow = { from: 0, to: 900_000, period: "15m", source: "default" };
+const PAGE: TimeWindow = { from: 0, to: 3_600_000, period: "1h", source: "page" };
+
+async function open(props: { window: TimeWindow; pageWindow?: TimeWindow | null }) {
+ const wrapper = mount(AssistantTimePrefix, { props, attachTo: document.body });
+ await wrapper.get('[data-test="assistant-context-chip-time"]').trigger("click");
+ await flushPromises();
+ const row = (id: string) =>
+ document.body.querySelector(`[data-test="assistant-context-time-${id}"]`);
+ return { wrapper, row };
+}
+
+enableAutoUnmount(afterEach);
+
+describe("AssistantTimePrefix", () => {
+ it("names the range and where it comes from", () => {
+ const wrapper = mount(AssistantTimePrefix, { props: { window: DEFAULT } });
+ const button = wrapper.get('[data-test="assistant-context-chip-time"]');
+ expect(button.text()).toBe("Last 15 min");
+ expect(button.attributes("aria-label")).toBe("Time range, Last 15 min, default");
+ });
+
+ it("marks the default on a page with no range, and offers no Page time", async () => {
+ const { row } = await open({ window: DEFAULT });
+ expect(row("page")).toBeNull();
+ expect(row("15m")?.textContent).toContain("default");
+ expect(row("15m")?.getAttribute("aria-checked")).toBe("true");
+ });
+
+ it("offers Page time on a page with a range, and goes back to it with null", async () => {
+ const { wrapper, row } = await open({
+ window: { ...PAGE, source: "custom", period: "7d" },
+ pageWindow: PAGE,
+ });
+ expect(row("page")?.textContent).toContain("Page time");
+ expect(row("7d")?.getAttribute("aria-checked")).toBe("true");
+ row("page")!.click();
+ await flushPromises();
+ expect(wrapper.emitted("change")).toEqual([[null]]);
+ });
+
+ it("applies a preset at once as an override", async () => {
+ const { wrapper, row } = await open({ window: DEFAULT });
+ row("24h")!.click();
+ await flushPromises();
+ expect(wrapper.emitted("change")?.[0]?.[0]).toMatchObject({ period: "24h" });
+ });
+});
diff --git a/web/src/components/ai-assistant/context/AssistantTimePrefix.vue b/web/src/components/ai-assistant/context/AssistantTimePrefix.vue
new file mode 100644
index 0000000000..8273055570
--- /dev/null
+++ b/web/src/components/ai-assistant/context/AssistantTimePrefix.vue
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+
+ {{ text }}
+
+
+
+
+
+
+
+ {{ t("assistant.context.time.reversed") }}
+
+
+
+
+
+
+
+
diff --git a/web/src/components/ai-assistant/welcome/AssistantLauncher.spec.ts b/web/src/components/ai-assistant/welcome/AssistantLauncher.spec.ts
index 358a3a52a7..dfd852bbec 100644
--- a/web/src/components/ai-assistant/welcome/AssistantLauncher.spec.ts
+++ b/web/src/components/ai-assistant/welcome/AssistantLauncher.spec.ts
@@ -61,8 +61,9 @@ vi.mock("@/components/ai-assistant/gate/AssistantGateState.vue", () => ({
},
}));
-function setup(gate: GateCode = "available") {
+function setup(gate: GateCode = "available", history: Record = {}) {
fake.current = fakeAssistant(gate);
+ fake.current.history = Object.assign(fake.current.history, history);
const router = fakeRouter([
{ path: "/billings/plans", name: "plans", component: { render: () => null } },
]);
@@ -221,21 +222,43 @@ describe("AssistantLauncher", () => {
expect(wrapper.find('[data-test="assistant-launcher-suggestions"]').exists()).toBe(false);
});
- it("hides its Suggestions while the sidebar shows its own welcome (D5 decision 10)", async () => {
+ it("folds to its header while the sidebar is open (D5 decision 10)", async () => {
const { wrapper, store } = setup();
expect(wrapper.find('[data-test="assistant-launcher-suggestions"]').exists()).toBe(true);
store.state.shell.placement = { kind: "sidebar" };
await flushPromises();
expect(wrapper.find('[data-test="assistant-launcher-suggestions"]').exists()).toBe(false);
- expect(wrapper.find('[data-test="assistant-launcher-your-chats"]').exists()).toBe(true);
+ expect(wrapper.find('[data-test="assistant-launcher-your-chats"]').exists()).toBe(false);
+ expect(wrapper.find("input").exists()).toBe(false);
+ expect(wrapper.find('[data-test="assistant-launcher-open"]').exists()).toBe(true);
});
- it("names the default window in the disclosure and opens the full view in place", () => {
+ it("holds the time prefix and the i inside the input, and opens the full view in place", () => {
const { wrapper } = setup();
- expect(wrapper.get('[data-test="assistant-launcher-disclosure"]').text()).toMatch(
- /^Sends with the default time range, \S/,
- );
+ const field = wrapper.get('[data-test="assistant-launcher-input"]');
+ expect(field.find('[data-test="assistant-context-chip-time"]').text()).toBe("Last 15 min");
+ expect(field.find('[data-test="assistant-launcher-sees"]').exists()).toBe(true);
expect(wrapper.get('[data-test="assistant-launcher-open"]').text()).toBe("Open full view");
+ expect(input(wrapper).attributes("placeholder")).toBe("Ask about your data");
+ });
+
+ it("turns Ask on only with text", async () => {
+ const { wrapper } = setup();
+ const ask = () => wrapper.get('[data-test="assistant-launcher-ask"]');
+ expect(ask().attributes("disabled")).toBeDefined();
+ await input(wrapper).setValue("why?");
+ expect(ask().attributes("disabled")).toBeUndefined();
+ });
+
+ it("states a failed list with the same words as the title menu, and retries", async () => {
+ const retry = vi.fn();
+ const listState = computed(() => ({ status: "error", offline: false }));
+ const { wrapper } = setup("available", { listState, retry });
+ await flushPromises();
+ const row = wrapper.get('[data-test="assistant-launcher-recent-failed"]');
+ expect(row.text()).toContain("Couldn't load your chats");
+ await wrapper.get('[data-test="assistant-launcher-recent-retry"]').trigger("click");
+ expect(retry).toHaveBeenCalled();
});
it("opens What the assistant sees from a named icon that takes no text width (PO A34)", () => {
@@ -248,10 +271,7 @@ describe("AssistantLauncher", () => {
it("shows at most three suggestions and no other suggestion group (PO A34)", () => {
const { wrapper } = setup();
expect(wrapper.findAll('[data-test^="assistant-launcher-starter-"]')).toHaveLength(3);
- expect(wrapper.findAll("h3").map((heading) => heading.text())).toEqual([
- "Suggestions",
- "Your chats",
- ]);
+ expect(wrapper.findAll("h3").map((heading) => heading.text())).toEqual(["Recent"]);
});
it("shows skeleton rows while the starters load", () => {
diff --git a/web/src/components/ai-assistant/welcome/AssistantLauncher.vue b/web/src/components/ai-assistant/welcome/AssistantLauncher.vue
index d6e43243f6..c8b2e12e91 100644
--- a/web/src/components/ai-assistant/welcome/AssistantLauncher.vue
+++ b/web/src/components/ai-assistant/welcome/AssistantLauncher.vue
@@ -24,10 +24,10 @@ along with this program. If not, see .
data-dd-privacy="mask"
:data-state="state"
>
-
-
-
-
+
+
+
+
{{ t("assistant.name") }}
.
/>
@@ -50,166 +51,186 @@ along with this program. If not, see .
+
-
-
-
-
-
-
-
-
-
- {{ t("assistant.welcome.launcher.ask") }}
-
-
-
-
-
-
- {{ disclosure }}
-
-
-
-
-
-
-
-
+
+
+
+
+ (timeOverride = range)"
+ />
+
+
+
+
+
+
+ {{ t("assistant.welcome.launcher.ask") }}
+
+
+
+
-
- {{ t("assistant.welcome.suggestions") }}
-
-
- -
-
+
-
+
- {{ starter.label }}
-
+ />
-
-
- {{ t("assistant.welcome.launcher.yourChats") }}
+
+
+ {{ t("assistant.welcome.launcher.recent") }}
{{ t("assistant.welcome.launcher.allChats") }}
-
-
+
-
+
+
+ {{ t("common.retry") }}
+
+
+
+
-
+ >
+ {{ t("assistant.welcome.launcher.noRecent") }}
+
+
-
-
- {{
+ {{
row.title
}}
,
{{ t(CHAT_STATUS_KEYS[row.status]) }}
- {{ row.at }}
+ {{ row.at }}
@@ -252,45 +273,45 @@ import { useStore } from "vuex";
import { provideAssistantSurface } from "@/components/ai-assistant/common/assistantSurface";
import AssistantIconButton from "@/components/ai-assistant/common/AssistantIconButton.vue";
-import AssistantMark from "@/components/ai-assistant/common/AssistantMark.vue";
-import AssistantStatusDot from "@/components/ai-assistant/common/AssistantStatusDot.vue";
+import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import {
CHAT_STATUS_KEYS,
- CHAT_STATUS_SIGNAL,
chatStatusOf,
inProgressFirst,
isInProgress,
} from "@/components/ai-assistant/history/chatStatus";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import AssistantContextSeesPopover from "@/components/ai-assistant/context/AssistantContextSeesPopover.vue";
+import AssistantTimePrefix from "@/components/ai-assistant/context/AssistantTimePrefix.vue";
import AssistantGateState from "@/components/ai-assistant/gate/AssistantGateState.vue";
import {
openAssistant,
starterPayload,
- timeRangeText,
timeWindowOf,
useAssistantCapability,
useAssistantHistory,
useAssistantName,
useAssistantStarters,
useAssistantStore,
+ type ComposerState,
type Starter,
} 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 OCard from "@/lib/core/Card/OCard.vue";
-import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
-import OShortcut from "@/lib/core/Shortcut/OShortcut.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
+import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import { trackAssistant } from "@/services/assistantAnalytics";
import { raw, useI18nTyped } from "@/types/i18n";
import { formatRelative } from "@/utils/formatters";
+import AssistantStarterRow from "./AssistantStarterRow.vue";
+
// Overlays opened from here are teleported to body, so they carry the assistant colour roles with them.
provideAssistantSurface();
@@ -301,11 +322,11 @@ const vuex = useStore();
const capability = useAssistantCapability();
const assistant = useAssistantStore();
const { mdUp } = useBreakpoint();
-const { starters, loading, scope } = useAssistantStarters({ page: "home" });
+const { starters, loading } = useAssistantStarters({ page: "home" });
const titleId = `assistant-launcher-title-${useId()}`;
-const disclosureId = `assistant-launcher-disclosure-${useId()}`;
const text = ref("");
+const timeOverride = ref(null);
const input = ref | null>(null);
const org = computed(() => vuex.state.selectedOrganization?.identifier ?? "");
@@ -316,25 +337,11 @@ const state = computed(() => {
const shown = computed(() => state.value !== null);
const creditsExhausted = computed(() => capability.value.creditMode === "exhausted");
const shownStarters = computed(() => starters.value.slice(0, LAUNCHER_ROWS));
-// The sidebar's own welcome already lists the starters, so Home shows one set (D5 decision 10).
-const sidebarWelcome = computed(() => {
- const shell = assistant.state.shell;
- const sid = shell.activeSid;
- return (
- shell.placement.kind === "sidebar" && !(sid && assistant.state.sessions[sid]?.messages.length)
- );
-});
-const showSuggestions = computed(() => state.value !== "gate" && !sidebarWelcome.value);
+// The sidebar is the composer while it is open, so the card folds to its header (D5 decision 10).
+const folded = computed(() => assistant.state.shell.placement.kind === "sidebar");
+const showSuggestions = computed(() => state.value !== "gate");
-// The full view sends what its own page shows; with no page range that is the default window.
-const disclosure = computed(() =>
- t(
- scope.value.streamCount === 0
- ? "assistant.welcome.launcher.disclosureNoStreams"
- : "assistant.welcome.launcher.disclosure",
- { range: timeRangeText(timeWindowOf(null, null)) },
- ),
-);
+const window = computed(() => timeWindowOf(null, timeOverride.value));
const history = useAssistantHistory();
const recentRows = history.recent(RECENT_LOOKUP);
@@ -349,6 +356,7 @@ const recent = computed(() =>
(row) => row.status,
).slice(0, LAUNCHER_ROWS),
);
+const recentFailed = computed(() => history.listState.value.status === "error");
const hasInProgress = computed(() => recent.value.some((row) => isInProgress(row.status)));
onMounted(() => history.ensureLoaded());
@@ -376,7 +384,11 @@ function ask(stage: boolean): void {
intent: stage ? "stage" : "send",
thread: "new",
container: "full",
- payload: { text: line, chips: [] },
+ payload: {
+ text: line,
+ chips: [],
+ ...(timeOverride.value && { timeOverride: timeOverride.value }),
+ },
});
}
@@ -394,7 +406,10 @@ function pickStarter(starter: Starter, index: number): void {
intent: "stage",
thread: "new",
container: "full",
- payload: starterPayload(starter, org.value),
+ payload: {
+ ...starterPayload(starter, org.value),
+ ...(timeOverride.value && { timeOverride: timeOverride.value }),
+ },
});
}
diff --git a/web/src/components/ai-assistant/welcome/AssistantStarterRow.vue b/web/src/components/ai-assistant/welcome/AssistantStarterRow.vue
new file mode 100644
index 0000000000..b1096e0880
--- /dev/null
+++ b/web/src/components/ai-assistant/welcome/AssistantStarterRow.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ {{ part }}
+ {{ part }}
+
+
+
+
+
+
diff --git a/web/src/components/ai-assistant/welcome/AssistantWelcome.spec.ts b/web/src/components/ai-assistant/welcome/AssistantWelcome.spec.ts
index 47fef754ac..4ed3366357 100644
--- a/web/src/components/ai-assistant/welcome/AssistantWelcome.spec.ts
+++ b/web/src/components/ai-assistant/welcome/AssistantWelcome.spec.ts
@@ -17,7 +17,6 @@ import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AssistantScope, Starter } from "@/composables/assistant";
-import OCard from "@/lib/core/Card/OCard.vue";
import { raw } from "@/types/i18n";
import AssistantWelcome, {
@@ -61,69 +60,34 @@ beforeEach(() => resetWelcomeTelemetryForTests());
afterEach(() => track.mockClear());
describe("AssistantWelcome", () => {
- it("is a section labelled by its capability line, with the scope as a paragraph", () => {
+ it("is a section of plain suggestion rows, nothing else (§8)", () => {
const wrapper = mountWelcome();
const section = wrapper.get('[data-test="assistant-welcome"]');
expect(section.element.tagName).toBe("SECTION");
- const heading = wrapper.get('[data-test="assistant-welcome-capability"]');
- expect(heading.element.tagName).toBe("H2");
- expect(section.attributes("aria-labelledby")).toBe(heading.attributes("id"));
- expect(wrapper.get('[data-test="assistant-welcome-scope"]').element.tagName).toBe("P");
- });
-
- it("states the capability by canWrite, treating unknown as read-write", () => {
- const readWrite =
- "Ask about your logs, traces, metrics, alerts and dashboards. It runs read-only queries, links to what it finds and asks before changing anything.";
- expect(mountWelcome().get("h2").text()).toBe(readWrite);
- expect(
- mountWelcome({ scope: { ...SCOPE, canWrite: "unknown" } })
- .get("h2")
- .text(),
- ).toBe(readWrite);
- expect(
- mountWelcome({ scope: { ...SCOPE, canWrite: "no" } })
- .get("h2")
- .text(),
- ).toContain("can't change anything in this organization.");
- });
-
- it("names the org tag by its display name, never the identifier", () => {
- const wrapper = mountWelcome({ scope: { ...SCOPE, org: "a1b2c3", orgName: "Acme" } });
- expect(wrapper.get('[data-test="assistant-welcome-scope-org"]').text()).toBe("org Acme");
- });
-
- it("lists what it sees as neutral tags, with Beta only in the roomy variant", () => {
- const compact = mountWelcome();
- expect(compact.get('[data-test="assistant-welcome-scope"]').text()).toContain("Sees:");
- expect(compact.get('[data-test="assistant-welcome-scope-org"]').text()).toBe("org acme");
- expect(compact.get('[data-test="assistant-welcome-scope-streams"]').text()).toBe("12 streams");
- expect(compact.get('[data-test="assistant-welcome-scope-time"]').text()).toBe("last 15 min");
- expect(compact.get('[data-test="assistant-welcome-sent-with-first"]').text()).toBe(
- "Sent with your first message",
+ expect(section.attributes("aria-label")).toBe("Suggestions");
+ expect(wrapper.findAll("li")).toHaveLength(3);
+ expect(wrapper.find("h2").exists()).toBe(false);
+ expect(wrapper.find('[data-test="assistant-welcome-scope"]').exists()).toBe(false);
+ expect(wrapper.get('[data-test="assistant-starter-0"]').classes()).toContain(
+ "whitespace-normal!",
);
- expect(compact.text()).not.toContain("Beta");
- const roomy = mountWelcome({ variant: "roomy" });
- expect(roomy.get('[data-test="assistant-welcome-beta"]').text()).toBe("Beta");
- expect(roomy.get('[data-test="assistant-welcome-scope"]').text()).not.toContain("Beta");
});
- it("says no streams yet for a fresh org and marks it", () => {
- const wrapper = mountWelcome({ scope: { ...SCOPE, streamCount: 0 } });
- expect(wrapper.get('[data-test="assistant-welcome-scope-streams"]').text()).toBe(
- "no streams yet",
- );
+ it("never offers more than three, and marks a fresh org", () => {
+ const wrapper = mountWelcome({
+ scope: { ...SCOPE, streamCount: 0 },
+ starters: [...STARTERS, starter(3)],
+ });
+ expect(wrapper.findAll("li")).toHaveLength(3);
expect(wrapper.get('[data-test="assistant-welcome"]').attributes("data-fresh")).toBe("");
});
- it("renders three wrapping secondary buttons in a list under a hidden Suggestions heading", () => {
- const wrapper = mountWelcome();
- expect(wrapper.get("h3").text()).toBe("Suggestions");
- expect(wrapper.get("h3").classes()).toContain("sr-only");
- const buttons = wrapper.findAll("ul > li button");
- expect(buttons).toHaveLength(3);
- expect(buttons[0].classes()).toContain("whitespace-normal!");
- expect(buttons[0].classes()).toContain("max-md:min-h-11");
- expect(wrapper.get("ul").html()).not.toMatch(/truncate|line-clamp/);
+ it("sets a backticked name in the mono face", () => {
+ const wrapper = mountWelcome({
+ starters: [starter(0, { label: raw("Summarise errors in `app_logs`") })],
+ });
+ expect(wrapper.get("code").text()).toBe("app_logs");
+ expect(wrapper.text()).not.toContain("`");
});
it("grows every starter to a 2.75 rem target on coarse pointers at any width", () => {
@@ -132,15 +96,10 @@ describe("AssistantWelcome", () => {
}
});
- it("renders neutral cards in the roomy variant", () => {
- expect(mountWelcome({ variant: "roomy" }).findAllComponents(OCard)).toHaveLength(3);
- });
-
- it("shows skeleton rows instead of the scope and starters while loading", () => {
+ it("shows skeleton rows instead of the starters while loading", () => {
const wrapper = mountWelcome({ loading: true });
expect(wrapper.find('[data-test="assistant-welcome-loading"]').exists()).toBe(true);
- expect(wrapper.find('[data-test="assistant-welcome-capability"]').exists()).toBe(true);
- expect(wrapper.find('[data-test="assistant-starter-0"]').exists()).toBe(false);
+ expect(wrapper.find("li").exists()).toBe(false);
});
it("emits a page starter for staging and opens a recent chat instead", async () => {
@@ -165,65 +124,4 @@ describe("AssistantWelcome", () => {
["ai_welcome_shown", { fresh_org: false, starter_kinds: ["page", "page", "recent"] }],
]);
});
-
- it("renders the scope tags as read-only tray chips that open What the assistant sees (`70` §8)", async () => {
- const wrapper = mount(AssistantWelcome, {
- props: { variant: "compact", scope: SCOPE, starters: STARTERS, loading: false },
- attachTo: document.body,
- });
- const tag = wrapper.get('[data-test="assistant-welcome-scope-org"]');
- expect(tag.attributes("data-assistant-chip")).toBe("");
- expect(tag.find('[data-test="assistant-context-chip-remove"]').exists()).toBe(false);
- await tag.get('[data-test="assistant-context-chip-button"]').trigger("click");
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(
- document.body.querySelector('[data-test="assistant-context-sees-popover"]'),
- ).not.toBeNull();
- wrapper.unmount();
- });
-
- it("marks and hands focus back to the scope chip that opened What the assistant sees (U2, V7)", async () => {
- const wrapper = mount(AssistantWelcome, {
- props: { variant: "compact", scope: SCOPE, starters: STARTERS, loading: false },
- attachTo: document.body,
- });
- await new Promise((resolve) => setTimeout(resolve, 0));
- const button = wrapper
- .get('[data-test="assistant-welcome-scope-time"]')
- .get('[data-test="assistant-context-chip-button"]');
- (button.element as HTMLElement).focus();
- await button.trigger("click");
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(
- document.body.querySelector('[data-test="assistant-context-sees-popover"]'),
- ).not.toBeNull();
- expect(button.attributes("aria-expanded")).toBe("true");
- expect(
- wrapper
- .get('[data-test="assistant-welcome-scope-org"]')
- .get('[data-test="assistant-context-chip-button"]')
- .attributes("aria-expanded"),
- ).toBe("false");
- document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(document.body.querySelector('[data-test="assistant-context-sees-popover"]')).toBeNull();
- expect(document.activeElement).toBe(button.element);
- wrapper.unmount();
- });
-});
-
-describe("AssistantWelcome docked", () => {
- it("leads the scope line with the editor's object, then its stream and range", () => {
- const wrapper = mountWelcome({
- scope: {
- ...SCOPE,
- timeRange: raw("last 1 h"),
- editor: { language: "sql", stream: "default" },
- },
- });
- const tags = wrapper
- .findAll('[data-test^="assistant-welcome-scope-"]')
- .map((tag) => tag.text());
- expect(tags).toEqual(["this SQL", "stream default", "last 1 h"]);
- });
});
diff --git a/web/src/components/ai-assistant/welcome/AssistantWelcome.vue b/web/src/components/ai-assistant/welcome/AssistantWelcome.vue
index aacf6237de..a2f99815c0 100644
--- a/web/src/components/ai-assistant/welcome/AssistantWelcome.vue
+++ b/web/src/components/ai-assistant/welcome/AssistantWelcome.vue
@@ -19,99 +19,37 @@ along with this program. If not, see .
data-test="assistant-welcome"
:data-variant="variant"
:data-fresh="fresh ? '' : undefined"
- :aria-labelledby="capabilityId"
+ :aria-label="t('assistant.welcome.suggestions')"
:class="[
- 'flex flex-col p-4',
- variant === 'roomy' ? 'mx-auto w-full max-w-3xl gap-4 py-8' : 'gap-3',
+ 'flex min-h-full flex-col justify-end px-3 pb-1',
+ variant === 'roomy' ? 'mx-auto w-full max-w-3xl' : '',
]"
>
-
- {{ capability }}
-
-
- {{ t("assistant.beta") }}
-
-
-
-
+
-
-
-
-
-
- {{ t("assistant.welcome.scope") }}
-
- {{ t("assistant.welcome.sentWithFirst") }}
-
-
-
-
- {{ t("assistant.welcome.suggestions") }}
-
- -
-
-
- {{ starter.label }}
-
-
-
- {{ starter.label }}
-
-
-
-
+