From 0d7154f3d33f3fa12d9e95e615aa309d1094ac84 Mon Sep 17 00:00:00 2001 From: ktx-abhay Date: Wed, 4 Nov 2026 12:38:10 +0530 Subject: [PATCH] fix(oncall): no header overlap, sticky note box, My on-call pages and scrolls inside --- .../oncall/OnCallActivityTimeline.spec.ts | 59 +++++++ .../oncall/OnCallActivityTimeline.vue | 39 ++++- .../oncall/OnCallClampedText.spec.ts | 73 +++++++++ .../components/oncall/OnCallClampedText.vue | 80 ++++++++++ .../oncall/OnCallMyDeliveries.spec.ts | 53 +++++++ .../components/oncall/OnCallMyDeliveries.vue | 34 ++-- .../components/shared/ActivityTimeline.vue | 17 +- web/src/locales/languages/ar-SA.json | 2 + web/src/locales/languages/de-DE.json | 2 + web/src/locales/languages/en-US.json | 2 + web/src/locales/languages/es-ES.json | 2 + web/src/locales/languages/fr-FR.json | 2 + web/src/locales/languages/it-IT.json | 2 + web/src/locales/languages/ja-JP.json | 2 + web/src/locales/languages/ko-KR.json | 2 + web/src/locales/languages/nl-NL.json | 2 + web/src/locales/languages/pl-PL.json | 2 + web/src/locales/languages/pt-PT.json | 2 + web/src/locales/languages/ru-RU.json | 2 + web/src/locales/languages/th-TH.json | 2 + web/src/locales/languages/tr-TR.json | 2 + web/src/locales/languages/vi-VN.json | 2 + web/src/locales/languages/zh-CN.json | 2 + web/src/locales/languages/zh-TW.json | 2 + web/src/views/OnCall/OnCallMine.vue | 20 +-- .../views/OnCall/OnCallResponseDetail.spec.ts | 38 ++++- web/src/views/OnCall/OnCallResponseDetail.vue | 149 ++++++++++++------ 27 files changed, 511 insertions(+), 85 deletions(-) create mode 100644 web/src/components/oncall/OnCallClampedText.spec.ts create mode 100644 web/src/components/oncall/OnCallClampedText.vue diff --git a/web/src/components/oncall/OnCallActivityTimeline.spec.ts b/web/src/components/oncall/OnCallActivityTimeline.spec.ts index b61e8a5844..a685a20736 100644 --- a/web/src/components/oncall/OnCallActivityTimeline.spec.ts +++ b/web/src/components/oncall/OnCallActivityTimeline.spec.ts @@ -162,4 +162,63 @@ describe("OnCallActivityTimeline", () => { expect(text).toContain("14:37"); expect(text).toContain("14:39 · +2m"); }); + /// The agent's paragraph is held to three lines with a way to read the rest; generated lines are not. + it("clamps the AI verdict line and leaves the other lines whole", async () => { + const verdict: OnCallResponseEvent = { + kind: "ai_verdict", + at: OPENED + 300_000_000, + actor: "o2-sre", + body: "The alert fired because the test stream carries synthetic data. ".repeat(8), + }; + const wrapper = mount(OnCallActivityTimeline, { + props: { events: [...events, verdict], commentText: "", openedAt: OPENED }, + global: { plugins: [i18n, store] }, + }); + await flushPromises(); + + const clamped = wrapper.findAll('[data-test="oncall-clamped-text-body"]'); + expect(clamped).toHaveLength(1); + expect(clamped[0].text()).toContain("synthetic data"); + expect(clamped[0].classes()).toContain("line-clamp-3"); + }); + + describe("pinned feed", () => { + const at = (n: number): OnCallResponseEvent => ({ + kind: "note", + at: OPENED + n * 1_000_000, + actor: "ana@o2.ai", + body: `note ${n}`, + }); + async function pinned() { + const wrapper = mount(OnCallActivityTimeline, { + props: { events: [at(1)], commentText: "", openedAt: OPENED, pinComposer: true }, + global: { plugins: [i18n, store] }, + }); + await flushPromises(); + const feed = wrapper.find(".overflow-y-auto").element as HTMLElement; + Object.defineProperty(feed, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(feed, "clientHeight", { configurable: true, value: 200 }); + return { wrapper, feed }; + } + + it("follows a new event when the reader is at the newest line", async () => { + const { wrapper, feed } = await pinned(); + feed.scrollTop = 800; + feed.dispatchEvent(new Event("scroll")); + await wrapper.setProps({ events: [at(1), at(2)] }); + await flushPromises(); + + expect(feed.scrollTop).toBe(1000); + }); + + it("leaves a reader scrolled up into the history where they are", async () => { + const { wrapper, feed } = await pinned(); + feed.scrollTop = 100; + feed.dispatchEvent(new Event("scroll")); + await wrapper.setProps({ events: [at(1), at(2)] }); + await flushPromises(); + + expect(feed.scrollTop).toBe(100); + }); + }); }); diff --git a/web/src/components/oncall/OnCallActivityTimeline.vue b/web/src/components/oncall/OnCallActivityTimeline.vue index 8c58a1245f..b0676d0214 100644 --- a/web/src/components/oncall/OnCallActivityTimeline.vue +++ b/web/src/components/oncall/OnCallActivityTimeline.vue @@ -30,10 +30,17 @@ along with this program. If not, see . data-test="oncall-activity-people" @pointerover="onPointerOver" @pointerout="onPointerOut" + @scroll.capture="onFeedScroll" > + . data-test-scroll-bottom="oncall-response-activity-scroll-bottom" data-test-comment-input="oncall-response-note-input" data-test-comment-send="oncall-response-note-submit" - @submit="$emit('submit')" - /> + @submit="onSubmit" + > + + nameOf(String(store.state.userInfo?.email ?? "")) || "User"); const HUMAN_KINDS: ResponseEventKind[] = ["ack", "handoff", "note"]; +/// The agent's free text runs to a paragraph; every other line is one generated sentence. +const CLAMPED_KINDS: ResponseEventKind[] = ["ai_verdict", "rca"]; const visibleEvents = computed(() => { const merged = mergeTimelineEvents(props.events); @@ -156,11 +171,25 @@ const formatRelativeTime = (at: number): string => const timelineRef = ref<{ scrollToBottom: () => void } | null>(null); -// Chat order: the newest line sits just above the note box, so a pinned feed opens scrolled to it. +// Chat order: the newest line sits just above the note box; a reader scrolled up into the history stays where they are. +const followNewest = ref(true); +const BOTTOM_SLACK = 8; + +function onFeedScroll(e: Event) { + const el = e.target; + if (!(el instanceof HTMLElement)) return; + followNewest.value = el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_SLACK; +} + +function onSubmit() { + followNewest.value = true; + emit("submit"); +} + watch( () => visibleEvents.value.length, async () => { - if (!props.pinComposer) return; + if (!props.pinComposer || !followNewest.value) return; await nextTick(); timelineRef.value?.scrollToBottom(); }, diff --git a/web/src/components/oncall/OnCallClampedText.spec.ts b/web/src/components/oncall/OnCallClampedText.spec.ts new file mode 100644 index 0000000000..64d7248cc7 --- /dev/null +++ b/web/src/components/oncall/OnCallClampedText.spec.ts @@ -0,0 +1,73 @@ +// 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 { flushPromises, mount } from "@vue/test-utils"; +import { afterEach, describe, expect, it } from "vitest"; + +import OnCallClampedText from "@/components/oncall/OnCallClampedText.vue"; +import i18n from "@/locales"; + +const proto = HTMLElement.prototype; +const original = { + scrollHeight: Object.getOwnPropertyDescriptor(proto, "scrollHeight"), + clientHeight: Object.getOwnPropertyDescriptor(proto, "clientHeight"), +}; + +function sized(scrollHeight: number, clientHeight: number) { + Object.defineProperty(proto, "scrollHeight", { configurable: true, get: () => scrollHeight }); + Object.defineProperty(proto, "clientHeight", { configurable: true, get: () => clientHeight }); +} + +async function render() { + const wrapper = mount(OnCallClampedText, { + props: { html: "The alert fired because the stream is synthetic." }, + global: { plugins: [i18n] }, + }); + await flushPromises(); + return wrapper; +} + +describe("OnCallClampedText", () => { + afterEach(() => { + for (const [key, desc] of Object.entries(original)) { + if (desc) Object.defineProperty(proto, key, desc); + } + }); + + it("offers no toggle when the text fits in three lines", async () => { + sized(60, 60); + const wrapper = await render(); + + expect(wrapper.find('[data-test="oncall-clamped-text-toggle"]').exists()).toBe(false); + expect(wrapper.find('[data-test="oncall-clamped-text-body"]').classes()).toContain( + "line-clamp-3", + ); + }); + + it("opens the full text and closes it again", async () => { + sized(200, 60); + const wrapper = await render(); + const toggle = () => wrapper.find('[data-test="oncall-clamped-text-toggle"]'); + const body = () => wrapper.find('[data-test="oncall-clamped-text-body"]'); + + expect(toggle().text()).toBe("Show more"); + await toggle().trigger("click"); + expect(body().classes()).not.toContain("line-clamp-3"); + expect(toggle().text()).toBe("Show less"); + await toggle().trigger("click"); + expect(body().classes()).toContain("line-clamp-3"); + expect(body().html()).toContain("the stream"); + }); +}); diff --git a/web/src/components/oncall/OnCallClampedText.vue b/web/src/components/oncall/OnCallClampedText.vue new file mode 100644 index 0000000000..96af64b02f --- /dev/null +++ b/web/src/components/oncall/OnCallClampedText.vue @@ -0,0 +1,80 @@ + + + + + + diff --git a/web/src/components/oncall/OnCallMyDeliveries.spec.ts b/web/src/components/oncall/OnCallMyDeliveries.spec.ts index 15c3bfed77..c68f24f25a 100644 --- a/web/src/components/oncall/OnCallMyDeliveries.spec.ts +++ b/web/src/components/oncall/OnCallMyDeliveries.spec.ts @@ -592,6 +592,59 @@ describe("OnCallMyDeliveries", () => { ); }); + describe("needs you pager", () => { + const open = (n: number) => + Array.from({ length: n }, (_, i) => + delivery({ event_id: `ev_${i}`, response_id: `resp_${i}`, at: AT + i }), + ); + const needsYou = (wrapper: ReturnType) => + wrapper.findComponent('[data-test="oncall-my-needs-you-table"]'); + + it("hides the pager while every open page fits", async () => { + answer(open(20)); + const wrapper = render(); + await flushPromises(); + + expect(needsYou(wrapper).attributes("pagination")).toBe("none"); + }); + + it("pages the open pages once they outgrow one page", async () => { + answer(open(21)); + const wrapper = render(); + await flushPromises(); + + expect(needsYou(wrapper).attributes("pagination")).toBe("client"); + expect(needsYou(wrapper).props("pageSize")).toBe(20); + }); + + it("keeps the chosen page size for the next visit", async () => { + answer(open(21)); + const wrapper = render(); + await flushPromises(); + needsYou(wrapper).vm.$emit("update:pageSize", 50); + await flushPromises(); + + const saved = useListViewState("oncall-my-deliveries", {}) as Record; + expect(saved.openPageSize).toBe(50); + }); + + it("pages through the open pages in the real table", async () => { + answer(open(25)); + const { OTable: _table, ...rest } = stubs; + const wrapper = mount(OnCallMyDeliveries, { + global: { plugins: [i18n, store], stubs: rest }, + }); + await flushPromises(); + + const card = wrapper.find('[data-test="oncall-my-needs-you"]'); + expect(card.find('[data-test="o2-table-pagination-bottom"]').exists()).toBe(true); + expect(card.find('[data-test="o2-table-pagination-info"]').text()).toMatch(/1\D+20\D+25/); + await card.find('[data-test="o2-table-next-page-btn"]').trigger("click"); + await flushPromises(); + expect(card.find('[data-test="o2-table-pagination-info"]').text()).toMatch(/21\D+25\D+25/); + }); + }); + describe("list view state", () => { const defaults = { unreadOnly: false, currentPage: 1, pageSize: 20 }; diff --git a/web/src/components/oncall/OnCallMyDeliveries.vue b/web/src/components/oncall/OnCallMyDeliveries.vue index 4fdcb349f3..7e0d40e720 100644 --- a/web/src/components/oncall/OnCallMyDeliveries.vue +++ b/web/src/components/oncall/OnCallMyDeliveries.vue @@ -16,7 +16,10 @@ along with this program. If not, see .