mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-03 21:34:40 +00:00
* feat(serve): chart model API errors and auto-retries in daemon status The Daemon Status dialog's Metrics tab tracked client→daemon HTTP request errors but had no visibility into model/LLM API failures or the automatic retries that absorb them. Add a "Model API health" chart plotting the per-window model API error and auto-retry counts. Those events originate in the ACP child (where LLM calls run), so they reach the daemon over the same child→daemon channel token usage already rides: a self-contained process-global counter (api-activity-tracker) is bumped at the logApiError / logApiRetry choke points, drained per live model round by the ACP MessageEmitter onto agent_message_chunk._meta, sniffed at the bridge fan-in, and folded into the daemon metrics ring as the llmApiErrors / llmApiRetries window aggregates (mirrored into the SDK wire type). A dedicated counter — rather than a new SessionMetrics field — keeps the change from rippling through ~20 stats/display/snapshot sites. Draining is guarded on a live frame's durationMs so replayed history can't consume real pending counts. * feat(web-shell): add per-chart help tooltips to daemon status metrics The Metrics tab packs ~12 dense time-series charts; with no inline explanation they generate recurring "what does this measure / why are there two 'errors' charts?" questions. Add an ⓘ affordance beside every chart title whose hover/focus reveals a one-line, plain-language note (what it measures, its unit/window, and what's normal). The button's aria-label carries the text to assistive tech; the visual bubble reuses the existing data-tooltip's surface so it reads in light and dark. The copy targets the highest-friction ambiguities: the HTTP "Requests" chart is called out as NOT model calls (vs. the model "API health" chart), and "API health" spells out the errors-vs-retries relationship (each failed attempt = 1 error, each backoff = 1 retry; errors above retries = hard failures). en + zh. * fix(web-shell): keep chart ⓘ hint out of the heading accessible name Review follow-up on #6837: - Move InfoHint out of the <h3> into a flex card header. The accessible-name algorithm folds a descendant button's aria-label (the whole help sentence) into the heading name, so a screen reader would announce "Model API health Each failed attempt = 1 error…" in the heading rotor. The flex header keeps the visual layout identical; a test now asserts no <h3> contains a button. - Add the missing logApiError OTel-SDK-off test, mirroring the logApiRetry one, so a future refactor that moves recordError() after the isTelemetrySdkInitialized guard is caught by a red test. * fix(web-shell): visible focus on chart ⓘ button, plus review nits Second review pass on #6837: - InfoHint focus: the ⓘ button now takes a filled-background focus/hover state (--muted, in scope under the DialogShell portal), matching the dialog's own header buttons (DialogShell .close / .iconButton). Keyboard focus is now unmistakable rather than a color shift alone (WCAG 2.4.7). - logApiRetry: doc comment corrected from "three-sink" to a four-sink fan-out — the apiActivityTracker increment (sink 0, fires before the SDK guard) was undocumented and read as stale. - Tighten the Metrics-tab SvgLineChart count assertion from >=10 to >=12 so a silently dropped chart card fails the test.
405 lines
8.7 KiB
CSS
405 lines
8.7 KiB
CSS
/* Scrolling is delegated to the DialogShell body (one scroll container, not
|
||
two), so this grows to its content and the shell — including fullscreen —
|
||
controls the height. */
|
||
.dialog {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
font-size: 13px;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbarActions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.updatedAt {
|
||
color: var(--muted-foreground);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.refreshError {
|
||
color: var(--error-color);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.refreshButton {
|
||
padding: 4px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: var(--foreground);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.refreshButton:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 10px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.levelOk {
|
||
color: var(--success-color);
|
||
background: color-mix(in srgb, var(--success-color) 14%, transparent);
|
||
}
|
||
|
||
.levelWarning {
|
||
color: var(--warning-color);
|
||
background: color-mix(in srgb, var(--warning-color) 14%, transparent);
|
||
}
|
||
|
||
.levelError {
|
||
color: var(--error-color);
|
||
background: color-mix(in srgb, var(--error-color) 14%, transparent);
|
||
}
|
||
|
||
.levelUnavailable {
|
||
color: var(--muted-foreground);
|
||
background: color-mix(in srgb, var(--muted-foreground) 14%, transparent);
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 2px;
|
||
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||
}
|
||
|
||
.tab {
|
||
min-height: 32px;
|
||
padding: 6px 14px;
|
||
border: none;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
background: transparent;
|
||
color: var(--muted-foreground);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.tab:hover {
|
||
color: var(--foreground);
|
||
}
|
||
|
||
/* Active tab: underline in the accent color, matching the dashboard's chart
|
||
accents; carries the selected state that `aria-selected` announces. */
|
||
.tabActive {
|
||
color: var(--foreground);
|
||
border-bottom-color: var(--primary);
|
||
}
|
||
|
||
/* Overview config cards: wider track so a wide panel shows ~3 roomy columns
|
||
instead of 4 cramped ones (still collapses to 2 on a narrow viewport). The
|
||
metrics tab overrides this with .chartsGrid below. */
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
/* The metrics tab renders in a full-height, extra-wide panel (not the old 70vh
|
||
dialog), so give the 11 time-series charts a wider track than the dense
|
||
overview cards — legible ~2–3-column trends instead of a squeezed 4-up. */
|
||
.chartsGrid {
|
||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 460px), 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.card {
|
||
border: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
|
||
border-radius: 10px;
|
||
padding: 10px 14px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.cardTitle {
|
||
margin: 0 0 8px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
color: var(--muted-foreground);
|
||
}
|
||
|
||
/* Chart cards render the title and its ⓘ help as siblings in a flex row (the
|
||
hint must stay out of the <h3> for a clean heading accessible name). The row
|
||
owns the bottom gap so the heading's own margin doesn't double it. */
|
||
.cardHeader {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.cardHeader .cardTitle {
|
||
margin: 0;
|
||
}
|
||
|
||
/* Per-chart help affordance: an ⓘ button whose hover/focus reveals a plain
|
||
explanation. The card has no `overflow: hidden` and the ⓘ sits at the card's
|
||
top-left after the title, so the bubble opens downward into the card's own
|
||
area — never clipped, never pushing horizontal scroll. */
|
||
.infoHint {
|
||
position: relative;
|
||
display: inline-flex;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.infoHintButton {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 16px;
|
||
height: 16px;
|
||
padding: 0;
|
||
border: none;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--muted-foreground);
|
||
/* Reset the uppercase heading context so the glyph renders cleanly. */
|
||
text-transform: none;
|
||
letter-spacing: normal;
|
||
font-size: 13px;
|
||
line-height: 1;
|
||
cursor: help;
|
||
}
|
||
|
||
/* Focus/hover: a filled background (not a color shift alone) so keyboard focus
|
||
is unmistakable — WCAG 2.4.7. Mirrors the dialog's own header buttons
|
||
(DialogShell `.close` / `.iconButton`), which use --muted rather than an
|
||
outline; --muted is in scope under the DialogShell portal. */
|
||
.infoHintButton:hover,
|
||
.infoHintButton:focus-visible {
|
||
background: var(--muted);
|
||
color: var(--foreground);
|
||
}
|
||
|
||
.infoHintTip {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
left: 0;
|
||
z-index: 30;
|
||
width: max-content;
|
||
max-width: 260px;
|
||
padding: 8px 10px;
|
||
border-radius: 6px;
|
||
/* Match the SvgLineChart data tooltip's surface; --popover/--card are absent
|
||
under the DialogShell body portal, so fall back to --background. */
|
||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||
background: var(--popover, var(--card, var(--background)));
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||
/* Reset the heading's uppercase/tracking so the sentence reads normally. */
|
||
text-transform: none;
|
||
letter-spacing: normal;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
line-height: 1.45;
|
||
color: var(--foreground);
|
||
white-space: normal;
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transition: opacity 120ms ease;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.infoHint:hover .infoHintTip,
|
||
.infoHint:focus-within .infoHintTip {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 2px 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
.rowLabel {
|
||
color: var(--muted-foreground);
|
||
}
|
||
|
||
.rowValue {
|
||
/* Keep short values (counts, "0 / 0 / 0", "first-responder") on one line;
|
||
the label wraps instead. Long path values opt back into wrapping via
|
||
.pathValue, which restores a small min-content width for this flex item. */
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.pathRow {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: 2px 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pathValue {
|
||
max-width: 100%;
|
||
font-family: var(--font-mono, monospace);
|
||
font-size: 12px;
|
||
/* Single line, ellipsis at the START so the tail (…/parent/workspace)
|
||
stays visible; the row widens/narrows with the card. */
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
direction: rtl;
|
||
text-align: left;
|
||
}
|
||
|
||
.empty {
|
||
color: var(--muted-foreground);
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.issueRow {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
padding: 3px 0;
|
||
}
|
||
|
||
.issueMessage {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.featureChips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px 6px;
|
||
/* A long capability set shouldn't make this card tower over its neighbours;
|
||
cap the height and let it scroll. */
|
||
max-height: 132px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.featureChip {
|
||
padding: 1px 7px;
|
||
border-radius: 5px;
|
||
background: color-mix(in srgb, var(--border) 40%, transparent);
|
||
color: var(--muted-foreground);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
font-family: var(--font-mono, monospace);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.summaryChip {
|
||
padding: 2px 8px;
|
||
border-radius: 6px;
|
||
background: color-mix(in srgb, var(--border) 40%, transparent);
|
||
font-size: 12px;
|
||
font-family: var(--font-mono, monospace);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.sessionRow {
|
||
padding: 6px 0;
|
||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||
}
|
||
|
||
.sessionRow:first-of-type {
|
||
border-top: none;
|
||
}
|
||
|
||
.sessionName {
|
||
font-family: var(--font-mono, monospace);
|
||
font-size: 12px;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.sessionMeta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 2px;
|
||
color: var(--muted-foreground);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.activePrompt {
|
||
color: var(--warning-color);
|
||
}
|
||
|
||
.workspaceRow {
|
||
padding: 6px 0;
|
||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||
}
|
||
|
||
.workspaceRow:first-of-type {
|
||
border-top: none;
|
||
}
|
||
|
||
.workspaceRowHead {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.workspaceName {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.workspaceDuration {
|
||
margin-left: auto;
|
||
color: var(--muted-foreground);
|
||
font-size: 12px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.workspaceError {
|
||
margin-top: 2px;
|
||
color: var(--error-color);
|
||
font-size: 12px;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.workspaceSummary {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.workspaceCell {
|
||
display: flex;
|
||
align-items: baseline;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.workspaceCellLabel {
|
||
font-family: var(--font-mono, monospace);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.workspaceCellMessage {
|
||
color: var(--muted-foreground);
|
||
overflow-wrap: anywhere;
|
||
}
|