From 2bdd291449f24d4d338ad399cd0cc83e62eee79a Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Thu, 2 Jul 2026 05:48:46 +0800 Subject: [PATCH 1/4] feat(service-logcat): add load-older paging affordance and stable row keys - LogEntry gains optional seq; row keys prefer seq, fall back to a collision-free ts/msg/filtered-index composite - New optional props onLoadOlder/hasOlder/loadingOlder render a load-older row at the old end (bottom) that also auto-fires when scrolled near it, latched once per page while loading - Prepend scroll compensation now keys off the top row so bottom (older) appends no longer shift scrollTop - Story LoadOlder demos seq-keyed paging; component tests cover the button, gating, loading suppression, scroll anchoring, and keys - Bump version 0.6.0 -> 0.6.1 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWoUBtpYmAJ6MW81W3ayTL --- package.json | 2 +- .../service-logcat/ServiceLogcat.stories.tsx | 51 +++++++ .../dev/service-logcat/ServiceLogcat.test.tsx | 124 ++++++++++++++++++ .../ui/dev/service-logcat/ServiceLogcat.tsx | 82 ++++++++++-- src/components/ui/dev/service-logcat/types.ts | 2 + 5 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 src/components/ui/dev/service-logcat/ServiceLogcat.test.tsx diff --git a/package.json b/package.json index 32494ece2..56f1cc28d 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.0", + "version": "0.6.1", "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..15484caf9 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,53 @@ export const WithRequestResponseDetail: Story = { export const Empty: Story = { args: { logs: [] }, }; + +// 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..9a35063ed --- /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("Loading 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..3e5c8f4a8 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Badge, type BadgeVariant } from "@/components/ui/feedback/badge/Badge"; import { FloatingLabelInput } from "@/components/ui/inputs/floating-label-input/FloatingLabelInput"; import { IconButton } from "@/components/ui/actions/icon-button/IconButton"; @@ -19,12 +19,18 @@ 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", }; 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; } const LEVEL_BADGE: Record = { @@ -54,6 +60,16 @@ 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 key: the source's sequence number when present; otherwise the +// ts/msg/filtered-index composite (ts+msg alone collides on repeated lines). +function rowKey(l: LogEntry, i: number): string { + return l.seq != null ? `${l.seq}` : `${l.ts}-${l.msg}-${i}`; +} + /** * 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,7 +77,12 @@ 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, +}: ServiceLogcatProps) { const { query, setQuery, floor, setFloor, tailing, setTailing, filtered, total } = useLogcat(logs); const scrollRef = useRef(null); @@ -80,9 +101,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 +120,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(filtered.length > 0 ? rowKey(filtered[0], 0) : null); useLayoutEffect(() => { const el = scrollRef.current; if (!el) return; - const grew = filtered.length > prevLenRef.current; + const topKey = filtered.length > 0 ? rowKey(filtered[0], 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 +159,8 @@ export function ServiceLogcat({ logs }: ServiceLogcatProps) { } prevLenRef.current = filtered.length; prevHeightRef.current = el.scrollHeight; - }, [tailing, filtered.length, openKey]); + prevTopKeyRef.current = topKey; + }, [tailing, filtered, openKey]); const copyAll = () => { const text = filtered.map((l) => `${l.ts} ${l.level.toUpperCase()} ${l.msg}`).join("\n"); @@ -197,8 +242,8 @@ export function ServiceLogcat({ logs }: ServiceLogcatProps) {

No matching log entries.

) : (
- {filtered.map((l) => { - const key = `${l.ts}-${l.msg}`; + {filtered.map((l, i) => { + const key = rowKey(l, i); const hasDetail = l.method != null || l.req_body != null || l.res_body != null; const open = openKey === key; @@ -241,6 +286,23 @@ export function ServiceLogcat({ logs }: ServiceLogcatProps) { })}
)} + {onLoadOlder && hasOlder && ( + + )} 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). From 117766656661e4bb744cb6c158a4bab7f85c5a72 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Thu, 2 Jul 2026 20:06:49 +0800 Subject: [PATCH 2/4] refactor(service-logcat): kit Button for load-older + occurrence-stable fallback keys The load-older row reuses Button (text/xs/fullWidth/loading) like ActivityList's load-more instead of a hand-rolled button+spinner, and the seq-less fallback key swaps the filtered index (which re-keyed every row per prepend) for a duplicate-occurrence suffix counted from the old end. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NWoUBtpYmAJ6MW81W3ayTL --- .../dev/service-logcat/ServiceLogcat.test.tsx | 2 +- .../ui/dev/service-logcat/ServiceLogcat.tsx | 59 ++++++++++++------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.test.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.test.tsx index 9a35063ed..08a54b116 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.test.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.test.tsx @@ -51,7 +51,7 @@ describe("ServiceLogcat load-older", () => { const { container } = render( , ); - const row = screen.getByText("Loading older entries…"); + 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. diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx index 3e5c8f4a8..21a8c0c84 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx @@ -1,5 +1,6 @@ -import { useEffect, 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"; @@ -64,10 +65,28 @@ function fmtTs(ts: string): string { // 24px pinned-at-top threshold with room for the load-older row itself. const LOAD_OLDER_NEAR_PX = 48; -// Stable row key: the source's sequence number when present; otherwise the -// ts/msg/filtered-index composite (ts+msg alone collides on repeated lines). -function rowKey(l: LogEntry, i: number): string { - return l.seq != null ? `${l.seq}` : `${l.ts}-${l.msg}-${i}`; +// 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; } /** @@ -85,6 +104,7 @@ export function ServiceLogcat({ }: ServiceLogcatProps) { const { query, setQuery, floor, setFloor, tailing, setTailing, filtered, total } = useLogcat(logs); + 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); @@ -141,11 +161,11 @@ export function ServiceLogcat({ // baseline updates after an expand/collapse too (no false offset). const prevLenRef = useRef(filtered.length); const prevHeightRef = useRef(0); - const prevTopKeyRef = useRef(filtered.length > 0 ? rowKey(filtered[0], 0) : null); + const prevTopKeyRef = useRef(rowKeys[0] ?? null); useLayoutEffect(() => { const el = scrollRef.current; if (!el) return; - const topKey = filtered.length > 0 ? rowKey(filtered[0], 0) : null; + const topKey = rowKeys[0] ?? null; const grewAbove = filtered.length > prevLenRef.current && topKey !== prevTopKeyRef.current; if (tailing && pinnedRef.current) { @@ -160,7 +180,7 @@ export function ServiceLogcat({ prevLenRef.current = filtered.length; prevHeightRef.current = el.scrollHeight; prevTopKeyRef.current = topKey; - }, [tailing, filtered, openKey]); + }, [tailing, filtered, rowKeys, openKey]); const copyAll = () => { const text = filtered.map((l) => `${l.ts} ${l.level.toUpperCase()} ${l.msg}`).join("\n"); @@ -243,7 +263,7 @@ export function ServiceLogcat({ ) : (
{filtered.map((l, i) => { - const key = rowKey(l, i); + const key = rowKeys[i]; const hasDetail = l.method != null || l.req_body != null || l.res_body != null; const open = openKey === key; @@ -287,21 +307,16 @@ export function ServiceLogcat({
)} {onLoadOlder && hasOlder && ( - + Load older entries + )} From 05c3ed11320c85603ef80f756fb0c91135ad18ea Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang <56163905+I-am-nothing@users.noreply.github.com> Date: Sat, 4 Jul 2026 07:31:34 +0800 Subject: [PATCH 3/4] feat(service-logcat,segmented-button): initialQuery seed, i18n labels, option badge (#328) * feat(service-logcat,segmented-button): initialQuery seed, i18n labels, option badge ServiceLogcat gains initialQuery (seed the text filter on mount, e.g. a deep link) and an optional labels prop covering every string it renders, so callers outside English can localize it. SegmentedButton options gain an optional badge (small count pill anchored to the option's corner). Co-Authored-By: Claude Sonnet 5 * docs(service-logcat,segmented-button): add stories for initialQuery, labels, badge Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- package.json | 2 +- .../service-logcat/ServiceLogcat.stories.tsx | 36 +++++++ .../ui/dev/service-logcat/ServiceLogcat.tsx | 99 ++++++++++++++----- .../ui/dev/service-logcat/useLogcat.ts | 4 +- .../SegmentedButton.stories.tsx | 12 +++ .../segmented-button/SegmentedButton.tsx | 13 ++- 6 files changed, 140 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 56f1cc28d..45038dc57 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.1", + "version": "0.6.2", "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 15484caf9..b3b828d6c 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx @@ -67,6 +67,42 @@ 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; diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx index 21a8c0c84..e06b15d1f 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx @@ -23,6 +23,53 @@ export const meta: ComponentMeta = { "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[]; @@ -32,6 +79,12 @@ export interface ServiceLogcatProps { 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 = { @@ -48,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 { @@ -101,9 +148,16 @@ export function ServiceLogcat({ 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? @@ -190,7 +244,7 @@ export function ServiceLogcat({ return (
- Logcat + {labels?.title ?? "Logcat"}
{filtered.length === 0 ? ( -

No matching log entries.

+

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

) : (
{filtered.map((l, i) => { @@ -294,10 +348,12 @@ export function ServiceLogcat({
{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}
)} @@ -315,7 +371,7 @@ export function ServiceLogcat({ onClick={onLoadOlder} className="mt-1" > - Load older entries + {labels?.loadOlderEntries ?? "Load older entries"} )}
@@ -323,10 +379,9 @@ export function ServiceLogcat({
- {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/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 && ( + + )} ))} From 0465d3658156ca2a4fd683b376d49aa9d71e7de3 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sat, 4 Jul 2026 07:36:04 +0800 Subject: [PATCH 4/4] chore: bump version to 0.6.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main already published 0.6.2 via #329 (an independent branch that merged directly to main before this one did), so this branch's own 0.6.2 would collide — no version diff for the release automation to act on when this merges. Bumping to 0.6.3 so merging this branch actually publishes. Co-Authored-By: Claude Sonnet 5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": {