From d39a4cb34f2daf71cb704b399dce6677a8e1d80d Mon Sep 17 00:00:00 2001 From: Ziyang Zeng Date: Mon, 19 Oct 2026 05:00:48 +0000 Subject: [PATCH] feat(setup): one page header, quieter rows and plainer statuses Every page opens with the same head: its title, one line on what it is for with a Learn more link to the guide at the image's own release, and the page's main action. The breadcrumb, the duplicate section labels and the "How it works" tables are gone; the guides already say it. - One route table (client/routes.js) feeds the sidebar, the tab bar, the router, the palette's Go to items and the G shortcuts, so a page is added or renamed in one place. page.mjs and the server read it too. - Sidebar marks count only what needs the person; running work shows a spinner and a published port a quiet dot. No plain counts. - An anonymous or unmounted home or state folder now counts as needing attention, in the sidebar and in T3 Code's Setup badge. - Rows keep a slot for their menu so actions line up down a list. Devices and unused links act from one menu; Rename and Revoke no longer wrap under each device on a phone. Turning T3 Connect off moves into its menu. - Destructive actions are no longer solid buttons: Clear caches, Stop and Revoke link are quiet, and still confirm. - Keyboard shortcuts move out of buttons into tooltips and the palette. - Notices only say what failed. Cache freshness and slow reads fill in by themselves. First start's progress is a line on each page rather than a banner. - The agents checklist step can always finish: Muse's saved credentials count, an unreadable sign-in offers a way forward, and no step that is not done is left without an action. - A channel name from mise ("stable" for Rust) is no longer shown as a release that is "available once it's a day old". - scripts/console-screens.js photographs every page from fixed sample data in three scenarios, at phone, tablet, desktop and embedded sizes. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 9 + docker/setup/app.js | 431 +++--- docker/setup/client/model.js | 208 +-- docker/setup/client/routes.js | 76 + docker/setup/client/storage.js | 2 +- docker/setup/console.css | 129 +- docker/setup/page.mjs | 70 +- docker/setup/server.mjs | 8 +- docs/ports.md | 7 +- scripts/auto-update-ui-audit.js | 4 +- scripts/console-screens.js | 200 +++ scripts/fixtures/console/ports.json | 71 + scripts/fixtures/console/providers.json | 941 +++++++++++++ scripts/fixtures/console/skills-claude.json | 412 ++++++ scripts/fixtures/console/skills-codex.json | 228 +++ scripts/fixtures/console/skills-opencode.json | 395 ++++++ scripts/fixtures/console/status.json | 1224 +++++++++++++++++ scripts/form-drafts-ui-audit.js | 14 +- scripts/ui-audit.js | 13 +- tests/console-model.test.mjs | 163 ++- tests/setup-page.test.mjs | 4 +- 21 files changed, 4174 insertions(+), 435 deletions(-) create mode 100644 docker/setup/client/routes.js create mode 100644 scripts/console-screens.js create mode 100644 scripts/fixtures/console/ports.json create mode 100644 scripts/fixtures/console/providers.json create mode 100644 scripts/fixtures/console/skills-claude.json create mode 100644 scripts/fixtures/console/skills-codex.json create mode 100644 scripts/fixtures/console/skills-opencode.json create mode 100644 scripts/fixtures/console/status.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d05a7af..f5157df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,15 @@ Issues and pull requests are welcome. Before opening one: `docker/t3-client/setup-bridge.js`, and prints a pairing link. `scripts/setup-bridge-audit.js` checks those pages in a browser, through the same router. + `node scripts/console-screens.js` photographs every setup page in light and + dark, on a phone, a tablet, a desktop and inside T3 Code, in three states: + set up, first start, and everything needing attention at once. Its data + comes from `scripts/fixtures/console`, so the pictures do not depend on the + machine, and nothing it presses reaches the server. Screenshots go to + `/tmp/t3-console-screens`; `--routes`, `--sizes`, `--themes` and + `--scenarios` narrow a run. Attach the pages you changed to the pull request. + `node scripts/ui-audit.js http://127.0.0.1:23780/__setup/ dev` measures the + same pages for clipped text, uneven buttons and misaligned glyphs. `node scripts/storage-ui-audit.js http://127.0.0.1:23780/__setup/ dev` checks storage layouts and keyboard flows in light/dark and embedded modes. Its storage data and mutations are intercepted, so it cannot clean a real diff --git a/docker/setup/app.js b/docker/setup/app.js index 2211e30..9b7d82c 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -7,16 +7,17 @@ // because the corrupted result still parses. Kept as a real file, nothing // rewrites it between here and the browser. // -// Four scripts share one scope, in this order: client/base.js (BASE, where the -// API lives), design/ui.js (window.T3C: icons and copy), client/model.js +// Five scripts share one scope, in this order: client/base.js (BASE, where the +// API lives), design/ui.js (window.T3C: icons and copy), client/routes.js +// (T3Routes: the table of pages), client/model.js // (T3Model: what every row, badge and step should say, as pure functions) and // client/kit.js (Kit: markup, patching, overlays, toasts, keys). This file is // the console on top of them: // // data /status every 15s (3s while work runs), /ports every 4s, paused // while the tab is hidden -// routes #overview #devices #agents #toolchains #ports #environment #more -// pages one renderer per route, markup in, Kit.patch out +// routes one per page in client/routes.js, plus #more on a phone +// pages one renderer per route, its head first, markup in, Kit.patch out // commands every button carries data-cmd; the palette runs the same table // flows pairing, sign-in, API keys, versions, confirmations // @@ -91,7 +92,6 @@ portsAt: 0, providers: null, route: 'overview', - agentFilter: 'all', toolFilter: '', }; // What the page knows that /status cannot: see the header comment. @@ -594,9 +594,9 @@ const versionLabel = (row) => { if (!row.version) return ''; if (row.versionTo) return html`${row.version} → ${row.versionTo}`; - // While work runs the row shows its progress instead. - const glyph = row.updateAvailable && row.state !== 'running' && row.state !== 'queued'; - return html`${row.version}${glyph ? html` ${icon('circle-arrow-up')}` : ''}`; + // A newer release says where it goes; while work runs the row shows its progress instead. + const next = row.updateAvailable && row.state !== 'running' && row.state !== 'queued'; + return html`${row.version}${next ? html` → ${row.latest}` : ''}`; }; const progressBar = (p) => html`
`; const phaseText = (p, kind) => { @@ -612,9 +612,11 @@ const variant = 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. + const menuSlot = () => html``; const menuButton = (row) => row.menu.length ? html`` - : ''; + : menuSlot(); /** An agent or toolchain row: tile, name and version, one status line, one verb, a menu. */ const resourceRow = (row, opts = {}) => { @@ -622,7 +624,7 @@ const noticeError = M.rowError(row, 'notice'); const detail = (source) => html` `; return html` -
+
${tile(row)}
${row.name}${versionLabel(row)}${badge(row.badge)}
@@ -639,6 +641,25 @@
<${raw(level || 'h2')} class="tc-section-title" id="${id}">${title}${actions ? html`
${actions}
` : ''}
${body} `; + /** + * The top of every page: its title, one line saying what it is for with a + * link to the guide that explains the rest, and the page's main action. + * Pages carry no explanation tables of their own; the guide is one press away. + */ + const learnMore = (doc) => doc ? html`Learn more (opens the guide in a new tab)` : ''; + const pageHead = (route, { title, sub, actions, doc } = {}) => { + const page = T3Routes.page(route) || {}; + const line = sub === undefined ? page.summary : sub; + const guide = doc === undefined ? page.doc : doc; + return html` +
+
+

${title || page.title}

