From 3ada3f0f612c05b880cae7ea8fc969fc2a6e98f2 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan Date: Sun, 1 Nov 2026 18:59:49 +0530 Subject: [PATCH] feat(assistant): composer redesign Claude-Session: https://claude.ai/code/session_017PUuNbanUSUUbZ5dcZBYmM --- .../agents/AssistantAgentPicker.spec.ts | 49 +- .../agents/AssistantAgentPicker.vue | 187 ++---- .../ai-assistant/agents/agentClause.ts | 25 + .../composer/AssistantAddContextMenu.spec.ts | 7 - .../composer/AssistantAddContextMenu.vue | 74 +-- .../composer/AssistantComposer.spec.ts | 266 +++++++-- .../composer/AssistantComposer.vue | 548 +++++++++++++----- .../composer/AssistantDictateButton.spec.ts | 17 +- .../composer/AssistantDictateButton.vue | 73 ++- .../composer/AssistantDictationMeter.vue | 21 +- .../AssistantVoiceAllowPopover.spec.ts | 13 +- .../composer/AssistantVoiceAllowPopover.vue | 21 +- .../composer/composerRows.spec.ts | 55 ++ .../ai-assistant/composer/composerUi.ts | 58 +- .../composer/useAssistantVoice.ts | 103 ++-- .../ai-assistant/composer/useComposerWidth.ts | 71 +++ .../context/AssistantChipPopover.spec.ts | 41 -- .../context/AssistantChipPopover.vue | 142 +---- .../context/AssistantContextChip.vue | 9 +- .../AssistantContextSeesPopover.spec.ts | 6 +- .../context/AssistantContextSeesPopover.vue | 31 +- .../context/AssistantContextTray.spec.ts | 238 ++------ .../context/AssistantContextTray.vue | 344 ++++------- .../context/AssistantMentionMenu.spec.ts | 33 +- .../context/AssistantMentionMenu.vue | 147 +++-- .../ai-assistant/context/AssistantMenuRow.vue | 45 ++ .../context/AssistantTimePrefix.spec.ts | 70 +++ .../context/AssistantTimePrefix.vue | 241 ++++++++ .../welcome/AssistantLauncher.spec.ts | 42 +- .../welcome/AssistantLauncher.vue | 287 ++++----- .../welcome/AssistantStarterRow.vue | 45 ++ .../welcome/AssistantWelcome.spec.ts | 144 +---- .../ai-assistant/welcome/AssistantWelcome.vue | 160 +---- .../assistant/assistantStore.types.ts | 1 + .../composables/assistant/openAssistant.ts | 1 + web/src/composables/assistant/prefs.ts | 1 + .../assistant/useAssistantStarters.spec.ts | 13 +- web/src/locales/languages/en-US.json | 129 ++--- 38 files changed, 2010 insertions(+), 1748 deletions(-) create mode 100644 web/src/components/ai-assistant/agents/agentClause.ts create mode 100644 web/src/components/ai-assistant/composer/composerRows.spec.ts create mode 100644 web/src/components/ai-assistant/composer/useComposerWidth.ts create mode 100644 web/src/components/ai-assistant/context/AssistantMenuRow.vue create mode 100644 web/src/components/ai-assistant/context/AssistantTimePrefix.spec.ts create mode 100644 web/src/components/ai-assistant/context/AssistantTimePrefix.vue create mode 100644 web/src/components/ai-assistant/welcome/AssistantStarterRow.vue diff --git a/web/src/components/ai-assistant/agents/AssistantAgentPicker.spec.ts b/web/src/components/ai-assistant/agents/AssistantAgentPicker.spec.ts index 27cb3e07e7..a9c5d3bd89 100644 --- a/web/src/components/ai-assistant/agents/AssistantAgentPicker.spec.ts +++ b/web/src/components/ai-assistant/agents/AssistantAgentPicker.spec.ts @@ -59,14 +59,12 @@ const ROSTER: CapabilityAgent[] = [ const stubs = { ODropdown: { template: '
' }, ODropdownGroup: { props: ["label"], template: "
{{ label }}
" }, - ODropdownSeparator: true, ODropdownItem: { props: ["description"], emits: ["select"], template: - '

{{ description }}

', + '

{{ description }}

', }, - OSheet: { template: '
' }, }; function setup(roster: CapabilityAgent[] = ROSTER, messages = false) { @@ -88,7 +86,6 @@ function setup(roster: CapabilityAgent[] = ROSTER, messages = false) { const trigger = (w: ReturnType) => w.get('[data-test="assistant-agent-picker"]'); const option = (w: ReturnType, id: string) => w.get(`[data-test="assistant-agent-option-${id}"]`); -const next = (w: ReturnType) => 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"); - }); }); diff --git a/web/src/components/ai-assistant/agents/AssistantAgentPicker.vue b/web/src/components/ai-assistant/agents/AssistantAgentPicker.vue index 8593641f1d..5df92d168e 100644 --- a/web/src/components/ai-assistant/agents/AssistantAgentPicker.vue +++ b/web/src/components/ai-assistant/agents/AssistantAgentPicker.vue @@ -15,142 +15,64 @@ along with this program. If not, see . -->
. with-image @select="pick" /> -
    -
  • - {{ row.label }} - - {{ row.keys }} -
  • -
