Merge pull request #270 from Ark0N/feat/readmymind-phase3-part1

Read My Mind phase 3 part 1: the modal grows up and reaches phones
This commit is contained in:
Ark0N
2026-08-10 04:38:02 +02:00
committed by GitHub
12 changed files with 512 additions and 77 deletions
+119 -8
View File
@@ -1,6 +1,6 @@
/**
* @fileoverview Desktop home screen session list: the open tabs as a vertical
* column down the left of the welcome overlay.
* @fileoverview Desktop home screen session list: the open tabs as a rail docked
* down the left edge of the welcome overlay.
*
* The welcome screen centers ~560px of content in a window that is usually
* 1400px+, so the two gutters are dead space. The left one now carries the same
@@ -8,11 +8,19 @@
* one row per live tab, in TAB ORDER (not sorted by state) so it reads as the
* tab strip rotated, and so Alt+1..9 still matches what you see.
*
* DESKTOP ONLY, and only in a wide enough window: the column is absolutely
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
* positioned so the centered welcome content never moves, which means it can
* only exist where the gutter is genuinely wider than the column. Below
* only exist where the gutter is genuinely wider than the rail. Below
* `HOME_SESSIONS_MIN_WIDTH` nothing renders; on a phone the mobile overview owns
* the home screen entirely and this surface stays out of its way.
* the home screen entirely and this surface stays out of its way. Width and type
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
* a fixed 256px card looks abandoned on a 2560px display.
*
* Each row carries when the session was FIRST CREATED and when it was LAST
* ACTIVE, both relative. Those two stamps go stale on their own (a sitting
* session emits no event), so a slow clock refreshes them IN PLACE from the
* epoch-ms values parked on the elements, rather than re-rendering: a re-render
* would restart every row's blink animation and its working ring.
*
* The working state is deliberately identical to the phone's: a pulsing green
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
@@ -27,19 +35,23 @@
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
* @dependency mobile-handlers.js (MobileDetection)
* @loadorder 12.56 of 16, after mobile-overview.js, before entrance-animations.js
*/
/**
* Narrowest window that gets the column. The welcome content is 560px wide and
* centered, so at 1180px each gutter is 310px, enough for the 256px column plus
* its 20px offset and still a visible gap. Anything narrower would overlap the
* Narrowest window that gets the rail. The welcome content is 560px wide and
* centered, so at 1180px each gutter is 310px, enough for the rail at its
* 250px floor and still a visible gap. Anything narrower would overlap the
* search panel, which is why this is a width gate and not a device-type gate.
*/
const HOME_SESSIONS_MIN_WIDTH = 1180;
/** How often the relative stamps are rewritten while the home screen is up. */
const HOME_SESSIONS_CLOCK_MS = 20000;
/** Pill copy per state. Same words as the phone overview, same reasons. */
const HOME_SESSIONS_PILL_LABEL = {
needs: 'needs you',
@@ -87,6 +99,7 @@ Object.assign(CodemanApp.prototype, {
this._wireHomeSessions(el);
if (!this.shouldShowHomeSessions()) {
el.hidden = true;
this._stopHomeSessionsClock();
return;
}
el.hidden = false;
@@ -96,6 +109,7 @@ Object.assign(CodemanApp.prototype, {
hideHomeSessions() {
const el = document.getElementById('homeSessions');
if (el) el.hidden = true;
this._stopHomeSessionsClock();
},
/** Re-render only when showing (called from the tab renderer's tail). */
@@ -173,6 +187,10 @@ Object.assign(CodemanApp.prototype, {
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
// Epoch ms, straight off the session payload; formatting happens at
// render time so the clock below can redo it without a re-render.
createdAt: Number(session.createdAt) || 0,
lastActivityAt: Number(session.lastActivityAt) || 0,
};
});
},
@@ -193,6 +211,7 @@ Object.assign(CodemanApp.prototype, {
if (!rows.length && !webviews.length) {
el.hidden = true;
el.replaceChildren();
this._stopHomeSessionsClock();
return;
}
el.hidden = false;
@@ -205,6 +224,95 @@ Object.assign(CodemanApp.prototype, {
for (const row of rows) list.appendChild(this._buildHomeSessionRow(row));
for (const webview of webviews) list.appendChild(this._buildHomeSessionsWebviewRow(webview));
el.appendChild(list);
this._startHomeSessionsClock();
},
// ═══════════════════════════════════════════════════════════════
// Age stamps: created / last active
// ═══════════════════════════════════════════════════════════════
/**
* The "created 2h ago · active 3m ago" footer line. Both stamps keep their raw
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
* rewrite the text without rebuilding the row.
*/
_buildHomeSessionsMeta(row) {
const meta = document.createElement('span');
meta.className = 'home-sessions-row-meta';
// Relative times are generated text, and "created"/"active" here are the
// same generic words that mean something else on other surfaces.
meta.setAttribute('data-i18n-skip', '');
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'home-sessions-meta-created'));
const sep = document.createElement('span');
sep.className = 'home-sessions-meta-sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
meta.appendChild(sep);
meta.appendChild(this._buildHomeSessionsStamp('active', row.lastActivityAt, 'home-sessions-meta-active'));
return meta;
},
/** One labelled stamp: a dim key, the relative value, full date in the title. */
_buildHomeSessionsStamp(key, timestamp, className) {
const wrap = document.createElement('span');
wrap.className = `home-sessions-meta-item ${className}`;
const label = document.createElement('span');
label.className = 'home-sessions-meta-key';
label.textContent = key;
wrap.appendChild(label);
const value = document.createElement('span');
value.dataset.hsTs = String(timestamp || 0);
value.textContent = this._homeSessionsAgo(timestamp);
wrap.appendChild(value);
if (timestamp)
wrap.title = `${key === 'created' ? 'First created' : 'Last active'}: ${new Date(timestamp).toLocaleString()}`;
return wrap;
},
/** Relative label for a stamp. `formatRelativeTime` is the app's one formatter. */
_homeSessionsAgo(timestamp) {
if (!timestamp) return '—';
return this.formatRelativeTime(timestamp) || '—';
},
/**
* Rewrites the stamps in place every `HOME_SESSIONS_CLOCK_MS`. In place, not a
* re-render: replacing the rows would restart the blink animation on every
* waiting row and the ring on every working one, twice a minute, for nothing.
*/
_startHomeSessionsClock() {
if (this._homeSessionsClock) return;
this._homeSessionsClock = setInterval(() => {
if (!this.isHomeSessionsVisible()) {
this._stopHomeSessionsClock();
return;
}
this._tickHomeSessionsTimes();
}, HOME_SESSIONS_CLOCK_MS);
},
_stopHomeSessionsClock() {
if (!this._homeSessionsClock) return;
clearInterval(this._homeSessionsClock);
this._homeSessionsClock = null;
},
_tickHomeSessionsTimes() {
const el = document.getElementById('homeSessions');
if (!el) return;
for (const node of el.querySelectorAll('[data-hs-ts]')) {
const ts = Number(node.dataset.hsTs) || 0;
const text = this._homeSessionsAgo(ts);
if (node.textContent !== text) node.textContent = text;
}
},
_buildHomeSessionsHeader(count) {
@@ -287,6 +395,9 @@ Object.assign(CodemanApp.prototype, {
pill.textContent = row.pill;
item.appendChild(pill);
// Wraps onto its own line (the row is flex-wrap) so it gets the full width.
item.appendChild(this._buildHomeSessionsMeta(row));
return item;
},
+1
View File
@@ -260,6 +260,7 @@
Insert: '插入',
"Put the text on the session's composer without submitting it": '将文本放入会话输入框但不提交',
'Predicted prompt, editable': '预测的提示,可编辑',
'Use this suggestion instead': '改用此建议',
'Select a session first': '请先选择一个会话',
'Read My Mind works on Claude sessions only': '读心术仅适用于 Claude 会话',
'Prompt sent': '提示已发送',
+3 -2
View File
@@ -323,9 +323,9 @@
<!-- Welcome Overlay (shown when no session active) -->
<div class="welcome-overlay" id="welcomeOverlay">
<!-- Open tabs as a vertical column in the left gutter (home-sessions.js).
<!-- Open tabs as a rail docked to the left edge (home-sessions.js).
Absolutely positioned so the centered content below never moves, and
therefore only rendered where the gutter is wider than the column;
therefore only rendered where the gutter is wider than the rail;
ships `hidden` and only that module reveals it. -->
<aside class="home-sessions" id="homeSessions" hidden></aside>
<div class="welcome-content">
@@ -2892,6 +2892,7 @@
<input type="text" id="readMyMindPrompt" class="readmymind-prompt-input" data-i18n-skip aria-label="Predicted prompt, editable" onkeydown="if(event.key==='Enter')app.sendReadMyMind(true)">
</div>
<div class="readmymind-why" id="readMyMindWhy" data-i18n-skip></div>
<div class="readmymind-alternates" id="readMyMindAlternates" data-i18n-skip style="display:none"></div>
</div>
<div class="readmymind-error" style="display:none">No suggestion this time. Rethink to try again.</div>
</div>
+21
View File
@@ -494,6 +494,7 @@ const KeyboardAccessoryBar = {
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
@@ -570,6 +571,7 @@ const KeyboardAccessoryBar = {
</button>
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">&#x1F4C1; Path</button>
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">&#x232B; All</button>
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
@@ -594,6 +596,9 @@ const KeyboardAccessoryBar = {
this.element = document.createElement('div');
this.element.className = 'keyboard-accessory-bar';
this.element.innerHTML = this._simpleButtons;
// The 🧠 key is opt-in (`readMyMindEnabled`, synced): it ships in both
// templates but stays display:none until the bar carries the marker class.
this.syncReadMyMind();
// Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => {
@@ -784,6 +789,11 @@ const KeyboardAccessoryBar = {
}
break;
}
case 'readmymind':
// Opens the shared Read My Mind modal (readmymind-ui.js); the modal
// takes focus, so deliberately NOT in the terminal-refocus set.
app.openReadMyMind?.();
break;
case 'paste':
this.pasteFromClipboard();
break;
@@ -829,6 +839,17 @@ const KeyboardAccessoryBar = {
this._confirmAction = null;
},
/** Reveal/hide the 🧠 key from the synced `readMyMindEnabled` setting.
* The marker class lives on the BAR because setMode() rebuilds the buttons'
* innerHTML on every layout switch (per-key state would be wiped). Called at
* init and re-synced by applyHeaderVisibilitySettings() on every settings
* apply, so a live toggle needs no reload. */
syncReadMyMind() {
if (!this.element) return;
const enabled = typeof app !== 'undefined' && typeof app.readMyMindEnabled === 'function' && app.readMyMindEnabled();
this.element.classList.toggle('rmm-enabled', enabled === true);
},
/** Send a slash command to the active session.
* Sends text and Enter separately so Ink processes them as distinct events. */
sendCommand(command) {
+30 -2
View File
@@ -534,8 +534,9 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
/* Read My Mind 🧠 button: desktop header only in phase 2; the phone surface
is a planned keyboard-accessory key (docs/readmymind-plan.md phase 3). */
/* Read My Mind 🧠 header button: never in the phone header; the phone
surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate,
see keyboard-accessory.js + the rmm-enabled rules in styles.css). */
.btn-icon-header.btn-readmymind {
display: none !important;
}
@@ -1323,6 +1324,33 @@ html.mobile-init .file-browser-panel {
width: calc(100% - 2rem);
}
/* Read My Mind: a small dialog (mirrors modal-sm), not a full-screen
takeover — it opens over the keyboard from the accessory 🧠 key and
should read as a quick suggestion sheet. Not modal-sm itself because
that caps desktop width at 340px; this modal wants 560px there. */
.modal-content.readmymind-modal {
height: auto;
max-height: 85vh;
border-radius: 12px;
margin: 1rem;
width: calc(100% - 2rem);
}
/* Four footer buttons on a narrow phone: let them wrap instead of clipping,
and give buttons + alternate rows finger-sized targets. */
.readmymind-modal .modal-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.readmymind-modal .modal-footer .btn {
flex: 1 1 auto;
min-height: 38px;
}
.readmymind-alt {
min-height: 38px;
}
/* Modal safe area padding - all sides for full-screen modals */
.ios-device .modal-content {
padding-top: var(--safe-area-top);
+80 -16
View File
@@ -2,12 +2,15 @@
* @fileoverview Read My Mind UI: predict the prompt you were about to type.
*
* A 🧠 header button (marker-hidden until the synced opt-in `readMyMindEnabled`
* setting is ON) opens a modal that asks the server for the user's most likely
* setting is ON; phones get a keyboard-accessory 🧠 key gated on the same
* setting) opens a modal that asks the server for the user's most likely
* next prompt (`POST /api/sessions/:id/readmymind`, one-shot predictor over the
* case's intent profile + live session signals). The top suggestion lands in an
* editable single-line field with its rationale below; buttons are Send (with
* Enter), Insert (drop on the CLI composer WITHOUT Enter, for editing), Rethink
* (re-run with the shown suggestion recorded as rejected), Dismiss.
* editable single-line field with its rationale below; the predictor's other
* suggestions render as tappable alternate rows that swap into the field
* without losing edits. Buttons are Send (with Enter), Insert (drop on the CLI
* composer WITHOUT Enter, for editing), Rethink (re-run with the whole shown
* set, main + alternates, recorded as rejected), Dismiss.
*
* Suggestions are NEVER auto-sent: the explicit click here is the security
* boundary for observed/injectable predictor inputs, so suggestion text is
@@ -20,6 +23,7 @@
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.sessions, this.activeSessionId, showToast)
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice, focus policy)
* @dependency settings-ui.js (loadAppSettingsFromStorage)
* @dependency api-client.js at runtime (this._apiJson; loads later but is only called after init)
* @loadorder 11.3, after panels-ui.js, before ultracode-panel.js
@@ -44,7 +48,7 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Rethink memory resets on each open (a fresh open is a fresh question).
this._rmm = { sessionId, shown: null, rejected: [], busy: false };
this._rmm = { sessionId, suggestions: [], selected: 0, rejected: [], busy: false };
document.getElementById('readMyMindModal')?.classList.add('active');
this._readMyMindPredict();
},
@@ -54,7 +58,7 @@ Object.assign(CodemanApp.prototype, {
this._rmm = null;
},
/** Run (or re-run) the prediction and render the top suggestion. */
/** Run (or re-run) the prediction and render the suggestion set. */
async _readMyMindPredict() {
const state = this._rmm;
if (!state || state.busy) return;
@@ -69,26 +73,83 @@ Object.assign(CodemanApp.prototype, {
if (this._rmm !== state) return;
state.busy = false;
const suggestion = data && data.suggestions && data.suggestions[0];
if (!suggestion) {
const suggestions = (data && Array.isArray(data.suggestions) ? data.suggestions : []).filter(
(s) => s && typeof s.prompt === 'string' && s.prompt.trim()
);
if (suggestions.length === 0) {
this._rmmSetPhase('error');
return;
}
state.shown = suggestion;
state.suggestions = suggestions.slice(0, 3);
state.selected = 0;
this._rmmSetPhase('ready');
this._rmmRender();
this._rmmFocusPrompt();
},
/** Paint the selected suggestion into the editable field, the rest as alternates. */
_rmmRender() {
const state = this._rmm;
const current = state && state.suggestions[state.selected];
if (!current) return;
const input = document.getElementById('readMyMindPrompt');
const why = document.getElementById('readMyMindWhy');
const kind = document.getElementById('readMyMindKind');
// Predictor output is derived from observable (injectable) content:
// value/textContent only, never innerHTML.
if (input) input.value = suggestion.prompt;
if (why) why.textContent = suggestion.why || '';
if (input) input.value = current.prompt;
if (why) why.textContent = current.why || '';
if (kind) {
kind.textContent = suggestion.kind || 'continue';
kind.className = `readmymind-kind readmymind-kind-${suggestion.kind || 'continue'}`;
kind.textContent = current.kind || 'continue';
kind.className = `readmymind-kind readmymind-kind-${current.kind || 'continue'}`;
}
input?.focus();
const alternates = document.getElementById('readMyMindAlternates');
if (!alternates) return;
alternates.replaceChildren();
// The container is data-i18n-skip (suggestion text must never be mistaken
// for app copy), so the one piece of app copy inside it is pre-translated.
const translate = window.codemanT || ((s) => s);
state.suggestions.forEach((suggestion, index) => {
if (index === state.selected) return;
const row = document.createElement('button');
row.type = 'button';
row.className = 'readmymind-alt';
row.title = suggestion.why || '';
row.setAttribute('aria-label', translate('Use this suggestion instead'));
const badge = document.createElement('span');
badge.className = `readmymind-kind readmymind-kind-${suggestion.kind || 'continue'}`;
badge.textContent = suggestion.kind || 'continue';
const text = document.createElement('span');
text.className = 'readmymind-alt-text';
text.textContent = suggestion.prompt;
row.append(badge, text);
row.addEventListener('click', () => this._rmmSelect(index));
alternates.appendChild(row);
});
alternates.style.display = alternates.childElementCount > 0 ? '' : 'none';
},
/** Swap an alternate into the field, folding the current edit back first. */
_rmmSelect(index) {
const state = this._rmm;
if (!state || state.busy || !state.suggestions[index]) return;
const input = document.getElementById('readMyMindPrompt');
const current = state.suggestions[state.selected];
// Keep edits: fold the field text back into the suggestion it belongs to,
// so toggling between alternates never loses typing.
if (input && current) current.prompt = input.value;
state.selected = index;
this._rmmRender();
this._rmmFocusPrompt();
},
/** Focus the editable field on desktop. On touch devices leave it blurred so
* the OS keyboard doesn't pop over the alternates that just rendered. */
_rmmFocusPrompt() {
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice()) return;
document.getElementById('readMyMindPrompt')?.focus();
},
/**
@@ -114,11 +175,14 @@ Object.assign(CodemanApp.prototype, {
this.showToast(withEnter ? 'Prompt sent' : 'Inserted, press Enter in the terminal to send', 'success');
},
/** Re-run with the shown suggestion recorded as a rejection. */
/** Re-run with the whole shown set (main + alternates) recorded as rejected:
* the user saw every row and asked for something else. */
rethinkReadMyMind() {
const state = this._rmm;
if (!state || state.busy) return;
if (state.shown && state.shown.prompt) state.rejected.push(state.shown.prompt);
for (const suggestion of state.suggestions) {
if (suggestion.prompt && suggestion.prompt.trim()) state.rejected.push(suggestion.prompt);
}
this._readMyMindPredict();
},
+6 -1
View File
@@ -2427,11 +2427,16 @@ Object.assign(CodemanApp.prototype, {
// Read My Mind 🧠 — hidden unless the synced opt-in `readMyMindEnabled` is
// ON (only an explicit true enables, mirroring the Approvals bell). Marker
// class (base is display:inline-flex !important); phones hide it in
// mobile.css regardless (the phase-3 surface there is an accessory key).
// mobile.css regardless (their surface is the keyboard-accessory 🧠 key,
// re-synced right below).
const readMyMindBtn = document.querySelector('.btn-readmymind');
if (readMyMindBtn) {
readMyMindBtn.classList.toggle('btn-readmymind--hidden', settings.readMyMindEnabled !== true);
}
// The accessory-bar 🧠 key shares the setting; its marker class lives on
// the bar element (keyboard-accessory.js), so a live toggle from a
// settings save reveals/hides it without a reload.
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.syncReadMyMind?.();
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
// Settings → Display → "Plan Usage Limits"). The template always ships it
+168 -40
View File
@@ -10865,6 +10865,56 @@ kbd {
padding: 12px 4px;
color: var(--text-dim);
}
/* Alternate suggestions (the predictor's verify/redirect kinds): tappable rows
that swap into the editable field (readmymind-ui.js _rmmSelect). */
.readmymind-alternates {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.readmymind-alt {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
text-align: left;
padding: 7px 10px;
background: transparent;
border: 1px solid var(--control-border);
border-radius: 8px;
color: var(--text-dim);
cursor: pointer;
font-size: 12px;
}
.readmymind-alt:hover,
.readmymind-alt:focus-visible {
border-color: var(--accent);
color: var(--text);
}
.readmymind-alt-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono-font, monospace);
}
/* Keyboard-accessory 🧠 key: the phone surface for the same opt-in setting
(the header 🧠 button stays phone-hidden in mobile.css). The key ships in
BOTH bar templates (keyboard-accessory.js) but stays hidden until the bar
carries the marker class: setMode() rebuilds the bar's innerHTML on every
layout switch, so the gate must live on the bar element, not on the key.
Lives here rather than mobile.css because large touch tablets (>1023px)
never load mobile.css but do render the accessory bar. */
.keyboard-accessory-bar .accessory-btn-rmm {
display: none;
}
.keyboard-accessory-bar.rmm-enabled .accessory-btn-rmm {
display: inline-flex;
}
.approvals-badge {
position: absolute;
@@ -14136,15 +14186,24 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
}
/* ══════════════════════════════════════════════════════════════════════════
Home screen: open tabs in the left gutter (home-sessions.js)
Home screen: open tabs docked down the left edge (home-sessions.js)
The welcome content is 560px wide and centered, so this column lives in dead
The welcome content is 560px wide and centered, so this rail lives in dead
space. It is `position: absolute` precisely so that stays true: the centered
content does not move by a pixel whether the column renders or not. That in
content does not move by a pixel whether the rail renders or not. That in
turn is why the width gate below has to exist — in a narrow window there is
no gutter to sit in, and an absolute box would simply overlap the search
panel. JS gates on the same 1180px so the two can never disagree.
It is FLUSH to the left edge and FULL HEIGHT on purpose: a floating, vertically
centered card in the middle of the gutter reads as debris, a docked rail with
an edge to hang off reads as part of the chrome.
Everything inside scales off ONE knob — the `font-size` below — because every
child sizes in `em`. Widen the window and the whole rail grows with it instead
of leaving 12px type stranded on a 2560px display. Do not reintroduce `rem`
or fixed px sizes for type/spacing here; that is what breaks the scaling.
The working dot is the phone's, exactly: pulsing green ringed by the very
same `tab-load-spin` a tab shows while it loads (reused from above, never
re-declared), plus a green halo. One signal, one motion, both home screens.
@@ -14152,14 +14211,20 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.home-sessions {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
left: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
gap: 8px;
width: 256px;
max-height: calc(100% - 3rem);
gap: 0.9em;
/* Ramps with the viewport, floored so it still clears the centered content at
the 1180px gate and capped so it never becomes the main event. */
width: clamp(250px, 19vw, 430px);
padding: 1.5em 1.1em;
background: linear-gradient(90deg, color-mix(in srgb, var(--bg-card) 65%, transparent) 0%, transparent 100%);
border-right: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
/* The single scale knob: ~12px at the gate, ~17px on a 2560px display. */
font-size: clamp(12.2px, calc(8px + 0.35vw), 17px);
text-align: left;
z-index: 1;
}
@@ -14181,12 +14246,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.home-sessions-header {
display: flex;
align-items: center;
gap: 8px;
padding: 0 6px;
gap: 0.66em;
flex-shrink: 0;
padding: 0 0.5em;
}
.home-sessions-title {
font-size: 0.66rem;
font-size: 0.87em;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
@@ -14197,25 +14263,29 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 16px;
padding: 0 5px;
min-width: 1.5em;
height: 1.35em;
padding: 0 0.42em;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.6rem;
font-size: 0.79em;
font-weight: 700;
font-family: monospace;
}
/* `min-height: 0` is what lets this scroll inside the full-height rail: without
it the flex item's auto minimum keeps growing and the overflow never engages. */
.home-sessions-list {
display: flex;
flex-direction: column;
gap: 4px;
gap: 0.35em;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 2px 2px 6px;
padding: 0.16em 0.16em 0.5em;
}
.home-sessions-list::-webkit-scrollbar {
@@ -14230,15 +14300,16 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.home-sessions-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
gap: 0.66em;
width: 100%;
padding: 7px 9px;
border-radius: 9px;
padding: 0.6em 0.75em;
border-radius: 0.75em;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-dim);
font-family: inherit;
font-size: 0.76rem;
font-size: 1em;
text-align: left;
cursor: pointer;
transition: background var(--transition-smooth), border-color var(--transition-smooth), color var(--transition-smooth);
@@ -14258,14 +14329,14 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
width: 1.3em;
height: 1.3em;
flex-shrink: 0;
border-radius: 3px;
border-radius: 0.25em;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.58rem;
font-size: 0.76em;
font-weight: 700;
font-family: monospace;
}
@@ -14273,8 +14344,8 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.home-sessions-dot {
position: relative;
flex-shrink: 0;
width: 9px;
height: 9px;
width: 0.75em;
height: 0.75em;
border-radius: 50%;
background: var(--text-muted);
}
@@ -14288,8 +14359,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
background: var(--yellow);
}
/* Idle is green, but a MUTED green: still unmistakably "fine", visibly greyer
and darker than the vivid green a working session gets, so a glance down the
rail separates "running right now" from "sitting there" without reading a
word. Mixed toward --text-muted rather than hardcoded, so every skin keeps
its own green. */
.home-sessions-dot--idle {
background: var(--green);
background: color-mix(in srgb, var(--green) 42%, var(--text-muted));
}
.home-sessions-dot--done {
@@ -14311,7 +14387,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.home-sessions-dot--working::after {
content: '';
position: absolute;
inset: -4px;
inset: -0.34em;
border: 2px solid color-mix(in srgb, var(--green) 25%, transparent);
border-top-color: var(--green);
border-radius: 50%;
@@ -14321,7 +14397,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.home-sessions-row-body {
display: flex;
flex-direction: column;
gap: 1px;
gap: 0.12em;
min-width: 0;
flex: 1;
}
@@ -14329,7 +14405,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.home-sessions-row-title {
display: flex;
align-items: center;
gap: 5px;
gap: 0.42em;
min-width: 0;
color: var(--text);
font-weight: 600;
@@ -14342,21 +14418,66 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
}
.home-sessions-row-sub {
font-size: 0.66rem;
font-size: 0.87em;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Age line: when the session was first created and when it last did anything.
Both are relative and refreshed in place on a timer (home-sessions.js), never
by a re-render — a re-render would restart the row's blink animation. */
.home-sessions-row-meta {
display: flex;
align-items: center;
gap: 0.4em;
/* Wraps to its own full-width line under the row (the row is `flex-wrap`),
so the stamps get the whole width instead of the sliver left beside the
status pill and ellipsize away on the narrow end of the rail. */
flex: 0 0 100%;
min-width: 0;
margin-top: -0.2em;
font-size: 0.78em;
font-family: monospace;
color: var(--text-muted);
opacity: 0.75;
white-space: nowrap;
overflow: hidden;
}
.home-sessions-meta-item {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.home-sessions-meta-key {
margin-right: 0.35em;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.home-sessions-meta-sep {
opacity: 0.45;
}
/* The freshest signal on the row: while a session is actually doing something,
its "active" stamp is the one the eye should land on. */
.home-sessions-row--working .home-sessions-meta-active {
color: var(--green);
opacity: 0.95;
}
.home-sessions-mode {
flex-shrink: 0;
padding: 0 4px;
border-radius: 3px;
padding: 0 0.35em;
border-radius: 0.25em;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.55rem;
font-size: 0.72em;
font-weight: 700;
font-family: monospace;
text-transform: uppercase;
@@ -14364,12 +14485,12 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.home-sessions-pill {
flex-shrink: 0;
padding: 2px 6px;
padding: 0.2em 0.55em;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.58rem;
font-size: 0.76em;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
@@ -14388,13 +14509,20 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
color: var(--yellow);
}
.home-sessions-pill--working,
.home-sessions-pill--idle {
.home-sessions-pill--working {
background: color-mix(in srgb, var(--green) 15%, transparent);
border-color: color-mix(in srgb, var(--green) 40%, transparent);
color: var(--green);
}
/* Same muting as the idle dot, one step further: the pill is a block of color,
so it reads louder than a 9px dot at the same mix. */
.home-sessions-pill--idle {
background: color-mix(in srgb, var(--green) 7%, transparent);
border-color: color-mix(in srgb, var(--green) 18%, var(--border));
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
}
/* Row accents: same language as the session tabs and the phone overview — red
means a question is pending, yellow means it wants input, green means work is
happening. Nothing else on this screen may reuse these colors. */