diff --git a/tests/kora_cli/test_fe_ops_quality_pass.py b/tests/kora_cli/test_fe_ops_quality_pass.py new file mode 100644 index 000000000000..aea79b69506b --- /dev/null +++ b/tests/kora_cli/test_fe_ops_quality_pass.py @@ -0,0 +1,253 @@ +"""Source-pin tests for KR-FE-OPS-QUALITY-PASS. + +Three improvements bundled in one PR. Per the CC#2 source-pin +discipline (no FE component test runner in the repo), each +improvement is verified via grep against the live TSX source. + +Scenarios: + 1. formatTimestamp appends "(local)" suffix per the TZ-clarity + contract; timestampAbsoluteUtc helper exported alongside + 2. The 5 panels that consume formatTimestamp from panelHelpers + also import timestampAbsoluteUtc for the hover tooltip + 3. Empty-state convergence: WebhookEvents / AgentActivity / + Reasoning use CheckCircle2 + positive copy when the timeline + is empty (genuine all-clear states per spec §1) + 4. SlackDM / Email keep neutral HelpCircle (data-hasn't-arrived + states per spec §1; converging would mislead) + 5. ShowMoreFooter component exists with correct tier ladder + 6. api.ts threads ?limit into the 4 timeline endpoints + 7. The 4 timeline panels wire ShowMoreFooter + limit state +""" + +import re +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_HELPERS_PATH = _REPO_ROOT / "web" / "src" / "lib" / "panelHelpers.ts" +_SHOW_MORE_PATH = _REPO_ROOT / "web" / "src" / "components" / "ShowMoreFooter.tsx" +_API_PATH = _REPO_ROOT / "web" / "src" / "lib" / "api.ts" +_PAGES = _REPO_ROOT / "web" / "src" / "pages" + + +# ---- 1-2. TZ rendering ------------------------------------------ + + +def test_format_timestamp_appends_local_suffix(): + """formatTimestamp must append "(local)" to the localized output + so operators on multiple machines aren't TZ-confused.""" + src = _HELPERS_PATH.read_text() + # Pin the literal "(local)" suffix in the return expression. + match = re.search( + r"function formatTimestamp[^}]*?return\s*`\$\{d\.toLocaleString\(\)\}\s*\(local\)`", + src, + re.DOTALL, + ) + assert match, ( + "formatTimestamp must append ' (local)' suffix to the " + "localized output — operator multi-machine TZ disambiguation" + ) + + +def test_timestamp_absolute_utc_helper_exported(): + """Companion helper for the hover/tooltip — returns Z-suffixed + UTC ISO so operators can grep against logs / substrate using + the canonical timestamp shape.""" + src = _HELPERS_PATH.read_text() + assert re.search( + r"export function timestampAbsoluteUtc\(", + src, + ), "panelHelpers.ts must export timestampAbsoluteUtc" + # Must return Z-suffixed UTC ISO (not local-rendered or + # ms-precision — operators grep for the canonical + # "2026-05-23T17:48:42Z" shape). + assert "toISOString()" in src, ( + "timestampAbsoluteUtc must use toISOString() for UTC" + ) + + +def test_panels_with_formatTimestamp_also_import_absolute_utc(): + """Every panel that displays formatTimestamp should also wire + the absolute-UTC hover via timestampAbsoluteUtc, so the + forensic-correlation hover affordance is available.""" + expected_panels = [ + "AgentActivityPanel.tsx", + "AlertsPanel.tsx", + "EmailPanel.tsx", + "ReasoningPanel.tsx", + "SlackDMPanel.tsx", + ] + for name in expected_panels: + src = (_PAGES / name).read_text() + assert "timestampAbsoluteUtc" in src, ( + f"{name} imports formatTimestamp but not " + f"timestampAbsoluteUtc — hover-for-UTC affordance missing" + ) + + +# ---- 3-4. Empty-state convergence --------------------------------- + + +def test_webhook_events_uses_positive_empty_state(): + """No webhook traffic = healthy idle = positive reinforcement. + Per spec §1: pin "No webhook traffic." copy + CheckCircle2 + + success-toned card.""" + src = (_PAGES / "WebhookEventsPanel.tsx").read_text() + # When data.events.length === 0 → positive reinforcement + assert "No webhook traffic" in src, ( + "WebhookEventsPanel empty state should say 'No webhook traffic.'" + ) + assert "border-success/30 bg-success/5" in src, ( + "WebhookEventsPanel data-empty card should use success-toned " + "border + background (positive reinforcement)" + ) + + +def test_agent_activity_uses_positive_empty_state(): + src = (_PAGES / "AgentActivityPanel.tsx").read_text() + assert "No agent activity." in src + assert "border-success/30 bg-success/5" in src + # Pin the specific success-icon usage in the empty state + assert "CheckCircle2 className=\"h-6 w-6 mx-auto mb-2 text-success\"" in src + + +def test_reasoning_uses_positive_empty_state(): + src = (_PAGES / "ReasoningPanel.tsx").read_text() + assert "No reasoning activity." in src + assert "Kora is idle." in src + assert "border-success/30 bg-success/5" in src + + +def test_slack_dm_keeps_neutral_empty_state(): + """Per spec §1: slack_dm empty pre-deploy = data-hasn't-arrived, + NOT system-healthy. Keep neutral HelpCircle + setup-runbook + pointer (existing copy).""" + src = (_PAGES / "SlackDMPanel.tsx").read_text() + # Existing copy refers to slack_app_setup_runbook + assert "slack_app_setup_runbook" in src, ( + "SlackDMPanel empty state should preserve the setup-runbook " + "pointer (data-hasn't-arrived semantic, NOT positive-" + "reinforcement; spec §1 explicitly excludes from convergence)" + ) + + +def test_email_keeps_neutral_empty_state(): + """Per spec §1: email empty pre-deploy = data-hasn't-arrived. + Keep neutral HelpCircle + setup-runbook pointer.""" + src = (_PAGES / "EmailPanel.tsx").read_text() + assert "purelymail_setup_runbook" in src + + +# ---- 5. ShowMoreFooter component ---------------------------------- + + +def test_show_more_footer_exists(): + assert _SHOW_MORE_PATH.is_file(), f"missing: {_SHOW_MORE_PATH}" + + +def test_show_more_tier_ladder_correct(): + """Tier ladder: 50 → 100 → 200 (backend cap). 200 must match the + backend's cap in kora_cli/web_server.py — every limit-aware + endpoint uses max(1, min(limit, 200)).""" + src = _SHOW_MORE_PATH.read_text() + match = re.search(r"SHOW_MORE_TIERS[^=]*=\s*\[([^\]]+)\]", src) + assert match, "SHOW_MORE_TIERS tier ladder must be exported" + nums = [int(n.strip()) for n in match.group(1).split(",") if n.strip()] + assert nums == [50, 100, 200], ( + f"SHOW_MORE_TIERS = {nums}; expected [50, 100, 200] per spec §1" + ) + + +def test_show_more_backend_cap_matches_200(): + """SHOW_MORE_BACKEND_CAP must match backend's 200 cap so FE + clamps to the same ceiling. A future backend cap bump (or drop) + requires both sides to agree; this pin makes the drift visible.""" + src = _SHOW_MORE_PATH.read_text() + assert "SHOW_MORE_BACKEND_CAP" in src + # Indirectly via the tier-ladder pin above; explicit string sweep + # too for the "(backend cap)" terminus copy. + assert "backend cap" in src.lower(), ( + "ShowMoreFooter at-cap terminus should explain the backend " + "cap so the operator knows older entries exist" + ) + + +def test_show_more_at_cap_points_to_forensic_data_sources(): + """At cap, the operator needs to know older entries are still + available — point them at the JSONL / substrate forensic + paths (the actual data sources behind the panels).""" + src = _SHOW_MORE_PATH.read_text() + assert "JSONL" in src and "substrate" in src.lower(), ( + "ShowMoreFooter at-cap terminus should mention JSONL + " + "substrate so the operator knows where to look forensically" + ) + + +# ---- 6. api.ts limit threading ----------------------------------- + + +def test_api_threads_limit_into_four_endpoints(): + """The 4 timeline endpoints accept ?limit; api client must + accept an optional limit param and thread it into the URL.""" + src = _API_PATH.read_text() + for fn_name in ( + "getRecentSlackDM", + "getRecentAgentActivity", + "getRecentReasoning", + "getRecentWebhookEvents", + ): + # Optional limit param signature + assert re.search( + rf"{fn_name}:\s*\(limit\?:\s*number\)", + src, + ), f"{fn_name} should accept an optional limit param" + + +# ---- 7. Panels wire ShowMoreFooter + limit state --------------- + + +def test_four_timeline_panels_wire_show_more_footer(): + """Each of the 4 timeline panels must (a) import the footer, + (b) track limit state with the SHOW_MORE_DEFAULT_LIMIT initial, + and (c) render ShowMoreFooter at the bottom of the timeline.""" + for name in ( + "AgentActivityPanel.tsx", + "ReasoningPanel.tsx", + "SlackDMPanel.tsx", + "WebhookEventsPanel.tsx", + ): + src = (_PAGES / name).read_text() + assert "ShowMoreFooter" in src, ( + f"{name} must import + render ShowMoreFooter" + ) + assert "SHOW_MORE_DEFAULT_LIMIT" in src, ( + f"{name} must initialize limit state from the shared " + f"SHOW_MORE_DEFAULT_LIMIT constant" + ) + # The setLimit handler must be wired to onShowMore so click + # bumps the limit (and the useEffect re-fetches via the + # limit dep on the loadX useCallback). + assert re.search( + r"onShowMore=\{setLimit\}", + src, + ), f"{name} must pass setLimit as onShowMore handler" + + +def test_four_timeline_panels_pass_limit_to_api_call(): + """The api.getRecentX(limit) call must thread the state so + clicking Show More actually re-fetches with the bumped limit.""" + cases = [ + ("AgentActivityPanel.tsx", "getRecentAgentActivity"), + ("ReasoningPanel.tsx", "getRecentReasoning"), + ("SlackDMPanel.tsx", "getRecentSlackDM"), + ("WebhookEventsPanel.tsx", "getRecentWebhookEvents"), + ] + for panel_name, api_fn in cases: + src = (_PAGES / panel_name).read_text() + assert re.search( + rf"\.{api_fn}\(limit\)", + src, + ), ( + f"{panel_name} must call api.{api_fn}(limit) " + f"(not the no-arg form) so Show More bumps actually fetch" + ) diff --git a/web/src/components/ShowMoreFooter.tsx b/web/src/components/ShowMoreFooter.tsx new file mode 100644 index 000000000000..d5afd1d282e4 --- /dev/null +++ b/web/src/components/ShowMoreFooter.tsx @@ -0,0 +1,83 @@ +// Show More affordance for timeline panels — KR-FE-OPS-QUALITY-PASS. +// +// The 4 timeline endpoints (slack-dm / agent-activity / reasoning / +// webhook-events) accept ?limit=N (default 50; backend cap 200) but +// the operator can't see >50 rows without manual URL construction. +// This footer surfaces the cap-bump UX subtly at the bottom of each +// timeline. +// +// Tiers: 50 → 100 → 200 (backend cap). At cap, the button is replaced +// with a terminus line pointing the operator at the forensic data +// sources for older entries. + +import { ChevronDown } from "lucide-react"; + +// Tier ladder. Mirrors the backend's 200 cap in +// kora_cli/web_server.py (every limit-aware endpoint capped at 200 +// via `max(1, min(limit, 200))`). FE clamps to the same ceiling so +// the operator's click can't out-grow what the endpoint will serve. +export const SHOW_MORE_TIERS: ReadonlyArray = [50, 100, 200]; +export const SHOW_MORE_DEFAULT_LIMIT = SHOW_MORE_TIERS[0]; +export const SHOW_MORE_BACKEND_CAP = + SHOW_MORE_TIERS[SHOW_MORE_TIERS.length - 1]; + +export function nextShowMoreTier(current: number): number | null { + const idx = SHOW_MORE_TIERS.indexOf(current); + if (idx === -1) { + // Operator-set limit that doesn't match a tier — find the next + // tier above current, or null at-cap. + const next = SHOW_MORE_TIERS.find((t) => t > current); + return next ?? null; + } + if (idx + 1 >= SHOW_MORE_TIERS.length) return null; + return SHOW_MORE_TIERS[idx + 1]; +} + +export function ShowMoreFooter({ + currentLimit, + totalShown, + onShowMore, + unitLabel = "entries", +}: { + currentLimit: number; + totalShown: number; + onShowMore: (next: number) => void; + /** Per-panel unit name for the visible "Showing N " line. */ + unitLabel?: string; +}) { + const next = nextShowMoreTier(currentLimit); + + // Don't render the footer at all if we haven't even filled one + // tier — the operator can see all rows; the Show More button + // would suggest more exist when none do. + if (totalShown < currentLimit && totalShown < SHOW_MORE_BACKEND_CAP) { + return null; + } + + if (next === null) { + // At backend cap — show forensic-entry-point terminus. + return ( +
+ Showing {totalShown} {unitLabel} (backend cap; older entries + via JSONL / substrate forensics) +
+ ); + } + + return ( +
+ + Showing {totalShown} {unitLabel} + + + +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0f103d1a15d0..d9580529476a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -122,16 +122,28 @@ export const api = { fetchJSON("/api/heartbeat/services"), getMCPClients: () => fetchJSON("/api/mcp/clients/list"), - getRecentWebhookEvents: () => - fetchJSON("/api/webhooks/events/recent"), - getRecentAgentActivity: () => - fetchJSON("/api/agent-activity/recent"), - getRecentSlackDM: () => - fetchJSON("/api/slack-dm/recent"), + // KR-FE-OPS-QUALITY-PASS: ?limit query param threading for the + // Show More affordance on 4 timeline panels. Backend cap is 200; + // FE clamps to that ceiling at call sites so the operator's + // request can't out-grow what the endpoint will serve. + getRecentWebhookEvents: (limit?: number) => + fetchJSON( + limit ? `/api/webhooks/events/recent?limit=${limit}` : "/api/webhooks/events/recent", + ), + getRecentAgentActivity: (limit?: number) => + fetchJSON( + limit ? `/api/agent-activity/recent?limit=${limit}` : "/api/agent-activity/recent", + ), + getRecentSlackDM: (limit?: number) => + fetchJSON( + limit ? `/api/slack-dm/recent?limit=${limit}` : "/api/slack-dm/recent", + ), getRecentEmail: () => fetchJSON("/api/email/recent"), - getRecentReasoning: () => - fetchJSON("/api/reasoning/recent"), + getRecentReasoning: (limit?: number) => + fetchJSON( + limit ? `/api/reasoning/recent?limit=${limit}` : "/api/reasoning/recent", + ), getCurrentAlerts: () => fetchJSON("/api/alerts/current"), getSessions: (limit = 20, offset = 0) => diff --git a/web/src/lib/panelHelpers.ts b/web/src/lib/panelHelpers.ts index f5f0d4922a2a..cee1b5c219dd 100644 --- a/web/src/lib/panelHelpers.ts +++ b/web/src/lib/panelHelpers.ts @@ -38,13 +38,36 @@ export function formatRelative(iso: string | null | undefined): string { } // Absolute timestamp formatted in the operator's browser locale. -// "—" for missing inputs; raw value passes through for un-parseable strings -// so the operator at least sees the bad input. +// "—" for missing inputs; raw value passes through for un-parseable +// strings so the operator at least sees the bad input. +// +// KR-FE-OPS-QUALITY-PASS: appends "(local)" hint so an operator +// switching between machines / timezones isn't momentarily confused +// about which TZ the rendered time is in. The hover tooltip (via +// timestampAbsoluteUtc) gives the unambiguous UTC ISO for forensic +// correlation against logs / substrate. export function formatTimestamp(iso: string | null | undefined): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; - return d.toLocaleString(); + return `${d.toLocaleString()} (local)`; +} + +// Companion to formatTimestamp — the unambiguous UTC ISO for the +// timestamp hover tooltip. Renders the original ISO when valid; falls +// back to the raw value or "—" so the title/aria-label always has a +// stringable value. Use on the SAME element as formatTimestamp via +// title= or aria-label= so operator hover surfaces the absolute form. +export function timestampAbsoluteUtc( + iso: string | null | undefined, +): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + // Normalize to Z-suffixed UTC ISO regardless of input form so the + // hover always reads as "2026-05-23T17:48:42Z" — the canonical + // shape operator workflows grep for. + return d.toISOString().replace(/\.\d{3}Z$/, "Z"); } // "142 ms" / "2.40 s" / "—" — formats a duration in milliseconds. diff --git a/web/src/pages/AgentActivityPanel.tsx b/web/src/pages/AgentActivityPanel.tsx index 4e37ae864fc2..e44b909728d9 100644 --- a/web/src/pages/AgentActivityPanel.tsx +++ b/web/src/pages/AgentActivityPanel.tsx @@ -26,7 +26,12 @@ import { formatLatency, formatRelative, formatTimestamp, + timestampAbsoluteUtc, } from "@/lib/panelHelpers"; +import { + SHOW_MORE_DEFAULT_LIMIT, + ShowMoreFooter, +} from "@/components/ShowMoreFooter"; import type { AgentActivityResponse, AgentCall, @@ -134,7 +139,9 @@ function CallRow({ call, expanded, onToggle }: CallRowProps) { called_at - {formatTimestamp(call.called_at)} + + {formatTimestamp(call.called_at)} +
@@ -174,6 +181,7 @@ export default function AgentActivityPanel() { const [expandedIds, setExpandedIds] = useState>(new Set()); const [statusFilter, setStatusFilter] = useState("all"); const [callerFilter, setCallerFilter] = useState("all"); + const [limit, setLimit] = useState(SHOW_MORE_DEFAULT_LIMIT); const { toast, showToast } = useToast(); const loadActivity = useCallback( @@ -181,7 +189,7 @@ export default function AgentActivityPanel() { if (isManual) setRefreshing(true); setLoadError(null); api - .getRecentAgentActivity() + .getRecentAgentActivity(limit) .then((resp) => setData(resp)) .catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e); @@ -192,7 +200,7 @@ export default function AgentActivityPanel() { if (isManual) setRefreshing(false); }); }, - [showToast], + [showToast, limit], ); useEffect(() => { @@ -366,11 +374,18 @@ export default function AgentActivityPanel() { {/* ── Timeline ───────────────────────────────────────── */} + {/* Empty-state convergence (KR-FE-OPS-QUALITY-PASS): + no recent agent activity on the /mcp surface is a + healthy idle steady-state — use positive-reinforcement + pattern from AlertsPanel. */} {data.calls.length === 0 ? ( - - - - No agent activity yet. MCP surface lives at /mcp on port 9119. + + + +
No agent activity.
+
+ /mcp endpoint healthy on port 9119. +
) : filteredCalls.length === 0 ? ( @@ -392,6 +407,12 @@ export default function AgentActivityPanel() { ))}
)} + )} diff --git a/web/src/pages/AlertsPanel.tsx b/web/src/pages/AlertsPanel.tsx index 58925facbab4..92343f2a17d0 100644 --- a/web/src/pages/AlertsPanel.tsx +++ b/web/src/pages/AlertsPanel.tsx @@ -28,7 +28,11 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; -import { formatRelative, formatTimestamp } from "@/lib/panelHelpers"; +import { + formatRelative, + formatTimestamp, + timestampAbsoluteUtc, +} from "@/lib/panelHelpers"; import type { Alert, AlertCategory, @@ -131,7 +135,7 @@ function AlertRow({ alert, expanded, onToggle }: AlertRowProps) { {alert.title} - + {formatRelative(alert.first_seen_at)} @@ -165,7 +169,9 @@ function AlertRow({ alert, expanded, onToggle }: AlertRowProps) { first_seen_at - {formatTimestamp(alert.first_seen_at)} + + {formatTimestamp(alert.first_seen_at)} +
diff --git a/web/src/pages/EmailPanel.tsx b/web/src/pages/EmailPanel.tsx index b52e39e999c3..b8aa1085df27 100644 --- a/web/src/pages/EmailPanel.tsx +++ b/web/src/pages/EmailPanel.tsx @@ -25,7 +25,11 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; -import { formatRelative, formatTimestamp } from "@/lib/panelHelpers"; +import { + formatRelative, + formatTimestamp, + timestampAbsoluteUtc, +} from "@/lib/panelHelpers"; import type { EmailDirection, EmailHandledStatus, @@ -185,7 +189,7 @@ function MessageRow({ message, expanded, onToggle }: MessageRowProps) { )} - + {formatRelative(message.timestamp)} @@ -214,7 +218,9 @@ function MessageRow({ message, expanded, onToggle }: MessageRowProps) { timestamp - {formatTimestamp(message.timestamp)} + + {formatTimestamp(message.timestamp)} +
diff --git a/web/src/pages/ReasoningPanel.tsx b/web/src/pages/ReasoningPanel.tsx index 44a1addc7fe7..99521e6dc9f5 100644 --- a/web/src/pages/ReasoningPanel.tsx +++ b/web/src/pages/ReasoningPanel.tsx @@ -27,7 +27,12 @@ import { formatLatency, formatRelative, formatTimestamp, + timestampAbsoluteUtc, } from "@/lib/panelHelpers"; +import { + SHOW_MORE_DEFAULT_LIMIT, + ShowMoreFooter, +} from "@/components/ShowMoreFooter"; import type { ReasoningCall, ReasoningCostRung, @@ -196,7 +201,7 @@ function CallRow({ call, expanded, onToggle }: CallRowProps) { )} - + {formatRelative(call.started_at)} @@ -243,7 +248,9 @@ function CallRow({ call, expanded, onToggle }: CallRowProps) { started_at - {formatTimestamp(call.started_at)} + + {formatTimestamp(call.started_at)} +
@@ -328,6 +335,7 @@ export default function ReasoningPanel() { const [refreshing, setRefreshing] = useState(false); const [expandedIds, setExpandedIds] = useState>(new Set()); const [filter, setFilter] = useState("all"); + const [limit, setLimit] = useState(SHOW_MORE_DEFAULT_LIMIT); const { toast, showToast } = useToast(); const loadReasoning = useCallback( @@ -335,7 +343,7 @@ export default function ReasoningPanel() { if (isManual) setRefreshing(true); setLoadError(null); api - .getRecentReasoning() + .getRecentReasoning(limit) .then((resp) => setData(resp)) .catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e); @@ -346,7 +354,7 @@ export default function ReasoningPanel() { if (isManual) setRefreshing(false); }); }, - [showToast], + [showToast, limit], ); useEffect(() => { @@ -570,12 +578,18 @@ export default function ReasoningPanel() { {/* ── Timeline (newest first) ──────────────────────── */} + {/* Empty-state convergence (KR-FE-OPS-QUALITY-PASS): an + idle Kora (no recent reasoning calls) is a healthy + steady-state — positive reinforcement instead of the + data-absence neutral. */} {data.calls.length === 0 ? ( - - - - No reasoning activity yet. Once Joshua DMs Kora, - reasoning calls will appear here. + + + +
No reasoning activity.
+
+ Kora is idle. +
) : visibleCalls.length === 0 ? ( @@ -597,6 +611,12 @@ export default function ReasoningPanel() { ))}
)} + )} diff --git a/web/src/pages/SlackDMPanel.tsx b/web/src/pages/SlackDMPanel.tsx index 2caa25592dfe..6188a07dab6e 100644 --- a/web/src/pages/SlackDMPanel.tsx +++ b/web/src/pages/SlackDMPanel.tsx @@ -23,7 +23,15 @@ import { Card, CardContent } from "@/components/ui/card"; import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; -import { formatRelative, formatTimestamp } from "@/lib/panelHelpers"; +import { + formatRelative, + formatTimestamp, + timestampAbsoluteUtc, +} from "@/lib/panelHelpers"; +import { + SHOW_MORE_DEFAULT_LIMIT, + ShowMoreFooter, +} from "@/components/ShowMoreFooter"; import type { SlackDMDirection, SlackDMHandledStatus, @@ -164,7 +172,7 @@ function MessageRow({ message, expanded, onToggle }: MessageRowProps) { )} - + {formatRelative(message.timestamp)} @@ -186,7 +194,9 @@ function MessageRow({ message, expanded, onToggle }: MessageRowProps) { timestamp - {formatTimestamp(message.timestamp)} + + {formatTimestamp(message.timestamp)} +
@@ -250,6 +260,7 @@ export default function SlackDMPanel() { const [refreshing, setRefreshing] = useState(false); const [expandedIds, setExpandedIds] = useState>(new Set()); const [filter, setFilter] = useState("all"); + const [limit, setLimit] = useState(SHOW_MORE_DEFAULT_LIMIT); const { toast, showToast } = useToast(); const loadDM = useCallback( @@ -257,7 +268,7 @@ export default function SlackDMPanel() { if (isManual) setRefreshing(true); setLoadError(null); api - .getRecentSlackDM() + .getRecentSlackDM(limit) .then((resp) => setData(resp)) .catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e); @@ -268,7 +279,7 @@ export default function SlackDMPanel() { if (isManual) setRefreshing(false); }); }, - [showToast], + [showToast, limit], ); useEffect(() => { @@ -489,6 +500,12 @@ export default function SlackDMPanel() { ))}
)} + )} diff --git a/web/src/pages/WebhookEventsPanel.tsx b/web/src/pages/WebhookEventsPanel.tsx index 69c04b937749..1aedb4f9ea4a 100644 --- a/web/src/pages/WebhookEventsPanel.tsx +++ b/web/src/pages/WebhookEventsPanel.tsx @@ -21,6 +21,10 @@ import { Toast } from "@/components/Toast"; import { useToast } from "@/hooks/useToast"; import { api } from "@/lib/api"; import { formatRelative, formatTimestamp } from "@/lib/panelHelpers"; +import { + SHOW_MORE_DEFAULT_LIMIT, + ShowMoreFooter, +} from "@/components/ShowMoreFooter"; import type { WebhookEvent, WebhookEventStatus, @@ -156,6 +160,7 @@ export default function WebhookEventsPanel() { const [refreshing, setRefreshing] = useState(false); const [expandedIds, setExpandedIds] = useState>(new Set()); const [filter, setFilter] = useState("all"); + const [limit, setLimit] = useState(SHOW_MORE_DEFAULT_LIMIT); const { toast, showToast } = useToast(); const loadEvents = useCallback( @@ -163,7 +168,7 @@ export default function WebhookEventsPanel() { if (isManual) setRefreshing(true); setLoadError(null); api - .getRecentWebhookEvents() + .getRecentWebhookEvents(limit) .then((resp) => setData(resp)) .catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e); @@ -174,7 +179,7 @@ export default function WebhookEventsPanel() { if (isManual) setRefreshing(false); }); }, - [showToast], + [showToast, limit], ); useEffect(() => { @@ -328,12 +333,37 @@ export default function WebhookEventsPanel() { {/* ── Events list (timeline, newest first) ────────────── */} {filteredEvents.length === 0 ? ( - - - - {data.events.length === 0 - ? "No webhook events yet. Public webhook plane is on port 9118; verify daemon is running." - : `No events matching filter "${filter}".`} + + + {/* Empty-state convergence (KR-FE-OPS-QUALITY-PASS): + no recent traffic on the public webhook plane is + a healthy steady-state for an idle daemon — use + AlertsPanel's positive-reinforcement pattern. + Filter-empty (operator chose a filter that + matches nothing) is NOT a healthy signal — keep + the neutral HelpCircle there. */} + {data.events.length === 0 ? ( + <> + +
+ No webhook traffic. +
+
+ Public plane healthy on port 9118. +
+ + ) : ( + + + {`No events matching filter "${filter}".`} + + )}
) : ( @@ -348,6 +378,12 @@ export default function WebhookEventsPanel() { ))} )} + )}