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