From 3b3f9d8443e1070c51d6b7023082ec375b14d135 Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sat, 8 Aug 2026 19:26:35 +0800 Subject: [PATCH 1/4] Unify settings and compute surfaces --- .../workspace/src/atlas/ComputeSurface.css | 957 ++++-------------- .../src/atlas/ComputeSurface.test.ts | 248 +---- .../workspace/src/atlas/ComputeSurface.tsx | 175 +--- frontend/workspace/src/atlas/HostStrip.css | 167 ++- .../workspace/src/atlas/HostStrip.test.ts | 31 +- frontend/workspace/src/atlas/HostStrip.tsx | 125 +-- .../workspace/src/atlas/KernelCard.test.tsx | 308 +----- frontend/workspace/src/atlas/KernelCard.tsx | 314 +----- .../workspace/src/atlas/KernelPanel.test.ts | 172 +--- frontend/workspace/src/atlas/KernelPanel.tsx | 368 +------ frontend/workspace/src/atlas/RightPane.tsx | 3 +- .../src/atlas/host-instruments.test.ts | 46 +- .../workspace/src/atlas/host-instruments.ts | 53 +- .../src/components/dialog-settings.test.ts | 6 + .../src/components/dialog-settings.tsx | 43 +- frontend/workspace/src/pages/session.tsx | 2 +- 16 files changed, 575 insertions(+), 2443 deletions(-) diff --git a/frontend/workspace/src/atlas/ComputeSurface.css b/frontend/workspace/src/atlas/ComputeSurface.css index 18f32603..b8df8efe 100644 --- a/frontend/workspace/src/atlas/ComputeSurface.css +++ b/frontend/workspace/src/atlas/ComputeSurface.css @@ -1,559 +1,231 @@ .compute-surface { - --compute-radius: 10px; -} - -/* 3a sets the tabs in letter-spaced capitals — the same voice as the labels - above them, so the strip and the tabs read as one apparatus rather than a - readout with navigation bolted underneath. Selection is a 2px underline in - the brand colour; the single solid terracotta on this surface stays with the - primary action. */ -.compute-surface__tabs { - box-sizing: border-box; + container: compute / inline-size; display: flex; - gap: 26px; - margin: 0; - padding: 0 18px; - border-bottom: 1px solid var(--color-border); - border-radius: 0; - background: transparent; -} - -.compute-surface__tab { - position: relative; - display: inline-flex; - align-items: baseline; - gap: 5px; + min-width: 0; min-height: 0; - padding: 9px 0; - border: 0; - border-radius: 0; - background: none; - font-size: 10px; - color: var(--color-text-muted); - cursor: pointer; -} - -/* The count joins the label as "JOBS · 24" rather than sitting apart, so the - pair reads as one token of type. */ -.compute-surface__count::before { - content: "·"; - padding-right: 5px; - color: var(--color-text-faint); -} - -.compute-surface__count { - font-family: inherit; - font-size: inherit; - letter-spacing: inherit; - color: inherit; - font-variant-numeric: tabular-nums; -} - -.compute-surface__tab[data-active="true"] { - background: none; - color: var(--color-text); - box-shadow: inset 0 -2px 0 var(--surface-brand-base); -} - -.compute-surface__tab:focus-visible { - outline: 2px solid var(--color-focus); - outline-offset: 2px; - border-radius: 4px; -} - -/* The visible count is aria-hidden so a screen reader hears "Jobs, 24 jobs" - rather than "Jobs 24" with no unit. */ -.compute-surface__sr { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; + height: 100%; + flex: 1; + flex-direction: column; overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; -} - -.compute-surface .kernel-panel__header { - box-sizing: border-box; - min-height: 48px; - gap: 12px; - padding: 7px 10px 7px 12px; background: var(--color-bg); + color: var(--color-text); + font-family: inherit; + font-size: 13px; + line-height: 1.35; } -.compute-surface .kernel-panel__heading { - grid-template-columns: 1fr; - gap: 1px; -} - -.compute-surface .kernel-panel__heading strong { - font-size: 14px; - font-weight: 600; - letter-spacing: -0.01em; -} - -.compute-surface .kernel-panel__heading > span:last-child { - grid-column: 1; - font-size: 12px; - line-height: 1.3; -} - -.compute-surface .kernel-panel__refresh { +.compute-surface__panel, +.compute-surface .kernel-panel { display: flex; - align-items: center; - gap: 12px; - font-size: 12px; -} - -.compute-surface .kernel-panel__primary-action { - width: auto; - height: 26px; - padding: 0 12px; - border: 0; - border-radius: 999px; - background: var(--surface-brand-base); - color: var(--text-on-brand-strong); - font-size: 12px; - font-weight: 500; - cursor: pointer; -} - -.compute-surface .kernel-panel__primary-action:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.compute-surface .kernel-panel__primary-action:hover:not(:disabled) { - background: var(--surface-brand-hover); + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; } .compute-surface .kernel-panel__body { - padding: 10px; -} - -.compute-surface .kernel-panel__create { - display: grid; - grid-template-columns: minmax(0, 1fr) 110px auto; - align-items: end; - gap: 8px; - margin-bottom: 10px; - padding: 10px; - border: 1px solid var(--color-border); - border-radius: 10px; - background: var(--color-bg-elevated); -} - -.compute-surface .kernel-panel__create label { - display: grid; - gap: 4px; min-width: 0; - font-size: 10px; - color: var(--color-text-faint); + min-height: 0; + flex: 1; + padding: 0; + overflow-x: hidden; + overflow-y: auto; } -.compute-surface .kernel-panel__create input, -.compute-surface .kernel-panel__create select, -.compute-surface .kernel-panel__create button { - box-sizing: border-box; - min-height: 32px; - padding: 6px 9px; +.compute-surface .kernel-panel__message { + margin: 10px 12px 0; + padding: 8px 10px; border: 1px solid var(--color-border); border-radius: 8px; - color: var(--color-text); - background: var(--color-bg); - font: inherit; -} - -.compute-surface .kernel-panel__create > div { - display: flex; - gap: 6px; -} - -.compute-surface .kernel-panel__create button[type="submit"] { - color: var(--color-bg); - background: var(--color-text); -} - -/* Sits under a hairline as the last thing in the header block, so it reads as - a note about the section rather than a banner inside the list. */ -.compute-surface .kernel-panel__scope { - display: block; - margin: 0 0 14px; - padding: 0 0 14px; - border: 0; - border-bottom: 1px solid var(--color-border); - border-radius: 0; - background: transparent; -} - -.compute-surface .kernel-panel__scope p { - max-width: none; - font-size: 12px; - line-height: 1.5; + background: var(--color-bg-elevated); color: var(--color-text-muted); -} - -.compute-surface .kernel-panel__message { - padding: 9px 10px; - margin-bottom: 9px; - border-radius: 8px; font-size: 12px; } -.compute-surface .kernel-panel__message--authority { - color: var(--color-text-muted); - background: color-mix(in srgb, var(--color-warning) 7%, var(--color-bg)); - border-color: color-mix(in srgb, var(--color-warning) 20%, var(--color-border)); +.compute-surface .kernel-panel__message--error { + border-color: color-mix(in srgb, var(--color-error) 24%, var(--color-border)); + color: var(--color-error); } .compute-surface .kernel-panel__empty { + display: flex; min-height: 220px; - gap: 8px; - padding: 30px 22px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 7px; + padding: 32px 24px; + color: var(--color-text-muted); + text-align: center; } .compute-surface .kernel-panel__empty > span { - width: 38px; - height: 38px; - border-radius: 10px; + display: grid; + width: 30px; + height: 30px; + place-items: center; + margin-bottom: 2px; + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text-faint); } .compute-surface .kernel-panel__empty strong { - font-size: 14px; + color: var(--color-text); + font-size: 13px; + font-weight: 500; } .compute-surface .kernel-panel__empty p { - max-width: 280px; + max-width: 310px; + margin: 0; font-size: 12px; - line-height: 1.45; -} - -.compute-surface .kernel-panel__list { - gap: 8px; + line-height: 1.5; } .compute-surface .kernel-panel__sessions { - display: grid; - gap: 16px; + display: flex; + min-width: 0; + flex-direction: column; } .compute-surface .kernel-session { - display: grid; - gap: 8px; + min-width: 0; + border-bottom: 1px solid var(--color-border); } .compute-surface .kernel-session__header { - display: flex; + display: grid; + min-width: 0; + min-height: 44px; + box-sizing: border-box; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - justify-content: space-between; - gap: 12px; - padding: 0 2px; + gap: 10px; + padding: 8px 14px; + background: var(--color-bg-subtle); color: var(--color-text-muted); font-size: 11px; } -.compute-surface .kernel-session__header > div { - display: flex; - align-items: baseline; - min-width: 0; - gap: 7px; -} - -.compute-surface .kernel-session__header strong { - overflow: hidden; - color: var(--color-text); - font-size: 12px; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; +.compute-surface .kernel-session[data-current="true"] .kernel-session__header { + background: color-mix(in srgb, var(--color-bg-elevated) 72%, var(--color-bg)); } -.compute-surface .kernel-session[data-current="true"] .kernel-session__header > div > span { - color: var(--color-accent); -} - -.compute-surface .kernel-panel__saved { - display: grid; - gap: 7px; - margin-top: 18px; - padding-top: 14px; - border-top: 1px solid var(--color-border); -} - -.compute-surface .kernel-panel__saved > header, -.compute-surface .kernel-panel__saved-row { +.compute-surface .kernel-session__identity { display: flex; + min-width: 0; align-items: center; - justify-content: space-between; - gap: 12px; + gap: 7px; } -.compute-surface .kernel-panel__saved > header { - color: var(--color-text-muted); +.compute-surface .kernel-session__identity > span { + color: var(--color-text-faint); font-size: 11px; } -.compute-surface .kernel-panel__saved > header strong { +.compute-surface .kernel-session__identity strong { + overflow: hidden; color: var(--color-text); font-size: 12px; -} - -.compute-surface .kernel-panel__saved-row { - min-height: 42px; - padding: 8px 10px; - border-radius: 9px; - background: var(--color-bg-elevated); - box-shadow: inset 0 0 0 1px var(--color-border); -} - -.compute-surface .kernel-panel__saved-row > div:first-child { - display: grid; - min-width: 0; - gap: 2px; -} - -.compute-surface .kernel-panel__saved-row > div:first-child strong, -.compute-surface .kernel-panel__saved-row > div:first-child span { - overflow: hidden; + font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } -.compute-surface .kernel-panel__saved-row > div:first-child strong { - color: var(--color-text); - font-size: 12px; -} - -.compute-surface .kernel-panel__saved-row > div:first-child span { - color: var(--color-text-muted); - font-size: 10px; -} - -.compute-surface .kernel-panel__saved-row > div:last-child { - display: flex; - gap: 5px; -} - -.compute-surface .kernel-panel__saved-row button { - min-height: 26px; - padding: 0 8px; +.compute-surface .kernel-session__identity em { + flex: none; + padding: 1px 6px; border: 1px solid var(--color-border); - border-radius: 6px; - color: var(--color-text-muted); - background: transparent; + border-radius: 999px; + color: var(--color-text-faint); font-size: 10px; - cursor: pointer; + font-style: normal; } -.compute-surface .kernel-panel__saved-row button:hover:not(:disabled) { - color: var(--color-text); - background: var(--color-bg-hover); -} - -.compute-surface .kernel-panel__saved-row button:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -/* 3a nests the kernel in its own card rather than letting it sit flat on the - panel — the runtime is a distinct object with its own controls, and the - inset says so. The card itself carries no padding: the head owns its own, - so the divider under it can run the full width of the plate. */ -.compute-surface .kernel-card { - gap: 0; - margin: 0; - padding: 0; - border-radius: 18px; - background: var(--color-bg-elevated); - box-shadow: inset 0 0 0 1px var(--color-border); - overflow: hidden; +.compute-surface .kernel-session__header > span { + font-variant-numeric: tabular-nums; + white-space: nowrap; } -/* The head is the toggle. A caret-sized hit target is well under the 44px a - thumb can reliably hit on a tablet, and this pane is read on one. */ -.compute-surface .kernel-card__plate { +.compute-surface .kernel-panel__list { display: flex; + min-width: 0; flex-direction: column; - gap: 16px; - width: 100%; - padding: 16px; - border: 0; - background: none; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} - -.compute-surface .kernel-card__plate:focus-visible { - outline: 2px solid var(--color-focus); - outline-offset: -2px; + gap: 0; } -.compute-surface .kernel-card[data-open="true"] .kernel-card__plate { - border-bottom: 1px solid var(--color-border); +.compute-surface .kernel-card { + display: grid; + min-width: 0; + min-height: 62px; + box-sizing: border-box; + grid-template-columns: minmax(136px, 1fr) 54px 76px 58px 52px; + align-items: center; + gap: 10px; + padding: 9px 14px; + border: 0; + border-top: 1px solid var(--color-border); + border-radius: 0; + background: var(--color-bg); + box-shadow: none; } -/* Flex, not the base grid: the head is two groups pushed apart, and the left - group stacks an eyebrow over the name. */ -.compute-surface .kernel-card__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; +.compute-surface .kernel-card:first-child { + border-top: 0; } -.compute-surface .kernel-card__title { +.compute-surface .kernel-card__main { display: flex; - flex-direction: column; - gap: 8px; min-width: 0; + align-items: center; + gap: 9px; } -/* The language badge becomes the eyebrow's voice: small letter-spaced - capitals over the name, rather than a coloured tile beside it. */ .compute-surface .kernel-card__language { - width: auto; - height: auto; - border-radius: 0; - background: none; - color: var(--color-text-faint); + display: grid; + width: 30px; + height: 30px; + flex: none; + place-items: center; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-elevated); + color: var(--color-text-muted); font-size: 10px; - white-space: nowrap; -} - -.compute-surface .kernel-card__title strong { - font-size: 17px; - font-weight: 400; - line-height: 1.1; - letter-spacing: -0.01em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-weight: 500; + letter-spacing: 0; } -.compute-surface .kernel-card__lede { +.compute-surface .kernel-card__copy { display: flex; - align-items: center; - flex: none; - gap: 12px; + min-width: 0; + flex-direction: column; + gap: 2px; } -.compute-surface .kernel-card__uptime { +.compute-surface .kernel-card__copy strong { + overflow: hidden; + color: var(--color-text); font-size: 12px; - font-variant-numeric: tabular-nums; - color: var(--color-text-muted); + font-weight: 500; + text-overflow: ellipsis; white-space: nowrap; } -.compute-surface .kernel-card__caret { +.compute-surface .kernel-card__copy > span { display: flex; + min-width: 0; align-items: center; - justify-content: center; - width: 9px; - color: var(--color-text-muted); -} - -/* Lifecycle reads as a pill, so a glance finds the runtime's state without - opening the plate. */ -.compute-surface .kernel-card__owner, -.compute-surface .kernel-card__state { - display: inline-flex; - align-items: center; - padding: 3px 8px; - border-radius: 999px; - font-size: 10px; - font-weight: 400; + gap: 5px; + overflow: hidden; + color: var(--color-text-faint); + font-size: 11px; + text-overflow: ellipsis; white-space: nowrap; } -.compute-surface .kernel-card__state { - flex: none; - min-height: 0; - gap: 6px; - color: var(--color-text); - box-shadow: inset 0 0 0 1px var(--color-border-strong); -} - -/* The card is a one-column grid, so a pill left to itself would stretch the - full width and stop reading as a pill. */ -.compute-surface .kernel-card__owner { - justify-self: start; - margin: 12px 16px 0; -} - -/* The tone used to reach the eye through a 5px dot beside the label. The 3a - pill has no dot, so the tone has to be the pill: a tinted ground and a ring - in the status colour. color-mix against the card's own ground keeps that - legible in all sixteen themes rather than only the dark one it was drawn in. */ -/* The word is set in the neutral text colour and the status colour moves to - the dot. Colouring the word meant setting a hue on a 12% tint of itself, - which works on a dark ground — the tint darkens it — but collapses on a - light one, where the tint is near-white and the hue has nothing to separate - from. Measured across all eighteen themes, that scheme fell to 1.0:1 on - vesper/light and sat under AA on nearly every light variant; this one holds - 3.8:1 at worst and clears AA almost everywhere. A dot carries colour at a - size where contrast is not what makes it legible. */ -.compute-surface .kernel-card__state[data-tone="active"] { - background: color-mix(in srgb, var(--color-success) 12%, var(--color-bg-elevated)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-success) 38%, var(--color-bg-elevated)); -} - -.compute-surface .kernel-card__state[data-tone="active"] .kernel-card__state-dot { - background: var(--color-success); -} - -.compute-surface .kernel-card__state[data-tone="pending"] { - background: color-mix(in srgb, var(--color-warning) 12%, var(--color-bg-elevated)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-warning) 38%, var(--color-bg-elevated)); -} - -.compute-surface .kernel-card__state[data-tone="pending"] .kernel-card__state-dot { - background: var(--color-warning); -} - -.compute-surface .kernel-card__state[data-tone="danger"] { - background: color-mix(in srgb, var(--color-error) 12%, var(--color-bg-elevated)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-error) 38%, var(--color-bg-elevated)); -} - -.compute-surface .kernel-card__state[data-tone="danger"] .kernel-card__state-dot { - background: var(--color-error); -} - -.compute-surface .kernel-card__state-dot { - width: 5px; - height: 5px; - flex: none; - border-radius: 999px; - background: var(--color-text-faint); -} - -/* Ready and muted stay uncoloured. A kernel that is merely alive is not news, - and colouring it would spend the eye's attention on the common case. */ -/* Ready and muted keep an uncoloured dot: a runtime that is merely alive is - not news, and tinting it would spend attention on the common case. */ -.compute-surface .kernel-card__state[data-tone="ready"], -.compute-surface .kernel-card__state[data-tone="muted"] { - color: var(--color-text-muted); -} - -/* The network statement sits under the environment header as its own line, - led by a dot in its tone. An open network is the state that changes what a - run can touch, so it carries the warning colour; a blocked one stays grey - rather than spending attention on the safe, and far more common, case. */ -.compute-surface .kernel-card__network { - display: flex; - align-items: center; - gap: 6px; - margin: -2px 0 2px; - font-size: 10px; - color: var(--color-text-muted); -} - -.compute-surface .kernel-card__network-dot { +.compute-surface .kernel-card__copy i { width: 6px; height: 6px; flex: none; @@ -561,354 +233,115 @@ background: var(--color-text-faint); } -.compute-surface .kernel-card__network[data-tone="pending"] { - color: var(--color-warning); +.compute-surface .kernel-card__copy i[data-tone="active"] { + background: var(--color-success); } -.compute-surface .kernel-card__network[data-tone="pending"] .kernel-card__network-dot { +.compute-surface .kernel-card__copy i[data-tone="pending"] { background: var(--color-warning); } -/* One row, as the design sets them. The base grid is two columns, which left - Stop stranded on a line of its own — and Stop is the one that discards work, - so it read as an afterthought rather than the control to be careful with. */ -.compute-surface .kernel-card__controls { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.compute-surface .kernel-card__controls button { - flex: 1 1 0; - min-width: 92px; - min-height: 34px; - border-radius: 999px; - font-size: 12px; -} - -/* The identity disclosure is set in the same capitals as the instrument - labels, so it reads as the last row of the record rather than a link. */ -.compute-surface .kernel-card__identity summary { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 0; - font-size: 10px; - font-weight: 400; - color: var(--color-text-faint); - list-style: none; -} - -.compute-surface .kernel-card__identity summary::-webkit-details-marker { - display: none; +.compute-surface .kernel-card__copy i[data-tone="danger"] { + background: var(--color-error); } -.compute-surface .kernel-card__identity summary::after { - content: "▾"; - font-size: 12px; +.compute-surface .kernel-card__uptime, +.compute-surface .kernel-card__metric { color: var(--color-text-muted); -} - -.compute-surface .kernel-card__identity[open] summary::after { - content: "▴"; -} - -/* Two figures that survive the collapse, because "is this runtime in my way" - is the question a collapsed list still has to answer. */ -.compute-surface .kernel-card__usage { - display: flex; - align-items: center; - gap: 16px; -} - -/* Equal halves, so the memory track and the core segments are the same length - and read as one instrument rather than two of different sizes. 3a pinned the - cores to 126px, which is close to even in a narrow pane but leaves the two - bars visibly mismatched once the pane is wide — and this one is read wide. */ -.compute-surface .kernel-card__usage-item { - display: flex; - flex-direction: column; - gap: 8px; - flex: 1 1 0; - min-width: 0; -} - -.compute-surface .kernel-card__usage-label { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; + font-size: 11px; font-variant-numeric: tabular-nums; - font-size: 12px; - color: var(--color-text-muted); white-space: nowrap; } -.compute-surface .kernel-card__usage-label > span:first-child { - font-size: 10px; - color: var(--color-text-faint); -} - -.compute-surface .kernel-card__usage-label strong { - font-weight: 400; - color: var(--color-text); -} - -.compute-surface .kernel-card__usage-track { - height: 3px; - border-radius: 999px; - background: var(--color-border); - overflow: hidden; -} - -/* Instrument blue, matching the host strip's gauge — the terracotta on this - surface belongs to the primary action alone. */ -.compute-surface .kernel-card__usage-fill { - height: 100%; - border-radius: 999px; - background: var(--border-info-selected); -} - -.compute-surface .kernel-card__usage-cores { - display: flex; - gap: 3px; -} - -.compute-surface .kernel-card__usage-cores > div { - flex: 1; - height: 3px; - border-radius: 999px; - background: var(--color-border); -} - -.compute-surface .kernel-card__usage-cores > div[data-lit="true"] { - background: var(--border-info-selected); -} - -/* The opened body keeps the head's horizontal rhythm, and sets its own - vertical one. The card is gap: 0 so the divider under the head can run the - full width of the plate, which means every block below has to carry its own - spacing — without this they stacked flush and the record read as one dense - paragraph of figures. */ -.compute-surface .kernel-card__metrics, -.compute-surface .kernel-card__environment, -.compute-surface .kernel-card__recovery, -.compute-surface .kernel-card__controls, -.compute-surface .kernel-card__identity { - margin: 20px 16px 0; -} - -/* The two grids are one ledger read as a single column of rows, so they sit - closer to each other than to the blocks around them. */ -.compute-surface .kernel-card__metrics--usage { - margin-top: 12px; -} - -.compute-surface .kernel-card__identity { - margin-bottom: 4px; -} - -/* Label and value joined by a dotted leader, as a printed record would set - them — the eye follows the rule across rather than guessing which value - belongs to which label. */ -.compute-surface .kernel-card__metrics, -.compute-surface .kernel-card__metrics--usage { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px 20px; - padding: 0; - border: 0; - border-radius: 0; - background: none; -} - .compute-surface .kernel-card__metric { display: flex; - align-items: baseline; - gap: 8px; - min-height: 0; - padding: 0; - border: 0; - background: none; -} - -/* Stated rather than merely omitted. The base rule (atlas.css, `.kernel-card__ - metric span`) sets uppercase and 0.05em tracking, and the cascade resolves - per property — so dropping these declarations here left the base ones - winning and the ledger still shouting. Setting them to their initial values - is what actually turns it off, which is why origin/main writes them out too. */ -.compute-surface .kernel-card__metric span { - order: 1; - flex: none; - font-size: 10px; - letter-spacing: 0; - text-transform: none; - color: var(--color-text-muted); - white-space: nowrap; -} - -.compute-surface .kernel-card__metric::after { - content: ""; - order: 2; - flex: 1; - border-bottom: 1px dotted var(--color-border-strong); - transform: translateY(-3px); + min-width: 0; + flex-direction: column; + gap: 1px; } .compute-surface .kernel-card__metric strong { - order: 3; - flex: none; - font-variant-numeric: tabular-nums; - font-size: 12px; - font-weight: 400; + overflow: hidden; color: var(--color-text); - white-space: nowrap; -} - -.compute-surface .kernel-card__environment { - gap: 12px; - padding: 20px 0 0; - border-top: 1px solid var(--color-border); - border-radius: 0; - background: none; -} - -.compute-surface .kernel-card__environment-header strong { - font-size: 14px; - font-weight: 400; + font-size: 11px; + font-weight: 500; + text-overflow: ellipsis; } -.compute-surface .kernel-card__environment-header span { - padding: 3px 8px; - border-radius: 999px; - box-shadow: inset 0 0 0 1px var(--color-border-strong); - font-size: 10px; +.compute-surface .kernel-card__metric small { + color: var(--color-text-faint); + font-size: 9px; + letter-spacing: 0; + line-height: 1; + text-transform: lowercase; } -.compute-surface .kernel-card__recovery { - padding-left: 0; - border-left: 0; - font-size: 12px; - line-height: 1.5; +.compute-surface .kernel-card__stop { + min-width: 48px; + height: 28px; + padding: 0 9px; + border: 1px solid var(--color-border); + border-radius: 7px; + background: transparent; color: var(--color-text-muted); + font: inherit; + font-size: 11px; + cursor: pointer; } -/* Three equal pills. None of them is the primary action on this surface — - that belongs to New kernel — so all three are outlined, and only Stop - carries a colour, because it is the one that discards work. */ -.compute-surface .kernel-card__controls { - gap: 8px; -} - -.compute-surface .kernel-card__controls button { - flex: 1; - min-height: 26px; - padding: 0 10px; - border: 0; - border-radius: 999px; - box-shadow: inset 0 0 0 1px var(--color-border-strong); - background: none; - color: var(--color-text); - font-size: 12px; -} - -.compute-surface .kernel-card__stop { +.compute-surface .kernel-card__stop:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--color-error) 36%, var(--color-border)); color: var(--color-error); } -.compute-surface .kernel-card__identity { - border-top: 1px solid var(--color-border); -} - -.compute-surface .kernel-card__identity summary { - padding-top: 11px; - font-size: 10px; - color: var(--color-text-muted); +.compute-surface .kernel-card__stop:disabled { + opacity: 0.45; + cursor: not-allowed; } -.compute-surface .kernel-card__identity > div { - gap: 8px; - padding-top: 7px; +.compute-surface .kernel-card__stop:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; } -.compute-surface .kernel-card__identity-row span, -.compute-surface .kernel-card__identity-row code { - font-size: 10px; -} +@container compute (max-width: 470px) { + .compute-surface .kernel-session__header { + grid-template-columns: minmax(0, 1fr); + gap: 3px; + padding-block: 9px; + } -.compute-jobs :where(button, input, select, textarea):focus-visible { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 54%, transparent); - outline: none; -} + .compute-surface .kernel-session__header > span { + padding-left: 18px; + } -@media (max-width: 720px) { - .compute-surface .kernel-panel__create { - grid-template-columns: minmax(0, 1fr) 96px; + .compute-surface .kernel-card { + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 8px; + padding: 10px 12px; } - .compute-surface .kernel-panel__create > div { + .compute-surface .kernel-card__main { grid-column: 1 / -1; - justify-content: flex-end; } -} - -/* A grid item with `justify-self: end` is sized to its content, not its - column, so the base rule's `text-overflow: ellipsis` never fired: a working - directory longer than the pane overflowed leftward and painted on top of its - own label. Stretching the value to the column is what makes it clip. */ -.compute-surface .kernel-card__environment-row { - align-items: start; -} -.compute-surface .kernel-card__environment-row code, -.compute-surface .kernel-card__environment-row p { - justify-self: stretch; + .compute-surface .kernel-card__uptime { + padding-left: 39px; + } } -/* A run that is still moving sweeps a gradient along the foot of its row. The - status column already says RUNNING, but a word does not read as motion, and - this is the only row in the ledger whose value will change on its own. The - warning tone is the same one the status text carries, so the row says one - thing in two ways rather than two things. */ -.compute-surface .compute-run--active::after { - content: ""; - position: absolute; - left: 8px; - right: 8px; - bottom: 0; - height: 2px; - border-radius: 999px; - background-image: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--color-warning) 85%, transparent) 50%, - transparent 100% - ); - background-size: 45% 100%; - background-repeat: no-repeat; - animation: compute-run-sweep 1.8s linear infinite; -} - -@keyframes compute-run-sweep { - from { - background-position: -45% 0; +@container compute (max-width: 350px) { + .compute-surface .kernel-card { + grid-template-columns: minmax(0, 1fr) auto auto; } - to { - background-position: 145% 0; + + .compute-surface .kernel-card__uptime { + display: none; } } -/* Motion here is decorative — the status column carries the same fact in - words, so removing it costs nothing. */ @media (prefers-reduced-motion: reduce) { - .compute-surface .compute-run--active::after { - animation: none; - background-image: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--color-warning) 55%, transparent) 50%, - transparent 100% - ); - background-size: 100% 100%; + .compute-surface * { + scroll-behavior: auto; } } diff --git a/frontend/workspace/src/atlas/ComputeSurface.test.ts b/frontend/workspace/src/atlas/ComputeSurface.test.ts index 958f550e..5c58aaa9 100644 --- a/frontend/workspace/src/atlas/ComputeSurface.test.ts +++ b/frontend/workspace/src/atlas/ComputeSurface.test.ts @@ -13,20 +13,15 @@ const server = await createServer({ server: { middlewareMode: true }, appType: "custom", resolve: { conditions: ["browser", "production"], dedupe: ["solid-js", "solid-js/web"] }, - ssr: { - noExternal: true, - resolve: { conditions: ["browser", "production"] }, - }, + ssr: { noExternal: true, resolve: { conditions: ["browser", "production"] } }, }) const [subject, web] = await Promise.all([ server.ssrLoadModule("/src/atlas/ComputeSurface.tsx") as Promise, server.ssrLoadModule("solid-js/web") as Promise, ]) const cleanups: Array<() => void> = [] -type Mounted = { kernels: number; jobs: number } afterAll(() => server.close()) - afterEach(() => { cleanups.splice(0).forEach((cleanup) => cleanup()) document.body.replaceChildren() @@ -39,235 +34,44 @@ const mount = (view: () => JSX.Element) => { return host } -const child = (name: keyof Mounted, mounted: Mounted) => () => { - mounted[name]++ - const panel = document.createElement("section") - panel.dataset.computeChild = name - panel.textContent = `${name} content` - return panel +const child = (name: string, calls: string[]) => () => { + calls.push(name) + const node = document.createElement("section") + node.dataset.computeChild = name + return node } -const request = (status: Array<"running" | "succeeded"> = []) => - Object.assign( - async () => - Response.json( - status.map((value, index) => ({ - id: `job_${index}`, - status: value, - })), - ), - { url: () => "http://localhost/settings/compute/jobs" }, - ) - describe("compute surface", () => { - test("defaults to Kernels and does not mount Jobs until selected", async () => { - const mounted = { kernels: 0, jobs: 0 } - const host = mount(() => - subject.ComputeSurface({ - strip: () => document.createElement("section"), - kernels: child("kernels", mounted), - jobs: child("jobs", mounted), - request: request(), - }), - ) - const kernels = host.querySelector('[role="tab"][data-compute-tab="kernels"]') - const jobs = host.querySelector('[role="tab"][data-compute-tab="jobs"]') + test("renders one project-wide live kernel inventory", () => { + const calls: string[] = [] + const host = mount(() => subject.ComputeSurface({ strip: child("strip", calls), kernels: child("kernels", calls) })) - expect(host.querySelector('[role="tablist"]')?.getAttribute("aria-label")).toBe("Compute views") - expect(kernels?.getAttribute("aria-selected")).toBe("true") - expect(jobs?.getAttribute("aria-selected")).toBe("false") + expect(calls).toEqual(["strip", "kernels"]) + expect(host.querySelector('[aria-label="Compute"]')).not.toBeNull() + expect(host.querySelector('[data-compute-child="strip"]')).not.toBeNull() expect(host.querySelector('[data-compute-child="kernels"]')).not.toBeNull() - expect(host.querySelector('[data-compute-child="jobs"]')).toBeNull() - expect(mounted).toEqual({ kernels: 1, jobs: 0 }) - const panel = host.querySelector('[role="tabpanel"]') - expect(kernels?.getAttribute("aria-controls")).toBe(panel?.id) - expect(panel?.getAttribute("aria-labelledby")).toBe(kernels?.id) - - jobs?.click() - await Promise.resolve() - - expect(kernels?.getAttribute("aria-selected")).toBe("false") - expect(jobs?.getAttribute("aria-selected")).toBe("true") - expect(host.querySelector('[data-compute-child="kernels"]')).toBeNull() - expect(host.querySelector('[data-compute-child="jobs"]')).not.toBeNull() - expect(mounted).toEqual({ kernels: 1, jobs: 1 }) - const next = host.querySelector('[role="tabpanel"]') - expect(jobs?.getAttribute("aria-controls")).toBe(next?.id) - expect(next?.getAttribute("aria-labelledby")).toBe(jobs?.id) + expect(host.querySelector('[role="tablist"]')).toBeNull() }) - test("uses automatic arrow-key activation and focus for its tabs", async () => { - const mounted = { kernels: 0, jobs: 0 } - const host = mount(() => - subject.ComputeSurface({ - strip: () => document.createElement("section"), - kernels: child("kernels", mounted), - jobs: child("jobs", mounted), - request: request(), - }), - ) - const kernels = host.querySelector('[role="tab"][data-compute-tab="kernels"]') - const jobs = host.querySelector('[role="tab"][data-compute-tab="jobs"]') - - kernels?.focus() - kernels?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })) - await Promise.resolve() - - expect(jobs?.getAttribute("aria-selected")).toBe("true") - expect(document.activeElement).toBe(jobs) - - jobs?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })) - await Promise.resolve() - - expect(kernels?.getAttribute("aria-selected")).toBe("true") - expect(document.activeElement).toBe(kernels) - }) - - test("carries the job total in the Jobs tab label", async () => { - const mounted = { kernels: 0, jobs: 0 } - const host = mount(() => - subject.ComputeSurface({ - strip: () => document.createElement("section"), - kernels: child("kernels", mounted), - jobs: child("jobs", mounted), - request: request(["running", "succeeded", "running"]), - }), - ) - const count = await (async function wait(attempts = 20): Promise { - const value = host.querySelector('[data-compute-tab="jobs"] .compute-surface__count') - if (value?.textContent === "3" || !attempts) return value - await Bun.sleep(10) - return wait(attempts - 1) - })() - - // The label states how many runs are here, not how many are moving — a - // total is what a glance at a tab wants, and the running ones are already - // legible in the list by their status dots. - expect(count?.textContent).toBe("3") - // Read aloud with a unit, because "Jobs 3" alone is ambiguous. - const tab = host.querySelector('[data-compute-tab="jobs"]') - expect(tab?.textContent).toContain("3 jobs") - expect(count?.getAttribute("aria-hidden")).toBe("true") - expect(mounted).toEqual({ kernels: 1, jobs: 0 }) - }) - - test("does not run the heavyweight jobs refresh at the live-view cadence while Kernels is selected", async () => { - const mounted = { kernels: 0, jobs: 0 } - const calls = { count: 0 } - const host = mount(() => - subject.ComputeSurface({ - strip: () => document.createElement("section"), - kernels: child("kernels", mounted), - jobs: child("jobs", mounted), - request: Object.assign( - async () => { - calls.count++ - return new Response(JSON.stringify([]), { headers: { "content-type": "application/json" } }) - }, - { url: () => "http://localhost/settings/compute/jobs" }, - ), - }), - ) - await Bun.sleep(2_700) - - expect(calls.count).toBe(1) - expect(host.querySelector('[data-compute-child="kernels"]')).not.toBeNull() - }) - - test("contains no unavailable or transport-facing product copy", () => { + test("contains no manual launcher or jobs mode", () => { const source = readFileSync(fileURLToPath(new URL("./ComputeSurface.tsx", import.meta.url)), "utf8") - expect(source).not.toContain("Terminal") - expect(source).not.toContain("Atlas Compute") - expect(source).not.toContain("OpenRouter") - expect(source).not.toContain("provider") + expect(source).not.toContain("ComputeJobs") + expect(source).not.toContain("New kernel") + expect(source).not.toContain("onEnsureSession") + expect(source).not.toContain('role="tab"') + expect(source).toContain("Compute only reflects what is live") }) - test("leaves research job creation to the agent in the product surface", () => { - const source = readFileSync(fileURLToPath(new URL("./ComputeSurface.tsx", import.meta.url)), "utf8") - - expect(source).toContain("manual={false}") - }) - - test("marks the selected tab with an underline rather than a filled shape", () => { + test("uses its own width for narrow layouts", () => { const css = readFileSync(fileURLToPath(new URL("./ComputeSurface.css", import.meta.url)), "utf8") + const host = readFileSync(fileURLToPath(new URL("./HostStrip.css", import.meta.url)), "utf8") - // Type on a hairline, not a pill group. - expect(css).toMatch(/\.compute-surface__tabs\s*\{[^}]*border-bottom: 1px solid var\(--color-border\)/s) - // origin/main's compute surface sets nothing in capitals and keeps - // letter-spacing only as negative tracking on a heading, so neither - // appears here either. - expect(css).not.toContain("text-transform: uppercase") - expect(css).not.toMatch(/letter-spacing: 0\.\d/) - // ...but absence is not enough, and asserting only absence is what let the - // ledger keep shouting while this test passed. atlas.css sets uppercase and - // 0.05em tracking on `.kernel-card__metric span`, and the cascade resolves - // per property, so the base declarations win against a rule that simply - // omits them. They have to be turned off by name. - const base = readFileSync(fileURLToPath(new URL("../styles/atlas.css", import.meta.url)), "utf8") - expect(base).toMatch(/\.kernel-card__metric span\s*\{[^}]*text-transform: uppercase/s) - expect(css).toMatch(/\.compute-surface \.kernel-card__metric span\s*\{[^}]*text-transform: none/s) - expect(css).toMatch(/\.compute-surface \.kernel-card__metric span\s*\{[^}]*letter-spacing: 0;/s) - // No face named on this surface: origin/main puts the whole app on one sans - // face, and every label here inherits it rather than naming a second token - // that could drift from it. `font-family: inherit` is the one allowed form. - expect(css).not.toContain("--font-code") - expect(css.match(/font-family:[^;]*/g) ?? []).toEqual(["font-family: inherit"]) - // The one solid terracotta on this surface belongs to the primary action, - // so selection is an underline drawn in the brand colour instead. - expect(css).toMatch( - /\.compute-surface__tab\[data-active="true"\]\s*\{[^}]*box-shadow: inset 0 -2px 0 var\(--surface-brand-base\)/s, - ) - expect(css).toMatch(/\.compute-surface__tab\[data-active="true"\]\s*\{[^}]*background: none/s) - // The count joins its label as one token of type rather than sitting apart. - expect(css).toMatch(/\.compute-surface__count::before\s*\{[^}]*content: "·"/s) - // No hardcoded colour: the app ships 16 themes. + expect(css).toContain("container: compute / inline-size") + expect(css).toContain("@container compute (max-width: 470px)") + expect(css).toContain("@container compute (max-width: 350px)") + expect(host).toContain("@container compute (max-width: 500px)") expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}/) - }) - - test("renders the host strip above the tablist", () => { - const host = mount(() => - subject.ComputeSurface({ - strip: () => { - const strip = document.createElement("section") - strip.dataset.computeChild = "strip" - return strip - }, - kernels: child("kernels", { kernels: 0, jobs: 0 }), - jobs: child("jobs", { kernels: 0, jobs: 0 }), - request: request(), - }), - ) - const surface = host.querySelector(".compute-surface") - const children = [...(surface?.children ?? [])] - const strip = children.findIndex((element) => element.matches('[data-compute-child="strip"]')) - const tabs = children.findIndex((element) => element.matches('[role="tablist"]')) - - expect(strip).toBe(0) - expect(tabs).toBeGreaterThan(strip) - }) - test("does not poll the jobs route itself while the jobs panel is mounted", () => { - const source = readFileSync(fileURLToPath(new URL("./ComputeSurface.tsx", import.meta.url)), "utf8") - // Two polls of the same route at different intervals, for the same number. - expect(source).toContain('if (state.tab !== "jobs") void refresh()') - expect(source).toContain('onTotalChange={(count) => setState("jobs", count)}') - }) - test("carries state colour on the dot, never on the word", () => { - const css = readFileSync(fileURLToPath(new URL("./ComputeSurface.css", import.meta.url)), "utf8") - - // Colouring the word means setting a hue on a 12% tint of itself. That - // works on a dark ground, where the tint darkens it, and collapses on a - // light one, where the tint is near-white and the hue has nothing to - // separate from — measured across all shipped themes it fell to 1.0:1 on - // vesper/light and sat under AA on nearly every light variant. The dot - // carries the colour instead, at a size where contrast is not what makes - // it legible. - for (const tone of ["active", "pending", "danger"]) { - const rule = css.match(new RegExp(`\\.kernel-card__state\\[data-tone="${tone}"\\] \\{[^}]*\\}`, "s"))?.[0] - expect(rule).toBeDefined() - expect(rule).not.toMatch(/color:/) - expect(rule).toContain("background: color-mix") - expect(css).toContain(`.kernel-card__state[data-tone="${tone}"] .kernel-card__state-dot`) - } + expect(host).not.toMatch(/#[0-9a-fA-F]{3,8}/) }) }) diff --git a/frontend/workspace/src/atlas/ComputeSurface.tsx b/frontend/workspace/src/atlas/ComputeSurface.tsx index 32acc59b..1ff546b0 100644 --- a/frontend/workspace/src/atlas/ComputeSurface.tsx +++ b/frontend/workspace/src/atlas/ComputeSurface.tsx @@ -1,181 +1,32 @@ -import { - createEffect, - createSignal, - createUniqueId, - For, - Match, - onCleanup, - Show, - Switch, - type Component, - type JSX, -} from "solid-js" -import { createStore } from "solid-js/store" +import type { Component, JSX } from "solid-js" import { Dynamic } from "solid-js/web" -import { ComputeJobs } from "@/atlas/ComputeJobs" -import { createComputeJobsAPI, type Status } from "@/atlas/ComputeJobsAPI" import { HostStrip } from "@/atlas/HostStrip" -import type { Capacity } from "@/atlas/host-instruments" import { KernelPanel } from "@/atlas/KernelPanel" -import { useSDK } from "@/context/sdk" -import type { ProjectRequest } from "@/utils/openscience-fetch" import "@/atlas/ComputeSurface.css" -type Tab = "kernels" | "jobs" - type ComputeSurfaceProps = { strip?: Component - kernels?: Component<{ - onEnsureSession?: () => Promise - capacity?: Partial - }> - jobs?: Component<{ - onEnsureSession?: () => Promise - onActiveChange?: (count: number) => void - onTotalChange?: (count: number) => void - manual?: boolean - }> - onEnsureSession?: () => Promise - request?: ProjectRequest + kernels?: Component } -const terminal = new Set(["succeeded", "failed", "cancelled", "interrupted"]) -const inactiveRefresh = 15_000 - -const tabs = [ - { id: "kernels", label: "Kernels" }, - { id: "jobs", label: "Jobs" }, -] as const - +/** + * Project-scoped compute inventory. + * + * This surface never starts work. Agent and notebook execution create kernels; + * Compute only reflects what is live and lets the user stop a process that is + * already running. Keeping that boundary here prevents a session switch from + * turning this project-wide inspector into a second execution launcher. + */ export function ComputeSurface(props: ComputeSurfaceProps = {}): JSX.Element { - // `active` still drives nothing visible on its own — the counts beside each - // tab label are totals, which is what 5a shows and what a glance wants: - // "how much is here", not "how much is moving". The active figure stays - // because the jobs panel reports it and the empty/idle copy reads better - // knowing it. - const [state, setState] = createStore({ tab: "kernels" as Tab, active: 0, jobs: 0, kernels: 0 }) - const id = createUniqueId() - const refs: Partial> = {} const strip = props.strip ?? HostStrip const kernels = props.kernels ?? KernelPanel - const jobs = props.jobs ?? ComputeJobs - const api = createComputeJobsAPI(props.request ?? useSDK().request) - const [capacity, setCapacity] = createSignal() - - const refresh = async () => { - const list = await api.list().catch(() => undefined) - if (!list) return - setState("active", list.filter((job) => !terminal.has(job.status)).length) - setState("jobs", list.length) - } - - // Only while the jobs panel is not mounted: when it is, it reports its own - // total off the poll it already runs, and a second poll here would ask the - // same route twice as often for the same number. - createEffect(() => { - if (state.tab !== "jobs") void refresh() - }) - - const timer = setInterval(() => { - if (state.tab !== "jobs") void refresh() - }, inactiveRefresh) - onCleanup(() => clearInterval(timer)) - - const select = (next: Tab, focus = false) => { - setState("tab", next) - if (focus) queueMicrotask(() => refs[next]?.focus()) - } - - const onKeyDown = (event: KeyboardEvent) => { - if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return - const current = tabs.findIndex((item) => item.id === state.tab) - const index = - event.key === "Home" - ? 0 - : event.key === "End" - ? tabs.length - 1 - : event.key === "ArrowRight" || event.key === "ArrowDown" - ? (current + 1) % tabs.length - : (current <= 0 ? tabs.length : current) - 1 - const next = tabs[index] - if (!next) return - event.preventDefault() - select(next.id, true) - } return (
- setState("kernels", live)} onCapacity={setCapacity} /> -
- - {(item) => ( - - )} - + +
+
- - - -
- -
-
- -
- setState("active", count)} - onTotalChange={(count) => setState("jobs", count)} - manual={false} - /> -
-
-
) } diff --git a/frontend/workspace/src/atlas/HostStrip.css b/frontend/workspace/src/atlas/HostStrip.css index ed63ed7d..8c700a4b 100644 --- a/frontend/workspace/src/atlas/HostStrip.css +++ b/frontend/workspace/src/atlas/HostStrip.css @@ -1,141 +1,108 @@ -/* 3a's instrument block. Where the previous pass drew two matched gauges, this - leads with a single figure at display size and puts its qualifiers in small - capitals beside it — a laboratory readout rather than a dashboard tile. Memory - in use leads, matching the histogram beside it, which plots the same. */ .host-strip { - display: flex; - flex-direction: column; - gap: 12px; - box-sizing: border-box; - padding: 18px 18px 14px; + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr) minmax(112px, 0.8fr); + gap: 0; border-bottom: 1px solid var(--color-border); background: var(--color-bg); } -/* Set in the same capitals as the instrument labels below it, and quiet: - it names the block rather than competing with the figure it introduces. */ -.host-strip__title { - font-size: 10px; - color: var(--color-text-faint); -} - -.host-strip__memory { +.host-strip__metric { display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 16px; min-width: 0; + min-height: 74px; + box-sizing: border-box; + justify-content: center; + flex-direction: column; + gap: 7px; + padding: 11px 14px; + border-right: 1px solid var(--color-border); } -.host-strip__figure { - display: flex; - align-items: baseline; - gap: 8px; - min-width: 0; +.host-strip__metric:last-child { + border-right: 0; } -.host-strip__headline { - font-size: 44px; - line-height: 0.9; - font-weight: 400; - letter-spacing: -0.02em; - color: var(--color-text); - font-variant-numeric: tabular-nums; +.host-strip__label { + color: var(--color-text-faint); + font-size: 10px; + font-weight: 500; } -/* Unit and ceiling sit on one baseline beside the figure — "4.4 GB used of - 16.4" — rather than stacked three deep. Stacking spent three lines saying - what one says, and the column of tiny type beside a 44px numeral was the - densest corner of the surface. */ -.host-strip__labels { +.host-strip__metric p { display: flex; + min-width: 0; align-items: baseline; gap: 4px; - min-width: 0; -} - -.host-strip__unit, -.host-strip__ceiling { + margin: 0; + overflow: hidden; + color: var(--color-text-muted); font-size: 10px; white-space: nowrap; } -.host-strip__unit { - color: var(--color-text-muted); -} - -/* One tone for the whole qualifier now that it reads as a single phrase; two - tones inside one line reads as an accident rather than a hierarchy. */ -.host-strip__ceiling { - color: var(--color-text-muted); +.host-strip__metric strong { + color: var(--color-text); + font-size: 15px; + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; } -/* Twenty samples of memory pressure, oldest left. The bars carry no labels - because the current value is already stated at 44px beside them; this is - here to show direction, not to be read off. */ -.host-strip__history { - display: flex; - align-items: flex-end; - gap: 2px; - height: 34px; - flex: none; +.host-strip__metric p > span { + overflow: hidden; + text-overflow: ellipsis; } -.host-strip__bar { - width: 4px; - border-radius: 1px; - background: var(--color-border-strong); - transition: height 240ms ease; +.host-strip__meter { + display: block; + width: 100%; + height: 3px; + overflow: hidden; + border-radius: 999px; + background: var(--color-border); } -/* The newest few in the accent, so the eye lands on now rather than on the - middle of the series. */ -.host-strip__bar[data-recent="true"] { - background: var(--surface-brand-base); +.host-strip__meter i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--color-text-muted); + transition: width 180ms ease; } -.host-strip__cores { - display: flex; +.host-strip__metric--kernels p { align-items: center; - gap: 10px; } -.host-strip__cores-label { - width: 52px; - flex: none; - font-size: 10px; - color: var(--color-text-muted); -} +@container compute (max-width: 500px) { + .host-strip { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } -.host-strip__segments { - display: flex; - gap: 3px; - flex: 1; - min-width: 0; -} + .host-strip__metric { + min-height: 68px; + padding: 10px 12px; + } -.host-strip__segment { - flex: 1; - height: 6px; - border-radius: 2px; - background: var(--color-border); -} + .host-strip__metric:nth-child(2) { + border-right: 0; + } -/* Busy cores read in the instrument blue, kept distinct from the terracotta so - the one solid accent on this surface still belongs to the primary action. */ -.host-strip__segment[data-lit="true"] { - background: var(--border-info-selected); -} + .host-strip__metric--kernels { + min-height: 42px; + grid-column: 1 / -1; + border-top: 1px solid var(--color-border); + border-right: 0; + } -.host-strip__cores-value { - font-size: 10px; - color: var(--color-text-muted); - font-variant-numeric: tabular-nums; - white-space: nowrap; + .host-strip__metric--kernels .host-strip__label { + display: none; + } } @media (prefers-reduced-motion: reduce) { - .host-strip__bar { + .host-strip__meter i { transition: none; } } diff --git a/frontend/workspace/src/atlas/HostStrip.test.ts b/frontend/workspace/src/atlas/HostStrip.test.ts index 6d6eb716..47793e18 100644 --- a/frontend/workspace/src/atlas/HostStrip.test.ts +++ b/frontend/workspace/src/atlas/HostStrip.test.ts @@ -109,7 +109,7 @@ describe("host strip", () => { expect(calls.length).toBeGreaterThan(0) await expect(calls[0]).rejects.toThrow() expect(host.querySelector("[data-boundary]")).toBeNull() - expect(host.querySelectorAll(".host-strip__figure, .host-strip__cores").length).toBe(2) + expect(host.querySelectorAll(".host-strip__metric").length).toBe(3) expect(values(host)).toEqual(["—", "—"]) expect(host.textContent).not.toContain("0 B") expect(host.textContent).not.toContain("0 / 0") @@ -122,21 +122,17 @@ describe("host strip", () => { expect((await calls[0])?.status).toBe(503) expect(host.querySelector("[data-boundary]")).toBeNull() - expect(host.querySelectorAll(".host-strip__figure, .host-strip__cores").length).toBe(2) + expect(host.querySelectorAll(".host-strip__metric").length).toBe(3) expect(values(host)).toEqual(["—", "—"]) expect(host.textContent).not.toContain("0 B") expect(host.textContent).not.toContain("0 / 0") }) - test("names the block, so its figures are not read as a kernel's own", () => { - // The strip states the machine; each kernel plate states that kernel. A - // reader who took "6.7 GB used" here for the kernel's own would be out by - // three orders of magnitude, and nothing else on the surface said which - // was which. + test("names the block as machine resources", () => { const source = readFileSync(fileURLToPath(new URL("./HostStrip.tsx", import.meta.url)), "utf8") - expect(source).toContain('System statistics') - expect(source).toContain('aria-label="System statistics"') + expect(source).toContain('Machine') + expect(source).toContain('aria-label="Machine resources"') }) test("states the machine's capacity once a poll succeeds", async () => { @@ -145,10 +141,9 @@ describe("host strip", () => { await settle(calls) expect(host.querySelector("[data-boundary]")).toBeNull() - expect(values(host)).toEqual(["6.7", "2 / 8"]) - // 5a states the reading itself rather than a sentence about it. - expect(host.textContent).toContain("GB used") - expect(host.textContent).toContain("of 16.0") + expect(values(host)).toEqual(["412.0 MB", "~0.4 of 8"]) + expect(host.textContent).toContain("of 16.0 GB memory") + expect(host.textContent).toContain("2kernels · 1 running") }) test("asks the route the compute strip is served from, naming itself to the server", async () => { @@ -210,7 +205,7 @@ describe("host strip", () => { const memoryTile = host.querySelector('[data-host-tile="memory"]') const cpuTile = host.querySelector('[data-host-tile="cpu"]') expect(memoryTile).not.toBeNull() - expect(values(host)).toEqual(["6.7", "2 / 8"]) + expect(values(host)).toEqual(["412.0 MB", "~0.4 of 8"]) capacity = { memory: { total: 16_000_000_000, available: 5_000_000_000, kernels: 900_000_000 }, @@ -225,7 +220,7 @@ describe("host strip", () => { expect(host.querySelector('[data-host-tile="cpu"]')).toBe(cpuTile) expect(host.contains(memoryTile)).toBe(true) // Freshness: the values inside those same nodes actually moved. - expect(values(host)).toEqual(["11.0", "4 / 8"]) + expect(values(host)).toEqual(["900.0 MB", "~1.1 of 8"]) }) test("stays mounted with no Suspense fallback while a poll is genuinely in flight", async () => { @@ -272,7 +267,7 @@ describe("host strip", () => { // Let the first load resolve; the fallback should be gone and tiles present. await Bun.sleep(20) expect(host.querySelector("[data-fallback]")).toBeNull() - expect(values(host)).toEqual(["6.7", "2 / 8"]) + expect(values(host)).toEqual(["412.0 MB", "~0.4 of 8"]) const memoryTile = host.querySelector('[data-host-tile="memory"]') expect(memoryTile).not.toBeNull() @@ -287,7 +282,7 @@ describe("host strip", () => { expect(host.querySelector("[data-fallback]")).toBeNull() expect(memoryTile?.isConnected).toBe(true) expect(host.querySelector('[data-host-tile="memory"]')).toBe(memoryTile) - expect(values(host)).toEqual(["6.7", "2 / 8"]) + expect(values(host)).toEqual(["412.0 MB", "~0.4 of 8"]) // Resolve it and confirm the value actually moved. settleSecond?.(new Response(JSON.stringify(refreshed), { headers: { "content-type": "application/json" } })) @@ -295,7 +290,7 @@ describe("host strip", () => { expect(host.querySelector("[data-fallback]")).toBeNull() expect(host.querySelector('[data-host-tile="memory"]')).toBe(memoryTile) - expect(values(host)).toEqual(["11.0", "4 / 8"]) + expect(values(host)).toEqual(["900.0 MB", "~1.1 of 8"]) }) test("refreshes when the tab is shown again and polls nothing after unmount", async () => { diff --git a/frontend/workspace/src/atlas/HostStrip.tsx b/frontend/workspace/src/atlas/HostStrip.tsx index e6bc75cd..95f3ae9c 100644 --- a/frontend/workspace/src/atlas/HostStrip.tsx +++ b/frontend/workspace/src/atlas/HostStrip.tsx @@ -1,48 +1,22 @@ -import { Index, createEffect, createMemo, createResource, createSignal, onCleanup, Show, type JSX } from "solid-js" +import { createMemo, createResource, onCleanup, type JSX } from "solid-js" import { useSDK } from "@/context/sdk" -import { histogram, hostReading, sample, type Capacity } from "@/atlas/host-instruments" +import { hostReading, type Capacity } from "@/atlas/host-instruments" import { identify } from "@/atlas/poll-identity" import "@/atlas/HostStrip.css" -// The transport is a prop so the degraded path can be mounted against a real -// endpoint that really fails; the session SDK supplies it in the product. type HostStripProps = { request?: (path: string) => Promise - // The tab labels carry a live kernel count, and this poll already asks for - // it every 2.5s. Reporting it upward costs nothing; a second poller for the - // same number would double the request rate on a route whose CPU figures are - // measured per client across the window between polls. - onKernels?: (live: number) => void - // The kernel plates draw their RAM bar against the host's total and their - // core segments against its core count. Both are already on this poll's - // body, so lifting the reading here is cheaper — and always consistent with - // the strip above — than giving every card its own /notebook/compute poll. - onCapacity?: (capacity: Capacity) => void } -// Names this mounted strip to the server. Both host and kernel CPU figures are -// measured across the window since the same client's previous poll, so two tabs -// sharing one identity would truncate each other's window to the gap between -// their polls — under the server's one-second floor, which then refuses a -// reading for whichever polled second, every cycle. See poll-identity.ts for -// why the identity is per mount rather than per module. - export function HostStrip(props: HostStripProps = {}): JSX.Element { const request = props.request ?? useSDK().request const client = identify() - // A poll that fails resolves to no capacity instead of rejecting. An errored - // resource re-throws where it is read, and the nearest ErrorBoundary wraps the - // entire workspace, so a server restart or a sleep/wake while this pane is - // open would swap the whole app for the error page. hostReading already reads - // an absent capacity as unavailable, which is the designed degraded state — - // never a 0, never a blank, never a thrown boundary. const load = () => request(`/notebook/compute?client=${encodeURIComponent(client)}`) .then((response) => (response.ok ? (response.json() as Promise) : undefined)) .catch(() => undefined) const [data, api] = createResource(load) - // A hidden tab skips its polls, so returning to it would otherwise show - // numbers up to one interval stale until the next tick. + const reading = createMemo(() => hostReading(data.latest)) const refresh = () => { if (document.hidden) return void api.refetch() @@ -54,74 +28,41 @@ export function HostStrip(props: HostStripProps = {}): JSX.Element { document.removeEventListener("visibilitychange", refresh) }) - // Read `data.latest` rather than `data()`: `data()` re-registers with the - // nearest Suspense boundary on every in-flight fetch, which suspends the - // entire RightPane (see RightPane.tsx's Suspense around ComputeSurface) on - // every 2.5s poll. `.latest` only suspends on the first load and returns the - // previous value while a refetch is in flight, so this memo — and the pane - // around it — stays mounted across polls. - const reading = createMemo(() => hostReading(data.latest)) - - // The histogram is the one thing here with memory. The route reports a point - // in time, so the series has to be accumulated client-side; it is bounded to - // SAMPLES, so this cannot grow without limit however long the pane stays open. - const [history, setHistory] = createSignal([]) - createEffect(() => { - const capacity = data.latest - if (capacity) setHistory((prior) => sample(prior, capacity)) - }) - const bars = createMemo(() => histogram(history())) - - createEffect(() => { - // Only report a count the body actually carried. A poll that failed, or one - // whose body arrived without the section, must leave the label showing the - // last known figure rather than asserting zero kernels. - const live = data.latest?.kernels?.live - if (typeof live === "number") props.onKernels?.(live) - }) - - createEffect(() => { - const capacity = data.latest - if (capacity) props.onCapacity?.(capacity) - }) - return ( -
- {/* Named, because the figures below it are the machine's and the ones on - each kernel plate are that kernel's, and nothing else on the surface - said which was which — a reader who took "12.6 GB USED" for the - kernel's own would be out by three orders of magnitude. */} - System statistics -
-
+
+
+ Machine +

{reading().headline} - - {reading().unit} - - {reading().ceiling} - - -

- {/* Decorative: the same series is already stated as a number beside it, - so a screen reader gains nothing from twenty bar heights. */} - + {reading().memory} +

+ +
+ +
+ CPU +

+ {reading().cores} + cores CPU +

+
-
- Cores - - - {(_, position) => } - - - {reading().cores} +
+ Live +

+ {reading().live} + {reading().kernels} +

) } + +function Meter(props: { value: number }): JSX.Element { + return ( + + ) +} diff --git a/frontend/workspace/src/atlas/KernelCard.test.tsx b/frontend/workspace/src/atlas/KernelCard.test.tsx index 252b87e3..57b5ed2e 100644 --- a/frontend/workspace/src/atlas/KernelCard.test.tsx +++ b/frontend/workspace/src/atlas/KernelCard.test.tsx @@ -5,7 +5,6 @@ import { createServer } from "vite" import solid from "vite-plugin-solid" import type { KernelStatus } from "@/notebook/runtime" -const cleanups: Array<() => void> = [] const server = await createServer({ root: fileURLToPath(new URL("../..", import.meta.url)), mode: "production", @@ -14,18 +13,15 @@ const server = await createServer({ server: { middlewareMode: true }, appType: "custom", resolve: { conditions: ["browser", "production"], dedupe: ["solid-js", "solid-js/web"] }, - ssr: { - noExternal: true, - resolve: { conditions: ["browser", "production"] }, - }, + ssr: { noExternal: true, resolve: { conditions: ["browser", "production"] } }, }) const [subject, web] = await Promise.all([ server.ssrLoadModule("/src/atlas/KernelCard.tsx") as Promise, server.ssrLoadModule("solid-js/web") as Promise, ]) +const cleanups: Array<() => void> = [] afterAll(() => server.close()) - afterEach(() => { cleanups.splice(0).forEach((cleanup) => cleanup()) document.body.replaceChildren() @@ -34,7 +30,7 @@ afterEach(() => { const kernel = (value: Partial = {}): KernelStatus => ({ id: "kernel-live", active: true, - state: "idle", + state: "running", projectID: "project-1", sessionID: "ses_current", name: "notebook:analysis.ipynb", @@ -49,305 +45,65 @@ const kernel = (value: Partial = {}): KernelStatus => ({ process_identity_verified: true, started_at: Date.now() - 4_000, last_activity_at: Date.now() - 1_000, + resources: { cpu_percent: 180, memory_bytes: 412_000_000 }, ...value, }) -// The plate collapses by default, so everything below the head — the ledger, -// the environment block, the controls, the identity list — is not in the DOM -// until it is opened. Mounting opens it, because that is the state these -// assertions are about; the collapsed head has its own tests below. -const mount = (view: () => JSX.Element, options: { collapsed?: boolean } = {}) => { +const mount = (view: () => JSX.Element) => { const host = document.createElement("div") document.body.append(host) cleanups.push(web.render(view, host)) - if (!options.collapsed) host.querySelector(".kernel-card__plate")?.click() return host } -const button = (host: HTMLElement, label: string) => - host.querySelector(`button[aria-label="${label}"]`) - -describe("KernelCard lifecycle controls", () => { - test("renders a reloaded live process as active with its exact incarnation and identity", () => { - const calls: string[] = [] - const host = mount(() => - subject.KernelCard({ - kernel: kernel(), - routeID: "ses_current", - action: "", - onControl: (action) => calls.push(action), - }), - ) - - expect(host.querySelector(".kernel-card__state")?.textContent).toBe("Ready") - expect(host.textContent).toContain("r4") - expect(host.textContent).toContain("8234") - expect(host.textContent).toContain("PID and process start verified") - expect(button(host, "Interrupt analysis.ipynb")?.disabled).toBe(true) - expect(button(host, "Restart analysis.ipynb")?.disabled).toBe(false) - expect(button(host, "Stop analysis.ipynb")?.disabled).toBe(false) +describe("kernel status row", () => { + test("shows the live runtime in one compact row", () => { + const host = mount(() => subject.KernelCard({ kernel: kernel(), action: "", onControl: () => {} })) - button(host, "Restart analysis.ipynb")?.click() - button(host, "Stop analysis.ipynb")?.click() - expect(calls).toEqual(["restart", "stop"]) + expect(host.querySelector(".kernel-card__language")?.textContent).toBe("Py") + expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("analysis.ipynb") + expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("Executing now") + expect(host.querySelectorAll(".kernel-card__metric")[0]?.textContent).toBe("412 MBrss") + expect(host.querySelectorAll(".kernel-card__metric")[1]?.textContent).toBe("1.8cores") + expect(host.querySelector(".kernel-card__uptime")?.textContent).toMatch(/^\d+s$/) }) - test("interrupts only running work and states restart data loss before the action", () => { + test("only exposes stop, never manual start, restart, interrupt, or forget", () => { const calls: string[] = [] const host = mount(() => - subject.KernelCard({ - kernel: kernel({ state: "running", queue_depth: 2 }), - routeID: "ses_current", - action: "", - onControl: (action) => calls.push(action), - }), - ) - - expect(button(host, "Interrupt analysis.ipynb")?.disabled).toBe(false) - expect(button(host, "Restart analysis.ipynb")?.title).toContain( - "All in-memory variables and queued cells will be lost", - ) - // The standing note under the controls is gone with the 3a plate. The - // warning has to survive somewhere the user meets it before acting, so it - // rides on the button that causes the loss. - expect(host.querySelector(".kernel-card__control-note")).toBeNull() - expect(button(host, "Stop analysis.ipynb")?.title).toContain("clear its in-memory state") - button(host, "Interrupt analysis.ipynb")?.click() - expect(calls).toEqual(["interrupt"]) - }) - - test("offers restart and forget for an inactive named R record but cannot stop it twice", () => { - const calls: string[] = [] - const host = mount(() => - subject.KernelCard({ - kernel: kernel({ - active: false, - state: "stopped", - language: "r", - incarnation: 2, - execution_count: 0, - process_id: null, - process_started_at: null, - process_identity_verified: null, - started_at: null, - }), - routeID: "ses_current", - action: "", - onControl: (action) => calls.push(action), - }), - ) - - expect(host.textContent).toContain("R environment") - expect(button(host, "Restart analysis.ipynb")?.disabled).toBe(false) - expect(button(host, "Stop analysis.ipynb")?.disabled).toBe(true) - expect(button(host, "Forget analysis.ipynb")?.disabled).toBe(false) - button(host, "Forget analysis.ipynb")?.click() - expect(calls).toEqual(["delete"]) - }) - - test("shows the local target with sampled usage and an unavailable fallback, never zero", () => { - // CPU is stated as a share of the machine, so this row needs the host - // reading the surface passes down: 12.34% of one core on an 8-core box is - // 1.5% of the machine. - const capacity = { memory: { total: 16_400_000_000, available: 12_000_000_000 }, cpu: { cores: 8 } } - const sampled = mount(() => - subject.KernelCard({ - kernel: kernel({ resources: { cpu_percent: 12.34, memory_bytes: 412_000_000 } }), - routeID: "ses_current", - action: "", - capacity, - onControl: () => {}, - }), - ) - const usage = sampled.querySelector(".kernel-card__metrics--usage") - expect(usage?.textContent).toContain("Target") - expect(usage?.textContent).toContain("Local") - expect(usage?.textContent).toContain("1.5%") - expect(usage?.textContent).toContain("412 MB") - expect(usage?.textContent).toContain("Uptime") - expect(usage?.textContent).toContain("GPU") - expect(usage?.textContent).toContain("VRAM") - - const partial = mount(() => - subject.KernelCard({ - kernel: kernel({ resources: { cpu_percent: 24 } }), - routeID: "ses_current", - action: "", - capacity, - onControl: () => {}, - }), + subject.KernelCard({ kernel: kernel(), action: "", onControl: (action) => calls.push(action) }), ) - const half = partial.querySelector(".kernel-card__metrics--usage") - expect(half?.textContent).toContain("3.0%") - expect(half?.textContent?.match(/Unavailable/g)?.length).toBe(3) - expect(half?.textContent).not.toContain("0 B") + const stop = host.querySelector('button[aria-label="Stop analysis.ipynb"]') - const bare = mount(() => - subject.KernelCard({ - kernel: kernel(), - routeID: "ses_current", - action: "", - onControl: () => {}, - }), - ) - const empty = bare.querySelector(".kernel-card__metrics--usage") - expect(empty?.textContent).toContain("Local") - expect(empty?.textContent).not.toContain("%") - expect(empty?.textContent?.match(/Unavailable/g)?.length).toBe(4) + expect(host.querySelectorAll("button").length).toBe(1) + expect(stop?.disabled).toBe(false) + expect(stop?.title).toContain("clear its in-memory state") + stop?.click() + expect(calls).toEqual(["stop"]) }) - test("keeps the lazy named session kernel restartable but not deletable", () => { + test("never presents an inactive record as startable", () => { const host = mount(() => subject.KernelCard({ - kernel: kernel({ - active: false, - state: "lazy", - name: "agent", - incarnation: null, - execution_count: 0, - process_id: null, - process_started_at: null, - process_identity_verified: null, - started_at: null, - }), - routeID: "ses_current", + kernel: kernel({ active: false, state: "stopped", started_at: null, resources: undefined }), action: "", onControl: () => {}, }), ) - expect(host.textContent).toContain("No process is running") - expect(button(host, "Restart Python analysis")?.disabled).toBe(false) - expect(button(host, "Forget Python analysis")).toBeNull() + expect(host.querySelectorAll("button").length).toBe(1) + expect(host.querySelector("button")?.disabled).toBe(true) + expect(host.querySelector(".kernel-card__uptime")?.textContent).toBe("—") + expect(host.textContent).not.toContain("Start") + expect(host.textContent).not.toContain("Restart") }) - test("blocks restart when execution authority is denied but leaves safe cleanup available", () => { - const calls: string[] = [] + test("counts uptime while a process stays mounted", async () => { const host = mount(() => - subject.KernelCard({ - kernel: kernel(), - routeID: "ses_current", - action: "", - restartDisabled: true, - restartTitle: "Trust this project to start or restart a kernel in this session.", - onControl: (action) => calls.push(action), - }), + subject.KernelCard({ kernel: kernel({ started_at: Date.now() - 2_000 }), action: "", onControl: () => {} }), ) - - expect(button(host, "Restart analysis.ipynb")?.disabled).toBe(true) - expect(button(host, "Restart analysis.ipynb")?.title).toContain("Trust this project") - expect(button(host, "Stop analysis.ipynb")?.disabled).toBe(false) - button(host, "Restart analysis.ipynb")?.click() - button(host, "Stop analysis.ipynb")?.click() - expect(calls).toEqual(["stop"]) - }) - test("collapses to a head that still answers whether the runtime is in the way", () => { - const host = mount( - () => - subject.KernelCard({ - kernel: kernel({ state: "running", resources: { cpu_percent: 180, memory_bytes: 2_400_000_000 } }), - routeID: "ses_current", - action: "", - index: 0, - capacity: { memory: { total: 16_400_000_000, available: 12_000_000_000 }, cpu: { cores: 8 } }, - onControl: () => {}, - }), - { collapsed: true }, - ) - - const plate = host.querySelector(".kernel-card__plate") - expect(plate?.getAttribute("aria-expanded")).toBe("false") - expect(host.querySelector(".kernel-card__language")?.textContent).toBe("Kernel 01 · Python") - expect(host.querySelector(".kernel-card__state")?.textContent).toBe("Running") - // The two figures that survive the collapse. - expect(host.querySelector(".kernel-card__usage")?.textContent).toContain("2.4") - expect(host.querySelector(".kernel-card__usage")?.textContent).toContain("/ 16.4 GB") - expect(host.querySelectorAll(".kernel-card__usage-cores > div").length).toBe(8) - expect(host.querySelectorAll('.kernel-card__usage-cores > div[data-lit="true"]').length).toBe(2) - - // Nothing below the head is in the DOM until it is opened, so a list of - // several runtimes stays a list rather than a stack of full records. - expect(host.querySelector(".kernel-card__controls")).toBeNull() - expect(host.querySelector(".kernel-card__metrics")).toBeNull() - expect(host.querySelector(".kernel-card__identity")).toBeNull() - expect(host.textContent).not.toContain("Runtime identity") - }) - - test("opens and closes from the head, which is the whole hit target", () => { - const host = mount( - () => - subject.KernelCard({ - kernel: kernel(), - routeID: "ses_current", - action: "", - onControl: () => {}, - }), - { collapsed: true }, - ) - const plate = host.querySelector(".kernel-card__plate") - - plate?.click() - expect(plate?.getAttribute("aria-expanded")).toBe("true") - expect(host.querySelector(".kernel-card__controls")).not.toBeNull() - expect(host.querySelector(".kernel-card")?.getAttribute("data-open")).toBe("true") - - plate?.click() - expect(plate?.getAttribute("aria-expanded")).toBe("false") - expect(host.querySelector(".kernel-card__controls")).toBeNull() - }) - - test("numbers each plate by its position so two collapsed heads read apart", () => { - const host = mount( - () => - subject.KernelCard({ - kernel: kernel({ language: "r" }), - routeID: "ses_current", - action: "", - index: 2, - onControl: () => {}, - }), - { collapsed: true }, - ) - - expect(host.querySelector(".kernel-card__language")?.textContent).toBe("Kernel 03 · R") - }) - test("counts uptime like a stopwatch rather than freezing at its first reading", async () => { - // The kernel object is reconciled in place, so it does not change while a - // runtime simply keeps running. Uptime therefore has to be driven by a - // clock inside the card; before it was, the head sat at "2s" for as long - // as the runtime lived. - const host = mount( - () => - subject.KernelCard({ - kernel: kernel({ active: true, state: "running", started_at: Date.now() - 2_000 }), - routeID: "ses_current", - action: "", - onControl: () => {}, - }), - { collapsed: true }, - ) - const first = host.querySelector(".kernel-card__uptime")?.textContent - expect(first).toMatch(/^\d+s$/) await Bun.sleep(1_200) - const second = host.querySelector(".kernel-card__uptime")?.textContent - expect(second).not.toBe(first) - }) - - test("stops the clock when there is nothing running to count", () => { - const host = mount( - () => - subject.KernelCard({ - kernel: kernel({ active: false, state: "stopped", started_at: null }), - routeID: "ses_current", - action: "", - onControl: () => {}, - }), - { collapsed: true }, - ) - - // "Unavailable" is three times the width of the figure it replaces and - // says nothing the lifecycle pill beside it does not. - expect(host.querySelector(".kernel-card__uptime")).toBeNull() + expect(host.querySelector(".kernel-card__uptime")?.textContent).not.toBe(first) }) }) diff --git a/frontend/workspace/src/atlas/KernelCard.tsx b/frontend/workspace/src/atlas/KernelCard.tsx index 02dc9674..41288379 100644 --- a/frontend/workspace/src/atlas/KernelCard.tsx +++ b/frontend/workspace/src/atlas/KernelCard.tsx @@ -1,317 +1,83 @@ -import { Index, Show, createEffect, createSignal, onCleanup, type JSX } from "solid-js" -import { IconChevronDown, IconChevronRight } from "@/atlas/shared/Icon" -import type { Capacity } from "./host-instruments" -import { plateEyebrow, plateUsage } from "./kernel-plate" +import { createEffect, createSignal, onCleanup, type JSX } from "solid-js" import { - kernelAtlasLabel, - kernelCanForget, - kernelCanInterrupt, kernelCanStop, - kernelEnvironmentLabel, - kernelEnvironmentTone, - kernelGpuLabel, kernelLabel, kernelLanguageLabel, kernelMemoryLabel, - kernelNetworkLabel, - kernelNetworkTone, - kernelOwnershipLabel, kernelRecoveryLabel, kernelStateLabel, - kernelTargetLabel, kernelTone, kernelUptimeLabel, - kernelVramLabel, type KernelStatus, } from "@/notebook/runtime" -export type KernelAction = "interrupt" | "restart" | "stop" | "delete" +export type KernelAction = "stop" -const time = (value: number | null) => { - if (!value) return "Unavailable" - const seconds = Math.max(0, Math.round((Date.now() - value) / 1_000)) - if (seconds < 60) return `${seconds}s ago` - const minutes = Math.round(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - return `${Math.round(minutes / 60)}h ago` +const memory = (value?: number) => { + const label = kernelMemoryLabel(value) + return label === "Unavailable" ? "—" : label } -const date = (value: number | null) => { - if (!value) return "Unavailable" - return new Date(value).toLocaleString() +const cores = (value?: number) => { + if (value === undefined || !Number.isFinite(value) || value < 0) return "—" + return (value / 100).toFixed(1) } export function KernelCard(props: { kernel: KernelStatus - routeID?: string action: string - index?: number - capacity?: Partial - restartDisabled?: boolean - restartTitle?: string onControl: (action: KernelAction) => void }): JSX.Element { - const owner = () => kernelOwnershipLabel(props.kernel, props.routeID) - const busy = (action: KernelAction) => props.action === `${props.kernel.id}:${action}` - // Collapsed by default. A session can hold several runtimes, and the full - // plate is nine rows of ledger plus three controls — stacked, that buries - // the one question the list is for, which is which kernels exist and whether - // any of them is busy. The head answers that without opening anything. - const [open, setOpen] = createSignal(false) - const usage = () => plateUsage(props.kernel, props.capacity) - // Uptime is a stopwatch, not a figure carried in on the poll. The kernel - // object is reconciled in place and does not change while a runtime simply - // keeps running, so nothing re-evaluated this label and it sat at whatever - // it read the moment the runtime came up. The interval only exists while - // there is something to count. + const busy = () => props.action === `${props.kernel.id}:stop` const [now, setNow] = createSignal(Date.now()) + createEffect(() => { if (!props.kernel.active || !props.kernel.started_at) return const timer = setInterval(() => setNow(Date.now()), 1_000) onCleanup(() => clearInterval(timer)) }) + const uptime = () => kernelUptimeLabel(props.kernel, now()) + return ( -
- {/* The whole head is the toggle, not a caret-sized target: on a tablet - the caret alone is well under the 44px a thumb can reliably hit. */} -