@@ -91,13 +79,12 @@ import { computed, nextTick, ref, useId } from "vue"; import AssistantIconButton from "@/components/ai-assistant/common/AssistantIconButton.vue"; import { assistantGlyph } from "@/components/ai-assistant/common/glyphs"; import AssistantMentionMenu from "@/components/ai-assistant/context/AssistantMentionMenu.vue"; -import OShortcut from "@/lib/core/Shortcut/OShortcut.vue"; import OInput from "@/lib/forms/Input/OInput.vue"; import OPopover from "@/lib/overlay/Popover/OPopover.vue"; import { trackAssistant } from "@/services/assistantAnalytics"; -import { raw, useI18nTyped, type I18nText } from "@/types/i18n"; +import { useI18nTyped } from "@/types/i18n"; -import { useVoiceAvailability } from "./useAssistantVoice"; +import { useComposerLayout } from "./composerUi"; defineProps(); @@ -109,52 +96,7 @@ const query = ref(""); const search = ref | null>(null); const menu = ref | null>(null); const listboxId = `assistant-add-context-${useId()}`; -const voice = useVoiceAvailability(); - -interface KeyRow { - id: string; - label: I18nText; - shortcut?: boolean; - keys?: I18nText; -} - -const voiceRow = computed((): KeyRow => { - if (voice.status.value === "ready") - return { - id: "assistantComposerDictate", - label: t("shortcuts.actions.assistantComposerDictate"), - shortcut: true, - }; - return { - id: "voice", - label: t( - voice.status.value === "off" - ? "assistant.voice.error.voice_off" - : "assistant.voice.error.unsupported", - ), - }; -}); - -// The keys the retired hints popover listed, plus the `@` that opens this same menu from the textbox. -const keyRows = computed((): KeyRow[] => [ - { id: "mention", label: t("assistant.composer.mention.title"), keys: raw("@") }, - { - id: "assistantComposerRecall", - label: t("shortcuts.actions.assistantComposerRecall"), - shortcut: true, - }, - { - id: "assistantComposerNewline", - label: t("shortcuts.actions.assistantComposerNewline"), - shortcut: true, - }, - { - id: "assistantComposerSend", - label: t("shortcuts.actions.assistantComposerSend"), - shortcut: true, - }, - voiceRow.value, -]); +const layout = useComposerLayout(); const searchAttrs = computed(() => ({ "aria-label": t("assistant.composer.addMenuSearch"), diff --git a/web/src/components/ai-assistant/composer/AssistantComposer.spec.ts b/web/src/components/ai-assistant/composer/AssistantComposer.spec.ts index dbfadbdd5f..1bdb576fb4 100644 --- a/web/src/components/ai-assistant/composer/AssistantComposer.spec.ts +++ b/web/src/components/ai-assistant/composer/AssistantComposer.spec.ts @@ -13,11 +13,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { flushPromises, mount } from "@vue/test-utils"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ContextChip, PendingApproval } from "@/composables/assistant"; -import { runEscapeLadder } from "@/composables/assistant/shortcuts"; +import { registerAssistantFocusTarget, runEscapeLadder } from "@/composables/assistant/shortcuts"; import { trackAssistant } from "@/services/assistantAnalytics"; import { raw } from "@/types/i18n"; @@ -36,7 +36,7 @@ import { composerUi, noteDraftCleared, resetComposerUiForTests } from "./compose import { HOLD_MS, SILENCE_MS, resetVoiceForTests, setVoiceOptIn } from "./useAssistantVoice"; const { fake, breakpoint } = vi.hoisted(() => ({ - fake: { current: null as any }, + fake: { current: null as any, member: false }, breakpoint: { md: true }, })); @@ -65,8 +65,12 @@ const ROW: ContextChip = { sent: "serialized", }; +const mounted: VueWrapper[] = []; + function setup(props: Partial = {}, withSession = true) { fake.current = fakeAssistant(); + if (fake.member) + fake.current.capability.value = { ...fake.current.capability.value, isAdmin: false }; const store = fake.current.store; const sid = withSession ? store.createSession({ @@ -84,6 +88,7 @@ function setup(props: Partial = {}, withSession = true) global: { plugins: [fakeRouter(), fakeVuex()] }, attachTo: host.lastElementChild as HTMLElement, }); + mounted.push(wrapper); const textarea = () => wrapper.get('[data-test="assistant-composer-field"]'); const status = () => wrapper.get('[data-test="assistant-composer-status"]').text(); return { wrapper, store, sid: sid!, send, textarea, status, host }; @@ -112,6 +117,7 @@ async function type(textarea: ReturnType["textarea"]>, beforeEach(() => { breakpoint.md = true; + fake.member = false; sessionStorage.clear(); localStorage.clear(); resetComposerUiForTests(); @@ -119,6 +125,8 @@ beforeEach(() => { }); afterEach(() => { + // A live composer over a wiped body would throw inside the next flush and fail an unrelated test. + for (const wrapper of mounted.splice(0)) if (wrapper.exists()) wrapper.unmount(); document.body.innerHTML = ""; }); @@ -143,16 +151,109 @@ describe("names and regions (CT-08)", () => { ); }); - it("uses the static name as the placeholder while the welcome offers a ghost (IW-06 AC 1)", () => { - const { textarea } = setup({ - ghost: { label: raw("Why errors?"), text: "Why errors?", chips: [] }, - }); - expect(textarea().attributes("placeholder")).toBe("Message O2 Assistant"); + it("asks about your data in an empty chat, with no count (§1)", () => { + const { textarea } = setup(); + expect(textarea().attributes("placeholder")).toBe("Ask about your data"); }); - it("teaches scope with the default range when the page has no stream (CT-01 AC 10)", () => { - const { textarea } = setup(); - expect(textarea().attributes("placeholder")).toBe("Ask a question or add context with @"); + it("asks for a follow-up once the chat has a turn", async () => { + const { textarea, store, sid } = setup(); + await answered(store, sid); + expect(textarea().attributes("placeholder")).toBe("Ask a follow-up"); + }); + + it("names the chosen agent while it is not the default, and says the switch once", async () => { + const { wrapper, textarea, store, sid } = setup(); + store.state.sessions[sid].nextAgentId = "sre"; + await flushPromises(); + expect(textarea().attributes("placeholder")).toBe("Ask Investigator"); + expect(wrapper.get('[data-test="assistant-composer-helper"]').text()).toBe( + "Investigator answers from now on. Earlier answers stay.", + ); + }); +}); + +describe("rows (§1)", () => { + it("keeps one row while nothing beyond time is attached, and two once a chip is", async () => { + const { wrapper, store, sid } = setup(); + await flushPromises(); + const frame = () => wrapper.get('[data-test="assistant-composer"]'); + expect(frame().attributes("data-rows")).toBe("1"); + store.addChip(sid, ROW); + await flushPromises(); + expect(frame().attributes("data-rows")).toBe("2"); + }); + + it("takes two rows for a second line of text", async () => { + const { wrapper, textarea } = setup(); + await type(textarea(), "one\ntwo"); + expect(wrapper.get('[data-test="assistant-composer"]').attributes("data-rows")).toBe("2"); + }); + + it("keeps the same DOM order at every width: text, tray, +, i, picker, mic, Send", () => { + const wrapper = mount(AssistantComposer, { + props: { sid: null }, + slots: { chips: '
tray
' }, + global: { plugins: [fakeRouter(), fakeVuex()] }, + }); + mounted.push(wrapper); + const order = (a: string, b: string) => + wrapper + .get(`[data-test="${a}"]`) + .element.compareDocumentPosition(wrapper.get(`[data-test="${b}"]`).element) & + Node.DOCUMENT_POSITION_FOLLOWING; + expect(order("assistant-composer-field", "tray-stub")).toBeTruthy(); + expect(order("tray-stub", "assistant-add-context")).toBeTruthy(); + expect(order("assistant-add-context", "assistant-context-sees")).toBeTruthy(); + expect(order("assistant-context-sees", "assistant-send")).toBeTruthy(); + }); +}); + +describe("refused sends (§1)", () => { + const refuse = (store: ReturnType["store"], sid: string, error: object) => { + const session = store.state.sessions[sid]; + session.turn = { state: "refused", error } as never; + session.turnMeta.endedAt = Date.now(); + }; + + afterEach(() => { + vi.useRealTimers(); + }); + + it("counts a busy refusal down, keeps the draft, and turns Send on at 0", async () => { + vi.useFakeTimers(); + const { wrapper, store, sid } = setup(); + store.setDraft(sid, { text: "why?" }); + refuse(store, sid, { code: "turn_limit", retryAfterMs: 2_000 }); + await vi.advanceTimersByTimeAsync(0); + const row = () => wrapper.find('[data-test="assistant-composer-error-row"]'); + expect(row().text()).toBe("O2 Assistant is busy. Send again in 2 s."); + expect(wrapper.get('[data-test="assistant-send"]').attributes("aria-disabled")).toBe("true"); + await vi.advanceTimersByTimeAsync(2_000); + expect(row().exists()).toBe(false); + expect(wrapper.get('[data-test="assistant-send"]').attributes("aria-disabled")).toBeUndefined(); + }); + + it("says the message wasn't sent when the service isn't reachable; Send is the retry", async () => { + const { wrapper, store, sid } = setup(); + store.setDraft(sid, { text: "why?" }); + refuse(store, sid, { code: "unreachable" }); + await flushPromises(); + expect(wrapper.get('[data-test="assistant-composer-error-row"]').text()).toBe( + "Isn't reachable. Your message wasn't sent.", + ); + expect(wrapper.get('[data-test="assistant-send"]').attributes("aria-disabled")).toBeUndefined(); + }); + + it("states the first attachment once; OK remembers it", async () => { + const { wrapper, store, sid } = setup(); + store.addChip(sid, ROW); + await flushPromises(); + const notice = '[data-test="assistant-composer-attach-notice"]'; + expect(wrapper.get(notice).text()).toContain("Attachments are saved with this chat."); + await wrapper.get('[data-test="assistant-composer-attach-ok"]').trigger("click"); + expect(wrapper.find(notice).exists()).toBe(false); + expect(JSON.parse(localStorage.getItem("o2.assistant.hints:k1")!).attachSaved).toBe(true); }); }); @@ -226,28 +327,61 @@ describe("states (`40` §1.3)", () => { expect(send).toHaveBeenCalledTimes(2); }); - it("is inert under an approval card: readonly, aria-disabled, the reason as helper (CT-07)", async () => { + it("is inert under an approval card: no controls, a muted kept draft, one stop out (CT-07)", async () => { const { wrapper, store, sid, textarea } = setup(); + store.setDraft(sid, { text: "kept" }); store.state.sessions[sid].pendingApproval = { id: "c1" } as PendingApproval; await flushPromises(); expect(wrapper.get('[data-test="assistant-composer"]').attributes("data-state")).toBe("inert"); expect(textarea().attributes("readonly")).toBeDefined(); expect(textarea().attributes("aria-disabled")).toBe("true"); - const helper = wrapper.get('[data-test="assistant-composer-helper"]'); - expect(helper.text()).toBe("Answer the request above to continue."); - expect(textarea().attributes("aria-describedby")).toContain(helper.attributes("id")); + expect(textarea().attributes("tabindex")).toBe("-1"); + expect(textarea().attributes("placeholder")).toBe("Decide above to continue"); + expect(textarea().element.value).toBe("kept"); + expect(wrapper.find('[data-test="assistant-composer-controls"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-composer-helper"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-composer-go-to-request"]').exists()).toBe(false); }); - it("switches to the reply placeholder and helper (CT-07 AC 4)", async () => { + it("offers Go to request only while the card is out of view", async () => { + let report: (entries: { isIntersecting: boolean }[]) => void = () => undefined; + vi.stubGlobal( + "IntersectionObserver", + class { + constructor(callback: typeof report) { + report = callback; + } + observe = vi.fn(); + disconnect = vi.fn(); + }, + ); + const { wrapper, store, sid, host } = setup(); + host.insertAdjacentHTML("afterbegin", '
'); + store.state.sessions[sid].pendingApproval = { id: "c1" } as PendingApproval; + await flushPromises(); + const link = '[data-test="assistant-composer-go-to-request"]'; + report([{ isIntersecting: false }]); + await flushPromises(); + expect(wrapper.get(link).text()).toBe("Go to request"); + report([{ isIntersecting: true }]); + await flushPromises(); + expect(wrapper.find(link).exists()).toBe(false); + vi.unstubAllGlobals(); + }); + + it("states the decline above the frame in reply mode, with no context controls (CT-07 AC 4)", async () => { const { wrapper, store, sid, textarea } = setup(); store.state.sessions[sid].pendingApproval = { id: "c1" } as PendingApproval; store.startReply(sid, "c1"); await flushPromises(); expect(textarea().attributes("placeholder")).toBe("Reply to the assistant"); expect(textarea().attributes("readonly")).toBeUndefined(); - expect(wrapper.get('[data-test="assistant-composer-helper"]').text()).toContain( - "Your reply answers", + expect(wrapper.get('[data-test="assistant-composer-reply"]').text()).toContain( + "Reply declines the request", ); + expect(wrapper.find('[data-test="assistant-add-context"]').exists()).toBe(false); + expect(wrapper.find('[data-test="assistant-context-sees"]').exists()).toBe(false); + expect(wrapper.get('[data-test="assistant-send"]').attributes("aria-label")).toBe("Send reply"); }); it("answers the card with Enter in reply mode, the turn still running, chips left staged", async () => { @@ -552,8 +686,8 @@ describe("images (CT-10)", () => { const png = (name: string, size = 10) => new File([new Uint8Array(size)], name, { type: "image/png" }); - it("names pasted shots uniquely and removes the one asked for (AC 1–2)", async () => { - const { wrapper, textarea, store, sid, status } = setup(); + it("names pasted shots uniquely (AC 1)", async () => { + const { textarea, store, sid } = setup(); for (let i = 0; i < 2; i++) { await textarea().trigger("paste", { clipboardData: { files: [png("image.png")], getData: () => "" }, @@ -561,14 +695,10 @@ describe("images (CT-10)", () => { await new Promise((resolve) => setTimeout(resolve, 20)); } await flushPromises(); - const names = () => - store.state.sessions[sid].composer.images.map((i: { filename: string }) => i.filename); - expect(names()).toEqual(["pasted-image-1.png", "pasted-image-2.png"]); - const removes = wrapper.findAll('[data-test="assistant-image-remove"]'); - expect(removes[1].attributes("aria-label")).toBe("Remove pasted-image-2.png"); - await removes[1].trigger("click"); - expect(names()).toEqual(["pasted-image-1.png"]); - expect(status()).toBe("pasted-image-2.png removed from context"); + const names = store.state.sessions[sid].composer.images.map( + (i: { filename: string }) => i.filename, + ); + expect(names).toEqual(["pasted-image-1.png", "pasted-image-2.png"]); }); it("states the type cap where the control is, never as a toast (AC 5)", async () => { @@ -586,7 +716,7 @@ describe("one frame, the + menu and long pastes (D4 §3.1, §3.6, §3.9)", () => const paste = (textarea: ReturnType["textarea"]>, text: string) => textarea.trigger("paste", { clipboardData: { files: [], getData: () => text } }); - it("holds the chips slot, the text and the controls inside the one bordered frame", () => { + it("holds the text and the controls inside the one bordered frame", () => { const { wrapper, textarea } = setup(); const frame = textarea().element.parentElement!; expect(frame.className).toContain("border"); @@ -595,19 +725,9 @@ describe("one frame, the + menu and long pastes (D4 §3.1, §3.6, §3.9)", () => expect(frame.querySelector('[data-test="assistant-send"]')).not.toBeNull(); expect(wrapper.find('[data-test="assistant-composer-hints"]').exists()).toBe(false); expect(wrapper.find('[data-test="assistant-attach-image"]').exists()).toBe(false); - const withChips = mount(AssistantComposer, { - props: { sid: null }, - slots: { chips: '
tray
' }, - global: { plugins: [fakeRouter(), fakeVuex()] }, - }); - const field = withChips.get('[data-test="assistant-composer-field"]').element; - const head = withChips.get('[data-test="assistant-composer-chips"]').element; - expect(head.parentElement).toBe(field.parentElement); - expect(head.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(head.querySelector('[data-test="tray-stub"]')).not.toBeNull(); }); - it("opens the + menu with a search, the context groups, Image and the keys footer", async () => { + it("opens the + menu with a search, the context groups and Image, and no voice row", async () => { seedMentionGroups(); const { wrapper } = setup(); await wrapper.get('[data-test="assistant-add-context"]').trigger("click"); @@ -618,11 +738,7 @@ describe("one frame, the + menu and long pastes (D4 §3.1, §3.6, §3.9)", () => expect(menu.querySelector('[data-test="assistant-mention-image"]')?.textContent).toContain( "Image…", ); - const keys = menu.querySelector('[data-test="assistant-add-context-keys"]')!; - expect(keys.querySelectorAll("li")).toHaveLength(5); - expect( - keys.querySelector('[data-test="assistant-composer-hint-voice"]')?.textContent, - ).toContain("Voice dictation is off in this browser."); + expect(menu.textContent).not.toContain("Voice dictation"); }); it("adds a + pick as a chip without touching the typed text", async () => { @@ -816,7 +932,9 @@ describe("round 2 (U7–U9, U12, U16, U18–U19, F5, F8)", () => { it("returns focus to Recent prompts when its sheet is dismissed (U20)", async () => { breakpoint.md = false; + vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("coarse") })); const { wrapper } = setup(); + vi.unstubAllGlobals(); const state = (wrapper.vm as unknown as { $: { setupState: Record } }).$ .setupState; state.recallEntries = [{ text: "earlier question", chips: [], at: 1 }]; @@ -1059,10 +1177,10 @@ describe("dictation (WP-48)", () => { afterEach(() => { resetVoiceForTests(); vi.useRealTimers(); - document.body.innerHTML = ""; }); - it("renders no mic on self-hosted until the browser opt-in (A5, A30)", async () => { + it("renders no mic for a member while dictation is off in this browser (A5, A30)", async () => { + fake.member = true; const { wrapper } = setup(); expect(wrapper.find('[data-test="assistant-dictate"]').exists()).toBe(false); setVoiceOptIn("k1", true); @@ -1071,6 +1189,18 @@ describe("dictation (WP-48)", () => { expect(mic(wrapper).attributes("aria-pressed")).toBe("false"); }); + it("offers an admin Turn on in place, which makes the mic live", async () => { + const { wrapper } = setup(); + expect(mic(wrapper).attributes("aria-haspopup")).toBe("dialog"); + await mic(wrapper).trigger("click"); + await flushPromises(); + const turnOn = '[data-test="assistant-dictate-turn-on"]'; + document.body.querySelector(turnOn)!.click(); + await flushPromises(); + expect(mic(wrapper).attributes("aria-haspopup")).toBeUndefined(); + expect(document.body.querySelector(turnOn)).toBeNull(); + }); + it("renders the mic on Cloud by default", async () => { const { wrapper } = setup(); fake.current.capability.value.edition = "cloud"; @@ -1126,10 +1256,10 @@ describe("dictation (WP-48)", () => { await mic(ctx.wrapper).trigger("click"); await flushPromises(); expect(ctx.wrapper.get('[data-test="assistant-dictate-waiting"]').text()).toBe( - "Waiting for microphone permission", + "Waiting for permission", ); expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("true"); - expect(ctx.status()).toBe("Waiting for microphone permission"); + expect(ctx.status()).toBe("Waiting for permission"); await mic(ctx.wrapper).trigger("click"); await flushPromises(); @@ -1137,15 +1267,13 @@ describe("dictation (WP-48)", () => { expect(ctx.status()).toBe("Dictation cancelled."); }); - it("shows listening, names the engine (B14) and says the start once", async () => { + it("shows listening with nothing under the frame, and says the start once", async () => { const ctx = voiceSetup(); await listen(ctx); expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("true"); expect(mic(ctx.wrapper).attributes("aria-label")).toBe("Dictate"); expect(ctx.wrapper.get('[data-test="assistant-dictate-status"]').text()).toContain("Listening"); - expect(helper(ctx.wrapper).text()).toBe( - "Chrome · OpenObserve doesn't store the audio · Enter or Esc to finish, not send", - ); + expect(helper(ctx.wrapper).exists()).toBe(false); expect(ctx.status()).toBe("Listening. Press Enter, Escape or the mic to stop."); expect(ctx.textarea().attributes("data-test")).toBe("assistant-composer-field"); expect(ctx.wrapper.get('[data-test="assistant-composer"]').attributes("data-state")).toBe( @@ -1241,7 +1369,7 @@ describe("dictation (WP-48)", () => { expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("false"); }); - it("stops when a card arrives, and shows the disabled mic's reason", async () => { + it("stops when a card arrives; the inert frame has no mic", async () => { const ctx = voiceSetup(); await listen(ctx); ctx.store.state.sessions[ctx.sid].pendingConsent = { id: "c1" } as never; @@ -1249,8 +1377,8 @@ describe("dictation (WP-48)", () => { expect(ctx.wrapper.get('[data-test="assistant-composer"]').attributes("data-state")).toBe( "inert", ); - expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("false"); - expect(mic(ctx.wrapper).attributes("aria-disabled")).toBe("true"); + expect(ctx.wrapper.find('[data-test="assistant-dictate"]').exists()).toBe(false); + expect(engine.abort).toHaveBeenCalled(); }); it("stops when the composer is hidden, and on unmount", async () => { @@ -1287,12 +1415,13 @@ describe("dictation (WP-48)", () => { expect(helper(ctx.wrapper).text()).toBe("Microphone access is blocked for this site."); expect(ctx.status()).toBe("Microphone access is blocked for this site."); - expect(mic(ctx.wrapper).attributes("aria-disabled")).toBeUndefined(); + expect(mic(ctx.wrapper).attributes("aria-disabled")).toBe("true"); + expect(mic(ctx.wrapper).attributes("aria-haspopup")).toBe("dialog"); expect(ctx.wrapper.find('[data-test="assistant-dictate-how"]').exists()).toBe(true); expect(ctx.store.state.sessions[ctx.sid].composer.text).toBe("typed"); }); - it("offers Try again for an unreachable service, naming the vendor", async () => { + it("offers Retry for an unreachable service, naming the vendor", async () => { const ctx = voiceSetup(); await listen(ctx); engine.handlers!.onError("network"); @@ -1313,7 +1442,7 @@ describe("dictation (WP-48)", () => { engine.handlers!.onStart(); engine.meter.db = -30; await vi.advanceTimersByTimeAsync(SILENCE_MS); - const line = "Your browser didn't turn speech into text. Type instead, or try another browser."; + const line = "Your browser didn't turn speech into text. Reload to try again."; expect(helper(ctx.wrapper).text()).toBe(line); expect(mic(ctx.wrapper).attributes("aria-disabled")).toBe("true"); }); @@ -1325,9 +1454,7 @@ describe("dictation (WP-48)", () => { window.dispatchEvent(new Event("offline")); await flushPromises(); expect(mic(ctx.wrapper).attributes("aria-disabled")).toBe("true"); - expect(helper(ctx.wrapper).text()).toBe( - "You're offline. Your draft is kept; dictation needs a connection.", - ); + expect(helper(ctx.wrapper).text()).toBe("You're offline. Draft kept."); online.mockRestore(); window.dispatchEvent(new Event("online")); }); @@ -1342,13 +1469,26 @@ describe("dictation (WP-48)", () => { engine.handlers!.onStart(); await vi.advanceTimersByTimeAsync(0); expect(ctx.wrapper.get('[data-test="assistant-dictate-status"]').text()).toContain( - "release Ctrl I to stop", + "Release Ctrl I to stop", ); document.dispatchEvent(new KeyboardEvent("keyup", { key: "i" })); await vi.advanceTimersByTimeAsync(0); expect(mic(ctx.wrapper).attributes("aria-pressed")).toBe("false"); }); + it("takes a tap of the chord to the waiting request and starts nothing", async () => { + const ctx = voiceSetup(); + ctx.store.state.sessions[ctx.sid].pendingApproval = { id: "c1" } as PendingApproval; + await flushPromises(); + const focusCard = vi.fn(() => true); + const off = registerAssistantFocusTarget("card", focusCard); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "i", ctrlKey: true })); + document.dispatchEvent(new KeyboardEvent("keyup", { key: "i" })); + off(); + expect(focusCard).toHaveBeenCalled(); + expect(engine.handlers).toBeNull(); + }); + it("does not dictate on a hold while a card holds the composer", async () => { const ctx = voiceSetup(); ctx.store.state.sessions[ctx.sid].pendingConsent = { id: "c1" } as never; diff --git a/web/src/components/ai-assistant/composer/AssistantComposer.vue b/web/src/components/ai-assistant/composer/AssistantComposer.vue index 9d44d03f63..3af81823c4 100644 --- a/web/src/components/ai-assistant/composer/AssistantComposer.vue +++ b/web/src/components/ai-assistant/composer/AssistantComposer.vue @@ -83,6 +83,58 @@ along with this program. If not, see . + + + + + +
+ + {{ t("assistant.composer.replyHelper") }} + + + {{ t("assistant.composer.replyCancel") }} + +
+
. @select="pickMention" @close="closeMention" /> - + . :readonly="inertKind !== null" :placeholder="placeholder" :input-attrs="textboxAttrs" + :tabindex="inertKind ? -1 : undefined" + :class="frameClass" placeholder-tone="strong" data-test="assistant-composer" :data-state="state" + :data-rows="twoRows ? 2 : 1" @update:model-value="onInput" @keydown="onKeydown" @paste="onPaste" @focus="refreshPageContext" @blur="onBlur" > - - @@ -47,6 +51,10 @@ export interface AssistantDictationMeterProps { /** 0–1. */ level: number; elapsedMs: number; + /** Below 24 rem the word yields first. */ + narrow?: boolean; + /** At 20 rem the elapsed time goes too. */ + tight?: boolean; } const BAR_WEIGHTS = [0.6, 1, 0.8, 0.5] as const; @@ -58,7 +66,10 @@ const MIN_BAR_SCALE = 0.2; - diff --git a/web/src/components/ai-assistant/composer/composerRows.spec.ts b/web/src/components/ai-assistant/composer/composerRows.spec.ts new file mode 100644 index 0000000000..cccff0a52d --- /dev/null +++ b/web/src/components/ai-assistant/composer/composerRows.spec.ts @@ -0,0 +1,55 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; + +import { composerFitsOneRow, composerPopoverClass, type ComposerRowInput } from "./composerUi"; + +const ROW: ComposerRowInput = { + narrow: false, + extras: false, + text: "", + textWidth: 0, + frameWidth: 406, + controlsWidth: 216, +}; + +describe("composerFitsOneRow", () => { + it("holds one row while the draft fits beside the controls", () => { + expect(composerFitsOneRow(ROW)).toBe(true); + expect(composerFitsOneRow({ ...ROW, text: "Find the cause", textWidth: 96 })).toBe(true); + }); + + it("moves the text above the controls once it no longer fits", () => { + expect(composerFitsOneRow({ ...ROW, text: "long", textWidth: 180 })).toBe(false); + }); + + it("always takes two rows when narrow, with extras, or on a second line", () => { + expect(composerFitsOneRow({ ...ROW, narrow: true })).toBe(false); + expect(composerFitsOneRow({ ...ROW, extras: true })).toBe(false); + expect(composerFitsOneRow({ ...ROW, text: "a\nb" })).toBe(false); + }); + + it("stays on one row until the frame has been measured", () => { + expect(composerFitsOneRow({ ...ROW, text: "x".repeat(200), frameWidth: 0 })).toBe(true); + }); +}); + +describe("composerPopoverClass", () => { + it("takes 3 px a side off a 20 rem window", () => { + expect(composerPopoverClass(true)).toContain("calc(20rem-0.375rem)"); + expect(composerPopoverClass(false)).toContain("min(20rem"); + }); +}); diff --git a/web/src/components/ai-assistant/composer/composerUi.ts b/web/src/components/ai-assistant/composer/composerUi.ts index 2c54a143b1..c13c27b018 100644 --- a/web/src/components/ai-assistant/composer/composerUi.ts +++ b/web/src/components/ai-assistant/composer/composerUi.ts @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { reactive, toRaw } from "vue"; +import { computed, inject, reactive, toRaw, type ComputedRef, type InjectionKey } from "vue"; import type { useAssistantStore } from "@/composables/assistant"; import type { I18nText } from "@/types/i18n"; @@ -21,6 +21,15 @@ import type { I18nText } from "@/types/i18n"; /** How long a cap sentence stays under the tray (`40` §2.6). */ export const CAP_NOTICE_MS = 6_000; +/** At and below this width the composer always takes two rows and chips sit behind `+N`. */ +export const COMPOSER_NARROW_REM = 24; + +/** The narrowest window; popovers take its width less 3 px a side. */ +export const COMPOSER_TIGHT_REM = 20; + +/** Chips shown before `+N` by width: none when narrow, one at 27 rem, three in a full view. */ +export const COMPOSER_WIDE_REM = 40; + /** A cleared draft's notice; `token` is `null` across orgs, where no Undo is offered (`40` §9). */ export interface ClearedNotice { token: string | null; @@ -40,10 +49,57 @@ export interface ComposerUi { cleared: ClearedNotice | null; } +/** What a control inside the composer needs to know about the composer's own width. */ +export interface ComposerLayout { + narrow: boolean; + tight: boolean; + wide: boolean; + /** Dictation at 20 rem takes the room of `+N` and the chips. */ + yieldChips: boolean; +} + +/** The inputs of the one-row decision; widths in px, 0 when not measured yet. */ +export interface ComposerRowInput { + narrow: boolean; + /** Chips, images, a meter or anything else the control row must show beyond time. */ + extras: boolean; + text: string; + textWidth: number; + frameWidth: number; + controlsWidth: number; +} + +export const composerLayoutKey: InjectionKey> = + Symbol("assistantComposerLayout"); + const states = reactive(new Map()); let seq = 0; +// The frame's 12 px text inset and the 8 px the text keeps from the first control. +const TEXT_INSET_PX = 20; + +/** One row holds while the draft fits a line beside the controls and nothing else needs showing. */ +export function composerFitsOneRow(input: ComposerRowInput): boolean { + if (input.narrow || input.extras || input.text.includes("\n")) return false; + if (!input.text || input.frameWidth === 0) return true; + return input.textWidth + TEXT_INSET_PX + input.controlsWidth <= input.frameWidth; +} + +/** The composer's layout for a control inside it; outside a composer nothing is narrow. */ +export function useComposerLayout(): ComputedRef { + return inject( + composerLayoutKey, + computed(() => ({ narrow: false, tight: false, wide: false, yieldChips: false })), + false, + ); +} + +/** One popover width on every board; 3 px a side short of a 20 rem window. */ +export function composerPopoverClass(tight: boolean): string { + return tight ? "w-[calc(20rem-0.375rem)]" : "w-[min(20rem,calc(100vw-0.375rem))]"; +} + /** The per-session view state the composer, the tray and the shell share. */ export function composerUi(sid: string): ComposerUi { let state = states.get(sid); diff --git a/web/src/components/ai-assistant/composer/useAssistantVoice.ts b/web/src/components/ai-assistant/composer/useAssistantVoice.ts index 655f5b9830..5951d33c1e 100644 --- a/web/src/components/ai-assistant/composer/useAssistantVoice.ts +++ b/web/src/components/ai-assistant/composer/useAssistantVoice.ts @@ -88,18 +88,6 @@ const SENTENCE_KEYS: Record, I18nKey> = { safari: "assistant.voice.engine.apple", }; -const ALLOW_KEYS: Record, I18nKey> = { - chrome: "assistant.voice.allow.chrome", - edge: "assistant.voice.allow.edge", - safari: "assistant.voice.allow.safari", -}; - -const BRAND_SHORT: Record, string> = { - chrome: "Chrome", - edge: "Edge", - safari: "Safari", -}; - // One engine and one owner per tab: a second composer starting takes the microphone from the first. let shared: VoiceController | null = null; let engineOverride: VoiceEngine | null = null; @@ -113,6 +101,9 @@ export type VoiceMode = "off" | "browser"; /** What a text entry may offer: `silent` keeps a disabled mic rather than removing it mid-use (`40` §1.8.4). */ export type VoiceStatus = "off" | "unsupported" | "silent" | "ready"; +/** `none` renders no mic; `crossed` is a reason a new press cannot help; `off` is an admin's Turn on. */ +export type VoiceMic = "none" | "live" | "crossed" | "off"; + export type VoicePhase = "idle" | "waiting" | "listening"; export type VoiceVia = "mic" | "hold"; @@ -144,6 +135,8 @@ export interface VoiceTarget { /** True while this entry is the one a hold should dictate into. */ holdReady(): boolean; hold(): void; + /** A press shorter than a hold. */ + tap(): void; } export interface VoiceState { @@ -161,6 +154,8 @@ export interface VoiceState { denied: boolean; /** Tab memory: this browser exposes a recogniser that returns nothing (B58). */ silent: boolean; + /** Tab memory: this browser has no speech service for the UI language. */ + languageRefused: boolean; online: boolean; /** Runs on the device, so no vendor hears it and being offline is fine. */ onDevice: boolean; @@ -200,6 +195,8 @@ export interface VoiceComposerOptions { writeDraft(sid: Sid, text: string): void; /** Hold is refused here: inert, not visible. */ blocked(): boolean; + /** A short press: takes focus to a pending request, if one waits. */ + tap(): void; /** True on a touch device below `md`, which uses the keyboard's own dictation key. */ touch(): boolean; say(line: I18nText): void; @@ -210,6 +207,7 @@ export interface VoiceComposer { status: ComputedRef; /** The mic renders. */ show: ComputedRef; + mic: ComputedRef; /** Mic off for now, but still there. */ offline: ComputedRef; mine: ComputedRef; @@ -217,6 +215,8 @@ export interface VoiceComposer { waiting: ComputedRef; denied: ComputedRef; helperLine: ComputedRef; + /** The helper line reports a failure, not a state. */ + failure: ComputedRef; action: ComputedRef<"retry" | "how" | null>; elapsedMs: ComputedRef; level: ComputedRef; @@ -227,7 +227,8 @@ export interface VoiceComposer { shown(text: string): string; primerOpen: Ref; sentence: ComputedRef; - allowSteps: ComputedRef; + /** Saves the opt-in, which makes the mic live in place. */ + turnOn(): void; press(via: VoiceVia): void; acceptPrimer(): void; declinePrimer(): void; @@ -278,6 +279,7 @@ export function createVoiceController(engine: VoiceEngine): VoiceController { level: 0, denied: false, silent: false, + languageRefused: false, online: typeof navigator === "undefined" || navigator.onLine !== false, onDevice: false, }); @@ -339,6 +341,7 @@ export function createVoiceController(engine: VoiceEngine): VoiceController { function fail(current: VoiceRun, code: VoiceErrorCode): void { if (code === "mic_denied") state.denied = true; + if (code === "language_unsupported") state.languageRefused = true; finish(current, { kind: "error", code }); } @@ -481,9 +484,11 @@ export function createVoiceController(engine: VoiceEngine): VoiceController { } function releaseHold(): void { + const tapped = holdTimer !== null; clearHold(); if (holding && state.via === "hold") stop("release"); holding = false; + if (tapped) for (const target of targets) target.tap(); } function onKeydown(event: KeyboardEvent): void { @@ -614,6 +619,7 @@ export function holdKeys(): string { export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer { const { t } = useI18nTyped(); const store = useAssistantStore(); + const capability = useAssistantCapability(); const availability = useVoiceAvailability(); const controller = voiceController(); const state = controller.state; @@ -630,11 +636,21 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer const listening = computed(() => mine.value && state.phase === "listening"); const waiting = computed(() => mine.value && state.phase === "waiting"); const status = availability.status; - const usable = computed(() => status.value === "ready" || status.value === "silent"); - const show = computed(() => usable.value && !options.touch()); const offline = computed(() => !state.online && !state.onDevice); + const mic = computed(() => { + if (options.touch() || status.value === "unsupported") return "none"; + if (status.value === "off") return capability.value.isAdmin ? "off" : "none"; + const unavailable = + status.value === "silent" || state.denied || state.languageRefused || offline.value; + return unavailable && !mine.value ? "crossed" : "live"; + }); + const show = computed(() => mic.value !== "none"); const brand = () => controller.engine.brand(); + const languageLine = computed(() => + t("assistant.voice.language", { language: raw(languageEndonym(getIntlLocale())) }), + ); + const errorLine = computed(() => { const code = lastError.value; if (!code) return null; @@ -642,43 +658,42 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer return t("assistant.voice.error.service_unreachable", { vendor: raw(serviceVendor(brand() ?? "chrome")), }); - if (code === "language_unsupported") - return t("assistant.voice.error.language_unsupported", { - language: raw(languageEndonym(getIntlLocale())), - }); + if (code === "language_unsupported") return languageLine.value; + if (code === "browser_silent") return t("assistant.voice.silent"); return t(`assistant.voice.error.${code}`); }); - const listeningLine = computed(() => { - const short = state.onDevice - ? t("assistant.voice.engineShort.device") - : brandShort(brand(), t("assistant.voice.engineShort.browser")); - const line = t("assistant.voice.helperListening", { engineShort: short }); - return raw(`${line} · ${t("assistant.voice.helperFinish")}`); + // A reason a new press cannot help outlives the edit that clears a one-off error. + const stuckLine = computed(() => { + if (status.value === "silent") return t("assistant.voice.silent"); + if (state.denied) return t("assistant.voice.error.mic_denied"); + return state.languageRefused ? languageLine.value : null; }); const helperLine = computed(() => { - if (listening.value) return listeningLine.value; if (enterHint.value) return t("assistant.voice.enterToSend"); - return errorLine.value; + return stuckLine.value ?? errorLine.value; }); + const failure = computed( + () => + helperLine.value !== null && + !enterHint.value && + (state.denied || !!lastError.value?.startsWith("service_unreachable")), + ); + const action = computed(() => { - if (lastError.value === "mic_denied") return "how"; + if (enterHint.value) return null; + if (state.denied) return "how"; return lastError.value?.startsWith("service_unreachable") ? "retry" : null; }); const rowLabel = computed(() => state.via === "hold" - ? t("assistant.voice.releaseToStop", { keys: raw(holdKeys()) }) + ? t("assistant.voice.holdLine", { keys: raw(holdKeys()) }) : t("assistant.voice.listening"), ); - const allowSteps = computed(() => { - const current = brand(); - return t(current ? ALLOW_KEYS[current] : "assistant.voice.allow.other"); - }); - function clearHint(): void { enterHint.value = false; if (enterTimer) clearTimeout(enterTimer); @@ -711,13 +726,12 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer }, ended, holdReady: () => - status.value === "ready" && - !options.touch() && - !offline.value && + mic.value === "live" && !options.blocked() && !composingHere && document.activeElement === options.field(), hold: () => press("hold"), + tap: options.tap, }; function begin(via: VoiceVia): void { @@ -730,7 +744,7 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer function press(via: VoiceVia): void { if (mine.value) return controller.stop("mic"); - if (status.value !== "ready" || offline.value || composingHere) return; + if (mic.value !== "live" || composingHere) return; const key = store.state.userOrgKey; if (key && readHints(key).dictate) return begin(via); pendingVia = via; @@ -749,6 +763,11 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer options.focusField(); } + function turnOn(): void { + const key = store.state.userOrgKey; + if (key) setVoiceOptIn(key, true); + } + function stopMine(reason: VoiceStopReason): void { if (mine.value) controller.stop(reason); } @@ -780,12 +799,14 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer return { status, show, + mic, offline, mine, listening, waiting, denied: computed(() => state.denied), helperLine, + failure, action, elapsedMs: computed(() => state.elapsedMs), level: computed(() => state.level), @@ -799,7 +820,7 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer }, primerOpen, sentence: availability.sentence, - allowSteps, + turnOn, press, acceptPrimer, declinePrimer, @@ -812,10 +833,6 @@ export function useAssistantVoice(options: VoiceComposerOptions): VoiceComposer }; } -function brandShort(brand: VoiceBrand, fallback: I18nText): I18nText { - return brand ? raw(BRAND_SHORT[brand]) : fallback; -} - function unreachableCode(engine: VoiceEngine): VoiceErrorCode { return engine.brand() ? "service_unreachable" : "service_unreachable_generic"; } diff --git a/web/src/components/ai-assistant/composer/useComposerWidth.ts b/web/src/components/ai-assistant/composer/useComposerWidth.ts new file mode 100644 index 0000000000..0231c30db3 --- /dev/null +++ b/web/src/components/ai-assistant/composer/useComposerWidth.ts @@ -0,0 +1,71 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { computed, onBeforeUnmount, onMounted, ref, type ComputedRef, type Ref } from "vue"; + +import { COMPOSER_NARROW_REM, COMPOSER_TIGHT_REM, COMPOSER_WIDE_REM } from "./composerUi"; + +export interface ComposerWidth { + /** The composer's own width in px; 0 until measured. */ + width: Ref; + narrow: ComputedRef; + tight: ComputedRef; + wide: ComputedRef; +} + +/** Follows the composer's own width, since a window or a sidebar is narrow on any screen. */ +export function useComposerWidth(root: Ref): ComposerWidth { + const width = ref(0); + const rem = ref(16); + let observer: ResizeObserver | null = null; + + const atMost = (limit: number) => width.value > 0 && width.value <= limit * rem.value; + + onMounted(() => { + const target = root.value; + if (!target || typeof ResizeObserver === "undefined") return; + rem.value = parseFloat(getComputedStyle(document.documentElement).fontSize) || rem.value; + observer = new ResizeObserver(() => (width.value = target.getBoundingClientRect().width)); + observer.observe(target); + }); + onBeforeUnmount(() => observer?.disconnect()); + + return { + width, + narrow: computed(() => atMost(COMPOSER_NARROW_REM)), + tight: computed(() => atMost(COMPOSER_TIGHT_REM)), + wide: computed(() => width.value >= COMPOSER_WIDE_REM * rem.value), + }; +} + +/** A control row's own width in a frame that may stretch it: its children and their gaps. */ +export function controlsWidthOf(row: HTMLElement | null): number { + if (!row) return 0; + const parts = Array.from(row.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement && !("spacer" in child.dataset), + ); + const gap = parseFloat(getComputedStyle(row).columnGap) || 0; + return parts.reduce((sum, part) => sum + part.offsetWidth, gap * (parts.length - 1)); +} + +/** The draft's width in the textbox's own font; 0 where the browser has no canvas. */ +export function textWidthOf(field: HTMLTextAreaElement | null, text: string): number { + if (!field || !text) return 0; + const context = document.createElement("canvas").getContext("2d"); + if (!context) return 0; + const style = getComputedStyle(field); + context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + return context.measureText(text).width; +} diff --git a/web/src/components/ai-assistant/context/AssistantChipPopover.spec.ts b/web/src/components/ai-assistant/context/AssistantChipPopover.spec.ts index cdf2c1b509..1664518806 100644 --- a/web/src/components/ai-assistant/context/AssistantChipPopover.spec.ts +++ b/web/src/components/ai-assistant/context/AssistantChipPopover.spec.ts @@ -140,47 +140,6 @@ describe("AssistantChipPopover", () => { expect(img.className).toContain("max-h-80"); }); - it("offers Use page time only once the time is custom, and Apply only after a pick (CT-01 AC 4–5)", async () => { - const time: ContextChip = { - ...ROW, - kind: "time", - label: raw("Time · last 15 min"), - data: undefined, - provenance: "page", - }; - const { wrapper } = await open({ - chip: time, - window: { from: 0, to: 900_000, period: "15m", source: "page" }, - }); - const picker = () => - document.body.querySelector('[data-test="assistant-context-time-picker"]')!; - expect( - picker().querySelector('[data-test="assistant-context-time-page"]')! - .disabled, - ).toBe(true); - expect( - picker().querySelector('[data-test="assistant-context-time-apply"]')! - .disabled, - ).toBe(true); - await wrapper.setProps({ window: { from: 0, to: 3_600_000, period: "1h", source: "custom" } }); - picker().querySelector('[data-test="assistant-context-time-page"]')!.click(); - expect(wrapper.emitted("apply-time")).toEqual([[null]]); - }); - - it("says why the default applies on a page without a range (CT-01 AC 2)", async () => { - const time: ContextChip = { - ...ROW, - kind: "time", - label: raw("Time · last 15 min · default"), - data: undefined, - provenance: "page", - }; - await open({ chip: time, window: { from: 0, to: 900_000, period: "15m", source: "default" } }); - expect( - document.body.querySelector('[data-test="assistant-context-time-default-note"]')?.textContent, - ).toContain("this page has no time range"); - }); - it("opens on its first action, and Tab at either edge hands focus back to the tray (CT-03 AC 5, U3)", async () => { const big = { ...ROW, data: { message: "z".repeat(2_000), _o2_id: "1" } }; const { wrapper } = await open({ chip: big }); diff --git a/web/src/components/ai-assistant/context/AssistantChipPopover.vue b/web/src/components/ai-assistant/context/AssistantChipPopover.vue index fba0821584..ed70f1146c 100644 --- a/web/src/components/ai-assistant/context/AssistantChipPopover.vue +++ b/web/src/components/ai-assistant/context/AssistantChipPopover.vue @@ -20,7 +20,7 @@ along with this program. If not, see . privacy="mask" side="top" align="start" - :aria-label="chip.kind === 'time' ? t('assistant.context.time.swap') : chip.label" + :aria-label="chip.label" content-class="flex w-[min(28rem,calc(100vw-1.5rem))] max-h-[min(32rem,70vh)] flex-col gap-3 overflow-y-auto p-3" @update:open="(value) => emit('update:open', value)" > @@ -30,7 +30,7 @@ along with this program. If not, see .
@@ -42,48 +42,7 @@ along with this program. If not, see .
- - -