From 3a4bb2d1057c8206232ac5468fed93e7b6b4b4d9 Mon Sep 17 00:00:00 2001 From: Ziyang Zeng Date: Mon, 19 Oct 2026 05:49:46 +0000 Subject: [PATCH] feat(setup): make Overview a health check once setup is done Overview opens with which server this is: its public host, T3 Code's version and uptime, and Open T3 Code. Until setup is done it is the checklist, as before. After that it is a Ready line, then: - Needs you, worst first, each with its one action: agents and tools that failed or are signed out, a port that would not publish, a home or state folder the next recreate would lose (How to fix opens the commands from Server), a disk over 80% full, settings an older image left behind, a T3 Code update that did not finish, and T3 Connect waiting on a restart. On a phone each action sits under its words. - Updates beside Recent: up to four releases with Update (T3 Code's asks first, since it restarts) and Update all, then the last six events. Updates leave Needs you: they are offered, not owed, and have their own card and page. The At a glance readouts and the second device list go; the Ready line and Access say the same. Needs you's buttons now look alike, extra-small buttons stay small at touch density, and Recent sets only versions in monospace. Co-Authored-By: Claude Opus 5.5 --- docker/setup/app.js | 143 ++++++++++++++++++++--------------- docker/setup/client/model.js | 99 +++++++++++++++++++----- docker/setup/console.css | 29 +++++++ docs/setup-page.md | 6 +- tests/console-model.test.mjs | 41 ++++++++-- 5 files changed, 233 insertions(+), 85 deletions(-) diff --git a/docker/setup/app.js b/docker/setup/app.js index 34be166..1531be2 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -557,7 +557,8 @@ const actionButton = (row, opts) => { const a = row.action; if (!a) return ''; - const variant = a.variant === 'primary' && opts.primary === false ? null : a.variant; + // A list of calls to action (Needs you) keeps every button alike. + const variant = opts.plain ? null : a.variant === 'primary' && opts.primary === false ? null : a.variant; return html``; }; // A row without a menu keeps its place, so every row's one action lines up. @@ -580,7 +581,7 @@ ${noticeError ? html`${dot('danger')}${noticeError.text}${detail('notice')}` : noticeLine(row.notice)} ${row.state === 'running' ? opLine(row) : ''} -
${actionButton(row, opts)}${opts.menu === false ? '' : menuButton(row)}
+
${actionButton(row, opts)}${opts.menu === false ? menuSlot() : menuButton(row)}
`; }; @@ -721,19 +722,29 @@ `; }; + /** Setup is done: a line that says so, and what is set up. */ const readyLine = (s) => html`
-

Ready

${M.readySummary(s, ui)}
- ${EMBED ? '' : html`Open T3 Code${icon('arrow-right')}`} +

Ready

${M.readySummary(s, ui, state.ports)}
`; - const attentionRow = (item) => { - if (item.target === 'port') return portRow(item.port); - return resourceRow(item, { primary: false, menu: false }); - }; + /** A button for an item's one action: a command, or a page to go to. */ + const itemAction = (a, primary) => !a ? '' + : a.cmd === 'goto' ? html`${a.label}` + : html``; + + /** A Needs you item that is not a row of its own page: the container's problems, a port. */ + const itemRow = (item) => html` +
+ +
${item.name}${item.text}
+
${itemAction(item.action)}${menuSlot()}
+
`; + // On a phone each item's one action goes under its words, so they read whole. + const attentionRow = (item) => (item.icon ? itemRow(item) : resourceRow(item, { plain: true, menu: false, wrap: true })); const activitySection = (act, n) => { const lines = []; @@ -742,7 +753,7 @@ lines.push(html`
${tile(row)}${row.badge.text} ${row.name}${row.versionTo ? ' ' + row.versionTo : ''}${p && p.pct !== null ? p.pct + '%' : p && p.phase ? p.phase : ''}${progressBar(p)}
`); } act.queued.forEach((row, i) => { - lines.push(html`
${tile(row)}${row.name} · queued${i === 0 && !act.running.length ? 'next' : i === 0 ? 'next' : 'queued'}
`); + lines.push(html`
${tile(row)}${row.name}${i === 0 ? 'next' : 'queued'}
`); }); for (const f of act.finished) { lines.push(html`
${icon(f.ok ? 'circle-check' : 'circle-alert', f.ok ? 'tc-ok' : 'tc-danger')}${f.text}${M.relTime(f.at, n)}
`); @@ -751,23 +762,50 @@ return section('act-title', 'Activity', html`
${lines}
`); }; - const glanceSection = (s, ports, n) => { - const sessions = s.sessions || []; - const lastSeen = sessions.some((c) => c.connected) ? 'active now' - : sessions.map((c) => M.toMs(c.lastConnectedAt)).filter(Boolean).sort((a, b) => b - a).map((t) => 'last seen ' + M.relTime(t, n))[0] || 'none connected yet'; - const agents = M.agentRows(s, ui, n); - const installed = agents.filter((a) => a.version); - const signed = agents.filter((a) => a.state === 'ok' || a.state === 'update').length; - const updates = agents.filter((a) => a.updateAvailable).length; - const published = ports ? M.portRows(ports, ui, n).filter((p) => p.state === 'open') : []; - const readout = (label, value, sub) => html`
${label}${value}${sub ? html`${sub}` : ''}
`; - const paths = s.paths || {}; - return section('glance-title', 'At a glance', html`
- ${readout('Devices', s.authLists?.session.available === false ? 'Not available yet' : sessions.length + ' paired', s.authLists?.session.available === false ? 'Reading in the background' : lastSeen)} - ${readout('Agents', signed + ' of ' + installed.length + ' signed in', updates ? M.plural(updates, 'update') + ' available' : 'all current')} - ${readout('Ports', published.length ? html`${dot('info')}${published.length} published` : 'None published', published.length ? published.map((p) => p.port + (p.startedAt ? ' · ' + M.duration((n - p.startedAt) / 1000) : '')).join(', ') : ports ? M.plural(M.portRows(ports, ui, n).length, 'port') + ' listening' : '')} - ${Number.isFinite(paths.volumeBytes) ? readout('Volume', M.formatBytes(paths.volumeBytes), paths.volume) : ''} -
`); + /** + * One release on the Overview: what is installed, what it goes to, and + * Update. T3 Code's own restarts it, so its button asks first. + */ + const updateRow = (row) => html` +
+ ${row.runtime ? html`` : tile(row)} +
${row.name}${row.version}${row.latest ? html` → ${row.latest}` : ''}
+
${row.state === 'running' + ? html`Updating` + : row.runtime ? html`` + : html``}
+
`; + + const overviewUpdates = (s, n) => { + const view = M.updatesView(s, ui, n); + const rt = view.runtime; + const rows = [ + ...(rt && (rt.available || rt.busy) ? [{ runtime: true, key: 't3', name: 'T3 Code', version: rt.version, latest: rt.busy ? rt.operation.version : rt.latest, state: rt.busy ? 'running' : 'update' }] : []), + ...view.available, + ]; + const shown = rows.slice(0, 4); + const checked = s.updates && s.updates.checkedAt ? 'Checked ' + M.relTime(s.updates.checkedAt, n) + '.' : 'Checked every hour.'; + return section('ov-updates', 'Updates', html`
+ ${shown.length ? html`
${shown.map(updateRow)}
` + : html`
${icon('circle-check', 'tc-ok')}Everything is up to date. ${checked}
`} + ${rows.length > shown.length ? html`${M.plural(rows.length - shown.length, 'more update')}${icon('arrow-right')}` : ''} +
`, { actions: view.due.length > 1 ? html`` : linkButton('#updates', 'All updates') }); + }; + + const RECENT_ICONS = { + 'device.paired': 'smartphone', 'device.renamed': 'pencil', 'port.published': 'globe', 'port.stopped': 'circle-stop', 'harness.updated': 'circle-arrow-up', + 'harness.installed': 'download', 'harness.uninstalled': 'trash-2', 'harness.failed': 'circle-alert', 'harness.enabled': 'circle-check', 'signin.ok': 'log-in', 'signin.out': 'log-out', 'setup.finished': 'download', + 'toolchain.installed': 'download', 'toolchain.updated': 'circle-arrow-up', 'toolchain.uninstalled': 'trash-2', 'toolchain.failed': 'circle-alert', + 'package.installed': 'download', 'package.updated': 'circle-arrow-up', 'package.uninstalled': 'trash-2', 'package.failed': 'circle-alert', + 'url.set': 'globe', 'url.cleared': 'globe', 'key.replaced': 'key-round', + 'connect.linked': 'globe', 'connect.unlinked': 'globe', 't3.restarted': 'refresh-cw', 't3.updating': 'circle-arrow-up', + }; + const recentSection = (s, n) => { + const events = (s.events || []).slice(0, 6); + return section('recent-title', 'Recent', html`
${events.length + ? html`
    ${events.map((e) => html` +
  1. ${icon(RECENT_ICONS[e.kind] || 'activity')}${e.text}${e.detail ? html` ${e.detail}` : ''}
  2. `)}
` + : html`
Nothing yet since the setup service started.
`}
`); }; /** A paired device: what it is, when it was last here, and (on Access) its menu. */ @@ -778,54 +816,37 @@ ${manage ? html`
` : ''} `; - const recentSection = (s, n) => { - const events = s.events || []; - if (!events.length) return ''; - const ICONS = { - 'device.paired': 'smartphone', 'device.renamed': 'pencil', 'port.published': 'globe', 'port.stopped': 'circle-stop', 'harness.updated': 'circle-arrow-up', - 'harness.installed': 'download', 'harness.uninstalled': 'trash-2', 'harness.failed': 'circle-alert', 'harness.enabled': 'circle-check', 'signin.ok': 'log-in', 'signin.out': 'log-out', 'setup.finished': 'download', - 'toolchain.installed': 'download', 'toolchain.updated': 'circle-arrow-up', 'toolchain.uninstalled': 'trash-2', 'toolchain.failed': 'circle-alert', - 'package.installed': 'download', 'package.updated': 'circle-arrow-up', 'package.uninstalled': 'trash-2', 'package.failed': 'circle-alert', - 'url.set': 'globe', 'url.cleared': 'globe', 'key.replaced': 'key-round', - 'connect.linked': 'globe', 'connect.unlinked': 'globe', 't3.restarted': 'refresh-cw', - }; - return section('recent-title', 'Recent', html`
    ${events.slice(0, 8).map((e) => html` -
  1. ${icon(ICONS[e.kind] || 'activity')}${e.text}${e.detail ? html` ${e.detail}` : ''}
  2. `)}
`, - { actions: muted('Since the setup service started') }); - }; - - /** The page a "Needs you" item lives on, and that page's link. */ - const NEEDS_PAGES = { agents: 'All agents', toolchains: 'All toolchains', sourcecontrol: 'Source control', ports: 'All ports' }; + /** The page a "Needs you" item lives on, and that page's link when they all share one. */ + const NEEDS_PAGES = { agents: 'All agents', toolchains: 'All toolchains', sourcecontrol: 'Source control', ports: 'All ports', server: 'Server', storage: 'Storage', updates: 'Updates' }; const needsPage = (x) => x.target === 'port' ? 'ports' : x.target === 'harness' ? 'agents' - : x.target === 'toolchain' && M.SOURCE_CONTROL[x.id] ? 'sourcecontrol' : 'toolchains'; + : x.target === 'server' ? (x.action && x.action.route) || 'server' + : x.target === 'toolchain' && M.SOURCE_CONTROL[x.id] ? 'sourcecontrol' : 'toolchains'; + /** + * Overview: which server this is, then setup until it is done, and after + * that a health check: what needs you, what can update, what happened. + */ const overviewPage = (s, n) => { const r = M.readiness(s, ui); // Until setup is done, signing agents in is its last step: said there, not twice. const needs = M.needsYou(s, state.ports, ui).filter((item) => r.ready || item.state !== 'signin'); - const devices = M.deviceRows(s.sessions, n); - // A link to the one page they all live on; from several, each row's own - // action is the way in. const pages = new Set(needs.map(needsPage)); const [page] = pages; - const actions = r.ready - ? html`` - : EMBED ? '' : html`${icon('external-link')}Open T3 Code`; + const server = s.server || {}; + const host = M.hostOf(s.publicUrl); + const keep = (text) => text.replace(/ /g, '\u00a0'); + const sub = html`${host ? html`${host} · ` : ''}${server.ok + ? ['T3 Code is running', Number.isFinite(server.uptimeSeconds) ? keep('up ' + M.duration(server.uptimeSeconds)) : null].filter(Boolean).join(', ') + '.' + : 'T3 Code isn’t answering yet.'}`; + const actions = EMBED ? '' : html`${icon('external-link')}Open T3 Code`; return html` - ${pageHead('overview', { actions })} + ${pageHead('overview', { sub, actions })} ${pageNotices('overview')} - ${s.storage && s.storage.disk && s.storage.disk.usedPercent >= 80 ? notice(s.storage.disk.usedPercent >= 90 ? 'danger' : 'warn', 'server', - s.storage.disk.usedPercent >= 90 ? 'Disk space is nearly full' : 'Disk space is running low', - M.formatBytes(s.storage.disk.availableBytes) + ' available on the home filesystem. Review storage before it fills up.', - html``) : ''} ${r.ready ? readyLine(s) : readinessGroup(r)} ${needs.length ? section('needs-title', 'Needs you', html`
${needs.map(attentionRow)}
`, - pages.size === 1 ? { actions: linkButton('#' + page, NEEDS_PAGES[page]) } : undefined) : ''} + pages.size === 1 && NEEDS_PAGES[page] ? { actions: linkButton('#' + page, NEEDS_PAGES[page]) } : undefined) : ''} ${activitySection(M.activity(s, ui, n), n)} - ${r.ready ? html` - ${glanceSection(s, state.ports, n)} - ${devices.length ? section('devs-title', 'Paired devices', html`
${devices.map((d) => deviceRow(d))}
`, { actions: linkButton('#access', 'Manage') }) : ''} - ${recentSection(s, n)}` : ''}`; + ${r.ready ? html`
${overviewUpdates(s, n)}${recentSection(s, n)}
` : ''}`; }; // -------------------------------------------------------------- devices -- diff --git a/docker/setup/client/model.js b/docker/setup/client/model.js index 4e84226..fd31d97 100644 --- a/docker/setup/client/model.js +++ b/docker/setup/client/model.js @@ -1242,40 +1242,97 @@ const T3Model = (() => { }; /** "2 devices paired · 5 agents signed in · 5 toolchains · nothing needs you" */ - const readySummary = (status, ui) => { + const readySummary = (status, ui, ports = null) => { const s = status || {}; const agents = agentRows(s, ui, 0); const signed = agents.filter((a) => ['ok', 'update'].includes(a.state)).length; const tools = (s.toolchains || []).filter((t) => t.installed).length; - const attention = needsYou(s, null, ui).length; + const attention = needsYou(s, ports, ui).length; + const keep = (text) => text.replace(/^(\d+) /, '$1\u00a0'); return [ - plural((s.sessions || []).length, 'device') + ' paired', - plural(signed, 'agent') + ' signed in', - plural(tools, 'toolchain'), - attention ? plural(attention, 'thing') + ' to look at' : 'nothing needs you', + keep(plural((s.sessions || []).length, 'device')) + ' paired', + keep(plural(signed, 'agent')) + ' signed in', + keep(plural(tools, 'toolchain')), + attention ? keep(plural(attention, 'thing needs', 'things need')) + ' you' : 'nothing needs you', ].join(' · '); }; // -------------------------------------------------------- what needs you -- - /** Rows that want the user: not signed in, failed, and updates available. */ + /** + * The container's own problems, as items for the Overview's Needs you: a + * home or state folder the next recreate would leave behind, a filling disk, + * an older image's settings, a T3 Code update that did not finish, and T3 + * Connect waiting on a restart. Each says what is wrong in a line and offers + * the one thing to do about it. + */ + const containerItems = (status) => { + const s = status || {}; + const paths = s.paths || {}; + const out = []; + const item = (id, tone, icon, name, text, action) => out.push({ target: 'server', id, key: 'server:' + id, tone, icon, name, text, action }); + const lost = (kind) => kind === 'anonymous' || kind === 'none'; + if (lost(paths.volumeKind)) { + item('home', 'danger', 'folder', 'Recreating this container would lose its home folder', + 'Agents, toolchains and caches are ' + (paths.volumeKind === 'none' ? 'inside the container.' : 'on an anonymous volume.'), + { cmd: 'volume.fix', id: 'home', label: 'How to fix' }); + } + if (lost(paths.stateKind)) { + item('state', 'danger', 'folder', 'Recreating this container would lose T3 Code’s state', + 'Conversations, sign-ins and settings are ' + (paths.stateKind === 'none' ? 'inside the container.' : 'on an anonymous volume.'), + { cmd: 'volume.fix', id: 'state', label: 'How to fix' }); + } + const disk = diskWarning(s); + if (disk) { + item('disk', disk, 'hard-drive', 'The disk is ' + s.storage.disk.usedPercent + '% full', + formatBytes(s.storage.disk.availableBytes) + ' free. Clearing caches and old versions makes room.', + { cmd: 'goto', route: 'storage', label: 'Free up space' }); + } + const legacy = (s.legacyEnv || []).filter((name) => name !== 'PATH'); + if ((s.legacyEnv || []).length) { + item('legacy', 'warn', 'triangle-alert', 'Settings left over from an older image', + (legacy.length ? listOf(legacy) + (legacy.length === 1 ? ' is' : ' are') : 'The old PATH is') + ' still set in the container. Nothing here uses them.', + { cmd: 'goto', route: 'server', label: 'Review' }); + } + const runtime = runtimeView(s); + if (runtime && runtime.error) { + item('runtime', 'warn', 'circle-arrow-up', 'T3 Code’s last update didn’t finish', String(runtime.error).split('\n')[0], + { cmd: 'goto', route: 'updates', label: 'Review' }); + } + const connect = connectView(s); + if (connect.state === 'restart' && connect.action) { + item('connect', 'warn', 'globe', 'T3 Connect is waiting for a restart', 'T3 Code makes the link when it next starts.', connect.action); + } + return out; + }; + + /** + * What needs the person, worst first: anything failed, or lost on the next + * recreate; then sign-ins and warnings. Agents, tools and source control + * CLIs come as their own rows; a port that would not publish and the + * container's own problems as items. Updates are not here: they are + * offered, on Updates. + */ const needsYou = (status, ports, ui) => { const s = status || {}; - const agents = agentRows(s, ui, 0); - const tools = [...toolchainRows(s, ui, 0), ...packageRows(s, ui, 0)]; - const rank = { failed: 0, signin: 1, update: 2 }; - const items = [ - ...agents.filter((a) => a.state in rank), - ...tools.filter((t) => t.state === 'failed' || t.state === 'update'), + const rank = { failed: 0, signin: 1 }; + const rows = [ + ...agentRows(s, ui, 0).filter((a) => a.state in rank), + ...[...toolchainRows(s, ui, 0), ...packageRows(s, ui, 0)].filter((t) => t.state === 'failed'), // Every CLI T3 Code would use and cannot yet, an added tool's too; the // image's gh is never pressed on anyone. ...sourceControlRows(s, ui, 0).filter((c) => !c.inImage && c.state in rank), - ]; + ].map((row) => Object.assign(row, { severity: rank[row.state] })); + const items = [...rows, ...containerItems(s).map((x) => Object.assign(x, { severity: x.tone === 'danger' ? 0 : 1 }))]; if (ports) { for (const p of portRows(ports, ui, 0)) { - if (p.state === 'failed') items.push({ target: 'port', id: p.port, state: 'failed', port: p }); + if (p.state !== 'failed') continue; + items.push({ target: 'port', id: p.port, key: 'port:' + p.port, tone: 'danger', icon: 'ethernet-port', severity: 0, + name: 'Port ' + p.port + ' couldn’t be published', text: p.error || 'The tunnel failed.', + action: { cmd: 'port.publish', id: String(p.port), label: 'Retry', icon: 'refresh-cw' } }); } } - return items.sort((a, b) => (rank[a.state] ?? 3) - (rank[b.state] ?? 3)); + // Stable within a severity: the order each page lists them in. + return items.map((item, i) => [item, i]).sort((a, b) => a[0].severity - b[0].severity || a[1] - b[1]).map(([item]) => item); }; // ------------------------------------------------------------- activity -- @@ -1515,6 +1572,14 @@ const T3Model = (() => { const add = (group, label, icon, cmd, extra) => items.push(Object.assign( { group, label, icon, cmd, id: group + ':' + label, keywords: (KEYWORDS[cmd.cmd] || '') + (cmd.cmd === 'goto' ? ' ' + label : '') }, extra || {})); + // The container's own problems, as Overview lists them. + const fixes = { home: 'Fix the home folder’s anonymous volume…', state: 'Fix the state folder’s anonymous volume…', + legacy: 'Review settings left by an older image', runtime: 'Review the unfinished T3 Code update' }; + for (const item of containerItems(s)) { + if (!fixes[item.id]) continue; + add(item.id === 'runtime' ? 'Updates' : 'Server', fixes[item.id], item.icon, + item.action.cmd === 'goto' ? { cmd: 'goto', route: item.action.route } : { cmd: item.action.cmd, id: item.action.id }, { attention: true }); + } add('Storage', 'Free up disk space', 'hard-drive', { cmd: 'storage.goto' }, { keywords: 'disk space caches cleanup folders usage manage storage', attention: Boolean(diskWarning(s)) }); add('Storage', 'Storage maintenance…', 'settings-2', { cmd: 'storage.preferences' }, { keywords: 'cache limits automatic cleanup retention preferences' }); @@ -1848,7 +1913,7 @@ const T3Model = (() => { agentRow, agentRows, toolRow, toolchainRow, toolchainRows, sourceControlRows, managedSourceControlRows, packageRows, portRows, looksLikeDatabase, monogram, managedOn, isVersionSpec, isToolSpec, specPrefix, SPEC_SAMPLES, searchRegistry, resolveRelease, matchReleases, releaseIndex, heldRelease, versionOf, toolName, SUGGESTED_TOOLS, deviceRows, linkRows, deviceKind, deviceName, - readiness, readySummary, needsYou, activity, setupBanner, setupFailures, + readiness, readySummary, needsYou, containerItems, activity, setupBanner, setupFailures, publicUrlSource, hereOffer, connectView, contextView, contextAgentsMdRow, contextProviderRows, navBadges, serverWarnings, diskWarning, attentionCount, summaries, updatesView, runtimeView, autoUpdateView, diff --git a/docker/setup/console.css b/docker/setup/console.css index 68cdb36..31eef41 100644 --- a/docker/setup/console.css +++ b/docker/setup/console.css @@ -97,6 +97,30 @@ html.tc-locked { overflow: hidden; } .tc-notice--progress > .tc-progress { grid-column: 2; width: auto; } } +/* Overview, once set up: Updates and Recent side by side where there is room. */ +.tc-overview-split { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); gap: var(--_gap) var(--space-6); align-items: start; } +/* A problem's icon carries its tone. */ +.tc-tile--icon[data-tone="danger"] { color: var(--danger); } +.tc-tile--icon[data-tone="warn"] { color: var(--warn); } +/* "2 more updates", at the foot of a short list. */ +.tc-group-more { display: flex; align-items: center; justify-content: center; gap: var(--space-1); min-height: 40px; padding: 0 var(--space-4); + font-size: 12px; color: var(--fg-muted); text-decoration: none !important; border-radius: 0 0 calc(var(--radius-xl) - 1px) calc(var(--radius-xl) - 1px); } +.tc-group-more:hover { color: var(--fg); background: var(--hover); } +.tc-group-more .tc-icon { width: 14px; height: 14px; } +/* A version moving to the next one, under a row's name. */ +.tc-row-main > .tc-version { min-width: 0; overflow-wrap: anywhere; } +.tc-empty--compact > .tc-icon { align-self: center; } + +/* A name and its version share a baseline, whatever their sizes. */ +.tc-row-title { align-items: baseline; } +.tc-row-title > .tc-dot { align-self: center; } +/* A row with work in it grows; its tile stays by the name. */ +.tc-row--expanded > .tc-tile, .tc-row--expanded > .tc-row-lead { align-self: start; margin-top: 2px; } +/* The release an update goes to. */ +.tc-version-to { color: var(--accent-text); } +/* Names and values that must not part at a hyphen. */ +.tc-nowrap { white-space: nowrap; } + /* A row without a menu keeps the menu's place, so actions line up down a list. */ .tc-row-menu-slot { display: block; width: var(--control-sm); height: 1px; flex: none; } .tc-root[data-density="touch"] .tc-row-menu-slot { width: 32px; } @@ -133,11 +157,16 @@ html.tc-locked { overflow: hidden; } @media (max-width: 639.98px) { .tc-row:has(> .tc-row-actions--wrap) { grid-template-columns: auto minmax(0, 1fr); } .tc-row > .tc-row-actions--wrap { grid-column: 2 / -1; justify-content: flex-start; } + .tc-row-actions--wrap > .tc-row-menu-slot { display: none; } /* Only rows without a leading tile put actions under the whole main block. A stale plain modifier must not move an icon row into a single column. */ .tc-row--plain:not(:has(> .tc-tile, > .tc-row-lead)):has(> .tc-row-actions--wrap) { grid-template-columns: minmax(0, 1fr); } .tc-row--plain:not(:has(> .tc-tile, > .tc-row-lead)) > .tc-row-actions--wrap { grid-column: 1 / -1; } } +/* Touch density raises every button to 16px text; an extra-small one (a + section's link, a card's Refresh) stays a step below the page's text. */ +.tc-root[data-density="touch"] .tc-btn--xs { --_h: 30px; font-size: 13px; padding: 0 9px; } +.tc-root[data-density="touch"] .tc-btn--xs .tc-icon { width: 15px; height: 15px; } /* A 28px input, for inline values beside small buttons. */ .tc-input--sm { height: var(--control-sm); font-size: 13px; } diff --git a/docs/setup-page.md b/docs/setup-page.md index 42562ad..8f9ea8b 100644 --- a/docs/setup-page.md +++ b/docs/setup-page.md @@ -29,7 +29,11 @@ signed in. A key set with `T3_SETUP_KEY` can only be changed there. **Overview** lists what's left before a phone can use the server: the server running, a [public URL](remote-access.md#the-public-url) set, a device paired, and the agents signed in, with a button for the next step. When all four are done, the list collapses to a -single **Ready** line and recent activity is shown instead. On a desktop the +single **Ready** line and the page becomes a health check: **Needs you** lists +anything failed or signed out, and problems with the container itself (a home +or state folder that a recreate would lose, a disk over 80% full, settings an +older image left behind), each with the one thing to do; **Updates** shows what +can update, with **Update all**; and **Recent** what happened lately. On a desktop the pages are in a sidebar: Overview and Access, then **Agents** (Agents, Instructions & skills), **Tools** (Toolchains, Source control, Ports) and **System** (Updates, Storage, Server). On a phone the most used are in a tab diff --git a/tests/console-model.test.mjs b/tests/console-model.test.mjs index a8b4245..97d1e08 100644 --- a/tests/console-model.test.mjs +++ b/tests/console-model.test.mjs @@ -535,14 +535,17 @@ test("a later step that needs the user is a warning, not a second todo", () => { test("the ready summary counts what is set up and what still wants a look", () => { const s = statusWith({ sessions: [{ sessionId: "a" }, { sessionId: "b" }] }); - assert.equal(M.readySummary(s, ui()), "2 devices paired · 5 agents signed in · 5 toolchains · nothing needs you"); + // A count keeps its noun on the same line. + assert.equal(M.readySummary(s, ui()), "2\u00a0devices paired · 5\u00a0agents signed in · 5\u00a0toolchains · nothing needs you"); + const signedOut = statusWith({ harnesses: [harness("claude", { signedIn: false })] }); + assert.match(M.readySummary(signedOut, ui()), /1\u00a0thing needs you$/); const update = statusWith({ harnesses: [harness("claude", { latestVersion: "2.0.0" })] }); - assert.match(M.readySummary(update, ui()), /1 thing to look at$/); + assert.match(M.readySummary(update, ui()), /nothing needs you$/, "an update is offered, not owed"); }); // ------------------------------------------------------ attention and badges -- -test("needs-you lists failures before sign-ins before updates", () => { +test("needs-you lists failures before sign-ins, and leaves updates to the Updates page", () => { const s = statusWith({ harnesses: [ harness("claude", { latestVersion: "2.0.0" }), @@ -552,7 +555,33 @@ test("needs-you lists failures before sign-ins before updates", () => { harness("cursor"), ], }); - assert.deepEqual(plain(M.needsYou(s, null, ui()).map((r) => r.id)), ["opencode", "codex", "claude"]); + assert.deepEqual(plain(M.needsYou(s, null, ui()).map((r) => r.id)), ["opencode", "codex"]); +}); + +test("needs-you names the container's own problems, each with the one thing to do", () => { + const s = statusWith({ + paths: { volumeKind: "anonymous", stateKind: "durable" }, + storage: { disk: { usedPercent: 91, availableBytes: 9e9 } }, + legacyEnv: ["GOROOT", "PATH"], + harnesses: [harness("codex", { signedIn: false })], + t3: { port: 3773, pid: 7, runtime: { channel: "stable", version: "0.1.0", releases: {}, operation: { phase: "rolled-back", error: "The new version did not start.\nlog: …" } } }, + connect: { state: "pending" }, + }); + const items = plain(M.needsYou(s, null, ui())); + assert.deepEqual(items.map((i) => i.id), ["home", "disk", "codex", "legacy", "runtime", "connect"], "anything lost or nearly full first, then the rest in page order"); + const by = Object.fromEntries(items.map((i) => [i.id, i])); + assert.deepEqual(by.home.action, { cmd: "volume.fix", id: "home", label: "How to fix" }); + assert.match(by.home.text, /anonymous volume/); + assert.equal(by.disk.tone, "danger"); + assert.equal(by.disk.name, "The disk is 91% full"); + assert.deepEqual(by.disk.action, { cmd: "goto", route: "storage", label: "Free up space" }); + assert.match(by.legacy.text, /^GOROOT is still set/); + assert.equal(by.runtime.text, "The new version did not start.", "only the first line of a long error"); + assert.equal(by.connect.action.cmd, "connect.restart"); + const failedPort = { available: true, listening: [8080], tunnels: [{ port: 8080, state: "failed", error: "edge" }] }; + const port = plain(M.needsYou(statusWith(), failedPort, ui()))[0]; + assert.deepEqual([port.name, port.text, port.action.cmd, port.action.id], ["Port 8080 couldn’t be published", "edge", "port.publish", "8080"]); + assert.deepEqual(plain(M.needsYou(statusWith(), null, ui())), [], "nothing wrong, nothing listed"); }); test("navigation badges count what needs the user, and only mark what is running or live", () => { @@ -586,7 +615,7 @@ test("the count T3 Code shows beside Setup is what needs the user, not what coul // codex signed out, opencode failed, go failed, an old image's setting: // four. Claude's and Rust's updates are offered, not owed. assert.equal(M.attentionCount(s, null, ui()), 4); - assert.equal(M.needsYou(s, null, ui()).length, 5, "the Overview still lists updates"); + assert.equal(M.needsYou(s, null, ui()).length, 4, "the Overview lists the same four"); const failedPort = { available: true, listening: [8080], tunnels: [{ port: 8080, state: "failed", error: "edge" }] }; assert.equal(M.attentionCount(s, failedPort, ui()), 5, "a port that would not publish counts when ports are known"); assert.equal(M.attentionCount(statusWith(), null, ui()), 0); @@ -1216,7 +1245,7 @@ test("source control counts with the toolchains: needs-you, badges, update all, scm("tea", { installed: false, version: null, auth: null }), ], }); - assert.deepEqual(plain(M.needsYou(s, null, ui()).map((i) => i.id)), ["glab", "fj"], "gh signed out is not owed anything"); + assert.deepEqual(plain(M.needsYou(s, null, ui()).map((i) => i.id)), ["glab"], "gh signed out is not owed anything, and fj's update is offered"); // Its own page, with its own badge; the Toolchains page is not blamed for it. const badges = M.navBadges(s, null, ui()); assert.deepEqual(plain(badges.sourcecontrol), { tone: "warn", count: 1 });