merge: assistant composer redesign lane

This commit is contained in:
Harsh Mahajan
2026-11-01 19:00:02 +05:30
38 changed files with 2010 additions and 1748 deletions
@@ -59,14 +59,12 @@ const ROSTER: CapabilityAgent[] = [
const stubs = {
ODropdown: { template: '<div><slot name="trigger" /><slot /></div>' },
ODropdownGroup: { props: ["label"], template: "<div>{{ label }}<slot /></div>" },
ODropdownSeparator: true,
ODropdownItem: {
props: ["description"],
emits: ["select"],
template:
'<div role="menuitemradio" @click="$emit(\'select\')"><slot name="icon-left" /><slot /><p>{{ description }}</p><slot name="icon-right" /></div>',
'<div @click="$emit(\'select\')"><slot name="icon-left" /><slot /><p>{{ description }}</p><slot name="icon-right" /></div>',
},
OSheet: { template: '<div><slot name="header" /><slot /></div>' },
};
function setup(roster: CapabilityAgent[] = ROSTER, messages = false) {
@@ -88,7 +86,6 @@ function setup(roster: CapabilityAgent[] = ROSTER, messages = false) {
const trigger = (w: ReturnType<typeof mount>) => w.get('[data-test="assistant-agent-picker"]');
const option = (w: ReturnType<typeof mount>, id: string) =>
w.get(`[data-test="assistant-agent-option-${id}"]`);
const next = (w: ReturnType<typeof mount>) => w.find('[data-test="assistant-agent-picker-next"]');
beforeEach(() => {
breakpoint.mobile = false;
@@ -106,14 +103,19 @@ describe("AssistantAgentPicker", () => {
);
});
it("names the trigger for a screen reader and lists the roster with heading, note and policy", () => {
it("names the trigger with the agent and its clause, and lists one row per agent", () => {
const { wrapper } = setup();
expect(trigger(wrapper).attributes("aria-label")).toContain("Change agent");
expect(wrapper.text()).toContain("Who answers");
expect(wrapper.text()).toContain("Type @ and a name");
expect(option(wrapper, "sre").text()).toContain("Read-only");
expect(option(wrapper, "generate-query").text()).toContain("Asks before changing anything");
expect(wrapper.findAll('[role="menuitemradio"]')).toHaveLength(3);
expect(trigger(wrapper).attributes("aria-label")).toBe(
"Agent: O2 Assistant, Asks before changing anything",
);
expect(wrapper.text()).toContain("Agent");
expect(option(wrapper, "sre").text()).toContain("Finds causes · Read-only");
expect(option(wrapper, "generate-query").text()).toContain(
"Writes queries · Asks before changing anything",
);
expect(wrapper.findAll('[role="option"]')).toHaveLength(3);
expect(option(wrapper, "o2-ai").attributes("aria-selected")).toBe("true");
expect(option(wrapper, "o2-ai").classes()).toContain("bg-dropdown-item-hover-bg");
});
it("sets a new chat's agent without sending, and announces it", async () => {
@@ -122,18 +124,18 @@ describe("AssistantAgentPicker", () => {
await option(wrapper, "sre").trigger("click");
expect(store.state.sessions[sid].agentId).toBe("sre");
expect(send).not.toHaveBeenCalled();
expect(trigger(wrapper).text()).toContain("Investigator");
expect(next(wrapper).exists()).toBe(false);
expect(trigger(wrapper).attributes("aria-label")).toContain("Investigator");
expect(trigger(wrapper).attributes("data-pending")).toBeUndefined();
expect(composerUi(sid).status?.text).toBe("Investigator will answer.");
});
it("shows Next on a chat with turns, and the current agent takes it away", async () => {
it("marks a pending switch on a chat with turns, and the current agent takes it away", async () => {
const { wrapper, store, sid } = setup(ROSTER, true);
await option(wrapper, "sre").trigger("click");
expect(next(wrapper).text()).toBe("Next: Investigator");
expect(trigger(wrapper).attributes("data-pending")).toBe("");
expect(store.state.sessions[sid].messages.some((m: any) => m.handoffFrom)).toBe(false);
await option(wrapper, "o2-ai").trigger("click");
expect(next(wrapper).exists()).toBe(false);
expect(trigger(wrapper).attributes("data-pending")).toBeUndefined();
});
it("locks a row with its reason, offers Ask an admin for a role, and reads the reason on Enter", async () => {
@@ -143,14 +145,14 @@ describe("AssistantAgentPicker", () => {
{ id: "generate-query", pickable: true, available: false, reason: "role" },
];
const { wrapper, store, sid } = setup(roster);
expect(option(wrapper, "generate-query").text()).toContain("Not available to your role");
expect(option(wrapper, "generate-query").text()).toContain("Not available");
expect(wrapper.get('[data-test="ask-admin-stub"]').attributes("data-kind")).toBe(
"agent_access",
);
expect(option(wrapper, "sre").text()).toContain("Not reachable right now");
await option(wrapper, "generate-query").trigger("click");
expect(store.state.sessions[sid].agentId).toBeUndefined();
expect(composerUi(sid).status?.text).toBe("Not available to your role");
expect(composerUi(sid).status?.text).toBe("Not available · Ask an admin");
});
it("marks an agent a send refused, until the capability is re-read", async () => {
@@ -159,15 +161,4 @@ describe("AssistantAgentPicker", () => {
await flushPromises();
expect(option(wrapper, "sre").text()).toContain("Turned off");
});
it("uses a sheet list with tall rows on a phone", async () => {
breakpoint.mobile = true;
const { wrapper, store, sid } = setup();
await trigger(wrapper).trigger("click");
expect(wrapper.find('[data-test="assistant-agent-sheet"]').exists()).toBe(true);
expect(trigger(wrapper).classes()).toContain("min-h-11");
expect(option(wrapper, "sre").classes()).toContain("min-h-14");
await option(wrapper, "sre").trigger("click");
expect(store.state.sessions[sid].agentId).toBe("sre");
});
});
@@ -15,142 +15,64 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<span
v-if="shown"
class="flex min-w-0 items-center gap-1.5"
data-test="assistant-agent-picker-root"
>
<template v-if="isMobile">
<OButton
v-bind="triggerAttrs"
variant="ghost"
size="xs"
class="min-h-11"
@click="sheetOpen = true"
>
<OIcon :name="agentGlyph[shownAgent]" size="xs" />
{{ shownName }}
<template #icon-right><OIcon :name="assistantGlyph.expand" size="xs" /></template>
</OButton>
<OSheet
v-model:open="sheetOpen"
v-model:snap="sheetSnap"
:ariaLabel="String(t('assistant.agent.picker.heading'))"
:expand-label="t('assistant.shell.sheetExpand')"
:collapse-label="t('assistant.shell.sheetPeek')"
data-test="assistant-agent-sheet"
privacy="mask"
>
<template #header>
<p class="text-text-heading px-4 text-sm font-medium">
{{ t("assistant.agent.picker.heading") }}
</p>
</template>
<div class="flex flex-col gap-1 p-2" role="radiogroup">
<div v-for="row in rows" :key="row.id" class="flex flex-col">
<OButton
variant="ghost"
size="sm"
role="radio"
:aria-checked="row.id === shownAgent"
:aria-disabled="row.reason ? 'true' : undefined"
class="h-auto! min-h-14 w-full justify-start! text-start"
:data-test="`assistant-agent-option-${row.id}`"
@click="choose(row)"
>
<AssistantMark
size="md"
class="size-8!"
:glyph="row.reason ? assistantGlyph.locked : agentGlyph[row.id]"
/>
<span class="flex min-w-0 flex-1 flex-col">
<span :class="row.reason ? 'text-text-secondary' : 'text-text-heading'">{{
row.name
}}</span>
<span class="text-text-secondary text-xs whitespace-normal">{{
row.reason ?? row.purpose
}}</span>
</span>
<template v-if="row.id === shownAgent" #icon-right>
<OIcon :name="assistantGlyph.done" size="sm" />
</template>
</OButton>
<AssistantAskAdminPopover
v-if="row.askAdmin"
kind="agent_access"
:org="org"
:agent="String(row.name)"
>
<template #trigger>
<OButton
variant="ghost-primary"
size="xs"
class="min-h-11"
data-test="assistant-agent-ask-admin"
>
{{ t("assistant.outcome.actions.askAdmin") }}
</OButton>
</template>
</AssistantAskAdminPopover>
</div>
</div>
<p class="text-text-secondary px-4 pb-3 text-xs">{{ t("assistant.agent.picker.note") }}</p>
</OSheet>
</template>
<span v-if="shown" class="flex shrink-0 items-center" data-test="assistant-agent-picker-root">
<ODropdown
v-else
v-model:open="menuOpen"
side="top"
align="start"
align="end"
privacy="mask"
content-class="w-[min(24rem,calc(100vw-1.5rem))]"
:content-class="composerPopoverClass(layout.tight)"
>
<template #trigger>
<OButton
v-bind="triggerAttrs"
variant="ghost"
size="xs"
size="icon-xs-sq"
:aria-label="name"
:data-pending="pending ? '' : undefined"
:class="[
'text-text-secondary w-9! gap-0.5',
pending ? 'bg-surface-accent-hover ring-border-focus ring-1' : '',
TOUCH_TARGET_CLASS,
]"
data-test="assistant-agent-picker"
@keydown.up.prevent="menuOpen = true"
>
<OIcon :name="agentGlyph[shownAgent]" size="xs" />
{{ shownName }}
<template #icon-right><OIcon :name="assistantGlyph.expand" size="xs" /></template>
<OIcon :name="assistantGlyph.expand" size="xs" />
<OTooltip v-if="!menuOpen" :content="tooltip" />
</OButton>
</template>
<ODropdownGroup :label="t('assistant.agent.picker.heading')">
<template v-for="row in rows" :key="row.id">
<ODropdownItem
role="menuitemradio"
:aria-checked="row.id === shownAgent"
role="option"
:aria-selected="row.id === shownAgent"
:disabled="!!row.reason"
focusable-unavailable
:text-value="row.name"
:description="row.reason"
:class="['min-h-8', row.id === shownAgent ? 'bg-dropdown-item-hover-bg' : '']"
:data-test="`assistant-agent-option-${row.id}`"
@select="choose(row)"
>
<template #icon-left>
<AssistantMark
size="sm"
:glyph="row.reason ? assistantGlyph.locked : agentGlyph[row.id]"
<OIcon
:name="row.reason ? assistantGlyph.locked : agentGlyph[row.id]"
size="xs"
class="text-text-secondary"
/>
</template>
<span class="flex min-w-0 flex-col">
<span class="flex min-w-0 items-center gap-2">
<span
:class="['text-sm font-medium', row.reason ? 'text-text-secondary' : '']"
:class="['shrink-0 text-sm', row.reason ? 'text-text-secondary' : '']"
data-test="assistant-agent-option-name"
>{{ row.name }}</span
>
<span v-if="!row.reason" class="text-text-secondary text-xs whitespace-normal">{{
row.purpose
<span class="text-text-secondary ms-auto min-w-0 text-end text-xs">{{
row.askAdmin ? t("assistant.agent.unavailable.short") : (row.reason ?? row.clause)
}}</span>
<span class="text-text-secondary text-xs">{{ row.policy }}</span>
</span>
<template v-if="row.id === shownAgent" #icon-right>
<OIcon :name="assistantGlyph.done" size="sm" />
</template>
</ODropdownItem>
<div v-if="row.askAdmin" class="px-3 pb-1">
<div v-if="row.askAdmin" class="flex justify-end px-3 pb-1">
<AssistantAskAdminPopover kind="agent_access" :org="org" :agent="String(row.name)">
<template #trigger>
<OButton variant="ghost-primary" size="xs" data-test="assistant-agent-ask-admin">
@@ -161,14 +83,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</template>
</ODropdownGroup>
<ODropdownSeparator />
<p class="text-text-secondary px-3 py-1.5 text-xs" data-test="assistant-agent-picker-note">
{{ t("assistant.agent.picker.note") }}
</p>
</ODropdown>
<OText v-if="nextName" variant="meta" class="truncate" data-test="assistant-agent-picker-next">
{{ t("assistant.agent.picker.next", { agent: nextName }) }}
</OText>
</span>
</template>
@@ -176,10 +91,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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<SheetSnap>("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<string>(() => 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<Row[]>(() =>
(roster.value ?? []).map((agent) => {
@@ -252,8 +171,7 @@ const rows = computed<Row[]>(() =>
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 {
@@ -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 <http://www.gnu.org/licenses/>.
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 });
}
@@ -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.");
});
});
@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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 <http://www.gnu.org/licenses/>.
:label="t('assistant.composer.addContext')"
:icon="assistantGlyph.addContext"
:no-tooltip="open"
class="h-7! w-7!"
data-test="assistant-add-context"
/>
>
<template #tooltip>{{ t("assistant.composer.addContextTip") }}</template>
</AssistantIconButton>
</template>
<div class="flex flex-col gap-2" data-test="assistant-add-context-menu">
<OInput
@@ -52,21 +55,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
with-image
@select="pick"
/>
<ul
class="border-border-default flex flex-col gap-1 border-t px-1 pt-2"
data-test="assistant-add-context-keys"
>
<li
v-for="row in keyRows"
:key="row.id"
class="flex items-center justify-between gap-3 text-xs"
:data-test="`assistant-composer-hint-${row.id}`"
>
<span class="text-text-secondary">{{ row.label }}</span>
<OShortcut v-if="row.shortcut" :id="row.id" tone="strong" />
<kbd v-else-if="row.keys" class="text-text-body font-mono">{{ row.keys }}</kbd>
</li>
</ul>
</div>
</OPopover>
</template>
@@ -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<AssistantAddContextMenuProps>();
@@ -109,52 +96,7 @@ const query = ref("");
const search = ref<InstanceType<typeof OInput> | null>(null);
const menu = ref<InstanceType<typeof AssistantMentionMenu> | 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"),
@@ -13,11 +13,11 @@
// 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 { 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<AssistantComposerProps> = {}, 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<AssistantComposerProps> = {}, withSession = true)
global: { plugins: [fakeRouter(), fakeVuex()] },
attachTo: host.lastElementChild as HTMLElement,
});
mounted.push(wrapper);
const textarea = () => wrapper.get<HTMLTextAreaElement>('[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<ReturnType<typeof setup>["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: '<div data-test="tray-stub">tray</div>' },
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<typeof setup>["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", '<div data-test="assistant-approval-card"></div>');
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<ReturnType<typeof setup>["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: '<div data-test="tray-stub">tray</div>' },
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<string, unknown> } }).$
.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<HTMLElement>(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;
@@ -83,6 +83,58 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
</OBanner>
<OBanner
v-if="errorRow"
variant="error-soft"
bar
dense
icon="error-outline"
:content="errorRow"
class="rounded-default min-h-9"
data-test="assistant-composer-error-row"
/>
<OBanner
v-if="attachNotice"
variant="default"
bar
dense
icon="info-outline"
:content="t('assistant.attach.saved')"
class="rounded-default min-h-9"
data-test="assistant-composer-attach-notice"
>
<template #actions>
<OButton
variant="ghost"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-composer-attach-ok"
@click="dismissAttachNotice"
>
{{ t("assistant.attach.ok") }}
</OButton>
</template>
</OBanner>
<div
v-if="replying"
class="flex min-w-0 items-center gap-2"
data-test="assistant-composer-reply"
>
<OText variant="meta" as="p" class="min-w-0 flex-1">
{{ t("assistant.composer.replyHelper") }}
</OText>
<OButton
variant="ghost"
size="chip"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-composer-reply-cancel"
@click="leaveReply"
>
{{ t("assistant.composer.replyCancel") }}
</OButton>
</div>
<div class="relative flex flex-col gap-1.5">
<AssistantMentionMenu
ref="mention"
@@ -93,7 +145,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@select="pickMention"
@close="closeMention"
/>
<!-- One frame holds the chips, the text and its controls, so the message reads as one control (D4 §3.1) -->
<!-- One DOM at every width: the text, then the control row beside it or under it -->
<OTextarea
ref="textbox"
:model-value="shownText"
@@ -105,76 +157,61 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: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"
>
<template v-if="$slots.chips || images.length > 0" #header>
<div
class="flex min-w-0 flex-col gap-1 px-1.5 pt-1.5"
data-test="assistant-composer-chips"
<template v-if="inertKind" #footer>
<OButton
v-if="cardAway"
variant="ghost-primary"
size="chip"
:class="['me-1 shrink-0', TOUCH_TARGET_CLASS]"
data-test="assistant-composer-go-to-request"
@click="goToRequest"
>
<slot name="chips" />
<ul
v-if="images.length > 0"
class="flex flex-wrap gap-1.5"
data-test="assistant-image-strip"
>
<li v-for="image in images" :key="image.id ?? image.filename">
<AssistantChipPopover
:chip="imageChip(image)"
:image-src="dataUrl(image)"
:open="openImage === image.id"
@update:open="(open) => (openImage = open ? (image.id ?? null) : null)"
@remove="removeImage(image)"
>
<template #anchor>
<span
class="border-border-default rounded-default inline-flex items-center gap-0.5 border p-0.5"
>
<OButton
variant="ghost"
size="icon-md"
class="p-0! max-md:size-11 pointer-coarse:size-11"
:aria-label="image.filename"
aria-haspopup="dialog"
:aria-expanded="openImage === image.id"
data-test="assistant-image-open"
@click="openImage = image.id ?? null"
>
<img
:src="dataUrl(image)"
:alt="image.filename"
class="rounded-default size-10 object-cover"
/>
</OButton>
<OButton
variant="ghost"
size="icon-xs"
class="max-md:size-11 pointer-coarse:size-11"
:aria-label="t('assistant.composer.removeImage', { name: image.filename })"
data-test="assistant-image-remove"
@click="removeImage(image)"
>
<OIcon :name="assistantGlyph.close" size="xs" />
</OButton>
</span>
</template>
</AssistantChipPopover>
</li>
</ul>
</div>
{{ t("assistant.composer.goToRequest") }}
</OButton>
</template>
<template #footer>
<template v-else #footer>
<div
class="flex items-center gap-1 px-1.5 pb-1.5"
ref="controls"
:class="[
'flex min-w-0 items-center gap-0.5',
twoRows ? 'mt-1 flex-wrap px-1 pb-1' : 'shrink-0 pe-1',
]"
data-test="assistant-composer-controls"
>
<slot v-if="!replying" name="chips" />
<AssistantDictationMeter
v-if="voice.listening.value"
:label="voice.rowLabel.value"
:level="voice.level.value"
:elapsed-ms="voice.elapsedMs.value"
:narrow="width.narrow.value"
:tight="width.tight.value && pickerRoom"
/>
<span
v-else-if="voice.waiting.value"
class="text-text-secondary inline-flex min-w-0 items-center gap-1.5 text-xs"
data-test="assistant-dictate-waiting"
>
<OSpinner size="xs" class="motion-reduce:hidden" />
<span class="truncate">{{
width.narrow.value
? t("assistant.voice.waitingShort")
: t("assistant.voice.waitingPermission")
}}</span>
</span>
<span v-if="twoRows" data-spacer class="flex-1" />
<AssistantAddContextMenu
v-if="!replying"
ref="addMenu"
@@ -182,30 +219,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@select="addOption"
@image="openPicker"
/>
<AssistantContextSeesPopover v-if="!replying" :sid="sid" />
<AssistantAgentPicker v-if="!replying" :sid="sid" />
<AssistantIconButton
v-if="!mdUp && recallEntries.length > 0"
v-if="coarse && recallEntries.length > 0"
:label="t('assistant.composer.recentPrompts')"
:icon="assistantGlyph.history"
data-test="assistant-recall-open"
@click="recallOpen = true"
/>
<span class="flex-1" />
<AssistantDictationMeter
v-if="voice.listening.value"
:label="voice.rowLabel.value"
:level="voice.level.value"
:elapsed-ms="voice.elapsedMs.value"
/>
<AssistantDictateButton
v-if="voice.show.value"
v-if="voice.mic.value !== 'none'"
:mic="voice.mic.value"
:pressed="voice.mine.value"
:denied="voice.denied.value"
:primer-open="voice.primerOpen.value"
:sentence="voice.sentence.value"
:disabled-reason="micOffReason"
:disabled-reason="voice.helperLine.value ?? micOffReason"
@press="onMicPress"
@accept="voice.acceptPrimer"
@decline="voice.declinePrimer"
@turn-on="voice.turnOn"
/>
<span class="grid">
<AssistantIconButton
@@ -214,31 +247,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: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"
/>
<AssistantIconButton
v-else
variant="primary"
:label="t('assistant.turn.sendLabel')"
:label="
replying ? t('assistant.composer.sendReply') : t('assistant.turn.sendLabel')
"
:icon="assistantGlyph.send"
:disabled="!canSend"
:disabled-reason="sendBlockedReason"
class="h-7! w-7!"
data-test="assistant-send"
@click="trySend"
/>
</span>
</div>
<OText
v-if="voice.waiting.value"
variant="meta"
as="p"
class="px-3 pb-1.5"
data-test="assistant-dictate-waiting"
>
{{ t("assistant.voice.waitingPermission") }}
</OText>
</template>
</OTextarea>
<AssistantDictationTail
@@ -264,25 +291,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: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 }}
</OText>
<OButton
v-if="replying"
variant="ghost"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-composer-reply-cancel"
@click="leaveReply"
>
{{ t("assistant.composer.replyCancel") }}
</OButton>
<OButton
v-if="pasteOffer && helper === pasteLine"
variant="ghost-primary"
size="xs"
size="chip"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-composer-paste-inline"
@click="pasteInline"
@@ -292,7 +313,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
v-if="helper === voice.helperLine.value && voice.action.value === 'retry'"
variant="ghost-primary"
size="xs"
size="chip"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-dictate-retry"
@click="voice.retry"
@@ -301,8 +322,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
<AssistantVoiceAllowPopover
v-if="helper === voice.helperLine.value && voice.action.value === 'how'"
:steps="voice.allowSteps.value"
/>
<OButton
v-if="helper === threadLine"
variant="ghost-primary"
size="chip"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-context-start-new"
@click="startNew"
>
{{ t("assistant.context.startNew") }}
</OButton>
</div>
<OText
v-if="storageBlocked"
@@ -328,7 +358,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@type-error="() => refuse('type', t('assistant.context.imageType'))"
/>
<AssistantRecallSheet
v-if="!mdUp"
v-if="coarse"
v-model:open="recallOpen"
:entries="recallEntries"
@select="pickRecall"
@@ -372,15 +402,29 @@ const EMPHASIS_MS = 2_000;
const MENTION_ANNOUNCE_MS = 400;
const CARET_KEYS: ReadonlySet<string> = 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<string> = new Set(["turn_limit", "rate_limited"]);
const UNSENT_CODES: ReadonlySet<string> = 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";
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, useId, watch } from "vue";
import AssistantAgentPicker from "@/components/ai-assistant/agents/AssistantAgentPicker.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 AssistantChipPopover from "@/components/ai-assistant/context/AssistantChipPopover.vue";
import AssistantContextSeesPopover from "@/components/ai-assistant/context/AssistantContextSeesPopover.vue";
import AssistantMentionMenu, {
type MentionOption,
} from "@/components/ai-assistant/context/AssistantMentionMenu.vue";
@@ -395,7 +439,6 @@ import {
isRunning,
markHintSeen,
pastedTextChip,
periodMs,
readHints,
readRecall,
refreshPageContext,
@@ -407,16 +450,23 @@ import {
useAssistantStore,
type RecallEntry,
} from "@/composables/assistant";
import {
DEFAULT_AGENT,
agentName,
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 OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OFile from "@/lib/forms/File/OFile.vue";
import type { FileValue } from "@/lib/forms/File/OFile.types";
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
import { trackAssistant } from "@/services/assistantAnalytics";
import { MAX_IMAGE_SIZE_BYTES, type ImageAttachment } from "@/ts/interfaces/chat";
import { MAX_IMAGE_SIZE_BYTES } from "@/ts/interfaces/chat";
import { raw, useI18nTyped } from "@/types/i18n";
import { formatBytes } from "@/utils/formatters";
import { getUUIDv7 } from "@/utils/uuid";
@@ -428,7 +478,10 @@ import AssistantDictationTail from "./AssistantDictationTail.vue";
import AssistantRecallSheet from "./AssistantRecallSheet.vue";
import AssistantVoiceAllowPopover from "./AssistantVoiceAllowPopover.vue";
import {
CAP_NOTICE_MS,
announceComposer,
composerFitsOneRow,
composerLayoutKey,
composerUi,
moveDraft,
forgetComposerUi,
@@ -436,6 +489,7 @@ import {
noteDraftCleared,
} from "./composerUi";
import { useAssistantVoice } from "./useAssistantVoice";
import { controlsWidthOf, textWidthOf, useComposerWidth } from "./useComposerWidth";
const props = withDefaults(defineProps<AssistantComposerProps>(), {
density: "compact",
@@ -454,6 +508,8 @@ const textbox = ref<InstanceType<typeof OTextarea> | null>(null);
const file = ref<InstanceType<typeof OFile> | null>(null);
const addMenu = ref<InstanceType<typeof AssistantAddContextMenu> | null>(null);
const mention = ref<InstanceType<typeof AssistantMentionMenu> | null>(null);
const controls = ref<HTMLElement | null>(null);
const width = useComposerWidth(root);
const uid = useId();
const helperId = `assistant-composer-helper-${uid}`;
const ghostHintId = `assistant-composer-ghost-${uid}`;
@@ -465,7 +521,6 @@ const blockedLine = ref<I18nText | null>(null);
const droppedImages = ref(0);
const recallOpen = ref(false);
const recallEntries = ref<RecallEntry[]>([]);
const openImage = ref<string | null>(null);
const mentionAt = ref<number | null>(null);
const mentionQuery = ref("");
const prefillPending = ref<{ text: string; chips: ContextChip[]; homeStarter?: boolean } | null>(
@@ -473,6 +528,8 @@ const prefillPending = ref<{ text: string; chips: ContextChip[]; homeStarter?: b
);
// The last long paste, offered back as plain text until the next edit or send (D4 decision 10).
const pasteOffer = ref<{ chipId: string; text: string; at: number } | null>(null);
const attachDismissed = ref(false);
const cardAway = ref(false);
const now = ref(Date.now());
let emphasisTimer: ReturnType<typeof setTimeout> | null = null;
let emptyEnterSaid = false;
@@ -481,6 +538,9 @@ let mentionTimer: ReturnType<typeof setTimeout> | null = null;
let pointerDown = false;
let cancelDeferredClose: (() => void) | null = null;
let clearedTimer: ReturnType<typeof setTimeout> | null = null;
let capTimer: ReturnType<typeof setTimeout> | null = null;
let cardObserver: IntersectionObserver | null = null;
let cardMutations: MutationObserver | null = null;
const usedSendIds = new Set<string>();
const offs: (() => void)[] = [];
@@ -504,9 +564,13 @@ const images = computed(() => composer.value?.images ?? []);
const stagedChips = computed(() =>
(composer.value?.chips ?? []).filter((chip) => chip.provenance !== "page"),
);
const hasContent = computed(
() => !!text.value.trim() || stagedChips.value.length > 0 || images.value.length > 0,
);
const attached = computed(() => stagedChips.value.length > 0 || images.value.length > 0);
const attachNotice = computed(() => {
const key = store.state.userOrgKey;
if (!key || attachDismissed.value || !attached.value || replying.value) return false;
return !readHints(key).attachSaved;
});
const hasContent = computed(() => !!text.value.trim() || attached.value);
const storageBlocked = computed(() => store.drafts.storageBlocked.value);
const ui = computed(() => composerUi(props.sid ?? ""));
const status = computed(() => (liveSid.value ? composerUi(liveSid.value).status : ui.value.status));
@@ -525,6 +589,9 @@ const inertKind = computed<"approval" | "consent" | null>(() => {
return session.value?.pendingConsent ? "consent" : null;
});
// The mic and the recall sheet follow the pointer, not the width, so a narrow desktop window keeps its keyboard.
const coarse = ref(!!window.matchMedia?.("(pointer: coarse)").matches);
const voice = useAssistantVoice({
field: el,
ensureSid,
@@ -535,7 +602,10 @@ const voice = useAssistantVoice({
store.setDraft(id, { text: value });
},
blocked: () => !props.visible || !!inertKind.value,
touch: () => !mdUp.value && !!window.matchMedia?.("(pointer: coarse)").matches,
tap: () => {
if (props.visible && inertKind.value) assistantFocusCard();
},
touch: () => !mdUp.value && coarse.value,
say,
focusField: () => el()?.focus({ preventScroll: true }),
});
@@ -553,7 +623,7 @@ const state = computed(() => {
return hasContent.value ? "draft" : "empty";
});
const showStop = computed(() => running.value);
const showStop = computed(() => running.value && !elsewhere.value);
// After 30 s without a frame Stop is the one emphasised control (HT-01 AC 3); the clock runs only while a turn does.
const clock = ref(Date.now());
@@ -574,28 +644,39 @@ const stalled = computed(() => {
const last = meta?.lastByteAt ?? meta?.lastFrameAt ?? meta?.startedAt ?? null;
return running.value && last !== null && clock.value - last >= STALL_MS;
});
const refusal = computed(() => (turn.value?.state === "refused" ? turn.value.error : null));
const busyLeft = ref(0);
let busyTimer: ReturnType<typeof setInterval> | null = null;
const busyUntil = computed(() => {
const error = refusal.value;
if (!error || !BUSY_CODES.has(error.code) || !error.retryAfterMs) return 0;
return (session.value?.turnMeta.endedAt ?? Date.now()) + error.retryAfterMs;
});
const errorRow = computed<I18nText | null>(() => {
if (busyLeft.value > 0)
return t("assistant.composer.busy", { product: t("assistant.name"), s: busyLeft.value });
return refusal.value && UNSENT_CODES.has(refusal.value.code)
? t("assistant.composer.unsent")
: null;
});
const canSend = computed(
() =>
voice.mine.value ||
(hasContent.value &&
!inertKind.value &&
(replying.value || (!running.value && !props.cooldownReason))),
(replying.value || (!running.value && !props.cooldownReason && busyLeft.value === 0))),
);
const sendBlockedReason = computed(() => {
if (busyLeft.value > 0) return errorRow.value ?? undefined;
if (props.cooldownReason) return props.cooldownReason;
if (inertKind.value)
return inertKind.value === "approval"
? t("assistant.composer.waitingApproval")
: t("assistant.composer.waitingConsent");
return hasContent.value ? undefined : t("assistant.composer.sendEmpty");
});
const micOffReason = computed<I18nText | undefined>(() => {
if (voice.status.value === "silent") return t("assistant.voice.error.browser_silent");
if (voice.offline.value && !voice.mine.value) return t("assistant.composer.offline");
if (inertKind.value === "approval") return t("assistant.composer.waitingApproval");
return inertKind.value === "consent" ? t("assistant.composer.waitingConsent") : undefined;
});
const micOffReason = computed<I18nText | undefined>(() =>
voice.offline.value && !voice.mine.value ? t("assistant.composer.offline") : undefined,
);
// A chat with no messages shows the welcome, and the ghost never repeats its starters (D5 decision 11, PO A34).
const ghostShown = computed(
@@ -611,11 +692,22 @@ const ghostHint = computed(() =>
: raw(""),
);
const shownAgent = computed(() => nextAgent(session.value));
const agentLabel = computed(() => agentName(shownAgent.value, t));
const pickerRoom = computed(() => rosterOffersChoice(store.state.capability.agents));
const placeholder = computed<I18nText>(() => {
if (inertKind.value) return t("assistant.composer.inert");
if (replying.value) return t("assistant.composer.replyPlaceholder");
if (props.ghost) return t("assistant.composer.name", { product: t("assistant.name") });
if (context.noScope.value) return t("assistant.composer.placeholderEmpty");
return t("assistant.composer.placeholder", { summary: context.summary.value });
if (shownAgent.value !== DEFAULT_AGENT)
return t("assistant.composer.agent", { agent: agentLabel.value });
if (!context.noScope.value)
return t("assistant.composer.scoped", {
stream: raw((context.page.value?.selectedStreams ?? []).join(", ")),
});
return session.value?.messages.length
? t("assistant.composer.followUp")
: t("assistant.composer.placeholder");
});
const recallHint = computed(() => {
@@ -631,12 +723,41 @@ const creditsRefusedAbove = computed(() => {
return turn?.state === "refused" && isCreditsRefusal(turn.error.code);
});
const pendingLine = computed<I18nText | null>(() => {
const next = session.value?.nextAgentId;
return next && next !== currentAgent(session.value)
? t("assistant.agent.pendingSwitch", { agent: agentLabel.value })
: null;
});
const capLine = computed(() => {
const cap = composerUi(props.sid ?? "").cap;
return cap && now.value - cap.at < CAP_NOTICE_MS ? cap.text : null;
});
// A door's staging into a chat that answered in the last 30 min offers a fresh chat instead (`40` §2.4).
const threadLine = computed<I18nText | null>(() => {
const current = session.value;
if (!current || current.messages.length === 0 || !current.title) return null;
if (!current.composer.chips.some((chip) => chip.provenance === "door")) return null;
const last = store.lastTurnAt(current.sid);
return last !== null && now.value - last < THREAD_WINDOW_MS
? t("assistant.context.inChat", { title: current.title })
: null;
});
const answeringLine = computed<I18nText | null>(() => {
if (elsewhere.value) return t("assistant.composer.elsewhere");
return running.value && hasContent.value && !stalled.value && state.value !== "stopping"
? t("assistant.composer.running")
: null;
});
const helper = computed<I18nText | null>(() => {
if (inertKind.value === "approval") return t("assistant.composer.waitingApproval");
if (inertKind.value === "consent") return t("assistant.composer.waitingConsent");
if (inertKind.value) return null;
if (voice.helperLine.value) return voice.helperLine.value;
if (replying.value) return t("assistant.composer.replyHelper");
if (blockedLine.value) return blockedLine.value;
if (capLine.value) return capLine.value;
const notice = liveSid.value ? store.entryNotice(liveSid.value) : null;
if (notice) return t(notice);
if (pasteOffer.value) return pasteLine.value;
@@ -650,9 +771,47 @@ const helper = computed<I18nText | null>(() => {
if (store.state.capability.creditMode === "exhausted" && !creditsRefusedAbove.value)
return t("assistant.credits.predictedExhausted", { org: raw(org.value) });
if (voice.show.value && voice.offline.value) return t("assistant.composer.offline");
return recallHint.value;
return answeringLine.value ?? pendingLine.value ?? threadLine.value ?? recallHint.value;
});
const helperFailed = computed(() => helper.value === voice.helperLine.value && voice.failure.value);
const voiceExtra = computed(() => voice.listening.value || voice.waiting.value);
const twoRows = ref(false);
function measureRows(): void {
const field = el();
twoRows.value = !composerFitsOneRow({
narrow: width.narrow.value,
extras: (attached.value && !replying.value) || voiceExtra.value,
text: shownText.value,
textWidth: width.width.value > 0 ? textWidthOf(field, shownText.value) : 0,
frameWidth: width.width.value > 0 ? (field?.parentElement?.clientWidth ?? 0) : 0,
controlsWidth: controlsWidthOf(controls.value),
});
}
watch(
[shownText, width.width, width.narrow, attached, replying, voiceExtra, inertKind],
() => void nextTick(measureRows),
{ flush: "post" },
);
const frameClass = computed(() => [
inertKind.value ? INERT_FRAME_CLASS : FRAME_CLASS,
twoRows.value ? "" : "[&>div]:flex-row [&>div]:items-center",
]);
provide(
composerLayoutKey,
computed(() => ({
narrow: width.narrow.value,
tight: width.tight.value,
wide: width.wide.value,
yieldChips: width.tight.value && voiceExtra.value && pickerRoom.value,
})),
);
const pasteLine = computed(() => t("assistant.context.pastedAsChip"));
const cleared = computed(() => {
@@ -682,12 +841,17 @@ const textboxAttrs = computed(() => ({
.filter(Boolean)
.join(" ") || undefined,
"aria-disabled": inertKind.value ? "true" : undefined,
"aria-readonly": inertKind.value ? "true" : undefined,
// A textarea cannot be a combobox, so there is no `aria-expanded`; the menu's state is spoken instead.
"aria-autocomplete": "list",
"aria-controls": mentionAt.value !== null ? listboxId : undefined,
"aria-activedescendant": mentionAt.value !== null ? mention.value?.activeId : undefined,
"data-assistant-composer": "",
class: "max-md:min-h-11 pointer-coarse:min-h-11",
class: [
"py-2.25! max-md:min-h-11 pointer-coarse:min-h-11",
twoRows.value ? "pb-0!" : "",
inertKind.value ? "text-text-secondary!" : "",
].join(" "),
}));
/** Puts the caret at the end or at an offset (CT-08 AC 9; `70` landing rules). */
@@ -818,22 +982,15 @@ function addOption(option: MentionOption): void {
void nextTick(() => focusInput("end"));
}
// Adds the option's chip or sets its time; false when a cap refused the chip.
// Adds the option's chip; false when a cap refused it.
function applyOption(id: Sid, option: MentionOption): boolean {
if (option.kind !== "time" && option.chip) {
if (option.chip) {
const result = store.addChip(id, option.chip);
if (!result.ok) {
capHit(result.cap === "rows" ? "rows" : "bytes");
return false;
}
}
if (option.kind === "time") {
const period = option.period ?? null;
store.setTimeOverride(
id,
period ? { from: Date.now() - periodMs(period), to: Date.now(), period } : null,
);
}
say(t("assistant.context.added", { label: option.label }));
trackAssistant("ai_context_changed", {
action: "mention",
@@ -953,9 +1110,7 @@ function trySend(): void {
if (!id || !composer.value) return;
if (inertKind.value) {
trackAssistant("ai_composer_enter_blocked", { reason: "inert" });
const line = helper.value ?? t("assistant.composer.waitingApproval");
if (!helperEmphasis.value) say(line);
return emphasise(line);
return say(placeholder.value);
}
if (replying.value) {
if (hasContent.value) return void sendReply(id);
@@ -963,6 +1118,7 @@ function trySend(): void {
emptyEnterSaid = true;
return;
}
if (busyLeft.value > 0 && errorRow.value) return say(errorRow.value);
if (props.cooldownReason) {
if (blockedLine.value !== props.cooldownReason) say(props.cooldownReason);
return emphasise(props.cooldownReason);
@@ -1153,6 +1309,14 @@ function capHit(cap: "rows" | "bytes" | "images" | "type" | "size", line?: I18nT
if (id && props.visible) noteCap(id, sentence);
}
function imageCapLine(name: string): I18nText {
return t("assistant.composer.imageCap", {
name: raw(name),
count: MAX_IMAGES,
size: raw(formatBytes(MAX_IMAGE_SIZE_BYTES)),
});
}
function refuse(cap: "type" | "size" | "images", line: I18nText): void {
capHit(cap, line);
}
@@ -1252,7 +1416,7 @@ async function addFiles(files: File[], pasted = false): Promise<void> {
const filename = pasted ? pastedName() : picked.name;
const result = store.addImage(id, { data, mimeType: picked.type, filename, size: picked.size });
if (!result.ok) {
capHit(result.cap === "images" ? "images" : result.cap);
capHit(result.cap === "images" ? "images" : result.cap, imageCapLine(filename));
break;
}
}
@@ -1273,35 +1437,23 @@ function onMicPress(event: MouseEvent): void {
if (event.detail > 0 && !voice.primerOpen.value) el()?.focus({ preventScroll: true });
}
function removeImage(image: ImageAttachment): void {
const id = liveSid.value;
if (!id || !image.id) return;
store.removeImage(id, image.id);
openImage.value = null;
say(t("assistant.context.removed", { label: raw(image.filename) }));
trackAssistant("ai_context_changed", {
action: "remove",
kind: "image",
count: 1,
bytes: image.size,
});
function dismissAttachNotice(): void {
attachDismissed.value = true;
const key = store.state.userOrgKey;
if (key) markHintSeen(key, "attachSaved");
void nextTick(() => focusInput("end"));
}
function imageChip(image: ImageAttachment): ContextChip {
return {
id: image.id ?? image.filename,
kind: "image",
label: raw(image.filename),
secondary: raw(formatBytes(image.size)),
sizeBytes: image.size,
provenance: "attach",
sent: "serialized",
};
function goToRequest(): void {
assistantFocusCard();
}
function dataUrl(image: ImageAttachment): string {
return `data:${image.mimeType};base64,${image.data}`;
// The whole staged payload moves, so the new chat sends exactly what this one would have (S8).
function startNew(): void {
const current = session.value;
if (!current) return;
const fresh = store.createSession({ origin: { ...current.origin }, thread: "new" });
moveDraft(store, current.sid, fresh);
}
// Drops anywhere on the container land in the strip, with an outline on the whole body while dragging (CT-10 AC 6).
@@ -1350,12 +1502,82 @@ watch(
},
);
// Stop goes when its turn ends, and a focused Stop would drop focus on the page (HT-04 AC 3).
watch(
showStop,
(shown) => {
if (!shown && root.value?.contains(document.activeElement))
if (document.activeElement?.matches('[data-test="assistant-stop"]')) focusInput("end");
},
{ flush: "pre" },
);
// A reason for a swallowed Enter is true only while its cause lasts (U8).
watch(inertKind, (kind) => {
if (kind === null) clearBlocked();
else voice.stop("other");
});
// A cap's sentence leaves on its own after 6 s; `now` has to tick for that.
watch(
() => composerUi(props.sid ?? "").cap?.at,
(at) => {
if (at === undefined) return;
now.value = Date.now();
if (capTimer) clearTimeout(capTimer);
capTimer = setTimeout(() => (now.value = Date.now()), CAP_NOTICE_MS);
},
);
watch(
busyUntil,
(until) => {
if (busyTimer) clearInterval(busyTimer);
busyTimer = null;
const tick = () => {
busyLeft.value = Math.max(0, Math.ceil((until - Date.now()) / 1000));
if (busyLeft.value === 0 && busyTimer) clearInterval(busyTimer);
};
tick();
if (busyLeft.value > 0) busyTimer = setInterval(tick, 1_000);
},
{ immediate: true },
);
// "Go to request" shows only while the card is out of view; the card may mount after the frame that named it.
function stopCardWatch(): void {
cardObserver?.disconnect();
cardMutations?.disconnect();
cardObserver = null;
cardMutations = null;
cardAway.value = false;
}
function observeCard(): boolean {
const card = shellRoot()?.querySelector('[data-test="assistant-approval-card"]');
if (!card || typeof IntersectionObserver === "undefined") return false;
cardObserver = new IntersectionObserver(([entry]) => (cardAway.value = !entry.isIntersecting));
cardObserver.observe(card);
return true;
}
watch(
() => session.value?.pendingApproval?.id,
async (id) => {
stopCardWatch();
await nextTick();
const shell = shellRoot();
if (!id || !shell || observeCard() || typeof MutationObserver === "undefined") return;
cardMutations = new MutationObserver(() => {
if (!observeCard()) return;
cardMutations?.disconnect();
cardMutations = null;
});
cardMutations.observe(shell, { childList: true, subtree: true });
},
{ immediate: true },
);
// The original chat keeps what was dictated: a switch, a hide or an org change settles there (`40` §1.8.3).
watch(liveSid, (id, previous) => {
if (previous && id !== previous) voice.stop("other");
@@ -1499,6 +1721,7 @@ watch(recallHint, (hint) => {
onMounted(() => {
refreshRecall();
measureRows();
const shell = shellRoot();
shell?.addEventListener("dragover", onDragOver);
shell?.addEventListener("dragleave", onDragLeave);
@@ -1578,6 +1801,9 @@ onBeforeUnmount(() => {
if (emphasisTimer) clearTimeout(emphasisTimer);
if (clearedTimer) clearTimeout(clearedTimer);
if (mentionTimer) clearTimeout(mentionTimer);
if (capTimer) clearTimeout(capTimer);
if (busyTimer) clearInterval(busyTimer);
stopCardWatch();
cancelDeferredClose?.();
});
@@ -24,7 +24,7 @@ import AssistantDictateButton, {
function render(props: Partial<AssistantDictateButtonProps> = {}) {
return mount(AssistantDictateButton, {
props: { pressed: false, denied: false, primerOpen: false, sentence: null, ...props },
props: { mic: "live", pressed: false, primerOpen: false, sentence: null, ...props },
attachTo: document.body,
});
}
@@ -48,8 +48,9 @@ describe("AssistantDictateButton", () => {
});
it("uses aria-disabled with a reason, so the mic stays focusable", () => {
const button = render({ disabledReason: raw("Blocked") }).get(mic);
const button = render({ mic: "crossed", disabledReason: raw("Blocked") }).get(mic);
expect(button.attributes("aria-disabled")).toBe("true");
expect(button.attributes("aria-haspopup")).toBe("dialog");
expect(button.attributes("disabled")).toBeUndefined();
});
@@ -59,6 +60,18 @@ describe("AssistantDictateButton", () => {
expect(wrapper.emitted("press")).toHaveLength(1);
});
it("opens Turn on for an admin's off mic instead of pressing, and hands the choice up", async () => {
const wrapper = render({ mic: "off" });
await wrapper.get(mic).trigger("click");
await flushPromises();
expect(wrapper.emitted("press")).toBeUndefined();
const off = document.body.querySelector('[data-test="assistant-dictate-off"]');
expect(off?.textContent).toContain("Dictation is off in this browser.");
document.body.querySelector<HTMLElement>('[data-test="assistant-dictate-turn-on"]')!.click();
await flushPromises();
expect(wrapper.emitted("turn-on")).toHaveLength(1);
});
it("names the engine in the primer and answers Not now and Continue", async () => {
const wrapper = render({ primerOpen: true, sentence: raw("Edge uses Microsoft.") });
await flushPromises();
@@ -16,28 +16,44 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<OPopover
:open="primerOpen"
:open="primerOpen || offOpen"
side="top"
align="end"
:aria-label="t('assistant.voice.primerTitle')"
content-class="flex w-[min(20rem,calc(100vw-1.5rem))] flex-col gap-2 p-3"
@update:open="(open) => !open && emit('decline')"
:aria-label="offOpen ? t('assistant.voice.offAdmin') : t('assistant.voice.primerTitle')"
:content-class="`${composerPopoverClass(layout.tight)} flex flex-col gap-2 p-3`"
@update:open="onOpenChange"
>
<template #anchor>
<AssistantIconButton
:label="t('assistant.voice.dictate')"
:icon="denied ? assistantGlyph.dictateOff : assistantGlyph.dictate"
:icon="crossed ? assistantGlyph.dictateOff : assistantGlyph.dictate"
:pressed="pressed"
:disabled="!!disabledReason"
:disabled="mic === 'crossed'"
:disabled-reason="disabledReason"
:no-tooltip="primerOpen"
:no-tooltip="primerOpen || offOpen"
:aria-haspopup="crossed ? 'dialog' : undefined"
class="h-7! w-7!"
data-test="assistant-dictate"
@click="(event) => emit('press', event)"
@click="onClick"
>
<template #tooltip>{{ tooltip }}</template>
</AssistantIconButton>
</template>
<section class="flex flex-col gap-2" data-test="assistant-dictate-primer">
<section v-if="offOpen" class="flex flex-col gap-2" data-test="assistant-dictate-off">
<OText variant="card-title" as="h2">{{ t("assistant.voice.offAdmin") }}</OText>
<div class="flex justify-end">
<OButton
variant="primary"
size="sm"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-dictate-turn-on"
@click="turnOn"
>
{{ t("assistant.voice.turnOn") }}
</OButton>
</div>
</section>
<section v-else class="flex flex-col gap-2" data-test="assistant-dictate-primer">
<OText variant="card-title" as="h2">{{ t("assistant.voice.primerTitle") }}</OText>
<OText variant="meta" as="p">{{ t("assistant.voice.primerBody") }}</OText>
<OText v-if="sentence" variant="meta" as="p" data-test="assistant-dictate-primer-engine">
@@ -71,12 +87,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import type { I18nText } from "@/types/i18n";
export interface AssistantDictateButtonProps {
/** `live` presses; `crossed` is unavailable and says why; `off` offers Turn on to an admin. */
mic: "live" | "crossed" | "off";
pressed: boolean;
denied: boolean;
primerOpen: boolean;
/** The sentence naming where the audio goes. */
sentence: I18nText | null;
/** Why the mic cannot start now; it stays focusable so the reason can be read. */
/** Why a crossed mic cannot start; it stays focusable so the reason can be read. */
disabledReason?: I18nText;
}
@@ -84,11 +101,12 @@ export interface AssistantDictateButtonEmits {
(e: "press", event: MouseEvent): void;
(e: "accept"): void;
(e: "decline"): void;
(e: "turn-on"): void;
}
</script>
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref } from "vue";
import AssistantIconButton from "@/components/ai-assistant/common/AssistantIconButton.vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
@@ -98,6 +116,7 @@ import OText from "@/lib/core/Typography/OText.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { composerPopoverClass, useComposerLayout } from "./composerUi";
import { holdKeys } from "./useAssistantVoice";
const props = withDefaults(defineProps<AssistantDictateButtonProps>(), {
@@ -107,10 +126,32 @@ const props = withDefaults(defineProps<AssistantDictateButtonProps>(), {
const emit = defineEmits<AssistantDictateButtonEmits>();
const { t } = useI18nTyped();
const layout = useComposerLayout();
const offOpen = ref(false);
const tooltip = computed(() =>
props.pressed
const crossed = computed(() => props.mic !== "live");
const tooltip = computed(() => {
if (props.mic === "off") return t("assistant.voice.offAdmin");
return props.pressed
? t("assistant.voice.stopTooltip")
: t("assistant.voice.tooltip", { keys: raw(holdKeys()) }),
);
: t("assistant.voice.tooltip", { keys: raw(holdKeys()) });
});
function onClick(event: MouseEvent): void {
if (props.mic === "off") offOpen.value = true;
else emit("press", event);
}
function onOpenChange(open: boolean): void {
if (open) return;
if (offOpen.value) offOpen.value = false;
else emit("decline");
}
// The mic turns live under the pointer, so the popover has nothing left to say.
function turnOn(): void {
offOpen.value = false;
emit("turn-on");
}
</script>
@@ -32,10 +32,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:style="{ transform: `scaleY(${barScale(weight)})` }"
/>
</span>
<span class="min-w-0 truncate">{{ label }}</span>
<span aria-hidden="true" class="tabular-nums" data-test="assistant-dictate-elapsed">{{
elapsed
}}</span>
<span v-if="!narrow" class="min-w-0 truncate">{{ label }}</span>
<span
v-if="!tight"
aria-hidden="true"
class="tabular-nums"
data-test="assistant-dictate-elapsed"
>{{ elapsed }}</span
>
</span>
</template>
@@ -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;
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<AssistantDictationMeterProps>();
const props = withDefaults(defineProps<AssistantDictationMeterProps>(), {
narrow: false,
tight: false,
});
const elapsed = computed(() => {
const seconds = Math.floor(props.elapsedMs / 1000);
@@ -16,8 +16,6 @@
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, describe, expect, it } from "vitest";
import { raw } from "@/types/i18n";
import AssistantVoiceAllowPopover from "./AssistantVoiceAllowPopover.vue";
afterEach(() => {
@@ -25,17 +23,14 @@ afterEach(() => {
});
describe("AssistantVoiceAllowPopover", () => {
it("gives this browser's steps and the headphones sentence (B17)", async () => {
const wrapper = mount(AssistantVoiceAllowPopover, {
props: { steps: raw("Open site settings.") },
attachTo: document.body,
});
it("says how to allow the microphone (B17)", async () => {
const wrapper = mount(AssistantVoiceAllowPopover, { attachTo: document.body });
await wrapper.get('[data-test="assistant-dictate-how"]').trigger("click");
await flushPromises();
const text = document.body.querySelector(
'[data-test="assistant-dictate-how-popover"]',
)?.textContent;
expect(text).toContain("Open site settings.");
expect(text).toContain("With a screen reader, use headphones while dictating.");
expect(text).toContain("Allow the microphone");
expect(text).toContain("Click the lock in the address bar");
});
});
@@ -18,8 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OPopover
side="top"
align="start"
:aria-label="t('assistant.voice.allow.title')"
content-class="flex w-[min(20rem,calc(100vw-1.5rem))] flex-col gap-2 p-3"
:aria-label="t('assistant.voice.allowTitle')"
:content-class="`${composerPopoverClass(layout.tight)} flex flex-col gap-2 p-3`"
>
<template #trigger>
<OButton
@@ -32,22 +32,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</template>
<section class="flex flex-col gap-2" data-test="assistant-dictate-how-popover">
<OText variant="card-title" as="h2">{{ t("assistant.voice.allow.title") }}</OText>
<OText variant="meta" as="p">{{ steps }}</OText>
<OText variant="meta" as="p">{{ t("assistant.voice.allow.headphones") }}</OText>
<OText variant="card-title" as="h2">{{ t("assistant.voice.allowTitle") }}</OText>
<OText variant="meta" as="p">{{ t("assistant.voice.allowBody") }}</OText>
</section>
</OPopover>
</template>
<script lang="ts">
import type { I18nText } from "@/types/i18n";
export interface AssistantVoiceAllowPopoverProps {
/** This browser's site-settings steps (B17). */
steps: I18nText;
}
</script>
<script setup lang="ts">
import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import OButton from "@/lib/core/Button/OButton.vue";
@@ -55,7 +45,8 @@ import OText from "@/lib/core/Typography/OText.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import { useI18nTyped } from "@/types/i18n";
defineProps<AssistantVoiceAllowPopoverProps>();
import { composerPopoverClass, useComposerLayout } from "./composerUi";
const { t } = useI18nTyped();
const layout = useComposerLayout();
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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");
});
});
@@ -13,7 +13,7 @@
// 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 { 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<ComputedRef<ComposerLayout>> =
Symbol("assistantComposerLayout");
const states = reactive(new Map<string, ComposerUi>());
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<ComposerLayout> {
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);
@@ -88,18 +88,6 @@ const SENTENCE_KEYS: Record<NonNullable<VoiceBrand>, I18nKey> = {
safari: "assistant.voice.engine.apple",
};
const ALLOW_KEYS: Record<NonNullable<VoiceBrand>, I18nKey> = {
chrome: "assistant.voice.allow.chrome",
edge: "assistant.voice.allow.edge",
safari: "assistant.voice.allow.safari",
};
const BRAND_SHORT: Record<NonNullable<VoiceBrand>, 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<VoiceStatus>;
/** The mic renders. */
show: ComputedRef<boolean>;
mic: ComputedRef<VoiceMic>;
/** Mic off for now, but still there. */
offline: ComputedRef<boolean>;
mine: ComputedRef<boolean>;
@@ -217,6 +215,8 @@ export interface VoiceComposer {
waiting: ComputedRef<boolean>;
denied: ComputedRef<boolean>;
helperLine: ComputedRef<I18nText | null>;
/** The helper line reports a failure, not a state. */
failure: ComputedRef<boolean>;
action: ComputedRef<"retry" | "how" | null>;
elapsedMs: ComputedRef<number>;
level: ComputedRef<number>;
@@ -227,7 +227,8 @@ export interface VoiceComposer {
shown(text: string): string;
primerOpen: Ref<boolean>;
sentence: ComputedRef<I18nText | null>;
allowSteps: ComputedRef<I18nText>;
/** 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<VoiceMic>(() => {
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<I18nText | null>(() => {
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<I18nText | null>(() => {
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<I18nText | null>(() => {
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";
}
@@ -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 <http://www.gnu.org/licenses/>.
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<number>;
narrow: ComputedRef<boolean>;
tight: ComputedRef<boolean>;
wide: ComputedRef<boolean>;
}
/** Follows the composer's own width, since a window or a sidebar is narrow on any screen. */
export function useComposerWidth(root: Ref<HTMLElement | null>): 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;
}
@@ -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<HTMLButtonElement>('[data-test="assistant-context-time-page"]')!
.disabled,
).toBe(true);
expect(
picker().querySelector<HTMLButtonElement>('[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<HTMLElement>('[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 });
@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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 <http://www.gnu.org/licenses/>.
<div
ref="body"
:data-test="chip.kind === 'time' ? 'assistant-context-time-picker' : 'assistant-chip-popover'"
data-test="assistant-chip-popover"
data-dd-privacy="mask"
class="flex min-w-0 flex-col gap-3"
>
@@ -42,48 +42,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</header>
<template v-if="chip.kind === 'time'">
<OText
v-if="window?.source === 'default'"
variant="meta"
data-test="assistant-context-time-default-note"
>
{{ t("assistant.context.time.defaultNote") }}
</OText>
<ODateTimeRange
v-bind="pickerValue"
:timezone="timezone"
show-timezone
:auto-apply="false"
tabindex="0"
data-test="assistant-context-time-range"
@change="onPick"
/>
<div class="flex flex-wrap justify-end gap-2">
<OButton
variant="ghost"
size="sm"
class="max-md:min-h-11 pointer-coarse:min-h-11"
:disabled="window?.source !== 'custom'"
data-test="assistant-context-time-page"
@click="emit('apply-time', null)"
>
{{ t("assistant.context.time.usePage") }}
</OButton>
<OButton
variant="primary"
size="sm"
class="max-md:min-h-11 pointer-coarse:min-h-11"
:disabled="!picked"
data-test="assistant-context-time-apply"
@click="picked && emit('apply-time', picked)"
>
{{ t("assistant.context.time.apply") }}
</OButton>
</div>
</template>
<template v-else>
<template v-if="chip.kind !== 'time'">
<img
v-if="chip.kind === 'image' && imageSrc"
:src="imageSrc"
@@ -176,7 +135,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script lang="ts">
import type { ComposerState, ContextChip, TimeWindow } from "@/composables/assistant";
import type { ContextChip } from "@/composables/assistant";
export interface AssistantChipPopoverProps {
chip: ContextChip;
@@ -184,8 +143,6 @@ export interface AssistantChipPopoverProps {
readonly?: boolean;
/** `data:` URL of an image chip. */
imageSrc?: string;
window?: TimeWindow;
timezone?: string;
/** "Open in Logs": shown when the chip names something the explorer can open. */
openLabel?: I18nText | null;
}
@@ -196,8 +153,6 @@ export interface AssistantChipPopoverEmits {
(e: "remove-row", index: number): void;
/** `true` sends `_o2_id` and `_original` too. */
(e: "trim", includeInternal: boolean): void;
/** `null` goes back to the page's time. */
(e: "apply-time", range: ComposerState["timeOverride"]): void;
/** Tab past the last control: the tray moves focus to the control after the chip. */
(e: "tab-out"): void;
/** Open in {page}: the tray opens it through the explorer applier. */
@@ -211,51 +166,25 @@ export interface AssistantChipPopoverEmits {
import { computed, nextTick, ref, watch } from "vue";
import { assistantGlyph, chipGlyph } from "@/components/ai-assistant/common/glyphs";
import { INTERNAL_FIELDS, periodMs } from "@/composables/assistant";
import { INTERNAL_FIELDS } from "@/composables/assistant";
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 ODateTimeRange from "@/lib/forms/DateTimeRange/ODateTimeRange.vue";
import type {
DateTimeRangeValue,
RelativeUnit,
} from "@/lib/forms/DateTimeRange/ODateTimeRange.types";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { convertDateToTimestamp, formatTimestampInTimezone } from "@/utils/date";
import { formatCount, formatOrgTime } from "@/utils/formatters";
import AssistantFieldList from "./AssistantFieldList.vue";
const UNIT_OF: Record<string, RelativeUnit> = {
s: "seconds",
m: "minutes",
h: "hours",
d: "days",
w: "weeks",
M: "months",
};
const TABBABLE =
"button:not([disabled]), [href], input:not([disabled]), [role='switch'], [tabindex='0']";
const LETTER_OF: Record<RelativeUnit, string> = {
seconds: "s",
minutes: "m",
hours: "h",
days: "d",
weeks: "w",
months: "M",
};
const props = withDefaults(defineProps<AssistantChipPopoverProps>(), {
readonly: false,
imageSrc: undefined,
window: undefined,
timezone: "UTC",
openLabel: null,
});
@@ -264,27 +193,16 @@ const emit = defineEmits<AssistantChipPopoverEmits>();
defineSlots<{ anchor: () => unknown }>();
const { t } = useI18nTyped();
const picked = ref<ComposerState["timeOverride"]>(null);
const body = ref<HTMLElement | null>(null);
watch(
() => props.open,
(open) => {
if (open) picked.value = null;
},
);
// The body mounts with the open popover; focus then lands on the first action (the picker for the time chip), not a scroll box (CT-03 AC 5).
// The body mounts with the open popover; focus then lands on the first action, not a scroll box.
watch(body, async (el, _old, onCleanup) => {
if (!el) return;
el.addEventListener("keydown", onTab);
onCleanup(() => el.removeEventListener("keydown", onTab));
await nextTick();
const first =
el.querySelector<HTMLElement>('[data-test="datetimerange-trigger"]') ??
el.querySelector("[data-chip-actions]")?.querySelector<HTMLElement>(TABBABLE) ??
tabbables()[0];
el.querySelector("[data-chip-actions]")?.querySelector<HTMLElement>(TABBABLE) ?? tabbables()[0];
first?.focus();
});
@@ -346,52 +264,6 @@ const provenance = computed<I18nText>(() => {
return raw(parts.join(" · "));
});
// The picker opens on the window the chip shows: relative stays relative, a range shows its ends in the org zone.
const pickerValue = computed(() => {
const window = props.window;
const match = window?.period ? /^(\d+)([smhdwM])$/.exec(window.period) : null;
if (window && match)
return {
mode: "relative" as const,
relativeAmount: Number(match[1]),
relativeUnit: UNIT_OF[match[2]],
};
if (!window)
return { mode: "relative" as const, relativeAmount: 15, relativeUnit: "minutes" as const };
const at = (ms: number, format: string) =>
formatTimestampInTimezone(ms * 1000, format, props.timezone);
return {
mode: "absolute" as const,
startDate: at(window.from, "YYYY-MM-DD"),
startTime: at(window.from, "HH:mm"),
endDate: at(window.to, "YYYY-MM-DD"),
endTime: at(window.to, "HH:mm"),
};
});
function onPick(value: DateTimeRangeValue): void {
if (value.type === "relative") {
const period = `${value.amount}${LETTER_OF[value.unit]}`;
const to = Date.now();
const from = to - periodMs(period);
picked.value = { from, to, period };
return;
}
const ms = (date: string, time: string) => {
const [year, month, day] = date.split("-");
return (
convertDateToTimestamp(
`${day}-${month}-${year}`,
time || "00:00",
value.timezone || props.timezone,
).timestamp / 1000
);
};
const from = ms(value.startDate, value.startTime);
const to = ms(value.endDate, value.endTime);
picked.value = from > 0 && to > from ? { from, to } : null;
}
// Characters of the attached value as the assistant reads it, not its UTF-8 bytes.
function characterCount(data: unknown): number {
if (data === undefined || data === null) return 0;
@@ -35,10 +35,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click="onClick"
@keydown="onKeydown"
>
<span v-if="compact" class="min-w-0 text-start break-words whitespace-normal">{{
shortLabel ?? label
}}</span>
<OTruncatedText v-else class="min-w-0">{{ label }}</OTruncatedText>
<OTruncatedText class="min-w-0">{{ compact ? (shortLabel ?? label) : label }}</OTruncatedText>
<span v-if="secondary && !compact" class="shrink-0 font-normal" aria-hidden="true"
>{{ dot }} {{ secondary }}</span
>
@@ -46,7 +43,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
v-if="removable"
variant="ghost"
size="icon-xs"
size="icon-chip"
class="max-md:size-11 pointer-coarse:size-11"
:aria-label="t('assistant.context.remove', { label })"
data-test="assistant-context-chip-remove"
@@ -69,7 +66,7 @@ export interface AssistantContextChipProps {
readonly?: boolean;
/** Non-interactive (a share, a disabled surface). */
inert?: boolean;
/** Below md: the secondary goes to the name only, so the chip fits a phone row. */
/** In the control row: the secondary goes to the name only, so the chip fits one line. */
compact?: boolean;
/** Compact text only (the range, the stream name); the icon says the kind and the name keeps the full label (CT-01 AC 9). */
shortLabel?: I18nText;
@@ -85,10 +85,10 @@ async function openPopover(shared = false) {
}
describe("AssistantContextSeesPopover", () => {
it("lists Sent with your message and Not read by the assistant yet from the table (CT-04 AC 1–2)", async () => {
it("lists what is sent, untitled, and Not read by the assistant yet from the table (CT-04 AC 1–2)", async () => {
const { panel } = await openPopover();
const sent = panel()!.querySelector('[data-test="assistant-context-sees-sent"]')!;
expect(sent.textContent).toContain("Sent with your message");
expect(sent.textContent).not.toContain("Sent with your message");
expect(sent.textContent).toContain("Streams");
expect(sent.textContent).toContain("default (traces)");
const notRead = panel()!.querySelector('[data-test="assistant-context-sees-not_read"]')!;
@@ -114,7 +114,7 @@ describe("AssistantContextSeesPopover", () => {
const plain = await openPopover();
expect(
plain.panel()!.querySelector('[data-test="assistant-context-sees-disclosure"]')?.textContent,
).toBe("Attached data is sent to the assistant and saved with this chat.");
).toBe("Sent to the assistant and saved with this chat.");
document.body.innerHTML = "";
const shared = await openPopover(true);
expect(
@@ -21,7 +21,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
side="top"
:align="align"
:aria-label="t('assistant.context.title')"
content-class="w-[min(28rem,calc(100vw-1.5rem))] max-h-[min(32rem,70vh)] overflow-y-auto p-3"
:content-class="`${widthClass} max-h-[min(32rem,70vh)] overflow-y-auto p-3`"
@update:open="setOpen"
>
<template v-if="$slots.anchor" #anchor>
@@ -32,6 +32,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<AssistantIconButton
:label="t('assistant.context.title')"
:icon="assistantGlyph.info"
class="h-7! w-7!"
data-test="assistant-context-sees"
/>
</slot>
@@ -60,12 +61,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="flex flex-col gap-1.5"
:data-test="`assistant-context-sees-${group.id}`"
>
<OText variant="label" as="h3">{{ group.title }}</OText>
<OText v-if="group.id === 'not_read'" variant="label" as="h3">{{ group.title }}</OText>
<dl class="flex flex-col gap-1">
<div
v-for="row in group.rows"
:key="row.id"
class="grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)] items-center gap-2"
class="grid grid-cols-[10rem_minmax(0,1fr)] items-center gap-2 max-md:grid-cols-[minmax(0,8rem)_minmax(0,1fr)]"
data-test="assistant-context-sees-row"
:data-field="row.id"
>
@@ -84,7 +85,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<OText variant="meta" as="p" data-test="assistant-context-sees-disclosure">{{
context.disclosure.value
disclosure
}}</OText>
<OText
v-if="voice.sentence.value"
@@ -106,7 +107,11 @@ export interface AssistantContextSeesPopoverProps {
/** Controlled open state; omit to let the trigger own it. */
open?: boolean;
align?: "start" | "center" | "end";
/** Home names no streams: it has no page to read them from. */
hideStreams?: boolean;
}
const STREAMS_ROW = "selectedStreams";
</script>
<script setup lang="ts">
@@ -114,6 +119,7 @@ import { computed, nextTick, ref, useId, watch } from "vue";
import AssistantIconButton from "@/components/ai-assistant/common/AssistantIconButton.vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import { useComposerLayout } from "@/components/ai-assistant/composer/composerUi";
import { useVoiceAvailability } from "@/components/ai-assistant/composer/useAssistantVoice";
import { useAssistantContext, useAssistantStore } from "@/composables/assistant";
import OText from "@/lib/core/Typography/OText.vue";
@@ -125,6 +131,7 @@ import { useI18nTyped } from "@/types/i18n";
const props = withDefaults(defineProps<AssistantContextSeesPopoverProps>(), {
open: undefined,
align: "end",
hideStreams: false,
});
const emit = defineEmits<{ (e: "update:open", open: boolean): void }>();
@@ -132,20 +139,32 @@ const emit = defineEmits<{ (e: "update:open", open: boolean): void }>();
defineSlots<{ trigger?: () => unknown; anchor?: () => unknown }>();
const { t } = useI18nTyped();
const context = useAssistantContext(() => props.sid, { store: useAssistantStore() });
const store = useAssistantStore();
const context = useAssistantContext(() => props.sid, { store });
const voice = useVoiceAvailability();
const layout = useComposerLayout();
const headingId = `assistant-sees-${useId()}`;
const heading = ref<InstanceType<typeof OText> | null>(null);
const ownOpen = ref(false);
const isOpen = computed(() => props.open ?? ownOpen.value);
const disclosure = computed(() =>
store.state.sessions[props.sid ?? ""]?.share
? context.disclosure.value
: t("assistant.context.seesDisclosure"),
);
const widthClass = computed(() =>
layout.value.tight ? "w-[calc(20rem-0.375rem)]" : "w-[min(28rem,calc(100vw-1.5rem))]",
);
const groups = computed(() =>
(["sent", "not_read"] as const)
.map((id) => ({
id,
title: id === "sent" ? t("assistant.context.sent") : t("assistant.context.notRead"),
rows: context.sees.value.filter((row) => row.group === id),
rows: context.sees.value.filter(
(row) => row.group === id && !(props.hideStreams && row.id === STREAMS_ROW),
),
}))
.filter((group) => group.rows.length > 0),
);
@@ -17,17 +17,13 @@ import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ContextChip } from "@/composables/assistant";
import {
refreshPageContext,
resetAssistantContextForTests,
} from "@/composables/assistant/context/useAssistantContext";
import { contextRegistry } from "@/composables/contextProviders/registry";
import { trackAssistant } from "@/services/assistantAnalytics";
import { raw } from "@/types/i18n";
import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant";
import { composerUi, noteCap, resetComposerUiForTests } from "../composer/composerUi";
import AssistantContextTray from "./AssistantContextTray.vue";
import { composerUi, resetComposerUiForTests } from "../composer/composerUi";
import AssistantContextTray, { trayChipLimit } from "./AssistantContextTray.vue";
const { fake, breakpoint } = vi.hoisted(() => ({
fake: { current: null as any },
@@ -108,26 +104,6 @@ afterEach(() => {
});
describe("AssistantContextTray", () => {
it("carries the agent picker while the roster offers a choice", async () => {
const { wrapper } = await setup();
const picker = '[data-test="assistant-agent-picker"]';
expect(wrapper.find(picker).exists()).toBe(false);
fake.current.capability.value = {
...fake.current.capability.value,
agents: [
{ id: "o2-ai", pickable: true, available: true },
{ id: "sre", pickable: true, available: true },
],
};
await flushPromises();
expect(wrapper.find(picker).exists()).toBe(true);
});
it("drops its own padding inside the composer frame", async () => {
const { wrapper } = await setup({ framed: true });
expect(wrapper.get('[data-test="assistant-context-tray"]').classes()).not.toContain("px-3");
});
it("puts a pasted text chip back into the draft from its popover (D4 §3.9)", async () => {
const { wrapper, store, sid } = await setup();
store.setDraft(sid, { text: "why" });
@@ -150,40 +126,28 @@ describe("AssistantContextTray", () => {
expect(store.state.sessions[sid].composer.chips).toEqual([]);
});
it("is a named group: time first, then page chips, then staged chips, then ghosts (`40` §2.1)", async () => {
it("is a named group: time first, then only the chips the user added (§1)", async () => {
const { wrapper, store, sid, labels } = await setup();
const group = wrapper.get('[data-test="assistant-context-tray"]');
expect(group.attributes()).toMatchObject({
role: "group",
"aria-label": "Context for your next message",
});
expect(labels()).toEqual([]);
expect(wrapper.get('[data-test="assistant-context-chip-time"]').text()).toBe("Last 15 min");
expect(wrapper.get('[data-test="assistant-context-chip-time"]').attributes("aria-label")).toBe(
"Time range, Last 15 min, from this page",
);
store.addChip(sid, ROW);
store.setDraft(sid, { removedPageKeys: ["currentSQLQuery"] });
await flushPromises();
expect(labels()).toEqual([
"Time · last 15 min · page",
"Stream · default · logs",
"Log row · cart · 13:57",
"SQL · default · Add back",
]);
expect(labels()).toEqual(["Log row · cart · 13:57"]);
});
it("removes a page chip into a ghost, announces it and fires the event (CT-01 AC 7)", async () => {
const { wrapper, store, sid } = await setup();
const removes = wrapper.findAll('[data-test="assistant-context-chip-remove"]');
await removes[0].trigger("click");
await flushPromises();
expect(store.state.sessions[sid].composer.removedPageKeys).toEqual(["selectedStreams"]);
expect(composerUi(sid).status?.text).toBe("Stream · default removed from context");
expect(trackAssistant).toHaveBeenCalledWith("ai_context_changed", {
action: "remove",
kind: "stream",
count: 1,
bytes: expect.any(Number),
});
const ghost = wrapper.findAll('[data-test="assistant-context-chip-button"]').at(-1)!;
await ghost.trigger("click");
expect(store.state.sessions[sid].composer.removedPageKeys).toEqual([]);
it("lands on no shell prop as a DOM attribute", async () => {
const { wrapper } = await setup({ collapsed: true, framed: true, visible: true });
const root = wrapper.get('[data-test="assistant-context-tray"]');
expect(root.attributes("framed")).toBeUndefined();
expect(root.attributes("visible")).toBeUndefined();
});
it("removes a staged chip with Delete and moves focus to the next chip (CT-08 AC 4)", async () => {
@@ -192,7 +156,8 @@ describe("AssistantContextTray", () => {
store.addChip(sid, { ...ROW, id: "row-2", label: raw("Log row · cart · 13:58") });
await flushPromises();
const buttons = () => wrapper.findAll('[data-test="assistant-context-chip-button"]');
await buttons()[3].trigger("keydown", { key: "Delete" });
await wrapper.get('[data-test="assistant-context-more"]').trigger("click");
await buttons()[0].trigger("keydown", { key: "Delete" });
await flushPromises();
expect(store.state.sessions[sid].composer.chips.map((c: ContextChip) => c.id)).toEqual([
"row-2",
@@ -200,79 +165,33 @@ describe("AssistantContextTray", () => {
expect(document.activeElement?.textContent).toContain("13:58");
});
it("collapses to Time + 1 + more below md and expands in place (CT-01 AC 9)", async () => {
breakpoint.md = false;
it("shows one chip then +N at 27 rem, all behind +N when narrow, and expands in place", async () => {
const { wrapper, store, sid, labels } = await setup();
store.addChip(sid, ROW);
store.addChip(sid, { ...ROW, id: "row-2", label: raw("Log row · cart · 13:58") });
await flushPromises();
expect(labels()).toHaveLength(2);
expect(labels()).toEqual(["Log row · cart · 13:57"]);
const more = wrapper.get('[data-test="assistant-context-more"]');
expect(more.text()).toBe("+2");
expect(more.text()).toBe("+1");
expect(more.attributes("aria-expanded")).toBe("false");
await more.trigger("click");
expect(labels()).toHaveLength(4);
expect(wrapper.get('[data-test="assistant-context-more"]').attributes("aria-expanded")).toBe(
"true",
);
expect(labels()).toHaveLength(2);
const fewer = wrapper.get('[data-test="assistant-context-more"]');
expect(fewer.attributes("aria-expanded")).toBe("true");
expect(fewer.attributes("aria-label")).toBe("Show fewer context chips");
expect(trayChipLimit({ narrow: true, wide: false })).toBe(0);
expect(trayChipLimit({ narrow: false, wide: true })).toBe(3);
});
it("states a cap under the tray, never as a toast (CT-02 AC 7)", async () => {
const { wrapper, sid } = await setup();
noteCap(sid, raw("Up to 20 rows or 32 KB of attached data per message"));
await flushPromises();
expect(wrapper.get('[data-test="assistant-context-cap"]').text()).toBe(
"Up to 20 rows or 32 KB of attached data per message",
);
});
it("offers a fresh chat when a door staged into a recent thread, moving the whole staged payload (`40` §2.4, S8)", async () => {
const { wrapper, store, sid } = await setup();
store.setTitle(sid, raw("Checkout 5xx"), "renamed");
store.state.sessions[sid].messages.push({ role: "user", content: raw("hi") });
store.state.sessions[sid].turnMeta.startedAt = Date.now();
store.addChip(sid, ROW);
store.addImage(sid, { data: "aGk=", mimeType: "image/png", filename: "shot.png", size: 2 });
store.setDraft(sid, {
timeOverride: { from: 0, to: 1, period: "7d" },
removedPageKeys: ["sqlQuery"],
});
await wrapper.setProps({ sid: null });
await wrapper.setProps({ sid });
await flushPromises();
expect(wrapper.get('[data-test="assistant-context-thread"]').text()).toContain(
"In: Checkout 5xx",
);
await wrapper.get('[data-test="assistant-context-start-new"]').trigger("click");
const fresh = store.state.shell.activeSid;
expect(fresh).not.toBe(sid);
expect(store.state.sessions[fresh].composer.chips.map((c: ContextChip) => c.id)).toEqual([
"row-1",
]);
expect(store.state.sessions[fresh].composer).toMatchObject({
timeOverride: { period: "7d" },
removedPageKeys: ["sqlQuery"],
});
expect(store.state.sessions[fresh].composer.images.map((i) => i.filename)).toEqual([
"shot.png",
]);
expect(store.state.sessions[sid].composer).toMatchObject({
chips: [],
images: [],
timeOverride: null,
removedPageKeys: [],
});
});
it("has the What the assistant sees trigger at its trailing edge (`40` §3)", async () => {
const { wrapper } = await setup();
expect(wrapper.find('[data-test="assistant-context-sees"]').exists()).toBe(true);
});
it("never shows images in the tray; the composer strip is their one surface (U1)", async () => {
const { store, sid, labels } = await setup();
it("carries images as chips, removed like any other (U1)", async () => {
const { store, sid, labels, wrapper } = await setup();
store.addImage(sid, { data: "aGk=", mimeType: "image/png", filename: "shot.png", size: 2 });
await flushPromises();
expect(labels().some((label) => label.includes("shot.png"))).toBe(false);
expect(labels()).toEqual(["shot.png"]);
await wrapper.get('[data-test="assistant-context-chip-remove"]').trigger("click");
await flushPromises();
expect(store.state.sessions[sid].composer.images).toEqual([]);
expect(composerUi(sid).status?.text).toBe("shot.png removed from context");
});
it("recounts a log rows chip's label when a row is removed", async () => {
@@ -298,7 +217,7 @@ describe("AssistantContextTray", () => {
expect(String(chip?.label)).toBe("Log rows · 2 · cart");
});
it("returns focus to the chip when its popover closes by Escape, and after Add back (U2)", async () => {
it("returns focus to the chip when its popover closes by Escape (U2)", async () => {
const { wrapper, store, sid } = await setup();
store.addChip(sid, ROW);
await flushPromises();
@@ -320,95 +239,6 @@ describe("AssistantContextTray", () => {
);
await flushPromises();
expect(document.activeElement).toBe(rowChip().element);
await wrapper.findAll('[data-test="assistant-context-chip-remove"]')[0].trigger("click");
await flushPromises();
const ghost = wrapper.findAll('[data-test="assistant-context-chip-button"]').at(-1)!;
expect(ghost.attributes("aria-haspopup")).toBeUndefined();
await ghost.trigger("click");
await flushPromises();
expect(document.activeElement?.textContent).toContain("Stream · default");
});
it("keeps the phone tray to one row: the icon names the kind, the full name stays accessible, names wrap, Fewer chips only when it hides something (U4, U13, V5)", async () => {
breakpoint.md = false;
const { wrapper, store, sid, labels } = await setup();
expect(labels()).toEqual(["15 min", "default"]);
const buttons = wrapper.findAll('[data-test="assistant-context-chip-button"]');
expect(buttons[0].attributes("aria-label")).toContain("Time · 15 min");
expect(buttons[1].attributes("aria-label")).toContain("default");
expect(
wrapper.find('[data-test="assistant-context-chip-stream"] .whitespace-normal').exists(),
).toBe(true);
store.setDraft(sid, { removedPageKeys: ["currentSQLQuery", "selectedStreams"] });
await flushPromises();
expect(wrapper.find('[data-test="assistant-context-more"]').text()).toBe("+2");
store.setDraft(sid, { removedPageKeys: [] });
store.addChip(sid, ROW);
await flushPromises();
const more = wrapper.get('[data-test="assistant-context-more"]');
expect(more.classes()).toContain("pointer-coarse:min-w-11");
await more.trigger("click");
const fewer = wrapper.get('[data-test="assistant-context-more"]');
expect(fewer.text()).toBe("Fewer chips");
expect(fewer.attributes("aria-label")).toBe("Show fewer context chips");
});
it("shows below md a name that sits inside the chip's name, never an id or the query (F12)", async () => {
breakpoint.md = false;
const { wrapper, store, sid } = await setup();
const mention = (kind: ContextChip["kind"], id: string, label: string, short: string) =>
store.addChip(sid, {
id: `mention:${kind}:${id}`,
kind,
label: raw(label),
short: raw(short),
ref: { type: kind, id, org: "default" },
sizeBytes: 1,
provenance: "mention",
sent: "whitelist",
});
mention("alert", "a1", "Alert · checkout-latency", "checkout-latency");
mention("panel", "d1", "Dashboard · Checkout", "Checkout");
store.addChip(sid, ROW);
await flushPromises();
await wrapper.get('[data-test="assistant-context-more"]').trigger("click");
const shown = wrapper.findAll('[data-test="assistant-context-chip-button"]').map((button) => ({
text: button.text().trim(),
name: button.attributes("aria-label") ?? "",
}));
expect(shown.map((chip) => chip.text)).toEqual([
"15 min",
"default",
"default",
"checkout-latency",
"Checkout",
"Log row · cart · 13:57",
]);
for (const chip of shown) expect(chip.name).toContain(chip.text);
expect(shown.some((chip) => /SELECT|\ba1\b|\bd1\b/.test(chip.text))).toBe(false);
});
it("never announces the page filling in its own fields as it loads, only later changes (CT-01 AC 12)", async () => {
resetAssistantContextForTests();
let page: Record<string, unknown> = { ...LOGS, selectedStreams: [] };
contextRegistry.register("logs", { getContext: () => page });
const now = vi.spyOn(Date, "now");
try {
const start = 1_800_000_000_000;
now.mockReturnValue(start);
const { sid } = await setup();
page = { ...LOGS };
refreshPageContext();
await flushPromises();
expect(composerUi(sid).status).toBeNull();
now.mockReturnValue(start + 3_500);
page = { ...LOGS, selectedStreams: ["default"], currentVRLQuery: ".x = 1" };
refreshPageContext();
await flushPromises();
expect(String(composerUi(sid).status?.text)).toBe("VRL · 1 line added to context");
} finally {
now.mockRestore();
}
});
describe("Run analysis on an incident chip", () => {
@@ -21,98 +21,64 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:aria-label="t('assistant.context.trayLabel')"
data-test="assistant-context-tray"
data-dd-privacy="mask"
:data-collapsed="compact && !expanded ? '' : undefined"
:class="['flex shrink-0 flex-col gap-1', framed ? '' : 'px-3 pt-2']"
:data-collapsed="hiddenCount > 0 ? '' : undefined"
:class="['flex min-w-0 items-center gap-0.5', expanded ? 'flex-wrap' : '']"
>
<div class="flex min-w-0 flex-wrap items-start gap-1">
<ul class="flex min-w-0 flex-[1_1_auto] flex-wrap items-center gap-1.5">
<li v-for="chip in shownChips" :key="chip.id" class="max-w-full min-w-0 shrink-0">
<AssistantChipPopover
:chip="chip"
:open="openId === chip.id"
:window="context.window.value"
:timezone="timezone"
:open-label="openLabelOf(chip)"
@update:open="(open) => (openId = open ? chip.id : null)"
@open-source="openSource(chip)"
@paste-inline="pasteInline(chip)"
@remove="remove(chip)"
@remove-row="(index) => removeRow(chip, index)"
@trim="(include) => trim(chip, include)"
@apply-time="applyTime"
@tab-out="tabOut(chip.id)"
>
<template #anchor>
<AssistantContextChip
:chip="chip"
:compact="!mdUp"
:short-label="mdUp ? undefined : shortLabelOf(chip)"
:expanded="openId === chip.id"
@open="openChip(chip)"
@remove="remove(chip)"
@move="(direction) => move(chip.id, direction)"
/>
</template>
</AssistantChipPopover>
</li>
<li v-for="chip in shownGhosts" :key="chip.id" class="max-w-full min-w-0 shrink-0">
<AssistantContextChip
:chip="chip"
state="ghost"
:popup="false"
@open="addBack(chip)"
@move="(direction) => move(chip.id, direction)"
/>
</li>
<li v-if="hiddenCount > 0 || (expanded && collapsible)" class="shrink-0">
<OButton
variant="ghost"
size="sm"
class="max-md:min-h-11 max-md:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11"
:aria-expanded="expanded"
:aria-label="
expanded
? t('assistant.context.fewerName')
: t('assistant.context.moreName', { count: hiddenCount }, hiddenCount)
"
data-test="assistant-context-more"
@click="expanded = !expanded"
>
{{
expanded
? t("assistant.context.fewer")
: t("assistant.context.more", { count: hiddenCount })
}}
</OButton>
</li>
</ul>
<div class="ms-auto flex min-w-0 shrink-0 items-center gap-1">
<AssistantAgentPicker :sid="sid" />
<AssistantContextSeesPopover :sid="sid" />
</div>
</div>
<p
v-if="threadTitle"
class="flex min-w-0 flex-wrap items-center gap-x-1 text-xs"
data-test="assistant-context-thread"
<AssistantTimePrefix
:window="context.window.value"
:page-window="pageWindow"
:timezone="timezone"
@change="applyTime"
/>
<ul v-if="shownChips.length > 0" class="flex min-w-0 flex-wrap items-center gap-0.5">
<li v-for="chip in shownChips" :key="chip.id" class="max-w-40 min-w-0">
<AssistantChipPopover
:chip="chip"
:open="openId === chip.id"
:image-src="imageSrcOf(chip)"
:open-label="openLabelOf(chip)"
@update:open="(open) => (openId = open ? chip.id : null)"
@open-source="openSource(chip)"
@paste-inline="pasteInline(chip)"
@remove="remove(chip)"
@remove-row="(index) => removeRow(chip, index)"
@trim="(include) => trim(chip, include)"
@tab-out="tabOut(chip.id)"
>
<template #anchor>
<AssistantContextChip
:chip="chip"
compact
:short-label="chip.short"
:expanded="openId === chip.id"
@open="openChip(chip)"
@remove="remove(chip)"
@move="(direction) => move(chip.id, direction)"
/>
</template>
</AssistantChipPopover>
</li>
</ul>
<OButton
v-if="!layout.yieldChips && (hiddenCount > 0 || expanded)"
variant="ghost"
size="chip"
:class="['text-text-secondary min-w-6 shrink-0 justify-center', TOUCH_TARGET_CLASS]"
:aria-expanded="expanded"
:aria-label="
expanded
? t('assistant.context.fewerName')
: t('assistant.context.moreName', { count: hiddenCount }, hiddenCount)
"
data-test="assistant-context-more"
@click="expanded = !expanded"
>
<OTruncatedText class="text-text-secondary min-w-0">{{
t("assistant.context.inChat", { title: threadTitle })
}}</OTruncatedText>
<OButton
variant="ghost"
size="sm"
class="max-md:min-h-11 pointer-coarse:min-h-11"
data-test="assistant-context-start-new"
@click="startNew"
>
{{ t("assistant.context.startNew") }}
</OButton>
</p>
<OText v-if="capLine" variant="meta" as="p" data-test="assistant-context-cap">{{
capLine
}}</OText>
{{
expanded
? t("assistant.context.fewer")
: t("assistant.context.more", { count: hiddenCount })
}}
</OButton>
</div>
</template>
@@ -121,73 +87,59 @@ import type { Sid } from "@/composables/assistant";
export interface AssistantContextTrayProps {
sid: Sid | null;
/** Docked containers, the sheet and a sidebar at 24 rem or narrower show `Time` + N + `+{count}`. */
collapsed?: boolean;
/** Only the container the user sees announces and reacts to page changes. */
visible?: boolean;
/** Inside the composer frame, which pads it (D4 §3.1). */
framed?: boolean;
}
/** Chips shown before `+N`; the composer's width decides which. */
export function trayChipLimit(layout: { narrow: boolean; wide: boolean }): number {
if (layout.narrow) return 0;
return layout.wide ? 3 : 1;
}
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { computed, nextTick, ref } from "vue";
import { useRoute } from "vue-router";
import { useStore } from "vuex";
import {
announceComposer,
CAP_NOTICE_MS,
composerUi,
moveDraft,
} from "@/components/ai-assistant/composer/composerUi";
import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import { announceComposer, useComposerLayout } from "@/components/ai-assistant/composer/composerUi";
import { explorerLabel } from "@/components/ai-assistant/evidence/evidenceText";
import {
byteSize,
timeRangeShortText,
timeWindowOf,
useAssistantContext,
useAssistantPlacement,
useAssistantStore,
type ComposerState,
type ContextChip,
} from "@/composables/assistant";
import useBreakpoint from "@/composables/useBreakpoint";
import OButton from "@/lib/core/Button/OButton.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { trackAssistant } from "@/services/assistantAnalytics";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { formatBytes } from "@/utils/formatters";
import { chipOpenQuery } from "@/composables/assistant/pure";
import { explorerOf } from "@/utils/assistant/explorerRoutes";
import AssistantAgentPicker from "@/components/ai-assistant/agents/AssistantAgentPicker.vue";
import AssistantChipPopover from "./AssistantChipPopover.vue";
import AssistantContextChip from "./AssistantContextChip.vue";
import AssistantContextSeesPopover from "./AssistantContextSeesPopover.vue";
import AssistantTimePrefix from "./AssistantTimePrefix.vue";
const THREAD_WINDOW_MS = 30 * 60_000;
// The shell still binds the pre-redesign layout props, which would land on the root as attributes.
defineOptions({ inheritAttrs: false });
// A page that just loaded fills its fields in over its first reads; that is not a change the user made.
const PAGE_SETTLE_MS = 3_000;
const props = withDefaults(defineProps<AssistantContextTrayProps>(), {
collapsed: false,
visible: true,
framed: false,
});
const props = defineProps<AssistantContextTrayProps>();
const { t } = useI18nTyped();
const store = useAssistantStore();
const route = useRoute();
const vuex = useStore();
const { mdUp } = useBreakpoint();
const context = useAssistantContext(() => props.sid, { store });
const layout = useComposerLayout();
const placement = useAssistantPlacement(store);
const root = ref<HTMLElement | null>(null);
const openId = ref<string | null>(null);
const expanded = ref(false);
const now = ref(Date.now());
let capTimer: ReturnType<typeof setTimeout> | null = null;
// Another user+org's chat is never shown or edited here (D21, F8).
const session = computed(() => {
@@ -195,77 +147,37 @@ const session = computed(() => {
return found && found.userOrgKey === store.state.userOrgKey ? found : null;
});
const timezone = computed<string>(() => vuex.state.timezone || "UTC");
const compact = computed(() => !mdUp.value || props.collapsed);
const limit = computed(() => (mdUp.value ? 3 : 2));
const allChips = computed(() =>
context.chips.value.map((chip) =>
!mdUp.value && chip.kind === "time"
? {
...chip,
label: t("assistant.context.time.page", {
range: timeRangeShortText(context.window.value),
}),
}
: chip,
),
// The page's own window, whatever the draft overrides it with; a page with no range offers no "Page time".
const pageWindow = computed(() => {
const own = timeWindowOf(context.page.value, null);
return own.source === "page" ? own : null;
});
// Page context shows in the `i` and the placeholder; chips are only what the user added (composer §1).
const imageChips = computed<ContextChip[]>(() =>
(session.value?.composer.images ?? []).map((image) => ({
id: image.id ?? image.filename,
kind: "image",
label: raw(image.filename),
secondary: raw(formatBytes(image.size)),
sizeBytes: image.size,
provenance: "attach",
sent: "serialized",
})),
);
const collapsible = computed(
() => compact.value && allChips.value.length + context.removed.value.length > limit.value,
);
const shownChips = computed(() =>
compact.value && !expanded.value ? allChips.value.slice(0, limit.value) : allChips.value,
);
const shownGhosts = computed(() => (compact.value && !expanded.value ? [] : context.removed.value));
const allChips = computed(() => [
...context.chips.value.filter((chip) => chip.kind !== "time" && chip.provenance !== "page"),
...imageChips.value,
]);
const shownChips = computed(() => {
if (expanded.value) return allChips.value;
return layout.value.yieldChips ? [] : allChips.value.slice(0, trayChipLimit(layout.value));
});
const hiddenCount = computed(() =>
compact.value && !expanded.value
? allChips.value.length - shownChips.value.length + context.removed.value.length
: 0,
expanded.value ? 0 : allChips.value.length - shownChips.value.length,
);
// A door's staging into a chat that answered in the last 30 min offers a fresh chat instead (`40` §2.4).
const threadTitle = computed(() => {
const current = session.value;
if (!current || current.messages.length === 0 || !current.title) return null;
if (!current.composer.chips.some((chip) => chip.provenance === "door")) return null;
const last = store.lastTurnAt(current.sid);
return last !== null && now.value - last < THREAD_WINDOW_MS ? current.title : null;
});
const capLine = computed(() => {
const cap = props.sid ? composerUi(props.sid).cap : null;
return cap && now.value - cap.at < CAP_NOTICE_MS ? cap.text : null;
});
watch(
() => (props.sid ? composerUi(props.sid).cap?.at : undefined),
(at) => {
if (at === undefined) return;
now.value = Date.now();
if (capTimer) clearTimeout(capTimer);
capTimer = setTimeout(() => (now.value = Date.now()), CAP_NOTICE_MS);
},
);
// A chip a page change adds is announced once; the page's own load and the time chip's ticks never are (CT-01 AC 12).
let settledAt = context.page.value ? 0 : null;
watch(
() => context.chips.value.filter((chip) => chip.provenance === "page" && chip.kind !== "time"),
(next, previous) => {
if (settledAt === null && context.page.value) settledAt = Date.now() + PAGE_SETTLE_MS;
if (settledAt === null || Date.now() < settledAt) return;
if (!props.visible || !props.sid || !previous) return;
const before = new Set(previous.map((chip) => chip.id));
for (const chip of next)
if (!before.has(chip.id))
announceComposer(props.sid, t("assistant.context.added", { label: chip.label }));
},
);
onBeforeUnmount(() => {
if (capTimer) clearTimeout(capTimer);
});
function changed(
action: "remove" | "add_back" | "swap_time" | "trim",
chip: ContextChip,
@@ -279,18 +191,13 @@ function openChip(chip: ContextChip): void {
if (openId.value) trackAssistant("ai_context_inspected", { target: "chip", kind: chip.kind });
}
function pageField(chip: ContextChip): string | null {
return chip.provenance === "page" && chip.id.startsWith("page:") ? chip.id.slice(5) : null;
}
async function remove(chip: ContextChip): Promise<void> {
const current = session.value;
const sid = current?.sid;
if (!sid || !current) return;
const order = [...allChips.value, ...context.removed.value].map((c) => c.id);
const order = allChips.value.map((c) => c.id);
const next = order[order.indexOf(chip.id) + 1] ?? null;
const field = pageField(chip);
if (field) store.setDraft(sid, { removedPageKeys: [...current.composer.removedPageKeys, field] });
if (chip.kind === "image") store.removeImage(sid, chip.id);
else store.removeChip(sid, chip.id);
openId.value = null;
announceComposer(sid, t("assistant.context.removed", { label: chip.label }));
@@ -310,27 +217,16 @@ function pasteInline(chip: ContextChip): void {
changed("remove", chip);
}
function addBack(chip: ContextChip): void {
const sid = session.value?.sid;
if (!sid) return;
const field = chip.id.replace(/^ghost:/, "");
store.addBackChip(sid, field);
announceComposer(sid, t("assistant.context.added", { label: chip.label }));
changed("add_back", chip);
// The ghost unmounts under the focused button, so focus follows the chip it became (U2).
void nextTick(() => focusChip(`page:${field}`));
}
// Below md the icon names the kind, so the chip shows the range or the name the chip was built with, never an id (CT-01 AC 9).
function shortLabelOf(chip: ContextChip): I18nText | undefined {
return chip.kind === "time" ? raw(timeRangeShortText(context.window.value)) : chip.short;
function imageSrcOf(chip: ContextChip): string | undefined {
if (chip.kind !== "image") return undefined;
const image = session.value?.composer.images.find((row) => (row.id ?? row.filename) === chip.id);
return image ? `data:${image.mimeType};base64,${image.data}` : undefined;
}
// Tab past the popover's last control lands on the control after its chip (APG non-modal popover, U3).
async function tabOut(id: string): Promise<void> {
await nextTick();
const button =
chipButtons()[[...shownChips.value, ...shownGhosts.value].findIndex((c) => c.id === id)];
const button = chipButtons()[shownChips.value.findIndex((c) => c.id === id)];
const tabbable = Array.from(
root.value?.querySelectorAll<HTMLElement>("button:not([disabled]), [tabindex='0']") ?? [],
);
@@ -338,11 +234,21 @@ async function tabOut(id: string): Promise<void> {
(next ?? button)?.focus();
}
// A time picked before the first keystroke starts the chat, as a pick of an agent does.
function applyTime(range: ComposerState["timeOverride"]): void {
const sid = session.value?.sid;
const sid =
session.value?.sid ??
(store.state.ready
? store.createSession({
origin: {
entryKind: "empty_state",
objectKind: "none",
container: placement.container.value ?? "sidebar",
},
})
: null);
if (!sid) return;
store.setTimeOverride(sid, range);
openId.value = null;
changed("swap_time", context.timeChip.value, 0);
}
@@ -389,14 +295,6 @@ function logRowsLabel(chip: ContextChip, count: number): I18nText {
: t("assistant.context.chip.logRowsPlain", { count }, count);
}
// The whole staged payload moves, so the new chat sends exactly what this one would have (S8).
function startNew(): void {
const current = session.value;
if (!current) return;
const fresh = store.createSession({ origin: { ...current.origin }, thread: "new" });
moveDraft(store, current.sid, fresh);
}
function chipButtons(): HTMLElement[] {
return Array.from(
root.value?.querySelectorAll<HTMLElement>('[data-test="assistant-context-chip-button"]') ?? [],
@@ -404,13 +302,13 @@ function chipButtons(): HTMLElement[] {
}
function move(id: string, direction: -1 | 1): void {
const order = [...shownChips.value, ...shownGhosts.value].map((chip) => chip.id);
const order = shownChips.value.map((chip) => chip.id);
focusChip(order[order.indexOf(id) + direction] ?? null);
}
// Focus goes to the chip with this id, else the textbox, so a keyboard removal never strands focus (CT-08 AC 4).
function focusChip(id: string | null): void {
const order = [...shownChips.value, ...shownGhosts.value].map((chip) => chip.id);
const order = shownChips.value.map((chip) => chip.id);
const target = id ? chipButtons()[order.indexOf(id)] : undefined;
if (target) return target.focus();
root.value
@@ -27,6 +27,7 @@ import { streamKeys } from "@/services/stream.querykeys";
import AssistantMentionMenu, {
MENTION_GROUP_LIMIT,
MENTION_SEARCH_LIMIT,
type MentionOption,
} from "./AssistantMentionMenu.vue";
@@ -167,7 +168,7 @@ describe("AssistantMentionMenu: Agents group (MULTI-AGENT-02)", () => {
wrapper.vm.choose(row);
expect(wrapper.emitted("select")).toBeUndefined();
expect(store.state.sessions[sid].agentId).toBeUndefined();
expect(composerUi(sid).status?.text).toBe("Not available to your role");
expect(composerUi(sid).status?.text).toBe("Not available · Ask an admin");
});
});
@@ -211,7 +212,7 @@ describe("AssistantMentionMenu", () => {
expect(wrapper.emitted("select")?.[0]?.[0]).toMatchObject({ action: "image" });
});
it("lists the five groups as a listbox with the first option active (CT-05 AC 1, 9)", async () => {
it("lists the groups as a listbox with the first option active, and no time group (CT-05 AC 1, 9)", async () => {
const { wrapper, options } = await open();
const listbox = document.body.querySelector('[data-test="assistant-mention-menu"]')!;
expect(listbox.getAttribute("role")).toBe("listbox");
@@ -219,26 +220,33 @@ describe("AssistantMentionMenu", () => {
const groups = [...document.body.querySelectorAll('[role="group"]')].map((g) =>
g.getAttribute("aria-label"),
);
expect(groups).toEqual(["Time range", "Streams", "Dashboards", "Alerts", "Pipelines"]);
expect(groups).toEqual(["Streams", "Dashboards", "Alerts"]);
expect(options()[0].getAttribute("aria-selected")).toBe("true");
expect((wrapper.vm as unknown as { activeId: string }).activeId).toBe(options()[0].id);
expect(options()[0].getAttribute("aria-label")).toBe("Time range, last 15 min");
expect(options()[0].getAttribute("aria-label")).toContain("Streams, ");
});
it("filters across groups, at most 8 per group with Show more (CT-05 AC 2)", async () => {
it("filters across groups, a query showing more per group, with Show all (CT-05 AC 2)", async () => {
const first = await open("che");
const { options } = first;
expect(options().map((o) => o.textContent?.trim().split("\n")[0].trim())).toEqual([
expect.stringContaining("Stream · checkout"),
expect.stringContaining("Dashboard · Checkout"),
expect.stringContaining("Alert · checkout-latency"),
expect.stringContaining("checkout"),
expect.stringContaining("Checkout"),
expect.stringContaining("checkout-latency"),
]);
first.wrapper.unmount();
const all = await open("stream");
expect(all.options().filter((o) => o.dataset.kind === "stream")).toHaveLength(
MENTION_SEARCH_LIMIT,
);
expect(
document.body.querySelector('[data-test="assistant-mention-more"]')?.textContent,
).toContain("Show all");
all.wrapper.unmount();
const plain = await open();
expect(plain.options().filter((o) => o.dataset.kind === "stream")).toHaveLength(
MENTION_GROUP_LIMIT,
);
expect(document.body.querySelector('[data-test="assistant-mention-more"]')).not.toBeNull();
});
it("emits the chosen chip with the ids the wire reads (CT-05 AC 3, 12)", async () => {
@@ -266,14 +274,13 @@ describe("AssistantMentionMenu", () => {
}
expect(picked.find((chip) => chip.kind === "panel")?.short).toBe("Checkout");
expect(picked.find((chip) => chip.kind === "alert")?.short).toBe("checkout-latency");
expect(picked.find((chip) => chip.kind === "pipeline_node")?.short).toBe("ingest-k8s");
});
it("moves the highlight from the textbox and hands Enter the current option", async () => {
const { wrapper } = await open();
const vm = wrapper.vm as unknown as { move(d: number): void; current(): { id: string } | null };
vm.move(1);
expect(vm.current()?.id).toBe("time:1h");
expect(vm.current()?.id).toMatch(/^mention:stream:/);
vm.move(-2);
expect(vm.current()).not.toBeNull();
});
@@ -305,7 +312,7 @@ describe("AssistantMentionMenu", () => {
expect(options()[0].className).toContain("pointer-coarse:min-h-11");
});
it("puts Show more and Retry in the arrow cycle, acted on by Enter (CT-05 AC 2, 11)", async () => {
it("puts Show all and Retry in the arrow cycle, acted on by Enter (CT-05 AC 2, 11)", async () => {
const { wrapper } = await open("stream");
const vm = wrapper.vm as unknown as {
move(d: number): void;
@@ -318,7 +325,7 @@ describe("AssistantMentionMenu", () => {
await flushPromises();
expect(
document.body.querySelectorAll('[data-test="assistant-mention-option"][data-kind="stream"]'),
).toHaveLength(10);
).toHaveLength(9);
expect(wrapper.emitted("select")).toBeUndefined();
});
});
@@ -43,8 +43,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:aria-busy="group.state === 'loading' || undefined"
:data-test="`assistant-mention-group-${group.id}`"
>
<div class="text-text-secondary px-2 pt-2 pb-1 text-xs font-medium" aria-hidden="true">
{{ group.title }}
<div
class="text-text-secondary flex justify-between px-2 pt-2 pb-1 text-xs font-medium"
aria-hidden="true"
>
<span>{{ group.title }}</span>
<span v-if="group.total" class="tabular-nums" data-test="assistant-mention-group-count">{{
group.total
}}</span>
</div>
<div v-if="group.state === 'loading'" class="px-2 py-1" aria-hidden="true">
<OSkeleton type="text" class="h-5 w-2/3" />
@@ -65,7 +71,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
})
"
:class="[
'rounded-default flex min-h-8 flex-col justify-center px-2 py-1 text-sm max-md:min-h-11 pointer-coarse:min-h-11',
'rounded-default flex min-h-8 items-center gap-2 px-2 py-1 text-sm max-md:min-h-11 pointer-coarse:min-h-11',
item.disabled ? 'text-text-secondary' : 'cursor-pointer',
item.id === activeOptionId
? 'bg-dropdown-item-hover-bg text-text-heading'
@@ -87,10 +93,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click="choose(item)"
@keydown.enter.prevent="choose(item)"
>
<OTruncatedText>{{ item.label }}</OTruncatedText>
<OIcon
v-if="item.glyph"
:name="item.glyph"
size="xs"
class="text-text-secondary shrink-0"
/>
<OTruncatedText :class="['min-w-0 flex-1', item.code ? 'font-mono' : '']">{{
item.label
}}</OTruncatedText>
<span
v-if="item.secondary"
:class="['text-xs', item.id === activeOptionId ? '' : 'text-text-secondary']"
v-if="item.secondary && (item.agent || query)"
:class="['shrink-0 text-xs', item.id === activeOptionId ? '' : 'text-text-secondary']"
>{{ item.secondary }}</span
>
</div>
@@ -108,26 +122,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script lang="ts">
import type { AgentId, ChipKind, ContextChip } from "@/composables/assistant";
import type { IconName } from "@/lib/core/Icon/OIcon.types";
import type { I18nText } from "@/types/i18n";
/** One choice of the `@` menu; a time option carries a period, Show more and Retry carry an action (CT-05 AC 2, 4, 11). */
/** One choice of the `@` menu; Show all and Retry carry an action (CT-05 AC 2, 4, 11). */
export interface MentionOption {
id: string;
kind: ChipKind;
label: I18nText;
secondary?: I18nText;
chip?: ContextChip;
/** `null` is "Page time". */
period?: string | null;
action?: MentionAction;
groupId?: string;
/** No access: shown, never chosen. */
disabled?: boolean;
/** An Agents row: it sets who answers and adds no chip. */
agent?: AgentId;
glyph?: IconName;
/** Names read as identifiers, in the mono face. */
code?: boolean;
}
/** An action row of the menu: Show more and Retry act in place, Image hands over to the file picker. */
/** An action row of the menu: Show all and Retry act in place, Image hands over to the file picker. */
export type MentionAction = "more" | "retry" | "image" | "agent";
export interface AssistantMentionMenuProps {
@@ -142,10 +158,11 @@ export interface AssistantMentionMenuProps {
withImage?: boolean;
}
/** At most this many options per group before "Show more" (CT-05 AC 2). */
export const MENTION_GROUP_LIMIT = 8;
/** At most this many options per group before "Show all", with no query typed. */
export const MENTION_GROUP_LIMIT = 2;
const TIME_PERIODS = ["15m", "1h", "24h", "7d"] as const;
/** With a query the list is short already, so a group shows more of what matched. */
export const MENTION_SEARCH_LIMIT = 8;
const STREAM_TYPES = ["logs", "metrics", "traces"] as const;
</script>
@@ -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<AssistantMentionMenuProps>(), {
@@ -240,21 +257,6 @@ const matches = (option: MentionOption) => {
return terms.value.every((term) => hay.includes(term));
};
const timeOptions = computed<MentionOption[]>(() => [
...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<MentionOption[]>(() =>
STREAM_TYPES.flatMap((type, index) =>
namesOf(streams[index].data.value)
@@ -263,7 +265,7 @@ const streamOptions = computed<MentionOption[]>(() =>
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<MentionOption[]>(() =>
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<MentionOption[]>(() =>
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<MentionOption[]>(() =>
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<Group[]>(() => {
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<Group[]>(() =>
[
...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<Group[]>(() =>
() => 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];
@@ -0,0 +1,45 @@
<!-- 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>
<OButton
variant="ghost"
size="sm"
block
role="menuitemradio"
:aria-checked="selected"
:class="[
'h-8! justify-start! gap-2 px-2! text-start max-md:h-11! pointer-coarse:h-11!',
selected ? 'bg-dropdown-item-hover-bg' : '',
]"
@click="emit('click')"
>
<span class="text-text-body min-w-0 flex-1 truncate font-normal">{{ label }}</span>
<span v-if="meta" class="text-text-secondary shrink-0 text-xs font-normal">{{ meta }}</span>
<OIcon v-if="selected" :name="assistantGlyph.done" size="xs" class="text-link shrink-0" />
</OButton>
</template>
<script setup lang="ts">
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import type { I18nText } from "@/types/i18n";
defineProps<{ label: I18nText; meta?: I18nText; selected?: boolean }>();
const emit = defineEmits<{ (e: "click"): void }>();
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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<HTMLElement>(`[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" });
});
});
@@ -0,0 +1,241 @@
<!-- 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>
<OPopover
:open="open"
privacy="mask"
side="top"
:align="align"
:aria-label="t('assistant.context.time.swap')"
:content-class="`${composerPopoverClass(layout.tight)} flex flex-col gap-0.5 p-1`"
@update:open="setOpen"
>
<template #trigger>
<OButton
variant="ghost"
size="chip"
:disabled="disabled"
:aria-label="name"
aria-haspopup="dialog"
:aria-expanded="open"
:class="['text-text-secondary shrink-0', TOUCH_TARGET_CLASS]"
data-test="assistant-context-chip-time"
>
<OIcon :name="assistantGlyph.chipTime" size="xs" />
<span class="whitespace-nowrap">{{ text }}</span>
</OButton>
</template>
<div role="menu" class="flex flex-col gap-0.5" data-test="assistant-context-time-picker">
<AssistantMenuRow
v-if="pageWindow"
:label="t('assistant.composer.mention.pageTime')"
:meta="textOf(pageWindow)"
:selected="window.source === 'page'"
data-test="assistant-context-time-page"
@click="pick(null)"
/>
<AssistantMenuRow
v-for="period in PERIODS"
:key="period"
:label="periodLabel(period)"
:meta="
period === DEFAULT_PERIOD && !pageWindow
? t('assistant.context.time.source.default')
: undefined
"
:selected="isSelected(period)"
:data-test="`assistant-context-time-${period}`"
@click="pickPeriod(period)"
/>
<ODateTimeRange
v-bind="pickerValue"
:timezone="timezone"
show-timezone
:auto-apply="false"
tabindex="0"
class="mt-0.5"
data-test="assistant-context-time-range"
@change="onPick"
/>
<OText
v-if="reversed"
variant="meta"
as="p"
role="alert"
class="text-status-error-text px-2 py-1"
data-test="assistant-context-time-reversed"
>
{{ t("assistant.context.time.reversed") }}
</OText>
</div>
</OPopover>
</template>
<script lang="ts">
import type { ComposerState, TimeWindow } from "@/composables/assistant";
export interface AssistantTimePrefixProps {
window: TimeWindow;
/** The page's own window, offered as "Page time"; null where the page has none. */
pageWindow?: TimeWindow | null;
timezone?: string;
align?: "start" | "end";
disabled?: boolean;
}
export interface AssistantTimePrefixEmits {
/** `null` goes back to the page's time or the default. */
(e: "change", range: ComposerState["timeOverride"]): void;
}
const PERIODS = ["15m", "1h", "24h", "7d"] as const;
const DEFAULT_PERIOD = "15m";
const UNIT_OF: Record<string, RelativeUnit> = {
s: "seconds",
m: "minutes",
h: "hours",
d: "days",
w: "weeks",
M: "months",
};
const LETTER_OF: Record<RelativeUnit, string> = {
seconds: "s",
minutes: "m",
hours: "h",
days: "d",
weeks: "w",
months: "M",
};
</script>
<script setup lang="ts">
import { computed, ref } from "vue";
import { assistantGlyph } from "@/components/ai-assistant/common/glyphs";
import { TOUCH_TARGET_CLASS } from "@/components/ai-assistant/common/touchTarget";
import {
composerPopoverClass,
useComposerLayout,
} from "@/components/ai-assistant/composer/composerUi";
import { periodMs, timeRangeShortText } from "@/composables/assistant";
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 ODateTimeRange from "@/lib/forms/DateTimeRange/ODateTimeRange.vue";
import type {
DateTimeRangeValue,
RelativeUnit,
} from "@/lib/forms/DateTimeRange/ODateTimeRange.types";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { convertDateToTimestamp, formatTimestampInTimezone } from "@/utils/date";
import AssistantMenuRow from "./AssistantMenuRow.vue";
const props = withDefaults(defineProps<AssistantTimePrefixProps>(), {
pageWindow: null,
timezone: "UTC",
align: "start",
disabled: false,
});
const emit = defineEmits<AssistantTimePrefixEmits>();
const { t } = useI18nTyped();
const layout = useComposerLayout();
const open = ref(false);
const reversed = ref(false);
const text = computed(() => textOf(props.window));
const name = computed(() =>
t("assistant.context.time.name", {
range: text.value,
source: t(`assistant.context.time.source.${props.window.source}`),
}),
);
// The picker opens on the window the prefix shows: relative stays relative, a range shows its ends in the org zone.
const pickerValue = computed(() => {
const match = props.window.period ? /^(\d+)([smhdwM])$/.exec(props.window.period) : null;
if (match)
return {
mode: "relative" as const,
relativeAmount: Number(match[1]),
relativeUnit: UNIT_OF[match[2]],
};
const at = (ms: number, format: string) =>
formatTimestampInTimezone(ms * 1000, format, props.timezone);
return {
mode: "absolute" as const,
startDate: at(props.window.from, "YYYY-MM-DD"),
startTime: at(props.window.from, "HH:mm"),
endDate: at(props.window.to, "YYYY-MM-DD"),
endTime: at(props.window.to, "HH:mm"),
};
});
function textOf(window: TimeWindow): I18nText {
const short = timeRangeShortText(window);
return window.period ? t("assistant.context.time.last", { period: short }) : short;
}
function periodLabel(period: string): I18nText {
return textOf({ from: 0, to: 0, period, source: "custom" });
}
function isSelected(period: string): boolean {
if (props.window.source === "page") return false;
return props.window.period === period;
}
function setOpen(value: boolean): void {
open.value = value;
reversed.value = false;
}
function pick(range: ComposerState["timeOverride"]): void {
emit("change", range);
setOpen(false);
}
function pickPeriod(period: string): void {
if (period === DEFAULT_PERIOD && !props.pageWindow) return pick(null);
const to = Date.now();
pick({ from: to - periodMs(period), to, period });
}
function onPick(value: DateTimeRangeValue): void {
if (value.type === "relative") return pickPeriod(`${value.amount}${LETTER_OF[value.unit]}`);
const ms = (date: string, time: string) => {
const [year, month, day] = date.split("-");
return (
convertDateToTimestamp(
`${day}-${month}-${year}`,
time || "00:00",
value.timezone || props.timezone,
).timestamp / 1000
);
};
const from = ms(value.startDate, value.startTime);
const to = ms(value.endDate, value.endTime);
reversed.value = !(from > 0 && to > from);
if (!reversed.value) pick({ from, to });
}
</script>
@@ -61,8 +61,9 @@ vi.mock("@/components/ai-assistant/gate/AssistantGateState.vue", () => ({
},
}));
function setup(gate: GateCode = "available") {
function setup(gate: GateCode = "available", history: Record<string, unknown> = {}) {
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", () => {
@@ -24,10 +24,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-dd-privacy="mask"
:data-state="state"
>
<OCard variant="outlined" class="flex min-w-0 flex-col gap-3 p-4 max-md:p-3">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<AssistantMark size="sm" />
<h2 :id="titleId" class="text-text-heading text-sm font-semibold">
<OCard variant="outlined" class="@container flex min-w-0 flex-col gap-3 p-3">
<div class="flex min-h-7 min-w-0 flex-wrap items-center gap-2">
<OIcon :name="assistantGlyph.assistant" size="sm" class="text-text-secondary shrink-0" />
<h2 :id="titleId" class="text-text-heading text-sm font-medium">
{{ t("assistant.name") }}
</h2>
<OTag
@@ -39,10 +39,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<span class="flex-1" />
<OButton
variant="ghost"
size="sm"
v-if="mdUp"
variant="ghost-primary"
size="chip"
:icon-right="assistantGlyph.goTo"
class="max-md:min-h-11 pointer-coarse:min-h-11"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-launcher-open"
@click="openFull"
>
@@ -50,166 +51,186 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</div>
<template v-if="folded" />
<AssistantGateState
v-if="state === 'gate'"
v-else-if="state === 'gate'"
reason="no_permission"
size="inline"
:is-admin="capability.isAdmin"
:context="{ org, feature: 'chat' }"
/>
<template v-else>
<div class="flex min-w-0 items-center gap-2">
<OInput
ref="input"
v-model="text"
size="md"
width="full"
:disabled="state === 'loading'"
:placeholder="t('assistant.welcome.launcher.placeholder')"
:input-attrs="{
'aria-label': t('assistant.welcome.launcher.inputName', { product }),
'aria-describedby': disclosureId,
maxlength: MAX_LAUNCHER_CHARS,
enterkeyhint: 'send',
}"
class="min-w-0 flex-1"
data-test="assistant-launcher-input"
@keydown="onKeydown"
>
<template #icon-left>
<OIcon :name="assistantGlyph.assistant" size="sm" class="text-text-secondary" />
</template>
</OInput>
<AssistantIconButton
v-if="text"
:label="t('assistant.welcome.launcher.clear')"
:icon="assistantGlyph.close"
data-test="assistant-launcher-clear"
@click="clear"
/>
<OButton
variant="primary"
size="sm-action"
:icon-left="mdUp ? undefined : assistantGlyph.send"
:disabled="state === 'loading'"
class="shrink-0 max-md:min-h-11 max-md:min-w-11 pointer-coarse:min-h-11"
data-test="assistant-launcher-ask"
@click="ask(false)"
>
<span :class="mdUp ? '' : 'sr-only'">{{ t("assistant.welcome.launcher.ask") }}</span>
<OShortcut v-if="mdUp" keys="enter" size="sm" class="ms-1" aria-hidden="true" />
</OButton>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-x-1">
<OText :id="disclosureId" variant="meta" as="p" data-test="assistant-launcher-disclosure">
{{ disclosure }}
</OText>
<AssistantContextSeesPopover :sid="null" align="start">
<template #trigger>
<AssistantIconButton
:label="t('assistant.context.title')"
:icon="assistantGlyph.info"
data-test="assistant-launcher-sees"
/>
</template>
</AssistantContextSeesPopover>
</div>
</template>
<OInput
v-else
ref="input"
v-model="text"
size="md"
width="full"
:disabled="state === 'loading'"
:placeholder="t('assistant.composer.placeholder')"
:input-attrs="{
'aria-label': t('assistant.welcome.launcher.inputName', { product }),
maxlength: MAX_LAUNCHER_CHARS,
enterkeyhint: 'send',
}"
class="min-w-0"
data-test="assistant-launcher-input"
@keydown="onKeydown"
>
<template #icon-right>
<span class="flex shrink-0 items-center gap-0.5">
<AssistantIconButton
v-if="text"
:label="t('assistant.welcome.launcher.clear')"
:icon="assistantGlyph.close"
class="h-7! w-7!"
data-test="assistant-launcher-clear"
@click="clear"
/>
<AssistantTimePrefix
:window="window"
:disabled="state === 'loading'"
align="end"
@change="(range) => (timeOverride = range)"
/>
<AssistantContextSeesPopover :sid="null" hide-streams>
<template #trigger>
<AssistantIconButton
:label="t('assistant.context.title')"
:icon="assistantGlyph.info"
class="h-7! w-7!"
data-test="assistant-launcher-sees"
/>
</template>
</AssistantContextSeesPopover>
<OButton
variant="primary"
size="xs"
:icon-left="mdUp ? undefined : assistantGlyph.send"
:disabled="state === 'loading' || !text.trim()"
class="shrink-0 max-md:min-h-11 max-md:min-w-11 pointer-coarse:min-h-11"
data-test="assistant-launcher-ask"
@click="ask(false)"
>
<span :class="mdUp ? '' : 'sr-only'">{{ t("assistant.welcome.launcher.ask") }}</span>
</OButton>
</span>
</template>
</OInput>
<div
:class="['grid min-w-0 grid-cols-1 gap-x-6 gap-y-3', showSuggestions && 'md:grid-cols-2']"
v-if="!folded"
:class="[
'grid min-w-0 grid-cols-1 gap-x-6 gap-y-3',
showSuggestions && '@[40rem]:grid-cols-2',
]"
>
<div
v-if="showSuggestions"
class="flex min-w-0 flex-col gap-2"
class="flex min-w-0 flex-col"
data-test="assistant-launcher-suggestions"
>
<h3 class="text-text-secondary text-xs font-medium">
{{ t("assistant.welcome.suggestions") }}
</h3>
<ul
v-if="state === 'loading'"
class="flex flex-col gap-2"
data-test="assistant-launcher-loading"
>
<li v-for="n in 3" :key="n"><OSkeleton type="text" class="h-8 w-full" /></li>
<li v-for="n in 3" :key="n"><OSkeleton type="text" class="h-5 w-3/5" /></li>
</ul>
<ul v-else class="flex flex-col gap-2">
<li v-for="(starter, index) in shownStarters" :key="starter.id" class="min-w-0">
<OButton
variant="secondary"
size="sm"
block
class="h-auto! min-h-8 justify-start! py-1.5 text-start whitespace-normal! max-md:min-h-11 pointer-coarse:min-h-11"
<ul v-else class="flex flex-col items-start">
<li v-for="(starter, index) in shownStarters" :key="starter.id" class="max-w-full">
<AssistantStarterRow
:label="starter.label"
:data-test="`assistant-launcher-starter-${index}`"
@click="pickStarter(starter, index)"
>
{{ starter.label }}
</OButton>
/>
</li>
</ul>
</div>
<!-- A chat that answers or waits outranks suggestions on a phone (PO A21). -->
<div
:class="['flex min-w-0 flex-col gap-2', hasInProgress && 'max-md:order-first']"
:class="['flex min-w-0 flex-col', hasInProgress && 'max-md:order-first']"
data-test="assistant-launcher-your-chats"
>
<div class="flex min-w-0 items-center gap-2">
<h3 class="text-text-secondary min-w-0 flex-1 text-xs font-medium">
{{ t("assistant.welcome.launcher.yourChats") }}
<div class="flex min-h-7 min-w-0 items-center gap-2">
<h3 class="text-text-secondary min-w-0 flex-1 text-xs">
{{ t("assistant.welcome.launcher.recent") }}
</h3>
<OButton
v-if="recent.length"
variant="ghost-primary"
size="sm"
class="shrink-0 max-md:min-h-11 pointer-coarse:min-h-11"
size="chip"
:class="['shrink-0', TOUCH_TARGET_CLASS]"
data-test="assistant-launcher-all-chats"
@click="openFull"
>{{ t("assistant.welcome.launcher.allChats") }}</OButton
>
</div>
<ul v-if="state === 'loading'" class="flex flex-col gap-2">
<li v-for="n in 3" :key="n"><OSkeleton type="text" class="h-8 w-full" /></li>
<ul v-if="state === 'loading'" class="flex flex-col gap-3 pt-2">
<li v-for="n in 2" :key="n"><OSkeleton type="text" class="h-5 w-2/3" /></li>
</ul>
<OEmptyState
<OBanner
v-else-if="recentFailed"
variant="error-soft"
bar
dense
icon="error-outline"
:content="t('assistant.history.serverError')"
class="rounded-default min-h-9"
data-test="assistant-launcher-recent-failed"
>
<template #actions>
<OButton
variant="ghost"
size="xs"
:class="TOUCH_TARGET_CLASS"
data-test="assistant-launcher-recent-retry"
@click="history.retry()"
>
{{ t("common.retry") }}
</OButton>
</template>
</OBanner>
<OText
v-else-if="recent.length === 0"
size="inline"
icon="history"
:backdrop="false"
:title="t('assistant.welcome.launcher.noRecent')"
:description="t('assistant.welcome.launcher.noRecentHint')"
variant="meta"
as="p"
class="flex min-h-7 items-center"
data-test="assistant-launcher-recent-empty"
/>
<ul v-else class="flex flex-col gap-1">
>
{{ t("assistant.welcome.launcher.noRecent") }}
</OText>
<ul v-else class="flex flex-col">
<li v-for="(row, index) in recent" :key="row.sid" class="min-w-0">
<OButton
variant="ghost"
size="sm"
block
class="h-auto! min-h-8 justify-start! gap-2 py-1.5 text-start max-md:min-h-11 pointer-coarse:min-h-11"
class="group h-8! justify-start! gap-2 px-1.5! text-start font-normal max-md:h-11! pointer-coarse:h-11!"
:data-test="`assistant-launcher-recent-${index}`"
:data-status="row.status ?? undefined"
@click="openChat(row.sid)"
>
<AssistantStatusDot
v-if="row.status"
:state="CHAT_STATUS_SIGNAL[row.status]"
placement="inline"
/>
<OTruncatedText class="text-text-body min-w-0 flex-1">{{
<OTruncatedText class="text-text-heading min-w-0 flex-1">{{
row.title
}}</OTruncatedText>
<span v-if="row.status || row.at" class="sr-only">, </span>
<OText
v-if="row.status"
variant="meta"
class="text-text-body shrink-0"
:class="[
'group-hover:text-text-body shrink-0',
row.status === 'needs_you' ? 'text-status-warning-text' : '',
]"
data-test="assistant-launcher-recent-status"
>
{{ t(CHAT_STATUS_KEYS[row.status]) }}
</OText>
<OText v-else-if="row.at" variant="meta" class="shrink-0">{{ row.at }}</OText>
<OText
v-else-if="row.at"
variant="meta"
class="group-hover:text-text-body shrink-0"
>{{ row.at }}</OText
>
</OButton>
</li>
</ul>
@@ -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<ComposerState["timeOverride"]>(null);
const input = ref<InstanceType<typeof OInput> | null>(null);
const org = computed<string>(() => 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 }),
},
});
}
@@ -0,0 +1,45 @@
<!-- 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>
<OButton
variant="ghost-primary"
size="sm"
class="h-auto! min-h-7 justify-start! px-0! py-0.5 text-start font-normal whitespace-normal! max-md:min-h-11 pointer-coarse:min-h-11"
@click="emit('click')"
>
<span>
<template v-for="(part, index) in parts" :key="index">
<code v-if="index % 2 === 1" class="font-mono text-xs">{{ part }}</code>
<template v-else>{{ part }}</template>
</template>
</span>
</OButton>
</template>
<script setup lang="ts">
import { computed } from "vue";
import OButton from "@/lib/core/Button/OButton.vue";
import type { I18nText } from "@/types/i18n";
const props = defineProps<{ label: I18nText }>();
const emit = defineEmits<{ (e: "click"): void }>();
// Backticks in a starter mark a stream or org name; odd parts sit between them.
const parts = computed(() => String(props.label).split("`"));
</script>
@@ -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"]);
});
});
@@ -19,99 +19,37 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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' : '',
]"
>
<h2
:id="capabilityId"
data-test="assistant-welcome-capability"
:class="['text-text-body font-normal', variant === 'roomy' ? 'text-base' : 'text-sm']"
>
{{ capability }}
</h2>
<p
v-if="variant === 'roomy'"
class="text-text-secondary text-xs"
data-test="assistant-welcome-beta"
>
{{ t("assistant.beta") }}
</p>
<div v-if="loading" class="flex flex-col gap-2" data-test="assistant-welcome-loading">
<OSkeleton type="text" class="h-6 w-2/3" />
<OSkeleton v-for="row in 3" :key="row" type="rect" class="h-9 w-full max-md:h-11" />
<OSkeleton v-for="row in 3" :key="row" type="text" class="h-5 w-3/5" />
</div>
<template v-else>
<AssistantContextSeesPopover v-model:open="seesOpen" :sid="sid" align="start">
<template #anchor>
<p class="flex flex-wrap items-center gap-1.5" data-test="assistant-welcome-scope">
<span class="text-text-secondary text-xs">{{ t("assistant.welcome.scope") }}</span>
<AssistantContextChip
v-for="tag in tags"
:key="tag.id"
:chip="tag"
readonly
:expanded="seesOpen && openedBy === tag.id"
:data-test="`assistant-welcome-scope-${tag.id}`"
@open="openSees(tag.id)"
/>
<span
class="text-text-secondary text-xs"
data-test="assistant-welcome-sent-with-first"
>{{ t("assistant.welcome.sentWithFirst") }}</span
>
</p>
</template>
</AssistantContextSeesPopover>
<h3 class="sr-only">{{ t("assistant.welcome.suggestions") }}</h3>
<ul :class="variant === 'roomy' ? 'grid gap-3 xl:grid-cols-3' : 'flex flex-col gap-2'">
<li v-for="(starter, index) in starters" :key="starter.id">
<OCard v-if="variant === 'roomy'" variant="outlined" class="h-full">
<OButton
variant="ghost"
block
class="h-full! min-h-11 justify-start! py-3 text-start whitespace-normal!"
:data-test="starter.dataTest"
@click="select(starter, index)"
>
{{ starter.label }}
</OButton>
</OCard>
<OButton
v-else
variant="secondary"
size="sm"
block
class="h-auto! min-h-8 justify-start! py-1.5 text-start whitespace-normal! max-md:min-h-11 pointer-coarse:min-h-11"
:data-test="starter.dataTest"
@click="select(starter, index)"
>
{{ starter.label }}
</OButton>
</li>
</ul>
</template>
<ul v-else class="flex flex-col items-start">
<li v-for="(starter, index) in shownStarters" :key="starter.id" class="max-w-full">
<AssistantStarterRow
:label="starter.label"
:data-test="starter.dataTest"
@click="select(starter, index)"
/>
</li>
</ul>
</section>
</template>
<script lang="ts">
import type { AssistantScope, Container, ContextChip, Sid, Starter } from "@/composables/assistant";
import type { AssistantScope, Container, Sid, Starter } from "@/composables/assistant";
export interface AssistantWelcomeProps {
variant: "compact" | "roomy";
scope: AssistantScope;
starters: Starter[];
loading: boolean;
/** The first starter, which the composer renders as its ghost (WP-05); the welcome only decides it. */
ghost?: Starter | null;
/** The telemetry container this welcome renders in. */
container?: Container;
/** The session whose "What the assistant sees" the scope chips open. */
sid?: Sid | null;
}
@@ -120,6 +58,9 @@ export interface AssistantWelcomeEmits {
(e: "open-chat", target: { sid: Sid } | { chatId: number }): void;
}
/** The empty chat never offers more than this. */
export const WELCOME_STARTERS = 3;
// Once per page session per container, however often the welcome re-renders.
const shown = new Set<string>();
@@ -130,18 +71,15 @@ export function resetWelcomeTelemetryForTests(): void {
</script>
<script setup lang="ts">
import { computed, ref, useId, watch } from "vue";
import { computed, watch } from "vue";
import AssistantContextChip from "@/components/ai-assistant/context/AssistantContextChip.vue";
import AssistantContextSeesPopover from "@/components/ai-assistant/context/AssistantContextSeesPopover.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OCard from "@/lib/core/Card/OCard.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import { trackAssistant } from "@/services/assistantAnalytics";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { useI18nTyped } from "@/types/i18n";
import AssistantStarterRow from "./AssistantStarterRow.vue";
const props = withDefaults(defineProps<AssistantWelcomeProps>(), {
ghost: null,
container: "sidebar",
sid: null,
});
@@ -149,63 +87,9 @@ const props = withDefaults(defineProps<AssistantWelcomeProps>(), {
const emit = defineEmits<AssistantWelcomeEmits>();
const { t } = useI18nTyped();
const capabilityId = `assistant-welcome-${useId()}`;
const seesOpen = ref(false);
// Only the tag that opened the shared popover reports it as expanded (V7).
const openedBy = ref<string | null>(null);
const fresh = computed(() => props.scope.streamCount === 0);
const capability = computed(() =>
props.scope.canWrite === "no"
? t("assistant.welcome.capability.readOnly")
: t("assistant.welcome.capability.readWrite"),
);
// The scope tags are read-only tray chips that open "What the assistant sees" (`70` §8).
const tags = computed<ContextChip[]>(() => {
const { org, orgName, streamCount, timeRange, pageObject } = props.scope;
const tag = (id: string, kind: ContextChip["kind"], label: I18nText): ContextChip => ({
id,
kind,
label,
sizeBytes: 0,
provenance: "page",
sent: "whitelist",
});
const list: ContextChip[] = [];
const editor = props.scope.editor;
if (editor) {
list.push(tag("object", "query", t(`assistant.welcome.scopeObject.${editor.language}`)));
if (editor.stream)
list.push(
tag("stream", "stream", t("assistant.welcome.scopeStream", { stream: raw(editor.stream) })),
);
if (timeRange) list.push(tag("time", "time", timeRange));
return list;
}
if (pageObject?.kind === "dashboard")
list.push(
tag(
"object",
"panel",
t("assistant.welcome.scopeObject.dashboard", { name: raw(pageObject.name) }),
),
);
if (org)
list.push(tag("org", "field", t("assistant.welcome.scopeOrg", { org: raw(orgName || org) })));
if (streamCount !== null)
list.push(
tag("streams", "stream", t("assistant.welcome.streams", { count: streamCount }, streamCount)),
);
if (timeRange) list.push(tag("time", "time", timeRange));
return list;
});
function openSees(id: string): void {
openedBy.value = id;
seesOpen.value = true;
}
const shownStarters = computed(() => props.starters.slice(0, WELCOME_STARTERS));
function select(starter: Starter, index: number): void {
trackAssistant("ai_welcome_starter", { starter_kind: starter.kind, index, via: "click" });
@@ -292,6 +292,7 @@ export interface OpenAssistantArgs {
/** `send` only for explain | troubleshoot | fix and the launcher's typed line (`10` §3). */
intent: "open" | "stage" | "send";
payload?: {
timeOverride?: ComposerState["timeOverride"];
chips: ContextChip[];
text?: string;
title?: I18nText;
@@ -447,6 +447,7 @@ function stagePayload(
mode: empty ? "replace" : "append",
...(payload.homeStarter && { homeStarter: true }),
});
if (payload.timeOverride) store.setTimeOverride(sid, payload.timeOverride);
if (payload.title && fresh) store.setTitle(sid, payload.title, "object");
const staged = store.state.sessions[sid].composer.chips.length - before;
// A repeated prompt the store skipped adds nothing, so the helper line would claim a change that did not happen.
+1
View File
@@ -159,6 +159,7 @@ export interface HintPrefs {
disclosure?: boolean;
/** The dictation primer was accepted (`40` §1.8.2). */
dictate?: boolean;
attachSaved?: boolean;
}
export interface PlacementPrefs {
@@ -185,7 +185,7 @@ describe("useAssistantStarters", () => {
dashboard.page$.value = "home";
await flushPromises();
expect(labels(dashboard.starters)).toEqual([
"What needs attention in `default` right now?",
"What needs attention right now?",
"What can you do here?",
]);
expect(dashboard.starters.value.every((starter) => starter.home === true)).toBe(true);
@@ -194,10 +194,7 @@ describe("useAssistantStarters", () => {
it("offers learn and org health off the page table, never the removed alerts starter", async () => {
const { starters } = setup("assistant", { currentPage: "assistant" });
await flushPromises();
expect(labels(starters)).toEqual([
"What can you do here?",
"What needs attention in `default` right now?",
]);
expect(labels(starters)).toEqual(["What can you do here?", "What needs attention right now?"]);
});
it("grounds Home on streams the org has, skips internal ones and stages the hour it names", async () => {
@@ -206,8 +203,8 @@ describe("useAssistantStarters", () => {
const { starters } = setup("home", null);
await flushPromises();
expect(labels(starters)).toEqual([
"What needs attention in `default` right now?",
"Summarise errors in `k8s` over the last hour",
"What needs attention right now?",
"Summarise errors in `k8s`",
"Which services had the slowest spans in the last hour?",
]);
expect(starters.value[1].period).toBe("1h");
@@ -265,7 +262,7 @@ describe("useAssistantStarters", () => {
});
const acme = effectScope().run(() => useAssistantStarters({ page: "home" }))!;
await flushPromises();
expect(labels(acme.starters)[0]).toBe("What needs attention in `Acme` right now?");
expect(labels(acme.starters)[0]).toBe("What needs attention right now?");
expect(acme.scope.value).toMatchObject({ org: "a1b2c3", orgName: "Acme" });
});
+57 -72
View File
@@ -9246,6 +9246,10 @@
"undoneState": "Undone",
"unsupported": "Activity history needs a newer server version."
},
"attach": {
"saved": "Attachments are saved with this chat.",
"ok": "OK"
},
"announce": {
"approval": "Waiting for your approval: {title}.",
"approvalExpires": "Waiting for your approval: {title}. Expires in {duration}.",
@@ -9432,7 +9436,6 @@
"queryError": "Query error",
"span": "Span"
},
"beta": "Beta",
"billing": {
"allowance": {
"confirm": "Set limit",
@@ -9512,7 +9515,6 @@
"dashboards": "Dashboards",
"pipelines": "Pipelines",
"streams": "Streams",
"time": "Time range",
"agents": "Agents"
},
"kind": {
@@ -9525,26 +9527,34 @@
"optionName": "{group}, {label}",
"pageTime": "Page time",
"retry": "Couldn't load {kind} · Retry",
"showMoreIn": "Show more {group}",
"showAll": "Show all",
"title": "Add to context"
},
"name": "Message {product}",
"placeholder": "Ask about {summary}",
"offline": "You're offline. Your draft is kept; dictation needs a connection.",
"placeholderEmpty": "Ask a question or add context with {'@'}",
"placeholder": "Ask about your data",
"followUp": "Ask a follow-up",
"scoped": "Ask about {stream}",
"agent": "Ask {agent}",
"inert": "Decide above to continue",
"busy": "{product} is busy. Send again in {s} s.",
"unsent": "Isn't reachable. Your message wasn't sent.",
"goToRequest": "Go to request",
"imageCap": "{name} wasn't added. Up to {count} images, {size} each.",
"elsewhere": "Answering in another tab.",
"sendReply": "Send reply",
"addContextTip": "Add context · {'@'}",
"offline": "You're offline. Draft kept.",
"rateLimited": "Wait for the Retry countdown to end.",
"recalled": "Recalled prompt {n} of {count}",
"recallHint": "↑ recalls your last prompt | ↑ recalls your last {count} prompts",
"recentPrompts": "Recent prompts",
"removeImage": "Remove {name}",
"replyHelper": "Your reply answers the request above; staged context stays here.",
"replyHelper": "Reply declines the request",
"replyPlaceholder": "Reply to the assistant",
"running": "Wait for this answer to finish, or stop it.",
"sendEmpty": "Type a message or add context to send",
"undo": "Undo",
"waitingApproval": "Answer the request above to continue.",
"waitingConsent": "Decide on paid usage above to continue.",
"replyCancel": "Cancel reply",
"replyCancel": "Cancel",
"replyRefused": "Your reply wasn't sent. This request can't be answered from this page.",
"addContext": "Add context",
"addMenuSearch": "Search streams, panels, alerts",
@@ -9610,6 +9620,7 @@
"chipName": "{label}, {secondary}",
"dataSharing": "Data sharing · {level}",
"disclosure": "Attached data is sent to the assistant and saved with this chat.",
"seesDisclosure": "Sent to the assistant and saved with this chat.",
"disclosureDismiss": "Dismiss the data notice",
"disclosureLine": "Sends the chips and this page's time range",
"disclosureShared": "Attached data is sent to the assistant, saved with this chat and included in any share.",
@@ -9658,14 +9669,19 @@
"streamsValue": "{names} ({type})",
"summary": "{scope} · {range}",
"time": {
"apply": "Apply",
"last": "Last {period}",
"name": "Time range, {range}, {source}",
"reversed": "End must be after start",
"source": {
"default": "default",
"page": "from this page",
"custom": "custom"
},
"custom": "custom",
"default": "Time · last 15 min · default",
"defaultNote": "The default range is used because this page has no time range.",
"fromPage": "page",
"page": "Time · {range}",
"swap": "Change time range",
"usePage": "Use page time"
"swap": "Change time range"
},
"title": "What the assistant sees",
"trayLabel": "Context for your next message",
@@ -10274,7 +10290,7 @@
"title": "{product} is turned off for this organization"
},
"openAccessSettings": "Open access settings",
"tryAgain": "Try again",
"tryAgain": "Retry",
"unhealthy": {
"body": "The assistant service isn't responding. Try again in a few minutes.",
"notice": "{product} isn't responding. Your last message wasn't answered.",
@@ -11429,14 +11445,8 @@
"stoppedIncomplete": "Stopped, incomplete"
},
"voice": {
"allow": {
"chrome": "In Chrome, select the icon to the left of the address, open Site settings and set Microphone to Allow.",
"edge": "In Edge, select the lock icon next to the address, open Permissions for this site and set Microphone to Allow.",
"headphones": "With a screen reader, use headphones while dictating.",
"other": "Open this site's settings in your browser and set Microphone to Allow.",
"safari": "In Safari, open Settings for This Website from the Safari menu and set Microphone to Allow.",
"title": "How to allow the microphone"
},
"allowTitle": "Allow the microphone",
"allowBody": "Click the lock in the address bar, allow Microphone, then reload this page.",
"cancelled": "Dictation cancelled.",
"continue": "Continue",
"dictate": "Dictate",
@@ -11447,33 +11457,23 @@
"microsoft": "Edge turns speech into text with Microsoft's speech service.",
"vendor": "Your browser sends speech to its maker's speech service to turn it into text."
},
"engineShort": {
"browser": "Your browser",
"device": "Your device"
},
"enterToSend": "Press Enter again to send.",
"error": {
"browser_silent": "Your browser didn't turn speech into text. Type instead, or try another browser.",
"how": "How to allow it",
"language_unsupported": "Dictation in {language} isn't supported by this browser.",
"mic_denied": "Microphone access is blocked for this site.",
"no_mic": "No microphone found. Connect one and try again.",
"no_speech": "Didn't hear anything. Try again.",
"service_unreachable": "Couldn't reach {vendor}'s speech service. Check your connection, then try again.",
"service_unreachable_generic": "Couldn't reach your browser's speech service. Check your connection, then try again.",
"service_unreachable": "Couldn't reach {vendor}'s speech service.",
"service_unreachable_generic": "Couldn't reach your browser's speech service.",
"stt_unavailable": "Speech-to-text stopped. What was heard is kept.",
"time_limit": "Dictation stops after 2 minutes. Press the mic to go on.",
"unsupported": "This browser can't dictate and server speech-to-text is off.",
"voice_off": "Voice dictation is off in this browser. An org admin can turn it on for their own browser under Settings, Assistant, Data."
"time_limit": "Dictation stops after 2 minutes. Press the mic to go on."
},
"helperFinish": "Enter or Esc to finish, not send",
"helperListening": "{engineShort} · OpenObserve doesn't store the audio",
"listening": "Listening",
"notNow": "Not now",
"primerBody": "Your browser will ask to use the microphone. What you say appears in the box for you to edit; nothing is sent until you press Send.",
"primerBody": "Nothing is sent until you press Send.",
"primerEngine": "{engineSentence} OpenObserve doesn't store the audio.",
"primerTitle": "Dictate into the message",
"releaseToStop": "Listening · release {keys} to stop",
"holdLine": "Release {keys} to stop",
"seesLine": "Dictation: {engineSentence} OpenObserve doesn't store the audio.",
"started": "Listening. Press Enter, Escape or the mic to stop.",
"stopTooltip": "Stop dictation",
@@ -11481,13 +11481,14 @@
"stoppedEnter": "Dictation stopped. {count} word added. Press Enter to send. | Dictation stopped. {count} words added. Press Enter to send.",
"tooltip": "Dictate · Hold {keys}",
"tryAgain": "Try again",
"waitingPermission": "Waiting for microphone permission"
"waitingPermission": "Waiting for permission",
"waitingShort": "Waiting",
"offAdmin": "Dictation is off in this browser.",
"turnOn": "Turn on",
"silent": "Your browser didn't turn speech into text. Reload to try again.",
"language": "Dictation isn't available in {language}, your browser language."
},
"welcome": {
"capability": {
"readOnly": "Ask about your logs, traces, metrics, alerts and dashboards. It runs read-only queries, links to what it finds and can't change anything in this organization.",
"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."
},
"ghostHint": "Suggestion: {label}. Press Right Arrow to use it.",
"ghostInserted": "Suggestion inserted",
"lastRange": "last {period}",
@@ -11495,14 +11496,10 @@
"ask": "Ask",
"clear": "Clear question",
"allChats": "All chats",
"disclosure": "Sends with the default time range, {range}",
"disclosureNoStreams": "Sends with the default time range, {range} · no streams yet",
"inputName": "Ask {product}",
"noRecent": "No chats yet",
"noRecentHint": "Start one above, or ask about a log row, panel or alert.",
"openFull": "Open full view",
"placeholder": "Ask about your logs, alerts and dashboards…",
"yourChats": "Your chats"
"recent": "Recent"
},
"prefill": {
"append": "Append",
@@ -11510,19 +11507,6 @@
"question": "Replace your draft with the suggestion?",
"replace": "Replace"
},
"scope": "Sees:",
"scopeObject": {
"dashboard": "dashboard {name}",
"js": "this JavaScript",
"json": "this JSON",
"promql": "this PromQL",
"regex": "this pattern",
"sql": "this SQL",
"vrl": "this VRL"
},
"scopeOrg": "org {org}",
"scopeStream": "stream {stream}",
"sentWithFirst": "Sent with your first message",
"starter": {
"alerts": {
"1": "Which alerts fired most in the last 24 h?",
@@ -11539,8 +11523,8 @@
"2": "Explain what this function does"
},
"home": {
"attention": "What needs attention in `{org}` right now?",
"errorsSummary": "Summarise errors in `{stream}` over the last hour",
"attention": "What needs attention right now?",
"errorsSummary": "Summarise errors in `{stream}`",
"slowSpans": "Which services had the slowest spans in the last hour?"
},
"ingest": {
@@ -11581,7 +11565,6 @@
"1Generic": "Which services had the slowest spans recently?"
}
},
"streams": "no streams yet | {count} stream | {count} streams",
"suggestions": "Suggestions",
"try": "Try: \"{prompt}\""
},
@@ -11610,29 +11593,31 @@
},
"investigator": {
"name": "Investigator",
"purpose": "Finds the likely cause of a problem, with evidence"
"purpose": "Finds causes"
},
"queryBuilder": {
"name": "Query builder",
"purpose": "Writes one SQL or PromQL query for you to run or insert"
"purpose": "Writes queries"
},
"policy": {
"readOnly": "Read-only",
"asks": "Asks before changing anything"
},
"picker": {
"label": "Agent: {agent}. Change agent",
"heading": "Who answers",
"note": "Type {'@'} and a name while writing to switch. Switching later hands this chat over; earlier answers stay as they are.",
"next": "Next: {agent}"
"label": "Agent: {agent}, {clause}",
"tooltip": "{agent} · {clause}",
"heading": "Agent"
},
"unavailable": {
"role": "Not available to your role",
"role": "Not available · Ask an admin",
"short": "Not available",
"disabled": "Turned off for this organization",
"unreachable": "Not reachable right now · checked {time}",
"unreachableNow": "Not reachable right now"
},
"announceChosen": "{agent} will answer."
"announceChosen": "{agent} will answer.",
"pendingSwitch": "{agent} answers from now on. Earlier answers stay.",
"clause": "{purpose} · {policy}"
},
"handoff": {
"marker": "Handed to {agent} by you · {time}",