diff --git a/package.json b/package.json index 45038dc57..dfade9553 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.6.2", + "version": "0.6.3", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx index 3f41c7649..b3b828d6c 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { ServiceLogcat } from "./ServiceLogcat"; import type { LogEntry } from "./types"; @@ -65,3 +66,89 @@ export const WithRequestResponseDetail: Story = { export const Empty: Story = { args: { logs: [] }, }; + +/** Seeds the text filter box on mount — e.g. landing pre-filtered from a deep link. */ +export const WithInitialQuery: Story = { + args: { logs: SAMPLE_LOGS, initialQuery: "users" }, +}; + +/** Every user-facing string overridden — e.g. for a non-English locale. */ +export const Localized: Story = { + args: { + logs: SAMPLE_LOGS, + labels: { + title: "日誌", + live: "即時", + paused: "已暫停", + floorAll: "全部", + floorWarn: "警告以上", + floorError: "錯誤", + filterAriaLabel: "依層級篩選", + filterPlaceholder: "篩選紀錄", + clearFilterAriaLabel: "清除篩選", + copyAriaLabel: "複製紀錄", + noMatchingEntries: "沒有符合的紀錄。", + request: "請求", + response: "回應", + noRequestResponseBody: "沒有請求或回應內容。", + loadOlderEntries: "載入較舊的紀錄", + entriesLabel: (filteredCount, total, query) => { + const base = + filteredCount === total ? `共 ${total} 筆` : `${total} 筆中的 ${filteredCount} 筆`; + return query ? `${base} · "${query}"` : base; + }, + tailingStatus: "即時追蹤中", + pausedStatus: "已暫停", + }, + }, +}; + +// Older pages are generated on demand, seq-keyed, and appended at the OLD end +// (the bottom). Scroll near the bottom or click the row to load a page. +const PAGE_SIZE = 20; +const TOTAL_OLDER = 60; + +function olderPage(before: number): LogEntry[] { + return Array.from({ length: PAGE_SIZE }, (_, i): LogEntry => { + const seq = before - PAGE_SIZE + i; + const sec = String(seq % 60).padStart(2, "0"); + return { + seq, + ts: `2026-06-30T16:22:${sec}.000000000Z`, + level: seq % 13 === 0 ? "warn" : "info", + msg: `GET /api/items?page=${seq} 200 ${5 + (seq % 20)}ms`, + duration_ms: 5 + (seq % 20), + }; + }); +} + +function LoadOlderDemo() { + const [logs, setLogs] = useState(() => + SAMPLE_LOGS.map((l, i) => ({ ...l, seq: TOTAL_OLDER + i })), + ); + const [loadingOlder, setLoadingOlder] = useState(false); + const oldestSeq = logs[0]?.seq ?? TOTAL_OLDER; + + const onLoadOlder = () => { + setLoadingOlder(true); + setTimeout(() => { + setLogs((cur) => [...olderPage(oldestSeq), ...cur]); + setLoadingOlder(false); + }, 800); + }; + + return ( +
+ 0} + loadingOlder={loadingOlder} + /> +
+ ); +} + +export const LoadOlder: Story = { + render: () => , +}; diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.test.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.test.tsx new file mode 100644 index 000000000..08a54b116 --- /dev/null +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.test.tsx @@ -0,0 +1,124 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ServiceLogcat } from "./ServiceLogcat"; +import type { LogEntry } from "./types"; + +afterEach(cleanup); + +// Chronological (oldest first), matching the component contract. +const LOGS: LogEntry[] = [ + { ts: "2026-06-30T16:23:40.000000000Z", level: "info", msg: "GET /api/a 200 8ms" }, + { ts: "2026-06-30T16:23:41.000000000Z", level: "warn", msg: "Rate limit approaching" }, + { ts: "2026-06-30T16:23:42.000000000Z", level: "error", msg: "GET /api/b 500" }, +]; + +// The mono scroll surface inside the Surface card. +function scrollEl(container: HTMLElement): HTMLElement { + return container.querySelector(".overflow-y-auto") as HTMLElement; +} + +// jsdom reports zero layout; stub the metrics the scroll handler reads. +function stubMetrics(el: HTMLElement, metrics: { scrollHeight: number; clientHeight: number }) { + Object.defineProperty(el, "scrollHeight", { + get: () => metrics.scrollHeight, + configurable: true, + }); + Object.defineProperty(el, "clientHeight", { + get: () => metrics.clientHeight, + configurable: true, + }); +} + +describe("ServiceLogcat load-older", () => { + it("fires onLoadOlder from the button row", () => { + const onLoadOlder = vi.fn(); + render(); + fireEvent.click(screen.getByText("Load older entries")); + expect(onLoadOlder).toHaveBeenCalledTimes(1); + }); + + it("renders no affordance when hasOlder is false or onLoadOlder is absent", () => { + const { rerender } = render( + {}} hasOlder={false} />, + ); + expect(screen.queryByText("Load older entries")).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByText("Load older entries")).not.toBeInTheDocument(); + }); + + it("shows the loading state and suppresses re-fire while loadingOlder", () => { + const onLoadOlder = vi.fn(); + const { container } = render( + , + ); + const row = screen.getByText("Load older entries"); + expect(row.closest("button")).toBeDisabled(); + fireEvent.click(row); + // Near-bottom scroll must not fire either while a page is in flight. + // (Twice: the first event is consumed by the programmatic-scroll guard.) + const el = scrollEl(container); + stubMetrics(el, { scrollHeight: 1000, clientHeight: 200 }); + el.scrollTop = 790; + fireEvent.scroll(el); + fireEvent.scroll(el); + expect(onLoadOlder).not.toHaveBeenCalled(); + }); + + it("auto-fires once when scrolled near the bottom", () => { + const onLoadOlder = vi.fn(); + const { container } = render( + , + ); + const el = scrollEl(container); + stubMetrics(el, { scrollHeight: 1000, clientHeight: 200 }); + el.scrollTop = 400; // far from the bottom + fireEvent.scroll(el); // consumed by the programmatic-scroll guard + fireEvent.scroll(el); + expect(onLoadOlder).not.toHaveBeenCalled(); + el.scrollTop = 790; // 10px from the bottom + fireEvent.scroll(el); + fireEvent.scroll(el); // burst of events fires once + expect(onLoadOlder).toHaveBeenCalledTimes(1); + }); + + it("keeps scrollTop when older rows append at the bottom, still offsets prepends", () => { + const metrics = { scrollHeight: 400, clientHeight: 200 }; + const withSeq = (l: LogEntry, seq: number): LogEntry => ({ ...l, seq }); + const logs = LOGS.map((l, i) => withSeq(l, 10 + i)); + const { container, rerender } = render( + {}} hasOlder />, + ); + const el = scrollEl(container); + stubMetrics(el, metrics); + fireEvent.click(screen.getByText("Live")); // stop tail-follow + el.scrollTop = 150; + fireEvent.scroll(el); // unpin from the top + + // Older page appended at the bottom: height grows below, scrollTop holds. + metrics.scrollHeight = 600; + const older: LogEntry[] = [ + withSeq({ ts: "2026-06-30T16:22:00.000000000Z", level: "info", msg: "older line" }, 1), + ]; + rerender( {}} hasOlder />); + expect(el.scrollTop).toBe(150); + + // Newer line prepended above: scrollTop offsets by the added height. + metrics.scrollHeight = 800; + const newer: LogEntry[] = [ + withSeq({ ts: "2026-06-30T16:24:00.000000000Z", level: "info", msg: "newer line" }, 99), + ]; + rerender( + {}} hasOlder />, + ); + expect(el.scrollTop).toBe(350); + }); + + it("renders seq-keyed rows, including repeated ts+msg lines", () => { + const dup: LogEntry[] = [ + { seq: 1, ts: "2026-06-30T16:23:40.000000000Z", level: "info", msg: "retrying" }, + { seq: 2, ts: "2026-06-30T16:23:40.000000000Z", level: "info", msg: "retrying" }, + ]; + render(); + expect(screen.getAllByText("retrying")).toHaveLength(2); + }); +}); diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx index 7b53b7939..e06b15d1f 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx @@ -1,5 +1,6 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Badge, type BadgeVariant } from "@/components/ui/feedback/badge/Badge"; +import { Button } from "@/components/ui/actions/button/Button"; import { FloatingLabelInput } from "@/components/ui/inputs/floating-label-input/FloatingLabelInput"; import { IconButton } from "@/components/ui/actions/icon-button/IconButton"; import { SectionLabel } from "@/components/ui/display/section-label/SectionLabel"; @@ -19,12 +20,71 @@ import { export const meta: ComponentMeta = { name: "ServiceLogcat", description: - "Service log console with severity-floor filter, text filter, live-tail toggle, copy, and an expandable request/response detail per line", + "Service log console with severity-floor filter, text filter, live-tail toggle, copy, load-older paging, and an expandable request/response detail per line", }; +/** Every user-facing string ServiceLogcat renders, for callers that need it + * in a locale other than English — all optional, defaulting to the current + * English copy, so passing nothing is fully backward compatible. */ +export interface ServiceLogcatLabels { + /** Default: "Logcat" */ + title?: string; + /** Live-tail toggle when on. Default: "Live" */ + live?: string; + /** Live-tail toggle when off. Default: "Paused" */ + paused?: string; + /** Severity-floor option: no filter. Default: "All" */ + floorAll?: string; + /** Severity-floor option: warn and above. Default: "Warn+" */ + floorWarn?: string; + /** Severity-floor option: errors only. Default: "Errors" */ + floorError?: string; + /** aria-label on the severity-floor segmented control. Default: "Filter by level" */ + filterAriaLabel?: string; + /** Text-filter input label. Default: "Filter logs" */ + filterPlaceholder?: string; + /** aria-label on the clear-filter button. Default: "Clear filter" */ + clearFilterAriaLabel?: string; + /** aria-label on the copy-logs button. Default: "Copy logs" */ + copyAriaLabel?: string; + /** Shown when the filter matches nothing. Default: "No matching log entries." */ + noMatchingEntries?: string; + /** Expanded-row request section heading. Default: "Request" */ + request?: string; + /** Expanded-row response section heading. Default: "Response" */ + response?: string; + /** Expanded-row body when neither request nor response is present. + * Default: "No request or response body." */ + noRequestResponseBody?: string; + /** Load-older-entries row label. Default: "Load older entries" */ + loadOlderEntries?: string; + /** Footer entry-count line, e.g. "42 entries" or "5 of 42" plus the active + * query. Called with (filteredCount, total, query). */ + entriesLabel?: (filteredCount: number, total: number, query: string) => string; + /** Footer status when tailing. Default: "tailing" */ + tailingStatus?: string; + /** Footer status when paused. Default: "paused" */ + pausedStatus?: string; +} + +const defaultEntriesLabel = (filteredCount: number, total: number, query: string) => + `${filteredCount === total ? `${total} entries` : `${filteredCount} of ${total}`}${query ? ` · "${query}"` : ""}`; + export interface ServiceLogcatProps { /** Log entries, chronological (oldest first); the console reverses them so the newest line sits at the top. */ logs: LogEntry[]; + /** Load the next page of older entries. With `hasOlder`, renders a load-older row at the old end (bottom) that also auto-fires when the user scrolls near it. */ + onLoadOlder?: () => void; + /** Whether older entries exist beyond the current `logs` window. */ + hasOlder?: boolean; + /** Older page currently loading — shows the row in a loading state and suppresses re-fire. */ + loadingOlder?: boolean; + /** Seeds the text filter box on mount (e.g. a module slug, to land pre-filtered + * from a deep link) — after that, the input is normal uncontrolled state. */ + initialQuery?: string; + /** Override any/all of the component's own strings for i18n. Unset fields + * fall back to the English default. */ + labels?: ServiceLogcatLabels; } const LEVEL_BADGE: Record = { @@ -41,12 +101,6 @@ const LEVEL_TEXT: Record = { error: "text-error", }; -const FLOOR_OPTIONS: readonly { value: LevelFloor; label: string }[] = [ - { value: "all", label: "All" }, - { value: "warn", label: "Warn+" }, - { value: "error", label: "Errors" }, -]; - // Compact single-line timestamp: "2026-06-30T16:23:45.225373081Z" -> // "2026-06-30 16:23:45.225" (drop the T, nanoseconds, and trailing Z). function fmtTs(ts: string): string { @@ -54,6 +108,34 @@ function fmtTs(ts: string): string { return m ? `${m[1]} ${m[2]}.${m[3]}` : ts; } +// Auto-fire distance from the bottom (old end) for load-older, mirroring the +// 24px pinned-at-top threshold with room for the load-older row itself. +const LOAD_OLDER_NEAR_PX = 48; + +// Stable row keys for one filtered window: the source's sequence number when +// present; otherwise ts+msg plus a duplicate-occurrence suffix (ts+msg alone +// collides on repeated lines, and an index suffix would re-key every row each +// time a new line prepends). Occurrences count from the OLD end so existing +// rows keep their keys as newer lines arrive; seq-less sources don't page +// older (seq is what the cursor API provides), so bottom-appends can't shift +// them in practice. +function rowKeysFor(filtered: LogEntry[]): string[] { + const seen = new Map(); + const keys = new Array(filtered.length); + for (let i = filtered.length - 1; i >= 0; i--) { + const l = filtered[i]; + if (l.seq != null) { + keys[i] = `${l.seq}`; + continue; + } + const base = `${l.ts}-${l.msg}`; + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + keys[i] = n === 0 ? base : `${base}-${n}`; + } + return keys; +} + /** * ServiceLogcat — the module service log console. A severity-floor filter, a * text filter, a live-tail toggle, copy, and a mono scroll surface that pins to @@ -61,9 +143,22 @@ function fmtTs(ts: string): string { * `logs` array (mock today; a live stream tomorrow) and the co-located * `useLogcat` hook owns all internal state. */ -export function ServiceLogcat({ logs }: ServiceLogcatProps) { +export function ServiceLogcat({ + logs, + onLoadOlder, + hasOlder = false, + loadingOlder = false, + initialQuery, + labels, +}: ServiceLogcatProps) { + const floorOptions: readonly { value: LevelFloor; label: string }[] = [ + { value: "all", label: labels?.floorAll ?? "All" }, + { value: "warn", label: labels?.floorWarn ?? "Warn+" }, + { value: "error", label: labels?.floorError ?? "Errors" }, + ]; const { query, setQuery, floor, setFloor, tailing, setTailing, filtered, total } = - useLogcat(logs); + useLogcat(logs, initialQuery); + const rowKeys = useMemo(() => rowKeysFor(filtered), [filtered]); const scrollRef = useRef(null); const pinnedRef = useRef(true); // is the view currently pinned at the top? const programmaticRef = useRef(false); @@ -80,9 +175,17 @@ export function ServiceLogcat({ logs }: ServiceLogcatProps) { pinnedRef.current = true; }; + // One-shot latch so a burst of scroll events fires onLoadOlder once; rearmed + // when the caller reacts (loadingOlder toggles or new entries arrive). + const loadOlderFiredRef = useRef(false); + useEffect(() => { + loadOlderFiredRef.current = false; + }, [loadingOlder, logs]); + // Track whether the view is at the top. A programmatic scroll-to-top is // ignored (guarded by programmaticRef). No auto-pause: tailing stays on; we // just stop following while the user is scrolled away from the top. + // Scrolling near the bottom (the OLD end) auto-fires load-older. const onScroll = () => { const el = scrollRef.current; if (!el) return; @@ -91,22 +194,37 @@ export function ServiceLogcat({ logs }: ServiceLogcatProps) { return; } pinnedRef.current = el.scrollTop < 24; + if ( + onLoadOlder && + hasOlder && + !loadingOlder && + !loadOlderFiredRef.current && + el.scrollHeight - el.scrollTop - el.clientHeight < LOAD_OLDER_NEAR_PX + ) { + loadOlderFiredRef.current = true; + onLoadOlder(); + } }; // Newest sits at the TOP. When tailing + pinned, follow to the top. Otherwise, // if new lines were prepended above while the user is reading lower down, they // would shove the view down — so offset scrollTop by the added height to keep - // the user anchored to the line they're reading. openKey is a dep so the - // height baseline updates after an expand/collapse too (no false offset). + // the user anchored to the line they're reading. Older pages append at the + // BOTTOM (the top row's key is unchanged) — content below the viewport never + // moves scrollTop, so no offset there. openKey is a dep so the height + // baseline updates after an expand/collapse too (no false offset). const prevLenRef = useRef(filtered.length); const prevHeightRef = useRef(0); + const prevTopKeyRef = useRef(rowKeys[0] ?? null); useLayoutEffect(() => { const el = scrollRef.current; if (!el) return; - const grew = filtered.length > prevLenRef.current; + const topKey = rowKeys[0] ?? null; + const grewAbove = + filtered.length > prevLenRef.current && topKey !== prevTopKeyRef.current; if (tailing && pinnedRef.current) { scrollToTop(); - } else if (grew) { + } else if (grewAbove) { const added = el.scrollHeight - prevHeightRef.current; if (added > 0) { programmaticRef.current = true; @@ -115,7 +233,8 @@ export function ServiceLogcat({ logs }: ServiceLogcatProps) { } prevLenRef.current = filtered.length; prevHeightRef.current = el.scrollHeight; - }, [tailing, filtered.length, openKey]); + prevTopKeyRef.current = topKey; + }, [tailing, filtered, rowKeys, openKey]); const copyAll = () => { const text = filtered.map((l) => `${l.ts} ${l.level.toUpperCase()} ${l.msg}`).join("\n"); @@ -125,7 +244,7 @@ export function ServiceLogcat({ logs }: ServiceLogcatProps) { return (
- Logcat + {labels?.title ?? "Logcat"}
{filtered.length === 0 ? ( -

No matching log entries.

+

{labels?.noMatchingEntries ?? "No matching log entries."}

) : (
- {filtered.map((l) => { - const key = `${l.ts}-${l.msg}`; + {filtered.map((l, i) => { + const key = rowKeys[i]; const hasDetail = l.method != null || l.req_body != null || l.res_body != null; const open = openKey === key; @@ -229,10 +348,12 @@ export function ServiceLogcat({ logs }: ServiceLogcatProps) {
{open && (
- {l.req_body ? : null} - {l.res_body ? : null} + {l.req_body ? : null} + {l.res_body ? : null} {!l.req_body && !l.res_body ? ( -

No request or response body.

+

+ {labels?.noRequestResponseBody ?? "No request or response body."} +

) : null}
)} @@ -241,15 +362,26 @@ export function ServiceLogcat({ logs }: ServiceLogcatProps) { })}
)} + {onLoadOlder && hasOlder && ( + + )}
- {filtered.length === total ? `${total} entries` : `${filtered.length} of ${total}`} - {query && ` · “${query}”`} + {(labels?.entriesLabel ?? defaultEntriesLabel)(filtered.length, total, query)} - {tailing ? "tailing" : "paused"} + {tailing ? (labels?.tailingStatus ?? "tailing") : (labels?.pausedStatus ?? "paused")}
); diff --git a/src/components/ui/dev/service-logcat/types.ts b/src/components/ui/dev/service-logcat/types.ts index dbea9e18d..f7df464e2 100644 --- a/src/components/ui/dev/service-logcat/types.ts +++ b/src/components/ui/dev/service-logcat/types.ts @@ -13,6 +13,8 @@ export interface LogEntry { ts: string; level: LogLevel; msg: string; + /** Monotonic sequence number from the log source; used as a stable row key when present. */ + seq?: number; // Optional request/response detail — present on access-log lines captured by // the dev-proxy, absent on plain log lines. Keys match the dispatch ring // Entry JSON (snake_case). diff --git a/src/components/ui/dev/service-logcat/useLogcat.ts b/src/components/ui/dev/service-logcat/useLogcat.ts index 4761c6df7..5531ef832 100644 --- a/src/components/ui/dev/service-logcat/useLogcat.ts +++ b/src/components/ui/dev/service-logcat/useLogcat.ts @@ -32,8 +32,8 @@ export interface UseLogcatResult { * first); the hook reverses them to newest-first so the console shows the * latest line at the top. */ -export function useLogcat(logs: LogEntry[]): UseLogcatResult { - const [query, setQuery] = useState(""); +export function useLogcat(logs: LogEntry[], initialQuery = ""): UseLogcatResult { + const [query, setQuery] = useState(initialQuery); const [floor, setFloor] = useState("all"); const [tailing, setTailing] = useState(true); diff --git a/src/components/ui/inputs/segmented-button/SegmentedButton.stories.tsx b/src/components/ui/inputs/segmented-button/SegmentedButton.stories.tsx index a2b86fe95..1ac125490 100644 --- a/src/components/ui/inputs/segmented-button/SegmentedButton.stories.tsx +++ b/src/components/ui/inputs/segmented-button/SegmentedButton.stories.tsx @@ -82,6 +82,18 @@ export const Disabled: Story = { }, }; +/** A small red count badge anchored to an option's corner (e.g. an open-items count) */ +export const WithBadge: Story = { + args: { + options: [ + { value: "list", label: "List" }, + { value: "issues", label: "Issues", badge: 4 }, + ], + value: "list", + "aria-label": "Select view", + }, +}; + /** Boxed/connected track with an inset selected pill */ export const Boxed: Story = { render: (args) => { diff --git a/src/components/ui/inputs/segmented-button/SegmentedButton.tsx b/src/components/ui/inputs/segmented-button/SegmentedButton.tsx index 23aea82c6..a71f499af 100644 --- a/src/components/ui/inputs/segmented-button/SegmentedButton.tsx +++ b/src/components/ui/inputs/segmented-button/SegmentedButton.tsx @@ -21,6 +21,9 @@ export interface SegmentedButtonOption { readonly icon?: string; /** Visual tone for the unselected state. Defaults to "default". */ readonly tone?: SegmentedButtonOptionTone; + /** Small red count badge anchored to the option's top-right corner (e.g. an + * open-items count). Omit for no badge. */ + readonly badge?: string | number; } export type SegmentedButtonSize = "sm" | "md"; @@ -139,7 +142,7 @@ export function SegmentedButton({ disabled={disabled} aria-pressed={value === opt.value} className={cn( - "font-medium transition-colors inline-flex items-center justify-center gap-1.5", + "relative font-medium transition-colors inline-flex items-center justify-center gap-1.5", isBoxed ? boxedSizeStyles[size] : sizeStyles[size], isBoxed ? boxedButtonStyle(value === opt.value, inverse) @@ -151,6 +154,14 @@ export function SegmentedButton({ > {opt.icon && } {opt.label} + {opt.badge != null && ( + + )} ))}