+ ${line || guide ? html`

${line || ''}${line && guide ? ' ' : ''}${learnMore(guide)}

` : ''} +
+ ${actions ? html`
${actions}
` : ''} +
`; + }; const muted = (text) => html`${text}`; // An empty state's icon, on its card with two more fanned out behind it. const emptyIcon = (name, cls) => html``; @@ -670,33 +691,34 @@ const skeletonGroup = (rows) => html``; // ------------------------------------------------------- page notices -- - /** What could not be read, or is not current, above the page it affects. */ + /** + * What could not be read, above the page it affects. Only failures are + * said: a slow read, or one answered from the last check while a fresh one + * runs, fills in by itself and is no news to anyone. + */ const pageNotices = (route) => { const s = state.status; const out = []; if (state.statusError) { - out.push(notice('danger', 'circle-alert', 'Could not read status', + out.push(notice('danger', 'circle-alert', 'Couldn’t reach the setup service', html`From ${BASE + '/status'}: ${state.statusError}.${BASE ? '' : html` If this page is served under a path prefix, set T3_SETUP_BASE_PATH.`}`, html``)); } if (!s) return out; + // What the first start is still installing, on every page but Overview, + // whose checklist and activity already say it. + const banner = M.setupBanner(s); + if (banner && route !== 'overview') { + out.push(html`
First start · ${banner.text}
${progressBar({ pct: banner.pct })}${banner.done}/${banner.total}
`); + } // A part that could not be read says so, instead of rendering as "none". if ((s.degraded || []).length) { - out.push(notice('warn', 'triangle-alert', 'Some of this is unreadable right now', - 'Could not read ' + M.listOf(s.degraded.map((d) => d.what)) + '. Try refreshing in a moment.')); + out.push(notice('warn', 'triangle-alert', 'Some details couldn’t be read', + 'Couldn’t read ' + M.listOf(s.degraded.map((d) => d.what)) + '. The console tries again on its own.')); } - if (route === 'devices' || route === 'overview') { - const lists = Object.values(s.authLists || {}); - const unavailable = lists.some((entry) => !entry.available); - const failed = lists.some((entry) => entry.error); - if (unavailable || failed) { - out.push(notice(failed ? 'warn' : null, failed ? 'triangle-alert' : 'refresh-cw', - failed ? 'Device details could not refresh' : 'Reading device details', - failed ? 'T3 Code has not returned a fresh list. Available details are from the last successful check; missing details are unavailable. The console retries automatically.' - : 'Pairing links and paired devices are loading in the background. This page updates automatically.')); - } else if (lists.some((entry) => entry.stale)) { - out.push(notice(null, 'history', null, 'Device details are from the last successful check while a fresh read finishes.')); - } + if ((route === 'devices' || route === 'overview') && Object.values(s.authLists || {}).some((entry) => entry.error)) { + out.push(notice('warn', 'triangle-alert', 'Couldn’t refresh the device list', + 'Showing devices from the last check. The console keeps trying.')); } const setup = s.setup; if (setup && (route === 'overview' || route === 'agents' || route === 'toolchains' || route === 'sourcecontrol')) { @@ -705,21 +727,15 @@ // stops after three failures in a row, which is nearly always no network. const failed = M.setupFailures(s); if (setup.state !== 'running' && failed.length) { - const title = failed.length === 1 ? 'First start could not install ' + (failed[0].name || failed[0].id) + const title = failed.length === 1 ? 'First start couldn’t install ' + (failed[0].name || failed[0].id) : 'First start left ' + M.plural(failed.length, 'install') + ' unfinished'; out.push(notice('warn', 'triangle-alert', title, - html`Try again from ${failed.length === 1 ? 'the row' : 'each row'}. `)); + html`Try again from ${failed.length === 1 ? 'its row' : 'each row'}. `)); } else if (setup.state === 'interrupted') { const done = items.filter((i) => i.state === 'done').length; out.push(notice(null, 'history', 'First start stopped part way', done + ' of ' + items.length + ' installed. It carries on the next time the container starts.')); } } - // The sign-in state below is the last definite answer, not a fresh probe: - // offline, the refresh exceeds its budget and warms the next poll instead. - const cache = route === 'agents' ? s.harnessCache : route === 'sourcecontrol' ? s.sourceControlCache : null; - if (cache && cache.stale && (cache.source === 'cache' || cache.source === 'cheap')) { - out.push(notice(null, 'history', null, 'Sign-in state is from the last check while a fresh one finishes. It refreshes on the next poll.')); - } return out; }; @@ -737,7 +753,7 @@
-
Setup · ${r.done} of ${r.total} done

${r.title}

+
Setup · ${r.done} of ${r.total} done

${r.title}

@@ -761,7 +777,7 @@
-

Ready

${M.readySummary(s, ui)}
+

Ready

${M.readySummary(s, ui)}
${EMBED ? '' : html`Open T3 Code${icon('arrow-right')}`}
`; @@ -784,7 +800,7 @@ lines.push(html`
${icon(f.ok ? 'circle-check' : 'circle-alert', f.ok ? 'tc-ok' : 'tc-danger')}${f.text}${M.relTime(f.at, n)}
`); } if (!lines.length) return ''; - return section('act-title', 'Activity', html`
${lines}
`, { actions: muted('Runs in the background') }); + return section('act-title', 'Activity', html`
${lines}
`); }; const glanceSection = (s, ports, n) => { @@ -799,19 +815,19 @@ 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' : s.authLists?.session.stale ? 'From the last check' : lastSeen)} + ${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) : ''}
`); }; - const deviceRow = (d, { revoke, fresh } = {}) => html` + /** A paired device: what it is, when it was last here, and (on Devices) its menu. */ + const deviceRow = (d, { manage, fresh } = {}) => html`
-
${d.name}${fresh ? html`New` : ''}
${[d.status, d.ends].filter(Boolean).join(' · ')}
- ${revoke ? html`
` - : d.connected ? html`` : html``} +
${d.name}${d.connected ? html`` : ''}${fresh ? html`New` : ''}
${[d.status, d.ends].filter(Boolean).join(' · ')}
+ ${manage ? html`
` : ''}
`; const recentSection = (s, n) => { @@ -837,13 +853,18 @@ const overviewPage = (s, n) => { const r = M.readiness(s, ui); - const needs = M.needsYou(s, state.ports, 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`; return html` + ${pageHead('overview', { 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', @@ -966,7 +987,7 @@
Expires in
${TTLS.map(([value, label]) => html``)}
- + ${noUrl ? html`
${notice('warn', 'triangle-alert', 'Set the public URL first', 'A pairing link points at it: the address your phone or browser uses to reach this server.', urlButtons(s, 'tc-btn--sm', 'tc-choice'))}
` : ''} ${pair.error && !noUrl ? html`` : ''}
`; @@ -1007,7 +1028,7 @@ ${m.qr ? html`
Scan with the T3 Code app
` : ''}
-
Listening for the device… this page updates on its own.
+
Listening for the device… this page updates on its own.
`; }; @@ -1021,39 +1042,37 @@ ? (m.label ? '“' + m.label + '” · ' : '') + 'single use · expires ' + M.relTime(m.expiresAt, n) : null; return html` + ${pageHead('devices')} ${pageNotices('devices')} - ${section('pair-title', 'Pair a device', ceremony ? pairCeremony(m, s, n) : pairForm(s), { actions: sub ? muted(sub) : null })} + ${section('pair-title', 'Pair a device', ceremony ? pairCeremony(m, s, n) : pairForm(s), + { actions: ceremony ? html`${sub ? muted(sub) : ''}` : null })} ${section('paired-title', 'Paired devices', devices.length - ? html`
${devices.map((d) => deviceRow(d, { revoke: true, fresh: d.id === pair.freshSession }))}
` - : s.authLists?.session.available === false ? html`
- ${emptyIcon('smartphone')} - Device list is not available yet - Reading paired devices in the background. This page updates automatically. + ? html`
${devices.map((d) => deviceRow(d, { manage: true, fresh: d.id === pair.freshSession }))}
` + : s.authLists?.session.available === false ? html`
+ Reading paired devices…
` : html`
${emptyIcon('smartphone')} No devices paired yet Create a link above, then scan it with the phone you want to drive this server from. - or run t3-pair in the container -
`, - { actions: ceremony ? html`` : devices.length ? muted(String(devices.length)) : null })} +
`)} ${links.length ? section('links-title', 'Unused links', html`
${links.map((l) => html`
${l.name}${l.waiting ? html`Waiting` : ''}
${l.status}
-
+
`)}
`) : ''} - ${section('anywhere-title', 'From anywhere', connectCard(s))} - ${devices.length ? '' : notice(null, 'history', 'Sessions last 30 days', 'After that the device asks to pair again, which takes a few seconds here. Threads, projects and agent sign-ins are kept on the volume.')}`; + ${section('anywhere-title', 'From anywhere', connectCard(s))}`; }; - /** T3 Connect on Devices: where it stands, and the one thing to do next. */ + /** T3 Connect: where it stands, the one thing to do next, and a menu for the rest. */ const connectCard = (s) => { const view = M.connectView(s); + const menu = view.menu || []; return html`
T3 Connect${badge(view.badge)}
${view.text}
- ${view.action ? html`
` : ''} + ${view.action || menu.length ? html`
${view.action ? html`` : ''}${menu.length ? html`` : ''}
` : ''}
`; }; @@ -1061,7 +1080,7 @@ const updateCheckButton = (s, scope, phone = false) => { const view = M.updateCheckView(s, scope, ui.updatePosting || ui.updateAwaiting); const warning = !view.checking && view.failures.length > 0; - return html``; + return html``; }; const settleSoftwareCheck = (s) => { @@ -1129,64 +1148,62 @@ }); layer.panel.addEventListener('click', (e) => { if (e.target.closest('[data-error-close]')) layer.close('close'); }); }; + /** + * Update all, a fresh check and the automatic-update settings for one scope + * (`harness` or `toolchain`): labelled at a desk, icons on a phone. + */ + const updateControls = (s, n, scope) => { + const rows = scope === 'harness' ? M.agentRows(s, ui, n) : [...M.toolchainRows(s, ui, n), ...M.packageRows(s, ui, n)]; + const auto = M.autoUpdateView(s, n); + const line = scope === 'harness' ? auto.providersLine : auto.toolchainsLine; + const on = scope === 'harness' ? auto.policy.providers.enabled : auto.policy.toolchains.enabled; + const updateAll = rows.some((r) => r.updateAvailable) + ? html`` : ''; + return html`${updateAll}${updateCheckButton(s, scope)}${updateCheckButton(s, scope, true)}`; + }; + const agentsPage = (s, n) => { const rows = M.agentRows(s, ui, n); - const needs = rows.filter((r) => r.attention || r.updateAvailable); - const shown = state.agentFilter === 'attention' ? needs : rows; - const filter = html`${updateCheckButton(s, 'harness', true)}
${[['all', 'All'], ['attention', 'Needs you']].map(([value, label]) => html``)}
`; return html` + ${pageHead('agents', { actions: updateControls(s, n, 'harness') })} ${pageNotices('agents')} - ${section('ag-title', 'Agents', html`
- ${shown.length ? html`
${shown.map((row) => resourceRow(row))}
` - : rows.length ? html`
Nothing needs youNo known updates or sign-in issues.
` - : html`
Agent state is not readable right now.
`} -
${icon('info', 'tc-icon--sm')}Then turn each provider on in T3 Code under Settings → Providers. Muse Code requires Nightly.
-
`, { actions: filter })} - ${section('ag-how', 'How sign-in works', html`
-
Credentials
On the state volume at ${(s.paths && s.paths.volume) || '/home/t3'}. They survive a recreate.
-
Signed-in check
Asks each CLI when supported. Muse Code checks sign-in on the first message.
-
From a shell
t3-login claude·t3-harness list
-
`)}`; +
+ ${rows.length ? html`
${rows.map((row) => resourceRow(row))}
` + : html`
Couldn’t read the agents just now. The console tries again on its own.
`} +
${icon('info', 'tc-icon--sm')}Turn each agent on in T3 Code under Settings → Providers.
+
`; }; // ----------------------------------------------------------- toolchains -- - const INCLUDED = [ - ['terminal', 'Node and Python', 'with npm, pip'], - ['terminal', 'git, git-lfs, ssh', 'version control'], - ['wrench', 'clang, CMake, GDB', 'native builds'], - ['server', 'psql, redis-cli', 'databases'], - ['activity', 'ffmpeg, ImageMagick', 'media'], - ['globe', 'Headless Chromium', 'browser image', 'browser'], - ]; + // What every image already has, said once under the lists rather than as a + // section of its own: none of it is managed here. + const IN_THE_IMAGE = ['Node', 'Python', 'git', 'clang', 'CMake', 'psql', 'redis-cli', 'ffmpeg', 'ImageMagick']; const toolchainsPage = (s, n) => { const rows = M.toolchainRows(s, ui, n); const added = M.packageRows(s, ui, n); - const paths = s.paths || {}; const browserImage = (s.image && s.image.variant === 'browser') || Boolean(s.browser); - const size = Number.isFinite(paths.toolchainsBytes) ? M.formatBytes(paths.toolchainsBytes) + (paths.toolchains ? ' in ' + paths.toolchains : '') : null; // A filter once the list is long enough to need one; it matches the name, // the commands a tool provides and its description. const filterable = added.length > 6; const q = filterable ? state.toolFilter.trim().toLowerCase() : ''; const shown = q ? added.filter((r) => [r.name, r.id, ...(r.bins || []), r.description || ''].join(' ').toLowerCase().includes(q)) : added; - const addButton = html``; + const addButton = html``; const filter = filterable ? html`
${icon('search')}
` : ''; return html` + ${pageHead('toolchains', { actions: updateControls(s, n, 'toolchain') })} ${pageNotices('toolchains')} - ${section('tc-title', 'Toolchains', html`
+ ${section('tc-title', 'Default toolchains', html`
${rows.length ? html`
${rows.map((row) => resourceRow(row))}
` - : html`
Toolchain state is not readable right now.
`} + : html`
Couldn’t read the toolchains just now. The console tries again on its own.
`}
${icon('info', 'tc-icon--sm')}Available in every directory. A project that pins a version in mise.toml or .tool-versions gets that one instead.
-
`, { actions: html`${updateCheckButton(s, 'toolchain', true)}${size ? muted(size) : ''}` })} +
`)} ${section('pk-title', 'Added tools', html`
${shown.length ? html`
${shown.map((row) => resourceRow(row))}
` : added.length ? html`
No added tool matches “${state.toolFilter.trim()}”.
` : toolsEmpty()} -
${icon('terminal', 'tc-icon--sm')}Anything in mise’s registry, or a backend spec like npm:prettier. Tools added with mise use -g in a terminal show up here too.
+ ${added.length ? html`
${icon('info', 'tc-icon--sm')}Tools added with mise use -g in a terminal show up here too.
` : ''}
`, { actions: html`${filter}${addButton}` })} - ${section('img-title', 'In the image', html`
${INCLUDED.filter((i) => !i[3] || browserImage).map(([ic, what, note]) => html`
${icon(ic)}${what}${note}
`)}
`, - { actions: muted('Updated by pulling a new image') })} - ${notice(null, 'shield-check', 'Exact versions stay pinned', html`mise records exact versions, for added tools too, and never falls back to another tool on PATH. Updates happen only when you press Update, run mise use, or opt in to automatic updates.`)}`; +

Also in the image: ${M.listOf([...IN_THE_IMAGE, ...(browserImage ? ['headless Chromium'] : [])])}.

`; }; // A first look at what can be added: one press opens the sheet on that tool. @@ -1194,7 +1211,7 @@ const toolsEmpty = () => html`
${emptyIcon('plus')} Add any tool mise can install - Cloud CLIs, other languages, linters: about a thousand tools, each pinned to an exact version like the toolchains above. + Cloud CLIs, other languages, linters: about a thousand tools, each pinned to an exact version like the toolchains above. Or name one with a backend, like npm:prettier.
${QUICK_ADD.map((name) => html``)}
`; @@ -1204,27 +1221,22 @@ // provider apart from the CLI's name, and who it is signed in as. const sourceControlPage = (s, n) => { const rows = M.sourceControlRows(s, ui, n); - const withProvider = (row) => Object.assign({}, row, { - status: Object.assign({}, row.status, { text: [row.provider, row.status.text].filter(Boolean).join(' · ') }), - }); + const updates = M.managedSourceControlRows(s, ui, n).some((r) => r.updateAvailable); return html` + ${pageHead('sourcecontrol', { actions: updates ? html`` : '' })} ${pageNotices('sourcecontrol')} - ${section('sc-title', 'Source control', html`
- ${rows.length ? html`
${rows.map((row) => resourceRow(withProvider(row)))}
` - : html`
Source control state is not readable right now.
`} -
${icon('info', 'tc-icon--sm')}T3 Code opens pull requests through these. Bitbucket needs none: add its token in T3 Code under Settings → Source Control.
-
`)} - ${section('sc-how', 'How sign-in works', html`
-
Device code
GitHub and Azure DevOps: approve a code on github.com or Microsoft’s page, from any device. There is no token to make.
-
Tokens
GitLab, Forgejo, Gitea, and GitHub Enterprise: a token you make on the server. The CLI gets it on stdin or in its environment, never on a command line, and it only counts once the server accepts it.
-
Credentials
On the state volume at ${(s.paths && s.paths.volume) || '/home/t3'}. They survive a recreate.
-
From a shell
t3-harness source-control
-
`)}`; +
+ ${rows.length ? html`
${rows.map((row) => resourceRow(row))}
` + : html`
Couldn’t read the source control CLIs just now. The console tries again on its own.
`} +
${icon('info', 'tc-icon--sm')}Bitbucket needs no CLI: add its token in T3 Code under Settings → Source Control.
+
`; }; // ---------------------------------------------------------------- ports -- const portRow = (p) => { - const tileEl = html``; + // The number is the row's name; the tile says whether it is out on the internet. + // A link or an error needs the row's width, so on a phone its buttons go under it. + const tileEl = html``; const proc = p.process ? html`${p.process}` : null; const where = p.address && p.address !== '0.0.0.0' && p.address !== '*' && p.address !== '::' ? html`${p.address}` : null; const joinParts = (parts) => parts.filter(Boolean).map((x, i) => i ? html` · ${x}` : x); @@ -1235,57 +1247,53 @@ if (p.state === 'open') { title = html`${title}Published`; status = html`${p.url}`; - actions = html` - ${copyButton(p.url, 'Copy URL for port ' + p.port, { iconOnly: true, ghost: false, size: 'tc-btn--sm' })} - `; - // On a phone the QR and the URL are the row; on a desktop, one press away. - panel = html`
+ // The link is what a published port is for: one press copies it, and + // the QR code, opening it and stopping it are in its menu. + actions = html`${copyButton(p.url, 'Copy link', { ghost: false, size: 'tc-btn--sm' })}`; + // The QR code, for another device, once asked for. + panel = p.expanded ? html`
${p.qr ? html`` : ''} -
Scan to open port ${p.port} on another device.${copyField(p.url.replace(/^https:\/\//, ''), { actions: copyButton(p.url, 'Copy URL for port ' + p.port, { iconOnly: true, size: 'tc-btn--sm' }) })}
-
`; +
Scan to open port ${p.port} on another device.
+
` : ''; } else if (p.state === 'starting') { title = html`${title}Publishing`; status = html`${joinParts([proc, 'waiting for a public hostname…'])}${progressBar(null)}`; - actions = html``; + actions = html`${menuSlot()}`; } else if (p.state === 'failed') { title = html`${title}Failed`; status = statusLine({ dot: 'danger', text: p.error || 'The tunnel failed' }); - actions = html``; + actions = html`${menuSlot()}`; } else { status = p.db ? html`${dot('warn')}${joinParts([proc, 'looks like a database, so Publish asks first'])}` : html`${proc || where ? joinParts([proc, where]) : p.listening ? 'Listening in the container' : 'Not listening right now'}`; - actions = html``; + actions = html`${menuSlot()}`; } return html` -
+
${tileEl}
${title}
${status}
-
${actions}
+
${actions}
${panel}
`; }; const portsPage = (s, n) => { const ports = state.ports; - if (!ports) return html`${pageNotices('ports')}${skeletonGroup(3)}`; + const head = pageHead('ports'); + if (!ports) return html`${head}${pageNotices('ports')}${skeletonGroup(3)}`; if (ports.available === false) { - return html`${pageNotices('ports')}${section('pl-title', 'Listening in the container', html`
Publishing is unavailable in this buildcloudflared is not in this image, so ports cannot get a public URL from here.
`)}`; + return html`${head}${pageNotices('ports')}
Publishing isn’t available in this imageIt has no cloudflared, so a port can’t get a public address from here.
`; } const rows = M.portRows(ports, ui, n); return html` + ${head} ${pageNotices('ports')} - ${section('pl-title', 'Listening in the container', html`
+ ${section('pl-title', 'Listening in this container', html`
${rows.length ? html`
${rows.map(portRow)}
` - : html`
${emptyIcon('ethernet-port')}Nothing is listening yetStart a dev server in a T3 Code terminal and it appears here within a few seconds.
`} -
${icon('terminal', 'tc-icon--sm')}From a terminal: t3-expose 5173 publishes, t3-expose stop 3000 stops.
-
`, { actions: muted('Checks every few seconds') })} - ${section('pl-how', 'How publishing works', html`
-
Tunnel
A Cloudflare quick tunnel. No account, no DNS record, no certificate.
-
Address
A random trycloudflare.com hostname with HTTPS, new each time you publish.
-
Bound to 127.0.0.1
Included. Most dev servers listen on loopback, and those are the ones that need this most.
-
`)} - ${notice('warn', 'triangle-alert', 'A published URL is public', 'It is random and stops working the moment you stop it, but anyone holding it can reach that port. Publish a dev server, not your database.')}`; + : html`
${emptyIcon('ethernet-port')}Nothing is listening yetStart a dev server in a T3 Code terminal and it shows up here within a few seconds.
`} +
${icon('info', 'tc-icon--sm')}A published address is public: anyone who has it can open that port. Stop it when you’re done.
+
`)}`; }; const publishPort = async (port) => { @@ -1338,7 +1346,7 @@ const keyRow = (source) => { const view = KEY_STATES[source] || KEY_STATES.env; return html`
-
Setup key${view.badge}
+
Setup key${view.badge ? html`${view.badge}` : ''}
${view.text} ${keyView.value ? html`
${copyField(keyView.value, { label: 'Copy', wrap: true })}
` : ''}
@@ -1391,6 +1399,7 @@ const legacy = s.legacyEnv || []; const legacyVars = legacy.filter((name) => name !== 'PATH'); return html` + ${pageHead('environment', { actions: html`` })} ${pageNotices('environment')} ${legacy.length ? notice('warn', 'triangle-alert', 'Settings left over from an older image', html`This container’s environment still has ${legacyVars.map((name, i) => html`${i ? (i === legacyVars.length - 1 ? ' and ' : ', ') : ''}${name}`)}${legacy.includes('PATH') ? html`${legacyVars.length ? ' and ' : ''}the old PATH` : ''}, from an image that shipped its own Go, Rust, Bun and Deno. Everything this image starts ignores them. Remove them from the container’s settings so docker exec stops seeing them too; the image sets its own PATH.`) : ''} @@ -1423,7 +1432,7 @@
Headless Chromium
${s.browser.chromium ? html`${dot('ok')}Ready` : html`${dot('warn')}Missing`}
${s.browser.playwrightMcp ? html`
Playwright MCP
${s.browser.playwrightMcp}
` : ''} ${s.browser.devtoolsMcp ? html`
Chrome DevTools MCP
${s.browser.devtoolsMcp}
` : ''} -
`, { actions: muted('Wired into Claude Code, Codex and OpenCode') }) : ''} +
${icon('info', 'tc-icon--sm')}Claude Code, Codex and OpenCode are set up to use these.
`) : ''} ${section('ev-console', 'This console', html`
${keySource ? keyRow(keySource) : ''} ${viaT3(s) @@ -1445,12 +1454,13 @@ const scmAttention = scm.filter((c) => c.attention).length; const scmSigned = scm.filter((c) => c.auth && c.auth.status === 'authenticated').length; return html` - ${section('pm-title', 'More', html``)} ${section('pm-app', 'Appearance', html`
Theme${EMBED ? html`Follows T3 Code` : themeSeg()}
`)} ${section('pm-srv', 'This server', html`
@@ -1478,10 +1488,11 @@ ${[['shared', 'Shared'], ['separate', 'Separate'], ['none', 'None']].map(([mode, label]) => html``)}
-
+
${menuSlot()}
`; const contextPage = (s) => html` + ${pageHead('context')} ${pageNotices('context')} ${section('cx-agentsmd', 'Instructions', html`
${resourceRow(M.contextAgentsMdRow(s))}${M.contextProviderRows(s).map(contextProviderRow)}
@@ -1615,111 +1626,69 @@ }; // ---------------------------------------------------------------- shell -- - const ROUTES = ['overview', 'devices', 'agents', 'context', 'toolchains', 'sourcecontrol', 'ports', 'environment', 'more']; - const TITLES = { overview: 'Overview', devices: 'Devices', agents: 'Agents', context: 'Context', toolchains: 'Toolchains', sourcecontrol: 'Source control', ports: 'Ports', environment: 'Environment', more: 'More' }; const PAGES = { overview: overviewPage, devices: devicesPage, agents: agentsPage, context: contextPage, toolchains: toolchainsPage, sourcecontrol: sourceControlPage, ports: portsPage, environment: environmentPage, more: morePage }; - // On a phone, Context, Toolchains, Source control and Environment live under More. - const TAB_OF = { context: 'more', toolchains: 'more', sourcecontrol: 'more', environment: 'more' }; + const ROUTES = Object.keys(PAGES); + const titleOf = (route) => (T3Routes.page(route) || {}).title || route; - const navBadge = (b) => { - if (!b) return { cls: 'tc-nav-count', text: '', hidden: true }; - if (b.tone) return { cls: 'tc-badge tc-badge--' + b.tone + ' tc-badge--count', text: String(b.count), hidden: !b.count }; - return { cls: 'tc-nav-count', text: String(b.count ?? ''), hidden: b.count === undefined }; + /** A sidebar mark: a count of what needs the person, or a quiet sign of work or of something live. */ + const NAV_MARK = { + warn: (b) => ({ cls: 'tc-nav-badge tc-badge tc-badge--warn tc-badge--count', text: String(b.count), title: M.plural(b.count, 'thing needs', 'things need') + ' you' }), + running: (b) => ({ cls: 'tc-nav-badge tc-nav-mark tc-nav-mark--running', text: '', title: M.plural(b.count, 'install') + ' running' }), + live: (b) => ({ cls: 'tc-nav-badge tc-nav-mark tc-nav-mark--live', text: '', title: M.plural(b.count, 'port') + ' published' }), }; + const navMark = (b) => (!b ? null : b.tone === 'warn' ? NAV_MARK.warn(b) : NAV_MARK[b.kind] ? NAV_MARK[b.kind](b) : null); const renderShell = (s, n) => { const badges = M.navBadges(s, state.ports, ui); for (const route of ROUTES) { const el = $('nav-' + route); + const mark = navMark(badges[route]); if (el) { - const b = navBadge(badges[route]); - if (el.className !== b.cls) el.className = b.cls; - if (el.textContent !== b.text) el.textContent = b.text; - el.hidden = b.hidden; - const tone = badges[route] && badges[route].tone; - el.title = tone === 'warn' ? b.text + ' need you' : tone === 'info' ? b.text + ' active' : ''; + const cls = mark ? mark.cls : 'tc-nav-badge'; + if (el.className !== cls) el.className = cls; + if (el.textContent !== (mark ? mark.text : '')) el.textContent = mark ? mark.text : ''; + el.hidden = !mark; + // Said with the link's name, so a screen reader hears "Agents, 2 things need you". + if (mark) el.setAttribute('aria-label', mark.title); else el.removeAttribute('aria-label'); + el.title = mark ? mark.title : ''; } const tab = $('tab-' + route); - if (tab) tab.hidden = !(badges[route] && badges[route].tone === 'warn' && badges[route].count); + if (tab) tab.hidden = !(badges[route] && badges[route].tone === 'warn'); } const server = s.server || {}; const image = s.image || {}; patch($('server-card'), html`
${dot(server.ok ? 'ok' : 'danger', server.ok && 'tc-dot--live')}${server.ok ? 'Running' : 'Not answering'}
- ${server.version ? html`
${server.version}
` : ''} -
${M.imageLabel(image).text || 'unversioned build'}
`); +
${M.imageLabel(image).text || 'unversioned build'}
`); patch($('phone-status'), html`${dot(server.ok ? 'ok' : 'danger', server.ok && 'tc-dot--live')}${server.ok ? 'Running' : 'Down'}`); for (const link of document.querySelectorAll('[data-open-t3]')) if (link.getAttribute('href') !== t3Url()) link.setAttribute('href', t3Url()); for (const link of document.querySelectorAll('[data-open-tab]')) if (link.getAttribute('href') !== BASE + '/' + location.hash) link.setAttribute('href', BASE + '/' + location.hash); - - // The banner: what the first start is still installing. - const banner = M.setupBanner(s); - const bannerEl = $('banner'); - bannerEl.hidden = !banner; - if (banner) { - patch(bannerEl, html`First start · ${banner.text}${progressBar({ pct: banner.pct })}${banner.done}/${banner.total}`); - } - }; - - const renderTopbar = (s, n) => { - const route = state.route; - const summary = s ? M.summaries(s, state.ports, ui, n, location.host)[route] : null; - const crumb = $('crumb'); - if (crumb.textContent !== TITLES[route]) crumb.textContent = TITLES[route]; - const sum = $('summary'); - if (summary) { - if (sum.textContent !== summary.text) sum.textContent = summary.text; - sum.classList.toggle('tc-mono', Boolean(summary.mono)); - } else if (sum.textContent) sum.textContent = ''; - let actions = ''; - if (s) { - if (route === 'overview') { - actions = M.readiness(s, ui).ready - ? html`` - : EMBED ? '' : html`${icon('external-link')}Open T3 Code`; - } else if (route === 'agents' || route === 'toolchains') { - const rows = route === 'agents' ? M.agentRows(s, ui, n) : [...M.toolchainRows(s, ui, n), ...M.packageRows(s, ui, n)]; - const hasUpdates = rows.some((r) => r.updateAvailable); - if (route === 'agents') { - const auto = M.autoUpdateView(s, n); - actions = html`${hasUpdates ? html`` : ''}${updateCheckButton(s, 'harness')}`; - } - else { - const auto = M.autoUpdateView(s, n); - actions = html`${hasUpdates ? html`` : ''}${updateCheckButton(s, 'toolchain')}`; - } - } else if (route === 'sourcecontrol') { - if (M.managedSourceControlRows(s, ui, n).some((r) => r.updateAvailable)) actions = html``; - } else if (route === 'ports') { - actions = html`Same list as t3-expose`; - } else if (route === 'environment') { - actions = html``; - } - } - patch($('page-actions'), actions); }; const render = () => { const s = state.status; const n = now(); if (s) renderShell(s, n); - renderTopbar(s, n); const route = state.route; const el = $('page-' + route); if (!el) return; if (!s) { // Still waiting for the first answer: keep the skeleton, unless the // answer was an error worth showing. - if (state.statusError) patch(el, html`${pageNotices(route)}${skeletonGroup(3)}`); + if (state.statusError) patch(el, html`${pageHead(route)}${pageNotices(route)}${skeletonGroup(3)}`); return; } patch(el, PAGES[route](s, n)); }; // --------------------------------------------------------------- routes -- + // #agents, or an address a page had before it moved (#devices once it is + // Access), which is rewritten in place so the address bar says where it is. const routeFromHash = () => { const name = location.hash.replace(/^#\/?/, '').split(/[/?]/)[0]; - return ROUTES.includes(name) ? name : 'overview'; + const route = T3Routes.resolve(name); + if (route && route !== name) history.replaceState(null, '', '#' + route); + return route && PAGES[route] ? route : 'overview'; }; const go = (route) => { if (location.hash === '#' + route) show(route, true); @@ -1733,14 +1702,14 @@ for (const page of document.querySelectorAll('.tc-page[data-route]')) page.hidden = page.getAttribute('data-route') !== route; for (const link of document.querySelectorAll('[data-nav]')) { const here = link.getAttribute('data-nav') === route - || (link.closest('.tc-tabbar') && link.getAttribute('data-nav') === TAB_OF[route]); + || (link.closest('.tc-tabbar') && link.getAttribute('data-nav') === T3Routes.tabOf(route)); if (here) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current'); } render(); if (changed && !first) { window.scrollTo(0, 0); - Kit.announce(TITLES[route]); + Kit.announce(titleOf(route)); if (focus) $('main').focus({ preventScroll: true }); } first = false; @@ -3495,12 +3464,41 @@ if (input) input.focus(); }, 'pair.ttl': (a) => { pair.ttl = a.value; render(); }, + 'device.menu': (a, el) => { + const d = M.deviceRows((state.status || {}).sessions, now()).find((row) => row.id === a.id); + if (!d) return; + Kit.menu(el, { + label: d.name + ' actions', + head: { title: d.name, sub: d.status, tile: html`` }, + items: [ + { label: 'Rename…', icon: 'pencil', run: () => openDeviceRename(d.id, el) }, + { sep: true }, + { label: 'Revoke…', icon: 'log-out', danger: true, run: () => confirmRevoke('session', d.id) }, + ], + }); + }, + 'link.menu': (a, el) => { + const l = M.linkRows((state.status || {}).pairings, null, now()).find((row) => row.id === a.id); + if (!l) return; + Kit.menu(el, { + label: 'Link ' + l.name + ' actions', + head: { title: l.name, sub: l.status, tile: html`` }, + items: [{ label: 'Revoke link…', icon: 'trash-2', danger: true, run: () => confirmRevoke('pairing', l.id) }], + }); + }, + 'connect.menu': (a, el) => { + const view = M.connectView(state.status); + if (!(view.menu || []).length) return; + Kit.menu(el, { + label: 'T3 Connect actions', + head: { title: 'T3 Connect', sub: view.badge ? view.badge.text : null, tile: html`` }, + items: view.menu.map((m) => m.sep ? m : Object.assign({}, m, { run: () => run(m.cmd, m.args || {}, el) })), + }); + }, 'device.revoke': (a) => confirmRevoke('session', a.id), 'device.rename': (a, el) => openDeviceRename(a.id, el), 'link.revoke': (a) => confirmRevoke('pairing', a.id), - 'agents.filter': (a) => { state.agentFilter = a.value; render(); }, - 'context.edit': (a, el) => openContextEditor(el), 'context.delete': async () => { const view = M.contextView(state.status); @@ -3557,6 +3555,21 @@ } }, + 'port.menu': (a, el) => { + const port = Number(a.id); + const p = M.portRows(state.ports, ui, now()).find((row) => row.port === port); + if (!p || p.state !== 'open') return; + Kit.menu(el, { + label: 'Port ' + port + ' actions', + head: { title: 'Port ' + port, sub: M.hostOf(p.url), tile: html`` }, + items: [ + ...(Kit.isPhone() ? [] : [{ label: p.expanded ? 'Hide the QR code' : 'Show a QR code', icon: 'qr-code', run: () => run('port.qr', { id: String(port) }, el) }]), + { label: 'Open the link', icon: 'external-link', run: () => window.open(p.url, '_blank', 'noopener') }, + { sep: true }, + { label: 'Stop publishing', icon: 'circle-stop', run: () => stopPort(port) }, + ], + }); + }, 'port.publish': (a) => publishPort(Number(a.id)), 'port.stop': (a) => stopPort(Number(a.id)), 'port.qr': (a) => { @@ -3628,8 +3641,8 @@ // ------------------------------------------------------------ shortcuts -- Kit.bind('mod+k', openPalette); Kit.bind('/', openPalette); - for (const [key, route] of [['o', 'overview'], ['d', 'devices'], ['a', 'agents'], ['c', 'context'], ['t', 'toolchains'], ['s', 'sourcecontrol'], ['p', 'ports'], ['e', 'environment']]) { - Kit.bind('g ' + key, () => { keyboardNav = true; go(route); }); + for (const page of T3Routes.PAGES) { + if (page.key) Kit.bind('g ' + page.key, () => { keyboardNav = true; go(page.route); }); } Kit.bind('p', () => { if (pair.showForm || !pair.minted || pair.minted.outcome) run('pair.start'); diff --git a/docker/setup/client/model.js b/docker/setup/client/model.js index d816adb..6ede1b6 100644 --- a/docker/setup/client/model.js +++ b/docker/setup/client/model.js @@ -287,15 +287,9 @@ const T3Model = (() => { notice && !(status && status.text && String(status.text).includes(notice.text)) ? notice : undefined; // ---------------------------------------------------------------- agents -- - const methodText = (h) => { - const meta = AGENTS[h.id] || {}; - switch (h.authMethod) { - case 'env': return 'key in the environment'; - case 'apikey': return 'stored API key'; - case 'oauth': return meta.how || 'browser sign-in'; - default: return meta.how || 'browser sign-in'; - } - }; + /** "Signed in", or how, when it is not the usual sign-in: an API key. */ + const signedInAs = (h) => (h.authMethod === 'env' ? 'Signed in with a key from the environment' + : h.authMethod === 'apikey' ? 'Signed in with an API key' : 'Signed in'); /** * One agent row. `ui` carries the page's own bookkeeping (operations it is @@ -409,25 +403,21 @@ const T3Model = (() => { } else if (h.signedIn === false) { row.state = 'signin'; row.attention = true; - row.status = { dot: 'warn', text: 'Not signed in · ' + (meta.how || 'sign-in') }; + row.status = { dot: 'warn', text: isKey ? 'No provider key yet' : 'Not signed in' }; row.action = isKey ? { cmd: 'harness.apikey', label: 'Add key', icon: 'key-round', variant: 'primary' } : { cmd: 'harness.signin', label: 'Sign in', icon: 'log-in', variant: 'primary' }; } else if (h.signedIn !== true) { row.state = 'unknown'; row.status.text = h.id === 'muse' && h.credentialsPresent - ? 'Credentials saved · sign-in checked on the first message' : 'Sign-in state not readable'; + ? 'Credentials saved, checked when you first use it' : 'Couldn’t check its sign-in'; if (h.id === 'muse' && updateAvailable) row.action = { cmd: 'harness.update', label: 'Update' }; } else { row.state = updateAvailable ? 'update' : 'ok'; - if (isKey) { - const names = (ui && ui.providerNames) || []; - row.status.text = names.length - ? plural(names.length, 'saved provider') + ' · ' + names.join(', ') - : 'Signed in · ' + methodText(h); - } else { - row.status.text = 'Signed in · ' + (updateAvailable ? latest + ' is available' : methodText(h)); - } + // The version beside the name says when there is a newer one; the + // status says only how it is signed in. + const names = (ui && ui.providerNames) || []; + row.status.text = isKey && names.length ? (names.length === 1 ? 'Key saved for ' : 'Keys saved for ') + listOf(names) : signedInAs(h); if (updateAvailable) row.action = { cmd: 'harness.update', label: 'Update' }; else if (isKey) row.action = { cmd: 'harness.apikey', label: 'Add key', icon: 'key-round' }; } @@ -464,7 +454,7 @@ const T3Model = (() => { row.action = { cmd: 'harness.install', label: 'Retry', icon: 'refresh-cw', variant: 'warning' }; } else { row.state = 'missing'; - row.status.text = 'Not installed · ' + (h.downloadBytes ? formatBytes(h.downloadBytes) + ', ' : '') + 'installed by T3 Code'; + row.status.text = 'Not installed' + (h.downloadBytes ? ' · a ' + formatBytes(h.downloadBytes) + ' download, through T3 Code' : ' · T3 Code installs it'); row.action = { cmd: 'harness.install', label: 'Install', icon: 'download' }; } row.notice = distinctNotice(notice, row.status); @@ -483,15 +473,15 @@ const T3Model = (() => { } else if (h.signedIn === false) { row.state = 'signin'; row.attention = true; - row.status = { dot: 'warn', text: 'Not signed in · ' + meta.how }; + row.status = { dot: 'warn', text: 'Not signed in' }; row.action = Object.assign({ variant: 'primary' }, signIn); } else if (updateAvailable) { row.state = 'update'; - row.status.text = (h.signedIn ? 'Signed in' : 'Installed') + ' · ' + latest + ' is available'; + row.status.text = h.signedIn ? (h.account ? 'Signed in as ' + h.account : 'Signed in') : 'Installed'; row.action = { cmd: 'harness.update', label: 'Update' }; } else if (h.signedIn === true) { row.state = 'ok'; - row.status.text = 'Signed in · ' + (h.account || 'Google account'); + row.status.text = h.account ? 'Signed in as ' + h.account : 'Signed in'; } else { // T3 checks the Google account when Antigravity is used, so "not // checked" is not "signed out": offer the sign-in, do not ask for it. @@ -574,6 +564,8 @@ const T3Model = (() => { dim: false, description: isPackage ? t.description || null : null, bins: isPackage ? t.bins || [] : [], + // An added tool has no mark of its own; one icon reads better than "No" or "Yt". + icon: isPackage ? 'terminal' : null, }; if (runningKind) { @@ -627,10 +619,12 @@ const T3Model = (() => { row.action = { cmd: cmd(t.operation === 'uninstall' ? 'uninstall' : 'update'), label: 'Retry', icon: 'refresh-cw', variant: 'warning' }; } else if (updateAvailable) { row.state = 'update'; - row.status.text = (meta.detail ? meta.detail + ' · ' : 'Installed · ') + latest + ' is available'; + row.status.text = meta.detail || 'Installed'; row.action = { cmd: cmd('update'), label: 'Update' }; } else { - row.status.text = [meta.detail || 'Installed', follows ? 'follows ' + follows : held ? held.text : status.updates?.errors?.[target + ':' + t.id] ? 'Update check failed' : latest ? 'up to date' : null].filter(Boolean).join(' · '); + const plain = follows ? 'follows ' + follows : held ? held.text : status.updates?.errors?.[target + ':' + t.id] ? 'couldn’t check for updates' : latest ? 'up to date' : null; + row.status.text = meta.detail ? [meta.detail, plain].filter(Boolean).join(' · ') + : plain === 'up to date' ? 'Up to date' : ['Installed', plain].filter(Boolean).join(' · '); } row.held = held; if (updateAvailable) row.menu.push({ cmd: cmd('update'), label: 'Update to ' + latest, icon: 'circle-arrow-up' }); @@ -713,7 +707,7 @@ const T3Model = (() => { row.status = { dot: 'warn', text: 'Missing the ' + listOf(missing) + ' extension' }; row.action = { cmd: 'toolchain.update', label: 'Repair', icon: 'wrench', variant: 'warning' }; } else if (settled && auth && auth.status === 'authenticated') { - row.status = { dot: null, text: [signedInText(auth), row.updateAvailable ? row.latest + ' is available' : null].filter(Boolean).join(' · ') }; + row.status = { dot: null, text: signedInText(auth) }; } else if (settled && auth && auth.status === 'unauthenticated') { row.state = 'signin'; row.attention = true; @@ -723,7 +717,7 @@ const T3Model = (() => { } else if (settled && auth && auth.status === 'unknown') { // What T3 Code cannot be sure of comes first; an update after it, as // for a signed-in row. - row.status = { dot: null, text: [capital(uncheckedText(auth)), row.updateAvailable ? row.latest + ' is available' : null].filter(Boolean).join(' · ') }; + row.status = { dot: null, text: capital(uncheckedText(auth)) }; } } const usable = Boolean(c.installed || added) && !c.inProgress && row.state !== 'running' && row.state !== 'queued'; @@ -902,7 +896,9 @@ const T3Model = (() => { */ const heldRelease = (t, version, status, now) => { const newest = t && t.newestVersion; - if (!newest || !version || !isNewer(newest, version)) return null; + // mise can name a channel ("stable" for Rust) where a release would be; + // a word is no release to wait for. + if (!newest || !version || !parseVersion(newest).numeric || !isNewer(newest, version)) return null; if (t.latestVersion && !isNewer(newest, t.latestVersion)) return null; const releasedAt = toMs(t.newestReleasedAt); const age = status && Number.isFinite(status.releaseAgeMs) ? status.releaseAgeMs : null; @@ -911,7 +907,8 @@ const T3Model = (() => { version: newest, releasedAt, availableAt, - text: newest + ' is out · ' + (availableAt !== null && availableAt > now ? 'mise offers it ' + relTime(availableAt, now) : 'mise offers it once it has been out a day'), + // mise waits a day before it offers a release; say when, not why. + text: newest + ' available ' + (availableAt !== null && availableAt > now ? relTime(availableAt, now) : 'once it’s a day old'), }; }; @@ -1070,13 +1067,15 @@ const T3Model = (() => { const connectView = (s) => { const c = s && s.connect; const restartable = Boolean(s && s.t3 && s.t3.pid); - if (!c) return { state: 'unknown', badge: null, text: 'Its state could not be read just now.', action: null }; + if (!c) return { state: 'unknown', badge: null, text: 'Couldn’t read its state just now.', action: null }; if (c.state === 'on') { return { state: 'on', badge: { tone: 'ok', text: 'On' }, text: 'Devices signed in to your T3 account reach this server through ' + (c.relayHost || 'T3’s relay') + '.', - action: { cmd: 'connect.off', label: 'Turn off' }, + action: null, + // Turning it off cuts devices off: a menu item and a confirmation away. + menu: [{ cmd: 'connect.off', label: 'Turn off T3 Connect…', icon: 'log-out', danger: true }], }; } if (c.state === 'pending' && c.startedSinceLink === true) { @@ -1145,8 +1144,13 @@ const T3Model = (() => { const readiness = (status, ui) => { const s = status || {}; const agents = agentRows(s, ui, 0); - const installed = agents.filter((a) => !['missing', 'queued', 'unsupported'].includes(a.state) && a.state !== 'running'); const notSigned = agents.filter((a) => a.state === 'signin'); + // Installed and able to work: signed in, or as far as this page can tell + // (credentials saved that the agent checks on first use, or a Google + // sign-in T3 Code checks when Antigravity runs). + const saved = new Set((s.harnesses || []).filter((h) => h.credentialsPresent).map((h) => h.id)); + const ready = agents.filter((a) => ['ok', 'update', 'unchecked'].includes(a.state) || (a.state === 'unknown' && saved.has(a.id))); + const unreadable = agents.filter((a) => a.state === 'unknown' && !saved.has(a.id)); const anyAgent = (s.harnesses || []).some((h) => h.installed); const setupRunning = Boolean(s.setup && s.setup.state === 'running'); const server = s.server || {}; @@ -1154,7 +1158,7 @@ const T3Model = (() => { const urlStep = () => { const here = hereOffer(s, ui); if (s.publicUrl) return { id: 'url', title: 'Public URL set', done: true, aside: publicUrlSource(s) }; - const step = { id: 'url', title: 'Public URL set', done: false }; + const step = { id: 'url', title: 'Set the public URL', done: false }; if (here && !here.local) { step.desc = 'Pairing links point at it. This page is open at ' + hostOf(here.url) + ', and T3 Code answers there too.'; step.action = { cmd: 'url.use', label: 'Use ' + hostOf(here.url), icon: 'check' }; @@ -1171,7 +1175,7 @@ const T3Model = (() => { const steps = [ { id: 'server', - title: 'Server running', + title: server.ok ? 'Server running' : 'Start the server', done: Boolean(server.ok), aside: server.ok ? [server.version, s.t3 && s.t3.port ? 'port ' + s.t3.port : null].filter(Boolean).join(' · ') : null, desc: server.ok ? null : 'T3 Code is not answering yet' + (server.detail ? ' (' + server.detail + ')' : '') + '. It usually takes a few seconds after a start.', @@ -1180,22 +1184,30 @@ const T3Model = (() => { urlStep(s, ui), { id: 'pair', - title: s.authLists?.session.available === false ? 'Reading paired devices' : 'Pair a device', + title: s.authLists?.session.available === false ? 'Reading paired devices' : (s.sessions || []).length ? 'Device paired' : 'Pair a device', done: (s.sessions || []).length > 0, - desc: s.authLists?.session.available === false ? 'The device list is loading in the background. This page updates automatically.' : 'Scan the QR with the T3 Code app, or open the link in a browser.', - action: s.authLists?.session.available === false ? null : { cmd: 'pair.start', label: 'Create pairing link', icon: 'link' }, + desc: s.authLists?.session.available === false ? 'The device list is loading in the background. This page updates automatically.' + : !s.publicUrl ? 'A pairing link points at the public URL, so this comes after it.' + : 'Scan the QR with the T3 Code app, or open the link in a browser.', + // Before there is an address there is nothing for a link to point at. + action: s.authLists?.session.available === false || !s.publicUrl ? null : { cmd: 'pair.start', label: 'Create pairing link', icon: 'link' }, }, { id: 'agents', - title: 'Sign in your agents', - done: anyAgent && notSigned.length === 0 && installed.length > 0 && !agents.some((a) => a.state === 'unknown'), + title: notSigned.length === 0 && ready.length > 0 ? 'Agents signed in' : 'Sign in your agents', + // Done once an agent can work and none waits for a sign-in. One whose + // sign-in cannot be checked from here (Muse checks on first use) is + // not waiting on anyone, so it never holds the step open. + done: notSigned.length === 0 && ready.length > 0, desc: notSigned.length ? listOf(notSigned.map((a) => a.name)) + (notSigned.length === 1 ? ' is' : ' are') + ' installed but not signed in.' - : setupRunning ? 'They are still installing; each one can sign in as soon as it lands.' - : !anyAgent ? 'No agent is installed yet.' : 'Every installed agent reports its sign-in.', + : setupRunning && !ready.length ? 'They are still installing. Each one can sign in as soon as it lands.' + : !anyAgent ? 'No agent is installed yet.' + : unreadable.length ? 'Couldn’t check whether ' + listOf(unreadable.map((a) => a.name)) + (unreadable.length === 1 ? ' is' : ' are') + ' signed in.' + : 'Install an agent that can sign in from here.', action: notSigned.length - ? { cmd: 'goto', route: 'agents', label: 'Review ' + notSigned.length } - : !anyAgent && !setupRunning ? { cmd: 'goto', route: 'agents', label: 'Open agents' } : null, + ? { cmd: 'goto', route: 'agents', label: 'Sign in' } + : setupRunning && !anyAgent ? null : { cmd: 'goto', route: 'agents', label: 'Open agents' }, }, ]; let todo = -1; @@ -1334,16 +1346,17 @@ const T3Model = (() => { done, total: items.length, pct: items.length ? Math.round((done / items.length) * 100) : 0, - text: left.length ? 'installing ' + listOf(left.slice(0, 3)) + (left.length > 3 ? ' and ' + (left.length - 3) + ' more' : '') + ' in the background. Everything else is ready.' + text: left.length ? 'installing ' + (left.length > 3 ? left.slice(0, 3).join(', ') + ' and ' + (left.length - 3) + ' more' : listOf(left)) + ' in the background. Everything else is ready.' : 'finishing up.', }; }; // ------------------------------------------------------------ navigation -- /** - * Counts and badges for the sidebar and the tab bar. A count is mono and - * quiet; when items need the user it becomes a warn badge, and when some are - * live, an info badge. + * What the sidebar and the tab bar mark beside a page. Only what needs the + * person is counted (`warn`); work in progress and a port open to the + * internet get a quiet mark of their own (`running`, `live`) with no number. + * How many of something a page holds is the page's to say. */ const navBadges = (status, ports, ui) => { const s = status || {}; @@ -1351,45 +1364,56 @@ const T3Model = (() => { const tools = [...toolchainRows(s, ui, 0), ...packageRows(s, ui, 0)]; const scm = sourceControlRows(s, ui, 0); const portList = ports ? portRows(ports, ui, 0) : []; - const agentAttention = agents.filter((a) => a.attention).length; - const toolsRunning = tools.filter((t) => t.state === 'running').length; - const toolsFailed = tools.filter((t) => t.state === 'failed').length; - // A source control CLI someone installed and has not signed in is as - // unusable to T3 Code as a failed one. The image's gh never counts. - const scmAttention = scm.filter((c) => c.attention).length; - const scmRunning = scm.filter((c) => c.state === 'running' || c.state === 'signing').length; - const published = portList.filter((p) => p.state === 'open').length; - const portsFailed = portList.filter((p) => p.state === 'failed').length; - return { + const warn = (count) => (count ? { tone: 'warn', count } : null); + const running = (count) => (count ? { tone: 'info', kind: 'running', count } : null); + const live = (count) => (count ? { tone: 'info', kind: 'live', count } : null); + const busy = (rows) => rows.filter((r) => r.state === 'running' || r.state === 'signing').length; + const badges = { overview: null, - devices: { count: (s.sessions || []).length }, - agents: agentAttention ? { tone: 'warn', count: agentAttention } : { count: agents.filter((a) => a.version && a.state !== 'missing').length }, + devices: null, + agents: warn(agents.filter((a) => a.attention).length) || running(busy(agents)), context: null, - toolchains: toolsFailed ? { tone: 'warn', count: toolsFailed } - : toolsRunning ? { tone: 'info', count: toolsRunning } : { count: tools.filter((t) => t.version).length }, - sourcecontrol: scmAttention ? { tone: 'warn', count: scmAttention } - : scmRunning ? { tone: 'info', count: scmRunning } : { count: scm.filter((c) => c.version && c.state !== 'missing').length }, - ports: portsFailed ? { tone: 'warn', count: portsFailed } - : published ? { tone: 'info', count: published } : ports ? { count: portList.length } : null, - // Settings an older image left in the container's environment. - environment: ((s.legacyEnv || []).length ? 1 : 0) + (s.storage && s.storage.disk && s.storage.disk.usedPercent >= 80 ? 1 : 0) - ? { tone: 'warn', count: ((s.legacyEnv || []).length ? 1 : 0) + (s.storage && s.storage.disk && s.storage.disk.usedPercent >= 80 ? 1 : 0) } : null, - more: toolsFailed + scmAttention + ((s.legacyEnv || []).length ? 1 : 0) + (s.storage && s.storage.disk && s.storage.disk.usedPercent >= 80 ? 1 : 0) - ? { tone: 'warn', count: toolsFailed + scmAttention + ((s.legacyEnv || []).length ? 1 : 0) + (s.storage && s.storage.disk && s.storage.disk.usedPercent >= 80 ? 1 : 0) } : null, + toolchains: warn(tools.filter((t) => t.state === 'failed').length) || running(busy(tools)), + // A source control CLI someone installed and has not signed in is as + // unusable to T3 Code as a failed one. The image's gh never counts. + sourcecontrol: warn(scm.filter((c) => c.attention).length) || running(busy(scm)), + ports: warn(portList.filter((p) => p.state === 'failed').length) || live(portList.filter((p) => p.state === 'open').length), + environment: warn(serverWarnings(s).length), }; + // The phone's More tab carries what needs the person on the pages under it. + const tabOf = typeof T3Routes === 'object' ? T3Routes.tabOf : () => null; + badges.more = warn(Object.entries(badges).filter(([route, b]) => tabOf(route) === 'more' && b && b.tone === 'warn') + .reduce((n, [, b]) => n + b.count, 0)); + return badges; + }; + + /** + * What about the container itself needs the person: a home or state folder + * that the next recreate would leave behind, a disk filling up, and + * settings an older image left in its environment. + */ + const serverWarnings = (status) => { + const s = status || {}; + const paths = s.paths || {}; + const out = []; + if (paths.volumeKind === 'anonymous' || paths.volumeKind === 'none') out.push('home'); + if (paths.stateKind === 'anonymous' || paths.stateKind === 'none') out.push('state'); + if (s.storage && s.storage.disk && s.storage.disk.usedPercent >= 80) out.push('disk'); + if ((s.legacyEnv || []).length) out.push('legacy'); + return out; }; /** * How many things need the person, counted as the sidebar's warn badges * count them: agents signed out or failing, tools that failed, ports that - * would not publish, settings an older image left behind. Updates are not + * would not publish, and what is wrong with the container. Updates are not * counted; they are offered, not owed, and some agent has one most days. * T3 Code's settings show this beside Setup. */ const attentionCount = (status, ports, ui) => { const badges = navBadges(status, ports, ui); - return ['agents', 'toolchains', 'sourcecontrol', 'ports', 'environment'] - .reduce((n, route) => n + (badges[route] && badges[route].tone === 'warn' ? badges[route].count : 0), 0); + return Object.entries(badges).filter(([route]) => route !== 'more') + .reduce((n, [, b]) => n + (b && b.tone === 'warn' ? b.count : 0), 0); }; // -------------------------------------------------------------- summary -- @@ -1443,6 +1467,10 @@ const T3Model = (() => { diagnostics: 'debug support report bug', 'updates.check': 'refresh latest new version', 'open.t3': 'launch app', goto: 'go to open page show', }; + // What a failed row's Retry does: install it again, or finish its update. + const retryLabel = (row) => (row.action && /\.update$/.test(row.action.cmd) ? 'Retry the ' + row.name + ' update' : 'Retry installing ' + row.name); + // An update says the version it starts from; the label says where it goes. + const from = (version) => (version ? { text: 'from ' + version, mono: true } : null); 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 || {})); @@ -1455,9 +1483,9 @@ const T3Model = (() => { : a.updateAvailable ? { text: a.latest, mono: true } : a.version ? { text: a.version, mono: true } : null; const attention = a.attention || a.updateAvailable; if (a.state === 'missing') add('Agents', 'Install ' + a.name, 'download', { cmd: 'harness.install', id: a.id }, { meta: null }); - if (a.state === 'signin') add('Agents', (a.action && a.action.label === 'Add key' ? 'Add a provider key for ' : 'Sign in ') + a.name, a.action && a.action.icon || 'log-in', { cmd: a.action.cmd, id: a.id }, { meta: hint, attention: true }); - if (a.state === 'failed' && a.action) add('Agents', 'Retry ' + a.name, 'refresh-cw', { cmd: a.action.cmd, id: a.id }, { meta: hint, attention: true }); - if (a.updateAvailable) add('Agents', 'Update ' + a.name + ' to ' + a.latest, 'circle-arrow-up', { cmd: 'harness.update', id: a.id }, { meta: { text: a.version, mono: true }, attention }); + if (a.state === 'signin') add('Agents', (a.action && a.action.label === 'Add key' ? 'Add a provider key for ' : 'Sign in to ') + a.name, a.action && a.action.icon || 'log-in', { cmd: a.action.cmd, id: a.id }, { meta: hint, attention: true }); + if (a.state === 'failed' && a.action) add('Agents', retryLabel(a), 'refresh-cw', { cmd: a.action.cmd, id: a.id }, { meta: hint, attention: true }); + if (a.updateAvailable) add('Agents', 'Update ' + a.name + ' to ' + a.latest, 'circle-arrow-up', { cmd: 'harness.update', id: a.id }, { meta: from(a.version) }); for (const m of a.menu) { if (m.sep || m.cmd === 'harness.update') continue; const label = m.cmd === 'harness.signin' ? 'Sign in ' + a.name + ' again' @@ -1470,8 +1498,8 @@ const T3Model = (() => { } for (const t of toolchainRows(s, ui, now)) { if (t.state === 'missing') add('Toolchains', 'Install ' + t.name, 'download', { cmd: 'toolchain.install', id: t.id }); - if (t.updateAvailable) add('Toolchains', 'Update ' + t.name + ' to ' + t.latest, 'circle-arrow-up', { cmd: 'toolchain.update', id: t.id }, { meta: { text: t.version, mono: true }, attention: true }); - if (t.state === 'failed') add('Toolchains', 'Retry ' + t.name, 'refresh-cw', { cmd: t.action.cmd, id: t.id }, { meta: { text: 'failed', dot: 'danger' }, attention: true }); + if (t.updateAvailable) add('Toolchains', 'Update ' + t.name + ' to ' + t.latest, 'circle-arrow-up', { cmd: 'toolchain.update', id: t.id }, { meta: from(t.version) }); + if (t.state === 'failed') add('Toolchains', retryLabel(t), 'refresh-cw', { cmd: t.action.cmd, id: t.id }, { meta: { text: 'failed', dot: 'danger' }, attention: true }); if (t.version && t.state !== 'running') add('Toolchains', 'Uninstall ' + t.name + '…', 'trash-2', { cmd: 'toolchain.uninstall', id: t.id }); } add('Toolchains', 'Add a tool…', 'plus', { cmd: 'package.add' }, { shortcut: 'N' }); @@ -1479,8 +1507,8 @@ const T3Model = (() => { for (const t of packageRows(s, ui, now)) { installed.add(t.id); const version = t.version ? { text: t.version, mono: true } : null; - if (t.updateAvailable) add('Toolchains', 'Update ' + t.name + ' to ' + t.latest, 'circle-arrow-up', { cmd: 'package.update', id: t.id }, { meta: version, attention: true }); - if (t.state === 'failed') add('Toolchains', 'Retry ' + t.name, 'refresh-cw', { cmd: t.action.cmd, id: t.id }, { meta: { text: 'failed', dot: 'danger' }, attention: true }); + if (t.updateAvailable) add('Toolchains', 'Update ' + t.name + ' to ' + t.latest, 'circle-arrow-up', { cmd: 'package.update', id: t.id }, { meta: from(t.version) }); + if (t.state === 'failed') add('Toolchains', retryLabel(t), 'refresh-cw', { cmd: t.action.cmd, id: t.id }, { meta: { text: 'failed', dot: 'danger' }, attention: true }); if (t.version && t.state !== 'running') { add('Toolchains', 'Install a specific version of ' + t.name + '…', 'history', { cmd: 'package.version', id: t.id }, { meta: version }); add('Toolchains', 'Uninstall ' + t.name + '…', 'trash-2', { cmd: 'package.uninstall', id: t.id }); @@ -1489,10 +1517,10 @@ const T3Model = (() => { for (const c of managedSourceControlRows(s, ui, now)) { const keywords = 'source control git ' + c.provider + ' ' + c.id + ' ' + (KEYWORDS[c.action ? c.action.cmd : ''] || ''); if (c.state === 'missing') add('Source control', 'Install ' + c.name, 'download', { cmd: 'toolchain.install', id: c.id }, { meta: { text: c.provider }, keywords: keywords + ' add download' }); - if (c.updateAvailable) add('Source control', 'Update ' + c.name + ' to ' + c.latest, 'circle-arrow-up', { cmd: 'toolchain.update', id: c.id }, { meta: { text: c.version, mono: true }, attention: true, keywords: keywords + ' upgrade' }); + if (c.updateAvailable) add('Source control', 'Update ' + c.name + ' to ' + c.latest, 'circle-arrow-up', { cmd: 'toolchain.update', id: c.id }, { meta: from(c.version), keywords: keywords + ' upgrade' }); if (c.state === 'failed' && c.action) { const repair = c.action.label === 'Repair'; - add('Source control', (repair ? 'Repair ' : 'Retry ') + c.name, c.action.icon || 'refresh-cw', { cmd: c.action.cmd, id: c.id }, + add('Source control', repair ? 'Repair ' + c.name : retryLabel(c), c.action.icon || 'refresh-cw', { cmd: c.action.cmd, id: c.id }, { meta: repair ? { text: 'needs repair', dot: 'warn' } : { text: 'failed', dot: 'danger' }, attention: true, keywords }); } if (c.version && c.state !== 'running') add('Source control', 'Uninstall ' + c.name + '…', 'trash-2', { cmd: 'toolchain.uninstall', id: c.id }, { keywords: keywords + ' remove delete' }); @@ -1502,7 +1530,7 @@ const T3Model = (() => { // Signed out, with sign-in on the row's button or, behind a Retry, in its menu. const signsIn = (c.action && c.action.cmd === 'scm.signin') || c.menu.some((m) => m.cmd === 'scm.signin' && m.label === 'Sign in…'); if (signsIn && c.auth && c.auth.status === 'unauthenticated') { - add('Source control', 'Sign in ' + c.name, 'log-in', { cmd: 'scm.signin', id: c.id }, { meta: { text: 'not signed in', dot: c.inImage ? null : 'warn' }, attention: !c.inImage, keywords }); + add('Source control', 'Sign in to ' + c.name, 'log-in', { cmd: 'scm.signin', id: c.id }, { meta: { text: 'not signed in', dot: c.inImage ? null : 'warn' }, attention: !c.inImage, keywords }); } for (const m of c.menu) { if (m.cmd === 'scm.signout') add('Source control', m.label.replace('Sign out', 'Sign ' + c.name + ' out'), 'log-out', { cmd: 'scm.signout', id: c.id }, { keywords: keywords + ' log out logout' }); @@ -1568,9 +1596,9 @@ const T3Model = (() => { if (s.setupKeySource && s.setupKeySource !== 'env') add('Environment', 'Replace setup key', 'refresh-cw', { cmd: 'key.replace' }); add('Environment', 'Copy diagnostics', 'copy', { cmd: 'diagnostics' }); - const pages = [['Overview', 'overview', 'layout-dashboard', 'O'], ['Devices', 'devices', 'smartphone', 'D'], ['Agents', 'agents', 'bot', 'A'], - ['Context', 'context', 'book-open', 'C'], ['Toolchains', 'toolchains', 'wrench', 'T'], ['Source control', 'sourcecontrol', 'git-pull-request', 'S'], ['Ports', 'ports', 'ethernet-port', 'P'], ['Environment', 'environment', 'settings-2', 'E']]; - for (const [label, route, icon, key] of pages) add('Go to', label, icon, { cmd: 'goto', route }, { shortcut: 'G ' + key }); + for (const page of typeof T3Routes === 'object' ? T3Routes.PAGES : []) { + add('Go to', page.title, page.icon, { cmd: 'goto', route: page.route }, { shortcut: page.key ? 'G ' + page.key.toUpperCase() : null }); + } return items; }; @@ -1739,9 +1767,9 @@ const T3Model = (() => { const idleMs = (at) => (Number.isFinite(at) && at > 0 ? Math.max(0, now - at) : null); const fmtIdle = (at) => { const ms = idleMs(at); - if (ms === null) return "No activity seen yet"; - if (ms < 60_000) return "Active just now"; - return "Last activity " + relTime(at, now); + if (ms === null) return "Not used since the setup service started"; + if (ms < 60_000) return "Last used just now"; + return "Last used " + relTime(at, now); }; const kindLine = (kind) => (!kind?.enabled ? "Off" : `On · check every ${kind.checkMinutes >= 60 && kind.checkMinutes % 60 === 0 ? (kind.checkMinutes / 60) + "h" : kind.checkMinutes + "m"} · update after ${kind.idleMinutes}m idle`); @@ -1772,7 +1800,7 @@ const T3Model = (() => { readiness, readySummary, needsYou, activity, setupBanner, setupFailures, publicUrlSource, hereOffer, connectView, contextView, contextAgentsMdRow, contextProviderRows, - navBadges, attentionCount, summaries, runtimeView, autoUpdateView, + navBadges, serverWarnings, attentionCount, summaries, runtimeView, autoUpdateView, paletteItems, searchPalette, redactedDiagnostics, storageView, }; diff --git a/docker/setup/client/routes.js b/docker/setup/client/routes.js new file mode 100644 index 0000000..8ae7656 --- /dev/null +++ b/docker/setup/client/routes.js @@ -0,0 +1,76 @@ +// The console's pages, in one table. +// +// The shell (page.mjs, on the server) draws the sidebar and the phone's tab +// bar from it; app.js routes, titles and heads each page with it; the view +// model offers it in the command palette; the shortcuts are its keys. A page +// is added, renamed or moved here and nowhere else. +// +// Like model.js this is a classic script with no DOM: the server and the tests +// run it under Node, and the page inlines it before model.js. +const T3Routes = (() => { + 'use strict'; + + // Each page: its route (the address's #hash), its title, the icon beside it + // in the sidebar, the second key of its G shortcut (G A for Agents), the + // sidebar group it sits in, a one-line summary for its header, and the guide + // in docs/ that explains it. + const PAGES = [ + { route: 'overview', title: 'Overview', icon: 'layout-dashboard', key: 'o', group: 'main', + summary: 'Setup progress, and anything on this server that needs you.', doc: 'setup-page.md' }, + { route: 'devices', title: 'Devices', icon: 'smartphone', key: 'd', group: 'main', + summary: 'Pair a phone or browser with this server, and manage the ones that can reach it.', doc: 'remote-access.md' }, + { route: 'agents', title: 'Agents', icon: 'bot', key: 'a', group: 'main', + summary: 'Install, sign in and update your coding agents.', doc: 'agents.md' }, + { route: 'context', title: 'Context', icon: 'book-open', key: 'c', group: 'main', + summary: 'Instructions and skills your agents load at the start of every session.', doc: 'context-management.md' }, + { route: 'toolchains', title: 'Toolchains', icon: 'wrench', key: 't', group: 'main', + summary: 'Languages and tools on every agent’s path, at exact versions.', doc: 'toolchains.md' }, + { route: 'sourcecontrol', title: 'Source control', icon: 'git-pull-request', key: 's', group: 'main', + summary: 'The CLIs T3 Code uses to open pull requests.', doc: 'source-control.md' }, + { route: 'ports', title: 'Ports', icon: 'ethernet-port', key: 'p', group: 'main', + summary: 'Give a dev server running here a public HTTPS address.', doc: 'ports.md' }, + { route: 'environment', title: 'Environment', icon: 'settings-2', key: 'e', group: 'main', + summary: 'T3 Code, this container and the setup console itself.', doc: 'configuration.md' }, + ]; + + // Sidebar groups, in order; a group without a label draws no heading. + const GROUPS = [ + { id: 'main', label: null }, + ]; + + // The phone's tab bar. Every other page lives under More, which lists them + // in the sidebar's groups. + const TABS = [ + { route: 'overview', title: 'Overview', icon: 'layout-dashboard' }, + { route: 'devices', title: 'Devices', icon: 'smartphone' }, + { route: 'agents', title: 'Agents', icon: 'bot' }, + { route: 'ports', title: 'Ports', icon: 'ethernet-port' }, + { route: 'more', title: 'More', icon: 'ellipsis' }, + ]; + const MORE = { route: 'more', title: 'More', icon: 'ellipsis', key: null, group: null, summary: 'Everything else on this server.', doc: null }; + + // Addresses a page had before it moved, so an old bookmark or a link from an + // older T3 Code still lands where it meant to. + const ALIASES = {}; + + const BY_ROUTE = new Map([...PAGES, MORE].map((page) => [page.route, page])); + const page = (route) => BY_ROUTE.get(route) || null; + /** The page an address names, through its aliases; null when it names none. */ + const resolve = (name) => (BY_ROUTE.has(name) ? name : ALIASES[name] || null); + /** The tab that holds a page on a phone. */ + const tabOf = (route) => (TABS.some((tab) => tab.route === route) ? route : 'more'); + /** Pages in their sidebar groups, each group with its label. */ + const grouped = () => GROUPS.map((group) => ({ ...group, pages: PAGES.filter((p) => p.group === group.id) })); + + /** + * A guide's address on GitHub, at the release this image was built from so + * it describes what is running; a branch build or an unknown one reads main. + */ + const REPO = 'https://github.com/dizys/t3code-docker/blob/'; + const docUrl = (doc, imageVersion) => { + const release = /^v?(\d+\.\d+\.\d+)$/.exec(String(imageVersion || '').trim()); + return REPO + (release ? 'v' + release[1] : 'main') + '/docs/' + doc; + }; + + return { PAGES, GROUPS, TABS, ALIASES, page, resolve, tabOf, grouped, docUrl }; +})(); diff --git a/docker/setup/client/storage.js b/docker/setup/client/storage.js index 842056e..93e98e0 100644 --- a/docker/setup/client/storage.js +++ b/docker/setup/client/storage.js @@ -175,7 +175,7 @@ const T3StorageUI = (() => { ${disk ? html`
` : ''}

Filesystem usage includes other containers and host files. Folder sizes below cover this container.

-

Build & download caches

Recreated when needed. Your project files and installed tools are kept.

+

Build & download caches

Recreated when needed. Your project files and installed tools are kept.

${current.cacheRows.length ? current.cacheRows.map((row) => html`
${row.label}${row.unavailable || row.description}
${row.size}
`) diff --git a/docker/setup/console.css b/docker/setup/console.css index 9806251..a07fcc8 100644 --- a/docker/setup/console.css +++ b/docker/setup/console.css @@ -39,15 +39,67 @@ html.tc-locked { overflow: hidden; } .tc-server-version { overflow-wrap: anywhere; } .tc-sidebar-row { display: flex; align-items: center; gap: var(--space-1); } .tc-sidebar-row > .tc-nav-item { flex: 1 1 auto; min-width: 0; } -.tc-banner-text { min-width: 0; } .tc-main { min-height: 100vh; min-height: 100dvh; } .tc-main:focus { outline: none; } -.tc-topbar-summary { min-width: 0; } -.tc-topbar-actions { display: flex; align-items: center; gap: 8px; flex: none; } -/* Inside T3 Code's settings the console is a dialog, closed from the end of - the top bar (the phone's too), set apart from the page's own actions. */ -.tc-topbar-sep { width: 1px; height: 16px; background: var(--border); flex: none; margin: 0 2px; } -.tc-topbar-actions:empty + .tc-topbar-sep { display: none; } + +/* The sidebar's pages, in groups. A mark beside a page is either a count of + what needs the person or a quiet sign: work running, a port out on the + internet. Never a plain count. */ +.tc-nav-group { display: flex; flex-direction: column; gap: 2px; } +.tc-nav-group + .tc-nav-group { margin-top: var(--space-2); } +.tc-nav-group > .tc-nav-label { padding: 6px 10px 4px; } +.tc-nav-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tc-nav-badge { flex: none; margin-left: auto; } +[data-theme="light"] .tc-nav-badge.tc-badge--warn { color: color-mix(in oklab, var(--warn-text), black 28%); } +[data-theme="light"] .tc-nav-badge.tc-badge--info { color: color-mix(in oklab, var(--info-text), black 18%); } +.tc-nav-mark { width: 16px; height: 16px; display: inline-grid; place-items: center; } +.tc-nav-mark::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--info); } +.tc-nav-mark--running::before { width: 10px; height: 10px; background: none; border: 1.5px solid var(--info); border-right-color: transparent; animation: tc-spin 0.75s linear infinite; } +.tc-nav-mark--live::before { box-shadow: 0 0 0 3px color-mix(in srgb, var(--info) 18%, transparent); } + +/* Inside T3 Code the console is a dialog: its close button holds the top + right corner of the page at a desk, and the end of the phone's bar. */ +.tc-embed-corner { position: fixed; top: 28px; right: 16px; z-index: var(--z-sticky); } +.tc-embed-corner .tc-embed-close { background: color-mix(in srgb, var(--bg) 80%, transparent); + -webkit-backdrop-filter: blur(12px) saturate(1.14); backdrop-filter: blur(12px) saturate(1.14); } +.tc-embed-corner .tc-embed-close:hover { background: var(--hover); } + +/* Every page opens with its title, one line on what it is for, and its main + action. */ +.tc-page { padding-top: 28px; } +.tc-page-head { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3) var(--space-6); min-width: 0; padding: 0 var(--space-4); } +.tc-page-head-text { flex: 1 1 26rem; min-width: 0; display: flex; flex-direction: column; gap: var(--space-1); } +.tc-page-title { font-size: 22px; line-height: 28px; font-weight: 600; letter-spacing: -0.02em; text-wrap: balance; overflow-wrap: anywhere; } +.tc-page-sub { color: var(--fg-muted); font-size: 14px; line-height: 20px; max-width: 72ch; text-wrap: pretty; } +.tc-page-learn { color: var(--fg); white-space: nowrap; text-decoration: underline; text-decoration-color: var(--border-strong); + text-underline-offset: 3px; text-decoration-thickness: 1px; border-radius: 2px; } +.tc-page-learn:hover { color: var(--accent-text); text-decoration-color: currentColor; } +.tc-page-head-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); min-height: 28px; } +/* Inside T3 Code the close button holds the top right corner: the head keeps + clear of it only as far as the page's own margin does not already. */ +[data-embed] .tc-page-head { padding-right: max(var(--space-4), calc(68px - max(0px, (100vw - var(--sidebar-w) - var(--content)) / 2) - var(--space-6))); } +/* A line under everything else on a page: what is there but not managed here. */ +.tc-page-foot { padding: 0 var(--space-4); font-size: 12px; line-height: 18px; color: var(--fg-muted); text-wrap: pretty; } + +/* First start, in progress: a line above the page it affects. */ +.tc-notice--progress { align-items: center; } +.tc-notice--progress > .tc-spinner { margin: 0 2px; } +.tc-notice--progress .tc-notice-desc { opacity: 1; color: var(--fg-muted); } +.tc-notice--progress .tc-notice-desc strong { color: var(--fg); font-weight: 500; } +.tc-notice--progress > .tc-progress { width: 96px; flex: none; } +.tc-notice--progress > .tc-op-pct { flex: none; } +/* On a phone the bar goes under the words, which keep the whole width. */ +@media (max-width: 639.98px) { + .tc-notice--progress { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; row-gap: var(--space-2); } + .tc-notice--progress > .tc-notice-body { grid-column: 2 / -1; } + .tc-notice--progress > .tc-progress { grid-column: 2; width: auto; } +} + +/* 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; } +/* A port's tile says whether it is out on the internet. */ +.tc-tile--port-state[data-live] { color: var(--info-text); } /* The phone's top bar: brand, the server's health in a word, search. */ .tc-phonebar { display: none; align-items: center; gap: 8px; height: var(--phonebar-h); padding: 0 12px 0 16px; flex: none; @@ -59,12 +111,13 @@ html.tc-locked { overflow: hidden; } @media (max-width: 1023.98px) { .tc-app { grid-template-columns: minmax(0, 1fr); } - .tc-sidebar, .tc-topbar { display: none; } + .tc-sidebar, .tc-embed-corner { display: none; } .tc-phonebar { display: flex; } .tc-tabbar { display: grid; position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-sticky); } - .tc-page { padding: 4px 16px calc(var(--tabbar-h) + env(safe-area-inset-bottom, 0px) + 24px); gap: var(--space-6); } - .tc-page .tc-section-head { padding: 0 4px; } - .tc-banner { padding: 6px 16px; } + .tc-page { padding: 8px 16px calc(var(--tabbar-h) + env(safe-area-inset-bottom, 0px) + 24px); gap: var(--space-6); } + .tc-page .tc-section-head, .tc-page-head, .tc-page-foot { padding: 0 4px; } + .tc-page-title { font-size: 20px; } + [data-embed] .tc-page-head { padding-right: 4px; } .tc-desk-only { display: none !important; } } @media (min-width: 1024px) { @@ -86,8 +139,6 @@ html.tc-locked { overflow: hidden; } /* A 28px input, for inline values beside small buttons. */ .tc-input--sm { height: var(--control-sm); font-size: 13px; } -.tc-section-sub { font-size: 12px; line-height: 18px; color: var(--fg-muted); } - /* Storage is a maintenance section within Environment. Reuse the console's surfaces, typography, and controls; the rules here only compose its layout. */ .tc-storage-disk { padding: var(--space-4); display: grid; gap: var(--space-3); } @@ -161,9 +212,6 @@ html.tc-locked { overflow: hidden; } .tc-storage-disk-head > .tc-row-name { font-size: 13px; } .tc-storage-disk-head > .tc-badge { flex: none; } } -.tc-group-pad { padding: var(--space-4); } -.tc-foot-cmds code { color: var(--fg); } -.tc-filter { margin-left: 4px; } /* Readiness collapsed to one line once everything is done. */ .tc-ready-line { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 2px var(--space-3); align-items: center; padding: 18px var(--space-4); } @@ -203,12 +251,15 @@ html.tc-locked { overflow: hidden; } .tc-pair-form-intro { display: flex; flex-direction: column; gap: var(--space-1); grid-column: 1 / -1; } .tc-pair-form-fields { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-4); } .tc-pair-form-note { grid-column: 1 / -1; } +/* The label field, the lifetime and the button stand the same height, on one line. */ +.tc-pair-form .tc-input { height: var(--control-md); } +.tc-pair-form .tc-seg { padding: 3px; } +.tc-pair-form .tc-seg button { height: 24px; } .tc-pair-phone-actions { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); } .tc-pair-done-text { display: flex; flex-direction: column; gap: 6px; align-items: center; } .tc-pair-done-text .tc-page-lede { max-width: 30ch; } .tc-pair-done-actions { display: flex; flex-direction: column; gap: var(--space-2); align-items: center; } .tc-pair-done .tc-steps { align-self: stretch; } -.tc-empty-cmd { margin-top: var(--space-2); } .tc-pair-done { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 18px; padding: 22px var(--space-4) 18px; } .tc-pair-done .tc-empty-media { margin: 10px 0 2px; } .tc-pair-done-title { font-size: 24px; line-height: 32px; font-weight: 600; letter-spacing: -0.025em; text-wrap: balance; } @@ -238,25 +289,18 @@ html.tc-locked { overflow: hidden; } .tc-port-panel .tc-qr svg { width: 168px; height: 168px; } } -/* "In the image": a static two-column list. */ -.tc-included { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } -.tc-included > div { display: flex; align-items: center; gap: 10px; padding: 12px var(--space-4); border-top: 1px solid var(--hairline); font-size: 14px; min-width: 0; } -.tc-included > div:nth-child(-n+2) { border-top: 0; } -.tc-included > div:nth-child(odd) { border-right: 1px solid var(--hairline); } -.tc-included .tc-icon { color: var(--fg-muted); } -.tc-included-what { margin-left: auto; font-size: 12px; color: var(--fg-muted); white-space: nowrap; } -@media (max-width: 639.98px) { - .tc-included { grid-template-columns: minmax(0, 1fr); } - .tc-included > div:nth-child(2) { border-top: 1px solid var(--hairline); } - .tc-included > div:nth-child(odd) { border-right: 0; } -} - /* A log: Recent on Overview, Activity's finished lines. */ .tc-log { list-style: none; margin: 0; padding: 0; } .tc-log li { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px var(--space-4); font-size: 14px; } .tc-log li + li { border-top: 1px solid var(--hairline); } .tc-log .tc-icon { color: var(--fg-muted); } -.tc-log time { font: 400 12px/1 var(--font-mono); color: var(--fg-muted); white-space: nowrap; } +.tc-log time { font-size: 12px; line-height: 1; color: var(--fg-muted); white-space: nowrap; font-variant-numeric: tabular-nums; } +@media (max-width: 639.98px) { + .tc-log li { grid-template-columns: 16px minmax(0, 1fr); row-gap: 2px; align-items: start; } + .tc-log li > .tc-icon { margin-top: 2px; } + .tc-log li > .tc-truncate { white-space: normal; } + .tc-log li > time { grid-column: 2; line-height: 16px; } +} /* Link rows: the phone's More page. */ .tc-linkrow { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 var(--space-4); color: var(--fg); text-decoration: none !important; font-size: 16px; background: none; border: 0; width: 100%; font-family: inherit; cursor: pointer; text-align: left; } @@ -272,7 +316,6 @@ html.tc-locked { overflow: hidden; } /* Key/value: a value that is prose, and inline actions that should not grow the row. */ -.tc-kv > dd.tc-kv-prose { display: block; } .tc-kv .tc-btn--xs { margin-block: -4px; } .tc-kv-aside { color: var(--fg-muted); font-size: 12px; } .tc-kv > dd .tc-kv-end { margin-left: auto; } @@ -371,7 +414,8 @@ html.tc-locked { overflow: hidden; } /* Popover menus position themselves against their trigger; no backdrop. */ .tc-layer--menu { z-index: var(--z-menu); pointer-events: none; } .tc-layer--menu > .tc-menu { position: fixed; pointer-events: auto; animation: tc-pop var(--dur-fast) ease-out; transform-origin: top right; } -.tc-menu-head { display: flex; align-items: center; gap: 12px; padding: 4px 8px 12px; } +/* A menu sheet's title lines up with its items: the same inset on the left. */ +.tc-menu-head { display: flex; align-items: center; gap: 12px; padding: 4px 20px 12px; } .tc-menu-head-text { display: flex; flex-direction: column; min-width: 0; } .tc-menu-head-title { font-size: 17px; line-height: 24px; font-weight: 600; } /* Under 1024px every sheet, menu and dialog rises from the bottom. */ @@ -382,6 +426,10 @@ html.tc-locked { overflow: hidden; } .tc-layer--center > .tc-dialog { width: 100%; max-width: none; border-radius: var(--radius-2xl) var(--radius-2xl) 0 0; max-height: 88%; animation: tc-rise-sheet 300ms cubic-bezier(0.32, 0.72, 0, 1); padding-bottom: env(safe-area-inset-bottom, 0px); } .tc-layer--center > .tc-dialog .tc-dialog-foot { flex-direction: column-reverse; } + /* Every dialog rising from the bottom carries a sheet's handle. */ + .tc-layer--center > .tc-dialog::before { content: ""; display: block; width: 36px; height: 4px; margin: 8px auto 0; border-radius: 2px; background: var(--border-strong); } + /* No keyboard here to name keys for. */ + .tc-root[data-density="touch"] .tc-palette-foot { display: none; } .tc-layer--center > .tc-dialog .tc-dialog-foot .tc-btn { width: 100%; } .tc-layer--palette { padding: 8px; } .tc-layer--palette > .tc-palette { width: 100%; } @@ -410,7 +458,6 @@ html.tc-locked { overflow: hidden; } .tc-signin-code .tc-input { flex: 1 1 auto; min-width: 0; } .tc-log-tail { margin: 0; padding: var(--space-2) var(--space-3); border-radius: var(--radius-lg); background: var(--surface-sunken); font: 400 12px/18px var(--font-mono); color: var(--fg-muted); white-space: pre-wrap; word-break: break-word; max-height: 120px; overflow: auto; } .tc-saved-keys-wrap { gap: var(--space-2); } -.tc-dialog-field { margin-top: var(--space-3); } /* A device name is a small edit: a compact dialog, with the device beside the field and the actions directly below it. At touch widths it becomes @@ -431,13 +478,7 @@ html.tc-locked { overflow: hidden; } .tc-device-rename-error > .tc-icon { flex: none; margin-top: 1px; } .tc-device-rename-foot { padding-top: 0; } .tc-device-rename-foot [data-key="device-save"] { min-width: 120px; } -@media (max-width: 639.98px) { - .tc-device-row:has(> .tc-row-actions) { grid-template-columns: auto minmax(0, 1fr); } - .tc-device-row > .tc-row-actions { grid-column: 2 / -1; justify-content: flex-start; margin-top: var(--space-2); } - .tc-root[data-density="touch"] .tc-device-row > .tc-row-actions .tc-btn { --_h: var(--touch); } -} @media (max-width: 1023.98px) { - .tc-device-rename::before { content: ""; display: block; width: 36px; height: 4px; margin: var(--space-2) auto 0; border-radius: 2px; background: var(--border-strong); } .tc-device-rename-body { padding: 20px 20px 16px; } .tc-root[data-density="touch"] .tc-device-rename .tc-input { height: var(--touch); } .tc-root[data-density="touch"] .tc-device-rename .tc-btn { --_h: var(--touch); } @@ -569,7 +610,6 @@ html.tc-locked { overflow: hidden; } .tc-context-sheet .tc-sheet-head, .tc-context-sheet .tc-sheet-foot { flex-shrink: 0; } .tc-context-sheet .tc-sheet-foot { flex-wrap: wrap; } .tc-context-sheet .tc-input--area { box-sizing: border-box; width: 100%; min-height: min(42dvh, 420px); resize: vertical; font-family: var(--font-mono); line-height: 1.6; } -.tc-context-filetools { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); } .tc-context-path { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); } .tc-editor-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2) var(--space-3); } .tc-editor-toolbar .tc-label { margin: 0; } @@ -581,7 +621,12 @@ html.tc-locked { overflow: hidden; } .tc-skill-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4); } .tc-skill-toolbar .tc-label { margin: 0; } .tc-skill-toolbar select { width: auto; min-width: 140px; max-width: 100%; } -.tc-skill-count { margin-left: auto; text-align: right; } +.tc-skill-count { white-space: nowrap; } +.tc-skill-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); } +@media (max-width: 639.98px) { + .tc-skill-actions { width: 100%; } + .tc-skill-actions > .tc-btn:not(.tc-btn--icon) { flex: 1 1 auto; } +} .tc-skill-message { display: flex; gap: var(--space-2); margin: 0; padding: var(--space-3) var(--space-4); overflow-wrap: anywhere; } .tc-skill-message > .tc-icon { flex: none; margin-top: 1px; } .tc-skill-description { margin: var(--space-1) 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } diff --git a/docker/setup/page.mjs b/docker/setup/page.mjs index 532dc53..7db04b8 100644 --- a/docker/setup/page.mjs +++ b/docker/setup/page.mjs @@ -11,17 +11,20 @@ // way out (`/\s+/` once reached the browser as `/s+/`). The smoke test holds // each inlined script byte-for-byte to its file. import { readFileSync } from "node:fs"; +import vm from "node:vm"; // Order matters: tokens, then components, then the page's own composition. export const STYLES = ["design/tokens.css", "design/components.css", "console.css"]; // Classic scripts that share one global scope, run in this order. base.js // resolves where the API lives; ui.js is the design system's behaviour layer -// (window.T3C); model.js is the console's pure view model (window.T3Model); -// kit.js the DOM layer (overlays, toasts, keyboard); app.js the console itself. +// (window.T3C); routes.js the table of pages (window.T3Routes); model.js the +// console's pure view model (window.T3Model); kit.js the DOM layer (overlays, +// toasts, keyboard); app.js the console itself. export const CONSOLE_SCRIPTS = [ "client/base.js", "design/ui.js", + "client/routes.js", "client/model.js", "client/kit.js", "client/storage.js", @@ -33,6 +36,19 @@ export const UNLOCK_SCRIPTS = ["client/base.js", "design/ui.js", "client/unlock. const read = (path) => readFileSync(new URL(path, import.meta.url), "utf8"); +/** + * Run classic scripts (no DOM) in one fresh context and return the globals + * they define: the server reads the console's own route table and view model + * this way, so the shell it draws and the counts it reports are the page's. + */ +export const runClassic = (paths, names) => { + const context = vm.createContext({ URL }); + for (const path of paths) vm.runInContext(read(path), context, { filename: path }); + return Object.fromEntries(names.map((name) => [name, vm.runInContext(name, context)])); +}; + +const { T3Routes: ROUTES } = runClassic(["client/routes.js"], ["T3Routes"]); + /** * Read every asset once. Throws at startup if one could not be inlined safely, * rather than shipping a page whose script ends early. @@ -103,24 +119,6 @@ const head = ({ nonce, css, mount, embed }) => ` const scripts = (list, nonce) => list.map(({ path, code }) => ``).join("\n"); -const NAV = [ - ["overview", "Overview", "layout-dashboard", "O"], - ["devices", "Devices", "smartphone", "D"], - ["agents", "Agents", "bot", "A"], - ["context", "Context", "book-open", "C"], - ["toolchains", "Toolchains", "wrench", "T"], - ["sourcecontrol", "Source control", "git-pull-request", "S"], - ["ports", "Ports", "ethernet-port", "P"], - ["environment", "Environment", "settings-2", "E"], -]; -const TABS = [ - ["overview", "Overview", "layout-dashboard"], - ["devices", "Devices", "smartphone"], - ["agents", "Agents", "bot"], - ["ports", "Ports", "ethernet-port"], - ["more", "More", "ellipsis"], -]; - const skeleton = (rows) => `