mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
merge: assistant composer redesign lane
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user