feat(ai): spin status ring on scroll-to-bottom button while generating

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-21 01:07:35 +08:00
1 parent 29470b8b3f
commit 89d29b28bc
2 files changed
+84 -1

No files matched your search

@@ -5192,13 +5192,26 @@ async function openExternalUrl(url: string) {
</div>
</div>
</ScrollArea>
<!-- Scroll-to-bottom affordance with an AI busy indicator. Shown when the
viewport is scrolled away from the bottom (hysteresis in
updateMessageScrollState) OR while a run is active for the visible
conversation — then the button doubles as a status marker: a
decorative rotating arc ring (aria-hidden; static under
prefers-reduced-motion) tells the user at a glance that the
assistant is still working, even when auto-scroll already pins the
view to the bottom. -->
<button
v-if="showScrollToBottom"
v-if="showScrollToBottom || isGenerating"
type="button"
class="absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 text-foreground shadow-md backdrop-blur hover:bg-muted"
:title="t('ai.scrollToBottom')"
@click="scrollToBottom({ force: true })"
>
<svg v-if="isGenerating" data-ai-scroll-ring aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full animate-spin text-primary motion-reduce:animate-none" viewBox="0 0 32 32" fill="none">
<!-- ~288° open arc of the r=13 ring box (statement-gutter spinner
geometry scaled to 32 units); spins clockwise via animate-spin. -->
<path d="M29 16a13 13 0 1 1-8.98-12.36" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
<ArrowDown class="h-4 w-4" />
<span class="sr-only">{{ t("ai.scrollToBottom") }}</span>
</button>
@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Regression coverage for the AI status ring on the scroll-to-bottom button:
//
// While a run is active for the visible conversation (`isGenerating`), the
// scroll-to-bottom button must stay visible even when the user is already at
// the bottom, and must render a decorative rotating arc ring (an "assistant is
// working" marker, Si-Octo-style). The ring is aria-hidden decoration driven
// purely by `isGenerating` — the scroll-driven hysteresis (`showScrollToBottom`
// + the MESSAGE_SCROLL_BUTTON_* thresholds) must stay the single source of the
// scroll path, and clicking must still force-scroll to the bottom.
//
// AiAssistant.vue is a ~6000-line SFC the suite never mounts (see
// .trellis/spec/app-tests/frontend/component-guidelines.md): these assertions
// pin the template wiring as text, matching the house source-assertion style.
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
const buttonMatch = source.match(/<button[^>]*v-if="showScrollToBottom \|\| isGenerating"[\s\S]*?<\/button>/);
function buttonEl(): string {
if (!buttonMatch) {
throw new Error('scroll-to-bottom button gated on "showScrollToBottom || isGenerating" not found in AiAssistant.vue');
}
return buttonMatch[0];
}
describe("AI assistant scroll-to-bottom button status ring", () => {
it("keeps the button visible while a run is active, not only when scrolled away", () => {
expect(buttonEl()).toContain('v-if="showScrollToBottom || isGenerating"');
});
it("renders the decorative rotating arc ring only while generating", () => {
const button = buttonEl();
const ring = button.match(/<svg[\s\S]*?<\/svg>/);
if (!ring) throw new Error("expected the inline status-ring <svg> inside the scroll-to-bottom button");
const ringEl = ring[0];
expect(ringEl).toContain('v-if="isGenerating"');
// Decorative: flagged for tests/e2e, excluded from the accessibility tree.
expect(ringEl).toContain("data-ai-scroll-ring");
expect(ringEl).toContain('aria-hidden="true"');
// House spinner idiom + the firm prefers-reduced-motion opt-out convention.
expect(ringEl).toContain("animate-spin");
expect(ringEl).toContain("motion-reduce:animate-none");
// Semantic token color — theme/dark-mode support comes free via currentColor.
expect(ringEl).toContain("text-primary");
// The ring must never swallow clicks, and fill="none" keeps the open arc
// from rendering as a filled pie slice.
expect(ringEl).toContain("pointer-events-none");
expect(ringEl).toContain('fill="none"');
// Open ~288° arc (statement-gutter spinner geometry scaled to the 32-unit
// ring box), round-capped like every other arc/spinner in the repo.
expect(ringEl).toContain("M29 16a13 13 0 1 1-8.98-12.36");
expect(ringEl).toContain('stroke-linecap="round"');
});
it("keeps the force scroll-to-bottom behavior and the arrow icon intact", () => {
const button = buttonEl();
expect(button).toContain("scrollToBottom({ force: true })");
expect(button).toContain("<ArrowDown");
expect(button).toContain('t("ai.scrollToBottom")');
});
it("leaves the scroll hysteresis thresholds untouched", () => {
// Visibility is an OR over the existing state machine; the thresholds must
// remain the single source of the scroll-driven path (show 120 > hide 48).
expect(source).toContain("MESSAGE_SCROLL_BUTTON_SHOW_THRESHOLD_PX = 120;");
expect(source).toContain("MESSAGE_SCROLL_BUTTON_HIDE_THRESHOLD_PX = 48;");
});
});