PR-Agent flagged that trace child rows rendered an extra stage-hint
cell while the header grid template only had 7 columns. Result: every
expanded child shifted module/message/latency/request rightward, and
the stage chip overlapped its neighbour in the trace timeline.
Add a dedicated 72px "Stage" column to the header grid template and
to every row variant. Standalone leaves and trace heads render an
empty span at the same width; trace children with stage hints render
the chip in that slot. Column edges now match across all row types.
Refs #1138, #1151
The first click on a trace's chevron did nothing visible when the
trace's child was the currently-selected entry. Root cause: `toggle`
only checked the `expanded` set; auto-expand kept re-injecting the
requestId so the row stayed open even after the user added it to
`expanded` (and oscillating subsequent clicks).
Replace the single `expanded` set with a tristate driven by two
sets — `userExpanded` (explicitly opened) and `userCollapsed`
(explicitly closed). Auto-expand only fires when the id is in
neither, so a click on an auto-expanded chevron now writes the
collapse intent to `userCollapsed` and the row collapses on the
first click.
Refs #1138, #1151
PR-Agent flagged that the previous derive-trace-groups split entries
into trace and leaf buckets *before* coalescing leaves. That meant two
identical no-requestId entries separated by other rows in the real
stream would still merge into a single `×N` row, hiding signal.
Walk `entries` in input order with single-pass coalescing: a leaf
only extends the previous leaf when no other entry (trace child or
otherwise) appeared between them. Trace children still gather across
interleaving since they're identified by `requestId`. Group children
get sorted ts-ascending for trace-timeline display, with the group's
positional ts pinned to the oldest child for the reverse-chrono
display sort.
Refs #1138, #1151
PR-Agent flagged that `React.MouseEvent` referenced an unimported
`React` namespace; in stricter TypeScript builds this would fail
typecheck. Import the type alias from `react` directly.
Refs #1138, #1151
PR-Agent re-review flagged that `copyText()` swallowed clipboard
errors but the row's `setJustCopied(true)` always fired regardless,
so users in insecure contexts (or with denied clipboard permission)
saw a fake success indicator while nothing was actually copied.
Make `copyText()` return a boolean and gate the "Copied" UI state
on it. When the clipboard write fails, the button stays in its idle
state and the user can retry rather than being misled.
Refs #1138, #1151
PR-Agent re-review flagged that adjacent trace rows from different
services with the same event/message/stage/level/module would still
merge because `source` was missing from the coalesce key. A request
that fans out across multiple services would lose the second service's
row behind a `× 2` badge.
Add `source` to the trace child coalesce key so each participating
service stays visible in the trace timeline.
Refs #1138, #1151
PR-Agent flagged that the row's copy-requestId widget was a
`<span role="button" tabIndex={-1}>` nested inside a row `<button>`.
Two issues:
1. Nested interactive elements inside a `<button>` is invalid HTML.
2. `tabIndex={-1}` made the copy widget unreachable via keyboard, so
the new affordance was effectively mouse-only.
Convert the row's outer element from `<button>` to `<div role="row"
tabIndex={0}>` with `onKeyDown` for Enter/Space → select. Now the row
itself is keyboard-focusable and the copy widget can be a real
`<button>` with focus-visible styling — keyboard users can Tab to it
and Enter to copy the requestId.
Refs #1138, #1151
PR-Agent flagged that the leaf and trace-child coalesce keys collapsed
on `(event, module, level, requestId, source)` which merges adjacent
entries that share that tuple but report different content (e.g.
`User logged in: alice` vs `User logged in: bob` would appear as a
single `× 2` row, hiding bob).
Add `message` to both coalesce keys so distinct content stays
distinct. Skip `latencyMs` and `metadata` — those drift per request
even on truly redundant polls and would defeat the dedup the feature
exists for.
Refs #1138, #1151
Trace rows pushed the TIME column ~30px right because the expand chevron
was an inline flex child while leaf rows had nothing in that position.
Result: every column drifted right whenever a trace row was visible.
Reserve a 16px slot at the start of every row (header + leaf + trace).
Leaf rows render an empty span; trace rows render the chevron centered
inside the slot. All columns now share the same x-coordinates regardless
of row type.
Refs #1138
On real backend, the logs page was drowning in dashboard self-polling
noise — every `/api/logs` poll emitted a 149-stage `web-server:http`
trace, burying real provider activity.
- New workspace state `hideDashboardInternals` defaults ON. Entries
whose `source` matches `/^web-server:/i` are filtered client-side
before they reach the list.
- Toggle exposed in advanced filters as a labelled checkbox/switch with
a one-line description, so users can opt in to see internals when
debugging the dashboard itself.
- `clearAdvancedFilters` resets the toggle back to ON to keep the
default signal-clean experience.
- Header stat strip (entries / traces / errors) wired through the shell.
Backend-side `web-server:*` instrumentation stays intact (still useful
for ops debugging) — just hidden from the user-facing log feed by
default.
Refs #1138, #1141, #1142
Logs page was visually disconnected from the rest of the dashboard. Health
uses ornamental `HEALTH.ATTENTIONREQUIRED` markers, Home uses `LIVE
Account Monitor` + stat cards. Logs shipped with a 14px h1 + clinical
table that felt like a different product.
- Header now opens with a `LOGS.STREAM` mono-uppercase marker matching
the dashboard's `HEALTH.X` style, plus a 16px "Live activity" title
and a status pill.
- Below the header, a stat strip mirrors the home page's monitor layout:
ENTRIES / TRACES / ERRORS counters, errors highlighted red when >0.
- Trace row + child row font sizes lifted from 11px to 12-13px;
request-id column widened to 112px to match the standalone-row table.
- Stage-hint fallback derived from event names so the trace timeline
still renders meaningful chips when backend entries lack an explicit
`stage` field (e.g. dashboard self-polling).
- Intra-trace coalesce: identical consecutive child rows collapse to a
single row with `× N` badge so a 149-stage self-poll trace renders as
3 rows of signal instead of 149 rows of noise.
Refs #1138, #1141, #1142
Both surfaces now consume `getDisplayModule / getDisplayStage /
getDisplayRequestId / getDisplayLatency` from `utils.ts`, replacing the
previous divergence where the list row fell back to `source` while the
detail panel showed em-dash placeholders for the same entry.
Also widens the request-id column in the row table from 88px to 112px
and adds a hover-revealed clipboard icon for one-click copy of the full
requestId. List header bumped to 12px sans-serif uppercase + 9h height
for legibility.
Refs #1138, #1141, #1142
Mirror the server-side CodexQuotaWindow metadata extension on the UI
side: api-client.ts gains category/cadence/featureLabel; utils.ts
breakdown returns additionalWindows so Spark quota does not pollute
core 5h/weekly badges; quota-tooltip-content renders Spark inline with
prettified labels (GPT-5.3-Codex-Spark -> Codex Spark). Adds breakdown
unit tests and i18n strings for English/Chinese/Vietnamese/Japanese.
Three more issues raised by upstream review after the v1.7 fix push.
Each is encoded both in code and in spec so future drift can't
reintroduce them:
1. Required `storageKey` was the wrong fix for cross-page state bleed.
Forcing every callsite to pass `storageKey` made future
production-page migrations a build-breaking event for no real
safety benefit. Better engineering: keep the prop optional and
default to a key derived from `window.location.pathname` so each
route gets its own localStorage slot automatically. Pathname-
derived defaults are unique by construction; the cross-page bleed
risk only exists for hardcoded shared keys, which the doc warns
against. SSR-safe (falls back to a stable string when window is
unavailable). Pages can still pass an explicit `storageKey` to opt
out of pathname coupling (e.g. when sub-routes should share state).
2. `MaskedInput` `type` was overridable by callers, which defeated
the component's purpose for credentials. Two-layer fix:
- Compile-time: `MaskedInputProps` now extends
`Omit<InputHTMLAttributes, 'type'>` so callers literally cannot
pass `type`. The TS surface enforces the contract.
- Runtime: the `type` attribute on `<Input>` is now placed AFTER
the spread so even an `as`-cast bypass can't override it.
3. The reveal toggle was keyboard-inaccessible. Both `MaskedInput`
and `Field` had `tabIndex={-1}` on the eye-icon button, removing
it from the tab order. Keyboard-only users couldn't show or hide
the secret. Resolution:
- Drop `tabIndex={-1}` so the button joins the natural tab order.
- Add `aria-pressed={revealed}` so screen readers announce toggle
state.
- Add a focus-visible accent ring so the focused state is visible
without a mouse.
Decisions log: v1.8 entry records the rationale for each correction
and explicitly calls out that v1.7's "required storageKey" fix was
worse than the problem it tried to solve.
Validation: typecheck + lint + format + build clean. Tests 519/521
pass (2 pre-existing account-visual-groups failures on dev,
unrelated). Styleguide demos still pass storageKey explicitly so
behavior is unchanged for the only existing callsites.
Three substantive issues raised by upstream review, encoded in code +
spec so future drift can't reintroduce them:
1. Width floor was unenforceable. The previous spec wording said
"form ≥ 360px / json ≥ 320px" but `react-resizable-panels` v3 only
accepts percentage `minSize`. On a 1280px viewport this could let a
user drag a pane down to ~250px — well below the documented floor.
- `Panel minSize` bumped 25 → 30 (≥ 30% of body width after rail)
- Spec rewritten percent-based with the actual 300–360px range
across realistic viewports plus a note on the v3 API constraint
and the `onResize`-clamp escape hatch if hard pixel floors become
necessary later.
2. `storageKey` default caused cross-page state bleed. The previous
default `storageKey="ccs.config-layout"` meant any `<ConfigLayout>`
without an explicit key would share localStorage state with every
other Config page — split ratios contaminating across unrelated
pages.
- `storageKey: string` is now REQUIRED (no default). TypeScript
compile-fails any caller that omits it.
- Spec restated to make the per-page-key contract explicit.
3. Sensitive-field heuristic was too narrow. The previous regex
`AUTH_TOKEN|API_KEY|SECRET|PASSWORD|PRIVATE_KEY` missed common
secret names (ACCESS_TOKEN, REFRESH_TOKEN, BEARER_TOKEN,
CLIENT_SECRET, CLIENT_ID, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
GCP/Azure/GitHub/OpenAI/Anthropic variants, JWT, OAUTH, CREDENTIAL,
PAT, WEBHOOK_SECRET, HMAC_KEY, SIGNING_KEY, SSH_KEY).
- New `src/lib/sensitive-label.ts` Single Source of Truth
(`isSensitiveLabel(label)`) with broadened regex; case-insensitive
and tolerant of `_`/`-` separators.
- `Field` imports the shared helper; future consumers do too.
- Spec §5g enumerates the new patterns and points at the SSoT.
Decisions log: v1.7 entry records the rationale and the connections
between spec wording and library API constraints, so the next reviewer
sees the trail rather than re-discovering it.
Validation: typecheck + lint + format clean; build clean; tests
519/521 pass (2 pre-existing account-visual-groups failures on dev,
unrelated). Styleguide demos already pass storageKey explicitly.
Adds a page-level design system spec for CCS dashboard Config pages,
without migrating any production page yet. Pure-spec PR: docs +
primitive treatments + DEV-only /_styleguide route. Production page
adoption is intentionally left for follow-up work, one page at a time.
Spec — `ui/docs/design-system.md`:
- §0 Layout invariants (NEVER VIOLATE): two-column shell, no second
horizontal strip below the global topbar, sibling-pane top alignment,
cliproxy as canonical reference, user-resizable form/json split, and
content-fit unified rail (`w-fit min-w-[240px] max-w-[360px]`)
- §1 identity-strip patterns: HeroBar (home), rail-anchored (cliproxy),
PageHeader (Monitor-only), Bespoke (health)
- §2 body archetypes: Config (rail + form + optional json) and Monitor
- §4 anti-patterns with rejected code samples (PageHeader-over-
ConfigLayout, tab-bar offsetting json pane, redundant description
band, blank vertical band)
- §5 color & accent rules using the existing Pampas/Crail palette only:
1-accent-dot rule, sibling-pane differentiation, status pill spec,
three-tier intensity ladder, sensitive-field treatment per §5g
Decisions log — `ui/docs/design-decisions.md`:
- v1.6: content-fit rail (replaces fixed 260px)
- v1.5: color & accent treatment for body panes
- v1.4: resizable form/json split
- v1.3: layout invariants and rail-anchored default for Config
Primitives — `ui/src/components/config-layout/`:
- ConfigLayout: rail rendered as content-fit `<aside>` within the
unified envelope; form↔json split via react-resizable-panels (already
in deps) with autoSaveId-driven per-page localStorage persistence
- FormPane: 1px Crail top strip on header + soft from-card gradient +
inset bottom shadow for scroll-depth cue; body wash bg-muted/20 so
FormSections read as elevated cards; footer bg-muted/40 anchors save
- FormSection: 2px Crail leading-edge stripe with three-tier intensity
(default 30% / hover 70% / focus-within solid + ring + shadow) + 1.5px
accent dot prefixing each title
- JsonPane: bg-muted/30 shell visually recesses the read-only pane vs
the active form pane (bg-card); accent dot + status pill in header;
inner <pre> sits inside an inset-shadow well; content stays plain
(a dedicated JSON viewer with its own color coding will replace it
later — pages MUST NOT add inline JSON tints in the meantime)
- ListPane: selected row uses three-tier intensity ladder (no stripe at
rest, accent/30 stripe on hover, solid Crail stripe + bg-accent/10
row tint + accent-tinted icon and count badge on selection)
Sensitive input primitive — `ui/src/components/ui/masked-input.tsx`:
Upgraded to match §5g: lock glyph prefixing label, "sensitive" accent
pill on the label row, accent focus ring (the only place the default
ring is overridden), accent-tinted reveal toggle on hover. Existing
consumers (settings-dialog, websearch, friendly-ui-section, env-editor-
section) inherit the treatment without code changes.
Shared Field — `ui/src/components/forms/field.tsx`:
Tiny self-documenting Field component referenced by /_styleguide demos.
Auto-detects sensitive labels (AUTH_TOKEN | API_KEY | SECRET | PASSWORD
| PRIVATE_KEY) and applies §5g treatment. Production pages can adopt
this as a follow-up; no production code currently consumes it.
Live styleguide — `ui/src/pages/_styleguide.tsx`:
DEV-only route (gated by import.meta.env.DEV in App.tsx) showcasing
every primitive in isolation plus composed Config + Monitor archetype
demos. Intro callout surfaces the §0 invariants. §1b/§2a/§2b demos are
canonical rail-anchored examples authors can copy. Demo content uses
realistic Anthropic-style env field sets so the visual density mirrors
the cliproxy reference.
What this PR does NOT do:
- No production page rewrites — `pages/{api,accounts,codex,copilot,
cursor,droid,shared,updates,analytics,claude-extension,cliproxy-ai-
providers}` are unchanged
- No test selector updates — the existing test suite passes (excepting
the 2 pre-existing account-visual-groups failures already on dev)
- No HTML preview reports — the canonical visual reference is the live
/_styleguide page
- No new dependencies — `react-resizable-panels` was already declared
Validation: typecheck + lint + format + build clean. Test suite green
(519/521 — 2 pre-existing dev failures unrelated to this PR).
PR-Agent flagged two more real regressions vs the original health page:
1. Stale timestamp (HealthStatusRibbon): formatRelativeTime reads
Date.now() during render, but nothing forces a re-render after mount
so the 'last scan' label froze and never advanced to '1 minute ago',
'2 hours ago', etc. Restore the per-second tick (matching the
original health.tsx behavior) so the label behaves like a real
relative timestamp.
2. Hidden copy action (HealthPriorityCard): the fix-copy button was
gated by group-hover, making it invisible to keyboard users and
unreachable on mobile (no hover state). Make it always visible with
an aria-label so screen-reader and touch users can copy the fix
command.
PR-Agent flagged two real bugs at score 84:
1. Accessibility (HealthPriorityCard): the priority card header was a
clickable <div> with onClick, making expand/collapse mouse-only.
Promote it to a <button type="button"> with aria-expanded reflecting
state and a contextual aria-label, and a focus-visible ring. The
inner content is extracted into a shared fragment so the button and
the no-content fallback <div> render the same markup.
2. Time display (HealthStatusRibbon): formatRelativeTime lost the hours
branch from the original health.tsx, so a 2-hour-old scan showed as
'120 minutes ago' instead of '2 hours ago'. Restore the hours
rollover with health.hoursAgo i18n key.
Iteration on top of the health redesign:
- layout.tsx: drop backdrop-blur transparency on the global header
(interfered with the new health page colored backdrop)
- health.tsx: switch from fixed full-viewport background to scoped
absolute positioning; replace unreliable external grain SVG (vercel
app) with an inline data URI that always loads
- health-priority-card.tsx: refine spacing, expansion behavior, and
visual hierarchy
PR-Agent caught a regression: pre-migration the four home stats had
onClick handlers navigating to /providers, /cliproxy, /accounts, and
/health. The Phase 2 migration dropped those handlers, removing the
shortcuts for all users.
Add an optional onClick prop to KpiCard. When supplied, the tile
promotes from a static <div> to a <button> with hover lift, keyboard
focus ring, and an accessible aria-label derived from the label.
Wire all four home KPIs back to their original navigation targets.
Render exactly one layout at a time -- desktop 3-pane grid OR mobile
tabs -- via a useIsDesktop matchMedia hook gated at the lg breakpoint.
Previously the component rendered BOTH and toggled visibility via
Tailwind 'hidden lg:grid' / 'lg:hidden'. This duplicated FormSection
elements in the DOM, so document.getElementById() in SectionRail's
scroll-spy and click-to-jump would resolve to the (hidden) desktop copy
first on mobile -- making the rail attach to the wrong scroller and
never update the active section.
MonitorLayout:
- Wrap ScrollArea in an explicit flex-col container with min-h-0. The
previous version relied on the parent (PageShell) being flex-col for
flex-1 to compute a definite height; that's fragile. The wrapper now
establishes its own flex column so the ScrollArea reliably scrolls
whether or not the caller's wrapper is flex.
ConfigLayout (MobileTabs):
- Derive active tab during render with fallback to first available, same
pattern applied to JsonPane in the previous round. Parent toggling
`left` or `json` props could change the available tabs and leave
the controlled Tabs value pointing at a tab id that no longer existed,
rendering an empty pane on mobile until the user manually switched.
JsonPane:
- Reset active tab when the tabs prop changes. Previously activeTab was
initialized once and never re-synced; a parent swapping the tab set
(e.g. selecting a different entity) could leave us pointing at a stale
id, leaving the pane empty and Copy returning '{}'.
- Force textarea remount on data change in editable mode via key={text}.
Uncontrolled defaultValue retained the prior value when the underlying
config changed, so onBlur could save stale text.
SectionRail:
- Default scroll-spy root to the nearest scrollable ancestor instead of
the page viewport. FormPane wraps its body in a shadcn ScrollArea, so
IntersectionObserver with root:null was watching the wrong scroller and
the active section never updated as the form scrolled.
- observeRoot prop still wins when supplied for explicit overrides.
The previous fix used react-hook-form's `dirtyFields.extraModels` to
gate forwarding of the empty value. RHF compares each value to its
`defaultValues` entry, so typing "x" and erasing back to "" reverts
the dirty flag to false — making it impossible to clear a saved
ANTHROPIC_EXTRA_MODELS through the dashboard.
Track a local boolean that latches true on the first onChange and
resets when the dialog closes. The latch survives a "type then
delete" round-trip, so the empty-string delete signal still reaches
the server, while a never-touched field still gets skipped to
preserve the existing saved value.
The edit dialog cannot pre-populate `extraModels` from the existing
profile (the Profile type carries no env data), so the field always
opens blank in edit mode. Forwarding that blank value unconditionally
clobbered any saved ANTHROPIC_EXTRA_MODELS, since the PUT route treats
an empty string as a delete signal.
Track react-hook-form's `dirtyFields.extraModels` and only include the
field in the update payload when the user actually touched it. Typing
then clearing still marks the field dirty, so the explicit-clear UX
keeps working.
Add ANTHROPIC_EXTRA_MODELS env var to allow each API profile to
configure additional models alongside the primary ANTHROPIC_MODEL.
These are synced to CLIProxy config.yaml during ccs cliproxy sync.
- profile-mapper.ts: parse ANTHROPIC_EXTRA_MODELS on sync
- profile-writer.ts: write extra models to settings.json
- shared.ts + create-command.ts: --extra-models CLI flag
- profile-routes.ts + route-helpers.ts: REST API support
- profile-dialog.tsx + api-client.ts: frontend input